From 3d20feebbd675de1354a6358edfda1754fb6875e Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sat, 22 Aug 2026 01:00:46 +0800 Subject: [PATCH 01/48] LibRed: continue the AutoNumber counter past the int32 wrap Probed ACE (OLE DB 16.0/12.0) at the counter boundary: there is no overflow error. The TDEF high-water (0x14) is a plain signed int32 and the next id is 0x14 + increment computed unchecked, so an ascending counter runs ... 2147483647, -2147483648, -2147483647 ... and a descending one mirrors it. ACE writes the wrapped id to 0x14 and carries on. A wrapped id that is already occupied is an ordinary duplicate-key rejection, and ACE burns the id anyway (0x14 advances despite the failed insert) so the next insert steps over it. LibRed generated the same wrapped id but then wedged: UpdateTdefCounters' monotone guard - the deliberate KB 884185 immunity - read the wrapped value as going backwards and left 0x14 pinned at int.MaxValue, so every later auto insert reissued int.MinValue. The damage was the on-disk 0x14, so ACE opening the file failed the same way. AssignAutoNumbers now returns a per-value flag array of the ids it generated, and UpdateTdefCounters takes a generated id as the new high-water unconditionally: it came from 0x14 + increment, so it is the next value in the sequence by construction, wrap included. The monotone guard now applies only to caller-supplied explicit ids, which is the only case KB 884185 was ever about. Confirmed that guard is still needed: making explicit ids leave 0x14 untouched instead fails 5 tests, including ACE reusing id 1 after LibRed bulk-writes rows 1-3 - explicit ids are how data gets into a counter column, and 0x14 is the only record of where the counter is. Also moves the AssignAutoNumbers doc comment onto the method (it had been orphaned above MaterializeLongValues). Spec: page-02a-tdef.md gains a verified wrap note under 3.1, and the 0x14/0x18 rows plus the appendix entry record that the value wraps. Tests: AceAutoNumberOverflowProbeTest covers both engines at both boundaries, the explicit-int.MaxValue route to the wrap, and the occupied-wrapped-id case. LibRed.Core 481/481, LibRed.Engine 898/898, LibRed.Ado 47/47. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Core/Storage/RowInserter.cs | 72 +++-- src/LibRed/docs/format/appendix-structures.md | 2 +- src/LibRed/docs/format/page-02a-tdef.md | 23 +- .../AceAutoNumberOverflowProbeTest.cs | 294 ++++++++++++++++++ 4 files changed, 362 insertions(+), 29 deletions(-) create mode 100644 test/LibRed.Core.Tests/AceAutoNumberOverflowProbeTest.cs diff --git a/src/LibRed/LibRed.Core/Storage/RowInserter.cs b/src/LibRed/LibRed.Core/Storage/RowInserter.cs index 9bd956d0..714833b2 100644 --- a/src/LibRed/LibRed.Core/Storage/RowInserter.cs +++ b/src/LibRed/LibRed.Core/Storage/RowInserter.cs @@ -36,7 +36,7 @@ public void Insert(object?[] values, bool updateIndexes) // Jet SQL omits the AutoNumber column from the insert). An explicitly supplied value is kept // as-is (Jet, unlike SQL Server, permits explicit AutoNumber values); either way the row's // final id drives both the row encoding and the high-water update below. - AssignAutoNumbers(format, values); + bool[]? generatedAutoNumbers = AssignAutoNumbers(format, values); if (updateIndexes) EnforceUniqueIndexes(values); // reject a duplicate before writing anything // Index keys are encoded from the *logical* values. MaterializeLongValues replaces a memo/OLE value @@ -71,7 +71,7 @@ public void Insert(object?[] values, bool updateIndexes) _channel.WritePage(pageNumber, page); - UpdateTdefCounters(format, values); + UpdateTdefCounters(format, values, generatedAutoNumbers); if (updateIndexes) UpdateIndexes(keyValues, new RowId(pageNumber, rowCount)); } @@ -505,13 +505,6 @@ private static int LowestRowOffset(byte[] page, JetFormatBase format, int rowCou return best; } - /// - /// Fills each AutoNumber column the caller left null with the next id — the TDEF high-water value - /// (`0x14`) plus one — matching how Jet assigns AutoNumbers. A value the caller supplied - /// explicitly is left untouched (Jet allows it, and then bumps - /// the high-water to it). Access permits only one AutoNumber column per table, but any number are - /// handled here for safety. - /// /// /// Access stores a memo/OLE value inline only up to 64 bytes (Jackcess /// MAX_INLINE_LONG_VALUE_SIZE, the same for Jet3/Jet4); a larger value goes on its own LVAL @@ -668,16 +661,26 @@ private TableDefinitionPage ReadDefinition() return definition; } - private void AssignAutoNumbers(JetFormatBase format, object?[] values) + /// + /// Fills each AutoNumber column the caller left null with the next id — the TDEF high-water value + /// (`0x14`) plus the increment — matching how Jet assigns AutoNumbers. A value the caller supplied + /// explicitly is left untouched (Jet allows it, and then bumps + /// the high-water to it). Access permits only one AutoNumber column per table, but any number are + /// handled here for safety. Returns a per-value flag array marking the ids this call generated + /// (null when it generated none), which uses to tell a generated + /// id from a caller-supplied one. + /// + private bool[]? AssignAutoNumbers(JetFormatBase format, object?[] values) { bool needed = false; foreach (ColumnDef column in _table.Columns) if (column.IsAutoNumber && values[column.Index] is null or DBNull) { needed = true; break; } - if (!needed) return; + if (!needed) return null; ReadOnlySpan tdef = _channel.ReadPageShared(_table.DefinitionPage).Span; int highWater = BinaryPrimitives.ReadInt32LittleEndian(tdef.Slice(format.TdefLastAutoNumberOffset, 4)); + bool[] generated = new bool[values.Length]; foreach (ColumnDef column in _table.Columns) if (column.IsAutoNumber && values[column.Index] is null or DBNull) { @@ -686,10 +689,16 @@ private void AssignAutoNumbers(JetFormatBase format, object?[] values) // sequential counter. Access relies on the PK's uniqueness to reject the rare collision. values[column.Index] = RandomAutoNumber(); else + { // Next id = last-assigned + increment. On a fresh table the last value (0x14) is - // Seed-Increment, so the first assigned id is the Seed. - values[column.Index] = highWater += column.Increment; + // Seed-Increment, so the first assigned id is the Seed. The addition is deliberately + // unchecked: past int.MaxValue the counter wraps to int.MinValue and keeps going, which + // is exactly what ACE does (no overflow error exists — see AceAutoNumberOverflowProbeTest). + values[column.Index] = highWater = unchecked(highWater + column.Increment); + generated[column.Index] = true; + } } + return generated; } /// A random non-zero signed Int32 for a "Random" AutoNumber, mirroring Access's GenUniqueID(). @@ -705,9 +714,11 @@ private static int RandomAutoNumber() /// page: /// /// Row count (`0x10`) — incremented. - /// AutoNumber high-water (`0x14`) — set to the max of its current value and the id just - /// written (Access reads it to pick the *next* id = this + 1; leaving it stale makes Access - /// reissue an existing id and reject the insert as a duplicate primary key). + /// AutoNumber high-water (`0x14`) — set to the id just written whenever that id advances the + /// counter: always for an id this insert generated (including the wrap past int.MaxValue), and for a + /// caller-supplied id only when it moves further in the increment's direction. (Access reads `0x14` + /// to pick the *next* id = this + increment; leaving it stale makes Access reissue an existing id and + /// reject the insert as a duplicate primary key.) /// Per-index **unique-entry count** (`0x3F + ordinal×12`, `+4`) — incremented by one for /// each **unique** index (a unique index gets a distinct key per row). This is the cumulative /// count Access advances on every insert and never decrements. The sibling **total-entry count** @@ -716,7 +727,7 @@ private static int RandomAutoNumber() /// ACE-inserted table reads total `0` while saved Northwind tables read total = row count). /// /// - private void UpdateTdefCounters(JetFormatBase format, object?[] values) + private void UpdateTdefCounters(JetFormatBase format, object?[] values, bool[]? generatedAutoNumbers) { byte[] tdef = _channel.ReadPageShared(_table.DefinitionPage).Span.ToArray(); @@ -732,14 +743,23 @@ private void UpdateTdefCounters(JetFormatBase format, object?[] values) if (column.IsRandomAutoNumber) continue; int assigned = Convert.ToInt32(value); int highWater = BinaryPrimitives.ReadInt32LittleEndian(tdef.AsSpan(format.TdefLastAutoNumberOffset, 4)); - // Advance 0x14 to the id just written when it moves further in the counter's direction — for a - // positive increment that's the max seen, for a negative (descending) counter the min. Using max - // unconditionally would let a descending counter reissue the previous id (duplicate key). - // NB deliberate divergence from Access: ACE seeds 0x14 from the *last inserted* value regardless - // of direction (KB 884185), so an explicit lower INSERT drops the counter and the next auto id - // collides with an existing row ("duplicate values in the index/primary key"). LibRed's monotone - // rule is immune — verified both sides in AutoNumberSeed[Immunity]Tests. - bool advances = column.Increment >= 0 ? assigned > highWater : assigned < highWater; + // 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 + // int.MaxValue (or int.MinValue for a descending counter), where the new id compares as going + // backwards yet is the correct continuation — ACE wraps and carries on, and the monotone rule + // below would instead pin 0x14 forever and reissue the same wrapped id on every later insert + // (a wedged table). Verified both sides in AceAutoNumberOverflowProbeTest. + // + // For a value the *caller* supplied explicitly, advance 0x14 only when it moves further in the + // counter's direction — for a positive increment that's the max seen, for a negative (descending) + // counter the min. Using max unconditionally would let a descending counter reissue the previous + // id (duplicate key). NB deliberate divergence from Access: ACE seeds 0x14 from the *last + // inserted* value regardless of direction (KB 884185), so an explicit lower INSERT drops the + // counter and the next auto id collides with an existing row ("duplicate values in the + // index/primary key"). LibRed's monotone rule is immune — verified both sides in + // AutoNumberSeed[Immunity]Tests. + bool advances = generatedAutoNumbers?[column.Index] == true + || (column.Increment >= 0 ? assigned > highWater : assigned < highWater); int newHighWater = advances ? assigned : highWater; if (advances) BinaryPrimitives.WriteInt32LittleEndian(tdef.AsSpan(format.TdefLastAutoNumberOffset, 4), assigned); @@ -748,7 +768,7 @@ private void UpdateTdefCounters(JetFormatBase format, object?[] values) // (an ALTER on a table that has an AutoNumber) reconstructs the counter from the cached // ColumnDef.Seed; if left stale, a rebuild after the high rows were deleted resets the counter to its // create-time value (verified: next id dropped to 1 instead of continuing past 6). Seed = next id. - column.Seed = newHighWater + column.Increment; + column.Seed = unchecked(newHighWater + column.Increment); } // TODO(non-unique-index-stats): a non-unique index's unique-entry count must advance only diff --git a/src/LibRed/docs/format/appendix-structures.md b/src/LibRed/docs/format/appendix-structures.md index c8170980..c7bb6a79 100644 --- a/src/LibRed/docs/format/appendix-structures.md +++ b/src/LibRed/docs/format/appendix-structures.md @@ -83,7 +83,7 @@ Variable section (`varOffsetTable`+`numVar`) omitted when the table has no varia | `0x08` | 4 | TDEF length (total logical bytes) | | `0x0C` | 4 | Constant marker `0x00000659` | | `0x10` | 4 | Row count | -| `0x14` | 4 | AutoNumber high-water = last assigned id (next = `+ 0x18`); seed `= 0x14 + increment` | +| `0x14` | 4 | AutoNumber high-water = last assigned id (next = `+ 0x18`, unchecked — **wraps** at the int32 boundary); seed `= 0x14 + increment` | | `0x18` | 4 | AutoNumber increment (signed int32; default 1) | | `0x1C` | 4 | Complex-type AutoNumber high-water | | `0x20` | 8 | Unknown / reserved (zero) | diff --git a/src/LibRed/docs/format/page-02a-tdef.md b/src/LibRed/docs/format/page-02a-tdef.md index b4672d3f..9a249b30 100644 --- a/src/LibRed/docs/format/page-02a-tdef.md +++ b/src/LibRed/docs/format/page-02a-tdef.md @@ -13,8 +13,8 @@ | `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`**, and disambiguated from row count with a delete-gap: after inserting 3 rows, deleting id `3`, and inserting again (which is assigned id `4`, *not* reused `3`), `0x14` = `4` = the last inserted id while the row **count** is `3`. (Also: Northwind Categories = `8`, non-autonumber/text-PK tables = `0`.) mdbtools labels this *"Next autonumber value"* — that's **off by one**; the stored value is the last assigned, and the next id is `+ increment`. **Write requirement:** a writer inserting into an AutoNumber table must advance this to the max 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. | -| `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/Jackcess mislabel it a 1-byte constant / "autonumber enable" flag, which only *looks* right because the default increment is 1 (LibRed's own finding). 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. | +| `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`**, and disambiguated from row count with a delete-gap: after inserting 3 rows, deleting id `3`, and inserting again (which is assigned id `4`, *not* reused `3`), `0x14` = `4` = the last inserted id while the row **count** is `3`. (Also: Northwind Categories = `8`, non-autonumber/text-PK tables = `0`.) 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. | +| `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/Jackcess mislabel it a 1-byte constant / "autonumber enable" flag, which only *looks* right because the default increment is 1 (LibRed's own finding). 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; LibRed has no complex-column fixture to confirm a non-zero value (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** (`ComplexAutoNumberRoundTripTests`) | | `0x20` | 8 | Unknown / reserved (zero observed) | | `0x28` | 1 | Table type: `0x4E` 'N' user, `0x53` 'S' system | @@ -52,6 +52,25 @@ > of logical-index info blocks and index names. A relationship adds a *logical* index that > shares a real index's data, so logical ≥ real. +> **The AutoNumber counter wraps at the int32 boundary — there is no overflow error** (verified vs ACE +> OLE DB 16.0/12.0, `AceAutoNumberOverflowProbeTest`). `0x14` is an ordinary signed int32 and the next id is +> `0x14 + 0x18` computed **unchecked**, so an ascending counter runs +> `… 2147483646, 2147483647, -2147483648, -2147483647 …` and a descending one mirrors it +> (`-2147483648 → 2147483647`). ACE issues the wrapped id, writes it to `0x14`, and carries on — nothing in +> the header records that the counter has been round the ring. This also happens without ever reaching the +> boundary by counting: an explicit `INSERT` of `2147483647` into a plain `COUNTER` sets `0x14` to it (the +> KB 884185 last-inserted rule), and the very next auto id is `-2147483648`. +> +> The only failure is a wrapped id that is **already occupied** — an ordinary duplicate-key rejection. ACE +> still advances `0x14` past it (the id is burned even though the insert failed), so the following insert +> succeeds and the counter steps over the squatter. +> +> **Write requirement:** a writer must treat an id it *generated* as advancing the counter unconditionally, +> because the wrapped value compares as going backwards. Applying a monotone "only if greater/lesser" rule to +> it pins `0x14` at `int.MaxValue` forever, so every later insert reissues `int.MinValue` and the table is +> wedged — for ACE too, since the damage is the on-disk `0x14`. LibRed therefore applies its monotone guard +> (the deliberate KB 884185 immunity) **only to caller-supplied explicit ids**; see `RowInserter`. + > **Reader/writer count guardrails.** LibRed validates the documented 255-column and 32-real-index > limits before allocating count-sized structures, rejects negative logical/real index counts, and checks > each fixed-size count-derived region against the fully assembled definition before parsing it. The writer diff --git a/test/LibRed.Core.Tests/AceAutoNumberOverflowProbeTest.cs b/test/LibRed.Core.Tests/AceAutoNumberOverflowProbeTest.cs new file mode 100644 index 00000000..31323b0f --- /dev/null +++ b/test/LibRed.Core.Tests/AceAutoNumberOverflowProbeTest.cs @@ -0,0 +1,294 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Catalog; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE (not an assertion of desired LibRed behaviour): what do ACE and LibRed do when a sequential +// AutoNumber (COUNTER) runs off the end of the signed Int32 range? +// +// The counter's on-disk state is a single Int32 — the TDEF high-water at 0x14 — and the next id is +// high-water + increment (0x18). Nothing in the format reserves a "counter exhausted" state, so the +// interesting questions are: +// 1. Does the engine refuse the insert, or does it wrap to the other end of Int32 and carry on? +// 2. If it wraps, what does the high-water become — and is the table then permanently wedged +// (every subsequent auto id identical, so a duplicate primary key)? +// 3. Is a descending counter (negative increment) symmetric at int.MinValue? +// 4. Does an *explicit* insert of int.MaxValue poison a plain COUNTER the same way? +// +// Every probe logs what actually happened (the id assigned, or the engine's own error text) plus the +// resulting 0x14 high-water read back through LibRed's catalog, so ACE's and LibRed's behaviour sit +// side by side. Assertions are deliberately minimal — they pin only what has been observed. +public class AceAutoNumberOverflowProbeTest(ITestOutputHelper output) +{ + private static OleDbConnection OpenOleDb(string path) + { + Exception? last = null; + for (int attempt = 0; attempt < 12; attempt++) + foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) + { + try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } + catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } + } + throw new InvalidOperationException("no provider", last); + } + + /// The TDEF AutoNumber high-water (0x14) — the last id handed out. ColumnDef.Seed is the *next* id. + private static string HighWater(string path) + { + try + { + using var db = JetDatabase.Open(path, readOnly: true); + var col = db.Catalog.FindTable("T")!.Columns.First(c => c.IsAutoNumber); + return (col.Seed - col.Increment).ToString(); + } + catch (Exception ex) { return $""; } + } + + private static string NewDb(string tag) + { + string path = Path.Combine(Path.GetTempPath(), $"{tag}-{Guid.NewGuid():N}.accdb"); + File.Copy(TestDatabases.NorthwindAccdb, path); + return path; + } + + // ---------------------------------------------------------------- ACE ----------------------------------------- + + /// Runs one ACE auto insert and logs the id it produced, or the engine's refusal; returns the id, or null if refused. + private int? AceInsert(OleDbConnection conn, string label) + { + try + { + using (var c = conn.CreateCommand()) { c.CommandText = $"INSERT INTO T (V) VALUES ('{label}')"; c.ExecuteNonQuery(); } + using var q = conn.CreateCommand(); + q.CommandText = $"SELECT Id FROM T WHERE V = '{label}'"; + int id = Convert.ToInt32(q.ExecuteScalar()); + output.WriteLine($" ACE insert '{label}' -> Id = {id}"); + return id; + } + catch (OleDbException ex) { output.WriteLine($" ACE insert '{label}' -> "); return null; } + } + + [Theory] + // Ascending counter parked two below int.MaxValue: 2147483646, 2147483647, then wraps to int.MinValue. + [InlineData("ace-max", 2147483646, 1, new[] { 2147483646, 2147483647, -2147483648, -2147483647 })] + // Descending counter parked two above int.MinValue: mirror image — wraps to int.MaxValue. + [InlineData("ace-min", -2147483647, -1, new[] { -2147483647, -2147483648, 2147483647, 2147483646 })] + public void Probe_ace_counter_at_the_int32_boundary(string tag, int seed, int increment, int[] expectedIds) + { + string path = NewDb(tag); + try + { + output.WriteLine($"ACE COUNTER({seed}, {increment})"); + using var conn = OpenOleDb(path); + try + { + using var ddl = conn.CreateCommand(); + ddl.CommandText = $"CREATE TABLE T (Id COUNTER({seed}, {increment}) CONSTRAINT PK PRIMARY KEY, V TEXT(5))"; + ddl.ExecuteNonQuery(); + } + catch (OleDbException ex) + { + output.WriteLine($" ACE rejected the DDL: {ex.Message.Trim()}"); + return; + } + + var ids = new List(); + foreach (string label in new[] { "a", "b", "c", "d" }) + { + ids.Add(AceInsert(conn, label)); + output.WriteLine($" high-water (0x14) now {HighWater(path)}"); + } + + using (var dump = conn.CreateCommand()) + { + dump.CommandText = "SELECT Id, V FROM T ORDER BY V"; + using var r = dump.ExecuteReader(); + while (r.Read()) output.WriteLine($" row {r[1]} = {r[0]}"); + } + + // Observed: ACE never refuses. It wraps the counter two's-complement style and keeps issuing ids, + // with the on-disk high-water following the wrapped value. + Assert.Equal(expectedIds.Cast(), ids); + Assert.Equal(expectedIds[^1].ToString(), HighWater(path)); + } + finally { try { File.Delete(path); } catch (IOException) { } } + } + + [Fact] + public void Probe_ace_explicit_max_value_into_a_plain_counter() + { + string path = NewDb("ace-explicit"); + try + { + output.WriteLine("ACE plain COUNTER, explicit INSERT of int.MaxValue, then auto inserts"); + // Each step gets its own connection: ACE writes the TDEF page lazily, so 0x14 read from a + // *second* handle while ACE still holds the file can lag the counter it will actually use. + void Step(Action act, string label) + { + using (var conn = OpenOleDb(path)) + try { act(conn); } + catch (OleDbException ex) { output.WriteLine($" {label} -> "); } + output.WriteLine($" high-water (0x14) on disk now {HighWater(path)}"); + } + + Step(c => { using var x = c.CreateCommand(); x.CommandText = "CREATE TABLE T (Id COUNTER CONSTRAINT PK PRIMARY KEY, V TEXT(5))"; x.ExecuteNonQuery(); }, "create"); + Step(c => AceInsert(c, "a"), "auto insert 'a'"); // auto, so 1 + Step(c => { using var x = c.CreateCommand(); x.CommandText = "INSERT INTO T (Id, V) VALUES (2147483647, 'max')"; x.ExecuteNonQuery(); output.WriteLine(" explicit 2147483647 accepted"); }, "explicit 2147483647"); + Step(c => AceInsert(c, "next"), "auto insert 'next'"); + Step(c => AceInsert(c, "next2"), "auto insert 'next2'"); + } + finally { try { File.Delete(path); } catch (IOException) { } } + } + + [Fact] + public void Probe_ace_seed_at_int32_max_and_a_wrapped_id_that_collides() + { + string path = NewDb("ace-collide"); + try + { + output.WriteLine("ACE COUNTER(2147483647, 1) with an existing row already parked on int.MinValue"); + using var conn = OpenOleDb(path); + void Exec(string sql) { using var c = conn.CreateCommand(); c.CommandText = sql; c.ExecuteNonQuery(); } + try { Exec("CREATE TABLE T (Id COUNTER(2147483647, 1) CONSTRAINT PK PRIMARY KEY, V TEXT(5))"); } + catch (OleDbException ex) { output.WriteLine($" ACE rejected the DDL: {ex.Message.Trim()}"); return; } + + // Park a row on the id the counter will wrap onto, so the wrap lands on an occupied key. The + // explicit insert drops the counter onto that value (KB 884185 last-inserted rule), so reseed + // back to int.MaxValue afterwards — otherwise the wrap never happens. + try { Exec("INSERT INTO T (Id, V) VALUES (-2147483648, 'squat')"); output.WriteLine(" explicit -2147483648 accepted"); } + catch (OleDbException ex) { output.WriteLine($" explicit -2147483648 -> "); } + try { Exec("ALTER TABLE T ALTER COLUMN Id COUNTER(2147483647, 1)"); output.WriteLine(" reseeded to COUNTER(2147483647, 1)"); } + catch (OleDbException ex) { output.WriteLine($" reseed -> "); } + output.WriteLine($" high-water (0x14) now {HighWater(path)}"); + + int? a = AceInsert(conn, "a"); // the seed itself + output.WriteLine($" high-water (0x14) now {HighWater(path)}"); + int? b = AceInsert(conn, "b"); // the wrap — lands on 'squat' + output.WriteLine($" high-water (0x14) now {HighWater(path)}"); + int? c = AceInsert(conn, "c"); // does the table recover afterwards? + output.WriteLine($" high-water (0x14) now {HighWater(path)}"); + + using (var dump = conn.CreateCommand()) + { + dump.CommandText = "SELECT Id, V FROM T ORDER BY V"; + using var r = dump.ExecuteReader(); + while (r.Read()) output.WriteLine($" row {r[1]} = {r[0]}"); + } + + // Observed: the wrap itself is fine; only a wrapped id that is already taken fails, and it fails as + // an ordinary duplicate-key error. ACE still burns that id (0x14 advances even though the insert + // was rejected), so the very next insert succeeds — the counter walks past the occupied slot. + Assert.Equal(int.MaxValue, a); + Assert.Null(b); + Assert.Equal(int.MinValue + 1, c); + } + finally { try { File.Delete(path); } catch (IOException) { } } + } + + // -------------------------------------------------------------- LibRed --------------------------------------- + + [Theory] + [InlineData("lib-max", 2147483646, 1, new[] { 2147483646, 2147483647, -2147483648, -2147483647 })] + [InlineData("lib-min", -2147483647, -1, new[] { -2147483647, -2147483648, 2147483647, 2147483646 })] + public void Probe_libred_counter_at_the_int32_boundary(string tag, int seed, int increment, int[] expectedIds) + { + string path = NewDb(tag); + try + { + output.WriteLine($"LibRed COUNTER({seed}, {increment})"); + using (var db = JetDatabase.Open(path, readOnly: false)) + { + db.CreateTable("T", + [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true, IsAutoNumber: true, Seed: seed, Increment: increment), + new ColumnSpec("V", JetDataType.Text, 5, IsFixedLength: false)], + primaryKey: ["Id"]); + var table = db.OpenTable("T"); + int idIdx = table.Definition.FindColumn("Id")!.Index; + int vIdx = table.Definition.FindColumn("V")!.Index; + + foreach (string label in new[] { "a", "b", "c", "d" }) + { + try + { + table.Insert([null, label]); + var col = db.Catalog.FindTable("T")!.Columns.First(c => c.IsAutoNumber); + output.WriteLine($" LibRed insert '{label}' -> ok; high-water (0x14) now {col.Seed - col.Increment}"); + } + catch (Exception ex) { output.WriteLine($" LibRed insert '{label}' -> <{ex.GetType().Name}: {ex.Message.Trim()}>"); } + } + + var ids = new List(); + foreach (object?[] row in table.Rows()) + { + output.WriteLine($" row {row[vIdx]} = {row[idIdx]}"); + ids.Add(Convert.ToInt32(row[idIdx])); + } + + // LibRed wraps and carries on exactly as ACE does — the generated id always advances 0x14, + // including past the boundary, so the counter doesn't wedge on the row after the wrap. + Assert.Equal(expectedIds, ids); + } + + // And what does ACE make of the file LibRed left behind? It must continue the wrapped sequence. + using (var conn = OpenOleDb(path)) + { + using (var q = conn.CreateCommand()) + { + q.CommandText = "SELECT Id, V FROM T ORDER BY V"; + using var r = q.ExecuteReader(); + while (r.Read()) output.WriteLine($" ACE reads row {r[1]} = {r[0]}"); + } + Assert.Equal(unchecked(expectedIds[^1] + increment), AceInsert(conn, "e")); + } + } + finally { try { File.Delete(path); } catch (IOException) { } } + } + + [Fact] + public void Probe_libred_explicit_max_value_into_a_plain_counter() + { + string path = NewDb("lib-explicit"); + try + { + output.WriteLine("LibRed plain COUNTER, explicit insert of int.MaxValue, then auto inserts"); + using var db = JetDatabase.Open(path, readOnly: false); + db.CreateTable("T", + [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true, IsAutoNumber: true), + new ColumnSpec("V", JetDataType.Text, 5, IsFixedLength: false)], + primaryKey: ["Id"]); + var table = db.OpenTable("T"); + int idIdx = table.Definition.FindColumn("Id")!.Index; + int vIdx = table.Definition.FindColumn("V")!.Index; + + void Report(string what, Action act) + { + try + { + act(); + var col = db.Catalog.FindTable("T")!.Columns.First(c => c.IsAutoNumber); + output.WriteLine($" {what} -> ok; high-water (0x14) now {col.Seed - col.Increment}"); + } + catch (Exception ex) { output.WriteLine($" {what} -> <{ex.GetType().Name}: {ex.Message.Trim()}>"); } + } + + Report("auto insert 'a'", () => table.Insert([null, "a"])); + Report("explicit int.MaxValue", () => table.Insert([int.MaxValue, "max"])); + Report("auto insert 'next'", () => table.Insert([null, "next"])); + Report("auto insert 'next2'", () => table.Insert([null, "next2"])); + + var ids = new List(); + foreach (object?[] row in table.Rows()) + { + output.WriteLine($" row {row[vIdx]} = {row[idIdx]}"); + ids.Add(Convert.ToInt32(row[idIdx])); + } + + // Matches ACE: the explicit int.MaxValue takes the counter with it, and the two ids after it are + // the wrapped continuation rather than a repeated int.MinValue. + Assert.Equal([1, int.MaxValue, int.MinValue, int.MinValue + 1], ids); + } + finally { try { File.Delete(path); } catch (IOException) { } } + } +} From 2a14814aa4be78f41028e8b428120cdff0b2bb70 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sat, 22 Aug 2026 01:28:49 +0800 Subject: [PATCH 02/48] Add JetParseTranslator for .NET Parse method support Implemented JetParseTranslator to map .NET Parse methods (e.g., int.Parse, double.Parse) to Jet/Access SQL type conversion functions (CBYTE, CDBL, CINT, CLNG, CDEC). Registered the translator in JetMethodCallTranslatorProvider. Updated related tests to expect Jet/Access SQL syntax for Parse operations instead of SQL Server-style CAST/CONVERT. --- .../JetMethodCallTranslatorProvider.cs | 1 + .../Internal/JetParseTranslator.cs | 48 +++++++++++++++++++ .../MiscellaneousTranslationsJetTest.cs | 30 ++++++------ .../MiscellaneousTranslationsLibRedTest.cs | 36 +++++++------- 4 files changed, 82 insertions(+), 33 deletions(-) create mode 100644 src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetParseTranslator.cs diff --git a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetMethodCallTranslatorProvider.cs b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetMethodCallTranslatorProvider.cs index 689996a3..355601f2 100644 --- a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetMethodCallTranslatorProvider.cs +++ b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetMethodCallTranslatorProvider.cs @@ -30,6 +30,7 @@ public JetMethodCallTranslatorProvider( new JetMathTranslator(sqlExpressionFactory), new JetNewGuidTranslator(sqlExpressionFactory), new JetObjectToStringTranslator(sqlExpressionFactory), + new JetParseTranslator(sqlExpressionFactory), new JetStringMethodTranslator(sqlExpressionFactory), new JetRandomTranslator(sqlExpressionFactory), new JetTimeOnlyMethodTranslator(sqlExpressionFactory) diff --git a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetParseTranslator.cs b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetParseTranslator.cs new file mode 100644 index 00000000..41d0ae43 --- /dev/null +++ b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetParseTranslator.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace EntityFrameworkCore.Jet.Query.ExpressionTranslators.Internal; + +/// +/// 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 +/// 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. +/// +public class JetParseTranslator(ISqlExpressionFactory sqlExpressionFactory) : IMethodCallTranslator +{ + private static readonly Type[] SupportedClrTypes = + [ + typeof(bool), // bit + typeof(byte), // tinyint + typeof(decimal), // decimal + typeof(double), // float + typeof(float), // float + typeof(short), // smallint + typeof(int), // int + typeof(long) // bigint + ]; + + private static readonly MethodInfo[] SupportedMethods + = SupportedClrTypes + .SelectMany(t => t.GetTypeInfo().GetDeclaredMethods(nameof(int.Parse)) + .Where(m => m.GetParameters().Length == 1 + && m.GetParameters().First().ParameterType == typeof(string))) + .ToArray(); + + /// + /// 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 + /// 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. + /// + public virtual SqlExpression? Translate( + SqlExpression? instance, + MethodInfo method, + IReadOnlyList arguments, + IDiagnosticsLogger logger) + => SupportedMethods.Contains(method) + ? sqlExpressionFactory.Convert( + arguments[0], + method.ReturnType) + : null; +} diff --git a/test/EFCore.Jet.FunctionalTests/Query/Translations/MiscellaneousTranslationsJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/Translations/MiscellaneousTranslationsJetTest.cs index a7fa8274..38b6ce76 100644 --- a/test/EFCore.Jet.FunctionalTests/Query/Translations/MiscellaneousTranslationsJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Query/Translations/MiscellaneousTranslationsJetTest.cs @@ -615,9 +615,9 @@ public override async Task Byte_Parse() AssertSql( """ -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 [b].[Int] >= 0 AND [b].[Int] <= 255 AND CAST(CONVERT(nvarchar(max), [b].[Int]) AS tinyint) = CAST(12 AS tinyint) +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 `b`.`Int` >= 0 AND `b`.`Int` <= 255 AND CBYTE((`b`.`Int` & '')) = CBYTE(12) """); } @@ -639,9 +639,9 @@ public override async Task Double_Parse() AssertSql( """ -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 CAST(CONVERT(nvarchar(max), [b].[Int]) AS float) = 8.0E0 +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 CDBL((`b`.`Int` & '')) = 8.0 """); } @@ -651,9 +651,9 @@ public override async Task Short_Parse() AssertSql( """ -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 CAST(CONVERT(nvarchar(max), [b].[Int]) AS smallint) = CAST(12 AS smallint) +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 CINT((`b`.`Int` & '')) = CINT(12) """); } @@ -663,9 +663,9 @@ public override async Task Int_Parse() AssertSql( """ -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 CAST(CONVERT(nvarchar(max), [b].[Int]) AS int) = 12 +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 CLNG((`b`.`Int` & '')) = 12 """); } @@ -675,9 +675,9 @@ public override async Task Long_Parse() AssertSql( """ -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 CAST(CONVERT(nvarchar(max), [b].[Int]) AS bigint) = CAST(12 AS bigint) +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 CLNG((`b`.`Int` & '')) = 12 """); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/MiscellaneousTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/MiscellaneousTranslationsLibRedTest.cs index 0814dea0..3673530f 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/Translations/MiscellaneousTranslationsLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/Translations/MiscellaneousTranslationsLibRedTest.cs @@ -614,9 +614,9 @@ public override async Task Byte_Parse() AssertSql( """ -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 [b].[Int] >= 0 AND [b].[Int] <= 255 AND CAST(CONVERT(nvarchar(max), [b].[Int]) AS tinyint) = CAST(12 AS tinyint) +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 `b`.`Int` >= 0 AND `b`.`Int` <= 255 AND CBYTE((`b`.`Int` & '')) = CBYTE(12) """); } @@ -626,9 +626,9 @@ public override async Task Decimal_Parse() AssertSql( """ -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 CAST(CONVERT(nvarchar(max), [b].[Int]) AS decimal(18,2)) = 8.0 +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 CDEC((`b`.`Int` & '')) = 8.0 """); } @@ -638,9 +638,9 @@ public override async Task Double_Parse() AssertSql( """ -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 CAST(CONVERT(nvarchar(max), [b].[Int]) AS float) = 8.0E0 +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 CDBL((`b`.`Int` & '')) = 8.0 """); } @@ -650,9 +650,9 @@ public override async Task Short_Parse() AssertSql( """ -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 CAST(CONVERT(nvarchar(max), [b].[Int]) AS smallint) = CAST(12 AS smallint) +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 CINT((`b`.`Int` & '')) = CINT(12) """); } @@ -662,9 +662,9 @@ public override async Task Int_Parse() AssertSql( """ -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 CAST(CONVERT(nvarchar(max), [b].[Int]) AS int) = 12 +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 CLNG((`b`.`Int` & '')) = 12 """); } @@ -674,9 +674,9 @@ public override async Task Long_Parse() AssertSql( """ -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 CAST(CONVERT(nvarchar(max), [b].[Int]) AS bigint) = CAST(12 AS bigint) +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 CLNG((`b`.`Int` & '')) = 12 """); } From e3dacd835255f41065bbe91f27f3622036b8adc8 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sat, 22 Aug 2026 21:46:43 +0800 Subject: [PATCH 03/48] LibRed: test-suite sweep, reader/writer page scope, and per-test cleanup Squashes a batch of engine fixes and the test work that found them. Engine and format fixes (LibRed.Core / LibRed.Engine / LibRed.Ado): - Empty Binary index key is the start flag alone (7F asc / 80 desc), not a zero-padded chunk. ACE oracle: ACE writes the index, LibRed reads the stored entry back and re-encodes it. A LibRed-written empty key previously could not compare equal to an ACE-written one in the same index. - Office-Standard EncryptionInfo is parsed from the declared frame (len@0x299, blob at 0x29B) instead of scanning page 0 for a signature. The length is authoritative for ACE itself - a file with key + descriptor but len = 0 is read by Access as plaintext - so the old scan opened databases ACE cannot. Verified by falsification: with the scan restored, LibRed opens such a file successfully. - UPDATE (Flag 4) and DELETE (Flag 5) stored action queries are recognised and reported as not-yet-executable, rather than falling into the generic unsupported bucket. Flags verified against ACE-authored procedures. - Row pointers are bounds-checked before decode, so a corrupt index entry raises InvalidDataException instead of decoding arbitrary bytes. - Commit validates every overlay page against the committed image it was derived from, under a per-file publish gate, so overlapping writers conflict deterministically instead of losing an update. A failed publication restores the already-published prefix and keeps the transaction rollbackable, reporting both the publish failure and any restore failure. - Schema-changing commits advance a per-file catalog generation; other open connections reload their parsed catalog on next access, while plain DML does not force a reload. - Function argument arity is validated against ACE's JES, including Jet quirks (two-argument IIf yields Null). Aggregates go through the same contract. - SQL COMMIT/ROLLBACK reconciles the ADO transaction handle. Page scope is now reader/writer rather than a mutex. A statement that cannot write (SELECT, set operation, system-variable select) takes it shared, so concurrent readers on one file still run together; everything else takes it exclusive for the whole statement, which is what makes a multi-page write atomic to readers. Anything not provably read-only takes the exclusive scope - the shared scope cannot be upgraded and says so rather than deadlocking. Parsing happens before the scope is taken. Test suite: - Wall-clock guards replaced with structural assertions (the planner is asked directly whether the rewrite engaged), and ThrowsAny replaced with specific exception types plus message assertions. - Shared AceTestDatabase / TemporaryDatabase helpers, in test/LibRed.Shared so the EF functional projects (which glob test/Shared wholesale and do not all reference LibRed.Core) are unaffected - EFCore.Jet.FunctionalTests had stopped building. - Temp databases are released per test. A database opened and abandoned by a static Fresh() helper kept its file locked for the whole process, so the copy survived every cleanup path; ~22 GB had accumulated in %TEMP%. Handles are now owned by the helper, closed before deletion, and released when the test ends, with a process-exit backstop. Peak temp copies during an engine run: 609 -> 19. - The five ACE-driving classes share one xunit collection. Concurrent ACE use faults natively (SEHException, then 0xC0000005 kills the run): those classes alone crashed 3 of 3 back-to-back runs, the other ~950 tests were clean 3 of 3. Parallelism is not disabled - only those five are serialized, against each other. - New: SchemaVisibilityTests (cross-connection catalog freshness, previously untested), reader/writer scope tests, and a zero-length-descriptor guard. Each was verified to fail with its mechanism disabled. - The Access-output legacy password comparison is restored as a fixture-gated test that skips with a reason (LIBRED_ENCTEST_DIR) instead of silently passing. Docs updated: transactions.md (scope semantics), page-00-database.md (descriptor framing, password fixtures), page-03-04-index-btree.md (empty Binary key), system-catalog.md (action flags), functions.md (arity). LibRed.Core 599, LibRed.Engine 962, LibRed.Ado 47 - all passing, no crashes, zero temp files leaked. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Ado/LibRedCommand.cs | 1 + src/LibRed/LibRed.Ado/LibRedConnection.cs | 8 + src/LibRed/LibRed.Ado/LibRedTransaction.cs | 8 + src/LibRed/LibRed.Core/Catalog/JetCatalog.cs | 28 +- .../LibRed.Core/Crypto/DatabaseEncryption.cs | 24 +- .../Crypto/OfficeStandardEncryption.cs | 12 +- .../LibRed.Core/Formats/StoredQueryFormat.cs | 6 + src/LibRed/LibRed.Core/IO/ILockManager.cs | 7 +- src/LibRed/LibRed.Core/IO/PageCache.cs | 61 +++- src/LibRed/LibRed.Core/IO/PageChannel.cs | 128 ++++++- src/LibRed/LibRed.Core/JetDatabase.cs | 15 +- .../LibRed.Core/Storage/IndexKeyEncoder.cs | 3 + src/LibRed/LibRed.Core/Storage/Table.cs | 4 + .../Execution/DecorrelationGate.cs | 19 +- .../Execution/ExpressionEvaluator.cs | 68 +++- .../LibRed.Engine/Execution/QueryExecutor.cs | 1 + src/LibRed/LibRed.Engine/QueryEngine.cs | 21 ++ src/LibRed/docs/design/transactions.md | 26 +- src/LibRed/docs/format/page-00-database.md | 20 +- .../docs/format/page-03-04-index-btree.md | 14 +- src/LibRed/docs/format/system-catalog.md | 2 + src/LibRed/docs/functions.md | 6 +- test/LibRed.Ado.Tests/LibRed.Ado.Tests.csproj | 4 + .../TemporalParameterTests.cs | 6 +- .../AccentCollationAccessTests.cs | 17 +- test/LibRed.Core.Tests/AceAlterColumnTests.cs | 52 +-- .../AceAlterConstraintTests.cs | 27 +- ...> AceAutoNumberOverflowRegressionTests.cs} | 62 ++-- .../AceChooseDefaultTests.cs | 17 +- .../AceCompoundDefaultTests.cs | 17 +- .../AceCreatedDatabaseTests.cs | 16 +- .../AceDefaultExpressionLimitsTests.cs | 22 +- .../AceNullArgumentProbeTest.cs | 42 ++- .../AcePreEpochDateProbeTest.cs | 21 +- test/LibRed.Core.Tests/AceRenameTests.cs | 22 +- .../AceSingleDoubleCompareProbeTest.cs | 21 +- .../AceSmuggledColRefDefaultTests.cs | 17 +- .../AceSwitchDefaultTests.cs | 17 +- .../AceVbaConversionProbeTest.cs | 32 +- .../ActionQueryProcedureAccessTests.cs | 133 +++++-- .../LibRed.Core.Tests/AddColumnAccessTests.cs | 38 +- .../AddIndexToPopulatedTableAccessTests.cs | 24 +- .../AllocatorAndLvalOwnershipTests.cs | 5 +- .../AlterForeignKeyAccessTests.cs | 19 +- .../AlterPrimaryKeyAccessTests.cs | 19 +- test/LibRed.Core.Tests/AutoNumberSeedTests.cs | 42 +-- .../BinaryIndexKeyAccessTests.cs | 10 +- .../BitwiseOperatorAccessTests.cs | 17 +- .../BooleanWriteAccessTests.cs | 17 +- .../ChainedLongValueAccessTests.cs | 25 +- test/LibRed.Core.Tests/CollationTests.cs | 15 +- .../ColumnAliasViewAccessTests.cs | 20 +- .../ColumnIdHighWaterTests.cs | 22 +- .../CompositeFkNullAccessTests.cs | 13 +- .../CompositeIndexOrderingAccessTests.cs | 90 +++++ .../CounterSeedIncrementAccessTests.cs | 17 +- .../CreateTableAccessTests.cs | 24 +- .../CreateViewAccessTests.cs | 13 +- .../LibRed.Core.Tests/DatabaseCreatorTests.cs | 4 +- .../DatabaseDefinitionPageTests.cs | 4 +- .../DatabaseEncryptionTests.cs | 168 ++++++++- .../DateTimeDefaultAccessTests.cs | 17 +- .../DecimalKeyEncodingTests.cs | 15 +- test/LibRed.Core.Tests/DeleteAccessTests.cs | 15 +- .../DerivedTableViewAccessTests.cs | 30 +- .../DistinctViewAccessTests.cs | 20 +- .../DropColumnAccessTests.cs | 25 +- .../DropColumnConstraintAccessTests.cs | 13 +- .../DropColumnLvPropAccessTests.cs | 13 +- .../DropConstraintAccessTests.cs | 15 +- .../LibRed.Core.Tests/DropIndexAccessTests.cs | 13 +- .../LibRed.Core.Tests/DropTableAccessTests.cs | 13 +- test/LibRed.Core.Tests/DropViewAccessTests.cs | 13 +- .../FixedCharEncodingTests.cs | 22 +- .../ForeignKeyLinkageTests.cs | 20 +- .../FunctionalTestsSmokeTest.cs | 51 +-- .../GlobalReferenceFreeMapTests.cs | 4 +- .../GroupByViewAccessTests.cs | 20 +- .../LibRed.Core.Tests/GuidKeyEncodingTests.cs | 25 +- .../IndexColumnLimitTests.cs | 10 +- .../LibRed.Core.Tests/IndexKeyEncoderTests.cs | 108 ++++++ .../IndexMaintenanceAccessTests.cs | 142 ++++++++ .../IndexOrderingAccessTests.cs | 134 +++++++ .../IndexSplitAccessTests.cs | 20 +- .../IndexTraversalCorruptionTests.cs | 36 +- test/LibRed.Core.Tests/IndexUsageMapTests.cs | 25 +- .../JetDatabaseLifetimeTests.cs | 7 +- .../JetNameValidationTests.cs | 15 +- .../JetTypeCodecBoundaryTests.cs | 133 +++++++ .../LegacyJetPasswordTests.cs | 193 +++++++---- .../LibRed.Core.Tests.csproj | 6 + test/LibRed.Core.Tests/LockManagerTests.cs | 5 +- .../LongValueCorruptionTests.cs | 5 +- .../LongValuePackingAccessTests.cs | 20 +- .../LibRed.Core.Tests/MemoKeyEncodingTests.cs | 15 +- .../MultiPageDefinitionTests.cs | 5 +- .../MultiPageTableDefinitionTests.cs | 5 +- .../NestedTransactionTests.cs | 113 ++++++ .../OfficeStandardEncryptionTests.cs | 4 +- .../OfficeStandardVariantReadTests.cs | 111 +++--- .../OrderByProcedureAccessTests.cs | 20 +- test/LibRed.Core.Tests/PageCacheTests.cs | 10 +- test/LibRed.Core.Tests/PageChannelTests.cs | 30 +- .../PageChannelWriteTests.cs | 13 +- .../PrimaryKeyNameAccessTests.cs | 17 +- .../ProcedureParameterAccessTests.cs | 25 +- .../PropertyNamePoolOrderTests.cs | 17 +- .../QualifiedStarViewAccessTests.cs | 20 +- .../RandomAutoNumberAccessTests.cs | 27 +- .../LibRed.Core.Tests/RefActionAccessTests.cs | 15 +- test/LibRed.Core.Tests/RequiredColumnTests.cs | 28 +- test/LibRed.Core.Tests/RowInserterTests.cs | 29 +- .../RowRelocationCorruptionTests.cs | 9 +- .../SelfReferencingForeignKeyAccessTests.cs | 19 +- test/LibRed.Core.Tests/TableCreatorTests.cs | 23 +- .../TdefVariableRegionTests.cs | 53 +++ .../TemporaryDatabaseTests.cs | 35 ++ .../LibRed.Core.Tests/TextKeyEncodingTests.cs | 20 +- .../TextPrimaryKeyInsertTests.cs | 15 +- .../TransactionFailureRecoveryTests.cs | 143 ++++++++ .../TransactionPhysicalRollbackAccessTests.cs | 175 ++++++++++ ...actionSavepointAndEncryptionAccessTests.cs | 219 ++++++++++++ test/LibRed.Core.Tests/UpdateAccessTests.cs | 35 +- test/LibRed.Core.Tests/UsageMapGrowthTests.cs | 15 +- test/LibRed.Core.Tests/UsageMapTests.cs | 15 +- .../LibRed.Core.Tests/WideTableAccessTests.cs | 22 +- .../WideTableUsageMapTests.cs | 30 +- test/LibRed.Engine.Tests/AceCollection.cs | 20 ++ test/LibRed.Engine.Tests/AddColumnTests.cs | 7 +- .../AddedFunctionsTests.cs | 7 +- .../LibRed.Engine.Tests/AggregateTypeTests.cs | 5 +- .../AliaslessDerivedTableTests.cs | 7 +- .../LibRed.Engine.Tests/AlterAddCheckTests.cs | 25 +- .../AlterAddUniqueTests.cs | 16 +- .../AlterColumnDefaultTests.cs | 7 +- .../AlterColumnRequiredTests.cs | 18 +- test/LibRed.Engine.Tests/AlterColumnTests.cs | 29 +- .../AlterTableAddForeignKeyTests.cs | 7 +- .../AlterTableAddPrimaryKeyTests.cs | 5 +- .../AlterTableDropConstraintTests.cs | 11 +- .../AlterTableRenameTests.cs | 19 +- .../ArithmeticTypeTests.cs | 13 +- .../AutoNumberReseedTests.cs | 13 +- .../AutoNumberSeedImmunityTests.cs | 5 +- .../BinaryComparisonTests.cs | 5 +- .../LibRed.Engine.Tests/BinaryLiteralTests.cs | 9 +- .../BitwiseAndDateFunctionTests.cs | 7 +- .../BooleanComparisonTests.cs | 5 +- .../BooleanPredicateTests.cs | 9 +- .../ByteArrayStringFunctionTests.cs | 7 +- .../ByteFunctionsBinaryTests.cs | 7 +- .../CascadeDeleteWorklistTests.cs | 7 +- .../CheckConstraintEnforcementTests.cs | 15 +- .../CheckConstraintTests.cs | 7 +- .../ChooseFunctionTests.cs | 7 +- .../ColumnSizeLimitTests.cs | 7 +- .../CompositeFkMatchFullTests.cs | 7 +- .../ConcurrentAllocationTests.cs | 131 +++++++ .../ConditionalsInSelectTests.cs | 7 +- .../ConversionFunctionTests.cs | 5 +- .../CorrelatedExistsSemiJoinTests.cs | 68 ++-- .../CorrelatedInSemiJoinTests.cs | 36 +- .../CorrelatedOuterAggregateTests.cs | 10 +- .../CorrelatedScalarAggregateTests.cs | 38 +- .../CorrelatedSeekTests.cs | 7 +- .../CounterSeedIncrementTests.cs | 7 +- test/LibRed.Engine.Tests/CreateIndexTests.cs | 13 +- .../CreateProcedureTests.cs | 27 +- .../CreateTableDefaultTests.cs | 21 +- test/LibRed.Engine.Tests/CreateViewTests.cs | 16 +- .../DateArithmeticTests.cs | 7 +- .../DateTimeDefaultTests.cs | 14 +- test/LibRed.Engine.Tests/DdlDmlTests.cs | 29 +- .../DecorrelationCostTests.cs | 115 +++--- .../DeferredFunctionsTests.cs | 7 +- test/LibRed.Engine.Tests/DeleteTests.cs | 5 +- .../DistinctAggregateTests.cs | 7 +- .../DistinctBetweenDateTests.cs | 9 +- .../DoubleExtremeLiteralTests.cs | 7 +- test/LibRed.Engine.Tests/DropColumnTests.cs | 7 +- .../DropConstraintIndexTests.cs | 29 +- test/LibRed.Engine.Tests/DropIndexTests.cs | 7 +- .../DropTableRelationshipTests.cs | 7 +- test/LibRed.Engine.Tests/DropTableTests.cs | 7 +- .../DropViewProcedureTests.cs | 7 +- .../ExecuteStatementTests.cs | 7 +- .../ExistsUpdateDeleteTests.cs | 5 +- .../ExpressionSubqueryViewTests.cs | 5 +- .../FinancialFunctionsTests.cs | 7 +- .../FirstLastAggregatesTests.cs | 50 ++- .../LibRed.Engine.Tests/ForeignKeyDdlTests.cs | 55 +-- .../FormatFunctionTests.cs | 7 +- .../FromlessSelectTests.cs | 7 +- .../FunctionArityAccessTests.cs | 181 ++++++++++ .../FunctionVariantTests.cs | 7 +- .../GenGuidDefaultTests.cs | 7 +- test/LibRed.Engine.Tests/GroupByOrderTests.cs | 7 +- test/LibRed.Engine.Tests/GuidLiteralTests.cs | 5 +- test/LibRed.Engine.Tests/HashJoinTests.cs | 7 +- test/LibRed.Engine.Tests/IifDefaultTests.cs | 7 +- test/LibRed.Engine.Tests/InClauseTests.cs | 17 +- test/LibRed.Engine.Tests/InListTests.cs | 7 +- test/LibRed.Engine.Tests/IndexSeekTests.cs | 12 +- .../IndexSelectionRefusalTests.cs | 94 +++++ test/LibRed.Engine.Tests/IndexSplitTests.cs | 5 +- .../InformationSchemaTests.cs | 12 +- .../InstrEdgeCasesTests.cs | 7 +- .../InstrRevEdgeCasesTests.cs | 7 +- .../JetTypeMappingTests.cs | 100 ++++++ .../JoinedUpdateDeleteTests.cs | 7 +- .../LeftRightEdgeCasesTests.cs | 9 +- .../LibRed.Engine.Tests.csproj | 10 + test/LibRed.Engine.Tests/LikeTests.cs | 7 +- .../LvalReclamationTests.cs | 9 +- test/LibRed.Engine.Tests/MSysAcesViewTests.cs | 5 +- test/LibRed.Engine.Tests/MathFunctionTests.cs | 5 +- .../MathFunctionTypeTests.cs | 5 +- test/LibRed.Engine.Tests/MemoIndexTests.cs | 7 +- .../MidReplaceEdgeCasesTests.cs | 7 +- .../MultiTableUpdateDeleteTests.cs | 9 +- .../NestedJoinViewTests.cs | 7 +- test/LibRed.Engine.Tests/NotLikeTests.cs | 7 +- .../ParenthesizedJoinTests.cs | 5 +- .../PreEpochDateOrderingTests.cs | 7 +- .../PredicatePushdownTests.cs | 25 +- .../PrimaryKeyNameTests.cs | 7 +- .../LibRed.Engine.Tests/QualifiedStarTests.cs | 7 +- .../RandomAutoNumberTests.cs | 7 +- .../ReaderWriterIsolationTests.cs | 172 +++++++++ .../ReferentialActionTests.cs | 9 +- .../RndValFunctionTests.cs | 7 +- .../SchemaVisibilityTests.cs | 84 +++++ .../SelectPredicateTests.cs | 7 +- .../LibRed.Engine.Tests/SelfRefInsertTests.cs | 7 +- test/LibRed.Engine.Tests/SortBoundTests.cs | 7 +- test/LibRed.Engine.Tests/SortPushdownTests.cs | 63 ++-- test/LibRed.Engine.Tests/SqlCommentTests.cs | 7 +- .../SqlTransactionControlTests.cs | 54 ++- test/LibRed.Engine.Tests/StableSortTests.cs | 7 +- .../StatementAtomicityTests.cs | 106 +++++- .../StatisticalAggregatesTests.cs | 53 +-- .../StrCompStrConvTests.cs | 7 +- .../StringComparisonTests.cs | 9 +- .../StringFunctionTests.cs | 7 +- .../StringLiteralEscapeTests.cs | 5 +- .../SwitchFunctionTests.cs | 7 +- test/LibRed.Engine.Tests/TopParameterTests.cs | 11 +- .../TransactionIsolationTests.cs | 327 +++++++++++++++++- .../TransactionalDdlRollbackAccessTests.cs | 166 +++++++++ .../LibRed.Engine.Tests/TrimFunctionsTests.cs | 7 +- test/LibRed.Engine.Tests/TypeAliasTests.cs | 7 +- .../UncorrelatedSubqueryHoistingTests.cs | 7 +- .../UnionDerivedTableTests.cs | 7 +- test/LibRed.Engine.Tests/UnionTests.cs | 1 - .../UniqueIndexEnforcementTests.cs | 7 +- test/LibRed.Engine.Tests/UpdateTests.cs | 9 +- test/LibRed.Engine.Tests/WideTableTests.cs | 7 +- test/LibRed.Shared/AceTestDatabase.cs | 43 +++ test/LibRed.Shared/TempDatabaseTest.cs | 26 ++ test/LibRed.Shared/TemporaryDatabase.cs | 180 ++++++++++ 260 files changed, 5156 insertions(+), 2175 deletions(-) rename test/LibRed.Core.Tests/{AceAutoNumberOverflowProbeTest.cs => AceAutoNumberOverflowRegressionTests.cs} (84%) create mode 100644 test/LibRed.Core.Tests/CompositeIndexOrderingAccessTests.cs create mode 100644 test/LibRed.Core.Tests/IndexMaintenanceAccessTests.cs create mode 100644 test/LibRed.Core.Tests/IndexOrderingAccessTests.cs create mode 100644 test/LibRed.Core.Tests/JetTypeCodecBoundaryTests.cs create mode 100644 test/LibRed.Core.Tests/NestedTransactionTests.cs create mode 100644 test/LibRed.Core.Tests/TemporaryDatabaseTests.cs create mode 100644 test/LibRed.Core.Tests/TransactionFailureRecoveryTests.cs create mode 100644 test/LibRed.Core.Tests/TransactionPhysicalRollbackAccessTests.cs create mode 100644 test/LibRed.Core.Tests/TransactionSavepointAndEncryptionAccessTests.cs create mode 100644 test/LibRed.Engine.Tests/AceCollection.cs create mode 100644 test/LibRed.Engine.Tests/ConcurrentAllocationTests.cs create mode 100644 test/LibRed.Engine.Tests/FunctionArityAccessTests.cs create mode 100644 test/LibRed.Engine.Tests/IndexSelectionRefusalTests.cs create mode 100644 test/LibRed.Engine.Tests/JetTypeMappingTests.cs create mode 100644 test/LibRed.Engine.Tests/ReaderWriterIsolationTests.cs create mode 100644 test/LibRed.Engine.Tests/SchemaVisibilityTests.cs create mode 100644 test/LibRed.Engine.Tests/TransactionalDdlRollbackAccessTests.cs create mode 100644 test/LibRed.Shared/AceTestDatabase.cs create mode 100644 test/LibRed.Shared/TempDatabaseTest.cs create mode 100644 test/LibRed.Shared/TemporaryDatabase.cs diff --git a/src/LibRed/LibRed.Ado/LibRedCommand.cs b/src/LibRed/LibRed.Ado/LibRedCommand.cs index a17091ea..17e35144 100644 --- a/src/LibRed/LibRed.Ado/LibRedCommand.cs +++ b/src/LibRed/LibRed.Ado/LibRedCommand.cs @@ -75,6 +75,7 @@ private Engine.CommandResult ExecuteBatch() try { last = engine.Execute(statement, parameters); + Connection?.ReconcileSqlTransactionControl(); } catch (LibRed.ConstraintViolationException e) { diff --git a/src/LibRed/LibRed.Ado/LibRedConnection.cs b/src/LibRed/LibRed.Ado/LibRedConnection.cs index abe6b161..64a26cd2 100644 --- a/src/LibRed/LibRed.Ado/LibRedConnection.cs +++ b/src/LibRed/LibRed.Ado/LibRedConnection.cs @@ -55,6 +55,14 @@ internal void RollbackTransaction(LibRedTransaction transaction) CurrentTransaction = null; } + /// Keeps an ADO transaction handle honest when SQL COMMIT/ROLLBACK closes its transaction. + internal void ReconcileSqlTransactionControl() + { + if (_database?.TransactionDepth != 0 || CurrentTransaction is null) return; + CurrentTransaction.CompleteFromSql(); + CurrentTransaction = null; + } + /// Opens a savepoint in the connection's active transaction (called by /// ). internal LibRed.IO.Savepoint CreateSavepoint() => diff --git a/src/LibRed/LibRed.Ado/LibRedTransaction.cs b/src/LibRed/LibRed.Ado/LibRedTransaction.cs index 9537bb0f..4c488690 100644 --- a/src/LibRed/LibRed.Ado/LibRedTransaction.cs +++ b/src/LibRed/LibRed.Ado/LibRedTransaction.cs @@ -63,6 +63,14 @@ private void EnsureActive() throw new InvalidOperationException("This transaction has already been committed or rolled back."); } + /// Marks this handle complete when SQL COMMIT/ROLLBACK closes the physical transaction. + internal void CompleteFromSql() + { + _completed = true; + _connection = null; + _savepoints.Clear(); + } + // A DbException (not a plain InvalidOperationException): referencing a released/never-opened savepoint is a // database-operation error, and callers — EF Core's transaction tests included — expect DbException for it. private Savepoint Lookup(string name) => diff --git a/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs b/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs index 6e50f8ea..252e12fc 100644 --- a/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs +++ b/src/LibRed/LibRed.Core/Catalog/JetCatalog.cs @@ -37,36 +37,47 @@ public sealed class JetCatalog(PageChannel channel, int catalogPage = 2) private Dictionary? _views; private Dictionary? _actionQueries; private Dictionary>? _queryParameters; + private long _seenSchemaGeneration = channel.SchemaGeneration; /// All tables in the database (user and system). - public IReadOnlyList Tables => _tables ??= LoadTables(); + public IReadOnlyList Tables { get { EnsureFresh(); return _tables ??= LoadTables(); } } /// Views (stored simple-SELECT queries) as name → reconstructed SELECT SQL, rebuilt from /// each view's MSysQueries rows. Complex/system queries that don't reconstruct are omitted. - public IReadOnlyDictionary Views { get { EnsureStoredQueries(); return _views!; } } + public IReadOnlyDictionary Views { get { EnsureFresh(); EnsureStoredQueries(); return _views!; } } /// Stored action queries (a CREATE PROCEDURE body that is not a SELECT) as name → readback. /// A supported query (CREATE/DROP TABLE, INSERT … VALUES) carries executable SQL; an unsupported one /// (INSERT … SELECT, etc.) carries only a reason and throws when LibRed is asked to execute it. - public IReadOnlyDictionary ActionQueries { get { EnsureStoredQueries(); return _actionQueries!; } } + public IReadOnlyDictionary ActionQueries { get { EnsureFresh(); EnsureStoredQueries(); return _actionQueries!; } } /// 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 { 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() + public void Invalidate(bool markChanged = true) { _tables = null; _relationships = null; _views = null; _actionQueries = null; _queryParameters = null; + _seenSchemaGeneration = _channel.SchemaGeneration; + if (markChanged) _channel.MarkSchemaChanged(); + } + + private void EnsureFresh() + { + long generation = _channel.SchemaGeneration; + if (generation == _seenSchemaGeneration) return; + Invalidate(markChanged: false); + _seenSchemaGeneration = generation; } /// All relationships (foreign keys) defined in the database. - public IReadOnlyList Relationships => _relationships ??= LoadRelationships(); + public IReadOnlyList Relationships { get { EnsureFresh(); return _relationships ??= LoadRelationships(); } } /// Relationships for which is the referencing (child) table. public IEnumerable ForeignKeysOf(string table) => @@ -250,6 +261,11 @@ private static StoredActionQuery ReconstructAction(List rows, int att return new StoredActionQuery($"INSERT INTO [{target}] ({columns}) VALUES ({values})", null); } + if (kind == StoredQueryFormat.ActionUpdate) + return new StoredActionQuery(null, "UPDATE stored queries are not executed by LibRed yet."); + if (kind == StoredQueryFormat.ActionDelete) + return new StoredActionQuery(null, "DELETE stored queries are not executed by LibRed yet."); + return new StoredActionQuery(null, "This stored action query kind is not supported by LibRed yet."); } diff --git a/src/LibRed/LibRed.Core/Crypto/DatabaseEncryption.cs b/src/LibRed/LibRed.Core/Crypto/DatabaseEncryption.cs index 7d8892ff..77f68f4a 100644 --- a/src/LibRed/LibRed.Core/Crypto/DatabaseEncryption.cs +++ b/src/LibRed/LibRed.Core/Crypto/DatabaseEncryption.cs @@ -62,8 +62,7 @@ public static void RemovePassword(string path, string password) public static void SetPasswordRc4(string path, string password, int keyBits = 40, StandardHash hash = StandardHash.Sha1) { ArgumentException.ThrowIfNullOrEmpty(password); - if (keyBits is < 40 or > 128 || keyBits % 8 != 0) - throw new ArgumentOutOfRangeException(nameof(keyBits), "RC4 key length must be 40–128 bits, in multiples of 8."); + ValidateRc4Options(keyBits, hash); byte[] file = File.ReadAllBytes(path); if (!DetectFormat(file).IsAccdb) @@ -81,6 +80,10 @@ public static void SetPasswordRc4(string path, string password, int keyBits = 40 /// with the new one — exactly remove + set. public static void ChangePassword(string path, string oldPassword, string newPassword, AccessEncryption scheme) { + ArgumentException.ThrowIfNullOrEmpty(newPassword); + // Validate before RemovePassword decrypts, so a rejected scheme can't leave the database plaintext. + // Detect reads only the header, so this costs one page rather than a second copy of the whole file. + ValidateScheme(scheme, DetectFormatOf(path)); RemovePassword(path, oldPassword); SetPassword(path, newPassword, scheme); } @@ -89,10 +92,19 @@ public static void ChangePassword(string path, string oldPassword, string newPas /// decrypt with the old password, then . public static void ChangePasswordRc4(string path, string oldPassword, string newPassword, int keyBits = 40, StandardHash hash = StandardHash.Sha1) { + ArgumentException.ThrowIfNullOrEmpty(newPassword); + ValidateRc4Options(keyBits, hash); RemovePassword(path, oldPassword); SetPasswordRc4(path, newPassword, keyBits, hash); } + private static void ValidateRc4Options(int keyBits, StandardHash hash) + { + if (keyBits is < 40 or > 128 || keyBits % 8 != 0) + throw new ArgumentOutOfRangeException(nameof(keyBits), "RC4 key length must be 40–128 bits, in multiples of 8."); + _ = ToHashName(hash); + } + /// Sets the legacy Jet 4 (.mdb) database password — the "Set Database Password" feature, which is /// password obfuscation only (the data pages stay plaintext; this is not RC4 page encryption). The password /// (≤20 chars) is stored UTF-16LE at 0x42, XOR-masked with the 32-bit truncation of the creation-date @@ -281,6 +293,14 @@ private static void ValidateScheme(AccessEncryption scheme, JetFormatBase format } } + /// Detects a file's format without loading it: reads only the + /// header, so this is a header read rather than a full copy of a database that may be hundreds of MB. + private static JetFormatBase DetectFormatOf(string path) + { + using FileStream stream = File.OpenRead(path); + return JetFormatBase.Detect(stream); + } + private static JetFormatBase DetectFormat(byte[] file) { using var ms = new MemoryStream(file, writable: false); diff --git a/src/LibRed/LibRed.Core/Crypto/OfficeStandardEncryption.cs b/src/LibRed/LibRed.Core/Crypto/OfficeStandardEncryption.cs index e879bbef..a64310ce 100644 --- a/src/LibRed/LibRed.Core/Crypto/OfficeStandardEncryption.cs +++ b/src/LibRed/LibRed.Core/Crypto/OfficeStandardEncryption.cs @@ -90,6 +90,16 @@ private OfficeStandardEncryption(bool rc4, HashAlgorithmName hashName, byte[] ba if (databaseKey == 0) return null; + const int descriptorOffset = 0x29B; + if (page0.Length < descriptorOffset) + throw new InvalidDataException("Page 0 is too short to contain an ACE EncryptionInfo frame."); + int descriptorLength = BinaryPrimitives.ReadUInt16LittleEndian(page0.Slice(0x299, 2)); + if (descriptorLength == 0) + return null; + if (descriptorLength > page0.Length - descriptorOffset) + throw new InvalidDataException("The declared Office-Standard EncryptionInfo extends beyond page 0."); + page0 = page0.Slice(descriptorOffset, descriptorLength); + int ei = LocateBinaryEncryptionInfo(page0); if (ei < 0) return null; @@ -346,7 +356,7 @@ private static int LocateBinaryEncryptionInfo(ReadOnlySpan page0) { // A binary EncryptionInfo begins: uint16 major, uint16 minor(=2 for standard/CryptoAPI), uint32 flags // (fCryptoAPI=0x04 set), uint32 headerSize. Validate against a known cipher AlgID to avoid false hits. - for (int i = 0x100; i + 32 < page0.Length && i < 0x400; i++) + for (int i = 0; i + 32 < page0.Length; i++) { ushort major = BinaryPrimitives.ReadUInt16LittleEndian(page0.Slice(i, 2)); ushort minor = BinaryPrimitives.ReadUInt16LittleEndian(page0.Slice(i + 2, 2)); diff --git a/src/LibRed/LibRed.Core/Formats/StoredQueryFormat.cs b/src/LibRed/LibRed.Core/Formats/StoredQueryFormat.cs index 5618b978..45be1e74 100644 --- a/src/LibRed/LibRed.Core/Formats/StoredQueryFormat.cs +++ b/src/LibRed/LibRed.Core/Formats/StoredQueryFormat.cs @@ -39,6 +39,12 @@ internal static class StoredQueryFormat /// AttrAction Flag value for an append (INSERT) query (target table in Name1). public const short ActionAppend = 3; + /// AttrAction Flag value for a DELETE query. + public const short ActionDelete = 5; + + /// AttrAction Flag value for an UPDATE query. + public const short ActionUpdate = 4; + /// AttrColumn Flag bit marking an appended literal value. public const short AppendValueFlag = unchecked((short)0x8000); } diff --git a/src/LibRed/LibRed.Core/IO/ILockManager.cs b/src/LibRed/LibRed.Core/IO/ILockManager.cs index 7c411bf7..c3295a75 100644 --- a/src/LibRed/LibRed.Core/IO/ILockManager.cs +++ b/src/LibRed/LibRed.Core/IO/ILockManager.cs @@ -15,9 +15,10 @@ namespace LibRed.IO; /// docs/design/transactions.md. /// /// -/// Locks are operation-scoped in this phase: acquired and released around a single page read or -/// write, which prevents a reader from seeing a half-written page. Holding locks to transaction commit (strict -/// two-phase locking, for full isolation) is a later refinement layered on the same seam. +/// Locks are operation-scoped: acquired and released around a single page read or write, which +/// prevents a reader from seeing a half-written page. Transaction overlays use optimistic committed-page +/// validation at publish time, so overlapping writers fail deterministically instead of losing an update. +/// Strict two-phase/cross-process locking remains a later refinement layered on the same seam. /// /// The API is Enter/Exit (not a disposable handle) so the hot path — a page read takes /// and releases a shared lock — allocates nothing; pairs each Enter with an Exit in a diff --git a/src/LibRed/LibRed.Core/IO/PageCache.cs b/src/LibRed/LibRed.Core/IO/PageCache.cs index 20dd920a..d5b738e6 100644 --- a/src/LibRed/LibRed.Core/IO/PageCache.cs +++ b/src/LibRed/LibRed.Core/IO/PageCache.cs @@ -4,8 +4,9 @@ namespace LibRed.IO; /// A bounded, write-through buffer pool of raw page bytes for one physical database file, shared by every /// open on that file (see ). A single cache per file is what /// keeps coexisting handles coherent: because they all read and write through the same pool, one connection's -/// committed — or, as today, uncommitted — writes are immediately visible to the others, exactly as the old -/// straight-to-disk reads were, only now served from memory instead of a Seek()+Read() per page. +/// committed writes are immediately visible to the others, while transaction-local overlay pages remain private, +/// exactly as the old straight-to-disk committed reads were, only now served from memory instead of a +/// Seek()+Read() per page. /// /// The pool owns its own copies: reads copy out to the caller and writes copy in from the /// caller, so a caller mutating its buffer can never corrupt a cached page. Eviction is LRU, capped at @@ -32,6 +33,10 @@ private sealed class Entry(int page, byte[] bytes) 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. + private readonly ReaderWriterLockSlim _publishGate = new(LockRecursionPolicy.SupportsRecursion); + private long _schemaGeneration; private readonly Dictionary> _map = []; private readonly LinkedList _lru = new(); // first = most-recently-used @@ -140,6 +145,58 @@ public void Store(int page, ReadOnlySpan source) } } + /// Removes a page which was appended during a failed commit and subsequently truncated. + public void Remove(int page) + { + lock (_gate) + { + if (!_map.Remove(page, out LinkedListNode? node)) return; + _lru.Remove(node); + } + } + + /// Serializes committed-page validation and publication for every channel on this file, and + /// excludes readers for the duration. A transaction validates all pages it derived from and publishes its + /// overlay while holding this gate; ordinary write-through operations use the same gate, preventing a write + /// between validation and publish. Writing statements hold it for their whole duration, so a reader never + /// sees one statement's pages half-written. The gate is recursive: a statement-scoped acquisition nests the + /// per-page publications inside it. + public void PublishLocked(Action action) => PublishLocked(() => { action(); return null; }); + + /// + public T PublishLocked(Func action) + { + // A reader that turns out to write is a statement-classification bug (see QueryEngine): the shared + // gate cannot be upgraded, so say so rather than surfacing a bare LockRecursionException. + if (_publishGate.IsReadLockHeld) + throw new InvalidOperationException( + "A page write was attempted inside a shared (read-consistent) scope; the statement should have taken the exclusive scope."); + + _publishGate.EnterWriteLock(); + try { return action(); } + finally { _publishGate.ExitWriteLock(); } + } + + /// Runs one logical read while excluding a multi-page commit publication, so the caller sees + /// either the complete pre-commit or complete post-commit page set. Shared: concurrent readers on this file + /// run together, and only a publication (see ) excludes them. + public T ReadConsistent(Func action) + { + _publishGate.EnterReadLock(); + try { return action(); } + finally { _publishGate.ExitReadLock(); } + } + + public long SchemaGeneration + { + get { lock (_gate) return _schemaGeneration; } + } + + public void MarkSchemaChanged() + { + lock (_gate) _schemaGeneration++; + } + /// Returns a previously cached higher-layer parse of (see /// ), or false if the page is not resident or has no parse cached. public bool TryGetParsed(int page, out object? parsed) diff --git a/src/LibRed/LibRed.Core/IO/PageChannel.cs b/src/LibRed/LibRed.Core/IO/PageChannel.cs index 23b9d248..37700dea 100644 --- a/src/LibRed/LibRed.Core/IO/PageChannel.cs +++ b/src/LibRed/LibRed.Core/IO/PageChannel.cs @@ -40,6 +40,11 @@ public sealed class PageChannel : IDisposable // lock. `_txPageCount` is the logical page count during a transaction (committed pages plus any the overlay // allocated), since deferred allocations do not grow the file until commit. private readonly Dictionary _overlay = []; + // Committed plaintext image from which each transactional page was first derived. At commit, every image + // must still match; otherwise another channel committed the same page and publishing this stale overlay + // would silently lose that writer's change. + private readonly Dictionary _commitBaselines = []; + private bool _schemaDirty; private int _txPageCount; private PageChannel(FileStream stream, JetFormatBase format, bool readOnly, string path, IPageCodec? codec, ILockManager? locks) @@ -60,6 +65,14 @@ private PageChannel(FileStream stream, JetFormatBase format, bool readOnly, stri public JetFormatBase Format { get; } + internal long SchemaGeneration => _cache.SchemaGeneration; + + internal void MarkSchemaChanged() + { + if (_active is not null) _schemaDirty = true; + else _cache.MarkSchemaChanged(); + } + public int PageSize => Format.PageSize; /// Number of pages currently in the file — or, inside a transaction, the logical count including @@ -231,6 +244,8 @@ public void WritePage(int pageNumber, ReadOnlySpan source) // restore it, then buffer a private copy of the new bytes and advance the logical page count. if (_active is not null) { + if (!_commitBaselines.ContainsKey(pageNumber)) + _commitBaselines[pageNumber] = ReadCommittedPageOrNull(pageNumber); if (_active.NeedsBeforeImage(pageNumber)) _active.RecordBeforeImage(pageNumber, _overlay.TryGetValue(pageNumber, out byte[]? prior) ? prior : null); _overlay[pageNumber] = source[..PageSize].ToArray(); @@ -245,6 +260,20 @@ public void WritePage(int pageNumber, ReadOnlySpan source) /// disk for an encrypted file while caching plaintext, growing the file if the page lies past its end. Used /// for non-transactional writes and to publish each overlay page on commit. private void WriteThrough(int pageNumber, ReadOnlySpan source) + { + byte[] copy = source[..PageSize].ToArray(); + _cache.PublishLocked(() => WriteThroughUnderPublishLock(pageNumber, copy)); + } + + /// Runs a logical read against one committed page-set generation. Shared: other readers on this + /// file run concurrently; only a publication excludes them. + internal T ReadConsistent(Func action) => _cache.ReadConsistent(action); + + /// Runs a logical write with every other reader and writer on this file excluded, so its pages + /// publish as one unit. + internal T WriteExclusive(Func action) => _cache.PublishLocked(action); + + private void WriteThroughUnderPublishLock(int pageNumber, ReadOnlySpan source) { _locks?.EnterExclusive(pageNumber); try @@ -302,6 +331,8 @@ public Transaction BeginTransaction() if (_active is not null) throw new InvalidOperationException("A transaction is already in progress."); _overlay.Clear(); + _commitBaselines.Clear(); + _schemaDirty = false; _txPageCount = PageCount; // committed count at start (PageCount is still file-based while _active is null) return _active = new Transaction(_txPageCount); } @@ -318,10 +349,71 @@ public void CommitTransaction(bool flush = true) int[] pages = _overlay.Keys.ToArray(); Array.Sort(pages); - _active = null; // clear first so WriteThrough takes the committed path and PageCount reverts to the file - foreach (int page in pages) - WriteThrough(page, _overlay[page]); - _overlay.Clear(); + _cache.PublishLocked(() => + { + foreach (int page in pages) + { + byte[]? current = ReadCommittedPageOrNull(page); + byte[]? baseline = _commitBaselines[page]; + if (!SamePage(baseline, current)) + throw new InvalidOperationException( + $"Transaction write conflict on page {page}: another connection committed a change to this page."); + } + + // Keep the transaction open until every page has published. If a later page fails, restore the + // already-published prefix from its validated committed baselines so the caller can still roll back. + var published = new List(pages.Length); + try + { + foreach (int page in pages) + { + WriteThroughUnderPublishLock(page, _overlay[page]); + published.Add(page); + } + } + catch (Exception publishFailure) + { + // The restore writes to the same file that just failed to accept a write, so they can fail + // too — and if one does, the original cause must not be swallowed by the cleanup's exception. + // Collect both: the caller needs the publish failure to know why the commit failed, and the + // restore failure to know the file was left mid-publish rather than rolled back. + List? restoreFailures = null; + for (int i = published.Count - 1; i >= 0; i--) + { + int page = published[i]; + try + { + if (_commitBaselines[page] is { } baseline) + { + WriteThroughUnderPublishLock(page, baseline); + } + else + { + // A null baseline is a transaction-allocated tail page. Validation proved no other + // writer had claimed it, and the publish gate excludes one while we truncate it again. + _stream.SetLength((long)page * PageSize); + _cache.Remove(page); + } + } + catch (Exception restoreFailure) + { + (restoreFailures ??= []).Add( + new IOException($"Could not restore page {page} after a failed commit publication.", restoreFailure)); + } + } + + if (restoreFailures is null) throw; + throw new AggregateException( + "A commit publication failed and the already-published pages could not all be restored; " + + "the file is left mid-publish.", [publishFailure, .. restoreFailures]); + } + + _active = null; + _overlay.Clear(); + _commitBaselines.Clear(); + if (_schemaDirty) _cache.MarkSchemaChanged(); + _schemaDirty = false; + }); if (flush) _stream.Flush(flushToDisk: true); } @@ -334,6 +426,8 @@ public void RollbackTransaction() { if (_active is null) return; _overlay.Clear(); + _commitBaselines.Clear(); + _schemaDirty = false; _active = null; } @@ -377,8 +471,34 @@ private void RestoreOverlay(List> before, int pageCou else _overlay[page] = image; } _txPageCount = pageCount; + foreach (int page in _commitBaselines.Keys.Where(p => !_overlay.ContainsKey(p)).ToArray()) + _commitBaselines.Remove(page); } + private byte[]? ReadCommittedPageOrNull(int pageNumber) + { + int committedPageCount = (int)(_stream.Length / PageSize); + if (pageNumber < 0 || pageNumber >= committedPageCount) return null; + + var buffer = new byte[PageSize]; + if (_cache.TryRead(pageNumber, buffer)) return buffer; + + _locks?.EnterShared(pageNumber); + try + { + if (_cache.TryRead(pageNumber, buffer)) return buffer; + _stream.Seek((long)pageNumber * PageSize, SeekOrigin.Begin); + _stream.ReadExactly(buffer); + _codec?.DecryptPage(pageNumber, buffer); + _cache.Store(pageNumber, buffer); + return buffer; + } + finally { _locks?.ExitShared(pageNumber); } + } + + private static bool SamePage(byte[]? left, byte[]? right) => + left is null ? right is null : right is not null && left.AsSpan().SequenceEqual(right); + /// Retrieves a higher-layer parse of a page previously stored via /// (e.g. an index page's decoded entries), or false if none is cached. The parse is dropped automatically /// when the page is written (any channel) or evicted, so a hit is always consistent with the current bytes. diff --git a/src/LibRed/LibRed.Core/JetDatabase.cs b/src/LibRed/LibRed.Core/JetDatabase.cs index f13968fe..7d723bb0 100644 --- a/src/LibRed/LibRed.Core/JetDatabase.cs +++ b/src/LibRed/LibRed.Core/JetDatabase.cs @@ -68,7 +68,7 @@ public DataPage ReadDataPage(int pageNumber) } /// Opens a database file (read-only by default). For a password-encrypted ACCDB, supply - /// — encrypted databases open read-only. + /// ; writable opens encrypt modified pages again before publishing them. public static JetDatabase Open(string path, bool readOnly = true, string? password = null) { // Coordinate page access between every handle open on this file (EF holds several connections on one @@ -94,6 +94,15 @@ public static JetDatabase Open(string path, bool readOnly = true, string? passwo /// Whether a transaction is currently open. public bool InTransaction => _channel.InTransaction; + /// Runs a logical read without allowing a concurrent multi-page commit to publish halfway through. + /// Shared — reads on this file run concurrently with each other. + public T ReadConsistent(Func action) => _channel.ReadConsistent(action); + + /// Runs a logical write with every other reader and writer on this file excluded, so a reader + /// never observes it half-published. Writing statements must use this rather than + /// : the shared scope cannot be upgraded. + public T WriteExclusive(Func action) => _channel.WriteExclusive(action); + /// Begins a page-level transaction; writes are undoable until . public void BeginTransaction() => _channel.BeginTransaction(); @@ -110,7 +119,7 @@ public void Rollback() { if (!_channel.InTransaction) return; _channel.RollbackTransaction(); - Catalog.Invalidate(); + Catalog.Invalidate(markChanged: false); } /// Opens a savepoint within the current transaction (used to make a single statement atomic @@ -123,7 +132,7 @@ public void Rollback() public void RollbackToSavepoint(Savepoint savepoint) { _channel.RollbackToSavepoint(savepoint); - Catalog.Invalidate(); + Catalog.Invalidate(markChanged: false); } /// Releases , merging its writes into the enclosing scope. diff --git a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs index 170b8ca2..40a2152d 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs @@ -154,6 +154,9 @@ public static byte[] Encode(IReadOnlyList<(ColumnDef Column, bool Ascending)> co private static void EncodeBinaryChunked(List buffer, byte[] data, bool ascending) { buffer.Add(ascending ? IndexKeyFlags.AscStart : IndexKeyFlags.DescStart); + // ACE represents an empty Binary value by the start flag alone. + if (data.Length == 0) + return; int offset = 0; do diff --git a/src/LibRed/LibRed.Core/Storage/Table.cs b/src/LibRed/LibRed.Core/Storage/Table.cs index ab30857e..d6ca9d2b 100644 --- a/src/LibRed/LibRed.Core/Storage/Table.cs +++ b/src/LibRed/LibRed.Core/Storage/Table.cs @@ -36,6 +36,10 @@ public Table(PageChannel channel, TableDef definition) private object?[]? GetRow(RowId id, RowDecoder decoder) { + if (id.Page <= 0 || id.Page >= Channel.PageCount) + throw new InvalidDataException( + $"Row pointer {id.Page}:{id.Row} is outside the file's 1..{Channel.PageCount - 1} page range."); + // Read just the one wanted slot straight from the page directory (O(1)), over the shared cache buffer // without copying the 4 KB page out — the bytes are consumed immediately by Decode. Both were the // seek's per-row hot cost. diff --git a/src/LibRed/LibRed.Engine/Execution/DecorrelationGate.cs b/src/LibRed/LibRed.Engine/Execution/DecorrelationGate.cs index 39850f78..3148078c 100644 --- a/src/LibRed/LibRed.Engine/Execution/DecorrelationGate.cs +++ b/src/LibRed/LibRed.Engine/Execution/DecorrelationGate.cs @@ -41,13 +41,26 @@ namespace LibRed.Engine.Execution; internal sealed class DecorrelationGate { /// 25 ms of per-row work before the decorrelated form takes over. - private static readonly long Budget = Stopwatch.Frequency / 40; + private static readonly long DefaultBudget = Stopwatch.Frequency / 40; + private readonly long _budget; + private readonly Func _timestamp; private long _spent; + internal DecorrelationGate(long? budget = null, Func? timestamp = null) + { + _budget = budget ?? DefaultBudget; + if (_budget <= 0) throw new ArgumentOutOfRangeException(nameof(budget)); + _timestamp = timestamp ?? Stopwatch.GetTimestamp; + } + /// Whether enough per-row time has been spent to justify one pass over the whole body. - internal bool Ready => _spent >= Budget; + internal bool Ready => _spent >= _budget; /// Charges one per-row evaluation, given the timestamp taken just before it started. - internal void Charge(long startTimestamp) => _spent += Stopwatch.GetTimestamp() - startTimestamp; + internal void Charge(long startTimestamp) + { + long elapsed = _timestamp() - startTimestamp; + if (elapsed > 0) _spent += elapsed; + } } diff --git a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs index bac40d8e..7a9af49f 100644 --- a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs +++ b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs @@ -135,10 +135,12 @@ private static bool TryNiladicFunction(ColumnReference c, out object? value) // value in the Jet expression service — so a trailing "$" is stripped and dispatched to the base name. string name = f.Name.ToUpperInvariant(); if (name.Length > 1 && name[^1] == '$') name = name[..^1]; + ValidateArity(name, f.Arguments.Count); return name switch { - "IIF" => IsTrue(f.Arguments[0]) ? Evaluate(f.Arguments[1]) : Evaluate(f.Arguments[2]), + "IIF" => IsTrue(f.Arguments[0]) ? Evaluate(f.Arguments[1]) + : f.Arguments.Count == 3 ? Evaluate(f.Arguments[2]) : null, "CHOOSE" => Choose(f), "SWITCH" => Switch(f), "DATEPART" => DatePart(Evaluate(f.Arguments[0]), Evaluate(f.Arguments[1])), @@ -290,6 +292,70 @@ private static bool TryNiladicFunction(ColumnReference c, out object? value) }; } + /// Rejects argument counts verified against ACE. Keep this table evidence-driven: add a function + /// only after its minimum/maximum have been exercised through ACE, since Jet includes quirks such as IIf's + /// accepted two-argument form (the omitted false branch is Null). + internal static void ValidateArity(string name, int count) + { + (int Min, int Max)? range = name switch + { + // Conversion, unary numeric/string/date/inspection functions and single-argument aliases. + "CBOOL" or "CBYTE" or "CINT" or "CLNG" or "CSNG" or "CDBL" or "CCUR" or "CDEC" + or "CSTR" or "CDATE" or "CVAR" + or "ABS" or "SGN" or "INT" or "FIX" or "SQR" or "EXP" or "LOG" or "SIN" or "COS" + or "TAN" or "ATN" + or "LEN" or "LCASE" or "UCASE" or "TRIM" or "LTRIM" or "RTRIM" 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" + or "QBCOLOR" or "ASCW" or "CHRW" or "ASCB" or "LENB" => (1, 1), + + "LEFT" or "RIGHT" or "STRING" or "LEFTB" or "RIGHTB" => (2, 2), + "MID" or "MIDB" => (2, 3), + "INSTR" or "INSTRREV" or "INSTRB" => (2, 4), + "STRCOMP" => (2, 3), + "STRCONV" => (2, 3), + "IIF" => (2, 3), + "CHOOSE" => (2, int.MaxValue), + "SWITCH" => (2, int.MaxValue), + + "NOW" or "DATE" or "TIME" or "TIMER" or "GENUNIQUEID" or "GENGUID" => (0, 0), + "DATEADD" => (3, 3), + "DATEDIFF" => (3, 5), + "DATEPART" => (2, 4), + "DATESERIAL" or "TIMESERIAL" => (3, 3), + "WEEKDAY" or "MONTHNAME" => (1, 2), + "WEEKDAYNAME" => (1, 3), + + "RGB" => (3, 3), + "ROUND" => (1, 2), + "RND" => (0, 1), + "REPLACE" => (3, 6), + "FORMAT" => (1, 4), + "FORMATCURRENCY" or "FORMATNUMBER" or "FORMATPERCENT" => (1, 5), + "FORMATDATETIME" => (1, 2), + "PARTITION" => (4, 4), + + "PMT" or "FV" or "PV" or "NPER" => (3, 5), + "IPMT" or "PPMT" => (4, 6), + "RATE" => (3, 6), + "SLN" => (3, 3), + "SYD" => (4, 4), + "DDB" => (4, 5), + + "COUNT" or "SUM" or "AVG" or "MIN" or "MAX" or "FIRST" or "LAST" or "STDEV" or "VAR" + or "STDEVP" or "VARP" or "STDDEV" or "STDDEVP" => (1, 1), + _ => null, + }; + bool invalidPairs = name == "SWITCH" && count % 2 != 0; + if (range is { } valid && (count < valid.Min || count > valid.Max || invalidPairs)) + throw new InvalidOperationException( + $"Wrong number of arguments used with function {name} (expected " + + (name == "SWITCH" ? "condition/value pairs" : valid.Min == valid.Max + ? valid.Min.ToString(CultureInfo.InvariantCulture) + : valid.Max == int.MaxValue ? $"at least {valid.Min}" : $"{valid.Min} to {valid.Max}") + ")."); + } + /// Access Choose(index, choice-1, choice-2, …): returns the 1-based choice at /// 's index, or NULL when the index is out of range (verified vs ACE: Choose(0,…) /// and Choose(5,…) on three choices both return Null). A NULL index is an error in ACE ("Data type diff --git a/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs b/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs index 2eea5006..cefc52c6 100644 --- a/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs @@ -1215,6 +1215,7 @@ private ExpressionEvaluator Eval(IReadOnlyList columns, object?[] private object? ComputeAggregate(FunctionCall call, List group, IReadOnlyList columns, EvalScope? outer) { string name = call.Name.ToUpperInvariant(); + ExpressionEvaluator.ValidateArity(name, call.Arguments.Count); Expression? arg = call.Arguments.Count > 0 ? call.Arguments[0] : null; // COUNT is an Access Long Integer (32-bit) — EF reads it with GetInt32, so return int, not long. diff --git a/src/LibRed/LibRed.Engine/QueryEngine.cs b/src/LibRed/LibRed.Engine/QueryEngine.cs index beb3461e..e6a19126 100644 --- a/src/LibRed/LibRed.Engine/QueryEngine.cs +++ b/src/LibRed/LibRed.Engine/QueryEngine.cs @@ -37,7 +37,23 @@ public QueryEngine(JetDatabase database, ISqlParser? parser = null) public ResultSet ExecuteQuery(string sql, IReadOnlyDictionary? parameters = null) { + // Parse outside the gate — it touches no pages, and holding a file-wide scope across it would + // serialize parsing for no isolation benefit. The parsed shape then picks the scope. SqlStatement parsed = _parser.ParseStatement(sql); + return Scoped(parsed, () => ExecuteQueryCore(parsed, parameters)); + } + + /// Runs in the page scope needs: shared + /// (concurrent with other readers) for a statement that cannot write, exclusive otherwise so its pages + /// publish as one unit. Anything not provably read-only takes the exclusive scope — a shared scope that + /// then writes is rejected, not silently upgraded. + private T Scoped(SqlStatement statement, Func action) => + statement is SelectStatement or SetOperationStatement or SystemVariableSelectStatement + ? _database.ReadConsistent(action) + : _database.WriteExclusive(action); + + private ResultSet ExecuteQueryCore(SqlStatement parsed, IReadOnlyDictionary? parameters) + { if (parsed is ExecuteStatement exec) return ExecuteProcedure(exec, parameters).Rows; SqlStatement ast = ViewExpander.Expand(parsed, _database.Catalog.Views, _parser); BoundStatement bound = _binder.Bind(ast); @@ -78,6 +94,11 @@ public int ExecuteStoredActionQuery(string name) public CommandResult Execute(string sql, IReadOnlyDictionary? parameters = null) { SqlStatement parsed = _parser.ParseStatement(sql); + return Scoped(parsed, () => ExecuteCore(parsed, parameters)); + } + + private CommandResult ExecuteCore(SqlStatement parsed, IReadOnlyDictionary? parameters) + { if (parsed is ExecuteStatement exec) return ExecuteProcedure(exec, parameters); SqlStatement ast = ViewExpander.Expand(parsed, _database.Catalog.Views, _parser); BoundStatement bound = _binder.Bind(ast); diff --git a/src/LibRed/docs/design/transactions.md b/src/LibRed/docs/design/transactions.md index a038f3bf..e6694f01 100644 --- a/src/LibRed/docs/design/transactions.md +++ b/src/LibRed/docs/design/transactions.md @@ -14,7 +14,15 @@ Status: **draft / accepted direction** · Date: 2026-07-18 > writer-serialization / reader-blocking of strict 2PL (§4) is not needed for isolation, and EF's parallel > shared-store tests (each mutating inside a rolled-back transaction) no longer leak into concurrent readers. > The exclusive page lock is now held only for the duration of an individual committed page write, not the -> whole transaction. The undo log described below is gone. Everything else here — the lock-manager layering +> whole transaction. Before publishing, commit compares every page's committed plaintext image with the image +> from which that overlay page was first derived, under a per-file publish gate. Writers touching disjoint pages +> may both commit; if another connection changed an overlapping data/index/TDEF/usage-map page, the stale commit +> fails with a write conflict and remains open for rollback. This prevents silent lost updates without strict +> two-phase locking. If publication itself fails after writing some pages, that prefix is restored from the +> validated baselines (and appended tail pages are truncated), leaving the transaction open and rollbackable. +> Schema-changing commits also advance a shared per-file catalog generation; other open +> connections invalidate their parsed table/relationship/view caches on the next catalog access, while ordinary +> DML does not force a catalog reload. The undo log described below is gone. Everything else here — the lock-manager layering > (L0), the ACE co-residency constraint (§2), commit-byte / cross-process protocol, cascade worklist — still > stands as the roadmap. See `TransactionIsolationTests` and [[libred-parallel-dirty-read-flakiness]]. @@ -216,9 +224,8 @@ Build correctness first with lock seams stubbed; drop the Jet lock manager in la the process-local monitor implementation. 5. **L4 ADO enforcement.** ✅ done. Wire `LibRedTransaction`/`LibRedCommand` to L2; reject stale/foreign transactions; EF savepoint support (`SupportsSavepoints`). -6. **SQL transaction-control statements (with nesting).** Add engine-native `BEGIN`/`COMMIT`/ - `ROLLBACK [TRANSACTION|WORK]` (and Access's `BEGIN TRANS`), plus named `SAVE`/`ROLLBACK - TRANSACTION `. Parse to AST → a new `QueryEngine.Route` branch that drives a +6. **SQL transaction-control statements (with nesting).** ✅ done. Engine-native `BEGIN`/`COMMIT`/ + `ROLLBACK [TRANSACTION|WORK]`. Parse to AST → a new `QueryEngine.Route` branch that drives a per-connection **transaction controller** (the §4 depth counter) on the *same* L2 as the ADO front door — so a SQL `BEGIN` and an ADO `BeginTransaction` can't open parallel transactions, and the controller is the single source of `InTransaction`. Two must-haves: @@ -243,9 +250,14 @@ don't move. ## 7. Open questions -- **Reader isolation while a writer commits:** do readers under shared locks see the - pre-commit page (blocked until release) or is a dirty-read window acceptable initially? - Proposed: block (strict 2PL) — simplest correct default. +- **Reader isolation while a writer commits:** ✅ every statement holds the shared cache's publication gate for + its complete duration, so a reader sees either the complete pre-commit or complete post-commit page set, never + a torn mixture while an overlay is being published. The gate is a **reader/writer** lock, not a mutex: a + statement that cannot write (`SELECT`, a set operation, a system-variable select) takes it **shared**, so + concurrent readers on one file still run together; everything else takes it **exclusive** for the whole + statement, which is what makes a multi-page write atomic to readers. Anything not provably read-only takes the + exclusive scope — the shared scope cannot be upgraded and says so rather than deadlocking. Parsing happens + before the scope is taken (it touches no pages). See `ReaderWriterIsolationTests`. - **Implicit-txn cost:** per-statement begin/commit must be cheap for read-only statements (no dirty pages ⇒ commit is just lock release). Ensure a read-only statement never touches the commit-byte. diff --git a/src/LibRed/docs/format/page-00-database.md b/src/LibRed/docs/format/page-00-database.md index 21686be8..350a21bd 100644 --- a/src/LibRed/docs/format/page-00-database.md +++ b/src/LibRed/docs/format/page-00-database.md @@ -133,7 +133,11 @@ CF 65 ED FF 07 C7 46 A1 78 16 0C ED E9 2D 62 D4 ; 0x88 `RemoveJetPassword`): write `UTF-16LE(password)` zero-padded to 40 bytes, XOR the date mask, then the base header mask — the exact inverse of the read. **Verified byte-identical to Access's own output**: `SetJetPassword` on a copy of `2002plain.mdb` reproduces Access-set `Test1`/`Test2`/`AAAA`/`z` files - bit-for-bit in the `0x42` field (`LegacyJetPasswordTests`). This is password-only obfuscation — the + bit-for-bit in the `0x42` field (`LegacyJetPasswordTests.SetJetPassword_matches_access_output`). Those + fixtures are not committed, so that case **skips with a reason** unless they are present — point + `LIBRED_ENCTEST_DIR` at them to run it. The rest of `LegacyJetPasswordTests` builds its own Jet 4 header and + covers the field transformation, limits, removal, and encoding independence on every platform. This is + password-only obfuscation — the data pages stay plaintext (`0x3E` key = 0); it is a *different* feature from Jet RC4 page encryption (§2.4), which the "Encode/Encrypt" menu applies. The earlier "per-file SID mask = f(date,password)" theory was a misdiagnosis — the mask is simply `(int)creationDate`. @@ -308,10 +312,11 @@ algorithm is used for `baseHash`, the per-block `H`, and the AES `0x36`/`0x5C` e - cipher: RC4 (re-keyed per page; the verifier + verifier-hash decrypt as one continuous stream) or **AES-ECB**. The applicable `(key length, RC4 pad, AES iteration count)` is decided by whichever authenticates the verifier. -Fixture-free known-answer tests (real salt + verifier vectors, synthetic page 0) in `OfficeStandardEncryptionTests`; -real-fixture variant sweep in `OfficeStandardVariantReadTests` covering **RC4 and AES-128/192/256 × MD5/SHA-1/ -SHA-256/SHA-384/SHA-512 × `KeySize=0`** (all Access-tool re-encryptions of `db2007-oldenc`), plus clean-rejection -cases (3DES/DES/RC2 ciphers, MD2 hash). +Fixture-free known-answer tests (real salt + verifier vectors, synthetic page 0) live in +`OfficeStandardEncryptionTests`; `DatabaseEncryptionTests` exercise generated RC4 key/hash variants end-to-end, +and `OfficeStandardVariantReadTests` mutate generated descriptors to verify clean rejection of unsupported +ciphers and hashes. The broader **RC4 and AES-128/192/256 × MD5/SHA-1/SHA-256/SHA-384/SHA-512 × +`KeySize=0`** sweep was verified against Access-tool re-encryptions of `db2007-oldenc` during format research. > **LibRed reads more than Access opens.** Verified on EverythingAccess-re-encrypted `db2007-oldenc` variants: > **AES-128/AES-256 with MD5 or SHA-512 hashing** authenticate and decode correctly in LibRed, but **Access refuses @@ -354,8 +359,9 @@ nonzero, parses `len` bytes of `EncryptionInfo` at `0x29B`; if **zero it treats with a nonzero `0x3E` key and a valid descriptor present. Verified across `db-nonstandard`/`db2007-oldenc`/ `db2013` (each length equals its exact blob size: 224 / 190 / 1055) and by experiment: a file with the key + descriptor but `len@0x299 = 0` makes Access read ciphertext as plaintext and offer to "recover"; writing the -length makes it prompt for the password and open. LibRed's *reader* ignores this (it scans for the descriptor), -but a *writer* must set it. The Agile XML descriptor uses the same `len@0x299` + blob-at-`0x29B` framing. +length makes it prompt for the password and open. LibRed likewise treats the length as authoritative: binary or +XML content outside the declared frame is ignored, and a frame extending beyond page 0 is rejected as malformed. +The Agile XML descriptor uses the same `len@0x299` + blob-at-`0x29B` framing. > **Creating encryption from scratch (implemented — Office Standard).** `LibRed.Crypto.DatabaseEncryption` > (`SetPassword`/`RemovePassword`/`ChangePassword`, scheme via `AccessEncryption`) encrypts a plaintext `.accdb` 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 17434afe..083d3488 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -34,8 +34,9 @@ Each entry ends with a **4-byte big-endian** trailing pointer: > **Reader/traversal guardrails.** LibRed validates every page number before I/O, requires page type > `0x03`/`0x04` and a consistent owning TDEF, bounds every bitmask-derived entry before reading its -> 4-byte trailer, and requires the compressed prefix to fit the first key. Node child/tail and leaf -> prev/next pointers must be zero where permitted or name an in-file page. Point/range seeks track every +> 4-byte trailer, and requires the compressed prefix to fit the first key. Node child/tail, leaf +> previous/next, and indexed-row page pointers are checked against the file's page range; optional leaf +> links must be zero or name an in-file page. Point/range seeks track every > descent and leaf-chain page and reject repeats; the full index cursor uses an iterative ordered walk > with the same repeated-page check, avoiding recursive stack exhaustion. A followed leaf link must > resolve to another leaf of the same owner. Insert, delete, split propagation, and leaf-link mutation @@ -171,10 +172,13 @@ Then the value, transformed: Verified against ACE (`CREATE INDEX … (K DESC)`), e.g. `00000000-…-0` → `80 FFFFFFFFFFFFFFFF 09 FFFFFFFFFFFFFFFF F7`. No trailing `0x00` (unlike descending text keys). - **Binary (general):** the start flag (`0x7F` asc / `0x80` desc), then the raw bytes in **8-byte - chunks**. Each chunk is 8 bytes — real bytes left-aligned, **zero-padded on the right** — followed by + chunks**. A genuinely **zero-length Binary value** is the start flag alone (`7F` ascending / `80` + descending). A non-empty value—including an all-zero value—uses normal chunks. Each chunk is + 8 bytes — real bytes left-aligned, + **zero-padded on the right** — followed by a **control byte**: `0x09` when a further chunk follows (a full 8-byte chunk with more data to come), - otherwise the **real-byte count of this final chunk** (`0x01…0x08`; `0x08` for a full final chunk, - `0x00` for empty data). The count `≤ 8 < 0x09`, so control values never collide. This is exactly the + otherwise the **real-byte count of this final chunk** (`0x01…0x08`; `0x08` for a full final chunk). + The count `≤ 8 < 0x09`, so control values never collide. This is exactly the GUID chunking generalised to any length: a 16-byte value is two chunks (`… 09 … 08`), and the old fixed 4-byte MSysQueries.Order case is the single-chunk form `7F <4B> 00000000 04`. The trailing length-terminator makes shorter values sort before longer ones that share a prefix (correct binary diff --git a/src/LibRed/docs/format/system-catalog.md b/src/LibRed/docs/format/system-catalog.md index 789c1a23..7a9f42fa 100644 --- a/src/LibRed/docs/format/system-catalog.md +++ b/src/LibRed/docs/format/system-catalog.md @@ -260,6 +260,8 @@ > > **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`. > - **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). > - **Append** (INSERT): MSysObjects `Flags=0x10000040`; a `0x01` row with `Flag 3` and `Name1` = the diff --git a/src/LibRed/docs/functions.md b/src/LibRed/docs/functions.md index 2c34e940..4db2352a 100644 --- a/src/LibRed/docs/functions.md +++ b/src/LibRed/docs/functions.md @@ -22,6 +22,10 @@ chronologically: below the 1899-12-30 epoch the day count goes negative while th positive, so 1899-12-29 06:00 is -1.25 and 18:00 is -1.75 and ACE orders the later time first. LibRed matches that, because `IndexKeyEncoder` writes the same serial as the index key and the two paths must agree (see `AcePreEpochDateProbeTest`). Date *functions* are unaffected — they work in date space. +Function argument counts are validated against ACE's JES, including optional arguments and Jet quirks: +`IIf` accepts two or three arguments (an omitted false branch yields NULL), `Choose` needs an index and at +least one choice, and `Switch` needs at least one complete condition/value pair. Aggregate calls are checked +by the same contract instead of bypassing scalar validation. > **Two expression services — what "ACE has it" means.** Access has (1) the **Jet/ACE OLE DB Expression > Service (JES)**, the built-in set the ACE OLE DB provider carries **standalone**, and (2) the **Access @@ -141,8 +145,6 @@ NULL (`Count` returns 0). `Environ`, `Randomize`, and the domain aggregates. `Split` also returns a Variant array (no scalar-SQL representation). - **No scalar-SQL form:** `IRR` / `NPV` (array argument); `Array` / `Join` / `CVErr` (VBA-only). -- **Argument arity is not validated** — LibRed reads the arguments it needs and ignores extras, where ACE - errors "Wrong number of arguments". A cross-cutting lenience, not per-function. See [page-02c-default-values.md](format/page-02c-default-values.md) for how these functions behave specifically in a column `DEFAULT` (the DDL-parser-vs-expression-service split, and the forbidden categories). diff --git a/test/LibRed.Ado.Tests/LibRed.Ado.Tests.csproj b/test/LibRed.Ado.Tests/LibRed.Ado.Tests.csproj index 5d38913b..7bbce0bd 100644 --- a/test/LibRed.Ado.Tests/LibRed.Ado.Tests.csproj +++ b/test/LibRed.Ado.Tests/LibRed.Ado.Tests.csproj @@ -27,6 +27,10 @@ + + + + diff --git a/test/LibRed.Ado.Tests/TemporalParameterTests.cs b/test/LibRed.Ado.Tests/TemporalParameterTests.cs index a35974cd..8e3b91a5 100644 --- a/test/LibRed.Ado.Tests/TemporalParameterTests.cs +++ b/test/LibRed.Ado.Tests/TemporalParameterTests.cs @@ -11,8 +11,10 @@ public class TemporalParameterTests { private static LibRedConnection OpenTemp() { - string path = Path.Combine(Path.GetTempPath(), $"tpar-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + // Tracked, so the copy is swept at process exit: this helper returns only the connection, so the + // caller has no path to delete in a finally. + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "tpar-"); var conn = new LibRedConnection($"Data Source={path}"); conn.Open(); return conn; diff --git a/test/LibRed.Core.Tests/AccentCollationAccessTests.cs b/test/LibRed.Core.Tests/AccentCollationAccessTests.cs index f26680c8..2389ceaf 100644 --- a/test/LibRed.Core.Tests/AccentCollationAccessTests.cs +++ b/test/LibRed.Core.Tests/AccentCollationAccessTests.cs @@ -12,23 +12,12 @@ namespace LibRed.Core.Tests; /// public class AccentCollationAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_finds_an_accented_city_through_the_index() { - string path = Path.Combine(Path.GetTempPath(), $"accent-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "accent-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -61,6 +50,6 @@ public void Access_finds_an_accented_city_through_the_index() using (var r = ordered.ExecuteReader()) while (r.Read()) { _ = r[0]; } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AceAlterColumnTests.cs b/test/LibRed.Core.Tests/AceAlterColumnTests.cs index b9c7af35..7855d74d 100644 --- a/test/LibRed.Core.Tests/AceAlterColumnTests.cs +++ b/test/LibRed.Core.Tests/AceAlterColumnTests.cs @@ -9,23 +9,12 @@ namespace LibRed.Core.Tests; // length and enforces it — a value that fits the new max is accepted, one past it is rejected. public class AceAlterColumnTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_reads_and_enforces_a_libred_widened_text_column() { - string path = Path.Combine(Path.GetTempPath(), $"alc-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "alc-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -48,14 +37,13 @@ [new ColumnSpec("K", JetDataType.Int32, 4, IsFixedLength: true), string? v; using (var c = conn.CreateCommand()) { c.CommandText = "SELECT V FROM T WHERE K = 1"; v = (string?)c.ExecuteScalar(); } Assert.Equal(40, v!.Length); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_applies_a_libred_alter_column_default() { - string path = Path.Combine(Path.GetTempPath(), $"acd-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "acd-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -72,14 +60,13 @@ [new ColumnSpec("K", JetDataType.Int32, 4, IsFixedLength: true), string? v; using (var c = conn.CreateCommand()) { c.CommandText = "SELECT V FROM T WHERE K = 1"; v = (string?)c.ExecuteScalar(); } Assert.Equal("unknown", v); // ACE applied the LibRed-written default } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_sees_a_libred_dropped_default_gone_but_keeps_not_null() { - string path = Path.Combine(Path.GetTempPath(), $"add-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "add-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -102,7 +89,7 @@ [new ColumnSpec("K", JetDataType.Int32, 4, IsFixedLength: true), int n; using (var c = conn.CreateCommand()) { c.CommandText = "SELECT N FROM T WHERE K = 2"; n = Convert.ToInt32(c.ExecuteScalar()); } Assert.Equal(8, n); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Byte-faithful: ACE recognises a LibRed-written GenGUID() default on a GUID column and applies it on its @@ -110,8 +97,7 @@ [new ColumnSpec("K", JetDataType.Int32, 4, IsFixedLength: true), [Fact] public void Access_applies_a_libred_written_genguid_default() { - string path = Path.Combine(Path.GetTempPath(), $"gg-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "gg-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -138,14 +124,13 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), Assert.All(guids, g => Assert.NotEqual(Guid.Empty, g)); Assert.NotEqual(guids[0], guids[1]); // ACE generated a fresh Guid per row } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_enforces_a_libred_alter_column_made_required() { - string path = Path.Combine(Path.GetTempPath(), $"areq-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "areq-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -175,14 +160,13 @@ [new ColumnSpec("K", JetDataType.Int32, 4, IsFixedLength: true), c.ExecuteNonQuery(); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_enforces_the_relationship_after_a_libred_parent_side_rewrite() { - string path = Path.Combine(Path.GetTempPath(), $"prw-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "prw-"); try { using (var conn = OpenOleDb(path)) @@ -208,7 +192,7 @@ public void Access_enforces_the_relationship_after_a_libred_parent_side_rewrite( using (var ok = conn.CreateCommand()) { ok.CommandText = "INSERT INTO C (CID, PID) VALUES (21, 1)"; ok.ExecuteNonQuery(); } } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Faithful round-trip: changing ONE column's type must not disturb another column's on-disk descriptor. @@ -218,8 +202,7 @@ public void Access_enforces_the_relationship_after_a_libred_parent_side_rewrite( [Fact] public void Libred_type_change_preserves_an_untouched_columns_descriptor_bytes() { - string path = Path.Combine(Path.GetTempPath(), $"pre-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "pre-"); try { using (var conn = OpenOleDb(path)) @@ -249,14 +232,13 @@ public void Libred_type_change_preserves_an_untouched_columns_descriptor_bytes() q.CommandText = "SELECT C FROM T"; Assert.Equal("hi", (string?)q.ExecuteScalar()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_reads_a_libred_full_rewrite_with_converted_values() { - string path = Path.Combine(Path.GetTempPath(), $"rw-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "rw-"); try { // ACE creates + populates a LONG column. @@ -284,6 +266,6 @@ public void Access_reads_a_libred_full_rewrite_with_converted_values() Assert.Equal(new[] { 42.0, 7.0, 3.5 }, vals); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AceAlterConstraintTests.cs b/test/LibRed.Core.Tests/AceAlterConstraintTests.cs index b0dfc7c3..adbb498c 100644 --- a/test/LibRed.Core.Tests/AceAlterConstraintTests.cs +++ b/test/LibRed.Core.Tests/AceAlterConstraintTests.cs @@ -10,23 +10,12 @@ namespace LibRed.Core.Tests; // structures — answering "does AddUnique write it the way ACE does": ACE accepts and enforces it. public class AceAlterConstraintTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_enforces_a_libred_added_check() { - string path = Path.Combine(Path.GetTempPath(), $"chk-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "chk-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -44,14 +33,13 @@ [new ColumnSpec("ID", JetDataType.Int32, 4, IsFixedLength: true), bad.CommandText = "INSERT INTO tblInvoices (ID, Amount) VALUES (2, -5)"; Assert.ThrowsAny(() => bad.ExecuteNonQuery()); // ACE rejects the check violation } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_stops_enforcing_a_check_libred_dropped() { - string path = Path.Combine(Path.GetTempPath(), $"dchk-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "dchk-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -74,14 +62,13 @@ [new ColumnSpec("ID", JetDataType.Int32, 4, IsFixedLength: true), read.CommandText = "SELECT Amount FROM tblInvoices WHERE ID = 1"; Assert.Equal(-5.0, Convert.ToDouble(read.ExecuteScalar())); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_enforces_a_libred_added_unique_constraint() { - string path = Path.Combine(Path.GetTempPath(), $"uq-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "uq-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -103,6 +90,6 @@ [new ColumnSpec("CustomerID", JetDataType.Int32, 4, IsFixedLength: true), dup.CommandText = "INSERT INTO tblCustomers (CustomerID, LastName, FirstName) VALUES (3, 'Smith', 'John')"; Assert.ThrowsAny(() => dup.ExecuteNonQuery()); // ACE rejects the duplicate composite } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AceAutoNumberOverflowProbeTest.cs b/test/LibRed.Core.Tests/AceAutoNumberOverflowRegressionTests.cs similarity index 84% rename from test/LibRed.Core.Tests/AceAutoNumberOverflowProbeTest.cs rename to test/LibRed.Core.Tests/AceAutoNumberOverflowRegressionTests.cs index 31323b0f..58694214 100644 --- a/test/LibRed.Core.Tests/AceAutoNumberOverflowProbeTest.cs +++ b/test/LibRed.Core.Tests/AceAutoNumberOverflowRegressionTests.cs @@ -5,8 +5,8 @@ namespace LibRed.Core.Tests; -// PROBE (not an assertion of desired LibRed behaviour): what do ACE and LibRed do when a sequential -// AutoNumber (COUNTER) runs off the end of the signed Int32 range? +// What ACE and LibRed do when a sequential AutoNumber (COUNTER) runs off the end of the signed Int32 range. +// Grown from a probe: the ACE cases are ground truth, and the LibRed cases pin the engine to it. // // The counter's on-disk state is a single Int32 — the TDEF high-water at 0x14 — and the next id is // high-water + increment (0x18). Nothing in the format reserves a "counter exhausted" state, so the @@ -17,22 +17,12 @@ namespace LibRed.Core.Tests; // 3. Is a descending counter (negative increment) symmetric at int.MinValue? // 4. Does an *explicit* insert of int.MaxValue poison a plain COUNTER the same way? // -// Every probe logs what actually happened (the id assigned, or the engine's own error text) plus the -// resulting 0x14 high-water read back through LibRed's catalog, so ACE's and LibRed's behaviour sit -// side by side. Assertions are deliberately minimal — they pin only what has been observed. -public class AceAutoNumberOverflowProbeTest(ITestOutputHelper output) +// Every case logs what happened (the id assigned, or the engine's own error text) plus the resulting 0x14 +// high-water read back through LibRed's catalog, so ACE's and LibRed's behaviour sit side by side in the +// output — and then asserts it. Nothing here is asserted that was not first observed against ACE. +public class AceAutoNumberOverflowRegressionTests(ITestOutputHelper output) { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); /// The TDEF AutoNumber high-water (0x14) — the last id handed out. ColumnDef.Seed is the *next* id. private static string HighWater(string path) @@ -48,8 +38,7 @@ private static string HighWater(string path) private static string NewDb(string tag) { - string path = Path.Combine(Path.GetTempPath(), $"{tag}-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, tag); return path; } @@ -75,7 +64,7 @@ private static string NewDb(string tag) [InlineData("ace-max", 2147483646, 1, new[] { 2147483646, 2147483647, -2147483648, -2147483647 })] // Descending counter parked two above int.MinValue: mirror image — wraps to int.MaxValue. [InlineData("ace-min", -2147483647, -1, new[] { -2147483647, -2147483648, 2147483647, 2147483646 })] - public void Probe_ace_counter_at_the_int32_boundary(string tag, int seed, int increment, int[] expectedIds) + public void Ace_counter_wraps_at_the_int32_boundary(string tag, int seed, int increment, int[] expectedIds) { string path = NewDb(tag); try @@ -90,8 +79,7 @@ public void Probe_ace_counter_at_the_int32_boundary(string tag, int seed, int in } catch (OleDbException ex) { - output.WriteLine($" ACE rejected the DDL: {ex.Message.Trim()}"); - return; + Assert.Fail($"ACE rejected the boundary COUNTER DDL: {ex.Message.Trim()}"); } var ids = new List(); @@ -113,11 +101,11 @@ public void Probe_ace_counter_at_the_int32_boundary(string tag, int seed, int in Assert.Equal(expectedIds.Cast(), ids); Assert.Equal(expectedIds[^1].ToString(), HighWater(path)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] - public void Probe_ace_explicit_max_value_into_a_plain_counter() + public void Ace_counter_wraps_after_an_explicit_max_value() { string path = NewDb("ace-explicit"); try @@ -129,7 +117,7 @@ void Step(Action act, string label) { using (var conn = OpenOleDb(path)) try { act(conn); } - catch (OleDbException ex) { output.WriteLine($" {label} -> "); } + catch (OleDbException ex) { Assert.Fail($"ACE step '{label}' failed: {ex.Message.Trim()}"); } output.WriteLine($" high-water (0x14) on disk now {HighWater(path)}"); } @@ -138,12 +126,20 @@ void Step(Action act, string label) Step(c => { using var x = c.CreateCommand(); x.CommandText = "INSERT INTO T (Id, V) VALUES (2147483647, 'max')"; x.ExecuteNonQuery(); output.WriteLine(" explicit 2147483647 accepted"); }, "explicit 2147483647"); Step(c => AceInsert(c, "next"), "auto insert 'next'"); Step(c => AceInsert(c, "next2"), "auto insert 'next2'"); + + using var verify = OpenOleDb(path); + using var query = verify.CreateCommand(); + query.CommandText = "SELECT Id FROM T ORDER BY V"; + using var reader = query.ExecuteReader(); + var ids = new List(); + while (reader.Read()) ids.Add(Convert.ToInt32(reader[0])); + Assert.Equal([1, int.MaxValue, int.MinValue, int.MinValue + 1], ids); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] - public void Probe_ace_seed_at_int32_max_and_a_wrapped_id_that_collides() + public void Ace_counter_advances_past_a_colliding_wrapped_id() { string path = NewDb("ace-collide"); try @@ -152,7 +148,7 @@ public void Probe_ace_seed_at_int32_max_and_a_wrapped_id_that_collides() using var conn = OpenOleDb(path); void Exec(string sql) { using var c = conn.CreateCommand(); c.CommandText = sql; c.ExecuteNonQuery(); } try { Exec("CREATE TABLE T (Id COUNTER(2147483647, 1) CONSTRAINT PK PRIMARY KEY, V TEXT(5))"); } - catch (OleDbException ex) { output.WriteLine($" ACE rejected the DDL: {ex.Message.Trim()}"); return; } + catch (OleDbException ex) { Assert.Fail($"ACE rejected the collision COUNTER DDL: {ex.Message.Trim()}"); } // Park a row on the id the counter will wrap onto, so the wrap lands on an occupied key. The // explicit insert drops the counter onto that value (KB 884185 last-inserted rule), so reseed @@ -184,7 +180,7 @@ public void Probe_ace_seed_at_int32_max_and_a_wrapped_id_that_collides() Assert.Null(b); Assert.Equal(int.MinValue + 1, c); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // -------------------------------------------------------------- LibRed --------------------------------------- @@ -192,7 +188,7 @@ public void Probe_ace_seed_at_int32_max_and_a_wrapped_id_that_collides() [Theory] [InlineData("lib-max", 2147483646, 1, new[] { 2147483646, 2147483647, -2147483648, -2147483647 })] [InlineData("lib-min", -2147483647, -1, new[] { -2147483647, -2147483648, 2147483647, 2147483646 })] - public void Probe_libred_counter_at_the_int32_boundary(string tag, int seed, int increment, int[] expectedIds) + public void Libred_counter_matches_ace_at_the_int32_boundary(string tag, int seed, int increment, int[] expectedIds) { string path = NewDb(tag); try @@ -243,11 +239,11 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true, IsAutoNumber: t Assert.Equal(unchecked(expectedIds[^1] + increment), AceInsert(conn, "e")); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] - public void Probe_libred_explicit_max_value_into_a_plain_counter() + public void Libred_counter_matches_ace_after_an_explicit_max_value() { string path = NewDb("lib-explicit"); try @@ -289,6 +285,6 @@ void Report(string what, Action act) // the wrapped continuation rather than a repeated int.MinValue. Assert.Equal([1, int.MaxValue, int.MinValue, int.MinValue + 1], ids); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AceChooseDefaultTests.cs b/test/LibRed.Core.Tests/AceChooseDefaultTests.cs index e767d39a..1525e7fc 100644 --- a/test/LibRed.Core.Tests/AceChooseDefaultTests.cs +++ b/test/LibRed.Core.Tests/AceChooseDefaultTests.cs @@ -10,17 +10,7 @@ namespace LibRed.Core.Tests; // ACE's OLE DB DDL parser rejects at CREATE but its expression service applies at insert. public class AceChooseDefaultTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Theory] [InlineData("Choose(1, 0, 1, 2)", false)] // 1st choice = 0 = False @@ -28,8 +18,7 @@ private static OleDbConnection OpenOleDb(string path) [InlineData("CBool(Choose(1, 0, 1, 2))", false)] // nested — DDL-parser-rejected, expression-service-applied public void Access_reads_and_applies_a_libred_written_choose_default(string def, bool expected) { - string path = Path.Combine(Path.GetTempPath(), $"ch-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "ch-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -44,6 +33,6 @@ [new ColumnSpec("K", JetDataType.Int32, 4, IsFixedLength: true), Assert.Equal(expected, Convert.ToBoolean(v)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AceCompoundDefaultTests.cs b/test/LibRed.Core.Tests/AceCompoundDefaultTests.cs index fc619cbb..a5d75d96 100644 --- a/test/LibRed.Core.Tests/AceCompoundDefaultTests.cs +++ b/test/LibRed.Core.Tests/AceCompoundDefaultTests.cs @@ -13,17 +13,7 @@ namespace LibRed.Core.Tests; // read and applied by ACE on insert. Verifies LibRed's SQL surface is a superset of ACE's DDL here. public class AceCompoundDefaultTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Theory] [InlineData("TEXT", "\"INV-\" & Year(Now())", "INV-2026")] // double-quoted string, & concat, nested call @@ -31,8 +21,7 @@ private static OleDbConnection OpenOleDb(string path) [InlineData("LONG", "Year(Now())", "2026")] // nested function call (ACE's DDL parser rejects) public void Access_reads_and_applies_a_libred_written_compound_default(string type, string def, string expected) { - string path = Path.Combine(Path.GetTempPath(), $"cx-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "cx-"); try { ColumnSpec v = type == "TEXT" @@ -54,6 +43,6 @@ [new ColumnSpec("K", JetDataType.Int32, 4, IsFixedLength: true), v], string want = expected.Replace("2026", DateTime.Now.Year.ToString()); Assert.Equal(want, Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AceCreatedDatabaseTests.cs b/test/LibRed.Core.Tests/AceCreatedDatabaseTests.cs index 7b69f745..f4c02a12 100644 --- a/test/LibRed.Core.Tests/AceCreatedDatabaseTests.cs +++ b/test/LibRed.Core.Tests/AceCreatedDatabaseTests.cs @@ -9,22 +9,12 @@ namespace LibRed.Core.Tests; /// A database LibRed creates from scratch (no DAO/ADOX) is opened, queried, and written by real Access. public class AceCreatedDatabaseTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no ACE OLE DB provider available", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Real_Access_opens_and_reads_a_libred_created_database() { - string path = Path.Combine(Path.GetTempPath(), $"libred-created-{Guid.NewGuid():N}.accdb"); + string path = TemporaryDatabase.CreatePath("libred-created-"); try { // Create the database, a user table, and a row entirely through LibRed — no Access, no DAO/ADOX. @@ -52,6 +42,6 @@ public void Real_Access_opens_and_reads_a_libred_created_database() using (var sel = conn.CreateCommand()) { sel.CommandText = "SELECT [Name] FROM [People] WHERE [Id]=2"; Assert.Equal("Alan", sel.ExecuteScalar()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AceDefaultExpressionLimitsTests.cs b/test/LibRed.Core.Tests/AceDefaultExpressionLimitsTests.cs index 75b4ce04..44ee9e76 100644 --- a/test/LibRed.Core.Tests/AceDefaultExpressionLimitsTests.cs +++ b/test/LibRed.Core.Tests/AceDefaultExpressionLimitsTests.cs @@ -14,17 +14,7 @@ namespace LibRed.Core.Tests; // default expression. LibRed writes such defaults to LvProp and they round-trip. public class AceDefaultExpressionLimitsTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); // SQL aggregates (Sum, Count) AND the domain aggregate DCount are all rejected in a default — the default // expression evaluator has a restricted function whitelist. Smuggled via LibRed so the expression reaches @@ -35,8 +25,7 @@ private static OleDbConnection OpenOleDb(string path) [InlineData("DCount('*','MSysObjects')")] public void Access_rejects_an_aggregate_function_in_a_default(string def) { - string path = Path.Combine(Path.GetTempPath(), $"agg-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "agg-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -52,7 +41,7 @@ [new ColumnSpec("K", JetDataType.Int32, 4, IsFixedLength: true), Assert.Contains("Unknown function", ex.Message); Assert.Contains("default value", ex.Message); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // The DAO 255-char DefaultValue cap is an API limit, not an engine one: a 300-char string-literal default is @@ -61,8 +50,7 @@ [new ColumnSpec("K", JetDataType.Int32, 4, IsFixedLength: true), [Fact] public void Access_accepts_a_default_expression_longer_than_255_chars() { - string path = Path.Combine(Path.GetTempPath(), $"len-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "len-"); try { using var conn = OpenOleDb(path); @@ -71,6 +59,6 @@ public void Access_accepts_a_default_expression_longer_than_255_chars() object? v; using (var c = conn.CreateCommand()) { c.CommandText = "SELECT V FROM T"; v = c.ExecuteScalar(); } Assert.Equal(300, (v as string)?.Length); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AceNullArgumentProbeTest.cs b/test/LibRed.Core.Tests/AceNullArgumentProbeTest.cs index e685ae22..4e6831fa 100644 --- a/test/LibRed.Core.Tests/AceNullArgumentProbeTest.cs +++ b/test/LibRed.Core.Tests/AceNullArgumentProbeTest.cs @@ -12,27 +12,16 @@ namespace LibRed.Core.Tests; // guard came along for free. EF 11 elides the redundant conversion, so MID now receives a NULL length directly // and ACE errors (the GearsOfWar Null_semantics_..._optional_navigation_complex failures). Guarding has to move // to the functions themselves, so this establishes which arguments actually need it. -public class AceNullArgumentProbeTest(ITestOutputHelper output) +public class AceNullArgumentRegressionTests(ITestOutputHelper output) { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static void Exec(OleDbConnection c, string sql) { using var cmd = c.CreateCommand(); cmd.CommandText = sql; cmd.ExecuteNonQuery(); } [Fact] - public void Probe_which_vba_arguments_reject_null() + public void Vba_numeric_arguments_reject_null_while_value_arguments_propagate_it() { - string path = Path.Combine(Path.GetTempPath(), $"acenull-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "acenull-"); try { using var conn = OpenOleDb(path); @@ -116,7 +105,28 @@ void Try(string label, string expr) // The guard, and the proof that IIF short-circuits: without it the same MID raises. Assert.Null(Raises("IIF(`I` IS NULL, NULL, MID('abc', 1, `I`))")); + + string[] propagates = + [ + "MID(`S`, 1, 2)", "LEN(`S`)", "INSTR(`S`, 'a')", "INSTR('abc', `S`)", + "UCASE(`S`)", "TRIM(`S`)", "ABS(`I`)", "ROUND(`I`, 2)", + "DATEDIFF('d', `S`, #01/01/2000#)", "IIF(`S`, 1, 2)", + "IIF(`I` IS NULL, NULL, MID('abc', 1, `I`))", + "IIF(`I` IS NULL, NULL, MID('abc', 1, IIF(`I` IS NULL, 0, `I`)))", + ]; + string[] rejectsNullNumericArgument = + [ + "MID('abc', 1, `I`)", "MID('abc', `I`, 2)", "MID('abc', 1, LEN(`S`))", + "LEFT('abc', `I`)", "RIGHT('abc', `I`)", "INSTR(`I`, 'abc', 'a')", + "STRING(`I`, 'a')", "SPACE(`I`)", "CHR(`I`)", + "DATEADD('d', `I`, #01/01/2000#)", + ]; + + foreach (string expression in propagates) + Assert.Null(Raises(expression)); + foreach (string expression in rejectsNullNumericArgument) + Assert.NotNull(Raises(expression)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AcePreEpochDateProbeTest.cs b/test/LibRed.Core.Tests/AcePreEpochDateProbeTest.cs index 389990a7..f387b287 100644 --- a/test/LibRed.Core.Tests/AcePreEpochDateProbeTest.cs +++ b/test/LibRed.Core.Tests/AcePreEpochDateProbeTest.cs @@ -17,19 +17,9 @@ namespace LibRed.Core.Tests; // ACE may well have inherited the same weirdness, in which case LibRed is already bug-compatible and should stay // that way. This probe establishes which it is. The existing DateAdd/DateDiff functional tests do not cover it: // they all use modern (Northwind-era) dates, where the serial is positive and the anomaly cannot appear. -public class AcePreEpochDateProbeTest(ITestOutputHelper output) +public class AcePreEpochDateRegressionTests(ITestOutputHelper output) { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static void Exec(OleDbConnection c, string sql) { using var cmd = c.CreateCommand(); cmd.CommandText = sql; cmd.ExecuteNonQuery(); } @@ -58,10 +48,9 @@ private void Report(OleDbConnection c, string label, string expr) } [Fact] - public void Probe_pre_epoch_dates() + public void Ace_uses_oa_serial_comparison_and_date_space_functions_before_the_epoch() { - string path = Path.Combine(Path.GetTempPath(), $"acepre-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "acepre-"); try { using var conn = OpenOleDb(path); @@ -134,6 +123,6 @@ public void Probe_pre_epoch_dates() Assert.Equal((short)0, Scalar(conn, "SELECT (#12/29/1899 06:00:00# < #12/29/1899 18:00:00#) FROM `P`")); Assert.Equal("1,3,2,4,5,6", order); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AceRenameTests.cs b/test/LibRed.Core.Tests/AceRenameTests.cs index 7fe4d40a..2573c767 100644 --- a/test/LibRed.Core.Tests/AceRenameTests.cs +++ b/test/LibRed.Core.Tests/AceRenameTests.cs @@ -13,17 +13,7 @@ namespace LibRed.Core.Tests; // relationship whose by-name references were repointed. public class AceRenameTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static void Exec(OleDbConnection conn, string sql) { @@ -42,8 +32,7 @@ private static void Exec(OleDbConnection conn, string sql) [Fact] public void Access_reads_a_libred_renamed_table_and_column_and_still_applies_the_default() { - string path = Path.Combine(Path.GetTempPath(), $"acerename-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "acerename-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -77,14 +66,13 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), stale.CommandText = "SELECT Title FROM DocumentArchive"; Assert.ThrowsAny(() => stale.ExecuteScalar()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_still_enforces_a_relationship_after_libred_renames_both_sides() { - string path = Path.Combine(Path.GetTempPath(), $"acerenamefk-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "acerenamefk-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -120,6 +108,6 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), Exec(conn, "INSERT INTO C (Id, OwnerId) VALUES (1, 1)"); Assert.Equal(1, Scalar(conn, "SELECT OwnerId FROM C WHERE Id = 1")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AceSingleDoubleCompareProbeTest.cs b/test/LibRed.Core.Tests/AceSingleDoubleCompareProbeTest.cs index 01a3eac9..b37a4027 100644 --- a/test/LibRed.Core.Tests/AceSingleDoubleCompareProbeTest.cs +++ b/test/LibRed.Core.Tests/AceSingleDoubleCompareProbeTest.cs @@ -9,32 +9,21 @@ namespace LibRed.Core.Tests; // "compare in single precision when a float is involved" rule matches ACE for the column-vs-column case, or // whether a column-type-aware fix is needed. Reports counts; the assertions just pin what we observed so a // future ACE change is noticed. -public class AceSingleDoubleCompareProbeTest(ITestOutputHelper output) +public class AceSingleDoubleCompareRegressionTests(ITestOutputHelper output) { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static void Exec(OleDbConnection c, string sql) { using var cmd = c.CreateCommand(); cmd.CommandText = sql; cmd.ExecuteNonQuery(); } private static int Count(OleDbConnection c, string sql) { using var cmd = c.CreateCommand(); cmd.CommandText = sql; return Convert.ToInt32(cmd.ExecuteScalar()); } [Fact] - public void Probe_single_vs_double_compare_precision() + public void Ace_compares_single_and_double_in_single_precision() { // 0.1 has different 4-byte (single) and 8-byte (double) approximations, so a SINGLE column and a DOUBLE // column both set to 0.1 hold genuinely different numbers — equal only if compared in single precision. string s = (0.1).ToString("R", CultureInfo.InvariantCulture); - string path = Path.Combine(Path.GetTempPath(), $"acesd-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "acesd-"); try { using var conn = OpenOleDb(path); @@ -57,6 +46,6 @@ public void Probe_single_vs_double_compare_precision() // ACE changed, or that LibRed should stop narrowing. See [[libred-single-precision-compare]]. Assert.Equal(1, colVsCol); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AceSmuggledColRefDefaultTests.cs b/test/LibRed.Core.Tests/AceSmuggledColRefDefaultTests.cs index 474413f3..7d725585 100644 --- a/test/LibRed.Core.Tests/AceSmuggledColRefDefaultTests.cs +++ b/test/LibRed.Core.Tests/AceSmuggledColRefDefaultTests.cs @@ -12,25 +12,14 @@ namespace LibRed.Core.Tests; // at create time. There is no way to smuggle a working column-ref default past the engine. public class AceSmuggledColRefDefaultTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Theory] [InlineData("[A] + 2", "does not recognize")] // engine names the field reference in the default [InlineData("A + 2", "Type mismatch")] // bare A parses as something else → type mismatch public void Access_opens_the_file_but_rejects_an_insert_using_a_smuggled_column_ref_default(string def, string expectedError) { - string path = Path.Combine(Path.GetTempPath(), $"smug-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "smug-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -60,6 +49,6 @@ public void Access_opens_the_file_but_rejects_an_insert_using_a_smuggled_column_ count.CommandText = "SELECT COUNT(*) FROM T"; Assert.Equal(0, Convert.ToInt32(count.ExecuteScalar())); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AceSwitchDefaultTests.cs b/test/LibRed.Core.Tests/AceSwitchDefaultTests.cs index c1dde5ba..c88a08c5 100644 --- a/test/LibRed.Core.Tests/AceSwitchDefaultTests.cs +++ b/test/LibRed.Core.Tests/AceSwitchDefaultTests.cs @@ -9,25 +9,14 @@ namespace LibRed.Core.Tests; // function), confirming LibRed's Switch matches ACE. public class AceSwitchDefaultTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Theory] [InlineData("Switch(1=1, 10, 1=2, 20)", 10)] [InlineData("Switch(1=2, 10, 1=1, 20)", 20)] public void Access_reads_and_applies_a_libred_written_switch_default(string def, int expected) { - string path = Path.Combine(Path.GetTempPath(), $"sw-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "sw-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -42,6 +31,6 @@ [new ColumnSpec("K", JetDataType.Int32, 4, IsFixedLength: true), Assert.Equal(expected, Convert.ToInt32(v)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AceVbaConversionProbeTest.cs b/test/LibRed.Core.Tests/AceVbaConversionProbeTest.cs index 0fae6f8e..57e23f6c 100644 --- a/test/LibRed.Core.Tests/AceVbaConversionProbeTest.cs +++ b/test/LibRed.Core.Tests/AceVbaConversionProbeTest.cs @@ -24,19 +24,9 @@ namespace LibRed.Core.Tests; // // Output is written to the test log; the assertions pin only what has actually been observed, so a future ACE // change (or a wrong assumption on our side) is noticed rather than silently absorbed. -public class AceVbaConversionProbeTest(ITestOutputHelper output) +public class AceVbaConversionRegressionTests(ITestOutputHelper output) { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static void Exec(OleDbConnection c, string sql) { using var cmd = c.CreateCommand(); cmd.CommandText = sql; cmd.ExecuteNonQuery(); } @@ -73,15 +63,14 @@ private void Report(OleDbConnection c, string label, string expr) } [Fact] - public void Probe_vba_conversion_functions() + public void Ace_vba_conversion_values_and_provider_types_are_pinned() { // 58.6 as a double is really 58.600000000000001421085471520..., so CDec either rounds it back to 58.6 // (OA's 15-digit VarDecFromR8) or expands the exact binary value. This is the value that broke // Sum_over_round_works_correctly_in_projection. string d586 = (58.6).ToString("R", CultureInfo.InvariantCulture); - string path = Path.Combine(Path.GetTempPath(), $"acevba-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "acevba-"); try { using var conn = OpenOleDb(path); @@ -157,7 +146,18 @@ public void Probe_vba_conversion_functions() // FormatException, so LibRed rejects input ACE accepts. Assert.Equal((short)-1, Scalar(conn, "SELECT CBool('-1') FROM `P`")); Assert.Equal((short)-1, Scalar(conn, "SELECT CBool(0.5) FROM `P`")); + + // Pin the remaining values emitted by the diagnostic table as well; the output is explanatory, + // not an unasserted exploratory branch. + Assert.Equal(58.6000m, Scalar(conn, "SELECT CCur(`D`) FROM `P`")); + Assert.Equal("58.6", Scalar(conn, "SELECT CStr(`D`) FROM `P`")); + // Do not pin OLE DB's CLR box here: computed values are frequently widened or otherwise + // misrepresented by the provider. The numeric result is the contract (JetDataReader normalizes it). + Assert.Equal(-1f, Convert.ToSingle(Scalar(conn, "SELECT CSng(True) FROM `P`"))); + Assert.Equal(-1.0000m, Scalar(conn, "SELECT CCur(True) FROM `P`")); + Assert.Throws(() => Scalar(conn, "SELECT CByte(True) FROM `P`")); + Assert.Equal((short)-1, Scalar(conn, "SELECT CBool('True') FROM `P`")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/ActionQueryProcedureAccessTests.cs b/test/LibRed.Core.Tests/ActionQueryProcedureAccessTests.cs index 02d9366d..f22fb772 100644 --- a/test/LibRed.Core.Tests/ActionQueryProcedureAccessTests.cs +++ b/test/LibRed.Core.Tests/ActionQueryProcedureAccessTests.cs @@ -14,20 +14,7 @@ namespace LibRed.Core.Tests; /// public class ActionQueryProcedureAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; } - } - Thread.Sleep(50); - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider opened the database.", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static void Exec(OleDbConnection conn, string procName) { @@ -40,8 +27,7 @@ private static void Exec(OleDbConnection conn, string procName) [Fact] public void Access_runs_a_libred_written_make_table_and_append_procedure() { - string path = Path.Combine(Path.GetTempPath(), $"action-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "action-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -86,7 +72,7 @@ public void Access_runs_a_libred_written_make_table_and_append_procedure() Assert.Equal(before + 1, Convert.ToInt32(c.ExecuteScalar())); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // An INSERT ... SELECT stored query (written by ACE) is read back but classified unsupported: LibRed @@ -94,8 +80,7 @@ public void Access_runs_a_libred_written_make_table_and_append_procedure() [Fact] public void Insert_select_stored_query_is_read_back_as_unsupported() { - string path = Path.Combine(Path.GetTempPath(), $"action-sel-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "action-sel-"); try { using (var conn = OpenOleDb(path)) @@ -110,8 +95,114 @@ public void Insert_select_stored_query_is_read_back_as_unsupported() using var db = JetDatabase.Open(path); StoredActionQuery q = db.Catalog.ActionQueries["CopyUkShippers"]; Assert.Null(q.Sql); - Assert.NotNull(q.UnsupportedReason); + Assert.Contains("INSERT", q.UnsupportedReason!, StringComparison.OrdinalIgnoreCase); + Assert.Contains("SELECT", q.UnsupportedReason!, StringComparison.OrdinalIgnoreCase); + Assert.Equal((short)3, ActionFlag(db, "CopyUkShippers")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Ace_parameterized_update_retains_parameter_order_while_remaining_explicitly_unsupported() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "action-params-"); + try + { + using (var conn = OpenOleDb(path)) + using (var command = conn.CreateCommand()) + { + command.CommandText = + "CREATE PROCEDURE [UpdateByCountry] (pTitle Text(50), pCountry Text(20)) AS " + + "UPDATE Customers SET ContactTitle = pTitle WHERE Country = pCountry"; + command.ExecuteNonQuery(); + } + + using var db = JetDatabase.Open(path); + Assert.Equal(["pTitle", "pCountry"], db.Catalog.QueryParameters["UpdateByCountry"]); + StoredActionQuery query = db.Catalog.ActionQueries["UpdateByCountry"]; + Assert.Null(query.Sql); + Assert.Contains("UPDATE", query.UnsupportedReason!, StringComparison.OrdinalIgnoreCase); + Assert.Equal((short)4, ActionFlag(db, "UpdateByCountry")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Ace_update_and_delete_procedures_are_retained_with_their_exact_action_kinds() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "action-kinds-"); + 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'"); + } + + 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); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } + + [Fact] + public void Ace_having_view_is_not_misclassified_as_an_action_query() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "having-view-"); + try + { + using (var conn = OpenOleDb(path)) + using (var command = conn.CreateCommand()) + { + command.CommandText = + "CREATE VIEW CountriesWithManyCustomers AS " + + "SELECT Country, COUNT(*) AS CustomerCount FROM Customers " + + "GROUP BY Country HAVING COUNT(*) > 3"; + command.ExecuteNonQuery(); + } + + 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")); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void CreateProcedure(OleDbConnection connection, string name, string body) + { + using var command = connection.CreateCommand(); + command.CommandText = $"CREATE PROCEDURE [{name}] AS {body}"; + command.ExecuteNonQuery(); + } + + private static short ActionFlag(JetDatabase database, string queryName) + { + TableDef objectsDef = database.Catalog.FindTable("MSysObjects")!; + int objectIdIndex = ColumnIndex(objectsDef, "Id"); + int objectNameIndex = ColumnIndex(objectsDef, "Name"); + int objectId = (int)database.OpenTable("MSysObjects") + .Rows().Single(row => Equals(row[objectNameIndex], queryName))[objectIdIndex]!; + + TableDef queriesDef = database.Catalog.FindTable("MSysQueries")!; + int queryObjectIdIndex = ColumnIndex(queriesDef, "ObjectId"); + int attributeIndex = ColumnIndex(queriesDef, "Attribute"); + int flagIndex = ColumnIndex(queriesDef, "Flag"); + object?[] action = database.OpenTable("MSysQueries") + .Rows().Single(row => Equals(row[queryObjectIdIndex], objectId) && Equals(row[attributeIndex], (byte)1)); + return (short)action[flagIndex]!; + } + + private static int ColumnIndex(TableDef definition, string name) => + definition.Columns.ToList().FindIndex(column => column.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); } diff --git a/test/LibRed.Core.Tests/AddColumnAccessTests.cs b/test/LibRed.Core.Tests/AddColumnAccessTests.cs index 0c53449d..6c2267f4 100644 --- a/test/LibRed.Core.Tests/AddColumnAccessTests.cs +++ b/test/LibRed.Core.Tests/AddColumnAccessTests.cs @@ -10,21 +10,14 @@ namespace LibRed.Core.Tests; // LibRed-column-added file, reads existing rows with the new column NULL, and can insert using it. public class AddColumnAccessTests { - private static OleDbConnection Open(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - throw new InvalidOperationException("no ace"); - } + private static OleDbConnection Open(string path) => AceTestDatabase.Open(path); private static void Ace(string path, params string[] sqls) { using var c = Open(path); foreach (var s in sqls) { using var m = c.CreateCommand(); m.CommandText = s; m.ExecuteNonQuery(); } } [Fact] public void Access_reads_and_extends_a_libred_column_added_table() { - string path = Path.Combine(Path.GetTempPath(), $"addcol-lr-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "addcol-lr-"); try { // ACE creates the table + rows so the TDEF/rows are authentic. @@ -64,14 +57,13 @@ public void Access_reads_and_extends_a_libred_column_added_table() Assert.Equal(99, r.GetInt32(1)); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_reads_a_libred_row_inserted_after_adding_a_fixed_column_to_a_populated_table() { - string path = Path.Combine(Path.GetTempPath(), $"addcol-fx-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "addcol-fx-"); try { Ace(path, @@ -94,14 +86,13 @@ public void Access_reads_a_libred_row_inserted_after_adding_a_fixed_column_to_a_ Assert.True(r.Read()); Assert.Equal(2, r.GetInt32(0)); Assert.Equal(20, r.GetInt32(1)); Assert.True(r.IsDBNull(2)); Assert.True(r.Read()); Assert.Equal(3, r.GetInt32(0)); Assert.Equal(30, r.GetInt32(1)); Assert.Equal(99, r.GetInt32(2)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Add_a_memo_column_falls_back_to_a_dedicated_usage_map_page_when_the_primary_is_full() { - string path = Path.Combine(Path.GetTempPath(), $"addmemo-ded-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "addmemo-ded-"); try { string memo = new string('z', 200); @@ -132,14 +123,13 @@ public void Add_a_memo_column_falls_back_to_a_dedicated_usage_map_page_when_the_ cmd.CommandText = "SELECT Extra FROM W WHERE Id = 1"; Assert.Equal(memo, cmd.ExecuteScalar()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Add_and_drop_column_work_on_a_multi_page_tdef() { - string path = Path.Combine(Path.GetTempPath(), $"multipage-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "multipage-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -171,14 +161,13 @@ public void Add_and_drop_column_work_on_a_multi_page_tdef() cmd.CommandText = "SELECT COUNT(*) FROM Wide"; Assert.Equal(1, Convert.ToInt32(cmd.ExecuteScalar())); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_reads_a_libred_memo_column_added_to_a_populated_table() { - string path = Path.Combine(Path.GetTempPath(), $"addcol-memo-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "addcol-memo-"); try { Ace(path, @@ -199,14 +188,13 @@ public void Access_reads_a_libred_memo_column_added_to_a_populated_table() using (var c = conn.CreateCommand()) { c.CommandText = "SELECT M FROM T WHERE Id = 2"; Assert.Equal(memo, c.ExecuteScalar()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Adding_columns_with_default_and_required_preserves_existing_props_and_access_applies_them() { - string path = Path.Combine(Path.GetTempPath(), $"addcol-lv-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "addcol-lv-"); try { // ACE creates a table that already has a column DEFAULT (A DEFAULT 5) in its LvProp blob. @@ -234,6 +222,6 @@ public void Adding_columns_with_default_and_required_preserves_existing_props_an using (var c = conn.CreateCommand()) { c.CommandText = "SELECT Qty FROM T WHERE Id = 1"; Assert.Equal(1, Convert.ToInt32(c.ExecuteScalar())); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AddIndexToPopulatedTableAccessTests.cs b/test/LibRed.Core.Tests/AddIndexToPopulatedTableAccessTests.cs index 2520a0f9..f4cd77db 100644 --- a/test/LibRed.Core.Tests/AddIndexToPopulatedTableAccessTests.cs +++ b/test/LibRed.Core.Tests/AddIndexToPopulatedTableAccessTests.cs @@ -11,23 +11,12 @@ namespace LibRed.Core.Tests; /// public class AddIndexToPopulatedTableAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_reads_and_enforces_a_primary_key_added_after_data() { - string path = Path.Combine(Path.GetTempPath(), $"populated-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "populated-"); try { using (var conn = OpenOleDb(path)) @@ -65,7 +54,7 @@ public void Access_reads_and_enforces_a_primary_key_added_after_data() using (var dup = conn2.CreateCommand()) { dup.CommandText = "INSERT INTO Region2 (RegionID, RegionDescription) VALUES (2, 'Dup')"; - Assert.ThrowsAny(() => dup.ExecuteNonQuery()); + Assert.Throws(() => dup.ExecuteNonQuery()); } using (var ok = conn2.CreateCommand()) { @@ -78,7 +67,7 @@ public void Access_reads_and_enforces_a_primary_key_added_after_data() Assert.Equal(5, Convert.ToInt32(c.ExecuteScalar())); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A large back-fill forces the index B-tree to split into multiple levels; Access must read every row @@ -87,8 +76,7 @@ public void Access_reads_and_enforces_a_primary_key_added_after_data() public void Access_reads_a_large_backfilled_index() { const int n = 2000; - string path = Path.Combine(Path.GetTempPath(), $"populated-big-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "populated-big-"); try { using (var conn = OpenOleDb(path)) @@ -114,6 +102,6 @@ public void Access_reads_a_large_backfilled_index() Assert.Equal("row1777", c.ExecuteScalar()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AllocatorAndLvalOwnershipTests.cs b/test/LibRed.Core.Tests/AllocatorAndLvalOwnershipTests.cs index 9a37f9f1..3bc6ebf9 100644 --- a/test/LibRed.Core.Tests/AllocatorAndLvalOwnershipTests.cs +++ b/test/LibRed.Core.Tests/AllocatorAndLvalOwnershipTests.cs @@ -132,12 +132,11 @@ private static (byte[] Page, RowSlot Slot) GlobalMap(Table table) private sealed class Fixture : IDisposable { - private readonly string _path = Path.Combine(Path.GetTempPath(), $"alloc-lval-{Guid.NewGuid():N}.accdb"); + private readonly string _path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "alloc-lval-"); private readonly JetDatabase _database; public Fixture() { - File.Copy(TestDatabases.NorthwindAccdb, _path); _database = JetDatabase.Open(_path, readOnly: false); Table = _database.OpenTable("Categories"); } @@ -148,7 +147,7 @@ public Fixture() public void Dispose() { _database.Dispose(); - File.Delete(_path); + TemporaryDatabase.Delete(_path); } } } diff --git a/test/LibRed.Core.Tests/AlterForeignKeyAccessTests.cs b/test/LibRed.Core.Tests/AlterForeignKeyAccessTests.cs index 5eca99ec..4f6061de 100644 --- a/test/LibRed.Core.Tests/AlterForeignKeyAccessTests.cs +++ b/test/LibRed.Core.Tests/AlterForeignKeyAccessTests.cs @@ -12,23 +12,12 @@ namespace LibRed.Core.Tests; /// public class AlterForeignKeyAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_reads_and_enforces_a_libred_added_foreign_key() { - string path = Path.Combine(Path.GetTempPath(), $"fk-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "fk-"); try { using (var conn = OpenOleDb(path)) @@ -57,13 +46,13 @@ public void Access_reads_and_enforces_a_libred_added_foreign_key() Exec("INSERT INTO CustDemo (CustomerTypeID, Descr) VALUES ('T1', 'Type one')"); Exec("INSERT INTO CustCustDemo (CustomerID, CustomerTypeID) VALUES ('ALFKI', 'T1')"); // valid parent // Referencing a non-existent parent must be rejected by the enforced relationship. - Assert.ThrowsAny(() => + Assert.Throws(() => Exec("INSERT INTO CustCustDemo (CustomerID, CustomerTypeID) VALUES ('ANATR', 'ZZ')")); using var count = conn2.CreateCommand(); count.CommandText = "SELECT COUNT(*) FROM CustCustDemo"; Assert.Equal(1, Convert.ToInt32(count.ExecuteScalar())); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AlterPrimaryKeyAccessTests.cs b/test/LibRed.Core.Tests/AlterPrimaryKeyAccessTests.cs index 75710d79..2189be18 100644 --- a/test/LibRed.Core.Tests/AlterPrimaryKeyAccessTests.cs +++ b/test/LibRed.Core.Tests/AlterPrimaryKeyAccessTests.cs @@ -10,23 +10,12 @@ namespace LibRed.Core.Tests; /// public class AlterPrimaryKeyAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_enforces_a_libred_written_multi_column_primary_key() { - string path = Path.Combine(Path.GetTempPath(), $"pk-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "pk-"); try { using (var conn = OpenOleDb(path)) @@ -49,12 +38,12 @@ void Insert(string a, string b) Insert("ALFKI", "T2"); // same CustomerID, different type — allowed Insert("ANATR", "T1"); // different CustomerID — allowed // Duplicate composite key must be rejected by the primary key. - Assert.ThrowsAny(() => Insert("ALFKI", "T1")); + Assert.Throws(() => Insert("ALFKI", "T1")); using var count = conn2.CreateCommand(); count.CommandText = "SELECT COUNT(*) FROM CCDemo"; Assert.Equal(3, Convert.ToInt32(count.ExecuteScalar())); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/AutoNumberSeedTests.cs b/test/LibRed.Core.Tests/AutoNumberSeedTests.cs index 03eea3e4..147a1550 100644 --- a/test/LibRed.Core.Tests/AutoNumberSeedTests.cs +++ b/test/LibRed.Core.Tests/AutoNumberSeedTests.cs @@ -11,17 +11,7 @@ namespace LibRed.Core.Tests; // advances 0x14 monotonically and is immune — see AutoNumberSeedImmunityTests in LibRed.Engine.Tests). public class AutoNumberSeedTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static int HighWater(JetDatabase db, string table) { @@ -32,8 +22,7 @@ private static int HighWater(JetDatabase db, string table) [Fact] public void Ace_seeds_the_high_water_from_the_last_inserted_value() { - string path = Path.Combine(Path.GetTempPath(), $"anb-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "anb-"); try { using (var conn = OpenOleDb(path)) @@ -54,7 +43,7 @@ public void Ace_seeds_the_high_water_from_the_last_inserted_value() bad.CommandText = "INSERT INTO Table1 (Field2) VALUES ('G')"; Assert.ThrowsAny(() => bad.ExecuteNonQuery()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Byte-faithful: LibRed's in-place counter reseed (metadata-only 0x14/0x18 edit, no rebuild) is read @@ -62,8 +51,7 @@ public void Ace_seeds_the_high_water_from_the_last_inserted_value() [Fact] public void Ace_reads_a_libred_in_place_counter_reseed() { - string path = Path.Combine(Path.GetTempPath(), $"lrr-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "lrr-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -84,7 +72,7 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true, IsAutoNumber: t q.CommandText = "SELECT Id FROM T WHERE V = 'a'"; Assert.Equal(100, Convert.ToInt32(q.ExecuteScalar())); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Symmetric to promotion but NOT a divergence: ACE allows demoting a counter to a plain integer. LibRed @@ -92,8 +80,7 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true, IsAutoNumber: t [Fact] public void Ace_reads_a_libred_counter_demoted_to_int() { - string path = Path.Combine(Path.GetTempPath(), $"c2i-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "c2i-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -113,7 +100,7 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true, IsAutoNumber: t q.CommandText = "SELECT COUNT(*) FROM T"; Assert.Equal(3, Convert.ToInt32(q.ExecuteScalar())); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Divergence (deliberate): ACE refuses to promote an existing plain integer column to AutoNumber via @@ -125,8 +112,7 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true, IsAutoNumber: t public void Ace_refuses_but_libred_allows_promoting_an_int_column_to_a_counter() { // ACE: reject. - string acePath = Path.Combine(Path.GetTempPath(), $"i2c-ace-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, acePath); + string acePath = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "i2c-ace-"); try { using var conn = OpenOleDb(acePath); @@ -138,11 +124,10 @@ public void Ace_refuses_but_libred_allows_promoting_an_int_column_to_a_counter() var ex = Assert.ThrowsAny(() => bad.ExecuteNonQuery()); Assert.Contains("Invalid field data type", ex.Message); } - finally { try { File.Delete(acePath); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(acePath); } // LibRed: allow, and the converted counter round-trips through ACE (next auto id = seed). - string libPath = Path.Combine(Path.GetTempPath(), $"i2c-lib-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, libPath); + string libPath = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "i2c-lib-"); try { using (var db = JetDatabase.Open(libPath, readOnly: false)) @@ -165,7 +150,7 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), // plain int q2.CommandText = "SELECT COUNT(*) FROM T"; Assert.Equal(4, Convert.ToInt32(q2.ExecuteScalar())); } - finally { try { File.Delete(libPath); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(libPath); } } // Ground truth for the reseed fix (KB 884185 resolution): ALTER COLUMN c COUNTER(seed, 1) sets the next id @@ -173,8 +158,7 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), // plain int [Fact] public void Ace_reseeds_the_next_id_via_alter_column_counter() { - string path = Path.Combine(Path.GetTempPath(), $"anr-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "anr-"); try { using var conn = OpenOleDb(path); @@ -187,6 +171,6 @@ public void Ace_reseeds_the_next_id_via_alter_column_counter() q.CommandText = "SELECT Field1 FROM Table1 WHERE Field2 = 'G'"; Assert.Equal(100, Convert.ToInt32(q.ExecuteScalar())); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/BinaryIndexKeyAccessTests.cs b/test/LibRed.Core.Tests/BinaryIndexKeyAccessTests.cs index d25a4ec5..38180411 100644 --- a/test/LibRed.Core.Tests/BinaryIndexKeyAccessTests.cs +++ b/test/LibRed.Core.Tests/BinaryIndexKeyAccessTests.cs @@ -49,7 +49,7 @@ public void Reencoding_binary_keys_reproduces_ace_bytes() } [Theory] - [InlineData(new byte[] { }, "7F 00 00 00 00 00 00 00 00 00")] // empty → one padded chunk, len 0 + [InlineData(new byte[] { }, "7F")] // empty → start marker only (live ACE oracle) [InlineData(new byte[] { 0x01, 0x02, 0x03 }, "7F 01 02 03 00 00 00 00 00 03")] // 3 bytes, single chunk [InlineData(new byte[] { 0x47, 0x75, 0x6D, 0x62, 0x61, 0x6C, 0x6C, 0x21 }, "7F 47 75 6D 62 61 6C 6C 21 08")] // full 8-byte chunk [InlineData(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 }, "7F 01 02 03 04 05 06 07 08 09 09 00 00 00 00 00 00 00 01")] // 9 bytes → two chunks @@ -63,13 +63,13 @@ public void Ascending_binary_key_matches_the_chunked_layout(byte[] data, string [Fact] public void Encoded_binary_keys_sort_in_value_order_both_directions() { - // Lexicographic byte order of the encoded keys must match value order ascending, and reverse it - // descending. (Descending mirrors the ACE-verified GUID descending: invert every byte except the - // 0x09 continuation markers.) + // For non-empty values, lexicographic byte order of the encoded keys must match value order ascending, + // and reverse it descending. Empty Binary has a special start-only key and is covered against live ACE + // in IndexOrderingAccessTests rather than being forced through this ordinary chunk-prefix property. var col = new ColumnDef { Name = "b", Type = JetDataType.Binary, Index = 0 }; byte[][] values = [ - [], [0x00], [0x00, 0x00], [0x01], [0x01, 0x02, 0x03], [0x01, 0x02, 0x03, 0x04], + [0x00], [0x00, 0x00], [0x01], [0x01, 0x02, 0x03], [0x01, 0x02, 0x03, 0x04], [0x02], [.. Enumerable.Repeat((byte)0xAB, 9)], [0xFF], [0xFF, 0x00], ]; diff --git a/test/LibRed.Core.Tests/BitwiseOperatorAccessTests.cs b/test/LibRed.Core.Tests/BitwiseOperatorAccessTests.cs index bfeade3c..97a54c43 100644 --- a/test/LibRed.Core.Tests/BitwiseOperatorAccessTests.cs +++ b/test/LibRed.Core.Tests/BitwiseOperatorAccessTests.cs @@ -9,24 +9,13 @@ namespace LibRed.Core.Tests; /// public class BitwiseOperatorAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); // ACE evaluates the same bitwise operator syntax, giving the values the LibRed engine tests assert. [Fact] public void Access_evaluates_bitwise_operators() { - string path = Path.Combine(Path.GetTempPath(), $"bitop-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "bitop-"); try { using var conn = OpenOleDb(path); @@ -46,6 +35,6 @@ int Ace(string expr) Assert.Equal(-6, Ace("BNOT 5")); Assert.Equal(10, Ace("6 BAND 3 BOR 8")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/BooleanWriteAccessTests.cs b/test/LibRed.Core.Tests/BooleanWriteAccessTests.cs index 3e7906fa..06d282ac 100644 --- a/test/LibRed.Core.Tests/BooleanWriteAccessTests.cs +++ b/test/LibRed.Core.Tests/BooleanWriteAccessTests.cs @@ -11,23 +11,12 @@ namespace LibRed.Core.Tests; /// public class BooleanWriteAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_reads_libred_written_bit_values() { - string path = Path.Combine(Path.GetTempPath(), $"bit-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "bit-"); try { using (var conn = OpenOleDb(path)) @@ -61,6 +50,6 @@ public void Access_reads_libred_written_bit_values() Assert.Equal(false, c.ExecuteScalar()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/ChainedLongValueAccessTests.cs b/test/LibRed.Core.Tests/ChainedLongValueAccessTests.cs index 60e65996..59337665 100644 --- a/test/LibRed.Core.Tests/ChainedLongValueAccessTests.cs +++ b/test/LibRed.Core.Tests/ChainedLongValueAccessTests.cs @@ -16,31 +16,12 @@ public class ChainedLongValueAccessTests private static readonly string Big = string.Concat(Enumerable.Range(0, 20_000).Select(i => (char)('A' + i % 26))); - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try - { - var conn = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); - conn.Open(); - return conn; - } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; } - } - Thread.Sleep(50); - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider opened the database.", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void A_large_memo_chains_across_lval_pages_and_round_trips() { - string path = Path.Combine(Path.GetTempPath(), $"chained-lval-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "chained-lval-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -66,6 +47,6 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), cmd.CommandText = "SELECT M FROM Big WHERE Id = 1"; Assert.Equal(Big, (string)cmd.ExecuteScalar()!); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/CollationTests.cs b/test/LibRed.Core.Tests/CollationTests.cs index 8307cc5d..23183d61 100644 --- a/test/LibRed.Core.Tests/CollationTests.cs +++ b/test/LibRed.Core.Tests/CollationTests.cs @@ -24,8 +24,7 @@ private static List Columns() => [Fact] public void A_new_database_defaults_to_general_legacy() { - string path = Path.Combine(Path.GetTempPath(), $"coll-def-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "coll-def-"); try { using var db = JetDatabase.Open(path); @@ -33,14 +32,13 @@ public void A_new_database_defaults_to_general_legacy() Assert.Equal(CollatingOrder.General, db.Collation.Order); Assert.Equal(0, db.Collation.Version); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Non_numeric_columns_carry_the_database_collation_and_round_trip() { - string path = Path.Combine(Path.GetTempPath(), $"coll-rt-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "coll-rt-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -59,14 +57,13 @@ public void Non_numeric_columns_carry_the_database_collation_and_round_trip() Assert.Equal(18, price.Precision); Assert.Equal(2, price.Scale); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void The_written_locale_bytes_are_byte_identical_to_the_old_hardcoded_constant() { - string path = Path.Combine(Path.GetTempPath(), $"coll-bytes-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "coll-bytes-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -85,7 +82,7 @@ public void The_written_locale_bytes_are_byte_identical_to_the_old_hardcoded_con Assert.Equal(0x04, descriptor[0x0C]); Assert.Equal(0x00, descriptor[0x0D]); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] diff --git a/test/LibRed.Core.Tests/ColumnAliasViewAccessTests.cs b/test/LibRed.Core.Tests/ColumnAliasViewAccessTests.cs index c50897b3..0812051a 100644 --- a/test/LibRed.Core.Tests/ColumnAliasViewAccessTests.cs +++ b/test/LibRed.Core.Tests/ColumnAliasViewAccessTests.cs @@ -11,26 +11,12 @@ namespace LibRed.Core.Tests; /// public class ColumnAliasViewAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; } - } - Thread.Sleep(50); - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider opened the database.", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_runs_a_multi_join_view_with_a_column_alias() { - string path = Path.Combine(Path.GetTempPath(), $"colalias-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "colalias-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -65,6 +51,6 @@ public void Access_runs_a_multi_join_view_with_a_column_alias() aliased.CommandText = "SELECT COUNT(CustomerName) FROM CustLines"; Assert.Equal(2155, Convert.ToInt32(aliased.ExecuteScalar())); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/ColumnIdHighWaterTests.cs b/test/LibRed.Core.Tests/ColumnIdHighWaterTests.cs index ee192bb2..9635150a 100644 --- a/test/LibRed.Core.Tests/ColumnIdHighWaterTests.cs +++ b/test/LibRed.Core.Tests/ColumnIdHighWaterTests.cs @@ -11,23 +11,12 @@ namespace LibRed.Core.Tests; // ACE enforces this ("Too many fields defined"); LibRed must too, rather than write a 256th id ACE can't read. public class ColumnIdHighWaterTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Ace_rejects_add_column_after_255_ids_used_even_with_dropped_columns() { - string path = Path.Combine(Path.GetTempPath(), $"c255a-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "c255a-"); try { using var conn = OpenOleDb(path); @@ -43,14 +32,13 @@ public void Ace_rejects_add_column_after_255_ids_used_even_with_dropped_columns( var ex = Assert.ThrowsAny(() => add.ExecuteNonQuery()); Assert.Contains("Too many fields", ex.Message); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Libred_rejects_add_column_once_the_id_high_water_reaches_255() { - string path = Path.Combine(Path.GetTempPath(), $"c255l-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "c255l-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -69,6 +57,6 @@ public void Libred_rejects_add_column_once_the_id_high_water_reaches_255() Assert.Contains("too many fields", ex.Message); Assert.Equal(245, db.Catalog.FindTable("C")!.Columns.Count); // unchanged — nothing written } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/CompositeFkNullAccessTests.cs b/test/LibRed.Core.Tests/CompositeFkNullAccessTests.cs index 15ba0fc0..2c4bc560 100644 --- a/test/LibRed.Core.Tests/CompositeFkNullAccessTests.cs +++ b/test/LibRed.Core.Tests/CompositeFkNullAccessTests.cs @@ -8,13 +8,7 @@ namespace LibRed.Core.Tests; // (SQL Server's MATCH SIMPLE would skip the check when any column is null; ACE does not.) public class CompositeFkNullAccessTests { - private static OleDbConnection Open(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - throw new InvalidOperationException("no ace"); - } + private static OleDbConnection Open(string path) => AceTestDatabase.Open(path); private static void Ok(OleDbConnection c, string sql) { using var m = c.CreateCommand(); m.CommandText = sql; m.ExecuteNonQuery(); } private static void Rejects(OleDbConnection c, string sql) => Assert.ThrowsAny(() => { using var m = c.CreateCommand(); m.CommandText = sql; m.ExecuteNonQuery(); }); @@ -22,8 +16,7 @@ private static void Rejects(OleDbConnection c, string sql) => [Fact] public void Access_applies_match_full_to_a_composite_foreign_key() { - string path = Path.Combine(Path.GetTempPath(), $"cfk-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "cfk-"); try { using var c = Open(path); @@ -38,6 +31,6 @@ public void Access_applies_match_full_to_a_composite_foreign_key() Rejects(c, "INSERT INTO C (Id, X, Y) VALUES (4, NULL, 2)"); // partial null → rejected Rejects(c, "INSERT INTO C (Id, X, Y) VALUES (5, 1, 99)"); // no matching parent → rejected } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/CompositeIndexOrderingAccessTests.cs b/test/LibRed.Core.Tests/CompositeIndexOrderingAccessTests.cs new file mode 100644 index 00000000..51316456 --- /dev/null +++ b/test/LibRed.Core.Tests/CompositeIndexOrderingAccessTests.cs @@ -0,0 +1,90 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +/// Checks mixed-type, mixed-direction composite index keys against a live ACE-created B-tree. +public class CompositeIndexOrderingAccessTests +{ + [Fact] + public void Libred_composite_key_bytes_and_traversal_match_ace() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "composite-index-oracle-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Execute(connection, "CREATE TABLE CompositeKeys (A INT, B VARCHAR(30), C DATETIME, D VARBINARY(16), V INT NOT NULL)"); + Execute(connection, "CREATE INDEX IX_CompositeKeys ON CompositeKeys (A ASC, B DESC, C ASC, D DESC, V ASC)"); + + object?[][] rows = + [ + [null, "same", new DateTime(1899, 12, 29, 18, 0, 0), new byte[] { 1, 2 }, 1], + [-1, "Zulu", new DateTime(1850, 6, 15), new byte[] { 0 }, 2], + [0, "same", new DateTime(1899, 12, 29, 6, 0, 0), Array.Empty(), 3], + [0, "same", new DateTime(1899, 12, 29, 6, 0, 0), new byte[] { 0 }, 4], + [0, "same", new DateTime(1899, 12, 29, 6, 0, 0), new byte[] { 0 }, 5], + [0, "Alpha", null, new byte[] { 0, 1, 2, 3, 4, 5, 6, 7 }, 6], + [0, null, new DateTime(1900, 1, 1), null, 7], + [1, "O'Brien", new DateTime(9999, 12, 31), Enumerable.Range(0, 16).Select(i => (byte)i).ToArray(), 8], + ]; + + foreach (object?[] row in rows) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO CompositeKeys (A, B, C, D, V) VALUES (?, ?, ?, ?, ?)"; + Add(insert, OleDbType.Integer, row[0]); + Add(insert, OleDbType.VarWChar, row[1]); + Add(insert, OleDbType.Date, row[2]); + Add(insert, OleDbType.VarBinary, row[3]); + Add(insert, OleDbType.Integer, row[4]); + insert.ExecuteNonQuery(); + } + } + + int[] aceOrder; + using (var connection = AceTestDatabase.Open(path)) + using (var command = connection.CreateCommand()) + { + command.CommandText = "SELECT V FROM CompositeKeys ORDER BY A ASC, B DESC, C ASC, D DESC, V ASC"; + using var reader = command.ExecuteReader(); + var values = new List(); + while (reader.Read()) values.Add(Convert.ToInt32(reader[0])); + aceOrder = [.. values]; + } + + using var db = JetDatabase.Open(path); + Table table = db.OpenTable("CompositeKeys"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_CompositeKeys"); + var decoder = new RowDecoder(table.Definition.Columns, db.Format); + int valueIndex = table.Definition.FindColumn("V")!.Index; + var libredOrder = new List(); + + foreach ((byte[] storedKey, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + { + object?[] row = decoder.Decode(db.ReadDataPage(rowId.Page).GetRow(rowId.Row)); + byte[] encoded = IndexKeyEncoder.Encode(index.Columns, row); + int value = Convert.ToInt32(row[valueIndex]); + Assert.True(storedKey.AsSpan().SequenceEqual(encoded), + $"V={value}: ACE={Convert.ToHexString(storedKey)} LibRed={Convert.ToHexString(encoded)}"); + libredOrder.Add(value); + } + + Assert.Equal(aceOrder, libredOrder); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Add(OleDbCommand command, OleDbType type, object? value) => + command.Parameters.Add(new OleDbParameter { OleDbType = type, Value = value ?? DBNull.Value }); + + private static void Execute(OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Core.Tests/CounterSeedIncrementAccessTests.cs b/test/LibRed.Core.Tests/CounterSeedIncrementAccessTests.cs index 80bb7557..81d31607 100644 --- a/test/LibRed.Core.Tests/CounterSeedIncrementAccessTests.cs +++ b/test/LibRed.Core.Tests/CounterSeedIncrementAccessTests.cs @@ -10,25 +10,14 @@ namespace LibRed.Core.Tests; // repair, and continues the AutoNumber sequence from the seed with the custom increment. public class CounterSeedIncrementAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Theory] [InlineData(1000, 7, new[] { 1000, 1007 })] // ascending custom counter [InlineData(100, -5, new[] { 95, 100 })] // descending counter (negative int32 increment), sorted asc public void Access_continues_a_libred_written_custom_counter(int seed, int increment, int[] expectedSortedIds) { - string path = Path.Combine(Path.GetTempPath(), $"cnt-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "cnt-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -52,6 +41,6 @@ public void Access_continues_a_libred_written_custom_counter(int seed, int incre // ACE picks up the LibRed-written seed/increment and continues the sequence in its direction. Assert.Equal(expectedSortedIds, ids); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/CreateTableAccessTests.cs b/test/LibRed.Core.Tests/CreateTableAccessTests.cs index 8abf8597..602ae6e0 100644 --- a/test/LibRed.Core.Tests/CreateTableAccessTests.cs +++ b/test/LibRed.Core.Tests/CreateTableAccessTests.cs @@ -9,31 +9,15 @@ public class CreateTableAccessTests { private static string CopyToTemp() { - string path = Path.Combine(Path.GetTempPath(), $"libred-createaccess-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-createaccess-"); return path; } - private static OleDbConnection OpenOleDb(string path) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try - { - // "OLE DB Services=-4" disables connection pooling so the file is released on - // Dispose and the temp copy can be deleted. - var conn = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); - conn.Open(); - return conn; - } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider (12.0/16.0) is available."); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static void TryDelete(string path) { - try { File.Delete(path); } catch (IOException) { /* lock lingered; temp file, ignore */ } + TemporaryDatabase.Delete(path); } [Fact] @@ -509,7 +493,7 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), using (var bad = conn.CreateCommand()) { bad.CommandText = "INSERT INTO T (Id, Age) VALUES (2, -1)"; // violates [Age] > 0 - Assert.ThrowsAny(() => bad.ExecuteNonQuery()); + Assert.Throws(() => bad.ExecuteNonQuery()); } } finally { TryDelete(path); } diff --git a/test/LibRed.Core.Tests/CreateViewAccessTests.cs b/test/LibRed.Core.Tests/CreateViewAccessTests.cs index b4480c1b..f4a3a638 100644 --- a/test/LibRed.Core.Tests/CreateViewAccessTests.cs +++ b/test/LibRed.Core.Tests/CreateViewAccessTests.cs @@ -9,18 +9,11 @@ public class CreateViewAccessTests { private static string CopyToTemp() { - string path = Path.Combine(Path.GetTempPath(), $"libred-view-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-view-"); return path; } - private static OleDbConnection OpenOleDb(string path) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - try { var c = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider is available."); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_executes_a_libred_created_view() @@ -59,6 +52,6 @@ public void Access_executes_a_libred_created_view() j.CommandText = "SELECT COUNT(*) FROM CustOrders"; Assert.True(Convert.ToInt32(j.ExecuteScalar()) > 0); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/DatabaseCreatorTests.cs b/test/LibRed.Core.Tests/DatabaseCreatorTests.cs index 46b2b3a2..38758f59 100644 --- a/test/LibRed.Core.Tests/DatabaseCreatorTests.cs +++ b/test/LibRed.Core.Tests/DatabaseCreatorTests.cs @@ -31,7 +31,7 @@ public void Synthesized_page0_header_matches_a_real_file(string fixture) [Fact] public void Creates_an_empty_database_that_round_trips_a_user_table() { - string path = Path.Combine(Path.GetTempPath(), $"libred_create_{Guid.NewGuid():N}.accdb"); + string path = TemporaryDatabase.CreatePath("libred_create_"); try { DatabaseCreator.CreateEmpty(path); @@ -66,7 +66,7 @@ public void Creates_an_empty_database_that_round_trips_a_user_table() Assert.Contains("Alan", names); } } - finally { if (File.Exists(path)) File.Delete(path); } + finally { if (File.Exists(path)) TemporaryDatabase.Delete(path); } } [Fact] diff --git a/test/LibRed.Core.Tests/DatabaseDefinitionPageTests.cs b/test/LibRed.Core.Tests/DatabaseDefinitionPageTests.cs index 6792772e..129004cc 100644 --- a/test/LibRed.Core.Tests/DatabaseDefinitionPageTests.cs +++ b/test/LibRed.Core.Tests/DatabaseDefinitionPageTests.cs @@ -61,7 +61,7 @@ public void Decodes_code_page_and_default_collation(string fixture) [Fact] public void Rejects_non_jet_file() { - string bogus = Path.Combine(Path.GetTempPath(), $"libred_{Guid.NewGuid():N}.bin"); + string bogus = TemporaryDatabase.CreatePath("libred_", ".bin"); File.WriteAllBytes(bogus, new byte[4096]); try { @@ -69,7 +69,7 @@ public void Rejects_non_jet_file() } finally { - File.Delete(bogus); + TemporaryDatabase.Delete(bogus); } } } diff --git a/test/LibRed.Core.Tests/DatabaseEncryptionTests.cs b/test/LibRed.Core.Tests/DatabaseEncryptionTests.cs index 39ce3d96..d052026b 100644 --- a/test/LibRed.Core.Tests/DatabaseEncryptionTests.cs +++ b/test/LibRed.Core.Tests/DatabaseEncryptionTests.cs @@ -1,4 +1,6 @@ +using System.Data.OleDb; using LibRed; +using LibRed.Catalog; using LibRed.Crypto; using Xunit; @@ -12,8 +14,7 @@ public class DatabaseEncryptionTests private static string Copy() { - string p = Path.Combine(Path.GetTempPath(), $"libred_enc_{Guid.NewGuid():N}.accdb"); - File.Copy(Plain, p, overwrite: true); + string p = TemporaryDatabase.CopyPath(Plain, "libred_enc_", overwrite: true); return p; } @@ -37,13 +38,14 @@ public void Set_then_read_with_password_then_remove(AccessEncryption scheme) DatabaseEncryption.SetPassword(path, "S3cret!", scheme); Assert.Equal(rows, TableRows(path, "S3cret!")); // opens with password - Assert.ThrowsAny(() => TableRows(path, null)); // requires it + var missing = Assert.Throws(() => TableRows(path, null)); + Assert.Contains("password is required", missing.Message, StringComparison.OrdinalIgnoreCase); Assert.Throws(() => TableRows(path, "wrong")); // rejects wrong one DatabaseEncryption.RemovePassword(path, "S3cret!"); Assert.Equal(rows, TableRows(path, null)); // plaintext again } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Theory] @@ -52,6 +54,7 @@ public void Set_then_read_with_password_then_remove(AccessEncryption scheme) [InlineData(56, StandardHash.Sha1)] [InlineData(128, StandardHash.Sha1)] // enhanced key length [InlineData(128, StandardHash.Sha256)] // enhanced hash + [InlineData(128, StandardHash.Sha384)] [InlineData(120, StandardHash.Sha512)] public void SetPasswordRc4_with_options_roundtrips(int keyBits, StandardHash hash) { @@ -62,12 +65,13 @@ public void SetPasswordRc4_with_options_roundtrips(int keyBits, StandardHash has DatabaseEncryption.SetPasswordRc4(path, "S3cret!", keyBits, hash); Assert.Equal(rows, TableRows(path, "S3cret!")); // opens with password - Assert.ThrowsAny(() => TableRows(path, null)); // requires it + var missing = Assert.Throws(() => TableRows(path, null)); + Assert.Contains("password is required", missing.Message, StringComparison.OrdinalIgnoreCase); DatabaseEncryption.RemovePassword(path, "S3cret!"); Assert.Equal(rows, TableRows(path, null)); // plaintext again } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Theory] @@ -78,7 +82,7 @@ public void SetPasswordRc4_rejects_invalid_key_length(int keyBits) { string path = Copy(); try { Assert.Throws(() => DatabaseEncryption.SetPasswordRc4(path, "pw", keyBits)); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -94,7 +98,149 @@ public void Change_password_re_encrypts() Assert.Equal(rows, TableRows(path, "new-pass")); // new password works Assert.Throws(() => TableRows(path, "old-pass")); // old one doesn't } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } + } + + private static OleDbConnection OpenAce(string path, string password) => AceTestDatabase.Open(path, password); + + [Fact] + public void Change_rc4_password_can_change_key_length_and_hash() + { + string path = Copy(); + try + { + int rows = TableRows(path, null); + DatabaseEncryption.SetPasswordRc4(path, "old-pass", 40, StandardHash.Sha1); + DatabaseEncryption.ChangePasswordRc4(path, "old-pass", "new-pass", 128, StandardHash.Sha512); + + Assert.Equal(rows, TableRows(path, "new-pass")); + Assert.Throws(() => TableRows(path, "old-pass")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Theory] + [InlineData(AccessEncryption.OfficeStandardRc4)] + [InlineData(AccessEncryption.OfficeStandardAes)] + [InlineData(AccessEncryption.Agile)] + public void Ace_opens_reads_and_modifies_a_libred_encrypted_database(AccessEncryption scheme) + { + const string password = "S3cret!"; + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-ace-encrypted-"); + try + { + DatabaseEncryption.SetPassword(path, password, scheme); + + using (var connection = OpenAce(path, password)) + { + using var count = connection.CreateCommand(); + count.CommandText = "SELECT COUNT(*) FROM Shippers"; + Assert.Equal(3, Convert.ToInt32(count.ExecuteScalar())); + + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Shippers (ShipperID, CompanyName, Phone) " + + "VALUES (4, 'ACE over LibRed encryption', '(08) 5550 0004')"; + Assert.Equal(1, insert.ExecuteNonQuery()); + } + + using var db = JetDatabase.Open(path, readOnly: true, password: password); + var shippers = db.OpenTable("Shippers"); + int id = shippers.Definition.FindColumn("ShipperID")!.Index; + int company = shippers.Definition.FindColumn("CompanyName")!.Index; + Assert.Contains(shippers.Rows(), row => + Convert.ToInt32(row[id]) == 4 && (string?)row[company] == "ACE over LibRed encryption"); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Failed_change_validation_leaves_original_encryption_intact() + { + string path = Copy(); + try + { + int rows = TableRows(path, null); + DatabaseEncryption.SetPassword(path, "old-pass", AccessEncryption.OfficeStandardAes); + + Assert.Throws(() => + DatabaseEncryption.ChangePassword(path, "old-pass", "", AccessEncryption.OfficeStandardRc4)); + Assert.Equal(rows, TableRows(path, "old-pass")); + + Assert.Throws(() => + DatabaseEncryption.ChangePassword(path, "old-pass", "new-pass", AccessEncryption.None)); + Assert.Equal(rows, TableRows(path, "old-pass")); + + Assert.Throws(() => + DatabaseEncryption.ChangePasswordRc4(path, "old-pass", "new-pass", 33)); + Assert.Equal(rows, TableRows(path, "old-pass")); + + Assert.Throws(() => + DatabaseEncryption.ChangePasswordRc4(path, "old-pass", "new-pass", 40, (StandardHash)999)); + Assert.Equal(rows, TableRows(path, "old-pass")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Rejected_operations_leave_the_file_byte_identical() + { + string path = Copy(); + try + { + byte[] plaintext = File.ReadAllBytes(path); + Assert.Throws(() => + DatabaseEncryption.SetPassword(path, "pw", AccessEncryption.None)); + Assert.Equal(plaintext, File.ReadAllBytes(path)); + + DatabaseEncryption.SetPassword(path, "old-pass", AccessEncryption.OfficeStandardAes); + byte[] encrypted = File.ReadAllBytes(path); + + Assert.Throws(() => + DatabaseEncryption.SetPassword(path, "other", AccessEncryption.OfficeStandardRc4)); + Assert.Equal(encrypted, File.ReadAllBytes(path)); + + Assert.Throws(() => + DatabaseEncryption.RemovePassword(path, "wrong")); + Assert.Equal(encrypted, File.ReadAllBytes(path)); + + Assert.Throws(() => + DatabaseEncryption.ChangePassword(path, "wrong", "new-pass", AccessEncryption.Agile)); + Assert.Equal(encrypted, File.ReadAllBytes(path)); + + Assert.Throws(() => + DatabaseEncryption.ChangePasswordRc4(path, "old-pass", "new-pass", 40, (StandardHash)(-1))); + Assert.Equal(encrypted, File.ReadAllBytes(path)); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(4095)] + [InlineData(ushort.MaxValue)] + public void Malformed_encryption_info_length_is_rejected_without_writing(int descriptorLength) + { + string path = Copy(); + try + { + DatabaseEncryption.SetPassword(path, "pw", AccessEncryption.OfficeStandardAes); + byte[] malformed = File.ReadAllBytes(path); + System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian( + malformed.AsSpan(0x299, 2), checked((ushort)descriptorLength)); + File.WriteAllBytes(path, malformed); + + Exception? error = Record.Exception(() => + { + using var _ = JetDatabase.Open(path, readOnly: true, password: "pw"); + }); + + Assert.NotNull(error); + Assert.True(error is InvalidDataException or InvalidOperationException or NotSupportedException or ArgumentException, + $"Unexpected exception type: {error.GetType().FullName}"); + Assert.Equal(malformed, File.ReadAllBytes(path)); + } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -107,7 +253,7 @@ public void Set_on_already_encrypted_throws() Assert.Throws(() => DatabaseEncryption.SetPassword(path, "pw2", AccessEncryption.OfficeStandardAes)); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -115,7 +261,7 @@ public void Remove_on_plaintext_throws() { string path = Copy(); try { Assert.Throws(() => DatabaseEncryption.RemovePassword(path, "pw")); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -128,6 +274,6 @@ public void Invalid_schemes_are_rejected() Assert.Throws(() => DatabaseEncryption.SetPassword(path, "pw", AccessEncryption.LegacyJet)); Assert.Throws(() => DatabaseEncryption.SetPassword(path, "pw", AccessEncryption.None)); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/DateTimeDefaultAccessTests.cs b/test/LibRed.Core.Tests/DateTimeDefaultAccessTests.cs index 727a1ef9..4a3efb44 100644 --- a/test/LibRed.Core.Tests/DateTimeDefaultAccessTests.cs +++ b/test/LibRed.Core.Tests/DateTimeDefaultAccessTests.cs @@ -9,23 +9,12 @@ namespace LibRed.Core.Tests; // file without repair and applies the default itself on a bare insert (a current timestamp). public class DateTimeDefaultAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_reads_and_applies_a_libred_written_now_default() { - string path = Path.Combine(Path.GetTempPath(), $"nowdef-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "nowdef-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -56,6 +45,6 @@ public void Access_reads_and_applies_a_libred_written_now_default() Assert.InRange(v, before, after); Assert.True(v.TimeOfDay > TimeSpan.Zero); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/DecimalKeyEncodingTests.cs b/test/LibRed.Core.Tests/DecimalKeyEncodingTests.cs index 0d5cf45b..94c76354 100644 --- a/test/LibRed.Core.Tests/DecimalKeyEncodingTests.cs +++ b/test/LibRed.Core.Tests/DecimalKeyEncodingTests.cs @@ -11,15 +11,7 @@ namespace LibRed.Core.Tests; // the whole 17-byte positive form. Verified against keys Access itself wrote. public class DecimalKeyEncodingTests { - private static OleDbConnection OpenOleDb(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider available."); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static readonly decimal[] Values = [ @@ -28,8 +20,7 @@ private static OleDbConnection OpenOleDb(string path) private static void AssertKeysMatchAccess(string ddl, string indexPredicate) { - string path = Path.Combine(Path.GetTempPath(), $"libred-deckey-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-deckey-"); try { using (var conn = OpenOleDb(path)) @@ -75,7 +66,7 @@ private static void AssertKeysMatchAccess(string ddl, string indexPredicate) Assert.Equal(Values.Length, checkedKeys); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] diff --git a/test/LibRed.Core.Tests/DeleteAccessTests.cs b/test/LibRed.Core.Tests/DeleteAccessTests.cs index 78efba69..0d9d68f8 100644 --- a/test/LibRed.Core.Tests/DeleteAccessTests.cs +++ b/test/LibRed.Core.Tests/DeleteAccessTests.cs @@ -12,21 +12,12 @@ namespace LibRed.Core.Tests; /// public class DeleteAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No provider"); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_reads_a_libred_deleted_row_as_gone() { - string path = Path.Combine(Path.GetTempPath(), $"del-ace-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "del-ace-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -53,6 +44,6 @@ public void Access_reads_a_libred_deleted_row_as_gone() using (var c = conn.CreateCommand()) { c.CommandText = "SELECT SUM(N) FROM T"; Assert.Equal(120, Convert.ToInt32(c.ExecuteScalar())); } // 10+20+40+50 (30 gone) } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/DerivedTableViewAccessTests.cs b/test/LibRed.Core.Tests/DerivedTableViewAccessTests.cs index 7e2175e9..a6c3f02e 100644 --- a/test/LibRed.Core.Tests/DerivedTableViewAccessTests.cs +++ b/test/LibRed.Core.Tests/DerivedTableViewAccessTests.cs @@ -21,25 +21,7 @@ public class DerivedTableViewAccessTests // The ACE OLE DB provider is intermittently unstable on x64 (a spurious "Cannot open database … // may be corrupt"; see the ace-provider-crash-flakiness note), so retry the open a few times. - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try - { - var conn = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); - conn.Open(); - return conn; - } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; } - } - Thread.Sleep(50); - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider opened the database.", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static void CreateView(string path, string name) { @@ -55,8 +37,7 @@ private static void CreateView(string path, string name) [Fact] public void Stores_the_derived_table_source_the_access_way() { - string path = Path.Combine(Path.GetTempPath(), $"derived-view-store-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "derived-view-store-"); try { CreateView(path, "CSbyCity2"); @@ -72,15 +53,14 @@ public void Stores_the_derived_table_source_the_access_way() Assert.Equal("u", n2[t]); Assert.Equal(4, attr.Count(a => a == 6)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // The long subquery Expression (> 64 bytes) is written to an LVAL page, so Access can run the view. [Fact] public void Access_runs_a_derived_table_union_view() { - string path = Path.Combine(Path.GetTempPath(), $"derived-view-run-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "derived-view-run-"); try { CreateView(path, "CSbyCity2"); @@ -94,7 +74,7 @@ public void Access_runs_a_derived_table_union_view() seek.CommandText = "SELECT Relationship FROM CSbyCity2 WHERE City = 'London'"; Assert.NotNull(seek.ExecuteScalar()); // the view resolves and returns rows } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } private static (List Attr, List Expr, List Name1, List Name2) diff --git a/test/LibRed.Core.Tests/DistinctViewAccessTests.cs b/test/LibRed.Core.Tests/DistinctViewAccessTests.cs index a5aaa690..a819ba6a 100644 --- a/test/LibRed.Core.Tests/DistinctViewAccessTests.cs +++ b/test/LibRed.Core.Tests/DistinctViewAccessTests.cs @@ -11,26 +11,12 @@ namespace LibRed.Core.Tests; /// public class DistinctViewAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; } - } - Thread.Sleep(50); - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider opened the database.", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_runs_a_distinct_right_join_between_view() { - string path = Path.Combine(Path.GetTempPath(), $"distinct-view-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "distinct-view-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -46,6 +32,6 @@ public void Access_runs_a_distinct_right_join_between_view() count.CommandText = "SELECT COUNT(*) FROM QO2"; Assert.Equal(86, Convert.ToInt32(count.ExecuteScalar())); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/DropColumnAccessTests.cs b/test/LibRed.Core.Tests/DropColumnAccessTests.cs index 686bed57..7b3542d1 100644 --- a/test/LibRed.Core.Tests/DropColumnAccessTests.cs +++ b/test/LibRed.Core.Tests/DropColumnAccessTests.cs @@ -12,15 +12,7 @@ namespace LibRed.Core.Tests; // otherwise a survivor after a dropped variable column decodes the wrong slot. public class DropColumnAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No ACE provider"); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static void Ace(string path, params string[] sqls) { @@ -43,8 +35,7 @@ private static string[] ColsOfT(string path) [Fact] public void Reads_rows_correctly_after_ace_drops_a_column() { - string path = Path.Combine(Path.GetTempPath(), $"dropcol-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "dropcol-"); try { Ace(path, @@ -60,14 +51,13 @@ public void Reads_rows_correctly_after_ace_drops_a_column() Assert.Equal(["A", "D"], ColsOfT(path)); Assert.Equal([["1", "dee"], ["2", "doo"]], ReadT(path)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_reads_a_libred_dropped_column_table() { - string path = Path.Combine(Path.GetTempPath(), $"dropcol-lr-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "dropcol-lr-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -95,7 +85,7 @@ public void Access_reads_a_libred_dropped_column_table() Assert.True(reader.Read()); Assert.Equal(2, reader.GetInt32(0)); Assert.Equal("doo", reader.GetString(1)); Assert.False(reader.Read()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -106,8 +96,7 @@ public void Access_adds_a_column_after_a_libred_drop_byte_faithfully() // the pure-ACE (drop+add) path: after dropping B (colId 1, leaving a gap), ACE's added columns get the // NEXT ids (4, 5), not the gap; a new variable column appends (varIdx 2) and a fixed one appends // (fixedOff 8); old rows read the new columns as NULL. - string path = Path.Combine(Path.GetTempPath(), $"dropadd-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "dropadd-"); try { Ace(path, @@ -133,6 +122,6 @@ public void Access_adds_a_column_after_a_libred_drop_byte_faithfully() Assert.Equal(["1", "10", "dee", "", ""], rows[0]); // old row: new columns NULL Assert.Equal(["2", "20", "new", "eee", "99"], rows[1]); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/DropColumnConstraintAccessTests.cs b/test/LibRed.Core.Tests/DropColumnConstraintAccessTests.cs index dfccafd6..da18204f 100644 --- a/test/LibRed.Core.Tests/DropColumnConstraintAccessTests.cs +++ b/test/LibRed.Core.Tests/DropColumnConstraintAccessTests.cs @@ -8,13 +8,7 @@ namespace LibRed.Core.Tests; // this: it throws for an indexed/keyed column and for a column participating in a relationship.) public class DropColumnConstraintAccessTests { - private static OleDbConnection Open(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - throw new InvalidOperationException("no ace"); - } + private static OleDbConnection Open(string path) => AceTestDatabase.Open(path); private static void Ok(OleDbConnection c, string sql) { using var m = c.CreateCommand(); m.CommandText = sql; m.ExecuteNonQuery(); } @@ -28,8 +22,7 @@ private static string Error(OleDbConnection c, string sql) [Fact] public void Access_rejects_dropping_an_indexed_or_related_column() { - string path = Path.Combine(Path.GetTempPath(), $"dcc-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "dcc-"); try { using var c = Open(path); @@ -42,6 +35,6 @@ public void Access_rejects_dropping_an_indexed_or_related_column() Assert.Contains("relationship", Error(c, "ALTER TABLE C DROP COLUMN Pid"), StringComparison.OrdinalIgnoreCase); // FK child Assert.Contains("relationship", Error(c, "ALTER TABLE P DROP COLUMN Id"), StringComparison.OrdinalIgnoreCase); // FK parent } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/DropColumnLvPropAccessTests.cs b/test/LibRed.Core.Tests/DropColumnLvPropAccessTests.cs index dcd11b93..6b774b21 100644 --- a/test/LibRed.Core.Tests/DropColumnLvPropAccessTests.cs +++ b/test/LibRed.Core.Tests/DropColumnLvPropAccessTests.cs @@ -10,13 +10,7 @@ namespace LibRed.Core.Tests; // of the column's property block), and ACE still opens/reads the result. public class DropColumnLvPropAccessTests { - private static OleDbConnection Open(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - throw new InvalidOperationException("no ace"); - } + private static OleDbConnection Open(string path) => AceTestDatabase.Open(path); private static void Ace(string path, params string[] sqls) { using var c = Open(path); foreach (var s in sqls) { using var m = c.CreateCommand(); m.CommandText = s; m.ExecuteNonQuery(); } } @@ -34,8 +28,7 @@ private static (IReadOnlyDictionary Defaults, IReadOnlySet public class DropConstraintAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No provider"); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_opens_a_libred_file_after_dropping_a_foreign_key() { - string path = Path.Combine(Path.GetTempPath(), $"dropc-ace-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "dropc-ace-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -56,6 +47,6 @@ public void Access_opens_a_libred_file_after_dropping_a_foreign_key() using (var c = conn.CreateCommand()) { c.CommandText = "INSERT INTO Child (Id, ParentId) VALUES (2, 99)"; Assert.Equal(1, c.ExecuteNonQuery()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/DropIndexAccessTests.cs b/test/LibRed.Core.Tests/DropIndexAccessTests.cs index 2a6101cb..0baa2a3c 100644 --- a/test/LibRed.Core.Tests/DropIndexAccessTests.cs +++ b/test/LibRed.Core.Tests/DropIndexAccessTests.cs @@ -9,21 +9,14 @@ namespace LibRed.Core.Tests; // LibRed mirrors this, and ACE opens+reads a LibRed-index-dropped file. public class DropIndexAccessTests { - private static OleDbConnection Open(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - throw new InvalidOperationException("no ace"); - } + private static OleDbConnection Open(string path) => AceTestDatabase.Open(path); private static void Ace(string path, params string[] sqls) { using var c = Open(path); foreach (var s in sqls) { using var m = c.CreateCommand(); m.CommandText = s; m.ExecuteNonQuery(); } } [Fact] public void Access_reads_a_libred_index_dropped_table() { - string path = Path.Combine(Path.GetTempPath(), $"dropix-lr-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "dropix-lr-"); try { // ACE creates the table + indexes + rows so the TDEF is authentic. @@ -53,6 +46,6 @@ public void Access_reads_a_libred_index_dropped_table() cmd2.CommandText = "SELECT Name FROM T WHERE Id = 2"; Assert.Equal("b", cmd2.ExecuteScalar()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/DropTableAccessTests.cs b/test/LibRed.Core.Tests/DropTableAccessTests.cs index 2ce9ddab..48358d47 100644 --- a/test/LibRed.Core.Tests/DropTableAccessTests.cs +++ b/test/LibRed.Core.Tests/DropTableAccessTests.cs @@ -9,21 +9,14 @@ namespace LibRed.Core.Tests; // table, reads the other tables, and reuses the freed pages when creating a new table. public class DropTableAccessTests { - private static OleDbConnection Open(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - throw new InvalidOperationException("no ace"); - } + private static OleDbConnection Open(string path) => AceTestDatabase.Open(path); private static void Ace(string path, params string[] sqls) { using var c = Open(path); foreach (var s in sqls) { using var m = c.CreateCommand(); m.CommandText = s; m.ExecuteNonQuery(); } } [Fact] public void Access_reads_a_libred_table_dropped_file_and_reuses_the_pages() { - string path = Path.Combine(Path.GetTempPath(), $"droptab-lr-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "droptab-lr-"); try { // ACE creates the tables + rows so the catalog/pages are authentic. @@ -60,6 +53,6 @@ public void Access_reads_a_libred_table_dropped_file_and_reuses_the_pages() Assert.True(new FileInfo(path).Length <= pagesBeforeDrop, $"file grew ({new FileInfo(path).Length} > {pagesBeforeDrop}) — freed pages were not reused"); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/DropViewAccessTests.cs b/test/LibRed.Core.Tests/DropViewAccessTests.cs index f6d12fe7..c2e13ef5 100644 --- a/test/LibRed.Core.Tests/DropViewAccessTests.cs +++ b/test/LibRed.Core.Tests/DropViewAccessTests.cs @@ -9,21 +9,14 @@ namespace LibRed.Core.Tests; // the file, no longer sees the object, and still runs the surviving views. public class DropViewAccessTests { - private static OleDbConnection Open(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - throw new InvalidOperationException("no ace"); - } + private static OleDbConnection Open(string path) => AceTestDatabase.Open(path); private static void Ace(string path, params string[] sqls) { using var c = Open(path); foreach (var s in sqls) { using var m = c.CreateCommand(); m.CommandText = s; m.ExecuteNonQuery(); } } [Fact] public void Access_reads_a_libred_view_dropped_file() { - string path = Path.Combine(Path.GetTempPath(), $"dropview-lr-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "dropview-lr-"); try { // ACE creates the views so the MSysObjects/MSysQueries/MSysACEs rows are authentic. @@ -47,6 +40,6 @@ public void Access_reads_a_libred_view_dropped_file() cmd.CommandText = "SELECT COUNT(*) FROM Keep"; Assert.True(Convert.ToInt32(cmd.ExecuteScalar()) > 0); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/FixedCharEncodingTests.cs b/test/LibRed.Core.Tests/FixedCharEncodingTests.cs index 1f3ca1d0..23b14318 100644 --- a/test/LibRed.Core.Tests/FixedCharEncodingTests.cs +++ b/test/LibRed.Core.Tests/FixedCharEncodingTests.cs @@ -10,23 +10,12 @@ namespace LibRed.Core.Tests; // "Column ... encoded to N bytes, expected M". This covers the round-trip and the ACE read-back. public class FixedCharEncodingTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Libred_inserts_and_reads_a_fixed_char_column() { - string path = Path.Combine(Path.GetTempPath(), $"fc-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "fc-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -39,14 +28,13 @@ [new ColumnSpec("K", JetDataType.Int32, 4, IsFixedLength: true), object? v = db.OpenTable("T").Rows().First()[1]; Assert.Equal("Eastern".PadRight(50), v); // fixed CHAR reads back space-padded (like ACE) } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_reads_a_libred_inserted_fixed_char() { - string path = Path.Combine(Path.GetTempPath(), $"fca-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "fca-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -64,6 +52,6 @@ [new ColumnSpec("RegionID", JetDataType.Int32, 4, IsFixedLength: true), string v = (string)c.ExecuteScalar()!; Assert.Equal("Eastern", v.TrimEnd()); // ACE reads it (space-padded); trimmed content matches } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/ForeignKeyLinkageTests.cs b/test/LibRed.Core.Tests/ForeignKeyLinkageTests.cs index d9c8465b..bb843bf2 100644 --- a/test/LibRed.Core.Tests/ForeignKeyLinkageTests.cs +++ b/test/LibRed.Core.Tests/ForeignKeyLinkageTests.cs @@ -42,8 +42,7 @@ private static List ReadLogicalBlocks(PageChannel ch, int page) [Fact] public void Child_and_parent_carry_cross_linked_relationship_blocks() { - string path = Path.Combine(Path.GetTempPath(), $"fklink-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "fklink-"); try { int parentPage, childPage; @@ -82,7 +81,7 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), // The parent's own primary key is untouched (still present, not a relationship). Assert.Contains(parent, b => b.Type == 0x01 && b.FkType == 0x00); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // FOREIGN KEY NO INDEX: ACE flags the child's outgoing block 0x03 instead of 0x02; the parent @@ -90,8 +89,7 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), [Fact] public void No_index_relationship_flags_the_child_block_0x03() { - string path = Path.Combine(Path.GetTempPath(), $"fknoidx-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "fknoidx-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -109,7 +107,7 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), Assert.Equal(0x03, child.Single(b => b.Name == "FKni").FkType); Assert.Equal(0x01, parent.Single(b => b.FkType == 0x01).FkType); // parent side unchanged } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A self-referencing foreign key (the table is its own parent) hosts BOTH ends in its one TDEF — @@ -118,8 +116,7 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), [Fact] public void Self_referencing_relationship_hosts_both_ends_in_one_table() { - string path = Path.Combine(Path.GetTempPath(), $"fkself-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "fkself-"); try { int page; @@ -144,14 +141,13 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), Assert.Equal((uint)incoming.Num, outgoing.FkNum); // cross-linked within the one table Assert.Equal((uint)outgoing.Num, incoming.FkNum); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Cascade_actions_are_written_on_both_ends() { - string path = Path.Combine(Path.GetTempPath(), $"fkcasc-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "fkcasc-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -174,6 +170,6 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), Assert.Equal(0x01, incoming.Upd); Assert.Equal(0x01, incoming.Del); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/FunctionalTestsSmokeTest.cs b/test/LibRed.Core.Tests/FunctionalTestsSmokeTest.cs index 9d7f9157..968a8c19 100644 --- a/test/LibRed.Core.Tests/FunctionalTestsSmokeTest.cs +++ b/test/LibRed.Core.Tests/FunctionalTestsSmokeTest.cs @@ -1,4 +1,3 @@ -using System.Runtime.CompilerServices; using LibRed; using LibRed.Catalog; using Xunit; @@ -14,45 +13,27 @@ public class FunctionalTestsSmokeTest(ITestOutputHelper output) { private readonly ITestOutputHelper _output = output; - [Fact] - public void Reads_the_builtin_datatypes_database() + public static TheoryData TrackedSchemaCorpus => + [ + TestDatabases.NorthwindAccdb, + TestDatabases.BuiltInDataTypesAccdb, + TestDatabases.EverythingIsBytesAccdb, + TestDatabases.DecimalsAccdb, + TestDatabases.WideTableAccdb, + TestDatabases.Ace16TypesAccdb, + ]; + + [Theory] + [MemberData(nameof(TrackedSchemaCorpus))] + public void Reads_every_table_and_row_in_the_tracked_schema_corpus(string path) { - var (tables, rows, failures) = Scan(TestDatabases.BuiltInDataTypesAccdb); + var (tables, rows, failures) = Scan(path); - _output.WriteLine($"tables={tables} rows={rows}"); + _output.WriteLine($"file={Path.GetFileName(path)} tables={tables} rows={rows}"); Assert.Empty(failures); Assert.True(tables > 0); } - [Fact] - public void Reads_the_full_functional_test_corpus_when_present() - { - // A ~70 MB local-only corpus (gitignored); present on a dev box that has run the - // EFCore.Jet functional tests, absent on CI. - string dir = Path.Combine(SourceDirectory(), "FunctionalTestsData"); - var files = Directory.Exists(dir) ? Directory.GetFiles(dir, "*.accdb") : []; - if (files.Length == 0) - { - _output.WriteLine("corpus not present — skipping"); - return; - } - - long totalTables = 0, totalRows = 0; - var allFailures = new List(); - foreach (string file in files) - { - var (tables, rows, failures) = Scan(file); - totalTables += tables; - totalRows += rows; - allFailures.AddRange(failures); - } - - _output.WriteLine($"files={files.Length} tables={totalTables} rows={totalRows}"); - foreach (string failure in allFailures) - _output.WriteLine(failure); - Assert.Empty(allFailures); - } - private static (int Tables, long Rows, List Failures) Scan(string path) { var failures = new List(); @@ -82,6 +63,4 @@ private static (int Tables, long Rows, List Failures) Scan(string path) return (tables, rows, failures); } - - private static string SourceDirectory([CallerFilePath] string path = "") => Path.GetDirectoryName(path)!; } diff --git a/test/LibRed.Core.Tests/GlobalReferenceFreeMapTests.cs b/test/LibRed.Core.Tests/GlobalReferenceFreeMapTests.cs index 5c8e4370..93653c3b 100644 --- a/test/LibRed.Core.Tests/GlobalReferenceFreeMapTests.cs +++ b/test/LibRed.Core.Tests/GlobalReferenceFreeMapTests.cs @@ -43,7 +43,7 @@ public void Allocate_and_free_through_a_reference_type_global_map() WriteBitmapPage(file, 2 * pageSize, inRangeBit: 5); WriteBitmapPage(file, 3 * pageSize, inRangeBit: null); - string path = Path.Combine(Path.GetTempPath(), $"libred-globalref-{Guid.NewGuid():N}.accdb"); + string path = TemporaryDatabase.CreatePath("libred-globalref-"); File.WriteAllBytes(path, file); try { @@ -58,7 +58,7 @@ public void Allocate_and_free_through_a_reference_type_global_map() } finally { - File.Delete(path); + TemporaryDatabase.Delete(path); } } diff --git a/test/LibRed.Core.Tests/GroupByViewAccessTests.cs b/test/LibRed.Core.Tests/GroupByViewAccessTests.cs index ba6fe9aa..5ef9fc72 100644 --- a/test/LibRed.Core.Tests/GroupByViewAccessTests.cs +++ b/test/LibRed.Core.Tests/GroupByViewAccessTests.cs @@ -11,26 +11,12 @@ namespace LibRed.Core.Tests; /// public class GroupByViewAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; } - } - Thread.Sleep(50); - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider opened the database.", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_runs_a_group_by_totals_view() { - string path = Path.Combine(Path.GetTempPath(), $"groupby-view-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "groupby-view-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -55,6 +41,6 @@ public void Access_runs_a_group_by_totals_view() sub.CommandText = "SELECT Subtotal FROM Subtotals WHERE OrderID = 10248"; Assert.Equal(440m, Convert.ToDecimal(sub.ExecuteScalar())); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/GuidKeyEncodingTests.cs b/test/LibRed.Core.Tests/GuidKeyEncodingTests.cs index bb9fcfdb..ce124c33 100644 --- a/test/LibRed.Core.Tests/GuidKeyEncodingTests.cs +++ b/test/LibRed.Core.Tests/GuidKeyEncodingTests.cs @@ -8,15 +8,7 @@ namespace LibRed.Core.Tests; public class GuidKeyEncodingTests { - private static OleDbConnection OpenOleDb(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider available."); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static readonly Guid[] Guids = [ @@ -32,8 +24,7 @@ private static OleDbConnection OpenOleDb(string path) public void Encoded_guid_keys_match_access_byte_for_byte() { // Build a real GUID-PK table via Access, then check our encoder reproduces each stored key. - string path = Path.Combine(Path.GetTempPath(), $"libred-guidkey-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-guidkey-"); try { using (var conn = OpenOleDb(path)) @@ -75,7 +66,7 @@ public void Encoded_guid_keys_match_access_byte_for_byte() } Assert.Equal(Guids.Length, checkd); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -83,8 +74,7 @@ public void Encoded_descending_guid_keys_match_access_byte_for_byte() { // A DESCENDING GUID index: ACE inverts every byte of the ascending key except the 0x09 field // marker. Build it via Access, then confirm our encoder reproduces each stored key (and decodes). - string path = Path.Combine(Path.GetTempPath(), $"libred-guiddesc-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-guiddesc-"); try { using (var conn = OpenOleDb(path)) @@ -127,7 +117,7 @@ public void Encoded_descending_guid_keys_match_access_byte_for_byte() } Assert.Equal(Guids.Length, checkd); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -135,8 +125,7 @@ public void Access_reads_a_libred_created_guid_key_table() { // The mirror direction: LibRed writes the GUID-PK table + rows, and ACE opens it, seeks by the // key, and returns every row in key order — proving the index keys we wrote are well-ordered. - string path = Path.Combine(Path.GetTempPath(), $"libred-guidwr-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-guidwr-"); try { var target = Guids[4]; // 12345678-... @@ -163,6 +152,6 @@ public void Access_reads_a_libred_created_guid_key_table() Assert.Equal(4, Convert.ToInt32(c.ExecuteScalar())); // seek by the GUID key finds the row } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/IndexColumnLimitTests.cs b/test/LibRed.Core.Tests/IndexColumnLimitTests.cs index 57521fa9..02a3e0e2 100644 --- a/test/LibRed.Core.Tests/IndexColumnLimitTests.cs +++ b/test/LibRed.Core.Tests/IndexColumnLimitTests.cs @@ -11,8 +11,7 @@ public class IndexColumnLimitTests [Fact] public void Primary_key_over_more_than_ten_columns_throws() { - string path = Path.Combine(Path.GetTempPath(), $"idxlimit-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "idxlimit-"); try { var columns = Enumerable.Range(0, 11) @@ -24,7 +23,7 @@ public void Primary_key_over_more_than_ten_columns_throws() var ex = Assert.Throws(() => db.CreateTable("Wide", columns, primaryKey: keyColumns)); Assert.Contains("10", ex.Message); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Jet/ACE caps a table at 32 indexes (keys + relationships included). A primary key plus 32 unique @@ -32,8 +31,7 @@ public void Primary_key_over_more_than_ten_columns_throws() [Fact] public void More_than_thirty_two_indexes_throws() { - string path = Path.Combine(Path.GetTempPath(), $"idxcount-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "idxcount-"); try { var columns = Enumerable.Range(0, 33) @@ -48,6 +46,6 @@ public void More_than_thirty_two_indexes_throws() db.CreateTable("Many", columns, primaryKey: ["C0"], relationships: null, uniqueConstraints: uniques)); Assert.Contains("32", ex.Message); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/IndexKeyEncoderTests.cs b/test/LibRed.Core.Tests/IndexKeyEncoderTests.cs index 8d1c1c72..56639b53 100644 --- a/test/LibRed.Core.Tests/IndexKeyEncoderTests.cs +++ b/test/LibRed.Core.Tests/IndexKeyEncoderTests.cs @@ -75,6 +75,114 @@ public void Round_trips_double_keys() } } + public static TheoryData ReversibleBoundaryValues => new() + { + { JetDataType.Byte, byte.MinValue }, + { JetDataType.Byte, byte.MaxValue }, + { JetDataType.Int16, short.MinValue }, + { JetDataType.Int16, short.MaxValue }, + { JetDataType.Int32, int.MinValue }, + { JetDataType.Int32, int.MaxValue }, + { JetDataType.Single, float.MinValue }, + { JetDataType.Single, -0.0f }, + { JetDataType.Single, float.MaxValue }, + { JetDataType.Double, double.MinValue }, + { JetDataType.Double, -0.0d }, + { JetDataType.Double, double.MaxValue }, + { JetDataType.Currency, -922337203685477.5808m }, + { JetDataType.Currency, 922337203685477.5807m }, + { JetDataType.DateTime, new DateTime(1800, 1, 1) }, + { JetDataType.DateTime, new DateTime(9999, 12, 31) }, + }; + + [Theory] + [MemberData(nameof(ReversibleBoundaryValues))] + public void Reversible_boundary_values_round_trip_in_both_directions(JetDataType type, object value) + { + var column = new ColumnDef { Name = "K", Type = type, Index = 0 }; + foreach (bool ascending in new[] { true, false }) + { + var columns = new[] { (column, ascending) }; + byte[] key = IndexKeyEncoder.Encode(columns, [value]); + Assert.Equal(value, IndexKeyDecoder.Decode(columns, key)[0]); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Null_round_trips_for_each_reversible_key_kind(bool ascending) + { + foreach (JetDataType type in new[] + { + JetDataType.Byte, JetDataType.Int16, JetDataType.Int32, JetDataType.Single, + JetDataType.Double, JetDataType.Currency, JetDataType.DateTime, JetDataType.Guid, + }) + { + var column = new ColumnDef { Name = "K", Type = type, Index = 0 }; + var columns = new[] { (column, ascending) }; + Assert.Null(IndexKeyDecoder.Decode(columns, IndexKeyEncoder.Encode(columns, [null]))[0]); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Guid_round_trips_in_both_directions(bool ascending) + { + var column = new ColumnDef { Name = "K", Type = JetDataType.Guid, Index = 0 }; + var columns = new[] { (column, ascending) }; + var values = new object?[] { Guid.Parse("00112233-4455-6677-8899-aabbccddeeff") }; + Assert.Equal(values[0], IndexKeyDecoder.Decode(columns, IndexKeyEncoder.Encode(columns, values))[0]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Boolean_null_is_normalized_to_false_because_boolean_keys_have_no_null_flag(bool ascending) + { + var column = new ColumnDef { Name = "K", Type = JetDataType.Boolean, Index = 0 }; + var columns = new[] { (column, ascending) }; + byte[] falseKey = IndexKeyEncoder.Encode(columns, [false]); + byte[] trueKey = IndexKeyEncoder.Encode(columns, [true]); + byte[] nullKey = IndexKeyEncoder.Encode(columns, [null]); + + Assert.NotEqual(falseKey, trueKey); + Assert.NotEqual(trueKey, nullKey); + Assert.Equal(falseKey, nullKey); + Assert.False((bool)IndexKeyDecoder.Decode(columns, falseKey)[0]!); + Assert.True((bool)IndexKeyDecoder.Decode(columns, trueKey)[0]!); + Assert.False((bool)IndexKeyDecoder.Decode(columns, nullKey)[0]!); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Truncated_fixed_and_guid_keys_stop_without_reading_past_the_payload(bool ascending) + { + var integer = new ColumnDef { Name = "I", Type = JetDataType.Int32, Index = 0 }; + var integerColumns = new[] { (integer, ascending) }; + byte[] integerKey = IndexKeyEncoder.Encode(integerColumns, [123]); + Assert.Null(IndexKeyDecoder.Decode(integerColumns, integerKey[..^1])[0]); + + var guid = new ColumnDef { Name = "G", Type = JetDataType.Guid, Index = 0 }; + var guidColumns = new[] { (guid, ascending) }; + byte[] guidKey = IndexKeyEncoder.Encode(guidColumns, [Guid.NewGuid()]); + Assert.Null(IndexKeyDecoder.Decode(guidColumns, guidKey[..^1])[0]); + } + + [Fact] + public void Descending_integer_bytes_sort_in_reverse_value_order() + { + var column = new ColumnDef { Name = "K", Type = JetDataType.Int32, Index = 0 }; + var columns = new[] { (column, false) }; + int[] ascendingValues = [-5, -1, 0, 1, 5, 100]; + byte[][] keys = ascendingValues.Select(v => IndexKeyEncoder.Encode(columns, [v])).ToArray(); + + for (int i = 1; i < keys.Length; i++) + Assert.True(Compare(keys[i - 1], keys[i]) > 0); + } + private static object?[] AlignToColumns(TableDef table, IndexDef index, object?[] keyValues) { // IndexKeyDecoder returns values in index-column order; the encoder reads values[column.Index]. diff --git a/test/LibRed.Core.Tests/IndexMaintenanceAccessTests.cs b/test/LibRed.Core.Tests/IndexMaintenanceAccessTests.cs new file mode 100644 index 00000000..5bd2a075 --- /dev/null +++ b/test/LibRed.Core.Tests/IndexMaintenanceAccessTests.cs @@ -0,0 +1,142 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +/// Cross-engine update/delete coverage for split indexes and relocated indexed rows. +public class IndexMaintenanceAccessTests +{ + private const int RowCount = 900; + + [Fact] + public void Ace_sees_libred_index_moves_deletes_and_relocation_after_splits() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "index-maint-lr-"); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + { + db.CreateTable("Maint", + [ + new("Id", JetDataType.Int32, 4, IsFixedLength: true), + new("K", JetDataType.Int32, 4, IsFixedLength: true), + new("S", JetDataType.Text, 255 * 2, IsFixedLength: false), + ], primaryKey: ["Id"], relationships: null, + uniqueConstraints: [new UniqueIndexSpec("IX_K", ["K"])]); + Table table = db.OpenTable("Maint"); + for (int i = 1; i <= RowCount; i++) table.Insert([i, i, "x"]); + + IndexDef pk = table.Definition.Indexes.Single(i => i.IsPrimaryKey); + IndexDef ixK = table.Definition.Indexes.Single(i => i.Name == "IX_K"); + Move(table, pk, ixK, 300, newId: 1300, newKey: 2300, new string('R', 255)); + Delete(table, pk, ixK, 400); + } + + using var connection = AceTestDatabase.Open(path); + AssertScalar(connection, "SELECT K FROM Maint WHERE Id = 1300", 2300); + AssertScalar(connection, "SELECT Id FROM Maint WHERE K = 2300", 1300); + AssertScalar(connection, "SELECT COUNT(*) FROM Maint WHERE Id = 300 OR K = 300", 0); + AssertScalar(connection, "SELECT COUNT(*) FROM Maint WHERE Id = 400 OR K = 400", 0); + AssertScalar(connection, "SELECT COUNT(*) FROM Maint", RowCount - 1); + AssertScalar(connection, "SELECT COUNT(*) FROM Maint WHERE Id BETWEEN 895 AND 900", 6); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Libred_sees_ace_index_moves_deletes_and_relocation_after_splits() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "index-maint-ace-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Execute(connection, "CREATE TABLE Maint (Id INT CONSTRAINT PK_Maint PRIMARY KEY, K INT, S VARCHAR(255))"); + Execute(connection, "CREATE UNIQUE INDEX IX_K ON Maint (K)"); + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Maint (Id, K, S) VALUES (?, ?, 'x')"; + insert.Parameters.Add("id", OleDbType.Integer); + insert.Parameters.Add("k", OleDbType.Integer); + for (int i = 1; i <= RowCount; i++) + { + insert.Parameters[0].Value = i; + insert.Parameters[1].Value = i; + insert.ExecuteNonQuery(); + } + Execute(connection, $"UPDATE Maint SET Id = 1300, K = 2300, S = '{new string('R', 255)}' WHERE Id = 300"); + Execute(connection, "DELETE FROM Maint WHERE Id = 400"); + } + + using var db = JetDatabase.Open(path); + Table table = db.OpenTable("Maint"); + IndexDef pk = table.Definition.Indexes.Single(i => i.IsPrimaryKey); + IndexDef ixK = table.Definition.Indexes.Single(i => i.Name == "IX_K"); + Assert.Equal(2300, Value(table, table.SeekRows(pk, [1300]).Single(), "K")); + Assert.Equal(1300, Value(table, table.SeekRows(ixK, [null, 2300]).Single(), "Id")); + Assert.Empty(table.SeekRows(pk, [300])); + Assert.Empty(table.SeekRows(ixK, [null, 300])); + Assert.Empty(table.SeekRows(pk, [400])); + Assert.Empty(table.SeekRows(ixK, [null, 400])); + Assert.Equal(RowCount - 1, table.Rows().Count()); + + AssertStoredKeysMatchRows(table, pk); + AssertStoredKeysMatchRows(table, ixK); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Move(Table table, IndexDef pk, IndexDef ixK, int id, int newId, int newKey, string text) + { + int idIndex = table.Definition.FindColumn("Id")!.Index; + int keyIndex = table.Definition.FindColumn("K")!.Index; + int textIndex = table.Definition.FindColumn("S")!.Index; + (RowId rowId, object?[] oldValues) = table.SeekRowsWithIds(pk, [id]).Single(); + var newValues = (object?[])oldValues.Clone(); + newValues[idIndex] = newId; + newValues[keyIndex] = newKey; + newValues[textIndex] = text; + table.Update(rowId, newValues); + table.MoveIndexEntry(pk, oldValues, newValues, rowId); + table.MoveIndexEntry(ixK, oldValues, newValues, rowId); + } + + private static void Delete(Table table, IndexDef pk, IndexDef ixK, int id) + { + (RowId rowId, object?[] values) = table.SeekRowsWithIds(pk, [id]).Single(); + table.RemoveIndexEntry(pk, values, rowId); + table.RemoveIndexEntry(ixK, values, rowId); + table.Delete(rowId); + } + + private static void AssertStoredKeysMatchRows(Table table, IndexDef index) + { + int entries = 0; + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + { + object?[] row = Assert.IsType(table.GetRow(rowId)); + Assert.Equal(stored, IndexKeyEncoder.Encode(index.Columns, row)); + entries++; + } + Assert.Equal(RowCount - 1, entries); + } + + private static int Value(Table table, object?[] row, string column) => + Convert.ToInt32(row[table.Definition.FindColumn(column)!.Index]); + + private static void AssertScalar(OleDbConnection connection, string sql, int expected) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + Assert.Equal(expected, Convert.ToInt32(command.ExecuteScalar())); + } + + private static void Execute(OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Core.Tests/IndexOrderingAccessTests.cs b/test/LibRed.Core.Tests/IndexOrderingAccessTests.cs new file mode 100644 index 00000000..caa4ff89 --- /dev/null +++ b/test/LibRed.Core.Tests/IndexOrderingAccessTests.cs @@ -0,0 +1,134 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +/// Checks boundary-value index byte encoding and traversal order against ACE's actual ORDER BY. +public class IndexOrderingAccessTests +{ + public static TheoryData KeyFamilies => new() + { + { "INT", OleDbType.Integer, [int.MinValue, -1, 0, 1, int.MaxValue, null] }, + { "SINGLE", OleDbType.Single, [-1000f, -0.25f, 0f, 0.25f, 1000f, null] }, + { "DOUBLE", OleDbType.Double, [-1e100, -0.5d, 0d, 0.5d, 1e100, null] }, + { "CURRENCY", OleDbType.Currency, [-922337203685477.5808m, -0.0001m, 0m, 0.0001m, 922337203685477.5807m, null] }, + { "DATETIME", OleDbType.Date, [new DateTime(1850, 6, 15, 10, 30, 0), new DateTime(1899, 12, 29, 6, 0, 0), new DateTime(1899, 12, 29, 18, 0, 0), new DateTime(1900, 1, 1), new DateTime(9999, 12, 31), null] }, + { "GUID", OleDbType.Guid, [Guid.Empty, new Guid("00000000-0000-0000-0000-000000000001"), new Guid("01020304-0506-0708-090a-0b0c0d0e0f10"), new Guid("ffffffff-ffff-ffff-ffff-ffffffffffff"), null] }, + { "VARBINARY(16)", OleDbType.VarBinary, [new byte[] { 0 }, new byte[] { 1, 0 }, new byte[] { 0, 1, 2, 3, 4, 5, 6, 7 }, new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8 }, Enumerable.Range(0, 16).Select(i => (byte)i).ToArray(), null] }, + { "VARCHAR(50)", OleDbType.VarWChar, ["", "0", "A", "A-B", "O'Brien", "Z", null] }, + }; + + [Theory] + [MemberData(nameof(KeyFamilies))] + public void Libred_key_bytes_and_traversal_match_ace_for_boundary_values( + string storeType, OleDbType parameterType, object?[] values) + { + foreach (bool ascending in new[] { true, false }) + AssertFamily(storeType, parameterType, values, ascending); + } + + [Theory] + [InlineData(true, "7F")] + [InlineData(false, "80")] + public void Empty_binary_key_matches_ace_start_marker_only(bool ascending, string expectedHex) + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "empty-binary-key-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Execute(connection, "CREATE TABLE EmptyBinaryKey (K VARBINARY(16), V INT NOT NULL)"); + Execute(connection, $"CREATE INDEX IX_EmptyBinaryKey ON EmptyBinaryKey (K {(ascending ? "ASC" : "DESC")})"); + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO EmptyBinaryKey (K, V) VALUES (?, 7)"; + insert.Parameters.Add(new OleDbParameter("k", OleDbType.VarBinary) { Value = Array.Empty() }); + insert.ExecuteNonQuery(); + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("EmptyBinaryKey"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_EmptyBinaryKey"); + (byte[] stored, RowId rowId) = Assert.Single(new IndexCursor(table.Channel, index.RootPage).RawEntries()); + Assert.Equal(expectedHex, Convert.ToHexString(stored)); + + object?[] row = new RowDecoder(table.Definition.Columns, db.Format) + .Decode(db.ReadDataPage(rowId.Page).GetRow(rowId.Row)); + var aligned = new object?[table.Definition.Columns.Count]; + aligned[table.Definition.FindColumn("K")!.Index] = row[table.Definition.FindColumn("K")!.Index]; + Assert.Equal(stored, IndexKeyEncoder.Encode(index.Columns, aligned)); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void AssertFamily(string storeType, OleDbType parameterType, object?[] values, bool ascending) + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "index-order-oracle-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Execute(connection, $"CREATE TABLE BoundaryKeys (K {storeType}, V INT NOT NULL)"); + Execute(connection, $"CREATE INDEX IX_BoundaryKeys ON BoundaryKeys (K {(ascending ? "ASC" : "DESC")})"); + for (int i = 0; i < values.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO BoundaryKeys (K, V) VALUES (?, ?)"; + insert.Parameters.Add(new OleDbParameter("k", parameterType) { Value = values[i] ?? DBNull.Value }); + insert.Parameters.Add(new OleDbParameter("v", OleDbType.Integer) { Value = i }); + insert.ExecuteNonQuery(); + } + } + + int[] aceOrder; + using (var connection = AceTestDatabase.Open(path)) + using (var command = connection.CreateCommand()) + { + command.CommandText = $"SELECT V FROM BoundaryKeys ORDER BY K {(ascending ? "ASC" : "DESC")}"; + using var reader = command.ExecuteReader(); + var ids = new List(); + while (reader.Read()) ids.Add(Convert.ToInt32(reader[0])); + aceOrder = [.. ids]; + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("BoundaryKeys"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_BoundaryKeys"); + int keyIndex = table.Definition.FindColumn("K")!.Index; + int valueIndex = table.Definition.FindColumn("V")!.Index; + var decoder = new RowDecoder(table.Definition.Columns, db.Format); + var libredOrder = new List(); + + foreach ((byte[] storedKey, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + { + object?[] row = decoder.Decode(db.ReadDataPage(rowId.Page).GetRow(rowId.Row)); + var aligned = new object?[table.Definition.Columns.Count]; + aligned[keyIndex] = row[keyIndex]; + byte[] encoded = IndexKeyEncoder.Encode(index.Columns, aligned); + Assert.True(storedKey.AsSpan().SequenceEqual(encoded), + $"V={row[valueIndex]}, K={Describe(row[keyIndex])}: ACE={Convert.ToHexString(storedKey)} LibRed={Convert.ToHexString(encoded)}"); + libredOrder.Add(Convert.ToInt32(row[valueIndex])); + } + + Assert.True(aceOrder.SequenceEqual(libredOrder), + $"ACE ORDER BY=[{string.Join(',', aceOrder)}], index traversal=[{string.Join(',', libredOrder)}]"); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Execute(OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + private static string Describe(object? value) => value switch + { + null => "NULL", + byte[] bytes => Convert.ToHexString(bytes), + _ => value.ToString() ?? "NULL", + }; +} diff --git a/test/LibRed.Core.Tests/IndexSplitAccessTests.cs b/test/LibRed.Core.Tests/IndexSplitAccessTests.cs index fc0cbc1a..531f23a3 100644 --- a/test/LibRed.Core.Tests/IndexSplitAccessTests.cs +++ b/test/LibRed.Core.Tests/IndexSplitAccessTests.cs @@ -15,26 +15,12 @@ public class IndexSplitAccessTests { private const int N = 1200; // well past one leaf, so the PK B-tree splits and the root grows a level - private static OleDbConnection OpenOleDb(string path) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try - { - var conn = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); - conn.Open(); - return conn; - } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider (12.0/16.0) is available."); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_reads_a_split_index_by_seek_and_range() { - string path = Path.Combine(Path.GetTempPath(), $"libred-splitaccess-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-splitaccess-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -74,6 +60,6 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), Assert.Equal(Convert.ToInt64(expected), Convert.ToInt64(cmd.ExecuteScalar())); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/IndexTraversalCorruptionTests.cs b/test/LibRed.Core.Tests/IndexTraversalCorruptionTests.cs index 7843c398..77f1a2cf 100644 --- a/test/LibRed.Core.Tests/IndexTraversalCorruptionTests.cs +++ b/test/LibRed.Core.Tests/IndexTraversalCorruptionTests.cs @@ -10,6 +10,7 @@ public class IndexTraversalCorruptionTests { private const int PageSize = 4096; private const int OwnerOffset = 0x04; + private const int PreviousPageOffset = 0x0C; private const int NextPageOffset = 0x10; private const int ChildTailOffset = 0x14; private const int EntryMaskOffset = 0x1B; @@ -18,7 +19,10 @@ public class IndexTraversalCorruptionTests [Theory] [InlineData("wrong-owner")] [InlineData("child-outside-file")] + [InlineData("child-zero")] + [InlineData("leaf-previous-outside-file")] [InlineData("leaf-next-outside-file")] + [InlineData("leaf-row-outside-file")] [InlineData("entry-shorter-than-trailer")] [InlineData("wrong-page-type")] [InlineData("descent-cycle")] @@ -28,8 +32,7 @@ public class IndexTraversalCorruptionTests [InlineData("compressed-prefix-too-long")] public void Malformed_index_traversal_is_rejected_as_corruption(string corruption) { - string path = Path.Combine(Path.GetTempPath(), $"index-corrupt-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "index-corrupt-"); try { (int root, int owner) = IndexIdentity(path); @@ -44,10 +47,24 @@ public void Malformed_index_traversal_is_rejected_as_corruption(string corruptio case "child-outside-file": BinaryPrimitives.WriteInt32LittleEndian(rootPage[ChildTailOffset..], pageCount + 1); break; + case "child-zero": + BinaryPrimitives.WriteInt32LittleEndian(rootPage[ChildTailOffset..], 0); + break; + case "leaf-previous-outside-file": + int previousLeaf = LeftmostLeaf(file, root); + BinaryPrimitives.WriteInt32LittleEndian(Page(file, previousLeaf)[PreviousPageOffset..], pageCount + 1); + break; case "leaf-next-outside-file": int leaf = LeftmostLeaf(file, root); BinaryPrimitives.WriteInt32LittleEndian(Page(file, leaf)[NextPageOffset..], pageCount + 1); break; + case "leaf-row-outside-file": + int rowLeaf = LeftmostLeaf(file, root); + Span rowLeafPage = Page(file, rowLeaf); + int rowEnd = FirstEntryEnd(rowLeafPage); + BinaryPrimitives.WriteInt32BigEndian( + rowLeafPage.Slice(EntryDataOffset + rowEnd - 4, 4), (pageCount + 1) << 8); + break; case "entry-shorter-than-trailer": rootPage[EntryMaskOffset..EntryDataOffset].Clear(); rootPage[EntryMaskOffset] = 0x02; // first entry ends after one byte, before its 4-byte trailer @@ -80,18 +97,18 @@ public void Malformed_index_traversal_is_rejected_as_corruption(string corruptio Table table = db.OpenTable("Orders"); IndexDef index = table.Definition.Indexes.Single(i => i.IsPrimaryKey); Assert.Throws(() => - corruption is "leaf-next-outside-file" or "leaf-cycle" or "child-wrong-owner" or "leaf-next-nonleaf" + corruption is "leaf-previous-outside-file" or "leaf-next-outside-file" or "leaf-row-outside-file" + or "leaf-cycle" or "child-wrong-owner" or "leaf-next-nonleaf" ? table.SeekRangeRows(index, null, null).ToList() : table.SeekRows(index, [int.MaxValue]).ToList()); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Full_index_cursor_rejects_a_child_owned_by_another_table() { - string path = Path.Combine(Path.GetTempPath(), $"index-cursor-corrupt-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "index-cursor-corrupt-"); try { (int root, int owner) = IndexIdentity(path); @@ -104,14 +121,13 @@ public void Full_index_cursor_rejects_a_child_owned_by_another_table() Table table = db.OpenTable("Orders"); Assert.Throws(() => new IndexCursor(table.Channel, root).RowIds().ToList()); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Full_index_cursor_rejects_a_cycle_without_recursive_descent() { - string path = Path.Combine(Path.GetTempPath(), $"index-cursor-cycle-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "index-cursor-cycle-"); try { (int root, _) = IndexIdentity(path); @@ -123,7 +139,7 @@ public void Full_index_cursor_rejects_a_cycle_without_recursive_descent() Table table = db.OpenTable("Orders"); Assert.Throws(() => new IndexCursor(table.Channel, root).RowIds().ToList()); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } private static (int Root, int Owner) IndexIdentity(string path) diff --git a/test/LibRed.Core.Tests/IndexUsageMapTests.cs b/test/LibRed.Core.Tests/IndexUsageMapTests.cs index f2ab0f21..763554b5 100644 --- a/test/LibRed.Core.Tests/IndexUsageMapTests.cs +++ b/test/LibRed.Core.Tests/IndexUsageMapTests.cs @@ -17,20 +17,7 @@ namespace LibRed.Core.Tests; /// public class IndexUsageMapTests { - private static OleDbConnection OpenOleDb(string path) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try - { - var connection = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); - connection.Open(); - return connection; - } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider available."); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); /// Every index page (types 0x03/0x04) in the file owned by the table's TDEF, by owner stamp. private static SortedSet IndexPagesOwnedByTable(string path, Table table, JetFormatBase format) @@ -87,8 +74,7 @@ private static IEnumerable BitsOf(ReadOnlySpan record) [Fact] public void An_index_usage_map_covers_every_btree_page_after_splits() { - string path = Path.Combine(Path.GetTempPath(), $"idxmap-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "idxmap-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -112,14 +98,13 @@ public void An_index_usage_map_covers_every_btree_page_after_splits() Assert.True(actual.Count > 2, "expected the B-trees to have split beyond their creation roots"); Assert.Equal(actual, mapped); // no page missing, none spurious } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Index_map_coverage_matches_what_access_itself_marks() { - string path = Path.Combine(Path.GetTempPath(), $"idxmap-ace-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "idxmap-ace-"); try { using (var connection = OpenOleDb(path)) @@ -139,6 +124,6 @@ public void Index_map_coverage_matches_what_access_itself_marks() // Access's own map must cover exactly the index pages it wrote — the invariant LibRed reproduces. Assert.Equal(IndexPagesOwnedByTable(path, table, db.Format), UnionOfIndexMaps(table, db.Format)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/JetDatabaseLifetimeTests.cs b/test/LibRed.Core.Tests/JetDatabaseLifetimeTests.cs index aab44433..eeb12d5c 100644 --- a/test/LibRed.Core.Tests/JetDatabaseLifetimeTests.cs +++ b/test/LibRed.Core.Tests/JetDatabaseLifetimeTests.cs @@ -10,8 +10,7 @@ public class JetDatabaseLifetimeTests [Fact] public void Failed_catalog_initialization_releases_the_file() { - string path = Path.Combine(Path.GetTempPath(), $"libred-invalid-{Guid.NewGuid():N}.accdb"); - File.Copy(Northwind, path); + string path = TemporaryDatabase.CopyPath(Northwind, "libred-invalid-"); try { @@ -39,12 +38,12 @@ public void Failed_catalog_initialization_releases_the_file() Assert.NotNull(error); // On Windows this fails if the unsuccessful Open leaked its FileStream. - File.Delete(path); + File.Delete(path); // deliberately no retry: proves the failed open released its handle immediately Assert.False(File.Exists(path)); } finally { - if (File.Exists(path)) File.Delete(path); + if (File.Exists(path)) TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/JetNameValidationTests.cs b/test/LibRed.Core.Tests/JetNameValidationTests.cs index 566a8169..a4359e6b 100644 --- a/test/LibRed.Core.Tests/JetNameValidationTests.cs +++ b/test/LibRed.Core.Tests/JetNameValidationTests.cs @@ -38,8 +38,7 @@ public void Accepts_valid_names(string name) [Fact] public void CreateTable_rejects_a_too_long_column_name() { - string path = Path.Combine(Path.GetTempPath(), $"nv-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "nv-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -48,21 +47,20 @@ [new ColumnSpec(new string('c', 100), JetDataType.Int32, 4, IsFixedLength: true) primaryKey: null)); Assert.Contains("64", ex.Message); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void CreateTable_rejects_a_forbidden_table_name() { - string path = Path.Combine(Path.GetTempPath(), $"nv-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "nv-"); try { using var db = JetDatabase.Open(path, readOnly: false); Assert.Throws(() => db.CreateTable("My.Table", [new ColumnSpec("K", JetDataType.Int32, 4, IsFixedLength: true)], primaryKey: ["K"])); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Constraint names (PK/unique/check) go to disk too and carry the same limits — a 100-char one corrupts @@ -70,8 +68,7 @@ public void CreateTable_rejects_a_forbidden_table_name() [Fact] public void CreateTable_rejects_over_long_constraint_names() { - string path = Path.Combine(Path.GetTempPath(), $"nv-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "nv-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -83,6 +80,6 @@ public void CreateTable_rejects_over_long_constraint_names() Assert.Throws(() => db.CreateTable("T3", [K, new("U", JetDataType.Int32, 4, IsFixedLength: true)], primaryKey: ["K"], uniqueConstraints: [new UniqueIndexSpec(L100, ["U"])])); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/JetTypeCodecBoundaryTests.cs b/test/LibRed.Core.Tests/JetTypeCodecBoundaryTests.cs new file mode 100644 index 00000000..aa8ca0fe --- /dev/null +++ b/test/LibRed.Core.Tests/JetTypeCodecBoundaryTests.cs @@ -0,0 +1,133 @@ +using System.Text; +using LibRed.Catalog; +using LibRed.Storage.Types; +using Xunit; + +namespace LibRed.Core.Tests; + +public class JetTypeCodecBoundaryTests +{ + private static ColumnDef Column( + JetDataType type, int length = 0, bool fixedLength = false, byte scale = 0) + => new() + { + Name = "Value", + Type = type, + Index = 0, + Length = length, + IsFixedLength = fixedLength, + Scale = scale, + }; + + public static TheoryData FixedValues => new() + { + { JetDataType.Byte, byte.MinValue }, + { JetDataType.Byte, byte.MaxValue }, + { JetDataType.Int16, short.MinValue }, + { JetDataType.Int16, short.MaxValue }, + { JetDataType.Int32, int.MinValue }, + { JetDataType.Int32, int.MaxValue }, + { JetDataType.Int64, long.MinValue }, + { JetDataType.Int64, long.MaxValue }, + { JetDataType.Single, float.MinValue }, + { JetDataType.Single, float.MaxValue }, + { JetDataType.Double, double.MinValue }, + { JetDataType.Double, double.MaxValue }, + { JetDataType.Currency, -922337203685477.5808m }, + { JetDataType.Currency, 922337203685477.5807m }, + { JetDataType.Guid, Guid.Empty }, + { JetDataType.Guid, Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff") }, + }; + + [Theory] + [MemberData(nameof(FixedValues))] + public void Fixed_width_minimum_and_maximum_values_round_trip(JetDataType type, object value) + { + ColumnDef column = Column(type); + Assert.Equal(value, JetTypeCodec.Decode(column, JetTypeCodec.Encode(column, value))); + } + + [Theory] + [InlineData(JetDataType.Byte, 1)] + [InlineData(JetDataType.Int16, 2)] + [InlineData(JetDataType.Int32, 4)] + [InlineData(JetDataType.Int64, 8)] + [InlineData(JetDataType.Single, 4)] + [InlineData(JetDataType.Double, 8)] + [InlineData(JetDataType.DateTime, 8)] + [InlineData(JetDataType.Currency, 8)] + [InlineData(JetDataType.Guid, 16)] + [InlineData(JetDataType.FixedPoint, 17)] + [InlineData(JetDataType.DateTimeExtended, 42)] + public void Fixed_width_decode_rejects_short_and_long_payloads(JetDataType type, int length) + { + ColumnDef column = Column(type); + Assert.Throws(() => JetTypeCodec.Decode(column, new byte[length - 1])); + Assert.Throws(() => JetTypeCodec.Decode(column, new byte[length + 1])); + } + + [Theory] + [InlineData("0", 0)] + [InlineData("1.2345", 12345)] + [InlineData("-1.2345", -12345)] + [InlineData("7922816251426433759354395.0335", null)] + public void Fixed_point_scale_round_trips_without_binary_floating_point( + string text, int? unscaledControl) + { + decimal value = decimal.Parse(text, System.Globalization.CultureInfo.InvariantCulture); + ColumnDef column = Column(JetDataType.FixedPoint, scale: 4); + byte[] encoded = JetTypeCodec.Encode(column, value); + Assert.Equal(value, JetTypeCodec.Decode(column, encoded)); + if (unscaledControl is not null) + Assert.Equal(unscaledControl.Value, decimal.ToInt32(value * 10_000m)); + } + + [Fact] + public void Fixed_point_rejects_a_nonzero_128_bit_top_word() + { + var bytes = new byte[17]; + bytes[1] = 1; + Assert.Throws(() => JetTypeCodec.Decode(Column(JetDataType.FixedPoint), bytes)); + } + + [Fact] + public void DateTimeExtended_decodes_day_and_tick_components_at_100ns_precision() + { + var expected = new DateTime(2021, 3, 4, 9, 8, 7).AddTicks(1_234_567); + long day = expected.Ticks / TimeSpan.TicksPerDay; + long time = expected.Ticks % TimeSpan.TicksPerDay; + byte[] encoded = Encoding.ASCII.GetBytes($"{day:D19}:{time:D19}:07"); + + Assert.Equal(42, encoded.Length); + Assert.Equal(expected, JetTypeCodec.Decode(Column(JetDataType.DateTimeExtended), encoded)); + } + + [Fact] + public void Compressed_and_uncompressed_empty_text_are_distinct_encodings_of_the_same_value() + { + Assert.Equal("", JetTypeCodec.DecodeText([])); + Assert.Equal("", JetTypeCodec.DecodeText([0xFF, 0xFE])); + Assert.Equal("ABC", JetTypeCodec.DecodeText([0xFF, 0xFE, 0x41, 0x42, 0x43])); + Assert.Equal("Å", JetTypeCodec.DecodeText(Encoding.Unicode.GetBytes("Å"))); + } + + [Fact] + public void Fixed_text_and_binary_are_padded_or_truncated_to_the_declared_width() + { + ColumnDef text = Column(JetDataType.Text, length: 6, fixedLength: true); + Assert.Equal("A ", JetTypeCodec.Decode(text, JetTypeCodec.Encode(text, "A"))); + Assert.Equal("ABC", JetTypeCodec.Decode(text, JetTypeCodec.Encode(text, "ABCD"))); + + ColumnDef binary = Column(JetDataType.Binary, length: 3, fixedLength: true); + Assert.Equal(new byte[] { 1, 0, 0 }, JetTypeCodec.Encode(binary, new byte[] { 1 })); + Assert.Equal(new byte[] { 1, 2, 3 }, JetTypeCodec.Encode(binary, new byte[] { 1, 2, 3, 4 })); + } + + [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); + } +} diff --git a/test/LibRed.Core.Tests/LegacyJetPasswordTests.cs b/test/LibRed.Core.Tests/LegacyJetPasswordTests.cs index 82a65a46..46c0d44c 100644 --- a/test/LibRed.Core.Tests/LegacyJetPasswordTests.cs +++ b/test/LibRed.Core.Tests/LegacyJetPasswordTests.cs @@ -1,3 +1,4 @@ +using LibRed.Storage; using LibRed.Crypto; using Xunit; @@ -8,71 +9,89 @@ namespace LibRed.Core.Tests; /// UTF-16LE(password) XOR (int)creationDateDouble, inside the header-masked region (recipe from jackcess, the /// inverse of its read path). Verified byte-identical to Access's own output: each fixture below is /// 2002plain.mdb with the named password set in Access, so re-encoding on a copy must reproduce it exactly. -/// Fixtures under enctest/ are NOT committed (user preference); tests skip when absent. +/// The password and encoding mechanics use generated Jet 4 inputs and run on every platform. /// public class LegacyJetPasswordTests { - private const string Dir = @"D:\toolkits\efcorejetlibred\test\LibRed.Core.Tests\enctest"; - private const string Plain = "2002plain.mdb"; + private static string CreateSyntheticJet4() + { + string path = TemporaryDatabase.CreatePath("libred_jet4_", ".mdb"); + byte[] file = new byte[4096 * 3]; + DatabaseCreator.BuildDefinitionPage( + version: 0x01, isAccdb: false, codePage: 1252, collationLcid: 1033, + collationVersion: 0, creationDays: 45000.25).CopyTo(file, 0); + new Random(1701).NextBytes(file.AsSpan(4096)); + File.WriteAllBytes(path, file); + return path; + } - public static IEnumerable Cases => - [ - ["2002plainpw.mdb", "Test1"], - ["2002plainTest2.mdb", "Test2"], - ["2002plain -aaaa.mdb", "AAAA"], - ["2002plain - z.mdb", "z"], - ]; + /// The Access-output fixtures: 2002plain.mdb plus copies of it with each password set by + /// Access itself. They are deliberately not committed, so this is located by convention (or the + /// LIBRED_ENCTEST_DIR environment variable) and skips with a reason when absent — a silent + /// early `return` would report a pass for a test that never ran. + private static string? FixtureDirectory + { + get + { + string directory = Environment.GetEnvironmentVariable("LIBRED_ENCTEST_DIR") + ?? Path.Combine(AppContext.BaseDirectory, "enctest"); + return Directory.Exists(directory) ? directory : null; + } + } + // The ground truth behind the whole codec: byte-identity with what Access itself writes. The mechanics + // tests below are synthetic and run everywhere; this one is the only thing that can catch the transform + // drifting away from Access, so keep it runnable rather than deleting it with the fixtures unavailable. [Theory] - [MemberData(nameof(Cases))] + [InlineData("2002plainpw.mdb", "Test1")] + [InlineData("2002plainTest2.mdb", "Test2")] + [InlineData("2002plain -aaaa.mdb", "AAAA")] + [InlineData("2002plain - z.mdb", "z")] public void SetJetPassword_matches_access_output(string accessFile, string password) { - string plain = Path.Combine(Dir, Plain); - string reference = Path.Combine(Dir, accessFile); - if (!File.Exists(plain) || !File.Exists(reference)) return; + string? directory = FixtureDirectory; + Assert.SkipWhen(directory is null, + "Access-set .mdb fixtures are not present; set LIBRED_ENCTEST_DIR to run this."); + + string plain = Path.Combine(directory!, "2002plain.mdb"); + string reference = Path.Combine(directory!, accessFile); + Assert.SkipUnless(File.Exists(plain) && File.Exists(reference), + $"'{accessFile}' or its 2002plain.mdb base is missing from {directory}."); - string tmp = Path.Combine(Path.GetTempPath(), $"libred_jetpw_{Guid.NewGuid():N}.mdb"); - File.Copy(plain, tmp, overwrite: true); + string tmp = TemporaryDatabase.CopyPath(plain, "libred_jetpw_", overwrite: true); try { DatabaseEncryption.SetJetPassword(tmp, password); - byte[] ours = File.ReadAllBytes(tmp); - byte[] access = File.ReadAllBytes(reference); // The 40-byte password field at 0x42 must match Access byte-for-byte. - Assert.Equal(access[0x42..(0x42 + 40)], ours[0x42..(0x42 + 40)]); + Assert.Equal( + File.ReadAllBytes(reference)[0x42..(0x42 + 40)], + File.ReadAllBytes(tmp)[0x42..(0x42 + 40)]); } - finally { File.Delete(tmp); } + finally { TemporaryDatabase.Delete(tmp); } } [Fact] public void RemoveJetPassword_matches_plain() { - string plain = Path.Combine(Dir, Plain); - if (!File.Exists(plain)) return; - - string tmp = Path.Combine(Path.GetTempPath(), $"libred_jetpw_{Guid.NewGuid():N}.mdb"); - File.Copy(plain, tmp, overwrite: true); + string tmp = CreateSyntheticJet4(); try { + byte[] original = File.ReadAllBytes(tmp); DatabaseEncryption.SetJetPassword(tmp, "Test1"); + Assert.NotEqual(original[0x42..(0x42 + 40)], File.ReadAllBytes(tmp)[0x42..(0x42 + 40)]); DatabaseEncryption.RemoveJetPassword(tmp); byte[] ours = File.ReadAllBytes(tmp); - byte[] original = File.ReadAllBytes(plain); Assert.Equal(original[0x42..(0x42 + 40)], ours[0x42..(0x42 + 40)]); // back to the unpassworded field } - finally { File.Delete(tmp); } + finally { TemporaryDatabase.Delete(tmp); } } [Fact] public void SetJetEncoding_roundtrips_and_stays_readable() { - string plain = Path.Combine(Dir, Plain); - if (!File.Exists(plain)) return; - - string tmp = Path.Combine(Path.GetTempPath(), $"libred_jetenc_{Guid.NewGuid():N}.mdb"); - File.Copy(plain, tmp, overwrite: true); + string tmp = CreateSyntheticJet4(); try { byte[] before = File.ReadAllBytes(tmp); @@ -81,77 +100,101 @@ public void SetJetEncoding_roundtrips_and_stays_readable() Assert.NotEqual(before, encoded); // pages actually changed Assert.NotEqual(0u, BitConverter.ToUInt32(encoded, 0x3E)); // dbKey masked-nonzero on disk - using (var db = LibRed.JetDatabase.Open(tmp, readOnly: true)) // encoded output opens via the codec - Assert.Contains("Table1", db.Catalog.UserTables.Select(t => t.Name)); - DatabaseEncryption.RemoveJetEncoding(tmp); Assert.Equal(before, File.ReadAllBytes(tmp)); // decode → byte-identical to original } - finally { File.Delete(tmp); } + finally { TemporaryDatabase.Delete(tmp); } } [Fact] public void Encode_and_password_are_independent() { - string plain = Path.Combine(Dir, Plain); - string pwRef = Path.Combine(Dir, "2002plainpw.mdb"); // 2002plain + Access-set "Test1" - if (!File.Exists(plain) || !File.Exists(pwRef)) return; - - string tmp = Path.Combine(Path.GetTempPath(), $"libred_jetboth_{Guid.NewGuid():N}.mdb"); - File.Copy(plain, tmp, overwrite: true); + string tmp = CreateSyntheticJet4(); try { DatabaseEncryption.SetJetPassword(tmp, "Test1"); + byte[] passwordField = File.ReadAllBytes(tmp)[0x42..(0x42 + 40)]; DatabaseEncryption.SetJetEncoding(tmp); - // The password field (0x42, page 0, never page-encrypted) still matches Access's password-only output. + // The password field is on page 0, which page encoding must never transform. byte[] both = File.ReadAllBytes(tmp); - byte[] pwOnly = File.ReadAllBytes(pwRef); - Assert.Equal(pwOnly[0x42..(0x42 + 40)], both[0x42..(0x42 + 40)]); + Assert.Equal(passwordField, both[0x42..(0x42 + 40)]); // Removing the encoding leaves the password field intact. DatabaseEncryption.RemoveJetEncoding(tmp); - Assert.Equal(pwOnly[0x42..(0x42 + 40)], File.ReadAllBytes(tmp)[0x42..(0x42 + 40)]); + Assert.Equal(passwordField, File.ReadAllBytes(tmp)[0x42..(0x42 + 40)]); } - finally { File.Delete(tmp); } + finally { TemporaryDatabase.Delete(tmp); } + } + + [Fact] + public void SetJetPassword_rejects_accdb() + { + string tmp = TemporaryDatabase.CopyPath(TestDatabases.WideTableAccdb, "libred_jetpw_", overwrite: true); + try { Assert.Throws(() => DatabaseEncryption.SetJetPassword(tmp, "x")); } + finally { TemporaryDatabase.Delete(tmp); } } [Fact] - public void Reads_access_encoded_plus_password_file() + public void Jet_password_accepts_twenty_characters_and_rejects_longer_or_empty_without_writing() { - // 2002encodedpw.mdb is a real Access file that is BOTH page-encoded (nonzero 0x3E key) AND password-set. - string enc = Path.Combine(Dir, "2002encodedpw.mdb"); - string plainpw = Path.Combine(Dir, "2002plainpw.mdb"); - if (!File.Exists(enc)) return; - - // LibRed reads Access's combined file via the stored encoding key — proves our RC4 decode matches Access. - using (var db = LibRed.JetDatabase.Open(enc, readOnly: true)) - Assert.Contains("Table1", db.Catalog.UserTables.Select(t => t.Name)); - - // The password field (page 0, never page-encrypted) is byte-identical to the password-only file. - if (File.Exists(plainpw)) - Assert.Equal(File.ReadAllBytes(plainpw)[0x42..(0x42 + 40)], File.ReadAllBytes(enc)[0x42..(0x42 + 40)]); - - // Decoding it in place yields a valid plaintext database that still opens. - string tmp = Path.Combine(Path.GetTempPath(), $"libred_decenc_{Guid.NewGuid():N}.mdb"); - File.Copy(enc, tmp, overwrite: true); + string tmp = CreateSyntheticJet4(); try { - DatabaseEncryption.RemoveJetEncoding(tmp); - using var db = LibRed.JetDatabase.Open(tmp, readOnly: true); - Assert.Contains("Table1", db.Catalog.UserTables.Select(t => t.Name)); + DatabaseEncryption.SetJetPassword(tmp, new string('x', 20)); + byte[] withMaximumPassword = File.ReadAllBytes(tmp); + + Assert.Throws(() => DatabaseEncryption.SetJetPassword(tmp, new string('y', 21))); + Assert.Equal(withMaximumPassword, File.ReadAllBytes(tmp)); + + Assert.Throws(() => DatabaseEncryption.SetJetPassword(tmp, "")); + Assert.Equal(withMaximumPassword, File.ReadAllBytes(tmp)); + + DatabaseEncryption.RemoveJetPassword(tmp); + Assert.NotEqual(withMaximumPassword[0x42..(0x42 + 40)], File.ReadAllBytes(tmp)[0x42..(0x42 + 40)]); } - finally { File.Delete(tmp); } + finally { TemporaryDatabase.Delete(tmp); } } [Fact] - public void SetJetPassword_rejects_accdb() + public void Rejected_jet_encoding_operations_leave_the_file_byte_identical() { - string accdb = Path.Combine(Dir, "..", "Data", "WideTable.accdb"); - if (!File.Exists(accdb)) return; - string tmp = Path.Combine(Path.GetTempPath(), $"libred_jetpw_{Guid.NewGuid():N}.accdb"); - File.Copy(accdb, tmp, overwrite: true); - try { Assert.Throws(() => DatabaseEncryption.SetJetPassword(tmp, "x")); } - finally { File.Delete(tmp); } + string tmp = CreateSyntheticJet4(); + try + { + byte[] plain = File.ReadAllBytes(tmp); + Assert.Throws(() => DatabaseEncryption.RemoveJetEncoding(tmp)); + Assert.Equal(plain, File.ReadAllBytes(tmp)); + + DatabaseEncryption.SetJetEncoding(tmp); + byte[] encoded = File.ReadAllBytes(tmp); + Assert.Throws(() => DatabaseEncryption.SetJetEncoding(tmp)); + Assert.Equal(encoded, File.ReadAllBytes(tmp)); + } + finally { TemporaryDatabase.Delete(tmp); } + } + + [Fact] + public void Jet_encoding_rejects_accdb_and_jet3_without_writing() + { + string accdb = TemporaryDatabase.CopyPath(TestDatabases.WideTableAccdb, "libred_jetenc_mismatch_"); + string jet3 = CreateSyntheticJet4(); + try + { + byte[] accdbBefore = File.ReadAllBytes(accdb); + Assert.Throws(() => DatabaseEncryption.SetJetEncoding(accdb)); + Assert.Equal(accdbBefore, File.ReadAllBytes(accdb)); + + byte[] jet3Bytes = File.ReadAllBytes(jet3); + jet3Bytes[0x14] = 0; + File.WriteAllBytes(jet3, jet3Bytes); + Assert.Throws(() => DatabaseEncryption.SetJetEncoding(jet3)); + Assert.Equal(jet3Bytes, File.ReadAllBytes(jet3)); + } + finally + { + TemporaryDatabase.Delete(accdb); + TemporaryDatabase.Delete(jet3); + } } } diff --git a/test/LibRed.Core.Tests/LibRed.Core.Tests.csproj b/test/LibRed.Core.Tests/LibRed.Core.Tests.csproj index cdb9faaf..3efd3ded 100644 --- a/test/LibRed.Core.Tests/LibRed.Core.Tests.csproj +++ b/test/LibRed.Core.Tests/LibRed.Core.Tests.csproj @@ -31,6 +31,12 @@ + + + + + diff --git a/test/LibRed.Core.Tests/LockManagerTests.cs b/test/LibRed.Core.Tests/LockManagerTests.cs index e694b258..f50f16a2 100644 --- a/test/LibRed.Core.Tests/LockManagerTests.cs +++ b/test/LibRed.Core.Tests/LockManagerTests.cs @@ -64,8 +64,7 @@ public void Acquire_returns_one_shared_manager_per_path_and_frees_it_on_last_rel [Fact] public void PageChannel_reads_and_writes_correctly_under_a_lock_manager() { - string path = Path.Combine(Path.GetTempPath(), $"libred-locked-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-locked-"); try { using var channel = PageChannel.Open(path, readOnly: false, locks: new MonitorLockManager()); @@ -80,6 +79,6 @@ public void PageChannel_reads_and_writes_correctly_under_a_lock_manager() Assert.NotEqual(page[10], channel.ReadPage(1).Span[10]); // rollback restored the original } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/LongValueCorruptionTests.cs b/test/LibRed.Core.Tests/LongValueCorruptionTests.cs index 18230914..13416a06 100644 --- a/test/LibRed.Core.Tests/LongValueCorruptionTests.cs +++ b/test/LibRed.Core.Tests/LongValueCorruptionTests.cs @@ -103,12 +103,11 @@ private static void WritePagePointer(Span pointer, int page) private sealed class Fixture : IDisposable { - private readonly string _path = Path.Combine(Path.GetTempPath(), $"lval-corrupt-{Guid.NewGuid():N}.accdb"); + private readonly string _path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "lval-corrupt-"); private readonly JetDatabase _database; public Fixture() { - File.Copy(TestDatabases.NorthwindAccdb, _path); _database = JetDatabase.Open(_path, readOnly: false); Table = _database.OpenTable("Categories"); Writer = new LongValueWriter(Table.Channel); @@ -122,7 +121,7 @@ public Fixture() public void Dispose() { _database.Dispose(); - File.Delete(_path); + TemporaryDatabase.Delete(_path); } } } diff --git a/test/LibRed.Core.Tests/LongValuePackingAccessTests.cs b/test/LibRed.Core.Tests/LongValuePackingAccessTests.cs index e93797cb..c2a239e1 100644 --- a/test/LibRed.Core.Tests/LongValuePackingAccessTests.cs +++ b/test/LibRed.Core.Tests/LongValuePackingAccessTests.cs @@ -21,20 +21,7 @@ public class LongValuePackingAccessTests private static readonly string[] Values = Enumerable.Range(0, N).Select(i => $"row{i}:" + new string((char)('A' + i % 26), 150)).ToArray(); - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; } - } - Thread.Sleep(50); - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider opened the database.", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static List MapPages(PageChannel ch, (int Row, int Page) ptr) { @@ -52,8 +39,7 @@ private static List MapPages(PageChannel ch, (int Row, int Page) ptr) [Fact] public void Small_long_values_share_lval_pages_and_round_trip() { - string path = Path.Combine(Path.GetTempPath(), $"lval-pack-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "lval-pack-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -98,6 +84,6 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true), Assert.Equal(Values[i - 1], (string)cmd.ExecuteScalar()!); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/MemoKeyEncodingTests.cs b/test/LibRed.Core.Tests/MemoKeyEncodingTests.cs index 3938766b..e4eadb63 100644 --- a/test/LibRed.Core.Tests/MemoKeyEncodingTests.cs +++ b/test/LibRed.Core.Tests/MemoKeyEncodingTests.cs @@ -11,15 +11,7 @@ namespace LibRed.Core.Tests; // ascending and descending, including truncation and an "ignorable" character (apostrophe). public class MemoKeyEncodingTests { - private static OleDbConnection OpenOleDb(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider available."); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static readonly string[] Values = [ @@ -35,8 +27,7 @@ private static OleDbConnection OpenOleDb(string path) private static void AssertKeysMatchAccess(string indexDdl) { - string path = Path.Combine(Path.GetTempPath(), $"libred-memokey-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-memokey-"); try { using (var conn = OpenOleDb(path)) @@ -76,7 +67,7 @@ private static void AssertKeysMatchAccess(string indexDdl) Assert.Equal(Values.Length, checkedKeys); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] diff --git a/test/LibRed.Core.Tests/MultiPageDefinitionTests.cs b/test/LibRed.Core.Tests/MultiPageDefinitionTests.cs index c4727ec3..a1531a60 100644 --- a/test/LibRed.Core.Tests/MultiPageDefinitionTests.cs +++ b/test/LibRed.Core.Tests/MultiPageDefinitionTests.cs @@ -13,8 +13,7 @@ public class MultiPageDefinitionTests public void Index_that_overflows_the_tdef_page_spills_to_a_continuation_and_round_trips() { const int n = 30; - string path = Path.Combine(Path.GetTempPath(), $"cont-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "cont-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -44,6 +43,6 @@ public void Index_that_overflows_the_tdef_page_spills_to_a_continuation_and_roun && ix.Columns.Select(c => c.Column.Name).SequenceEqual([$"C{i:D2}"])); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/MultiPageTableDefinitionTests.cs b/test/LibRed.Core.Tests/MultiPageTableDefinitionTests.cs index 8817fc1a..92b9704e 100644 --- a/test/LibRed.Core.Tests/MultiPageTableDefinitionTests.cs +++ b/test/LibRed.Core.Tests/MultiPageTableDefinitionTests.cs @@ -53,8 +53,7 @@ public void Reads_rows_from_a_wide_table() [InlineData("wrong-root-header")] public void Rejects_an_invalid_continuation_chain_before_assembly(string corruption) { - string path = Path.Combine(Path.GetTempPath(), $"bad-tdef-chain-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.WideTableAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.WideTableAccdb, "bad-tdef-chain-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -107,6 +106,6 @@ public void Rejects_an_invalid_continuation_chain_before_assembly(string corrupt Assert.Throws(() => db.ReadTableDefinition(firstPage)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/NestedTransactionTests.cs b/test/LibRed.Core.Tests/NestedTransactionTests.cs new file mode 100644 index 00000000..c4984602 --- /dev/null +++ b/test/LibRed.Core.Tests/NestedTransactionTests.cs @@ -0,0 +1,113 @@ +using LibRed.Catalog; +using LibRed.Tests.Shared; +using Xunit; + +namespace LibRed.Core.Tests; + +public class NestedTransactionTests +{ + private static readonly ColumnSpec[] Schema = + [ + new("Id", JetDataType.Int32, 4, IsFixedLength: true), + ]; + + private static TemporaryDatabase Fresh() + { + var temp = TemporaryDatabase.CopyOf(TestDatabases.NorthwindAccdb, "nested"); + JetDatabase db = temp.Open(); + db.CreateTable("NestedTxn", Schema, primaryKey: ["Id"]); + return temp; + } + + private static int[] Values(JetDatabase db) + => db.OpenTable("NestedTxn").Rows().Select(r => Convert.ToInt32(r[0])).Order().ToArray(); + + [Fact] + public void Committing_inner_and_outer_levels_keeps_both_writes() + { + using var temp = Fresh(); + JetDatabase db = temp.Database; + db.BeginNested(); + db.OpenTable("NestedTxn").Insert([1]); + db.BeginNested(); + db.OpenTable("NestedTxn").Insert([2]); + + Assert.True(db.InTransaction); + Assert.Equal(2, db.TransactionDepth); + + db.CommitNested(); + Assert.Equal(1, db.TransactionDepth); + db.CommitNested(); + + Assert.False(db.InTransaction); + Assert.Equal(0, db.TransactionDepth); + Assert.Equal([1, 2], Values(db)); + } + + [Fact] + public void Rolling_back_inner_level_keeps_outer_write_and_transaction_open() + { + using var temp = Fresh(); + JetDatabase db = temp.Database; + db.BeginNested(); + db.OpenTable("NestedTxn").Insert([1]); + db.BeginNested(); + db.OpenTable("NestedTxn").Insert([2]); + + db.RollbackNested(); + + Assert.True(db.InTransaction); + Assert.Equal(1, db.TransactionDepth); + Assert.Equal([1], Values(db)); + + db.CommitNested(); + Assert.Equal([1], Values(db)); + } + + [Fact] + public void Rollback_all_discards_every_level_and_resets_controller() + { + using var temp = Fresh(); + JetDatabase db = temp.Database; + db.BeginNested(); + db.OpenTable("NestedTxn").Insert([1]); + db.BeginNested(); + db.OpenTable("NestedTxn").Insert([2]); + + db.RollbackAll(); + + Assert.False(db.InTransaction); + Assert.Equal(0, db.TransactionDepth); + Assert.Empty(Values(db)); + + db.RollbackAll(); // idempotent at depth zero + Assert.Equal(0, db.TransactionDepth); + } + + [Fact] + public void Outermost_rollback_invalidates_catalog_entries_created_in_transaction() + { + using var temp = Fresh(); + JetDatabase db = temp.Database; + db.BeginNested(); + db.CreateTable("Transient", Schema); + Assert.NotNull(db.Catalog.FindTable("Transient")); + + db.RollbackNested(); + + Assert.Null(db.Catalog.FindTable("Transient")); + var error = Assert.Throws(() => db.OpenTable("Transient")); + Assert.Equal("name", error.ParamName); + } + + [Fact] + public void Commit_and_rollback_without_a_nested_transaction_are_rejected() + { + using var temp = Fresh(); + JetDatabase db = temp.Database; + Assert.Throws(() => db.CommitNested()); + Assert.Throws(() => db.RollbackNested()); + Assert.Equal(0, db.TransactionDepth); + Assert.False(db.InTransaction); + } +} diff --git a/test/LibRed.Core.Tests/OfficeStandardEncryptionTests.cs b/test/LibRed.Core.Tests/OfficeStandardEncryptionTests.cs index defa2e2a..ff64e46b 100644 --- a/test/LibRed.Core.Tests/OfficeStandardEncryptionTests.cs +++ b/test/LibRed.Core.Tests/OfficeStandardEncryptionTests.cs @@ -102,7 +102,7 @@ private static byte[] BuildPage0( int verifierHashSize = 20) { var page = new byte[4096]; - int ei = 0x100; + const int ei = 0x29B; const int headerSize = 32; void U16(int o, ushort v) => BinaryPrimitives.WriteUInt16LittleEndian(page.AsSpan(o), v); void U32(int o, uint v) => BinaryPrimitives.WriteUInt32LittleEndian(page.AsSpan(o), v); @@ -121,6 +121,8 @@ private static byte[] BuildPage0( encVerifier.CopyTo(page, v + 4 + salt.Length); U32(v + 4 + salt.Length + 16, (uint)verifierHashSize); // VerifierHashSize (SHA1 normally 20) encVerifierHash.CopyTo(page, v + 4 + salt.Length + 16 + 4); + int descriptorLength = v + 4 + salt.Length + 16 + 4 + encVerifierHash.Length - ei; + U16(0x299, checked((ushort)descriptorLength)); return page; } } diff --git a/test/LibRed.Core.Tests/OfficeStandardVariantReadTests.cs b/test/LibRed.Core.Tests/OfficeStandardVariantReadTests.cs index 10b3efc4..36582096 100644 --- a/test/LibRed.Core.Tests/OfficeStandardVariantReadTests.cs +++ b/test/LibRed.Core.Tests/OfficeStandardVariantReadTests.cs @@ -1,64 +1,87 @@ +using System.Buffers.Binary; using LibRed; +using LibRed.Crypto; using Xunit; namespace LibRed.Core.Tests; -/// -/// Reads real Office-"Standard"/CryptoAPI encrypted .accdb fixtures re-encrypted with the -/// "Encryption Manager for Access 2007" (EverythingAccess.com) tool, sweeping the AlgID / AlgIDHash / KeySize -/// combinations it exposes. Verifies LibRed honours the hashing algorithm (MD5/SHA-1/…/SHA-512), resolves -/// KeySize == 0 to the algorithm default, and rejects genuinely-unsupported ciphers (3DES) cleanly. -/// Fixtures live under enctest/ and are NOT committed (user preference); the test skips when absent. -/// +/// Fixture-free rejection coverage for unsupported Office-Standard descriptor variants. public class OfficeStandardVariantReadTests { - private const string Dir = @"D:\toolkits\efcorejetlibred\test\LibRed.Core.Tests\enctest"; - private const string Password = "Test123"; - - public static IEnumerable Readable => - [ - ["db2007-oldenc.accdb"], // RC4-40, SHA-1 - ["db2007-oldenc - Copy.accdb"], // RC4-56, SHA-1 - ["db2007-oldenc - Copy (2).accdb"], // RC4, MD5, KeySize=0 (default) - ["db2007-oldenc - Copy (3).accdb"], // RC4, MD5, KeySize=0, Enhanced provider - ["db2007-oldenc - Copy (4).accdb"], // RC4-120, SHA-512 - ["db2007-oldenc - Copy (5).accdb"], // AES-256, MD5, KeySize=0 (Access won't open; LibRed can) - ["db2007-oldenc - Copy (6).accdb"], // AES-128, MD5, KeySize=0 (Access won't open; LibRed can) - ["db2007-oldenc - Copy (8).accdb"], // AES-256, SHA-512, KeySize=0 (key < hash ⇒ truncate, no expansion) - ["db2007-oldenc - Copy (12).accdb"],// AES-256, SHA-256, KeySize=0 (key == hash 32 ⇒ 0x36/0x5C expansion) - ["db2007-oldenc - Copy (13).accdb"],// AES-256, SHA-384, KeySize=0 (key 32 < hash 48 ⇒ truncate) - ["db2007-oldenc - Copy (14).accdb"],// AES-192, SHA-256, KeySize=0 (24-byte key path) - ["db2007-oldenc - Copy (15).accdb"],// AES-128, SHA-512, KeySize=0 (key 16 < hash 64 ⇒ truncate) - ]; + private const int DescriptorOffset = 0x29B; + private const int AlgorithmOffset = DescriptorOffset + 12 + 8; + private const int HashOffset = DescriptorOffset + 12 + 12; [Theory] - [MemberData(nameof(Readable))] - public void Reads_office_standard_variant(string file) + [InlineData(0x6603u)] // 3DES-168 + [InlineData(0x6609u)] // 3DES-112 + [InlineData(0x6601u)] // DES + public void Unsupported_cipher_is_rejected_cleanly(uint algorithm) { - string path = Path.Combine(Dir, file); - if (!File.Exists(path)) return; // fixtures not committed (user preference) - - using var db = JetDatabase.Open(path, readOnly: true, password: Password); - Assert.Equal(1, db.OpenTable("Table1").Rows().Count()); + string path = CreateEncryptedCopy(); + try + { + MutateUInt32(path, AlgorithmOffset, algorithm); + Assert.Throws(() => JetDatabase.Open(path, readOnly: true, password: "Test123")); + } + finally { TemporaryDatabase.Delete(path); } } [Theory] - [InlineData("db2007-oldenc - Copy (7).accdb")] // 3DES-168 cipher - [InlineData("db2007-oldenc - Copy (9).accdb")] // MD2 hashing (no managed implementation) - [InlineData("db2007-oldenc - Copy (10).accdb")] // 3DES-112 cipher - [InlineData("db2007-oldenc - Copy (11).accdb")] // DES cipher - public void Rejects_unsupported_cleanly(string file) + [InlineData(0x8001u)] // MD2 + [InlineData(0x8002u)] // MD4 + [InlineData(0xDEADu)] // unknown + public void Unsupported_hash_is_rejected_cleanly(uint hashAlgorithm) { - string path = Path.Combine(Dir, file); // all also refused by Access itself - if (!File.Exists(path)) return; + string path = CreateEncryptedCopy(); + try + { + MutateUInt32(path, HashOffset, hashAlgorithm); + Assert.Throws(() => JetDatabase.Open(path, readOnly: true, password: "Test123")); + } + finally { TemporaryDatabase.Delete(path); } + } - // A clear NotSupportedException, never a DivideByZero/EndOfStream from mis-reading ciphertext as plaintext. + // The descriptor length at 0x299 is authoritative, not a hint: Access reads a file whose key and + // descriptor are both present but whose length is zero as *plaintext*, and offers to "recover" it + // (verified experiment, page-00-database.md). A reader that instead scans page 0 for the EncryptionInfo + // signature would decrypt this file happily — succeeding where ACE cannot, which is treating corruption + // as valid. LibRed reaches the same verdict as ACE (this is not a readable encrypted database) but + // reports it rather than surfacing ciphertext as data: the 0x3E key says "encrypted", no descriptor is + // readable within the declared frame, so the scheme is unsupported. The password being correct is + // deliberate — it must not rescue the file. + [Fact] + public void A_zero_length_descriptor_is_read_as_unencrypted_like_ace() + { + string path = CreateEncryptedCopy(); try { - JetDatabase.Open(path, readOnly: true, password: Password); - Assert.Fail($"expected NotSupportedException for {file}"); + MutateUInt16(path, 0x299, 0); + var error = Assert.Throws( + () => JetDatabase.Open(path, readOnly: true, password: "Test123")); + Assert.Contains("unsupported scheme", error.Message, StringComparison.OrdinalIgnoreCase); } - catch (NotSupportedException) { /* expected */ } - catch (IOException) { /* fixture locked by another process (an environment condition); skip */ } + finally { TemporaryDatabase.Delete(path); } + } + + private static string CreateEncryptedCopy() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.WideTableAccdb, "office-standard-variant-"); + DatabaseEncryption.SetPasswordRc4(path, "Test123"); + return path; + } + + private static void MutateUInt32(string path, int offset, uint value) + { + byte[] file = File.ReadAllBytes(path); + BinaryPrimitives.WriteUInt32LittleEndian(file.AsSpan(offset, 4), value); + File.WriteAllBytes(path, file); + } + + private static void MutateUInt16(string path, int offset, ushort value) + { + byte[] file = File.ReadAllBytes(path); + BinaryPrimitives.WriteUInt16LittleEndian(file.AsSpan(offset, 2), value); + File.WriteAllBytes(path, file); } } diff --git a/test/LibRed.Core.Tests/OrderByProcedureAccessTests.cs b/test/LibRed.Core.Tests/OrderByProcedureAccessTests.cs index 5c0a204e..5facc1d0 100644 --- a/test/LibRed.Core.Tests/OrderByProcedureAccessTests.cs +++ b/test/LibRed.Core.Tests/OrderByProcedureAccessTests.cs @@ -12,26 +12,12 @@ namespace LibRed.Core.Tests; /// public class OrderByProcedureAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; } - } - Thread.Sleep(50); - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider opened the database.", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_runs_a_top_order_by_query() { - string path = Path.Combine(Path.GetTempPath(), $"orderby-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "orderby-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -61,6 +47,6 @@ public void Access_runs_a_top_order_by_query() Assert.Equal(prices.OrderByDescending(p => p).ToList(), prices); // ORDER BY DESC Assert.Equal(263.50m, prices[0]); // Côte de Blaye } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/PageCacheTests.cs b/test/LibRed.Core.Tests/PageCacheTests.cs index 229cac00..7d2eb276 100644 --- a/test/LibRed.Core.Tests/PageCacheTests.cs +++ b/test/LibRed.Core.Tests/PageCacheTests.cs @@ -13,9 +13,7 @@ public class PageCacheTests { private static string CopyNorthwind(string tag) { - string path = Path.Combine(Path.GetTempPath(), $"pagecache-{tag}-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); - return path; + return TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, $"pagecache-{tag}-"); } [Fact] @@ -39,7 +37,7 @@ public void A_second_channels_cached_page_reflects_the_first_channels_write() byte[] seen = reader.ReadPage(page).Span.ToArray(); Assert.Equal(mutated, seen); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -62,7 +60,7 @@ public void Rollback_restores_the_cached_image_not_just_the_disk() // After rollback the pool must serve the pre-transaction bytes, not the rolled-back write. Assert.Equal(original, ch.ReadPage(page).Span.ToArray()); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -85,6 +83,6 @@ public void Reopening_after_the_last_channel_closes_reads_committed_bytes_from_d using var reopened = PageChannel.Open(path, readOnly: true); Assert.Equal(mutated, reopened.ReadPage(page).Span.ToArray()); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/PageChannelTests.cs b/test/LibRed.Core.Tests/PageChannelTests.cs index d48bc71a..2aebb43f 100644 --- a/test/LibRed.Core.Tests/PageChannelTests.cs +++ b/test/LibRed.Core.Tests/PageChannelTests.cs @@ -10,8 +10,7 @@ public void WritePage_beyond_the_end_grows_the_file_and_zero_fills_the_gap() { // A page allocated from the global free-pages map can lie past the physical end of a small // file (allocation defers the write); writing it must grow the file rather than throw. - string path = Path.Combine(Path.GetTempPath(), $"libred-grow-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-grow-"); try { using var channel = PageChannel.Open(path, readOnly: false); @@ -25,7 +24,7 @@ public void WritePage_beyond_the_end_grows_the_file_and_zero_fills_the_gap() Assert.Equal(0xAB, channel.ReadPage(before + 2).Span[0]); Assert.Equal(0, channel.ReadPage(before + 1).Span[0]); // the skipped page is zero-filled } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -33,8 +32,7 @@ public void RollbackTransaction_restores_modified_pages_and_drops_allocated_ones { // The undo log is what gives EF Core's shared-database tests their per-test isolation: a // rolled-back transaction must leave the file byte-for-byte as it was before it began. - string path = Path.Combine(Path.GetTempPath(), $"libred-rollback-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-rollback-"); try { byte[] before = File.ReadAllBytes(path); // baseline captured before opening (channel takes an exclusive lock) @@ -64,14 +62,13 @@ public void RollbackTransaction_restores_modified_pages_and_drops_allocated_ones Assert.Equal(before, File.ReadAllBytes(path)); // byte-for-byte identical to pre-transaction } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void CommitTransaction_keeps_the_writes() { - string path = Path.Combine(Path.GetTempPath(), $"libred-commit-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-commit-"); try { using var channel = PageChannel.Open(path, readOnly: false); @@ -85,14 +82,13 @@ public void CommitTransaction_keeps_the_writes() Assert.False(channel.InTransaction); Assert.Equal(page[10], channel.ReadPage(1).Span[10]); // change survived the commit } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void RollbackToSavepoint_keeps_pre_savepoint_writes_undoes_later_ones_and_drops_allocations() { - string path = Path.Combine(Path.GetTempPath(), $"libred-sp-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-sp-"); try { using var channel = PageChannel.Open(path, readOnly: false); @@ -125,7 +121,7 @@ public void RollbackToSavepoint_keeps_pre_savepoint_writes_undoes_later_ones_and channel.CommitTransaction(); Assert.Equal(0x11, channel.ReadPage(1).Span[10]); // and the kept write survives commit } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -133,8 +129,7 @@ public void RollbackToSavepoint_restores_a_page_to_its_savepoint_state_not_trans { // A page written both before and after the savepoint must come back to its at-savepoint bytes, which // is what the per-frame (not per-transaction) before-image snapshot guarantees. - string path = Path.Combine(Path.GetTempPath(), $"libred-sp2-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-sp2-"); try { using var channel = PageChannel.Open(path, readOnly: false); @@ -153,14 +148,13 @@ public void RollbackToSavepoint_restores_a_page_to_its_savepoint_state_not_trans Assert.Equal(0x11, channel.ReadPage(1).Span[10]); // restored to the savepoint state, not the original } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void ReleaseSavepoint_merges_into_the_parent_so_an_outer_rollback_still_undoes_it() { - string path = Path.Combine(Path.GetTempPath(), $"libred-sp3-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-sp3-"); try { using var channel = PageChannel.Open(path, readOnly: false); @@ -182,6 +176,6 @@ public void ReleaseSavepoint_merges_into_the_parent_so_an_outer_rollback_still_u Assert.Equal(original, channel.ReadPage(1).Span[10]); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/PageChannelWriteTests.cs b/test/LibRed.Core.Tests/PageChannelWriteTests.cs index a262eba0..097eec8a 100644 --- a/test/LibRed.Core.Tests/PageChannelWriteTests.cs +++ b/test/LibRed.Core.Tests/PageChannelWriteTests.cs @@ -7,8 +7,7 @@ public class PageChannelWriteTests { private static string CopyToTemp() { - string path = Path.Combine(Path.GetTempPath(), $"libred-write-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-write-"); return path; } @@ -30,7 +29,7 @@ public void Multiple_channels_can_open_the_same_file_at_once() writer.WritePage(5, page); Assert.Equal(page, reader.ReadPage(5).Span.ToArray()); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -50,7 +49,7 @@ public void Rewriting_a_page_unchanged_is_a_no_op_byte_for_byte() using (var channel = PageChannel.Open(path, readOnly: true)) Assert.Equal(original, channel.ReadPage(5).Span.ToArray()); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -74,7 +73,7 @@ public void Written_bytes_survive_a_reopen() Assert.Equal(0xCD, reread[101]); } } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -99,7 +98,7 @@ public void AllocatePage_grows_the_file_by_one_zeroed_page() Assert.All(channel.ReadPage(allocated).Span.ToArray(), b => Assert.Equal(0, b)); } } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -111,6 +110,6 @@ public void Read_only_channel_refuses_writes() using var channel = PageChannel.Open(path, readOnly: true); Assert.Throws(() => channel.WritePage(5, new byte[channel.PageSize])); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/PrimaryKeyNameAccessTests.cs b/test/LibRed.Core.Tests/PrimaryKeyNameAccessTests.cs index 0bc8ab0e..54fe367b 100644 --- a/test/LibRed.Core.Tests/PrimaryKeyNameAccessTests.cs +++ b/test/LibRed.Core.Tests/PrimaryKeyNameAccessTests.cs @@ -15,17 +15,7 @@ namespace LibRed.Core.Tests; /// public class PrimaryKeyNameAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static string AcePrimaryKeyName(OleDbConnection conn, string table) { @@ -38,8 +28,7 @@ private static string AcePrimaryKeyName(OleDbConnection conn, string table) [InlineData(null, "PrimaryKey")] public void Access_reports_the_libred_primary_key_name(string? pkName, string expectedName) { - string path = Path.Combine(Path.GetTempPath(), $"pkname-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "pkname-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -51,6 +40,6 @@ [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true)], using var conn = OpenOleDb(path); Assert.Equal(expectedName, AcePrimaryKeyName(conn, "T")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/ProcedureParameterAccessTests.cs b/test/LibRed.Core.Tests/ProcedureParameterAccessTests.cs index 4ac6a50e..faa1739c 100644 --- a/test/LibRed.Core.Tests/ProcedureParameterAccessTests.cs +++ b/test/LibRed.Core.Tests/ProcedureParameterAccessTests.cs @@ -12,26 +12,12 @@ namespace LibRed.Core.Tests; /// public class ProcedureParameterAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; } - } - Thread.Sleep(50); - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider opened the database.", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_runs_a_parameterized_procedure() { - string path = Path.Combine(Path.GetTempPath(), $"proc-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "proc-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -63,7 +49,7 @@ public void Access_runs_a_parameterized_procedure() int count = Convert.ToInt32(cmd.ExecuteScalar()); Assert.Equal(expected, count); // the procedure honours the supplied parameter values } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // "Employee Sales by Country" shape: @-parameters + a nested join onto the "Order Subtotals" view. @@ -71,8 +57,7 @@ public void Access_runs_a_parameterized_procedure() [Fact] public void Access_runs_a_nested_join_at_parameter_procedure() { - string path = Path.Combine(Path.GetTempPath(), $"empsales-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "empsales-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -120,6 +105,6 @@ public void Access_runs_a_nested_join_at_parameter_procedure() cmd.Parameters.Add(new OleDbParameter("Ending_Date", new DateTime(1997, 12, 31))); Assert.Equal(expected, Convert.ToInt32(cmd.ExecuteScalar())); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/PropertyNamePoolOrderTests.cs b/test/LibRed.Core.Tests/PropertyNamePoolOrderTests.cs index 2184c603..6e256885 100644 --- a/test/LibRed.Core.Tests/PropertyNamePoolOrderTests.cs +++ b/test/LibRed.Core.Tests/PropertyNamePoolOrderTests.cs @@ -13,17 +13,7 @@ namespace LibRed.Core.Tests; // future "tidy-up" that sorts the pool in PropertyBlob.Write (which uses Distinct() = first appearance). public class PropertyNamePoolOrderTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private static List ReadNamePool(byte[] blob) { @@ -52,8 +42,7 @@ private static List ReadNamePool(byte[] blob) [Fact] public void Ace_stores_the_name_pool_in_first_appearance_order_not_alphabetical() { - string path = Path.Combine(Path.GetTempPath(), $"pnp-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "pnp-"); try { using (var conn = OpenOleDb(path)) @@ -90,6 +79,6 @@ public void Ace_stores_the_name_pool_in_first_appearance_order_not_alphabetical( ])); Assert.Equal(pool, libred); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/QualifiedStarViewAccessTests.cs b/test/LibRed.Core.Tests/QualifiedStarViewAccessTests.cs index 88acbdb6..15ab9a4c 100644 --- a/test/LibRed.Core.Tests/QualifiedStarViewAccessTests.cs +++ b/test/LibRed.Core.Tests/QualifiedStarViewAccessTests.cs @@ -11,26 +11,12 @@ namespace LibRed.Core.Tests; /// public class QualifiedStarViewAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; } - } - Thread.Sleep(50); - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider opened the database.", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_runs_a_qualified_star_view() { - string path = Path.Combine(Path.GetTempPath(), $"qstar-view-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "qstar-view-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -53,6 +39,6 @@ public void Access_runs_a_qualified_star_view() Assert.False(reader.IsDBNull(0)); // Products.* projected ProductName Assert.False(reader.IsDBNull(1)); // and the joined CategoryName } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/RandomAutoNumberAccessTests.cs b/test/LibRed.Core.Tests/RandomAutoNumberAccessTests.cs index 883de8bd..2df87377 100644 --- a/test/LibRed.Core.Tests/RandomAutoNumberAccessTests.cs +++ b/test/LibRed.Core.Tests/RandomAutoNumberAccessTests.cs @@ -10,17 +10,7 @@ namespace LibRed.Core.Tests; // insert), and continues issuing random-looking (non-sequential) ids of its own. public class RandomAutoNumberAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); // A "Random" AutoNumber: an AutoNumber column carrying DefaultValue = GenUniqueID() (byte-identical to the // UI-authored fixture database4.accdb). Created here via the Core CreateTable API with a column default. @@ -38,8 +28,7 @@ private static void CreateRandomAutoNumberTable(string path) [Fact] public void Access_reads_a_libred_written_random_autonumber_and_continues_it() { - string path = Path.Combine(Path.GetTempPath(), $"rand-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "rand-"); try { CreateRandomAutoNumberTable(path); @@ -64,14 +53,13 @@ public void Access_reads_a_libred_written_random_autonumber_and_continues_it() Assert.DoesNotContain(0, ids); Assert.False(ids.Zip(ids.Skip(1)).All(p => p.Second - p.First == 1), "ids should not be sequential"); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_reads_a_libred_written_plain_long_genuniqueid_default_and_applies_it() { - string path = Path.Combine(Path.GetTempPath(), $"plain-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "plain-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -100,14 +88,13 @@ public void Access_reads_a_libred_written_plain_long_genuniqueid_default_and_app Assert.Equal(3, vs.Distinct().Count()); Assert.DoesNotContain(0, vs); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Random_autonumber_descriptor_round_trips_through_libred() { - string path = Path.Combine(Path.GetTempPath(), $"rand-desc-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "rand-desc-"); try { CreateRandomAutoNumberTable(path); @@ -120,6 +107,6 @@ public void Random_autonumber_descriptor_round_trips_through_libred() Assert.True(col.IsRandomAutoNumber); Assert.Equal("GenUniqueID()", col.DefaultValue); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/RefActionAccessTests.cs b/test/LibRed.Core.Tests/RefActionAccessTests.cs index 29856a8f..47e4dfaf 100644 --- a/test/LibRed.Core.Tests/RefActionAccessTests.cs +++ b/test/LibRed.Core.Tests/RefActionAccessTests.cs @@ -11,21 +11,12 @@ namespace LibRed.Core.Tests; /// public class RefActionAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No provider"); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_applies_a_libred_written_on_delete_set_null() { - string path = Path.Combine(Path.GetTempPath(), $"ri-ace-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "ri-ace-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -49,6 +40,6 @@ public void Access_applies_a_libred_written_on_delete_set_null() using (var cmd = conn.CreateCommand()) { cmd.CommandText = "SELECT COUNT(*) FROM C WHERE ParentId IS NULL"; Assert.Equal(2, Convert.ToInt32(cmd.ExecuteScalar())); } // both nulled } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/RequiredColumnTests.cs b/test/LibRed.Core.Tests/RequiredColumnTests.cs index 8409cdb2..00dd9550 100644 --- a/test/LibRed.Core.Tests/RequiredColumnTests.cs +++ b/test/LibRed.Core.Tests/RequiredColumnTests.cs @@ -12,15 +12,7 @@ namespace LibRed.Core.Tests; /// public class RequiredColumnTests { - private static OleDbConnection OpenOleDb(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No provider"); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); private const string Ddl = "CREATE TABLE T (Id counter PRIMARY KEY, Req int NOT NULL, Opt int, Def int DEFAULT 7 NOT NULL)"; @@ -54,10 +46,8 @@ private static byte[] ReadLvProp(string path, string table) [Fact] public void Required_property_blob_matches_access_byte_for_byte() { - string acePath = Path.Combine(Path.GetTempPath(), $"req-ace-{Guid.NewGuid():N}.accdb"); - string libPath = Path.Combine(Path.GetTempPath(), $"req-lib-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, acePath); - File.Copy(TestDatabases.NorthwindAccdb, libPath); + string acePath = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "req-ace-"); + string libPath = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "req-lib-"); try { using (var conn = OpenOleDb(acePath)) @@ -69,14 +59,13 @@ public void Required_property_blob_matches_access_byte_for_byte() Assert.True(ace.AsSpan().SequenceEqual(lib), $"ace={Convert.ToHexString(ace)}\nlib={Convert.ToHexString(lib)}"); } - finally { foreach (var p in new[] { acePath, libPath }) try { File.Delete(p); } catch (IOException) { } } + finally { foreach (var p in new[] { acePath, libPath }) TemporaryDatabase.Delete(p); } } [Fact] public void Libred_reads_required_back_as_not_nullable() { - string path = Path.Combine(Path.GetTempPath(), $"req-read-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "req-read-"); try { using (var conn = OpenOleDb(path)) @@ -88,14 +77,13 @@ public void Libred_reads_required_back_as_not_nullable() Assert.False(def.FindColumn("Def")!.IsNullable); Assert.True(def.FindColumn("Opt")!.IsNullable); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_enforces_required_on_a_libred_created_table() { - string path = Path.Combine(Path.GetTempPath(), $"req-enf-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "req-enf-"); try { CreateViaLibRed(path); @@ -112,6 +100,6 @@ public void Access_enforces_required_on_a_libred_created_table() using (var c = conn.CreateCommand()) { c.CommandText = "INSERT INTO T (Req) VALUES (5)"; Assert.Equal(1, c.ExecuteNonQuery()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/RowInserterTests.cs b/test/LibRed.Core.Tests/RowInserterTests.cs index b8ea265f..69968e71 100644 --- a/test/LibRed.Core.Tests/RowInserterTests.cs +++ b/test/LibRed.Core.Tests/RowInserterTests.cs @@ -10,8 +10,7 @@ public class RowInserterTests { private static string CopyToTemp() { - string path = Path.Combine(Path.GetTempPath(), $"libred-insert-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-insert-"); return path; } @@ -59,7 +58,7 @@ public void Inserted_row_round_trips_through_our_reader() // Original rows are intact. Assert.Contains((1, "Speedy Express", "(503) 555-9831"), rows); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -92,7 +91,7 @@ public void Index_stays_sorted_across_inserts_including_a_middle_key() Assert.Equal([1, 2, 3, 4, 5], ids); // index order, with 4 inserted in the middle } } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -140,26 +139,10 @@ public void Insert_matches_access_own_engine_and_is_readable_by_access() } finally { - File.Delete(ours); - File.Delete(access); + TemporaryDatabase.Delete(ours); + TemporaryDatabase.Delete(access); } } - private static OleDbConnection OpenOleDb(string path) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try - { - var conn = new OleDbConnection($"Provider={provider};Data Source={path}"); - conn.Open(); - return conn; - } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) - { - // Try the next provider version. - } - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider (12.0/16.0) is available."); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); } diff --git a/test/LibRed.Core.Tests/RowRelocationCorruptionTests.cs b/test/LibRed.Core.Tests/RowRelocationCorruptionTests.cs index dba32824..f8e409c9 100644 --- a/test/LibRed.Core.Tests/RowRelocationCorruptionTests.cs +++ b/test/LibRed.Core.Tests/RowRelocationCorruptionTests.cs @@ -32,7 +32,7 @@ public void Corrupt_relocation_is_rejected_during_table_scan(string corruption) Table table = db.OpenTable("T"); Assert.Throws(() => table.Rows().ToList()); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -48,7 +48,7 @@ public void Corrupt_relocation_is_rejected_during_index_seek() IndexDef primaryKey = table.Definition.Indexes.Single(i => i.IsPrimaryKey); Assert.Throws(() => table.SeekRows(primaryKey, [3]).ToList()); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -64,13 +64,12 @@ public void Corrupt_relocation_is_rejected_before_raw_rewrite() Assert.Throws(() => new RowInserter(table.Channel, table.Definition).RewriteRowRaw(source, [1, 0, 0])); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } private static (string Path, RowId Source) CreateRelocatedRow() { - string path = Path.Combine(Path.GetTempPath(), $"reloc-corrupt-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "reloc-corrupt-"); string mid = new('m', 80), big = new('X', 255); using var db = JetDatabase.Open(path, readOnly: false); diff --git a/test/LibRed.Core.Tests/SelfReferencingForeignKeyAccessTests.cs b/test/LibRed.Core.Tests/SelfReferencingForeignKeyAccessTests.cs index 876bebc3..9d1f7086 100644 --- a/test/LibRed.Core.Tests/SelfReferencingForeignKeyAccessTests.cs +++ b/test/LibRed.Core.Tests/SelfReferencingForeignKeyAccessTests.cs @@ -12,23 +12,12 @@ namespace LibRed.Core.Tests; /// public class SelfReferencingForeignKeyAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_reads_and_enforces_a_self_referencing_foreign_key() { - string path = Path.Combine(Path.GetTempPath(), $"selffk-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "selffk-"); try { using (var conn = OpenOleDb(path)) @@ -62,7 +51,7 @@ public void Access_reads_and_enforces_a_self_referencing_foreign_key() Exec("INSERT INTO Emp (EmployeeID, ReportsTo) VALUES (5, 2)"); // valid manager Exec("INSERT INTO Emp (EmployeeID, ReportsTo) VALUES (6, NULL)"); // no manager — allowed - Assert.ThrowsAny(() => // manager 99 doesn't exist + Assert.Throws(() => // manager 99 doesn't exist Exec("INSERT INTO Emp (EmployeeID, ReportsTo) VALUES (7, 99)")); using (var c = conn2.CreateCommand()) @@ -71,6 +60,6 @@ public void Access_reads_and_enforces_a_self_referencing_foreign_key() Assert.Equal(6, Convert.ToInt32(c.ExecuteScalar())); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/TableCreatorTests.cs b/test/LibRed.Core.Tests/TableCreatorTests.cs index 444c483a..485d8c84 100644 --- a/test/LibRed.Core.Tests/TableCreatorTests.cs +++ b/test/LibRed.Core.Tests/TableCreatorTests.cs @@ -58,7 +58,7 @@ public void Multi_page_insert_marks_only_the_tail_page_free() Assert.Equal([owned.Max()], free); } } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -92,7 +92,7 @@ public void Insert_maintains_the_unique_index_stat_and_leaves_total_at_zero() Assert.Equal(3, table.Definition.Indexes.Single(i => i.IsPrimaryKey).UniqueEntryCount); } } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -128,7 +128,7 @@ public void Insert_spanning_multiple_data_pages_round_trips() Assert.True(ownedPages > 1, $"expected multiple data pages, got {ownedPages}"); } } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -162,7 +162,7 @@ public void Autonumber_is_generated_when_the_column_is_omitted() Assert.Equal(8, byV["c"]); } } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -193,13 +193,12 @@ public void Autonumber_insert_tracks_the_tdef_high_water_mark() Assert.Equal(5, highWater); // Access reads this to pick the next id (= 6) } } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } private static string CopyToTemp() { - string path = Path.Combine(Path.GetTempPath(), $"libred-create-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-create-"); return path; } @@ -231,7 +230,7 @@ public void Created_table_appears_in_the_catalog_with_its_columns() Assert.Empty(db.OpenTable("Widgets").Rows()); // starts empty } } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -259,7 +258,7 @@ public void Created_table_round_trips_inserted_rows() Assert.Equal([2, "second", new DateTime(2021, 3, 4)], rows[1]); } } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -305,7 +304,7 @@ public void Created_table_round_trips_nulls_and_numeric_edges() Assert.Equal([int.MinValue, 0, int.MaxValue], ids); } } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -341,7 +340,7 @@ public void Created_table_with_primary_key_has_a_working_index() Assert.Equal([1, 2, 3], ids); } } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -360,6 +359,6 @@ public void Creating_a_table_leaves_existing_tables_readable() Assert.Equal(830, db.OpenTable("Orders").Rows().Count()); } } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/TdefVariableRegionTests.cs b/test/LibRed.Core.Tests/TdefVariableRegionTests.cs index 6497d4b1..c866dd39 100644 --- a/test/LibRed.Core.Tests/TdefVariableRegionTests.cs +++ b/test/LibRed.Core.Tests/TdefVariableRegionTests.cs @@ -130,6 +130,59 @@ public void Overlong_index_name_is_rejected_before_decoding() definition.Read(new PageBuffer(page.AsMemory(0, declaredLength), 99), Format)); } + [Theory] + [InlineData("negative-length")] + [InlineData("shorter-than-header")] + [InlineData("longer-than-buffer")] + [InlineData("too-many-columns")] + [InlineData("variable-column-high-water-overflow")] + [InlineData("negative-index-count")] + [InlineData("too-many-indexes")] + [InlineData("negative-logical-index-count")] + [InlineData("logical-index-region-overflow")] + public void Malformed_header_counts_and_lengths_are_rejected_before_region_allocation(string corruption) + { + byte[] page = TdefBuilder.Build(Format, TableType.User, + [new("C", JetDataType.Int32, 4, IsFixedLength: true)]).Page; + int declaredLength = BinaryPrimitives.ReadInt32LittleEndian(page.AsSpan(Format.TdefLengthOffset, 4)); + + switch (corruption) + { + case "negative-length": + BinaryPrimitives.WriteInt32LittleEndian(page.AsSpan(Format.TdefLengthOffset, 4), -1); + break; + case "shorter-than-header": + BinaryPrimitives.WriteInt32LittleEndian( + page.AsSpan(Format.TdefLengthOffset, 4), Format.TdefRealIndexBlockOffset - 1); + break; + case "longer-than-buffer": + BinaryPrimitives.WriteInt32LittleEndian(page.AsSpan(Format.TdefLengthOffset, 4), declaredLength + 1); + break; + case "too-many-columns": + BinaryPrimitives.WriteUInt16LittleEndian(page.AsSpan(Format.TdefColumnCountOffset, 2), 256); + break; + case "variable-column-high-water-overflow": + BinaryPrimitives.WriteUInt16LittleEndian(page.AsSpan(Format.TdefVariableColumnsOffset, 2), 256); + break; + case "negative-index-count": + BinaryPrimitives.WriteInt32LittleEndian(page.AsSpan(Format.TdefIndexCountOffset, 4), -1); + break; + case "too-many-indexes": + BinaryPrimitives.WriteInt32LittleEndian(page.AsSpan(Format.TdefIndexCountOffset, 4), 33); + break; + case "negative-logical-index-count": + BinaryPrimitives.WriteInt32LittleEndian(page.AsSpan(Format.TdefRealIndexCountOffset, 4), -1); + break; + case "logical-index-region-overflow": + BinaryPrimitives.WriteInt32LittleEndian(page.AsSpan(Format.TdefRealIndexCountOffset, 4), int.MaxValue); + break; + } + + var definition = new TableDefinitionPage(); + Assert.Throws(() => + definition.Read(new PageBuffer(page.AsMemory(0, declaredLength), 99), Format)); + } + [Fact] public void Valid_memo_usage_map_entry_remains_available() { diff --git a/test/LibRed.Core.Tests/TemporaryDatabaseTests.cs b/test/LibRed.Core.Tests/TemporaryDatabaseTests.cs new file mode 100644 index 00000000..e0f7c7e5 --- /dev/null +++ b/test/LibRed.Core.Tests/TemporaryDatabaseTests.cs @@ -0,0 +1,35 @@ +using LibRed.Tests.Shared; +using Xunit; + +namespace LibRed.Core.Tests; + +public class TemporaryDatabaseTests +{ + [Fact] + public void Dispose_closes_the_database_and_removes_the_copy() + { + string path; + using (var temp = TemporaryDatabase.CopyOf(TestDatabases.NorthwindAccdb, "tempdb-lifetime")) + { + path = temp.Path; + temp.Open(readOnly: true); + Assert.True(File.Exists(path)); + Assert.Throws(() => temp.Open(readOnly: true)); + } + + Assert.False(File.Exists(path)); + } + + [Fact] + public void Preserve_leaves_the_copy_for_diagnostics() + { + string path; + using (var temp = TemporaryDatabase.CopyOf(TestDatabases.NorthwindAccdb, "tempdb-preserve")) + { + path = temp.Preserve(); + } + + try { Assert.True(File.Exists(path)); } + finally { TemporaryDatabase.Delete(path); } + } +} diff --git a/test/LibRed.Core.Tests/TextKeyEncodingTests.cs b/test/LibRed.Core.Tests/TextKeyEncodingTests.cs index 7ade9fe9..e7a0c53a 100644 --- a/test/LibRed.Core.Tests/TextKeyEncodingTests.cs +++ b/test/LibRed.Core.Tests/TextKeyEncodingTests.cs @@ -8,15 +8,7 @@ namespace LibRed.Core.Tests; public class TextKeyEncodingTests { - private static OleDbConnection OpenOleDb(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider available."); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Encoded_text_keys_match_access_byte_for_byte() @@ -37,8 +29,7 @@ public void Encoded_text_keys_match_access_byte_for_byte() "ß", "Straße", "Þor", "NuNuCa Nuß-Nougat-Creme", "Aß-B", ]; - string path = Path.Combine(Path.GetTempPath(), $"libred-textkey-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-textkey-"); try { using (var conn = OpenOleDb(path)) @@ -77,7 +68,7 @@ public void Encoded_text_keys_match_access_byte_for_byte() } Assert.Equal(values.Length, checkd); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -85,8 +76,7 @@ public void Encoded_descending_text_keys_match_access_byte_for_byte() { string[] values = ["A", "B", "AB", "Z", "Apple", "A-B", "O'Brien", "0", "Order9"]; - string path = Path.Combine(Path.GetTempPath(), $"libred-textdesc-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-textdesc-"); try { using (var conn = OpenOleDb(path)) @@ -126,6 +116,6 @@ public void Encoded_descending_text_keys_match_access_byte_for_byte() } Assert.Equal(values.Length, checkd); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/TextPrimaryKeyInsertTests.cs b/test/LibRed.Core.Tests/TextPrimaryKeyInsertTests.cs index 03c4d5b3..e4274fef 100644 --- a/test/LibRed.Core.Tests/TextPrimaryKeyInsertTests.cs +++ b/test/LibRed.Core.Tests/TextPrimaryKeyInsertTests.cs @@ -7,21 +7,12 @@ namespace LibRed.Core.Tests; public class TextPrimaryKeyInsertTests { - private static OleDbConnection OpenOleDb(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider available."); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Insert_into_text_primary_key_table_is_seekable_by_access() { - string path = Path.Combine(Path.GetTempPath(), $"libred-textpk-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-textpk-"); try { // Insert a new Customers row (text PK 'CustomerID') through LibRed. @@ -53,6 +44,6 @@ public void Insert_into_text_primary_key_table_is_seekable_by_access() Assert.Equal("ZZ Top Trading", cmd.ExecuteScalar()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/TransactionFailureRecoveryTests.cs b/test/LibRed.Core.Tests/TransactionFailureRecoveryTests.cs new file mode 100644 index 00000000..78349eac --- /dev/null +++ b/test/LibRed.Core.Tests/TransactionFailureRecoveryTests.cs @@ -0,0 +1,143 @@ +using LibRed.IO; +using Xunit; + +namespace LibRed.Core.Tests; + +public class TransactionFailureRecoveryTests +{ + [Fact] + public void Conflicting_file_growth_can_rollback_and_retry_without_truncating_the_winner() + { + string path = Fresh("growth-conflict-"); + try + { + using var first = PageChannel.Open(path, readOnly: false); + using var second = PageChannel.Open(path, readOnly: false); + int originalCount = first.PageCount; + + first.BeginTransaction(); + second.BeginTransaction(); + int firstPage = first.AllocatePage(); + int secondPage = second.AllocatePage(); + Assert.Equal(originalCount, firstPage); + Assert.Equal(firstPage, secondPage); + WriteMarker(first, firstPage, 0x11); + WriteMarker(second, secondPage, 0x22); + + first.CommitTransaction(); + var conflict = Assert.Throws(() => second.CommitTransaction()); + Assert.Contains("write conflict", conflict.Message, StringComparison.OrdinalIgnoreCase); + second.RollbackTransaction(); + + Assert.Equal(originalCount + 1, second.PageCount); + Assert.Equal(0x11, second.ReadPage(firstPage).Span[100]); + + second.BeginTransaction(); + int retryPage = second.AllocatePage(); + Assert.Equal(originalCount + 1, retryPage); + WriteMarker(second, retryPage, 0x22); + second.CommitTransaction(); + + Assert.Equal(originalCount + 2, first.PageCount); + Assert.Equal(0x11, first.ReadPage(firstPage).Span[100]); + Assert.Equal(0x22, first.ReadPage(retryPage).Span[100]); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Disposing_channel_with_outstanding_growth_discards_the_overlay() + { + string path = Fresh("dispose-growth-"); + try + { + byte[] before = File.ReadAllBytes(path); + using (var channel = PageChannel.Open(path, readOnly: false)) + { + channel.BeginTransaction(); + int page = channel.AllocatePage(); + WriteMarker(channel, page, 0x7E); + Assert.Equal(before.Length / channel.PageSize + 1, channel.PageCount); + } + + Assert.Equal(before, File.ReadAllBytes(path)); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Failed_multi_page_publication_restores_already_published_pages_and_leaves_transaction_rollbackable() + { + string path = Fresh("commit-failure-"); + try + { + byte[] before = File.ReadAllBytes(path); + var locks = new FailOnceOnSecondExclusiveLockManager(); + using (var channel = PageChannel.Open(path, readOnly: false, locks: locks)) + { + channel.BeginTransaction(); + WriteMarker(channel, 5, 0x51); + WriteMarker(channel, 6, 0x61); + + Assert.Throws(() => channel.CommitTransaction()); + Assert.True(channel.InTransaction); + channel.RollbackTransaction(); + } + + Assert.Equal(before, File.ReadAllBytes(path)); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Failed_growth_publication_truncates_published_tail_and_same_transaction_can_retry_commit() + { + string path = Fresh("commit-growth-failure-"); + try + { + int originalLength = checked((int)new FileInfo(path).Length); + var locks = new FailOnceOnSecondExclusiveLockManager(); + using (var channel = PageChannel.Open(path, readOnly: false, locks: locks)) + { + channel.BeginTransaction(); + int firstPage = channel.AllocatePage(); + int secondPage = channel.AllocatePage(); + WriteMarker(channel, firstPage, 0x31); + WriteMarker(channel, secondPage, 0x32); + + Assert.Throws(() => channel.CommitTransaction()); + Assert.True(channel.InTransaction); + Assert.Equal(originalLength, new FileInfo(path).Length); + + channel.CommitTransaction(); + Assert.False(channel.InTransaction); + Assert.Equal(originalLength + 2 * channel.PageSize, new FileInfo(path).Length); + Assert.Equal(0x31, channel.ReadPage(firstPage).Span[100]); + Assert.Equal(0x32, channel.ReadPage(secondPage).Span[100]); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void WriteMarker(PageChannel channel, int page, byte marker) + { + byte[] bytes = channel.ReadPage(page).Span.ToArray(); + bytes[100] = marker; + channel.WritePage(page, bytes); + } + + private static string Fresh(string prefix) => TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, prefix); + + private sealed class FailOnceOnSecondExclusiveLockManager : ILockManager + { + private int _exclusiveEntries; + public void EnterShared(int page) { } + public void ExitShared(int page) { } + public void EnterExclusive(int page) + { + if (Interlocked.Increment(ref _exclusiveEntries) == 2) + throw new IOException("Injected commit publication failure."); + } + public void ExitExclusive(int page) { } + } +} diff --git a/test/LibRed.Core.Tests/TransactionPhysicalRollbackAccessTests.cs b/test/LibRed.Core.Tests/TransactionPhysicalRollbackAccessTests.cs new file mode 100644 index 00000000..5b392a61 --- /dev/null +++ b/test/LibRed.Core.Tests/TransactionPhysicalRollbackAccessTests.cs @@ -0,0 +1,175 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +/// Rollback coverage for page allocation, B-tree splits, relocation, and long-value ownership. +public class TransactionPhysicalRollbackAccessTests +{ + [Fact] + public void Rollback_discards_index_splits_and_restores_seekable_tree_for_ace() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "rollback-split-"); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + { + db.CreateTable("SplitTxn", + [new("Id", JetDataType.Int32, 4, IsFixedLength: true), + new("S", JetDataType.Text, 40, IsFixedLength: false)], + primaryKey: ["Id"]); + Table table = db.OpenTable("SplitTxn"); + for (int i = 1; i <= 10; i++) table.Insert([i, $"base-{i}"]); + } + byte[] before = File.ReadAllBytes(path); + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + Table table = db.OpenTable("SplitTxn"); + db.BeginTransaction(); + for (int i = 11; i <= 1300; i++) table.Insert([i, $"new-{i}"]); + IndexDef changedPk = table.Definition.Indexes.Single(i => i.IsPrimaryKey); + Assert.Equal("new-1300", Value(table, table.SeekRows(changedPk, [1300]).Single(), "S")); + Assert.Equal(1300, table.Rows().Count()); + + db.Rollback(); + table = db.OpenTable("SplitTxn"); + IndexDef restoredPk = table.Definition.Indexes.Single(i => i.IsPrimaryKey); + Assert.Equal(10, table.Rows().Count()); + Assert.Empty(table.SeekRows(restoredPk, [1300])); + Assert.Equal("base-10", Value(table, table.SeekRows(restoredPk, [10]).Single(), "S")); + } + + Assert.Equal(before, File.ReadAllBytes(path)); + using var connection = AceTestDatabase.Open(path); + AssertScalar(connection, "SELECT COUNT(*) FROM SplitTxn", 10); + AssertScalar(connection, "SELECT COUNT(*) FROM SplitTxn WHERE Id = 1300", 0); + Assert.Equal("base-10", ExecuteScalar(connection, "SELECT S FROM SplitTxn WHERE Id = 10")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Rollback_restores_relocated_row_and_moved_secondary_index_for_ace() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "rollback-relocate-"); + try + { + const string oldKey = "key-3"; + string large = new('R', 255); + using (var db = JetDatabase.Open(path, readOnly: false)) + { + db.CreateTable("RelocateTxn", + [ + new("Id", JetDataType.Int32, 4, IsFixedLength: true), + new("K", JetDataType.Text, 255 * 2, IsFixedLength: false), + new("A", JetDataType.Text, 255 * 2, IsFixedLength: false), + new("B", JetDataType.Text, 255 * 2, IsFixedLength: false), + ], primaryKey: ["Id"], relationships: null, + uniqueConstraints: [new UniqueIndexSpec("IX_K", ["K"])]); + Table table = db.OpenTable("RelocateTxn"); + for (int i = 1; i <= 12; i++) table.Insert([i, $"key-{i}", new string('a', 80), new string('b', 80)]); + } + byte[] before = File.ReadAllBytes(path); + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + Table table = db.OpenTable("RelocateTxn"); + IndexDef pk = table.Definition.Indexes.Single(i => i.IsPrimaryKey); + IndexDef ixK = table.Definition.Indexes.Single(i => i.Name == "IX_K"); + (RowId rowId, object?[] oldValues) = table.SeekRowsWithIds(pk, [3]).Single(); + object?[] newValues = (object?[])oldValues.Clone(); + newValues[table.Definition.FindColumn("K")!.Index] = large; + newValues[table.Definition.FindColumn("A")!.Index] = large; + newValues[table.Definition.FindColumn("B")!.Index] = large; + + db.BeginTransaction(); + table.Update(rowId, newValues); + table.MoveIndexEntry(ixK, oldValues, newValues, rowId); + Assert.Single(table.SeekRows(ixK, [null, large])); + Assert.Empty(table.SeekRows(ixK, [null, oldKey])); + + db.Rollback(); + table = db.OpenTable("RelocateTxn"); + ixK = table.Definition.Indexes.Single(i => i.Name == "IX_K"); + Assert.Single(table.SeekRows(ixK, [null, oldKey])); + Assert.Empty(table.SeekRows(ixK, [null, large])); + Assert.Equal(oldKey, Value(table, table.Rows().Single(r => Convert.ToInt32(r[0]) == 3), "K")); + } + + Assert.Equal(before, File.ReadAllBytes(path)); + using var connection = AceTestDatabase.Open(path); + AssertScalar(connection, $"SELECT COUNT(*) FROM RelocateTxn WHERE K = '{large}'", 0); + Assert.Equal(oldKey, ExecuteScalar(connection, "SELECT K FROM RelocateTxn WHERE Id = 3")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Rollback_restores_lval_data_and_usage_maps_for_ace() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "rollback-lval-"); + try + { + string original = new('A', 20000); + string replacement = new('B', 24000); + int[] ownedBefore; + using (var db = JetDatabase.Open(path, readOnly: false)) + { + db.CreateTable("LvalTxn", + [new("Id", JetDataType.Int32, 4, IsFixedLength: true), + new("M", JetDataType.Memo, 0, IsFixedLength: false)], + primaryKey: ["Id"]); + Table table = db.OpenTable("LvalTxn"); + table.Insert([1, original]); + ownedBefore = table.UsageMap.DataPages().ToArray(); + } + byte[] before = File.ReadAllBytes(path); + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + Table table = db.OpenTable("LvalTxn"); + IndexDef pk = table.Definition.Indexes.Single(i => i.IsPrimaryKey); + (RowId rowId, object?[] values) = table.SeekRowsWithIds(pk, [1]).Single(); + object?[] updated = (object?[])values.Clone(); + int memoIndex = table.Definition.FindColumn("M")!.Index; + updated[memoIndex] = replacement; + + db.BeginTransaction(); + table.Update(rowId, updated, new HashSet { memoIndex }); + table.Insert([2, new string('C', 28000)]); + Assert.Equal(replacement, table.SeekRows(pk, [1]).Single()[memoIndex]); + Assert.Equal(2, table.Rows().Count()); + + db.Rollback(); + table = db.OpenTable("LvalTxn"); + pk = table.Definition.Indexes.Single(i => i.IsPrimaryKey); + Assert.Equal(original, table.SeekRows(pk, [1]).Single()[memoIndex]); + Assert.Empty(table.SeekRows(pk, [2])); + Assert.Equal(ownedBefore, table.UsageMap.DataPages().ToArray()); + } + + Assert.Equal(before, File.ReadAllBytes(path)); + using var connection = AceTestDatabase.Open(path); + AssertScalar(connection, "SELECT COUNT(*) FROM LvalTxn", 1); + Assert.Equal(original, ExecuteScalar(connection, "SELECT M FROM LvalTxn WHERE Id = 1")); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static object? Value(Table table, object?[] row, string column) => + row[table.Definition.FindColumn(column)!.Index]; + + private static object? ExecuteScalar(OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } + + private static void AssertScalar(OleDbConnection connection, string sql, int expected) => + Assert.Equal(expected, Convert.ToInt32(ExecuteScalar(connection, sql))); +} diff --git a/test/LibRed.Core.Tests/TransactionSavepointAndEncryptionAccessTests.cs b/test/LibRed.Core.Tests/TransactionSavepointAndEncryptionAccessTests.cs new file mode 100644 index 00000000..08f49dec --- /dev/null +++ b/test/LibRed.Core.Tests/TransactionSavepointAndEncryptionAccessTests.cs @@ -0,0 +1,219 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Catalog; +using LibRed.Crypto; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +public class TransactionSavepointAndEncryptionAccessTests +{ + [Theory] + [InlineData(AccessEncryption.OfficeStandardRc4)] + [InlineData(AccessEncryption.OfficeStandardAes)] + [InlineData(AccessEncryption.Agile)] + public void Encrypted_commit_publishes_splits_relocation_and_lval_changes_to_other_readers_and_ace( + AccessEncryption scheme) + { + const string password = "Commit-S3cret!"; + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "encrypted-commit-"); + string large = new('C', 255); + string committedMemo = new('D', 20000); + try + { + using (var setup = JetDatabase.Open(path, readOnly: false)) + { + CreatePhysicalTable(setup, "EncryptedCommit"); + Table table = setup.OpenTable("EncryptedCommit"); + for (int i = 1; i <= 10; i++) table.Insert([i, $"key-{i}", "short", "short", new string('A', 5000)]); + } + DatabaseEncryption.SetPassword(path, password, scheme); + byte[] encryptedBefore = File.ReadAllBytes(path); + + using (var writer = JetDatabase.Open(path, readOnly: false, password: password)) + using (var reader = JetDatabase.Open(path, password: password)) + { + Table writerTable = writer.OpenTable("EncryptedCommit"); + writer.BeginTransaction(); + for (int i = 11; i <= 900; i++) writerTable.Insert([i, $"new-{i}", "x", "x", "memo"]); + MoveLargeIndexedRow(writerTable, id: 3, large, committedMemo); + + Assert.Equal(900, writerTable.Rows().Count()); + Assert.Equal(10, reader.OpenTable("EncryptedCommit").Rows().Count()); + + writer.Commit(); + Table readerTable = reader.OpenTable("EncryptedCommit"); + IndexDef pk = readerTable.Definition.Indexes.Single(i => i.IsPrimaryKey); + IndexDef ixK = readerTable.Definition.Indexes.Single(i => i.Name == "IX_K"); + Assert.Equal(900, readerTable.Rows().Count()); + Assert.Single(readerTable.SeekRows(pk, [900])); + Assert.Single(readerTable.SeekRows(ixK, [null, large])); + Assert.Equal(committedMemo, Value(readerTable, readerTable.SeekRows(pk, [3]).Single(), "M")); + } + + Assert.NotEqual(encryptedBefore, File.ReadAllBytes(path)); + using var connection = AceTestDatabase.Open(path, password); + AssertScalar(connection, "SELECT COUNT(*) FROM EncryptedCommit", 900); + AssertScalar(connection, "SELECT COUNT(*) FROM EncryptedCommit WHERE Id = 900", 1); + Assert.Equal(large, ExecuteScalar(connection, "SELECT K FROM EncryptedCommit WHERE Id = 3")); + Assert.Equal(committedMemo, ExecuteScalar(connection, "SELECT M FROM EncryptedCommit WHERE Id = 3")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Inner_rollback_discards_splits_relocation_and_lval_pages_but_outer_commit_survives_for_ace() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "savepoint-physical-"); + string oldKey = "key-3"; + string large = new('R', 255); + string originalMemo = new('A', 5000); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + { + CreatePhysicalTable(db, "SavepointTxn"); + Table table = db.OpenTable("SavepointTxn"); + for (int i = 1; i <= 10; i++) table.Insert([i, $"key-{i}", "short", "short", originalMemo]); + + db.BeginNested(); + table.Insert([11, "outer", "outer", "outer", "outer"]); + + db.BeginNested(); + for (int i = 12; i <= 1000; i++) table.Insert([i, $"inner-{i}", "x", "x", new string('M', 6000)]); + MoveLargeIndexedRow(table, id: 3, large, new string('B', 20000)); + IndexDef pk = table.Definition.Indexes.Single(i => i.IsPrimaryKey); + IndexDef ixK = table.Definition.Indexes.Single(i => i.Name == "IX_K"); + Assert.Single(table.SeekRows(pk, [1000])); + Assert.Single(table.SeekRows(ixK, [null, large])); + + db.RollbackNested(); + Assert.True(db.InTransaction); + Assert.Equal(1, db.TransactionDepth); + table = db.OpenTable("SavepointTxn"); + pk = table.Definition.Indexes.Single(i => i.IsPrimaryKey); + ixK = table.Definition.Indexes.Single(i => i.Name == "IX_K"); + Assert.Single(table.SeekRows(pk, [11])); + Assert.Empty(table.SeekRows(pk, [12])); + Assert.Empty(table.SeekRows(pk, [1000])); + Assert.Single(table.SeekRows(ixK, [null, oldKey])); + Assert.Empty(table.SeekRows(ixK, [null, large])); + Assert.Equal(originalMemo, Value(table, table.SeekRows(pk, [3]).Single(), "M")); + + db.CommitNested(); + Assert.False(db.InTransaction); + } + + using var connection = AceTestDatabase.Open(path); + AssertScalar(connection, "SELECT COUNT(*) FROM SavepointTxn", 11); + AssertScalar(connection, "SELECT COUNT(*) FROM SavepointTxn WHERE Id = 11 AND K = 'outer'", 1); + AssertScalar(connection, "SELECT COUNT(*) FROM SavepointTxn WHERE Id >= 12", 0); + Assert.Equal(oldKey, ExecuteScalar(connection, "SELECT K FROM SavepointTxn WHERE Id = 3")); + Assert.Equal(originalMemo, ExecuteScalar(connection, "SELECT M FROM SavepointTxn WHERE Id = 3")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Theory] + [InlineData(AccessEncryption.OfficeStandardRc4)] + [InlineData(AccessEncryption.OfficeStandardAes)] + [InlineData(AccessEncryption.Agile)] + public void Encrypted_rollback_discards_splits_relocation_and_lval_writes_byte_for_byte(AccessEncryption scheme) + { + const string password = "Txn-S3cret!"; + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "encrypted-rollback-"); + string oldKey = "key-3"; + string large = new('Z', 255); + string originalMemo = new('A', 5000); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + { + CreatePhysicalTable(db, "EncryptedTxn"); + Table table = db.OpenTable("EncryptedTxn"); + for (int i = 1; i <= 10; i++) table.Insert([i, $"key-{i}", "short", "short", originalMemo]); + } + DatabaseEncryption.SetPassword(path, password, scheme); + byte[] encryptedBefore = File.ReadAllBytes(path); + + using (var db = JetDatabase.Open(path, readOnly: false, password: password)) + { + Table table = db.OpenTable("EncryptedTxn"); + db.BeginTransaction(); + for (int i = 11; i <= 900; i++) table.Insert([i, $"new-{i}", "x", "x", new string('N', 6000)]); + MoveLargeIndexedRow(table, id: 3, large, new string('B', 20000)); + IndexDef pk = table.Definition.Indexes.Single(i => i.IsPrimaryKey); + IndexDef ixK = table.Definition.Indexes.Single(i => i.Name == "IX_K"); + Assert.Single(table.SeekRows(pk, [900])); + Assert.Single(table.SeekRows(ixK, [null, large])); + + db.Rollback(); + table = db.OpenTable("EncryptedTxn"); + pk = table.Definition.Indexes.Single(i => i.IsPrimaryKey); + ixK = table.Definition.Indexes.Single(i => i.Name == "IX_K"); + Assert.Equal(10, table.Rows().Count()); + Assert.Empty(table.SeekRows(pk, [900])); + Assert.Single(table.SeekRows(ixK, [null, oldKey])); + Assert.Empty(table.SeekRows(ixK, [null, large])); + Assert.Equal(originalMemo, Value(table, table.SeekRows(pk, [3]).Single(), "M")); + } + + Assert.Equal(encryptedBefore, File.ReadAllBytes(path)); + using (var db = JetDatabase.Open(path, password: password)) + Assert.Equal(10, db.OpenTable("EncryptedTxn").Rows().Count()); + + using var connection = AceTestDatabase.Open(path, password); + AssertScalar(connection, "SELECT COUNT(*) FROM EncryptedTxn", 10); + AssertScalar(connection, "SELECT COUNT(*) FROM EncryptedTxn WHERE Id = 900", 0); + Assert.Equal(oldKey, ExecuteScalar(connection, "SELECT K FROM EncryptedTxn WHERE Id = 3")); + Assert.Equal(originalMemo, ExecuteScalar(connection, "SELECT M FROM EncryptedTxn WHERE Id = 3")); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void CreatePhysicalTable(JetDatabase db, string name) => + db.CreateTable(name, + [ + new("Id", JetDataType.Int32, 4, IsFixedLength: true), + new("K", JetDataType.Text, 255 * 2, IsFixedLength: false), + new("A", JetDataType.Text, 255 * 2, IsFixedLength: false), + new("B", JetDataType.Text, 255 * 2, IsFixedLength: false), + new("M", JetDataType.Memo, 0, IsFixedLength: false), + ], primaryKey: ["Id"], relationships: null, + uniqueConstraints: [new UniqueIndexSpec("IX_K", ["K"])]); + + private static void MoveLargeIndexedRow(Table table, int id, string newKey, string newMemo) + { + IndexDef pk = table.Definition.Indexes.Single(i => i.IsPrimaryKey); + IndexDef ixK = table.Definition.Indexes.Single(i => i.Name == "IX_K"); + (RowId rowId, object?[] oldValues) = table.SeekRowsWithIds(pk, [id]).Single(); + object?[] newValues = (object?[])oldValues.Clone(); + newValues[table.Definition.FindColumn("K")!.Index] = newKey; + newValues[table.Definition.FindColumn("A")!.Index] = newKey; + newValues[table.Definition.FindColumn("B")!.Index] = newKey; + int memoIndex = table.Definition.FindColumn("M")!.Index; + newValues[memoIndex] = newMemo; + table.Update(rowId, newValues, new HashSet + { + table.Definition.FindColumn("K")!.Index, + table.Definition.FindColumn("A")!.Index, + table.Definition.FindColumn("B")!.Index, + memoIndex, + }); + table.MoveIndexEntry(ixK, oldValues, newValues, rowId); + } + + private static object? Value(Table table, object?[] row, string column) => + row[table.Definition.FindColumn(column)!.Index]; + + private static object? ExecuteScalar(OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } + + private static void AssertScalar(OleDbConnection connection, string sql, int expected) => + Assert.Equal(expected, Convert.ToInt32(ExecuteScalar(connection, sql))); +} diff --git a/test/LibRed.Core.Tests/UpdateAccessTests.cs b/test/LibRed.Core.Tests/UpdateAccessTests.cs index fb23d989..0306419d 100644 --- a/test/LibRed.Core.Tests/UpdateAccessTests.cs +++ b/test/LibRed.Core.Tests/UpdateAccessTests.cs @@ -12,21 +12,12 @@ namespace LibRed.Core.Tests; /// public class UpdateAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No provider"); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Access_reads_a_libred_in_place_update_including_memo_growth() { - string path = Path.Combine(Path.GetTempPath(), $"upd-ace-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "upd-ace-"); string big = new('x', 5000); try { @@ -69,14 +60,13 @@ public void Access_reads_a_libred_in_place_update_including_memo_growth() Assert.StartsWith("a considerably longer", (string)r[1]); Assert.Equal(big, (string)r[2]); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_reads_a_libred_relocated_row() { - string path = Path.Combine(Path.GetTempPath(), $"reloc-lr-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "reloc-lr-"); string mid = new('m', 80), big = new('X', 255); try { @@ -107,14 +97,13 @@ public void Access_reads_a_libred_relocated_row() using (var c = conn.CreateCommand()) { c.CommandText = "SELECT A FROM T WHERE Id = 4"; Assert.Equal(mid, c.ExecuteScalar()); } // a neighbour } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Libred_reads_an_access_relocated_row() { - string path = Path.Combine(Path.GetTempPath(), $"reloc-ace-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "reloc-ace-"); string mid = new('m', 80), big = new('X', 255); try { @@ -136,14 +125,13 @@ public void Libred_reads_an_access_relocated_row() static void Exec(OleDbConnection c, string sql) { using var cmd = c.CreateCommand(); cmd.CommandText = sql; cmd.ExecuteNonQuery(); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_reads_a_memo_after_libred_reclaims_and_reuses_lval_pages() { - string path = Path.Combine(Path.GetTempPath(), $"upd-reclaim-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "upd-reclaim-"); string Big(char c) => new(c, 20000); // chained (dedicated pages), so each update frees + reuses pages try { @@ -173,14 +161,13 @@ public void Access_reads_a_memo_after_libred_reclaims_and_reuses_lval_pages() using (var c = conn.CreateCommand()) { c.CommandText = "SELECT M FROM T"; Assert.Equal(Big('g'), c.ExecuteScalar()); } // 'b'+5 = 'g' } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Access_seeks_a_libred_updated_primary_key() { - string path = Path.Combine(Path.GetTempPath(), $"upd-key-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "upd-key-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -209,6 +196,6 @@ public void Access_seeks_a_libred_updated_primary_key() using (var c = conn.CreateCommand()) { c.CommandText = "SELECT COUNT(*) FROM T WHERE Id = 2"; Assert.Equal(0, Convert.ToInt32(c.ExecuteScalar())); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/UsageMapGrowthTests.cs b/test/LibRed.Core.Tests/UsageMapGrowthTests.cs index 04fd715b..32c27dfa 100644 --- a/test/LibRed.Core.Tests/UsageMapGrowthTests.cs +++ b/test/LibRed.Core.Tests/UsageMapGrowthTests.cs @@ -16,22 +16,13 @@ namespace LibRed.Core.Tests; /// public class UsageMapGrowthTests { - private static OleDbConnection OpenOleDb(string path) - { - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No provider"); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); [Fact] public void Table_growing_past_the_inline_window_round_trips_through_libred_and_access() { const int rows = 400; // ~1 row/page on top of Northwind → owned pages cross page 512 - string path = Path.Combine(Path.GetTempPath(), $"umgrow-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "umgrow-"); string big = new('x', 255); try { @@ -73,6 +64,6 @@ public void Table_growing_past_the_inline_window_round_trips_through_libred_and_ { c.CommandText = $"SELECT C0 FROM Big WHERE Id = {rows}"; Assert.Equal(big, c.ExecuteScalar()); } } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/UsageMapTests.cs b/test/LibRed.Core.Tests/UsageMapTests.cs index d3ccbc79..c0ed936b 100644 --- a/test/LibRed.Core.Tests/UsageMapTests.cs +++ b/test/LibRed.Core.Tests/UsageMapTests.cs @@ -35,8 +35,7 @@ public void Usage_map_excludes_stale_orphan_pages() [Fact] public void Reference_usage_map_rejects_a_record_larger_than_the_format_shape() { - string path = Path.Combine(Path.GetTempPath(), $"bad-usage-map-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "bad-usage-map-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -58,14 +57,13 @@ public void Reference_usage_map_rejects_a_record_larger_than_the_format_shape() Assert.Throws(() => table.UsageMap.DataPages().ToList()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Exact_empty_reference_usage_map_remains_valid() { - string path = Path.Combine(Path.GetTempPath(), $"empty-reference-map-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "empty-reference-map-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -82,14 +80,13 @@ public void Exact_empty_reference_usage_map_remains_valid() Assert.Empty(table.UsageMap.DataPages()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void Reference_usage_map_rejects_a_pointer_to_a_non_bitmap_page() { - string path = Path.Combine(Path.GetTempPath(), $"bad-bitmap-pointer-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "bad-bitmap-pointer-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -106,6 +103,6 @@ public void Reference_usage_map_rejects_a_pointer_to_a_non_bitmap_page() Assert.Throws(() => table.UsageMap.DataPages().ToList()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/WideTableAccessTests.cs b/test/LibRed.Core.Tests/WideTableAccessTests.cs index 1dd71ab5..f647148b 100644 --- a/test/LibRed.Core.Tests/WideTableAccessTests.cs +++ b/test/LibRed.Core.Tests/WideTableAccessTests.cs @@ -11,17 +11,7 @@ namespace LibRed.Core.Tests; /// public class WideTableAccessTests { - private static OleDbConnection OpenOleDb(string path) - { - Exception? last = null; - for (int attempt = 0; attempt < 12; attempt++) - foreach (string p in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try { var c = new OleDbConnection($"Provider={p};Data Source={path};OLE DB Services=-4;"); c.Open(); return c; } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { last = ex; Thread.Sleep(40); } - } - throw new InvalidOperationException("no provider", last); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); // A wide table of TEXT columns (inline — no long-value usage maps) whose TDEF spans continuation pages: // Access opens it, enumerates every column, and a row round-trips including the far-end column. @@ -29,8 +19,7 @@ private static OleDbConnection OpenOleDb(string path) public void Access_opens_a_wide_multi_page_table() { const int n = 120; - string path = Path.Combine(Path.GetTempPath(), $"wide-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "wide-"); try { var cols = new List { new("Id", JetDataType.Int32, 4, IsFixedLength: true, IsAutoNumber: true) }; @@ -52,7 +41,7 @@ public void Access_opens_a_wide_multi_page_table() Assert.Equal("z", c.ExecuteScalar()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A table with more memo columns than one usage-map page holds (each memo needs a used + a free map, @@ -63,8 +52,7 @@ public void Access_opens_a_wide_multi_page_table() public void Access_opens_a_wide_memo_table_and_round_trips_a_long_value() { const int n = 80; // 160 long-value maps: far more than fit on one usage-map page - string path = Path.Combine(Path.GetTempPath(), $"widemem-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "widemem-"); string big = new('x', 8000); // forces an LVAL page — exercises the column's used-pages usage map try { @@ -90,6 +78,6 @@ public void Access_opens_a_wide_memo_table_and_round_trips_a_long_value() } } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Core.Tests/WideTableUsageMapTests.cs b/test/LibRed.Core.Tests/WideTableUsageMapTests.cs index 05db6918..0c46c820 100644 --- a/test/LibRed.Core.Tests/WideTableUsageMapTests.cs +++ b/test/LibRed.Core.Tests/WideTableUsageMapTests.cs @@ -34,20 +34,7 @@ public class WideTableUsageMapTests /// owned bitmap reaches ~3,800 bytes at 30,000 pages and no longer fits soon after. private const int Rows = 32_000; - private static OleDbConnection OpenOleDb(string path) - { - foreach (string provider in new[] { "Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0" }) - { - try - { - var connection = new OleDbConnection($"Provider={provider};Data Source={path};OLE DB Services=-4;"); - connection.Open(); - return connection; - } - catch (Exception ex) when (ex is OleDbException or InvalidOperationException) { } - } - throw new InvalidOperationException("No Microsoft.ACE.OLEDB provider available."); - } + private static OleDbConnection OpenOleDb(string path) => AceTestDatabase.Open(path); /// The type, record length and (inline only) start page of a table's usage map. private static (byte Type, int Length, int StartPage) ReadMap(Table table, JetFormatBase format, int tdefPointerOffset) @@ -89,8 +76,7 @@ private static void Fill(Table table, int rows) [Fact] public void The_free_pages_map_slides_a_512_page_window_instead_of_growing() { - string path = Path.Combine(Path.GetTempPath(), $"freewin-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "freewin-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -110,7 +96,7 @@ public void The_free_pages_map_slides_a_512_page_window_instead_of_growing() Assert.Equal(usage.MaxDataPage(), tail); Assert.InRange(tail, startPage, startPage + 511); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } /// @@ -123,8 +109,7 @@ public void The_free_pages_map_slides_a_512_page_window_instead_of_growing() [Fact] public void The_owned_pages_bitmap_grows_in_4_byte_steps() { - string path = Path.Combine(Path.GetTempPath(), $"grow4-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "grow4-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -142,14 +127,13 @@ public void The_owned_pages_bitmap_grows_in_4_byte_steps() int expected = 5 + (bitmapBytes + 3) / 4 * 4; Assert.Equal(expected, length); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] public void A_255_column_table_spanning_a_reference_usage_map_round_trips_through_access() { - string path = Path.Combine(Path.GetTempPath(), $"wide255-{Guid.NewGuid():N}.accdb"); - File.Copy(TestDatabases.NorthwindAccdb, path); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "wide255-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -181,6 +165,6 @@ public void A_255_column_table_spanning_a_reference_usage_map_round_trips_throug command.CommandText = "SELECT COUNT(*) FROM Wide255"; Assert.Equal(Rows, Convert.ToInt32(command.ExecuteScalar())); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/AceCollection.cs b/test/LibRed.Engine.Tests/AceCollection.cs new file mode 100644 index 00000000..99c52ae9 --- /dev/null +++ b/test/LibRed.Engine.Tests/AceCollection.cs @@ -0,0 +1,20 @@ +using Xunit; + +namespace LibRed.Engine.Tests; + +/// +/// Groups the classes that drive the real ACE OLE DB provider, so xunit runs them one after another. ACE +/// faults under concurrent use — two of these classes running in parallel throw +/// SEHException: External component has thrown an exception at the same millisecond and take the test +/// process down with 0xC0000005. Measured: the five classes alone crashed 3 of 3 back-to-back runs +/// without this, while the other ~950 tests were clean 3 of 3; with it, three back-to-back full-suite runs +/// passed. +/// +/// This does not disable parallelism. Every other class in the assembly still runs in +/// parallel around this collection — only these five are serialized, and only against each other. Add the +/// attribute to any new class that opens an ACE OLE DB connection. +[CollectionDefinition(Name)] +public sealed class AceCollection +{ + public const string Name = "ACE OLE DB"; +} diff --git a/test/LibRed.Engine.Tests/AddColumnTests.cs b/test/LibRed.Engine.Tests/AddColumnTests.cs index 536e5189..d0a03cdf 100644 --- a/test/LibRed.Engine.Tests/AddColumnTests.cs +++ b/test/LibRed.Engine.Tests/AddColumnTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // ALTER TABLE ... ADD COLUMN through LibRed's engine. Metadata TDEF edit: the column is appended, existing // rows read it as NULL, and (on an empty table) subsequent inserts include it. -public class AddColumnTests +public class AddColumnTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"addcol-eng-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "addcol-eng-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id long PRIMARY KEY, A long)"); return e; } diff --git a/test/LibRed.Engine.Tests/AddedFunctionsTests.cs b/test/LibRed.Engine.Tests/AddedFunctionsTests.cs index d56a6fd9..64fb86e8 100644 --- a/test/LibRed.Engine.Tests/AddedFunctionsTests.cs +++ b/test/LibRed.Engine.Tests/AddedFunctionsTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // Functions added after the LibRed-vs-ACE function-whitelist cross-check. Each expected value is what ACE // returned for the same call in the sweep. -public class AddedFunctionsTests +public class AddedFunctionsTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"af-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "af-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)"); return e; diff --git a/test/LibRed.Engine.Tests/AggregateTypeTests.cs b/test/LibRed.Engine.Tests/AggregateTypeTests.cs index aa7c8ce7..e9af83ef 100644 --- a/test/LibRed.Engine.Tests/AggregateTypeTests.cs +++ b/test/LibRed.Engine.Tests/AggregateTypeTests.cs @@ -10,8 +10,7 @@ public class AggregateTypeTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"agg-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "agg-"); return path; } @@ -23,7 +22,7 @@ private static string Fresh() using var db = JetDatabase.Open(path); return new QueryEngine(db).ExecuteQuery(sql).Rows.First()[0]; } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] diff --git a/test/LibRed.Engine.Tests/AliaslessDerivedTableTests.cs b/test/LibRed.Engine.Tests/AliaslessDerivedTableTests.cs index f7a9328a..5f250932 100644 --- a/test/LibRed.Engine.Tests/AliaslessDerivedTableTests.cs +++ b/test/LibRed.Engine.Tests/AliaslessDerivedTableTests.cs @@ -8,8 +8,7 @@ public class AliaslessDerivedTableTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"aliasless-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "aliasless-"); return path; } @@ -28,7 +27,7 @@ public void Aliasless_derived_table_in_from() var only = Assert.Single(rows); Assert.Equal(true, only[0]); // Customers is non-empty → EXISTS true } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -43,6 +42,6 @@ public void Aliasless_derived_table_projecting_its_column() "SELECT n FROM (SELECT COUNT(*) AS n FROM Orders)").Rows.ToList(); Assert.Equal(830, Assert.Single(rows)[0]); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/AlterAddCheckTests.cs b/test/LibRed.Engine.Tests/AlterAddCheckTests.cs index 91c9b29d..35f25257 100644 --- a/test/LibRed.Engine.Tests/AlterAddCheckTests.cs +++ b/test/LibRed.Engine.Tests/AlterAddCheckTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // ALTER TABLE ... ADD CONSTRAINT name CHECK (expr): persists the check to LvProp; the engine then enforces it // on insert/update (like a CREATE TABLE check). Merges with any existing checks. -public class AlterAddCheckTests +public class AlterAddCheckTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"aac-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "aac-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } [Fact] @@ -37,8 +36,8 @@ public void Alter_add_check_merges_with_an_existing_check() // both checks now enforced e.ExecuteNonQuery("INSERT INTO T (ID, A, B) VALUES (1, 5, 50)"); // both pass - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO T (ID, A, B) VALUES (2, -1, 50)")); // CkA - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO T (ID, A, B) VALUES (3, 5, 200)")); // CkB + AssertCheckViolation(e, "INSERT INTO T (ID, A, B) VALUES (2, -1, 50)", "CkA"); + AssertCheckViolation(e, "INSERT INTO T (ID, A, B) VALUES (3, 5, 200)", "CkB"); } [Fact] @@ -47,7 +46,7 @@ public void Drop_check_constraint_stops_enforcement() var e = Fresh(); e.ExecuteNonQuery("CREATE TABLE T ( ID LONG PRIMARY KEY, Amount LONG )"); e.ExecuteNonQuery("ALTER TABLE T ADD CONSTRAINT CK_T_Amount CHECK (Amount > 0)"); - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO T (ID, Amount) VALUES (1, -5)")); // enforced + AssertCheckViolation(e, "INSERT INTO T (ID, Amount) VALUES (1, -5)", "CK_T_Amount"); e.ExecuteNonQuery("ALTER TABLE T DROP CONSTRAINT CK_T_Amount"); e.ExecuteNonQuery("INSERT INTO T (ID, Amount) VALUES (2, -5)"); // no longer enforced @@ -63,7 +62,7 @@ public void Drop_check_constraint_leaves_a_sibling_check_intact() e.ExecuteNonQuery("ALTER TABLE T DROP CONSTRAINT CkA"); e.ExecuteNonQuery("INSERT INTO T (ID, A, B) VALUES (1, -1, 50)"); // CkA gone → A=-1 allowed - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO T (ID, A, B) VALUES (2, 5, 200)")); // CkB still enforced + AssertCheckViolation(e, "INSERT INTO T (ID, A, B) VALUES (2, 5, 200)", "CkB"); } [Fact] @@ -78,8 +77,16 @@ public void Docs_credit_limit_scenario_via_alter() e.ExecuteNonQuery("INSERT INTO tblCustomers (CustomerID, CustomerLimit) VALUES (1, 50)"); // Update above the limit fails; at/below the limit succeeds. - Assert.ThrowsAny(() => e.ExecuteNonQuery("UPDATE tblCustomers SET CustomerLimit = 200 WHERE CustomerID = 1")); + AssertCheckViolation(e, + "UPDATE tblCustomers SET CustomerLimit = 200 WHERE CustomerID = 1", "LimitRule"); e.ExecuteNonQuery("UPDATE tblCustomers SET CustomerLimit = 100 WHERE CustomerID = 1"); Assert.Equal(100.0, Convert.ToDouble(e.ExecuteQuery("SELECT CustomerLimit FROM tblCustomers WHERE CustomerID = 1").Rows.Single()[0])); } + + private static void AssertCheckViolation(QueryEngine engine, string sql, string constraintName) + { + var error = Assert.Throws(() => engine.ExecuteNonQuery(sql)); + Assert.Contains(constraintName, error.Message); + Assert.Contains("validation rule", error.Message, StringComparison.OrdinalIgnoreCase); + } } diff --git a/test/LibRed.Engine.Tests/AlterAddUniqueTests.cs b/test/LibRed.Engine.Tests/AlterAddUniqueTests.cs index 0386f085..bae3a902 100644 --- a/test/LibRed.Engine.Tests/AlterAddUniqueTests.cs +++ b/test/LibRed.Engine.Tests/AlterAddUniqueTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // ALTER TABLE ... ADD CONSTRAINT name UNIQUE (cols): adds a unique (non-primary) index, which LibRed then // enforces on insert (a duplicate composite key is rejected; NULLs are distinct). -public class AlterAddUniqueTests +public class AlterAddUniqueTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"auq-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "auq-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } [Fact] @@ -26,8 +25,10 @@ public void Add_unique_constraint_is_enforced() // different name → fine e.ExecuteNonQuery("INSERT INTO tblCustomers (CustomerID, [Last Name], [First Name]) VALUES (2, 'Smith', 'Jane')"); // duplicate composite → rejected - Assert.ThrowsAny(() => + var error = Assert.Throws(() => e.ExecuteNonQuery("INSERT INTO tblCustomers (CustomerID, [Last Name], [First Name]) VALUES (3, 'Smith', 'John')")); + Assert.Equal("UQ_Name", error.ConstraintName, ignoreCase: true); + Assert.False(error.IsPrimaryKey); } [Fact] @@ -39,7 +40,10 @@ public void Add_unique_on_a_populated_table_backfills() e.ExecuteNonQuery("INSERT INTO T (K, C) VALUES (2, 'b')"); e.ExecuteNonQuery("ALTER TABLE T ADD CONSTRAINT UQ_C UNIQUE (C)"); // the back-filled index enforces new inserts - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO T (K, C) VALUES (3, 'a')")); + var error = Assert.Throws(() => + e.ExecuteNonQuery("INSERT INTO T (K, C) VALUES (3, 'a')")); + Assert.Equal("UQ_C", error.ConstraintName, ignoreCase: true); + Assert.False(error.IsPrimaryKey); e.ExecuteNonQuery("INSERT INTO T (K, C) VALUES (4, 'c')"); } } diff --git a/test/LibRed.Engine.Tests/AlterColumnDefaultTests.cs b/test/LibRed.Engine.Tests/AlterColumnDefaultTests.cs index 3e6e9c1e..1bd9c7a9 100644 --- a/test/LibRed.Engine.Tests/AlterColumnDefaultTests.cs +++ b/test/LibRed.Engine.Tests/AlterColumnDefaultTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // ALTER TABLE ... ALTER COLUMN ... SET/DROP DEFAULT through LibRed's engine. An LvProp edit only (no retype): // the default is applied on an omit-insert, DROP DEFAULT removes it (a later omit-insert reads NULL), and // SET DEFAULT replaces it. EF Core emits `ALTER COLUMN c DROP DEFAULT` in migrations. -public class AlterColumnDefaultTests +public class AlterColumnDefaultTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"altdef-eng-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "altdef-eng-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id long PRIMARY KEY, V long)"); return e; } diff --git a/test/LibRed.Engine.Tests/AlterColumnRequiredTests.cs b/test/LibRed.Engine.Tests/AlterColumnRequiredTests.cs index d4b79def..47c36d1c 100644 --- a/test/LibRed.Engine.Tests/AlterColumnRequiredTests.cs +++ b/test/LibRed.Engine.Tests/AlterColumnRequiredTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // ALTER TABLE ... ALTER COLUMN ... NOT NULL / NULL through LibRed's engine. Sets (or clears) the column's // Required LvProp property — the ALTER-side of what CREATE writes — and the engine enforces it on insert. // EF emits this to "make a column required" (with a prior UPDATE to null-fill and a DEFAULT). -public class AlterColumnRequiredTests +public class AlterColumnRequiredTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"altreq-eng-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "altreq-eng-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id long PRIMARY KEY, V text(255))"); // V nullable return e; } @@ -26,7 +25,7 @@ public void Make_column_required_then_nullable() // Make required: an insert omitting V is now rejected. e.ExecuteNonQuery("ALTER TABLE T ALTER COLUMN V text(255) NOT NULL"); - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO T (Id) VALUES (2)")); + AssertRequiredViolation(e, "INSERT INTO T (Id) VALUES (2)"); e.ExecuteNonQuery("INSERT INTO T (Id, V) VALUES (3, 'c')"); // supplying a value still works // Make nullable again: omitting V is accepted (LibRed clears Required, unlike ACE's DDL). @@ -47,8 +46,15 @@ public void Make_required_with_default_matches_the_ef_migration_shape() e.ExecuteNonQuery("ALTER TABLE T ALTER COLUMN V text(255) NOT NULL DEFAULT ''"); Assert.Equal("", e.ExecuteQuery("SELECT V FROM T WHERE Id = 1").Rows.Single()[0]); // null-filled row - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO T (Id, V) VALUES (2, NULL)")); // explicit NULL rejected + AssertRequiredViolation(e, "INSERT INTO T (Id, V) VALUES (2, NULL)"); // explicit NULL rejected e.ExecuteNonQuery("INSERT INTO T (Id) VALUES (3)"); // omit → default '' applies Assert.Equal("", e.ExecuteQuery("SELECT V FROM T WHERE Id = 3").Rows.Single()[0]); } + + private static void AssertRequiredViolation(QueryEngine engine, string sql) + { + var error = Assert.Throws(() => engine.ExecuteNonQuery(sql)); + Assert.Contains("T.V", error.Message); + Assert.Contains("must enter a value", error.Message, StringComparison.OrdinalIgnoreCase); + } } diff --git a/test/LibRed.Engine.Tests/AlterColumnTests.cs b/test/LibRed.Engine.Tests/AlterColumnTests.cs index 83ff6985..8d0499b5 100644 --- a/test/LibRed.Engine.Tests/AlterColumnTests.cs +++ b/test/LibRed.Engine.Tests/AlterColumnTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // ALTER TABLE ... ALTER COLUMN field type: changes a variable text/binary column's max length (a descriptor // edit; existing rows are untouched, since variable columns store their own length). Changing the storage type // throws NotSupported (would need a full column rewrite). -public class AlterColumnTests +public class AlterColumnTests : TempDatabaseTest { private static (QueryEngine Engine, JetDatabase Db) Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"alc-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var db = JetDatabase.Open(path, readOnly: false); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "alc-"); + var db = TemporaryDatabase.OpenTracked(path, readOnly: false); return (new QueryEngine(db), db); } @@ -87,7 +86,9 @@ public void Rewrite_preserves_primary_key_uniqueness() e.ExecuteNonQuery("INSERT INTO T (K, N) VALUES (1, 5)"); e.ExecuteNonQuery("ALTER TABLE T ALTER COLUMN N LONG"); // SHORT -> LONG // PK still enforced after the rebuild - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO T (K, N) VALUES (1, 9)")); + var error = Assert.Throws(() => + e.ExecuteNonQuery("INSERT INTO T (K, N) VALUES (1, 9)")); + Assert.True(error.IsPrimaryKey); e.ExecuteNonQuery("INSERT INTO T (K, N) VALUES (2, 9)"); } @@ -100,7 +101,10 @@ public void Rewrite_preserves_a_secondary_unique_index() e.ExecuteNonQuery("INSERT INTO T (K, N, C) VALUES (1, 5, 'a')"); e.ExecuteNonQuery("ALTER TABLE T ALTER COLUMN N LONG"); // the unique index on C survives the rebuild - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO T (K, N, C) VALUES (2, 9, 'a')")); + var error = Assert.Throws(() => + e.ExecuteNonQuery("INSERT INTO T (K, N, C) VALUES (2, 9, 'a')")); + Assert.Equal("UQ_C", error.ConstraintName, ignoreCase: true); + Assert.False(error.IsPrimaryKey); e.ExecuteNonQuery("INSERT INTO T (K, N, C) VALUES (3, 9, 'b')"); } @@ -122,7 +126,7 @@ public void Alter_non_relationship_column_on_a_child_preserves_the_foreign_key() // data survived and the FK is still enforced after the rebuild Assert.Equal(5.0, Convert.ToDouble(e.ExecuteQuery("SELECT CData FROM C WHERE CID = 10").Rows.Single()[0])); - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO C (CID, PID, CData) VALUES (11, 99, 1)")); // orphan rejected + AssertForeignKeyViolation(e, "INSERT INTO C (CID, PID, CData) VALUES (11, 99, 1)"); // orphan rejected e.ExecuteNonQuery("INSERT INTO C (CID, PID, CData) VALUES (12, 1, 2)"); // valid parent ok } @@ -188,7 +192,7 @@ public void Alter_a_parent_side_non_relationship_column_preserves_the_relationsh // parent data converted, and the relationship still enforced (child orphan rejected, valid parent ok) Assert.Equal(100.0, Convert.ToDouble(e.ExecuteQuery("SELECT PData FROM P WHERE PID = 1").Rows.Single()[0])); - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO C (CID, PID, CData) VALUES (20, 77, 1)")); // orphan + AssertForeignKeyViolation(e, "INSERT INTO C (CID, PID, CData) VALUES (20, 77, 1)"); // orphan e.ExecuteNonQuery("INSERT INTO C (CID, PID, CData) VALUES (21, 1, 1)"); // valid parent // the existing child row still resolves to its parent Assert.Equal(5, Convert.ToInt32(e.ExecuteQuery("SELECT CData FROM C WHERE CID = 10").Rows.Single()[0])); @@ -200,7 +204,14 @@ public void Alter_parent_and_child_both_work_across_the_relationship() var e = Related(); e.ExecuteNonQuery("ALTER TABLE C ALTER COLUMN CData DOUBLE"); // child rebuild e.ExecuteNonQuery("ALTER TABLE P ALTER COLUMN PData DOUBLE"); // then parent rebuild - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO C (CID, PID, CData) VALUES (30, 88, 1)")); + AssertForeignKeyViolation(e, "INSERT INTO C (CID, PID, CData) VALUES (30, 88, 1)"); e.ExecuteNonQuery("INSERT INTO C (CID, PID, CData) VALUES (31, 1, 1)"); } + + private static void AssertForeignKeyViolation(QueryEngine engine, string sql) + { + var error = Assert.Throws(() => engine.ExecuteNonQuery(sql)); + Assert.Contains("FK", error.Message); + Assert.Contains("no matching row", error.Message, StringComparison.OrdinalIgnoreCase); + } } diff --git a/test/LibRed.Engine.Tests/AlterTableAddForeignKeyTests.cs b/test/LibRed.Engine.Tests/AlterTableAddForeignKeyTests.cs index c32a862a..fdf12a80 100644 --- a/test/LibRed.Engine.Tests/AlterTableAddForeignKeyTests.cs +++ b/test/LibRed.Engine.Tests/AlterTableAddForeignKeyTests.cs @@ -8,8 +8,7 @@ public class AlterTableAddForeignKeyTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"alterfk-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "alterfk-"); return path; } @@ -37,7 +36,7 @@ public void Add_foreign_key_to_existing_table() Assert.Equal("Demographics", fk.ReferencedTable, ignoreCase: true); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A self-referencing foreign key (Northwind's Employees.ReportsTo → Employees.EmployeeID). @@ -62,6 +61,6 @@ public void Add_self_referencing_foreign_key() Assert.Equal("Staff", fk.ReferencedTable, ignoreCase: true); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/AlterTableAddPrimaryKeyTests.cs b/test/LibRed.Engine.Tests/AlterTableAddPrimaryKeyTests.cs index cd255dd6..0e1846fe 100644 --- a/test/LibRed.Engine.Tests/AlterTableAddPrimaryKeyTests.cs +++ b/test/LibRed.Engine.Tests/AlterTableAddPrimaryKeyTests.cs @@ -8,8 +8,7 @@ public class AlterTableAddPrimaryKeyTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"alterpk-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "alterpk-"); return path; } @@ -39,6 +38,6 @@ public void Add_multi_column_primary_key() Assert.Equal(["CustomerID", "CustomerTypeID"], pk.Columns.Select(c => c.Column.Name)); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/AlterTableDropConstraintTests.cs b/test/LibRed.Engine.Tests/AlterTableDropConstraintTests.cs index 22ea403f..ac8106ee 100644 --- a/test/LibRed.Engine.Tests/AlterTableDropConstraintTests.cs +++ b/test/LibRed.Engine.Tests/AlterTableDropConstraintTests.cs @@ -8,8 +8,7 @@ public class AlterTableDropConstraintTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"dropc-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "dropc-"); return path; } @@ -52,7 +51,7 @@ public void Drop_foreign_key_constraint_stops_enforcement_and_persists() Assert.True(child.Indexes[0].IsPrimaryKey); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Drop then re-add the same FK: because the drop fully removes the old backing index + TDEF blocks, @@ -78,7 +77,7 @@ public void Drop_then_re_add_same_foreign_key_works() Assert.Single(db.Catalog.FindTable("Child")!.Indexes.Where(i => i.Name == "FK_Child_Parent")); Assert.Throws(() => e.ExecuteNonQuery("INSERT INTO Child (Id, ParentId) VALUES (1, 99)")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A self-referencing FK hosts both ends (outgoing + incoming block) in one TDEF; dropping it removes @@ -102,7 +101,7 @@ public void Drop_self_referencing_foreign_key() Assert.Single(db.Catalog.FindTable("Emp")!.Indexes); // only PK_Emp Assert.Equal(1, e.ExecuteNonQuery("INSERT INTO Emp (Id, MgrId) VALUES (2, 99)")); // now allowed } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Dropping a name that is neither a relationship nor an index throws a clear error. (A real FK/PK/unique @@ -120,6 +119,6 @@ public void Drop_unknown_constraint_throws() () => e.ExecuteNonQuery("ALTER TABLE T DROP CONSTRAINT NoSuchThing")); Assert.Contains("NoSuchThing", ex.Message); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/AlterTableRenameTests.cs b/test/LibRed.Engine.Tests/AlterTableRenameTests.cs index d79ceecb..17dbc19e 100644 --- a/test/LibRed.Engine.Tests/AlterTableRenameTests.cs +++ b/test/LibRed.Engine.Tests/AlterTableRenameTests.cs @@ -13,8 +13,7 @@ public class AlterTableRenameTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"rename-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "rename-"); return path; } @@ -75,7 +74,7 @@ public void Rename_moves_the_catalog_name_and_repoints_the_relationship() } finally { - File.Delete(path); + TemporaryDatabase.Delete(path); } } @@ -102,7 +101,7 @@ public void Renamed_table_still_enforces_its_foreign_key() } finally { - File.Delete(path); + TemporaryDatabase.Delete(path); } } @@ -128,7 +127,7 @@ public void Rename_repoints_both_sides_of_a_self_reference() } finally { - File.Delete(path); + TemporaryDatabase.Delete(path); } } @@ -158,7 +157,7 @@ public void Rename_allows_renaming_a_table_to_its_own_name_or_a_different_case() } finally { - File.Delete(path); + TemporaryDatabase.Delete(path); } } @@ -215,7 +214,7 @@ public void Rename_column_moves_the_name_keeps_the_default_and_the_index() } finally { - File.Delete(path); + TemporaryDatabase.Delete(path); } } @@ -250,7 +249,7 @@ public void Rename_column_repoints_the_relationship_on_either_side() } finally { - File.Delete(path); + TemporaryDatabase.Delete(path); } } @@ -276,7 +275,7 @@ public void Rename_column_rejects_a_name_already_on_the_table_and_a_column_that_ } finally { - File.Delete(path); + TemporaryDatabase.Delete(path); } } @@ -308,7 +307,7 @@ public void Rename_rejects_a_name_that_is_already_taken_and_a_table_that_does_no } finally { - File.Delete(path); + TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/ArithmeticTypeTests.cs b/test/LibRed.Engine.Tests/ArithmeticTypeTests.cs index 810d4d3a..e497dfe9 100644 --- a/test/LibRed.Engine.Tests/ArithmeticTypeTests.cs +++ b/test/LibRed.Engine.Tests/ArithmeticTypeTests.cs @@ -10,8 +10,7 @@ public class ArithmeticTypeTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"arith-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "arith-"); return path; } @@ -26,7 +25,7 @@ private static string Fresh() e.ExecuteNonQuery("INSERT INTO One (Id) VALUES (1)"); return e.ExecuteQuery($"SELECT {expr} FROM One").Rows.First()[0]; } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -48,7 +47,7 @@ public void Order_id_plus_one_is_int() object? v = new QueryEngine(db).ExecuteQuery("SELECT OrderID + 1 AS c FROM Orders").Rows.First()[0]; Assert.IsType(v); // the failing Union_over_binary_binary shape } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -64,7 +63,7 @@ public void Unary_negate_preserves_type() Assert.IsType(e.ExecuteQuery("SELECT -OrderID AS c FROM Orders").Rows.First()[0]); // int Assert.IsType(e.ExecuteQuery("SELECT -UnitPrice AS c FROM Products").Rows.First()[0]); // currency } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -92,7 +91,7 @@ public void Integer_division_and_mod_stay_int() "SELECT (OrderID \\ OrderID) \\ 2 AS A FROM Orders").Rows.First()[0]; Assert.IsType(v); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -107,6 +106,6 @@ public void Currency_arithmetic_stays_decimal() Assert.IsType(e.ExecuteQuery("SELECT UnitPrice + 1 AS c FROM Products").Rows.First()[0]); Assert.IsType(e.ExecuteQuery("SELECT UnitPrice / 2 AS c FROM Products").Rows.First()[0]); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/AutoNumberReseedTests.cs b/test/LibRed.Engine.Tests/AutoNumberReseedTests.cs index 97fc4e24..219eef0f 100644 --- a/test/LibRed.Engine.Tests/AutoNumberReseedTests.cs +++ b/test/LibRed.Engine.Tests/AutoNumberReseedTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // ALTER TABLE t ALTER COLUMN c COUNTER(seed, increment) reseeds an AutoNumber column (the KB 884185 fix // syntax). LibRed accepts it and sets the next id to `seed`, including negative seeds and a negative // (descending) increment. Also guards the counter against being reset from a stale cached seed on a rebuild. -public class AutoNumberReseedTests +public class AutoNumberReseedTests : TempDatabaseTest { private static QueryEngine Fresh(out JetDatabase db) { - string path = Path.Combine(Path.GetTempPath(), $"reseed-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - db = JetDatabase.Open(path, readOnly: false); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "reseed-"); + db = TemporaryDatabase.OpenTracked(path, readOnly: false); return new QueryEngine(db); } @@ -182,7 +181,8 @@ public void Promote_rejects_a_second_counter() try { e.ExecuteNonQuery("CREATE TABLE T (Id COUNTER CONSTRAINT PK PRIMARY KEY, N LONG)"); - var ex = Assert.ThrowsAny(() => e.ExecuteNonQuery("ALTER TABLE T ALTER COLUMN N COUNTER(1, 1)")); + var ex = Assert.Throws(() => + e.ExecuteNonQuery("ALTER TABLE T ALTER COLUMN N COUNTER(1, 1)")); Assert.Contains("already has one", ex.Message); } finally { db.Dispose(); } @@ -199,7 +199,8 @@ public void Reseed_rejects_a_counter_in_a_relationship() e.ExecuteNonQuery("CREATE TABLE P (Id COUNTER CONSTRAINT PK PRIMARY KEY, V TEXT(5))"); e.ExecuteNonQuery("CREATE TABLE C (Cid COUNTER PRIMARY KEY, Pid LONG, CONSTRAINT FK FOREIGN KEY (Pid) REFERENCES P(Id))"); e.ExecuteNonQuery("INSERT INTO P (V) VALUES ('a')"); - var ex = Assert.ThrowsAny(() => e.ExecuteNonQuery("ALTER TABLE P ALTER COLUMN Id COUNTER(100, 1)")); + var ex = Assert.Throws(() => + e.ExecuteNonQuery("ALTER TABLE P ALTER COLUMN Id COUNTER(100, 1)")); Assert.Contains("part of one or more relationships", ex.Message); } finally { db.Dispose(); } diff --git a/test/LibRed.Engine.Tests/AutoNumberSeedImmunityTests.cs b/test/LibRed.Engine.Tests/AutoNumberSeedImmunityTests.cs index 029be6b0..87cc31f7 100644 --- a/test/LibRed.Engine.Tests/AutoNumberSeedImmunityTests.cs +++ b/test/LibRed.Engine.Tests/AutoNumberSeedImmunityTests.cs @@ -13,8 +13,7 @@ public class AutoNumberSeedImmunityTests [Fact] public void Explicit_lower_value_does_not_lower_the_counter_or_cause_a_collision() { - string path = Path.Combine(Path.GetTempPath(), $"anl-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "anl-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -30,6 +29,6 @@ public void Explicit_lower_value_does_not_lower_the_counter_or_cause_a_collision var got = e.ExecuteQuery("SELECT Field1 FROM Table1 WHERE Field2 = 'G'").Rows.Single()[0]; Assert.Equal(7, Convert.ToInt32(got)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/BinaryComparisonTests.cs b/test/LibRed.Engine.Tests/BinaryComparisonTests.cs index 62e97079..63d6acab 100644 --- a/test/LibRed.Engine.Tests/BinaryComparisonTests.cs +++ b/test/LibRed.Engine.Tests/BinaryComparisonTests.cs @@ -12,8 +12,7 @@ public class BinaryComparisonTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"bin-cmp-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "bin-cmp-"); return path; } @@ -33,7 +32,7 @@ private static void Run(Action act) Ins(e, 4, [1, 2, 3, 4, 5]); act(e); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } static void Ins(QueryEngine e, int id, byte[] k) => e.ExecuteNonQuery("INSERT INTO B (Id, K) VALUES (@id, @k)", diff --git a/test/LibRed.Engine.Tests/BinaryLiteralTests.cs b/test/LibRed.Engine.Tests/BinaryLiteralTests.cs index 05f09602..47b1836f 100644 --- a/test/LibRed.Engine.Tests/BinaryLiteralTests.cs +++ b/test/LibRed.Engine.Tests/BinaryLiteralTests.cs @@ -8,8 +8,7 @@ public class BinaryLiteralTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"binlit-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "binlit-"); return path; } @@ -34,7 +33,7 @@ public void Hex_binary_literal_round_trips() Assert.Equal(small, (byte[])e.ExecuteQuery("SELECT Pic FROM Pics WHERE Id = 1").Rows.First()[0]!); Assert.Equal(big, (byte[])e.ExecuteQuery("SELECT Pic FROM Pics WHERE Id = 2").Rows.First()[0]!); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -46,9 +45,9 @@ public void Odd_length_hex_literal_is_rejected() using var db = JetDatabase.Open(path, readOnly: false); var e = new QueryEngine(db); e.ExecuteNonQuery("CREATE TABLE Pics2 (Id LONG, Pic OLEOBJECT)"); - Assert.ThrowsAny(() => + Assert.Throws(() => e.ExecuteNonQuery("INSERT INTO Pics2 (Id, Pic) VALUES (1, 0x010)")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/BitwiseAndDateFunctionTests.cs b/test/LibRed.Engine.Tests/BitwiseAndDateFunctionTests.cs index 8932fde1..49f79e34 100644 --- a/test/LibRed.Engine.Tests/BitwiseAndDateFunctionTests.cs +++ b/test/LibRed.Engine.Tests/BitwiseAndDateFunctionTests.cs @@ -8,8 +8,7 @@ public class BitwiseAndDateFunctionTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"bitdate-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "bitdate-"); return path; } @@ -24,7 +23,7 @@ private static string Fresh() e.ExecuteNonQuery("INSERT INTO One (Id) VALUES (1)"); return e.ExecuteQuery($"SELECT {expr} FROM One").Rows.First()[0]; } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // DateValue / TimeValue / IsDate (VBA/Access), semantics verified against ACE. @@ -81,7 +80,7 @@ public void Bitwise_on_byte_or_short_promotes_to_int() Assert.Equal(1, r[4]); Assert.IsType(r[4]); // byte 5 & byte 3 Assert.Equal(2, r[5]); Assert.IsType(r[5]); // short 6 & short 3 } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] diff --git a/test/LibRed.Engine.Tests/BooleanComparisonTests.cs b/test/LibRed.Engine.Tests/BooleanComparisonTests.cs index 0497a006..6858ca02 100644 --- a/test/LibRed.Engine.Tests/BooleanComparisonTests.cs +++ b/test/LibRed.Engine.Tests/BooleanComparisonTests.cs @@ -12,8 +12,7 @@ public class BooleanComparisonTests [Fact] public void Numeric_bool_column_compares_equal_to_a_boolean_predicate() { - string path = Path.Combine(Path.GetTempPath(), $"boolcmp-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "boolcmp-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -31,6 +30,6 @@ public void Numeric_bool_column_compares_equal_to_a_boolean_predicate() Assert.Equal([1, 2], ids); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/BooleanPredicateTests.cs b/test/LibRed.Engine.Tests/BooleanPredicateTests.cs index b582823d..4e87eedb 100644 --- a/test/LibRed.Engine.Tests/BooleanPredicateTests.cs +++ b/test/LibRed.Engine.Tests/BooleanPredicateTests.cs @@ -9,8 +9,7 @@ public class BooleanPredicateTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"boolpred-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "boolpred-"); return path; } @@ -37,7 +36,7 @@ public void Any_nonzero_number_is_truthy_as_a_predicate() Assert.Equal([1, 3, 4], Ids(e.ExecuteQuery("SELECT Id FROM Tvals WHERE D"))); // non-zero doubles incl. 0.5 Assert.Equal(1, e.ExecuteQuery("SELECT Id FROM Tvals WHERE N AND Id = 3").Rows.Count()); // combined } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A native BIT column written by LibRed: the value may be inserted as 1/-1/0 or TRUE/FALSE (Northwind's @@ -65,7 +64,7 @@ public void Bit_column_written_by_libred_round_trips_all_value_forms() Assert.Equal(true, e.ExecuteQuery("SELECT Flag FROM B WHERE Id = 1").Rows.First()[0]); Assert.Equal(false, e.ExecuteQuery("SELECT Flag FROM B WHERE Id = 2").Rows.First()[0]); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A native Jet YesNo (bool) column still works as a bare predicate (Northwind Products.Discontinued = 8). @@ -80,6 +79,6 @@ public void Native_yesno_boolean_is_truthy_as_a_predicate() Assert.Equal(8, e.ExecuteQuery("SELECT ProductID FROM Products WHERE Discontinued").Rows.Count()); Assert.Equal(69, e.ExecuteQuery("SELECT ProductID FROM Products WHERE NOT Discontinued").Rows.Count()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/ByteArrayStringFunctionTests.cs b/test/LibRed.Engine.Tests/ByteArrayStringFunctionTests.cs index 4b9b9049..839723f8 100644 --- a/test/LibRed.Engine.Tests/ByteArrayStringFunctionTests.cs +++ b/test/LibRed.Engine.Tests/ByteArrayStringFunctionTests.cs @@ -10,13 +10,12 @@ namespace LibRed.Engine.Tests; /// .ToString() (which yields "System.Byte[]"). EF emits this for byte[].Contains(x) as /// INSTR(1, STRCONV(arr, 64), 0xXX, 0) > 0. /// -public class ByteArrayStringFunctionTests +public class ByteArrayStringFunctionTests : TempDatabaseTest { private static QueryEngine Engine() { - string path = Path.Combine(Path.GetTempPath(), $"bstr-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "bstr-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE B (Id LONG PRIMARY KEY, Arr VARBINARY(10))"); e.ExecuteNonQuery("INSERT INTO B (Id, Arr) VALUES (1, 0x414201)"); // A B 0x01 e.ExecuteNonQuery("INSERT INTO B (Id, Arr) VALUES (2, 0x4142)"); // A B diff --git a/test/LibRed.Engine.Tests/ByteFunctionsBinaryTests.cs b/test/LibRed.Engine.Tests/ByteFunctionsBinaryTests.cs index d68ed5bc..66d2b1f5 100644 --- a/test/LibRed.Engine.Tests/ByteFunctionsBinaryTests.cs +++ b/test/LibRed.Engine.Tests/ByteFunctionsBinaryTests.cs @@ -6,13 +6,12 @@ // The byte functions (LenB/AscB/LeftB/RightB/MidB) on a RAW BINARY column — matching ACE, which reinterprets the // binary as a UTF-16LE string (an odd trailing byte zero-padded) before applying them. Expected values are what // ACE returned for the same data. Covers odd (3-byte) and even (4-byte) values. -public class ByteFunctionsBinaryTests +public class ByteFunctionsBinaryTests : TempDatabaseTest { private static QueryEngine Seeded() { - string path = Path.Combine(Path.GetTempPath(), $"bfb-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var db = JetDatabase.Open(path, readOnly: false); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "bfb-"); + var db = TemporaryDatabase.OpenTracked(path, readOnly: false); db.CreateTable("T", [new ColumnSpec("K", JetDataType.Int32, 4, IsFixedLength: true), new ColumnSpec("B", JetDataType.Binary, 50, IsFixedLength: false)], diff --git a/test/LibRed.Engine.Tests/CascadeDeleteWorklistTests.cs b/test/LibRed.Engine.Tests/CascadeDeleteWorklistTests.cs index c916a609..d91b172c 100644 --- a/test/LibRed.Engine.Tests/CascadeDeleteWorklistTests.cs +++ b/test/LibRed.Engine.Tests/CascadeDeleteWorklistTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // ON DELETE CASCADE is evaluated with an explicit worklist, not recursion: a cyclic FK graph terminates, a // shared child in a diamond is deleted exactly once, and a deep chain does not overflow the call stack. -public class CascadeDeleteWorklistTests +public class CascadeDeleteWorklistTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"cascade-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "cascade-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } [Fact] diff --git a/test/LibRed.Engine.Tests/CheckConstraintEnforcementTests.cs b/test/LibRed.Engine.Tests/CheckConstraintEnforcementTests.cs index 9ac71de1..8bd52cd6 100644 --- a/test/LibRed.Engine.Tests/CheckConstraintEnforcementTests.cs +++ b/test/LibRed.Engine.Tests/CheckConstraintEnforcementTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // LibRed enforces CHECK constraints on INSERT and UPDATE: a row is rejected only when a check evaluates to // explicitly FALSE (NULL/unknown passes). Checks may reference the row's columns and use subqueries. -public class CheckConstraintEnforcementTests +public class CheckConstraintEnforcementTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"chk-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "chk-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } [Fact] @@ -32,7 +31,9 @@ public void Check_is_enforced_on_update() var e = Fresh(); e.ExecuteNonQuery("CREATE TABLE tblInvoices ( ID LONG PRIMARY KEY, Amount DOUBLE, CONSTRAINT CheckAmount CHECK (Amount > 0) )"); e.ExecuteNonQuery("INSERT INTO tblInvoices (ID, Amount) VALUES (1, 50)"); - Assert.ThrowsAny(() => e.ExecuteNonQuery("UPDATE tblInvoices SET Amount = -1 WHERE ID = 1")); + var error = Assert.Throws(() => + e.ExecuteNonQuery("UPDATE tblInvoices SET Amount = -1 WHERE ID = 1")); + Assert.Contains("CheckAmount", error.Message); e.ExecuteNonQuery("UPDATE tblInvoices SET Amount = 75 WHERE ID = 1"); // valid update succeeds Assert.Equal(75.0, Convert.ToDouble(e.ExecuteQuery("SELECT Amount FROM tblInvoices WHERE ID = 1").Rows.Single()[0])); } @@ -62,7 +63,9 @@ public void Subquery_check_is_enforced() Assert.Contains("LimitRule", ex.Message); // The docs scenario: updating above the limit fails, at/below succeeds. - Assert.ThrowsAny(() => e.ExecuteNonQuery("UPDATE tblCustomers SET CustomerLimit = 200 WHERE CustomerID = 1")); + var updateError = Assert.Throws(() => + e.ExecuteNonQuery("UPDATE tblCustomers SET CustomerLimit = 200 WHERE CustomerID = 1")); + Assert.Contains("LimitRule", updateError.Message); e.ExecuteNonQuery("UPDATE tblCustomers SET CustomerLimit = 100 WHERE CustomerID = 1"); } diff --git a/test/LibRed.Engine.Tests/CheckConstraintTests.cs b/test/LibRed.Engine.Tests/CheckConstraintTests.cs index 58eca362..5cc74f0f 100644 --- a/test/LibRed.Engine.Tests/CheckConstraintTests.cs +++ b/test/LibRed.Engine.Tests/CheckConstraintTests.cs @@ -8,8 +8,7 @@ public class CheckConstraintTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"chk-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "chk-"); return path; } @@ -47,7 +46,7 @@ public void Table_level_check_constraint_is_accepted_and_table_is_created() Assert.Equal("[BirthDate] < NOW()", expr); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -62,6 +61,6 @@ public void Column_level_check_constraint_is_accepted() using (var db = JetDatabase.Open(path)) Assert.NotNull(db.Catalog.FindTable("T")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/ChooseFunctionTests.cs b/test/LibRed.Engine.Tests/ChooseFunctionTests.cs index 98fdd695..e0ef560c 100644 --- a/test/LibRed.Engine.Tests/ChooseFunctionTests.cs +++ b/test/LibRed.Engine.Tests/ChooseFunctionTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // Access Choose(index, choice-1, choice-2, …) — 1-based selection. Verified against ACE: out-of-range index → NULL, // NULL index → error, any value type allowed. Exercised through DEFAULT expressions (LibRed's FROM-less SELECT is // a separate limitation). Motivated by translating SQL Server CHOOSE/CONVERT to Jet/ACE-native Choose/CBool. -public class ChooseFunctionTests +public class ChooseFunctionTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"choose-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "choose-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } private static object? DefaultOf(string type, string def) diff --git a/test/LibRed.Engine.Tests/ColumnSizeLimitTests.cs b/test/LibRed.Engine.Tests/ColumnSizeLimitTests.cs index 70583627..c21f7206 100644 --- a/test/LibRed.Engine.Tests/ColumnSizeLimitTests.cs +++ b/test/LibRed.Engine.Tests/ColumnSizeLimitTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // Jet/ACE caps a char/varchar column at 255 characters and a binary/varbinary column at 510 bytes (verified // vs ACE: char(255)/binary(510) accepted, char(256)/binary(511) rejected "Size of field is too long"). LibRed // enforces the same caps at CREATE so it never writes a fixed column Access can't open. -public class ColumnSizeLimitTests +public class ColumnSizeLimitTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"sizelim-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "sizelim-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } [Theory] diff --git a/test/LibRed.Engine.Tests/CompositeFkMatchFullTests.cs b/test/LibRed.Engine.Tests/CompositeFkMatchFullTests.cs index afd597cb..b7711118 100644 --- a/test/LibRed.Engine.Tests/CompositeFkMatchFullTests.cs +++ b/test/LibRed.Engine.Tests/CompositeFkMatchFullTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // A composite foreign key follows ACE's MATCH FULL rule (verified vs ACE): the FK is skipped only when EVERY // column is null; a partial null (some null, some not) is rejected — unlike SQL Server's MATCH SIMPLE, which // would skip the check when any column is null. -public class CompositeFkMatchFullTests +public class CompositeFkMatchFullTests : TempDatabaseTest { private static QueryEngine Setup() { - string path = Path.Combine(Path.GetTempPath(), $"cfk-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "cfk-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE P (A long, B long, CONSTRAINT PK_P PRIMARY KEY (A, B))"); e.ExecuteNonQuery("CREATE TABLE C (Id long PRIMARY KEY, X long, Y long, " + "CONSTRAINT FK_C FOREIGN KEY (X, Y) REFERENCES P (A, B))"); diff --git a/test/LibRed.Engine.Tests/ConcurrentAllocationTests.cs b/test/LibRed.Engine.Tests/ConcurrentAllocationTests.cs new file mode 100644 index 00000000..e51472bd --- /dev/null +++ b/test/LibRed.Engine.Tests/ConcurrentAllocationTests.cs @@ -0,0 +1,131 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Crypto; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +[Collection(AceCollection.Name)] +public class ConcurrentAllocationTests +{ + [Fact] + public void Concurrent_autonumber_and_lval_allocations_conflict_then_retry_without_duplicate_ids_or_pages() + { + string path = Fresh("concurrent-allocation-"); + try + { + CreateTable(path); + RunConflictAndRetry(path); + VerifyWithLibRed(path); + VerifyWithAce(path); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Encrypted_concurrent_allocations_conflict_then_retry_and_remain_readable_by_ace() + { + const string password = "Concurrent-S3cret!"; + string path = Fresh("concurrent-encrypted-"); + try + { + CreateTable(path); + DatabaseEncryption.SetPassword(path, password, AccessEncryption.Agile); + + RunConflictAndRetry(path, password); + VerifyWithLibRed(path, password); + VerifyWithAce(path, password); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void RunConflictAndRetry(string path, string? password = null) + { + using var firstDb = JetDatabase.Open(path, readOnly: false, password: password); + using var secondDb = JetDatabase.Open(path, readOnly: false, password: password); + var first = new QueryEngine(firstDb); + var second = new QueryEngine(secondDb); + int committedPages = firstDb.OpenTable("ConcurrentAlloc").Channel.PageCount; + string firstMemo = new('A', 24000); + string staleMemo = new('B', 28000); + string retryMemo = new('C', 32000); + + first.ExecuteNonQuery("BEGIN TRANSACTION"); + second.ExecuteNonQuery("BEGIN TRANSACTION"); + first.ExecuteNonQuery( + $"INSERT INTO ConcurrentAlloc (K, M) VALUES ('first', '{firstMemo}')"); + second.ExecuteNonQuery( + $"INSERT INTO ConcurrentAlloc (K, M) VALUES ('stale', '{staleMemo}')"); + + Assert.Equal(1, Identity(first)); + Assert.Equal(1, Identity(second)); + Assert.True(firstDb.OpenTable("ConcurrentAlloc").Channel.PageCount > committedPages); + Assert.True(secondDb.OpenTable("ConcurrentAlloc").Channel.PageCount > committedPages); + + first.ExecuteNonQuery("COMMIT"); + var conflict = Assert.Throws(() => second.ExecuteNonQuery("COMMIT")); + Assert.Contains("write conflict", conflict.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(secondDb.InTransaction); + second.ExecuteNonQuery("ROLLBACK"); + + Assert.Single(second.ExecuteQuery("SELECT Id FROM ConcurrentAlloc WHERE K = 'first'").Rows); + Assert.Empty(second.ExecuteQuery("SELECT Id FROM ConcurrentAlloc WHERE K = 'stale'").Rows); + + second.ExecuteNonQuery("BEGIN TRANSACTION"); + second.ExecuteNonQuery( + $"INSERT INTO ConcurrentAlloc (K, M) VALUES ('retry', '{retryMemo}')"); + Assert.Equal(2, Identity(second)); + second.ExecuteNonQuery("COMMIT"); + + var rows = first.ExecuteQuery("SELECT Id, K FROM ConcurrentAlloc ORDER BY Id").Rows.ToArray(); + Assert.Equal(2, rows.Length); + Assert.Equal((1, "first"), (Convert.ToInt32(rows[0][0]), (string)rows[0][1]!)); + Assert.Equal((2, "retry"), (Convert.ToInt32(rows[1][0]), (string)rows[1][1]!)); + } + + private static void CreateTable(string path) + { + using var db = JetDatabase.Open(path, readOnly: false); + var e = new QueryEngine(db); + e.ExecuteNonQuery("CREATE TABLE ConcurrentAlloc (Id COUNTER PRIMARY KEY, K TEXT(30), M MEMO)"); + e.ExecuteNonQuery("CREATE UNIQUE INDEX UX_ConcurrentAlloc_K ON ConcurrentAlloc (K)"); + } + + private static void VerifyWithLibRed(string path, string? password = null) + { + using var db = JetDatabase.Open(path, password: password); + var e = new QueryEngine(db); + Assert.Equal(2, Convert.ToInt32(e.ExecuteQuery("SELECT COUNT(*) FROM ConcurrentAlloc").Rows.Single()[0])); + Assert.Equal(24000, ((string)e.ExecuteQuery("SELECT M FROM ConcurrentAlloc WHERE Id = 1").Rows.Single()[0]!).Length); + Assert.Equal(32000, ((string)e.ExecuteQuery("SELECT M FROM ConcurrentAlloc WHERE Id = 2").Rows.Single()[0]!).Length); + Assert.Empty(e.ExecuteQuery("SELECT Id FROM ConcurrentAlloc WHERE K = 'stale'").Rows); + } + + private static void VerifyWithAce(string path, string? password = null) + { + using var connection = AceTestDatabase.Open(path, password); + AssertScalar(connection, "SELECT COUNT(*) FROM ConcurrentAlloc", 2); + AssertScalar(connection, "SELECT COUNT(*) FROM ConcurrentAlloc WHERE Id = 1 AND K = 'first'", 1); + AssertScalar(connection, "SELECT COUNT(*) FROM ConcurrentAlloc WHERE Id = 2 AND K = 'retry'", 1); + AssertScalar(connection, "SELECT COUNT(*) FROM ConcurrentAlloc WHERE K = 'stale'", 0); + Assert.Equal(24000, ((string)ExecuteScalar(connection, "SELECT M FROM ConcurrentAlloc WHERE Id = 1")!).Length); + Assert.Equal(32000, ((string)ExecuteScalar(connection, "SELECT M FROM ConcurrentAlloc WHERE Id = 2")!).Length); + } + + private static int Identity(QueryEngine engine) => + Convert.ToInt32(engine.ExecuteQuery("SELECT @@IDENTITY").Rows.Single()[0]); + + private static string Fresh(string prefix) => + TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), prefix); + + private static object? ExecuteScalar(OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } + + private static void AssertScalar(OleDbConnection connection, string sql, int expected) => + Assert.Equal(expected, Convert.ToInt32(ExecuteScalar(connection, sql))); +} diff --git a/test/LibRed.Engine.Tests/ConditionalsInSelectTests.cs b/test/LibRed.Engine.Tests/ConditionalsInSelectTests.cs index c0329dc5..deb8b05a 100644 --- a/test/LibRed.Engine.Tests/ConditionalsInSelectTests.cs +++ b/test/LibRed.Engine.Tests/ConditionalsInSelectTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // IIF/Choose/Switch are ordinary scalar functions — they work anywhere an expression is allowed: SELECT // projections, WHERE, ORDER BY. Unlike in a DEFAULT (row-blind), here they can reference the row's own columns, // because a query evaluates against a row scope. Same functions, different scope. -public class ConditionalsInSelectTests +public class ConditionalsInSelectTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"cis-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "cis-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY, N LONG )"); e.ExecuteNonQuery("INSERT INTO T (K, N) VALUES (1, 5)"); e.ExecuteNonQuery("INSERT INTO T (K, N) VALUES (2, 15)"); diff --git a/test/LibRed.Engine.Tests/ConversionFunctionTests.cs b/test/LibRed.Engine.Tests/ConversionFunctionTests.cs index 27e59e5b..036e6f9d 100644 --- a/test/LibRed.Engine.Tests/ConversionFunctionTests.cs +++ b/test/LibRed.Engine.Tests/ConversionFunctionTests.cs @@ -8,8 +8,7 @@ public class ConversionFunctionTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"conv-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "conv-"); return path; } @@ -24,7 +23,7 @@ private static string Fresh() e.ExecuteNonQuery("INSERT INTO One (Id) VALUES (1)"); return e.ExecuteQuery($"SELECT {expr} FROM One").Rows.First()[0]; } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] diff --git a/test/LibRed.Engine.Tests/CorrelatedExistsSemiJoinTests.cs b/test/LibRed.Engine.Tests/CorrelatedExistsSemiJoinTests.cs index 65edf5ac..82c6edfe 100644 --- a/test/LibRed.Engine.Tests/CorrelatedExistsSemiJoinTests.cs +++ b/test/LibRed.Engine.Tests/CorrelatedExistsSemiJoinTests.cs @@ -1,6 +1,7 @@ using LibRed; using LibRed.Engine; using LibRed.Engine.Execution; +using LibRed.Tests.Shared; using LibRed.Sql.Ast; using LibRed.Sql.Parsing; using Xunit; @@ -11,13 +12,12 @@ namespace LibRed.Engine.Tests; // hashed, rather than re-running the body per outer row. These pin the SEMANTICS of that rewrite — every case // here must give the same answer whether or not the optimisation engages, so they are written to fail if the // rewrite ever changes meaning. (The speedup itself is measured separately; correctness is what needs guarding.) -public class CorrelatedExistsSemiJoinTests +public class CorrelatedExistsSemiJoinTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"exsemi-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "exsemi-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); // Outer: 1..5, with a NULL key to exercise the null side of the correlation. e.ExecuteNonQuery("CREATE TABLE O ( Id LONG PRIMARY KEY, K LONG, Tag TEXT(10) )"); @@ -44,13 +44,13 @@ private static long[] Ids(QueryEngine e, string sql) // since falling back gives the same answer — so this is the only thing that can tell a decorrelated shape // from a declined one. (The timing guards below do the same job for the shape EF actually emits, but at the // cost of running a 92 s query when they fail.) The outer scope is O aliased `o`, as in the queries above. - private static bool Decorrelates(QueryEngine e, string sql) + private static bool Decorrelates(QueryEngine e, string sql, IReadOnlyList? outerColumns = null) { var select = (SelectStatement)new AntlrSqlParser().ParseStatement(sql); Expression predicate = select.Where is UnaryExpression u ? u.Operand : select.Where!; var exists = (ExistsExpression)predicate; - OutputColumn[] outer = + IReadOnlyList outer = outerColumns ?? [ new("o", "Id", typeof(long)), new("o", "K", typeof(long)), new("o", "Tag", typeof(string)), ]; @@ -434,20 +434,26 @@ public void An_unqualified_column_in_the_residual_falls_back() Ids(Fresh(), "SELECT o.Id FROM O AS o WHERE EXISTS (SELECT 1 FROM I AS i WHERE i.K = o.K AND Keep = 1)")); - // Every test above passes whether or not the rewrite engages, because falling back gives the same answer — - // which is precisely how an earlier version of this optimisation appeared "correct" while never firing at - // all (SubtreeAliases returned no aliases for a projected plan, so every subquery was declined). This guard - // fails if that happens again: the real shape EF generates for a predicate over a navigation takes ~92 s - // per-row and ~0.4 s decorrelated, so the threshold is ~30x clear of the fast path and ~6x clear of the slow - // one. It is a performance guard, not a correctness test. + // Every semantics test above passes whether or not the rewrite engages. Pin the real EF-generated shape by + // asking the analysis directly instead of inferring it from wall-clock duration. [Fact] public void The_rewrite_actually_engages_on_the_shape_ExecuteDelete_generates() { - string path = Path.Combine(Path.GetTempPath(), $"exsemiperf-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + using var temp = TemporaryDatabase.CopyOf( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "exsemiplan"); + var e = new QueryEngine(temp.Open()); + + Assert.True(Decorrelates(e, + """ + SELECT `o`.`OrderID` FROM `Order Details` AS `o` + WHERE EXISTS ( + SELECT 1 FROM (`Order Details` AS `o0` + INNER JOIN `Orders` AS `o1` ON `o0`.`OrderID` = `o1`.`OrderID`) + LEFT JOIN `Customers` AS `c` ON `o1`.`CustomerID` = `c`.`CustomerID` + WHERE (`c`.`CustomerID` LIKE 'F%') AND `o0`.`OrderID` = `o`.`OrderID` + AND `o0`.`ProductID` = `o`.`ProductID`) + """, [new("o", "OrderID", typeof(int)), new("o", "ProductID", typeof(int))])); - var sw = System.Diagnostics.Stopwatch.StartNew(); int deleted = e.ExecuteNonQuery( """ DELETE FROM `Order Details` AS `o` @@ -458,24 +464,28 @@ SELECT 1 LEFT JOIN `Customers` AS `c` ON `o1`.`CustomerID` = `c`.`CustomerID` WHERE (`c`.`CustomerID` LIKE 'F%') AND `o0`.`OrderID` = `o`.`OrderID` AND `o0`.`ProductID` = `o`.`ProductID`) """); - sw.Stop(); - Assert.Equal(164, deleted); - Assert.True(sw.Elapsed < TimeSpan.FromSeconds(15), - $"correlated EXISTS took {sw.Elapsed.TotalSeconds:F1}s — the decorrelation is no longer engaging"); } - // Same guard for the TOP form, because A_top_of_at_least_one_does_not_change_existence gives the right answer - // whether the TOP is dropped or the whole rewrite is declined — so only timing distinguishes them. EF emits - // TOP 1 inside EXISTS for Any(), so this is the shape that matters most in practice. + // Same structural guard for the TOP form EF emits for Any(). [Fact] public void The_rewrite_engages_even_when_the_body_has_a_top() { - string path = Path.Combine(Path.GetTempPath(), $"exsemitop-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + using var temp = TemporaryDatabase.CopyOf( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "exsemitop"); + var e = new QueryEngine(temp.Open()); + + Assert.True(Decorrelates(e, + """ + SELECT `o`.`OrderID` FROM `Order Details` AS `o` + WHERE EXISTS ( + SELECT TOP 1 1 FROM (`Order Details` AS `o0` + INNER JOIN `Orders` AS `o1` ON `o0`.`OrderID` = `o1`.`OrderID`) + LEFT JOIN `Customers` AS `c` ON `o1`.`CustomerID` = `c`.`CustomerID` + WHERE (`c`.`CustomerID` LIKE 'F%') AND `o0`.`OrderID` = `o`.`OrderID` + AND `o0`.`ProductID` = `o`.`ProductID`) + """, [new("o", "OrderID", typeof(int)), new("o", "ProductID", typeof(int))])); - var sw = System.Diagnostics.Stopwatch.StartNew(); int deleted = e.ExecuteNonQuery( """ DELETE FROM `Order Details` AS `o` @@ -486,10 +496,6 @@ SELECT TOP 1 1 LEFT JOIN `Customers` AS `c` ON `o1`.`CustomerID` = `c`.`CustomerID` WHERE (`c`.`CustomerID` LIKE 'F%') AND `o0`.`OrderID` = `o`.`OrderID` AND `o0`.`ProductID` = `o`.`ProductID`) """); - sw.Stop(); - Assert.Equal(164, deleted); - Assert.True(sw.Elapsed < TimeSpan.FromSeconds(15), - $"TOP-bodied correlated EXISTS took {sw.Elapsed.TotalSeconds:F1}s — the TOP is no longer being dropped"); } } diff --git a/test/LibRed.Engine.Tests/CorrelatedInSemiJoinTests.cs b/test/LibRed.Engine.Tests/CorrelatedInSemiJoinTests.cs index 2dddbb8f..7b505a3c 100644 --- a/test/LibRed.Engine.Tests/CorrelatedInSemiJoinTests.cs +++ b/test/LibRed.Engine.Tests/CorrelatedInSemiJoinTests.cs @@ -1,6 +1,7 @@ using LibRed; using LibRed.Engine; using LibRed.Engine.Execution; +using LibRed.Tests.Shared; using LibRed.Sql.Ast; using LibRed.Sql.Parsing; using Xunit; @@ -11,13 +12,12 @@ namespace LibRed.Engine.Tests; // values are hashed, rather than the body being re-run per outer row. IN is the harder case because it is // three-valued — "no match" and "no match but the column held a NULL" are FALSE and UNKNOWN, and they differ once // NOT IN is in play — so these tests are mostly about that, not about the speed. -public class CorrelatedInSemiJoinTests +public class CorrelatedInSemiJoinTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"insemi-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "insemi-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE O ( Id LONG PRIMARY KEY, K LONG, Tag TEXT(10) )"); e.ExecuteNonQuery("INSERT INTO O (Id, K, Tag) VALUES (1, 10, 'a')"); @@ -41,12 +41,12 @@ private static long[] Ids(QueryEngine e, string sql) // Whether the rewrite engages, asked of the analysis directly: every semantics test here passes either way, // since falling back to the per-row loop gives the same answer. - private static bool Decorrelates(QueryEngine e, string sql) + private static bool Decorrelates(QueryEngine e, string sql, IReadOnlyList? outerColumns = null) { var select = (SelectStatement)new AntlrSqlParser().ParseStatement(sql); var inq = (InSubqueryExpression)select.Where!; - OutputColumn[] outer = + IReadOnlyList outer = outerColumns ?? [ new("o", "Id", typeof(long)), new("o", "K", typeof(long)), new("o", "Tag", typeof(string)), ]; @@ -166,18 +166,23 @@ public void Delete_with_a_correlated_in_removes_exactly_the_matching_rows() public void The_analysis_accepts_exactly_these_shapes(bool expected, string sql) => Assert.Equal(expected, Decorrelates(Fresh(), sql)); - // As with EXISTS, correctness tests pass whether or not the rewrite fires, so this pins that it DOES — the - // per-row form re-runs a two-table join for every candidate row. Northwind: 2155 Order Details rows against - // the orders of customers whose ID starts with F. Measured ~26 s per-row against ~0.2 s decorrelated, so the - // threshold sits well clear of both. + // As with EXISTS, correctness tests pass whether or not the rewrite fires, so pin the analysis directly. [Fact] public void The_rewrite_engages_on_a_correlated_in_over_a_join() { - string path = Path.Combine(Path.GetTempPath(), $"insemiperf-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + using var temp = TemporaryDatabase.CopyOf( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "insemiplan"); + var e = new QueryEngine(temp.Open()); + + Assert.True(Decorrelates(e, + """ + SELECT `o`.`OrderID` FROM `Order Details` AS `o` + WHERE `o`.`OrderID` IN ( + SELECT `o1`.`OrderID` FROM `Orders` AS `o1` + INNER JOIN `Customers` AS `c` ON `o1`.`CustomerID` = `c`.`CustomerID` + WHERE (`c`.`CustomerID` LIKE 'F%') AND `o1`.`OrderID` = `o`.`OrderID`) + """, [new("o", "OrderID", typeof(int))])); - var sw = System.Diagnostics.Stopwatch.StartNew(); int deleted = e.ExecuteNonQuery( """ DELETE FROM `Order Details` AS `o` @@ -187,9 +192,6 @@ DELETE FROM `Order Details` AS `o` INNER JOIN `Customers` AS `c` ON `o1`.`CustomerID` = `c`.`CustomerID` WHERE (`c`.`CustomerID` LIKE 'F%') AND `o1`.`OrderID` = `o`.`OrderID`) """); - sw.Stop(); - Assert.Equal(164, deleted); - Assert.True(sw.ElapsedMilliseconds < 5000, $"took {sw.ElapsedMilliseconds} ms — the IN was not decorrelated"); } } diff --git a/test/LibRed.Engine.Tests/CorrelatedOuterAggregateTests.cs b/test/LibRed.Engine.Tests/CorrelatedOuterAggregateTests.cs index 61666484..4e4a249b 100644 --- a/test/LibRed.Engine.Tests/CorrelatedOuterAggregateTests.cs +++ b/test/LibRed.Engine.Tests/CorrelatedOuterAggregateTests.cs @@ -11,8 +11,7 @@ public class CorrelatedOuterAggregateTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"corragg-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "corragg-"); return path; } @@ -41,7 +40,7 @@ public void Subquery_references_outer_group_aggregate() foreach (var r in rows) Assert.Equal(expected[Convert.ToInt32(r[0])], Convert.ToInt32(r[1])); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // The exact Northwind shape: MAX(o.OrderID) inside the subquery WHERE, wrapped in IIF/CLNG. @@ -59,8 +58,9 @@ public void Northwind_complex_where_shape_parses_and_runs() " IIF((MAX(o.OrderID) * 6) IS NULL, NULL, CLNG(MAX(o.OrderID) * 6)) " + " OR (o0.EmployeeID IS NULL AND MAX(o.OrderID) IS NULL)) AS `Max` " + "FROM Orders AS o GROUP BY o.EmployeeID").Rows.ToList(); - Assert.NotEmpty(rows); // it runs without "Function MAX is not supported" + Assert.Equal(Enumerable.Range(1, 9), rows.Select(r => Convert.ToInt32(r[0])).OrderBy(x => x)); + Assert.All(rows, r => Assert.Null(r[1])); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/CorrelatedScalarAggregateTests.cs b/test/LibRed.Engine.Tests/CorrelatedScalarAggregateTests.cs index 71ff98c7..df63b14c 100644 --- a/test/LibRed.Engine.Tests/CorrelatedScalarAggregateTests.cs +++ b/test/LibRed.Engine.Tests/CorrelatedScalarAggregateTests.cs @@ -1,6 +1,7 @@ using LibRed; using LibRed.Engine; using LibRed.Engine.Execution; +using LibRed.Tests.Shared; using LibRed.Sql.Ast; using LibRed.Sql.Parsing; using Xunit; @@ -11,13 +12,12 @@ namespace LibRed.Engine.Tests; // the correlation column instead of once per outer row. The semantics that need pinning are all about the outer // row with NO partner: the correlated body still returns a row there, so absence from the grouped result is not // null but the aggregate's own empty-input value, which differs per aggregate (COUNT 0, SUM/MIN/MAX null). -public class CorrelatedScalarAggregateTests +public class CorrelatedScalarAggregateTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"scagg-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "scagg-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE O ( Id LONG PRIMARY KEY, K LONG, Tag TEXT(10) )"); e.ExecuteNonQuery("INSERT INTO O (Id, K, Tag) VALUES (1, 10, 'a')"); // two partners @@ -43,12 +43,12 @@ private static long[] Ids(QueryEngine e, string sql) => e.ExecuteQuery($"SELECT o.Id, {scalar} FROM O AS o") .Rows.ToDictionary(r => Convert.ToInt64(r[0]), r => r[1]); - private static bool Decorrelates(QueryEngine e, string sql) + private static bool Decorrelates(QueryEngine e, string sql, IReadOnlyList? outerColumns = null) { var select = (SelectStatement)new AntlrSqlParser().ParseStatement(sql); var scalar = (ScalarSubquery)select.Projection[1].Value; - OutputColumn[] outer = + IReadOnlyList outer = outerColumns ?? [ new("o", "Id", typeof(long)), new("o", "K", typeof(long)), new("o", "Tag", typeof(string)), ]; @@ -180,12 +180,11 @@ public void The_analysis_accepts_exactly_these_shapes(bool expected, string sql) [Fact] public void The_rewrite_engages_on_a_correlated_count_over_northwind() { - string path = Path.Combine(Path.GetTempPath(), $"scaggperf-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + using var temp = TemporaryDatabase.CopyOf( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "scaggplan"); + var e = new QueryEngine(temp.Open()); - var sw = System.Diagnostics.Stopwatch.StartNew(); - int rows = e.ExecuteQuery( + const string sql = """ SELECT `o`.`OrderID` FROM `Order Details` AS `o` @@ -194,10 +193,21 @@ SELECT COUNT(*) FROM `Orders` AS `o1` INNER JOIN `Customers` AS `c` ON `o1`.`CustomerID` = `c`.`CustomerID` WHERE `o1`.`OrderID` = `o`.`OrderID`) > 0 - """).Rows.Count(); - sw.Stop(); + """; + + Assert.True(Decorrelates(e, + """ + SELECT `o`.`OrderID`, ( + SELECT COUNT(*) + FROM `Orders` AS `o1` + INNER JOIN `Customers` AS `c` ON `o1`.`CustomerID` = `c`.`CustomerID` + WHERE `o1`.`OrderID` = `o`.`OrderID`) + FROM `Order Details` AS `o` + """, [new("o", "OrderID", typeof(int))])); + + int rows = e.ExecuteQuery( + sql).Rows.Count(); Assert.Equal(2155, rows); - Assert.True(sw.ElapsedMilliseconds < 5000, $"took {sw.ElapsedMilliseconds} ms — the aggregate was not decorrelated"); } } diff --git a/test/LibRed.Engine.Tests/CorrelatedSeekTests.cs b/test/LibRed.Engine.Tests/CorrelatedSeekTests.cs index 954e61cb..86330ab5 100644 --- a/test/LibRed.Engine.Tests/CorrelatedSeekTests.cs +++ b/test/LibRed.Engine.Tests/CorrelatedSeekTests.cs @@ -10,13 +10,12 @@ namespace LibRed.Engine.Tests; /// index seek keyed off the outer row (not a full scan re-run per outer row). These verify the results are /// identical to the unoptimised form — the seek must be a pure speedup — including a composite-key correlation. /// -public class CorrelatedSeekTests +public class CorrelatedSeekTests : TempDatabaseTest { private static QueryEngine Seeded() { - string path = Path.Combine(Path.GetTempPath(), $"corr-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "corr-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE P (Id LONG PRIMARY KEY, Nm TEXT(20))"); e.ExecuteNonQuery("CREATE TABLE C (Id LONG PRIMARY KEY, Pid LONG, Amt LONG)"); e.ExecuteNonQuery("CREATE INDEX IX_Pid ON C (Pid)"); diff --git a/test/LibRed.Engine.Tests/CounterSeedIncrementTests.cs b/test/LibRed.Engine.Tests/CounterSeedIncrementTests.cs index 5fe75288..e99eff2f 100644 --- a/test/LibRed.Engine.Tests/CounterSeedIncrementTests.cs +++ b/test/LibRed.Engine.Tests/CounterSeedIncrementTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // COUNTER(seed, increment): the first generated AutoNumber id is the seed, then +increment per row (verified // vs ACE, which stores increment at TDEF 0x18 and last-value = seed-increment at 0x14). A plain COUNTER is 1/1. -public class CounterSeedIncrementTests +public class CounterSeedIncrementTests : TempDatabaseTest { private static (QueryEngine Engine, JetDatabase Db) Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"counter-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var db = JetDatabase.Open(path, readOnly: false); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "counter-"); + var db = TemporaryDatabase.OpenTracked(path, readOnly: false); return (new QueryEngine(db), db); } diff --git a/test/LibRed.Engine.Tests/CreateIndexTests.cs b/test/LibRed.Engine.Tests/CreateIndexTests.cs index 8ac7c07c..a82fa2de 100644 --- a/test/LibRed.Engine.Tests/CreateIndexTests.cs +++ b/test/LibRed.Engine.Tests/CreateIndexTests.cs @@ -8,8 +8,7 @@ public class CreateIndexTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"cidx-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "cidx-"); return path; } @@ -41,7 +40,7 @@ public void Create_index_round_trips_and_inserts_maintain_it() Assert.Equal(2, new QueryEngine(db).ExecuteQuery("SELECT `Id` FROM `T`").Rows.Count()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -65,7 +64,7 @@ public void Create_index_multi_column_and_with_primary() Assert.Contains(t.Indexes, ix => ix.Name == "PK_T" && ix.IsPrimaryKey && ix.IsUnique); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // WITH IGNORE NULL: the index is created (flag 0x02, reader exposes IgnoreNulls), and a row whose @@ -93,7 +92,7 @@ public void With_ignore_null_creates_sparse_index_and_skips_null_rows() Assert.Equal(3, new QueryEngine(db).ExecuteQuery("SELECT `Id` FROM `T`").Rows.Count()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A descending index: the index-data block records the column as descending (Ascending = false), @@ -120,7 +119,7 @@ public void Descending_index_records_direction_and_inserts() Assert.False(ascending); // recorded as descending } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // CREATE INDEX on a populated table back-fills the new index over the existing rows. @@ -146,6 +145,6 @@ public void Create_index_on_non_empty_table_backfills() Assert.Equal(1, e.ExecuteQuery("SELECT `Id` FROM `T` WHERE `Name` = 'b'").Rows.Count()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/CreateProcedureTests.cs b/test/LibRed.Engine.Tests/CreateProcedureTests.cs index d756b36d..ab4c81fd 100644 --- a/test/LibRed.Engine.Tests/CreateProcedureTests.cs +++ b/test/LibRed.Engine.Tests/CreateProcedureTests.cs @@ -8,8 +8,7 @@ public class CreateProcedureTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"proc-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "proc-"); return path; } @@ -28,7 +27,7 @@ public void Create_procedure_with_parameters_executes() "SELECT Orders.OrderID FROM Orders " + "WHERE Orders.OrderDate BETWEEN `Beginning Date` AND `Ending Date`"); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A stored parameterized query is read back (PARAMETERS clause + body) and executed through LibRed's @@ -61,7 +60,7 @@ public void Parameterized_procedure_reads_back_and_executes() Assert.Equal(direct, viaProc); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Read back ACE's OWN stored "Ten Most Expensive Products" (TOP 10 + ORDER BY DESC, shipped in Northwind) @@ -81,7 +80,7 @@ public void Read_back_northwinds_top_order_by_procedure() Assert.Equal(prices.OrderByDescending(p => p).ToList(), prices); // descending Assert.Equal(263.50m, prices[0]); // Côte de Blaye, Northwind's priciest } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Round-trip a LibRed-created TOP + ORDER BY procedure (under a non-colliding name). @@ -106,7 +105,7 @@ public void Top_and_order_by_procedure_round_trips() Assert.Equal(263.50m, prices[0]); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Parenthesised @-parameter list + a nested paren-join onto a view ("Employee Sales by Country" shape). @@ -145,7 +144,7 @@ public void Parenthesised_at_parameter_procedure_reads_back_and_executes() Assert.Equal(direct, viaProc); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Action-query procedure bodies we support (CREATE TABLE, INSERT ... VALUES) parse and store. @@ -160,7 +159,7 @@ public void Action_query_procedure_body_is_stored(string sql) using var db = JetDatabase.Open(path, readOnly: false); new QueryEngine(db).ExecuteNonQuery(sql); // no throw } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Stored action queries are read back from the file and executed through LibRed's own engine by name: @@ -196,7 +195,7 @@ public void Action_queries_read_back_and_execute_through_libred() Assert.Throws(() => e.ExecuteStoredActionQuery("NoSuchQuery")); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Bodies we don't support: UPDATE/DELETE/DROP have no grammar; an INSERT without a column list can't be @@ -211,9 +210,13 @@ public void Unsupported_procedure_body_is_rejected(string sql) try { using var db = JetDatabase.Open(path, readOnly: false); - Assert.ThrowsAny(() => new QueryEngine(db).ExecuteNonQuery(sql)); + if (sql.Contains("AddNoCols", StringComparison.Ordinal)) + Assert.Throws(() => new QueryEngine(db).ExecuteNonQuery(sql)); + else + Assert.Throws(() => + new QueryEngine(db).ExecuteNonQuery(sql)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A procedure name, like a view, cannot collide with an existing table. @@ -227,6 +230,6 @@ public void Procedure_name_colliding_with_an_object_throws() Assert.Throws(() => new QueryEngine(db).ExecuteNonQuery("CREATE PROCEDURE `Customers` AS SELECT `CustomerID` FROM `Customers`")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/CreateTableDefaultTests.cs b/test/LibRed.Engine.Tests/CreateTableDefaultTests.cs index 3432e34f..0704e9dd 100644 --- a/test/LibRed.Engine.Tests/CreateTableDefaultTests.cs +++ b/test/LibRed.Engine.Tests/CreateTableDefaultTests.cs @@ -8,8 +8,7 @@ public class CreateTableDefaultTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"def-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "def-"); return path; } @@ -34,7 +33,7 @@ public void Column_default_parses_and_table_is_created(string columnDdl) using (var db = JetDatabase.Open(path)) Assert.NotNull(db.Catalog.FindTable("T")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A DEFAULT is persisted to the table's LvProp property blob, read back onto the column, and applied @@ -77,7 +76,7 @@ public void Default_persists_reads_back_and_applies_on_insert() Assert.Null(r3[0]); // explicit NULL, not the default } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // `INSERT INTO t DEFAULT VALUES` (EF emits it for an all-store-generated/all-default row): one row is @@ -102,7 +101,7 @@ public void Insert_default_values_inserts_one_all_defaults_row() Assert.All(rows, r => Assert.Equal("std", r[1])); // DEFAULT applied Assert.All(rows, r => Assert.Null(r[2])); // no default → NULL } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A NOT NULL column with no default that an insert leaves unset is rejected — matching Access @@ -128,7 +127,7 @@ public void Insert_leaving_a_required_column_null_throws() // Providing the value succeeds. Assert.Equal(1, e.ExecuteNonQuery("INSERT INTO `T` (`Req`) VALUES (7)")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -148,7 +147,7 @@ public void Update_setting_a_required_column_null_throws_without_mutating_the_ro Assert.Contains("T.Req", ex.Message); Assert.Equal("kept", e.ExecuteQuery("SELECT `Req` FROM `T` WHERE `Id` = 1").Rows.Single()[0]); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // DEFAULT VALUES succeeds when every required column is covered by a DEFAULT (or is the AutoNumber). @@ -164,7 +163,7 @@ public void Insert_default_values_succeeds_when_required_columns_have_defaults() Assert.Equal(1, e.ExecuteNonQuery("INSERT INTO `T` DEFAULT VALUES")); Assert.Equal(3, Convert.ToInt32(e.ExecuteQuery("SELECT `Kind` FROM `T`").Rows.Single()[0])); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Creating a table whose name already exists (case-insensitively) is rejected, rather than writing a @@ -185,7 +184,7 @@ public void Duplicate_table_name_throws() Assert.Throws(() => e.ExecuteNonQuery("CREATE TABLE `Shippers` (`Id` INTEGER PRIMARY KEY)")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -199,7 +198,7 @@ public void Temporary_table_throws_not_supported() new QueryEngine(db).ExecuteNonQuery("CREATE TEMPORARY TABLE `T` (`Id` INTEGER)")); Assert.Contains("TEMPORARY", ex.Message); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -213,6 +212,6 @@ public void With_compression_throws_not_supported() new QueryEngine(db).ExecuteNonQuery("CREATE TABLE `T` (`S` VARCHAR(20) WITH COMPRESSION)")); Assert.Contains("COMPRESSION", ex.Message); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/CreateViewTests.cs b/test/LibRed.Engine.Tests/CreateViewTests.cs index ba14ac1a..6509eacb 100644 --- a/test/LibRed.Engine.Tests/CreateViewTests.cs +++ b/test/LibRed.Engine.Tests/CreateViewTests.cs @@ -8,8 +8,7 @@ public class CreateViewTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"view-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "view-"); return path; } @@ -24,7 +23,7 @@ public void Create_view_executes() e.ExecuteNonQuery("CREATE VIEW `LondonCust` AS SELECT `CustomerID`, `CompanyName` FROM `Customers` WHERE `City` = 'London'"); e.ExecuteNonQuery("CREATE VIEW `CustOrders` AS SELECT `c`.`CustomerID`, `o`.`OrderID` FROM `Customers` AS `c` INNER JOIN `Orders` AS `o` ON `c`.`CustomerID` = `o`.`CustomerID`"); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A view is read back from the file (its MSysQueries rows), reconstructed to SQL, and resolved as a @@ -47,7 +46,7 @@ public void View_is_queryable_through_libred() var viaView = e.ExecuteQuery("SELECT `CustomerID` FROM `LondonCust`").Rows.Select(r => r[0]).OrderBy(x => x).ToList(); var viaTable = e.ExecuteQuery("SELECT `CustomerID` FROM `Customers` WHERE `City` = 'London'").Rows.Select(r => r[0]).OrderBy(x => x).ToList(); - Assert.NotEmpty(viaView); + Assert.Equal(6, viaView.Count); Assert.Equal(viaTable, viaView); // A predicate applied on top of the view. @@ -57,10 +56,11 @@ public void View_is_queryable_through_libred() // The join view runs and returns the same count as the underlying join. int viaJoinView = e.ExecuteQuery("SELECT `CustomerID` FROM `CustOrders`").Rows.Count(); int viaJoin = e.ExecuteQuery("SELECT `c`.`CustomerID` FROM `Customers` AS `c` INNER JOIN `Orders` AS `o` ON `c`.`CustomerID` = `o`.`CustomerID`").Rows.Count(); + Assert.Equal(830, viaJoinView); Assert.Equal(viaJoin, viaJoinView); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Theory] @@ -74,7 +74,7 @@ public void Non_simple_view_throws(string sql, string expected) var ex = Assert.Throws(() => new QueryEngine(db).ExecuteNonQuery(sql)); Assert.Contains(expected, ex.Message); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -97,7 +97,7 @@ public void A_group_by_totals_view_round_trips() e.ExecuteQuery("SELECT Subtotal FROM `Subtotals` WHERE OrderID = 10248").Rows.First()[0])); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -111,6 +111,6 @@ public void View_name_colliding_with_an_object_throws() Assert.Throws(() => new QueryEngine(db).ExecuteNonQuery("CREATE VIEW `Customers` AS SELECT `CustomerID` FROM `Customers`")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/DateArithmeticTests.cs b/test/LibRed.Engine.Tests/DateArithmeticTests.cs index 88562922..f12a9257 100644 --- a/test/LibRed.Engine.Tests/DateArithmeticTests.cs +++ b/test/LibRed.Engine.Tests/DateArithmeticTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // Date/time arithmetic on the OLE Automation serial (days since 1899-12-30, fractional part = time), verified // vs ACE: date+time and date±N days yield a DateTime; date−date yields a plain day count. -public class DateArithmeticTests +public class DateArithmeticTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"datearith-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "datearith-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } private static object? Scalar(string expr) => diff --git a/test/LibRed.Engine.Tests/DateTimeDefaultTests.cs b/test/LibRed.Engine.Tests/DateTimeDefaultTests.cs index 6497594e..1a869a1d 100644 --- a/test/LibRed.Engine.Tests/DateTimeDefaultTests.cs +++ b/test/LibRed.Engine.Tests/DateTimeDefaultTests.cs @@ -10,13 +10,12 @@ namespace LibRed.Engine.Tests; // Time() → current time on the Jet epoch 1899-12-30 (time only) // Bare Date / Time are NOT niladic in Jet SQL (they are reserved type keywords — ACE rejects them; they need // parentheses), so only Now is recognised without parentheses. -public class DateTimeDefaultTests +public class DateTimeDefaultTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"dtdef-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "dtdef-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } private static DateTime InsertAndRead(string def) @@ -115,7 +114,7 @@ public void A_bare_word_default_is_a_literal_string(string bareDefault, string e public void A_multi_word_bare_default_is_a_syntax_error() { var e = Fresh(); - Assert.ThrowsAny(() => + Assert.Throws(() => e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY, V TEXT(50) DEFAULT this is the default string )")); } @@ -130,7 +129,7 @@ public void An_aggregate_function_in_a_default_is_rejected(string def) { var e = Fresh(); e.ExecuteNonQuery($"CREATE TABLE T ( K LONG PRIMARY KEY, V LONG DEFAULT {def} )"); - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)")); + Assert.Throws(() => e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)")); } // A default cannot reference a table/query (subquery) — ACE forbids "references to queries". LibRed rejects @@ -145,7 +144,8 @@ public void A_default_cannot_reference_a_table_or_query(string def) e.ExecuteNonQuery($"CREATE TABLE T ( K LONG PRIMARY KEY, V LONG DEFAULT {def} )"); // Rejected when the default is applied (its text is parsed lazily at insert) — a subquery is never a // valid default expression. - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)")); + Assert.Throws(() => + e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)")); } [Fact] diff --git a/test/LibRed.Engine.Tests/DdlDmlTests.cs b/test/LibRed.Engine.Tests/DdlDmlTests.cs index c0b01579..17ecacee 100644 --- a/test/LibRed.Engine.Tests/DdlDmlTests.cs +++ b/test/LibRed.Engine.Tests/DdlDmlTests.cs @@ -8,8 +8,7 @@ public class DdlDmlTests { private static string CopyToTemp() { - string path = Path.Combine(Path.GetTempPath(), $"libred-ddl-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "libred-ddl-"); return path; } @@ -29,7 +28,7 @@ public void Bigint_and_datetime2_are_rejected_when_creating_on_a_pre_2016_format var ex = Assert.Throws(() => new QueryEngine(db).ExecuteNonQuery(sql)); Assert.Contains(expectedVersionInMessage, ex.Message); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -44,7 +43,7 @@ public void Altering_a_column_to_bigint_is_rejected_on_a_pre_2016_format() var ex = Assert.Throws(() => e.ExecuteNonQuery("ALTER TABLE `T` ALTER COLUMN `V` BIGINT")); Assert.Contains("Access 2016", ex.Message); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -67,7 +66,7 @@ public void Memo_column_round_trips_including_null_and_a_long_value() Assert.Null(rows[1][1]); Assert.Equal(longText, rows[2][1]); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -97,7 +96,7 @@ public void Ef_generated_create_table_shape_parses_and_round_trips() var pk = Assert.Single(db.Catalog.FindTable("Widgets")!.Indexes, i => i.IsPrimaryKey); Assert.Equal(["Id"], pk.Columns.Select(c => c.Column.Name)); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -118,7 +117,7 @@ public void Statements_with_a_trailing_semicolon_parse() Assert.Equal(1, Convert.ToInt32(rows[0][0])); Assert.Equal("a", rows[0][1]); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -140,7 +139,7 @@ public void Autonumber_is_generated_for_inserts_that_omit_the_column() .Rows.Select(r => Convert.ToInt32(r[0])).ToList(); Assert.Equal([1, 2, 10, 11], ids); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -170,7 +169,7 @@ public void Create_insert_select_round_trips_through_sql() Assert.Equal(9.99m, Convert.ToDecimal(rows[0][2])); Assert.Equal("Sprocket", rows[1][1]); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -189,7 +188,7 @@ public void Insert_with_parameters_round_trips() var only = Assert.Single(engine.ExecuteQuery("SELECT `Name` FROM `P` WHERE `Id` = 7").Rows); Assert.Equal("param", only[0]); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -225,7 +224,7 @@ public void Insert_round_trips_nulls_booleans_negative_numbers_and_double_quoted Assert.False((bool)rows[1][4]!); Assert.Equal("-checked", rows[1][5]); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -253,7 +252,7 @@ public void Width_suffixed_and_two_word_type_aliases_work() Assert.Equal(LibRed.Catalog.JetDataType.Int16, def.FindColumn("Small")!.Type); Assert.Equal(LibRed.Catalog.JetDataType.Byte, def.FindColumn("Tiny")!.Type); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -266,7 +265,7 @@ public void Boolean_alias_logical1_maps_to_boolean() new QueryEngine(db).ExecuteNonQuery("CREATE TABLE `B` (`Id` INTEGER, `Flag` LOGICAL1)"); Assert.Equal(LibRed.Catalog.JetDataType.Boolean, db.Catalog.FindTable("B")!.FindColumn("Flag")!.Type); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } // ANSI/SQL-Server type-name aliases EF Core's relational defaults emit that weren't handled: @@ -286,7 +285,7 @@ public void Ansi_type_aliases_map_to_the_right_storage_type(string typeName, Lib new QueryEngine(db).ExecuteNonQuery($"CREATE TABLE `D` (`Id` INTEGER, `V` {typeName})"); Assert.Equal(expected, db.Catalog.FindTable("D")!.FindColumn("V")!.Type); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -305,6 +304,6 @@ public void Create_without_primary_key_is_allowed() Assert.Equal(1, Convert.ToInt32(only[0])); Assert.Equal("x", only[1]); } - finally { File.Delete(path); } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/DecorrelationCostTests.cs b/test/LibRed.Engine.Tests/DecorrelationCostTests.cs index 21803730..c0adcd8f 100644 --- a/test/LibRed.Engine.Tests/DecorrelationCostTests.cs +++ b/test/LibRed.Engine.Tests/DecorrelationCostTests.cs @@ -1,79 +1,82 @@ -using System.Diagnostics; -using LibRed; -using LibRed.Engine; +using LibRed.Engine.Execution; using Xunit; namespace LibRed.Engine.Tests; -// Decorrelating a correlated subquery is sound from the first probe but not always cheaper: it runs the body once -// WITHOUT the correlation, so a body that per-row would have been one index seek pays a full pass instead. These -// pin both directions of that trade — a tiny outer over a big indexed inner must stay per-row, while a genuinely -// slow correlation must still switch. They are timing tests, so the thresholds are set an order of magnitude clear -// of the measured figures rather than close to them. +// The gate is a time-budget policy, but its contract does not require a wall clock. A fake timestamp keeps these +// tests deterministic under coverage, profiling, emulation and heavily loaded CI machines. public class DecorrelationCostTests { - private const int InnerRows = 20_000; - - /// A small outer table and a large indexed inner one, the shape decorrelation must NOT take over. - private static QueryEngine SmallOuterLargeInner() + [Fact] + public void Work_below_the_budget_stays_per_row() { - string path = Path.Combine(Path.GetTempPath(), $"cost-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); - - e.ExecuteNonQuery("CREATE TABLE Small ( Id LONG PRIMARY KEY )"); - for (var i = 1; i <= 3; i++) - { - e.ExecuteNonQuery($"INSERT INTO Small (Id) VALUES ({i})"); - } + long now = 0; + var gate = new DecorrelationGate(budget: 100, timestamp: () => now); - e.ExecuteNonQuery("CREATE TABLE Big ( Id LONG PRIMARY KEY, K LONG )"); - e.ExecuteNonQuery("BEGIN TRANSACTION"); - for (var i = 1; i <= InnerRows; i++) + for (var i = 0; i < 9; i++) { - e.ExecuteNonQuery($"INSERT INTO Big (Id, K) VALUES ({i}, {i})"); + long started = now; + now += 10; + gate.Charge(started); + Assert.False(gate.Ready); } - - e.ExecuteNonQuery("COMMIT"); - e.ExecuteNonQuery("CREATE INDEX IX_Big_K ON Big (K)"); - return e; } - private static long Time(QueryEngine e, string sql) + [Fact] + public void Crossing_the_budget_switches_at_the_boundary_and_stays_ready() { - e.ExecuteQuery(sql).Rows.Count(); // warm the plan and the pages - var sw = Stopwatch.StartNew(); - e.ExecuteQuery(sql).Rows.Count(); - return sw.ElapsedMilliseconds; + long now = 1_000; + var gate = new DecorrelationGate(budget: 100, timestamp: () => now); + + long first = now; + now += 60; + gate.Charge(first); + Assert.False(gate.Ready); + + long second = now; + now += 40; + gate.Charge(second); + Assert.True(gate.Ready); + + long third = now; + now += 1; + gate.Charge(third); + Assert.True(gate.Ready); } - [Theory] - // Three outer rows, each answered by an index seek into 20,000 rows. Decorrelating would hash all 20,000 to - // answer three questions: measured 0.2 ms per-row against 32.8 ms decorrelated for EXISTS, 0.1 against 34.5 - // for IN, and 0.2 against 117.7 for the COUNT. The gate keeps all three per-row, since three seeks never - // reach its budget. - [InlineData("SELECT s.Id FROM Small AS s WHERE EXISTS (SELECT 1 FROM Big AS b WHERE b.K = s.Id)")] - [InlineData("SELECT s.Id FROM Small AS s WHERE s.Id IN (SELECT b.K FROM Big AS b WHERE b.K = s.Id)")] - [InlineData("SELECT s.Id FROM Small AS s WHERE (SELECT COUNT(*) FROM Big AS b WHERE b.K = s.Id) > 0")] - public void A_tiny_outer_over_a_large_indexed_inner_stays_per_row(string sql) + [Fact] + public void Independent_gates_do_not_share_charges() { - QueryEngine e = SmallOuterLargeInner(); - Assert.Equal(3, e.ExecuteQuery(sql).Rows.Count()); // and still answers correctly - long ms = Time(e, sql); - Assert.True(ms < 15, $"took {ms} ms — the body was decorrelated when per-row was ~0.2 ms"); + long now = 0; + var first = new DecorrelationGate(budget: 10, timestamp: () => now); + var second = new DecorrelationGate(budget: 10, timestamp: () => now); + + now = 10; + first.Charge(0); + + Assert.True(first.Ready); + Assert.False(second.Ready); } [Fact] - public void The_same_body_does_switch_once_the_outer_is_large() + public void Non_advancing_or_regressing_clock_does_not_reduce_or_inflate_the_charge() { - // Same tables, outer and inner swapped: now every one of the 20,000 outer rows asks the question, so the - // per-row form pays 20,000 seeks and the gate switches after the first few. This is the guard that the - // cost check didn't simply disable decorrelation — without the switch this shape is far slower. - QueryEngine e = SmallOuterLargeInner(); - const string sql = "SELECT b.Id FROM Big AS b WHERE EXISTS (SELECT 1 FROM Small AS s WHERE s.Id = b.K)"; + long now = 50; + var gate = new DecorrelationGate(budget: 10, timestamp: () => now); + + gate.Charge(50); + now = 40; + gate.Charge(50); + Assert.False(gate.Ready); - Assert.Equal(3, e.ExecuteQuery(sql).Rows.Count()); - long ms = Time(e, sql); - Assert.True(ms < 2000, $"took {ms} ms"); + now = 60; + gate.Charge(50); + Assert.True(gate.Ready); } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Budget_must_be_positive(long budget) + => Assert.Throws(() => new DecorrelationGate(budget, () => 0)); } diff --git a/test/LibRed.Engine.Tests/DeferredFunctionsTests.cs b/test/LibRed.Engine.Tests/DeferredFunctionsTests.cs index 7c4db6df..d4297808 100644 --- a/test/LibRed.Engine.Tests/DeferredFunctionsTests.cs +++ b/test/LibRed.Engine.Tests/DeferredFunctionsTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // Partition / StrConv / WeekdayName — implemented after probing their exact ACE semantics. Expected values are // what ACE returned, except WeekdayName's omitted first-day (OS-locale-dependent) which is asserted only with an // explicit first-day argument. -public class DeferredFunctionsTests +public class DeferredFunctionsTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"dff-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "dff-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)"); return e; diff --git a/test/LibRed.Engine.Tests/DeleteTests.cs b/test/LibRed.Engine.Tests/DeleteTests.cs index 2f4ad27d..4da49f23 100644 --- a/test/LibRed.Engine.Tests/DeleteTests.cs +++ b/test/LibRed.Engine.Tests/DeleteTests.cs @@ -8,8 +8,7 @@ public class DeleteTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"delete-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "delete-"); return path; } @@ -43,6 +42,6 @@ public void Delete_removes_matching_rows_and_their_index_entries() e.ExecuteNonQuery("INSERT INTO T (N) VALUES (99)"); Assert.Equal(99, Convert.ToInt32(e.ExecuteQuery("SELECT N FROM T").Rows.Single()[0])); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/DistinctAggregateTests.cs b/test/LibRed.Engine.Tests/DistinctAggregateTests.cs index 5ed260d5..432b2a4f 100644 --- a/test/LibRed.Engine.Tests/DistinctAggregateTests.cs +++ b/test/LibRed.Engine.Tests/DistinctAggregateTests.cs @@ -8,13 +8,12 @@ namespace LibRed.Engine.Tests; /// The ANSI intra-aggregate DISTINCT (COUNT(DISTINCT col), SUM(DISTINCT col), …) aggregates over /// the distinct set of the argument's VALUES — distinct on the column values, NOT distinct rows (see DISTINCTROW). /// -public class DistinctAggregateTests +public class DistinctAggregateTests : TempDatabaseTest { private static QueryEngine Seeded() { - string path = Path.Combine(Path.GetTempPath(), $"da-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "da-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id LONG PRIMARY KEY, Grp TEXT(5), V LONG)"); // Group 'a': V = 10, 10, 20, 20, 20 → distinct {10,20}; all {10,10,20,20,20} int id = 0; diff --git a/test/LibRed.Engine.Tests/DistinctBetweenDateTests.cs b/test/LibRed.Engine.Tests/DistinctBetweenDateTests.cs index b8c7fc74..896febf2 100644 --- a/test/LibRed.Engine.Tests/DistinctBetweenDateTests.cs +++ b/test/LibRed.Engine.Tests/DistinctBetweenDateTests.cs @@ -18,8 +18,7 @@ public class DistinctBetweenDateTests private static string Fresh() { - string p = Path.Combine(Path.GetTempPath(), $"qorders-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), p); + string p = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "qorders-"); return p; } @@ -34,7 +33,7 @@ public void Distinct_between_and_date_literals_execute() Assert.Equal(4, rs.ColumnNames.Count); Assert.Equal(86, rs.Rows.Count()); // distinct customers with a 1997 order } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -50,7 +49,7 @@ public void Distinct_dedupes_rows() Assert.True(distinct < all); // Customers has many rows but few distinct countries Assert.Equal(21, distinct); // Northwind customer countries } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -65,6 +64,6 @@ public void Access_own_quarterly_orders_view_reads_back() var view = e.ExecuteQuery("SELECT * FROM `Quarterly Orders`"); Assert.Equal(direct, view.Rows.Count()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/DoubleExtremeLiteralTests.cs b/test/LibRed.Engine.Tests/DoubleExtremeLiteralTests.cs index eb97efab..02eb2354 100644 --- a/test/LibRed.Engine.Tests/DoubleExtremeLiteralTests.cs +++ b/test/LibRed.Engine.Tests/DoubleExtremeLiteralTests.cs @@ -11,13 +11,12 @@ namespace LibRed.Engine.Tests; // intended. ACE instead rejects the literal outright ("Syntax error in number"), which is why those tests fail // on the OLE DB path — NOT because ACE mishandles E+308: ACE round-trips MaxValue/MinValue/Epsilon exactly when // given a correctly-rounded (17-digit) literal or a parameter. -public class DoubleExtremeLiteralTests +public class DoubleExtremeLiteralTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"dxl-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "dxl-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE D (Id LONG PRIMARY KEY, V DOUBLE)"); e.ExecuteNonQuery("INSERT INTO D (Id, V) VALUES (1, 0.5)"); return e; diff --git a/test/LibRed.Engine.Tests/DropColumnTests.cs b/test/LibRed.Engine.Tests/DropColumnTests.cs index 65c2e14f..72a919ff 100644 --- a/test/LibRed.Engine.Tests/DropColumnTests.cs +++ b/test/LibRed.Engine.Tests/DropColumnTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // ALTER TABLE ... DROP COLUMN through LibRed's engine: a metadata-only TDEF edit. Existing rows are not // rewritten, so the surviving columns must still read back correctly. -public class DropColumnTests +public class DropColumnTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"dropcol-eng-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "dropcol-eng-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id long PRIMARY KEY, A long, B text(20), C text(20))"); e.ExecuteNonQuery("INSERT INTO T (Id, A, B, C) VALUES (1, 10, 'bee', 'see')"); e.ExecuteNonQuery("INSERT INTO T (Id, A, B, C) VALUES (2, 20, 'buzz', 'sizz')"); diff --git a/test/LibRed.Engine.Tests/DropConstraintIndexTests.cs b/test/LibRed.Engine.Tests/DropConstraintIndexTests.cs index e4102e9b..4d1e5d54 100644 --- a/test/LibRed.Engine.Tests/DropConstraintIndexTests.cs +++ b/test/LibRed.Engine.Tests/DropConstraintIndexTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // In Jet/ACE a UNIQUE constraint IS a unique index, so DROP CONSTRAINT and DROP INDEX are interchangeable. // LibRed's DROP CONSTRAINT now drops a same-named unique/PK index (falling through from the FK path), matching // ACE — while still dropping FK relationships and rejecting an FK-backing index. -public class DropConstraintIndexTests +public class DropConstraintIndexTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"dci-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "dci-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } [Fact] @@ -24,7 +23,7 @@ public void Drop_constraint_removes_a_unique_index() e.ExecuteNonQuery("CREATE UNIQUE INDEX ix_a ON T (A)"); // dup rejected while the unique index exists e.ExecuteNonQuery("INSERT INTO T (K, A) VALUES (1, 10)"); - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO T (K, A) VALUES (2, 10)")); + AssertUniqueViolation(e, "INSERT INTO T (K, A) VALUES (2, 10)", "ix_a"); e.ExecuteNonQuery("ALTER TABLE T DROP CONSTRAINT ix_a"); // DROP CONSTRAINT on an index // now the duplicate is allowed @@ -38,7 +37,7 @@ public void Drop_constraint_removes_a_unique_constraint_added_via_alter() e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY, A LONG )"); e.ExecuteNonQuery("ALTER TABLE T ADD CONSTRAINT UQ_A UNIQUE (A)"); e.ExecuteNonQuery("INSERT INTO T (K, A) VALUES (1, 10)"); - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO T (K, A) VALUES (2, 10)")); + AssertUniqueViolation(e, "INSERT INTO T (K, A) VALUES (2, 10)", "UQ_A"); e.ExecuteNonQuery("ALTER TABLE T DROP CONSTRAINT UQ_A"); e.ExecuteNonQuery("INSERT INTO T (K, A) VALUES (3, 10)"); // duplicate now fine @@ -52,7 +51,10 @@ public void Drop_constraint_still_drops_a_foreign_key() e.ExecuteNonQuery("CREATE TABLE C ( CID LONG PRIMARY KEY, PID LONG, CONSTRAINT FK_C FOREIGN KEY (PID) REFERENCES P (PID) )"); e.ExecuteNonQuery("INSERT INTO P (PID) VALUES (1)"); // orphan rejected while FK enforced - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO C (CID, PID) VALUES (10, 99)")); + var orphan = Assert.Throws(() => + e.ExecuteNonQuery("INSERT INTO C (CID, PID) VALUES (10, 99)")); + Assert.Contains("FK_C", orphan.Message); + Assert.Contains("no matching row", orphan.Message, StringComparison.OrdinalIgnoreCase); e.ExecuteNonQuery("ALTER TABLE C DROP CONSTRAINT FK_C"); e.ExecuteNonQuery("INSERT INTO C (CID, PID) VALUES (11, 99)"); // orphan now allowed @@ -63,6 +65,17 @@ public void Drop_constraint_on_a_nonexistent_name_throws() { var e = Fresh(); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); - Assert.ThrowsAny(() => e.ExecuteNonQuery("ALTER TABLE T DROP CONSTRAINT Nope")); + var error = Assert.Throws(() => + e.ExecuteNonQuery("ALTER TABLE T DROP CONSTRAINT Nope")); + Assert.Contains("Nope", error.Message); + Assert.Contains("no foreign key, primary key, unique, or check constraint", error.Message, + StringComparison.OrdinalIgnoreCase); + } + + private static void AssertUniqueViolation(QueryEngine engine, string sql, string indexName) + { + var error = Assert.Throws(() => engine.ExecuteNonQuery(sql)); + Assert.Equal(indexName, error.ConstraintName, ignoreCase: true); + Assert.False(error.IsPrimaryKey); } } diff --git a/test/LibRed.Engine.Tests/DropIndexTests.cs b/test/LibRed.Engine.Tests/DropIndexTests.cs index 940228a7..fa4e31b8 100644 --- a/test/LibRed.Engine.Tests/DropIndexTests.cs +++ b/test/LibRed.Engine.Tests/DropIndexTests.cs @@ -5,13 +5,12 @@ namespace LibRed.Engine.Tests; // DROP INDEX index ON table through LibRed's engine. -public class DropIndexTests +public class DropIndexTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"dropix-eng-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "dropix-eng-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id long PRIMARY KEY, Name text(20), Code long)"); e.ExecuteNonQuery("CREATE INDEX IX_Name ON T (Name)"); e.ExecuteNonQuery("CREATE UNIQUE INDEX UX_Code ON T (Code)"); diff --git a/test/LibRed.Engine.Tests/DropTableRelationshipTests.cs b/test/LibRed.Engine.Tests/DropTableRelationshipTests.cs index 24de622c..4145ebf8 100644 --- a/test/LibRed.Engine.Tests/DropTableRelationshipTests.cs +++ b/test/LibRed.Engine.Tests/DropTableRelationshipTests.cs @@ -8,13 +8,12 @@ namespace LibRed.Engine.Tests; // allowed and removes the relationship (ACE lets you drop the referencing table while the parent stays); // dropping a table still REFERENCED as a parent by a surviving child is rejected. Mirrors the order the // scaffolding cleanup uses (child first, then parent). -public class DropTableRelationshipTests +public class DropTableRelationshipTests : TempDatabaseTest { private static QueryEngine Setup() { - string path = Path.Combine(Path.GetTempPath(), $"drop-rel-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "drop-rel-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE K2 ( Id int, A varchar, UNIQUE (A) )"); e.ExecuteNonQuery("CREATE TABLE Kilimanjaro ( Id int, B varchar, UNIQUE (B), FOREIGN KEY (B) REFERENCES K2 (A) )"); return e; diff --git a/test/LibRed.Engine.Tests/DropTableTests.cs b/test/LibRed.Engine.Tests/DropTableTests.cs index f70ed202..bd636b97 100644 --- a/test/LibRed.Engine.Tests/DropTableTests.cs +++ b/test/LibRed.Engine.Tests/DropTableTests.cs @@ -5,13 +5,12 @@ namespace LibRed.Engine.Tests; // DROP TABLE table through LibRed's engine. -public class DropTableTests +public class DropTableTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"droptab-eng-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "droptab-eng-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id long PRIMARY KEY, Name text(20))"); e.ExecuteNonQuery("CREATE INDEX IX_Name ON T (Name)"); e.ExecuteNonQuery("INSERT INTO T (Id, Name) VALUES (1, 'a')"); diff --git a/test/LibRed.Engine.Tests/DropViewProcedureTests.cs b/test/LibRed.Engine.Tests/DropViewProcedureTests.cs index bbe19ee2..d57eb464 100644 --- a/test/LibRed.Engine.Tests/DropViewProcedureTests.cs +++ b/test/LibRed.Engine.Tests/DropViewProcedureTests.cs @@ -5,13 +5,12 @@ namespace LibRed.Engine.Tests; // DROP VIEW / DROP PROCEDURE through LibRed's engine — both remove a type-5 query object. -public class DropViewProcedureTests +public class DropViewProcedureTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"dropview-eng-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "dropview-eng-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } [Fact] diff --git a/test/LibRed.Engine.Tests/ExecuteStatementTests.cs b/test/LibRed.Engine.Tests/ExecuteStatementTests.cs index d4b902c7..707e9911 100644 --- a/test/LibRed.Engine.Tests/ExecuteStatementTests.cs +++ b/test/LibRed.Engine.Tests/ExecuteStatementTests.cs @@ -11,13 +11,12 @@ namespace LibRed.Engine.Tests; /// binding positional argument values to its declared parameters. A stored SELECT returns rows; a stored /// action query returns its rows-affected count. /// -public class ExecuteStatementTests +public class ExecuteStatementTests : TempDatabaseTest { private static QueryEngine Seeded() { - string path = Path.Combine(Path.GetTempPath(), $"exec-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "exec-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id LONG PRIMARY KEY, Nm TEXT(50), Amt LONG)"); foreach (var (id, nm, amt) in new[] { (1, "a", 10), (2, "b", 20), (3, "c", 30) }) e.ExecuteNonQuery($"INSERT INTO T (Id, Nm, Amt) VALUES ({id}, '{nm}', {amt})"); diff --git a/test/LibRed.Engine.Tests/ExistsUpdateDeleteTests.cs b/test/LibRed.Engine.Tests/ExistsUpdateDeleteTests.cs index 2deadfc5..b5ce8ddf 100644 --- a/test/LibRed.Engine.Tests/ExistsUpdateDeleteTests.cs +++ b/test/LibRed.Engine.Tests/ExistsUpdateDeleteTests.cs @@ -10,8 +10,7 @@ public class ExistsUpdateDeleteTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"exists-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "exists-"); return path; } @@ -40,6 +39,6 @@ public void Exists_correlated_where_on_update_and_delete() Assert.Equal(1, deleted); Assert.Equal(new object?[] { 1 }, e.ExecuteQuery("SELECT Id FROM P").Rows.Select(r => System.Convert.ToInt32(r[0])).Cast()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/ExpressionSubqueryViewTests.cs b/test/LibRed.Engine.Tests/ExpressionSubqueryViewTests.cs index 7e96aaf6..c6cc8458 100644 --- a/test/LibRed.Engine.Tests/ExpressionSubqueryViewTests.cs +++ b/test/LibRed.Engine.Tests/ExpressionSubqueryViewTests.cs @@ -13,8 +13,7 @@ public class ExpressionSubqueryViewTests { private static string Fresh() { - string p = Path.Combine(Path.GetTempPath(), $"expr-view-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), p); + string p = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "expr-view-"); return p; } @@ -48,6 +47,6 @@ public void View_inside_exists_and_scalar_subqueries_is_expanded() Scalar(e, "SELECT COUNT(*) FROM Shippers WHERE (SELECT COUNT(*) FROM `LondonCust`) > 0")); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/FinancialFunctionsTests.cs b/test/LibRed.Engine.Tests/FinancialFunctionsTests.cs index 11ea075f..8a99c43b 100644 --- a/test/LibRed.Engine.Tests/FinancialFunctionsTests.cs +++ b/test/LibRed.Engine.Tests/FinancialFunctionsTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // Financial, FormatX, and colour functions — all exposed by the ACE JES and now implemented in LibRed. Expected // values are exactly what ACE returned. Culture pinned to en-US for the locale-sensitive FormatX cases. -public class FinancialFunctionsTests +public class FinancialFunctionsTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"fin-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "fin-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)"); return e; diff --git a/test/LibRed.Engine.Tests/FirstLastAggregatesTests.cs b/test/LibRed.Engine.Tests/FirstLastAggregatesTests.cs index 4dffccd9..d7e95bcc 100644 --- a/test/LibRed.Engine.Tests/FirstLastAggregatesTests.cs +++ b/test/LibRed.Engine.Tests/FirstLastAggregatesTests.cs @@ -8,40 +8,58 @@ namespace LibRed.Engine.Tests; // null-filtered (verified vs ACE: First over a leading NULL row returns NULL). public class FirstLastAggregatesTests { - private static QueryEngine Seeded() + private sealed class SeededDatabase : IDisposable { - string path = Path.Combine(Path.GetTempPath(), $"fl-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); - e.ExecuteNonQuery("CREATE TABLE F ( K LONG PRIMARY KEY, V TEXT(20) )"); - e.ExecuteNonQuery("INSERT INTO F (K, V) VALUES (1, NULL)"); - e.ExecuteNonQuery("INSERT INTO F (K, V) VALUES (2, 'beta')"); - e.ExecuteNonQuery("INSERT INTO F (K, V) VALUES (3, 'gamma')"); - e.ExecuteNonQuery("INSERT INTO F (K, V) VALUES (4, 'delta')"); - return e; + private readonly TemporaryDatabase _temporary = TemporaryDatabase.CopyOf( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "fl-"); + + public SeededDatabase() + { + Engine = new QueryEngine(_temporary.Open()); + Engine.ExecuteNonQuery("CREATE TABLE F ( K LONG PRIMARY KEY, V TEXT(20) )"); + Engine.ExecuteNonQuery("INSERT INTO F (K, V) VALUES (1, NULL)"); + Engine.ExecuteNonQuery("INSERT INTO F (K, V) VALUES (2, 'beta')"); + Engine.ExecuteNonQuery("INSERT INTO F (K, V) VALUES (3, 'gamma')"); + Engine.ExecuteNonQuery("INSERT INTO F (K, V) VALUES (4, 'delta')"); + } + + public QueryEngine Engine { get; } + public void Dispose() => _temporary.Dispose(); } private static object? Agg(QueryEngine e, string expr) => e.ExecuteQuery($"SELECT {expr} FROM F").Rows.Single()[0]; [Fact] public void First_returns_the_first_rows_value_including_null() - => Assert.Null(Agg(Seeded(), "First(V)")); // first row's V is NULL — not skipped + { + using var seeded = new SeededDatabase(); + Assert.Null(Agg(seeded.Engine, "First(V)")); // first row's V is NULL — not skipped + } [Fact] public void Last_returns_the_last_rows_value() - => Assert.Equal("delta", Convert.ToString(Agg(Seeded(), "Last(V)"))); + { + using var seeded = new SeededDatabase(); + Assert.Equal("delta", Convert.ToString(Agg(seeded.Engine, "Last(V)"))); + } [Theory] [InlineData("First(K)", 1)] [InlineData("Last(K)", 4)] public void First_last_over_key(string expr, int expected) - => Assert.Equal(expected, Convert.ToInt32(Agg(Seeded(), expr))); + { + using var seeded = new SeededDatabase(); + Assert.Equal(expected, Convert.ToInt32(Agg(seeded.Engine, expr))); + } [Fact] public void First_last_are_grouped() { - var e = Seeded(); - var rows = e.ExecuteQuery("SELECT K, First(V), Last(V) FROM F GROUP BY K ORDER BY K").Rows; - Assert.NotEmpty(rows); + using var seeded = new SeededDatabase(); + var rows = seeded.Engine.ExecuteQuery("SELECT K, First(V), Last(V) FROM F GROUP BY K ORDER BY K").Rows + .Select(r => (Key: Convert.ToInt32(r[0]), First: r[1] as string, Last: r[2] as string)).ToList(); + Assert.Equal( + [(1, null, null), (2, "beta", "beta"), (3, "gamma", "gamma"), (4, "delta", "delta")], + rows); } } diff --git a/test/LibRed.Engine.Tests/ForeignKeyDdlTests.cs b/test/LibRed.Engine.Tests/ForeignKeyDdlTests.cs index d1dec5a6..158a4616 100644 --- a/test/LibRed.Engine.Tests/ForeignKeyDdlTests.cs +++ b/test/LibRed.Engine.Tests/ForeignKeyDdlTests.cs @@ -13,8 +13,7 @@ public class ForeignKeyDdlTests [Fact] public void Foreign_key_persists_enforces_and_round_trips() { - string path = Path.Combine(Path.GetTempPath(), $"fk-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "fk-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -30,8 +29,8 @@ public void Foreign_key_persists_enforces_and_round_trips() engine.ExecuteNonQuery("INSERT INTO `Child` (`Id`, `ParentId`) VALUES (2, NULL)"); // null FK: allowed // Orphan reference is rejected. - Assert.ThrowsAny(() => - engine.ExecuteNonQuery("INSERT INTO `Child` (`Id`, `ParentId`) VALUES (3, 99)")); + AssertForeignKeyViolation(engine, + "INSERT INTO `Child` (`Id`, `ParentId`) VALUES (3, 99)", "FK_Child_Parent", "Parent"); } using (var db = JetDatabase.Open(path)) @@ -52,7 +51,7 @@ public void Foreign_key_persists_enforces_and_round_trips() Assert.Equal(2, new QueryEngine(db).ExecuteQuery("SELECT `Id` FROM `Child`").Rows.Count()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // The column-level (single-field) REFERENCES form — `Pid INTEGER CONSTRAINT fk REFERENCES P (Id)` — @@ -60,8 +59,7 @@ public void Foreign_key_persists_enforces_and_round_trips() [Fact] public void Column_level_references_builds_and_enforces_the_relationship() { - string path = Path.Combine(Path.GetTempPath(), $"colref-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "colref-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -73,8 +71,8 @@ public void Column_level_references_builds_and_enforces_the_relationship() "`Pid` INTEGER CONSTRAINT `FK_C_P` REFERENCES `P` (`Id`) ON DELETE CASCADE)"); engine.ExecuteNonQuery("INSERT INTO `P` (`Id`) VALUES (1)"); engine.ExecuteNonQuery("INSERT INTO `C` (`Id`, `Pid`) VALUES (1, 1)"); - Assert.ThrowsAny(() => - engine.ExecuteNonQuery("INSERT INTO `C` (`Id`, `Pid`) VALUES (2, 42)")); + AssertForeignKeyViolation(engine, + "INSERT INTO `C` (`Id`, `Pid`) VALUES (2, 42)", "FK_C_P", "P"); } using (var db = JetDatabase.Open(path)) { @@ -84,15 +82,14 @@ public void Column_level_references_builds_and_enforces_the_relationship() Assert.True(fk.CascadeDelete); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Column-level and table-level UNIQUE constraints each create a unique (non-primary) index. [Fact] public void Unique_constraints_create_unique_indexes() { - string path = Path.Combine(Path.GetTempPath(), $"uq-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "uq-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -109,7 +106,7 @@ public void Unique_constraints_create_unique_indexes() && ix.Columns.Select(c => c.Column.Name).SequenceEqual(["A", "B"])); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A multiple-field (composite) foreign key referencing a composite primary key: persists a pair of @@ -118,8 +115,7 @@ public void Unique_constraints_create_unique_indexes() [Fact] public void Composite_foreign_key_persists_and_enforces() { - string path = Path.Combine(Path.GetTempPath(), $"fkcomp-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "fkcomp-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -131,8 +127,8 @@ public void Composite_foreign_key_persists_and_enforces() "CONSTRAINT `FK_C_P` FOREIGN KEY (`A`, `B`) REFERENCES `P` (`A`, `B`))"); engine.ExecuteNonQuery("INSERT INTO `P` (`A`, `B`) VALUES (1, 2)"); engine.ExecuteNonQuery("INSERT INTO `C` (`Id`, `A`, `B`) VALUES (1, 1, 2)"); // matches (1,2) - Assert.ThrowsAny(() => // (1,3) has no parent - engine.ExecuteNonQuery("INSERT INTO `C` (`Id`, `A`, `B`) VALUES (2, 1, 3)")); + AssertForeignKeyViolation(engine, // (1,3) has no parent + "INSERT INTO `C` (`Id`, `A`, `B`) VALUES (2, 1, 3)", "FK_C_P", "P"); } using (var db = JetDatabase.Open(path)) { @@ -145,7 +141,7 @@ public void Composite_foreign_key_persists_and_enforces() && ix.Columns.Select(c => c.Column.Name).SequenceEqual(["A", "B"])); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A self-referencing foreign key (as in the GearsOfWar model, a table whose FK targets itself). The @@ -153,8 +149,7 @@ public void Composite_foreign_key_persists_and_enforces() [Fact] public void Self_referencing_foreign_key_creates_and_enforces() { - string path = Path.Combine(Path.GetTempPath(), $"fkself-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "fkself-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -165,8 +160,8 @@ public void Self_referencing_foreign_key_creates_and_enforces() "CONSTRAINT `FK_Emp_Emp` FOREIGN KEY (`Mgr`) REFERENCES `Emp` (`Id`))"); engine.ExecuteNonQuery("INSERT INTO `Emp` (`Id`, `Mgr`) VALUES (1, NULL)"); // top of chain engine.ExecuteNonQuery("INSERT INTO `Emp` (`Id`, `Mgr`) VALUES (2, 1)"); // reports to 1 - Assert.ThrowsAny(() => // 99 doesn't exist - engine.ExecuteNonQuery("INSERT INTO `Emp` (`Id`, `Mgr`) VALUES (3, 99)")); + AssertForeignKeyViolation(engine, // 99 doesn't exist + "INSERT INTO `Emp` (`Id`, `Mgr`) VALUES (3, 99)", "FK_Emp_Emp", "Emp"); } using (var db = JetDatabase.Open(path)) { @@ -176,7 +171,7 @@ public void Self_referencing_foreign_key_creates_and_enforces() Assert.Equal(("Mgr", "Id"), fk.Columns.Single()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Access documents ON UPDATE before ON DELETE; EF Core emits only ON DELETE. The grammar accepts @@ -184,8 +179,7 @@ public void Self_referencing_foreign_key_creates_and_enforces() [Fact] public void On_update_and_on_delete_parse_in_access_order() { - string path = Path.Combine(Path.GetTempPath(), $"fkord-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "fkord-"); try { using (var db = JetDatabase.Open(path, readOnly: false)) @@ -203,6 +197,15 @@ public void On_update_and_on_delete_parse_in_access_order() Assert.True(fk.CascadeDelete); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } + } + + private static void AssertForeignKeyViolation( + QueryEngine engine, string sql, string constraintName, string referencedTable) + { + var error = Assert.Throws(() => engine.ExecuteNonQuery(sql)); + Assert.Contains(constraintName, error.Message); + Assert.Contains($"'{referencedTable}'", error.Message); + Assert.Contains("no matching row", error.Message, StringComparison.OrdinalIgnoreCase); } } diff --git a/test/LibRed.Engine.Tests/FormatFunctionTests.cs b/test/LibRed.Engine.Tests/FormatFunctionTests.cs index 257abf3b..89fc0e53 100644 --- a/test/LibRed.Engine.Tests/FormatFunctionTests.cs +++ b/test/LibRed.Engine.Tests/FormatFunctionTests.cs @@ -17,13 +17,12 @@ private static string EvalEnUs(string expr) CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US"); try { - string path = Path.Combine(Path.GetTempPath(), $"fmt-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + using var temp = TemporaryDatabase.CopyOf( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "fmt-"); + var e = new QueryEngine(temp.Open()); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)"); string r = e.ExecuteQuery($"SELECT {expr} FROM T").Rows.Single()[0]?.ToString()!; - try { File.Delete(path); } catch (IOException) { } return r; } finally { CultureInfo.CurrentCulture = prev; } diff --git a/test/LibRed.Engine.Tests/FromlessSelectTests.cs b/test/LibRed.Engine.Tests/FromlessSelectTests.cs index 9de2dada..1abbd90d 100644 --- a/test/LibRed.Engine.Tests/FromlessSelectTests.cs +++ b/test/LibRed.Engine.Tests/FromlessSelectTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; /// A FROM-less SELECT (e.g. SELECT 2) yields exactly one row. ACE accepts this (verified via the /// OLE DB provider), and EF's CommandInterception scalar tests send a bare SELECT 1/SELECT 2. -public class FromlessSelectTests +public class FromlessSelectTests : TempDatabaseTest { private static QueryEngine Engine() { - string path = Path.Combine(Path.GetTempPath(), $"fl-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "fl-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } [Fact] diff --git a/test/LibRed.Engine.Tests/FunctionArityAccessTests.cs b/test/LibRed.Engine.Tests/FunctionArityAccessTests.cs new file mode 100644 index 00000000..8f9cffa8 --- /dev/null +++ b/test/LibRed.Engine.Tests/FunctionArityAccessTests.cs @@ -0,0 +1,181 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +/// Function arities taken from docs/functions.md and verified directly against ACE's JES. +// This class deliberately feeds ACE expressions it must reject. It runs in the ACE collection so no other +// ACE-driving class is inside the provider at the same time — concurrent ACE use faults natively and kills +// the test process (see AceCollection). +[Collection(AceCollection.Name)] +public class FunctionArityAccessTests +{ + private sealed record Arity(string Name, int Min, int? Max, params string[] Arguments); + + private static readonly Arity[] ConversionMathString = + [ + .. Unary("CBool", "CByte", "CInt", "CLng", "CSng", "CDbl", "CCur", "CStr", "CDate", "CVar", + "Abs", "Sgn", "Int", "Fix", "Sqr", "Exp", "Log", "Sin", "Cos", "Tan", "Atn", + "Len", "LCase", "UCase", "Trim", "LTrim", "RTrim", "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"), + 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"), + new("InStrRev", 2, 4, "'abc'", "'b'", "-1", "0", "0"), + new("Replace", 3, 6, "'abc'", "'a'", "'x'", "1", "-1", "0", "0"), + new("String", 2, 2, "2", "'x'", "0"), new("StrComp", 2, 3, "'a'", "'b'", "0", "0"), + new("StrConv", 2, 3, "'abc'", "1", "1033", "0"), + new("Left$", 2, 2, "'abc'", "1", "0"), new("UCase$", 1, 1, "'abc'", "0"), + ]; + + private static readonly Arity[] DateLogicalInspection = + [ + new("Now", 0, 0, "1"), new("Date", 0, 0, "1"), new("Time", 0, 0, "1"), + new("DateAdd", 3, 3, "'d'", "1", "#1/1/2020#", "1"), + new("DateDiff", 3, 5, "'d'", "#1/1/2020#", "#1/2/2020#", "1", "1", "1"), + new("DatePart", 2, 4, "'d'", "#1/1/2020#", "1", "1", "1"), + new("DateSerial", 3, 3, "2020", "1", "1", "1"), new("TimeSerial", 3, 3, "1", "2", "3", "1"), + .. Unary("DateValue", "TimeValue", "Year", "Month", "Day", "Hour", "Minute", "Second", "IsDate", + "IsNull", "IsNumeric", "IsError", "TypeName", "VarType"), + new("Weekday", 1, 2, "#1/1/2020#", "1", "1"), new("MonthName", 1, 2, "1", "True", "1"), + new("WeekdayName", 1, 3, "1", "True", "1", "1"), + new("IIf", 2, 3, "True", "1", "2", "3"), new("Choose", 2, null, "1", "'a'"), + new("Switch", 2, null, "True", "1"), + ]; + + private static readonly Arity[] FormattingFinancialVariants = + [ + new("Format", 1, 4, "1", "'0.00'", "1", "1", "1"), + new("FormatCurrency", 1, 5, "1", "2", "-1", "-1", "-1", "1"), + new("FormatNumber", 1, 5, "1", "2", "-1", "-1", "-1", "1"), + new("FormatPercent", 1, 5, "1", "2", "-1", "-1", "-1", "1"), + new("FormatDateTime", 1, 2, "#1/1/2020#", "1", "1"), + new("Partition", 4, 4, "1", "0", "10", "1", "1"), + new("Pmt", 3, 5, "0.01", "12", "100", "0", "0", "0"), + new("FV", 3, 5, "0.01", "12", "-10", "100", "0", "0"), + new("PV", 3, 5, "0.01", "12", "-10", "0", "0", "0"), + new("NPer", 3, 5, "0.01", "-10", "100", "0", "0", "0"), + new("IPmt", 4, 6, "0.01", "1", "12", "100", "0", "0", "0"), + new("PPmt", 4, 6, "0.01", "1", "12", "100", "0", "0", "0"), + new("Rate", 3, 6, "12", "-10", "100", "0", "0", "0.1", "0"), + new("SLN", 3, 3, "100", "10", "5", "1"), new("SYD", 4, 4, "100", "10", "5", "1", "1"), + new("DDB", 4, 5, "100", "10", "5", "1", "2", "1"), + new("RGB", 3, 3, "1", "2", "3", "4"), new("QBColor", 1, 1, "1", "1"), + .. Unary("AscB", "LenB", "AscW", "ChrW"), + new("LeftB", 2, 2, "'abc'", "2", "1"), new("RightB", 2, 2, "'abc'", "2", "1"), + new("MidB", 2, 3, "'abc'", "1", "2", "1"), + new("InStrB", 2, 4, "1", "'abc'", "'b'", "0", "0"), + ]; + + private static readonly Arity[] Aggregates = + [ + .. Unary("Count", "Sum", "Avg", "Min", "Max", "First", "Last", "StDev", "Var", "StDevP", "VarP", + "StdDev", "StdDevP"), + ]; + + [Fact] + public void All_documented_function_arities_are_enforced_by_libred() => AssertLibRedArities( + ConversionMathString.Concat(DateLogicalInspection).Concat(FormattingFinancialVariants).Concat(Aggregates), + "Switch(True, 1, False)"); + + [Fact] + public void Representative_arity_boundaries_match_ACE() => AssertAceExpressions( + [ + "Len('abc', 1)", "Abs(1, 2)", "Left('abc', 1, 2)", "IIf(True, 1, 2, 3)", + "Date(1)", "RGB(1, 2, 3, 4)", "Round(1, 2, 3)", + "Replace('abc', 'a', 'x', 1, -1, 0, 99)", + "Len()", "Left('abc')", "RGB(1, 2)", "Replace('abc', 'a')", + ]); + + [Fact] + public void Libred_only_functions_have_documented_arities() => + AssertLibRedRejects("CDec()", "CDec(1, 2)", "GenUniqueID(1)", "GenGUID(1)"); + + private static IEnumerable Unary(params string[] names) => + names.Select(n => new Arity(n, 1, 1, "1", "1")); + + private static void AssertLibRedArities(IEnumerable cases, params string[] extraInvalid) + { + string path = CopyNorthwind("function-arity-"); + try + { + using var db = JetDatabase.Open(path); + var engine = new QueryEngine(db); + foreach (Arity item in cases) + { + if (item.Min > 0) AssertLibRedRejected(engine, Call(item, item.Min - 1)); + if (item.Max is int max) AssertLibRedRejected(engine, Call(item, max + 1)); + } + foreach (string expression in extraInvalid) AssertLibRedRejected(engine, expression); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void AssertAceExpressions(IEnumerable expressions) + { + string path = CopyNorthwind("function-arity-ace-"); + try + { + using OleDbConnection ace = AceTestDatabase.Open(path); + using var db = JetDatabase.Open(path); + var engine = new QueryEngine(db); + foreach (string expression in expressions) AssertRejected(ace, engine, expression); + AssertIifQuirk(ace, engine, "IIf(True, 7)", 7); + AssertIifQuirk(ace, engine, "IIf(False, 7)", null); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void AssertIifQuirk(OleDbConnection ace, QueryEngine engine, string expression, int? expected) + { + using OleDbCommand command = ace.CreateCommand(); + command.CommandText = $"SELECT {expression} AS V FROM Customers"; + object? aceValue = command.ExecuteScalar(); + Assert.Equal(expected, aceValue is DBNull ? null : Convert.ToInt32(aceValue)); + object? libRedValue = engine.ExecuteQuery($"SELECT {expression} AS V FROM Customers").Rows.First()[0]; + Assert.Equal(expected, libRedValue is null ? null : Convert.ToInt32(libRedValue)); + } + + private static string Call(Arity item, int count) => + $"{item.Name}({string.Join(", ", item.Arguments.Take(count))})"; + + private static void AssertRejected(OleDbConnection ace, QueryEngine engine, string expression) + { + using OleDbCommand command = ace.CreateCommand(); + command.CommandText = $"SELECT {expression} AS V FROM Customers"; + Exception? aceError = Record.Exception(() => command.ExecuteScalar()); + Assert.True(aceError is OleDbException, + $"ACE unexpectedly accepted {expression}; result/error was {aceError?.GetType().Name ?? "no error"}."); + AssertLibRedRejected(engine, expression); + } + + private static void AssertLibRedRejected(QueryEngine engine, string expression) + { + Exception error = Assert.Throws(() => + engine.ExecuteQuery($"SELECT {expression} AS V FROM Customers").Rows.ToList()); + Assert.Contains("Wrong number of arguments", error.Message, StringComparison.OrdinalIgnoreCase); + } + + private static void AssertLibRedRejects(params string[] expressions) + { + string path = CopyNorthwind("function-arity-libred-"); + try + { + using var db = JetDatabase.Open(path); + var engine = new QueryEngine(db); + foreach (string expression in expressions) + { + Exception error = Assert.Throws(() => + engine.ExecuteQuery($"SELECT {expression} AS V FROM Customers").Rows.ToList()); + Assert.Contains("Wrong number of arguments", error.Message, StringComparison.OrdinalIgnoreCase); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + private static string CopyNorthwind(string prefix) => TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), prefix); +} diff --git a/test/LibRed.Engine.Tests/FunctionVariantTests.cs b/test/LibRed.Engine.Tests/FunctionVariantTests.cs index 487dd212..f1dea3d2 100644 --- a/test/LibRed.Engine.Tests/FunctionVariantTests.cs +++ b/test/LibRed.Engine.Tests/FunctionVariantTests.cs @@ -9,13 +9,12 @@ namespace LibRed.Engine.Tests; // B = byte-based (UTF-16, 2 bytes/char): LenB('abc')=6, InStrB(1,'abc','b')=3, LeftB('abc',2)='a' // W = wide/Unicode code point: ChrW(233)='é' // Also covers base Asc(), which needed a grammar fix (ASC is a reserved keyword). -public class FunctionVariantTests +public class FunctionVariantTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"fv-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "fv-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)"); return e; diff --git a/test/LibRed.Engine.Tests/GenGuidDefaultTests.cs b/test/LibRed.Engine.Tests/GenGuidDefaultTests.cs index d5789b5f..0e7fb902 100644 --- a/test/LibRed.Engine.Tests/GenGuidDefaultTests.cs +++ b/test/LibRed.Engine.Tests/GenGuidDefaultTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // GenGUID() is Access's GUID generator — the sibling of GenUniqueID(). Like GenUniqueID it is default-only in // ACE ("Undefined function 'GenGUID' in expression" inside a SELECT) but valid as a GUID column's DEFAULT, // yielding a fresh Guid per row. EF Core models store-generated Guid keys as HasDefaultValueSql("GenGUID()"). -public class GenGuidDefaultTests +public class GenGuidDefaultTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"gg-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "gg-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } [Fact] diff --git a/test/LibRed.Engine.Tests/GroupByOrderTests.cs b/test/LibRed.Engine.Tests/GroupByOrderTests.cs index 6ac2ae62..e819cb5c 100644 --- a/test/LibRed.Engine.Tests/GroupByOrderTests.cs +++ b/test/LibRed.Engine.Tests/GroupByOrderTests.cs @@ -10,13 +10,12 @@ namespace LibRed.Engine.Tests; /// matches this — which also makes a TOP-1-over-a-GROUP-BY deterministic (e.g. a scalar subquery that picks the /// "first" group), as SQL Server and Access do. /// -public class GroupByOrderTests +public class GroupByOrderTests : TempDatabaseTest { private static QueryEngine Seeded() { - string path = Path.Combine(Path.GetTempPath(), $"gbo-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "gbo-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id LONG PRIMARY KEY, K TEXT(5), N LONG)"); // Insert with keys deliberately out of order (D, A, C, B) so insertion order != sorted order. (string k, int n)[] rows = [("D", 1), ("A", 2), ("C", 3), ("B", 4), ("A", 5), ("C", 6)]; diff --git a/test/LibRed.Engine.Tests/GuidLiteralTests.cs b/test/LibRed.Engine.Tests/GuidLiteralTests.cs index f0a31e62..b3b501ff 100644 --- a/test/LibRed.Engine.Tests/GuidLiteralTests.cs +++ b/test/LibRed.Engine.Tests/GuidLiteralTests.cs @@ -8,8 +8,7 @@ public class GuidLiteralTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"guidlit-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "guidlit-"); return path; } @@ -37,6 +36,6 @@ public void Guid_brace_literal_inserts_and_round_trips() var hit = e.ExecuteQuery($"SELECT `TemplateType` FROM `EmailTemplate` WHERE `Id` = {g}").Rows.Single(); Assert.Equal(0, Convert.ToInt32(hit[0])); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/HashJoinTests.cs b/test/LibRed.Engine.Tests/HashJoinTests.cs index 252e1de4..11da0eda 100644 --- a/test/LibRed.Engine.Tests/HashJoinTests.cs +++ b/test/LibRed.Engine.Tests/HashJoinTests.cs @@ -12,15 +12,14 @@ namespace LibRed.Engine.Tests; /// rows as a nested loop: same-value matching under Access's coercions (case-insensitive text, numeric width), /// null keys never matching, LEFT-join null padding, composite keys, and residual non-equi conjuncts. /// -public class HashJoinTests +public class HashJoinTests : TempDatabaseTest { // P.Id is a PK (indexed); C.Pid is deliberately NOT indexed, so P ⋈ C ON P.Id = C.Pid cannot become an // index-nested-loop and falls to the hash join. private static QueryEngine TwoTables() { - string path = Path.Combine(Path.GetTempPath(), $"hashjoin-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "hashjoin-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE P (Id LONG PRIMARY KEY, Nm TEXT(20))"); e.ExecuteNonQuery("CREATE TABLE C (Id LONG PRIMARY KEY, Pid LONG, Tag TEXT(10), Amt LONG)"); for (int i = 0; i < 50; i++) e.ExecuteNonQuery($"INSERT INTO P (Id, Nm) VALUES ({i}, 'p{i}')"); diff --git a/test/LibRed.Engine.Tests/IifDefaultTests.cs b/test/LibRed.Engine.Tests/IifDefaultTests.cs index 85a6082f..30250b0b 100644 --- a/test/LibRed.Engine.Tests/IifDefaultTests.cs +++ b/test/LibRed.Engine.Tests/IifDefaultTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // IIF(cond, truePart, falsePart) works as a DEFAULT expression — verified to match ACE (both branches, an // environment-function condition, and string results). -public class IifDefaultTests +public class IifDefaultTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"iif-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "iif-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } private static object? DefaultOf(string type, string def) diff --git a/test/LibRed.Engine.Tests/InClauseTests.cs b/test/LibRed.Engine.Tests/InClauseTests.cs index d163b290..aef5cb6e 100644 --- a/test/LibRed.Engine.Tests/InClauseTests.cs +++ b/test/LibRed.Engine.Tests/InClauseTests.cs @@ -8,8 +8,7 @@ public class InClauseTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"in-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "in-"); return path; } @@ -26,7 +25,7 @@ public void In_with_literals() // Numeric IN. Assert.Equal(2, e.ExecuteQuery("SELECT ProductID FROM Products WHERE ProductID IN (1, 2)").Rows.Count()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // The failing shape: a mix of parameters and a constant in the IN list. @@ -43,7 +42,7 @@ public void In_with_parameters_and_a_constant() new Dictionary { ["prm1"] = "ALFKI", ["prm2"] = "AROUT" }).Rows.Count(); Assert.Equal(3, n); // ALFKI, AROUT, ANTON } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -59,7 +58,7 @@ public void Not_in_excludes_the_list() "SELECT CustomerID FROM Customers WHERE CustomerID NOT IN ('ALFKI', 'ANATR')").Rows.Count(); Assert.Equal(total - 2, notIn); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -79,7 +78,7 @@ public void In_subquery_non_correlated() Assert.True(direct > 0); Assert.Equal(direct, viaIn); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -99,7 +98,7 @@ public void Not_in_subquery() "(SELECT CategoryID FROM Categories WHERE CategoryName = 'Beverages')").Rows.Count(); Assert.Equal(total - inCount, notIn); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // The exact failing shape: a correlated IN subquery nested inside EXISTS (Where_contains_on_navigation). @@ -122,7 +121,7 @@ public void Correlated_in_subquery_inside_exists() Assert.True(total > 0); Assert.Equal(total, viaExists); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Case-insensitive membership (Access text semantics). @@ -136,6 +135,6 @@ public void In_is_case_insensitive() Assert.Equal(1, new QueryEngine(db).ExecuteQuery( "SELECT CustomerID FROM Customers WHERE CustomerID IN ('alfki')").Rows.Count()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/InListTests.cs b/test/LibRed.Engine.Tests/InListTests.cs index 8003127d..6bf7ab09 100644 --- a/test/LibRed.Engine.Tests/InListTests.cs +++ b/test/LibRed.Engine.Tests/InListTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // x IN (literal list) is kept as a flat node and evaluated iteratively. The regression that motivated this: // EF Core inlines a "huge number of values" Contains as thousands of constants, and lowering that to a deep // OR-tree recursed once per item and overflowed the stack (crashing the test host, not just failing). -public class InListTests +public class InListTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"inlist-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "inlist-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); for (int i = 1; i <= 5; i++) e.ExecuteNonQuery($"INSERT INTO T (K) VALUES ({i})"); return e; diff --git a/test/LibRed.Engine.Tests/IndexSeekTests.cs b/test/LibRed.Engine.Tests/IndexSeekTests.cs index a4823d9b..fead211a 100644 --- a/test/LibRed.Engine.Tests/IndexSeekTests.cs +++ b/test/LibRed.Engine.Tests/IndexSeekTests.cs @@ -10,13 +10,12 @@ namespace LibRed.Engine.Tests; /// IndexSeekNode) instead of a full scan, with the original predicate kept as a residual re-check. These /// verify the results are identical to a scan — the seek must be a pure speedup, never change the answer. /// -public class IndexSeekTests +public class IndexSeekTests : TempDatabaseTest { private static QueryEngine Seeded() { - string path = Path.Combine(Path.GetTempPath(), $"seek-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "seek-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE B (Id LONG PRIMARY KEY, K LONG, V TEXT(40))"); e.ExecuteNonQuery("CREATE INDEX IX_K ON B (K)"); for (int i = 0; i < 500; i++) e.ExecuteNonQuery($"INSERT INTO B (Id, K, V) VALUES ({i}, {i % 10}, 'v{i}')"); @@ -61,9 +60,8 @@ public void No_match_returns_empty() // --- index-nested-loop join: the inner side is seeked per outer row, not scanned --- private static QueryEngine TwoTables() { - string path = Path.Combine(Path.GetTempPath(), $"nlj-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "nlj-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE P (Id LONG PRIMARY KEY, Nm TEXT(20))"); e.ExecuteNonQuery("CREATE TABLE C (Id LONG PRIMARY KEY, Pid LONG, Amt LONG)"); e.ExecuteNonQuery("CREATE INDEX IX_Pid ON C (Pid)"); diff --git a/test/LibRed.Engine.Tests/IndexSelectionRefusalTests.cs b/test/LibRed.Engine.Tests/IndexSelectionRefusalTests.cs new file mode 100644 index 00000000..d8098125 --- /dev/null +++ b/test/LibRed.Engine.Tests/IndexSelectionRefusalTests.cs @@ -0,0 +1,94 @@ +using LibRed; +using LibRed.Engine; +using LibRed.Engine.Plan; +using Xunit; + +namespace LibRed.Engine.Tests; + +public class IndexSelectionRefusalTests : TempDatabaseTest +{ + private static QueryEngine Fresh() + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "seek-refusal-"); + var engine = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); + engine.ExecuteNonQuery("CREATE TABLE T (Id LONG PRIMARY KEY, A LONG, B LONG, V TEXT(20))"); + engine.ExecuteNonQuery("CREATE INDEX IX_AB ON T (A, B)"); + engine.ExecuteNonQuery("INSERT INTO T (Id, A, B, V) VALUES (1, 10, 20, '10')"); + engine.ExecuteNonQuery("INSERT INTO T (Id, A, B, V) VALUES (2, 11, 21, '11')"); + return engine; + } + + private static bool ContainsSeek(PlanNode node) + => node is IndexSeekNode or IndexRangeSeekNode || node.Children.Any(ContainsSeek); + + private static bool ContainsHashJoin(PlanNode node) + => node is HashJoinNode || node.Children.Any(ContainsHashJoin); + + [Theory] + [InlineData("SELECT Id FROM T WHERE B = 20")] + [InlineData("SELECT Id FROM T WHERE A = 10")] + public void A_partially_constrained_composite_index_is_not_used_as_a_point_seek(string sql) + => Assert.False(ContainsSeek(Fresh().PlanFor(sql))); + + [Fact] + public void Fully_constraining_the_composite_index_uses_one_point_seek() + { + PlanNode plan = Fresh().PlanFor("SELECT Id FROM T WHERE A = 10 AND B = 20"); + var seek = Assert.IsType(FindSeek(plan)); + Assert.Equal("IX_AB", seek.Index.Name); + Assert.Equal(2, seek.Keys.Count); + } + + [Theory] + [InlineData("SELECT Id FROM T WHERE A > (SELECT MAX(A) FROM T)")] + [InlineData("SELECT Id FROM T WHERE A = B")] + public void A_row_or_subquery_dependent_value_is_not_a_seek_bound(string sql) + => Assert.False(ContainsSeek(Fresh().PlanFor(sql))); + + [Fact] + public void A_computed_derived_projection_is_not_assumed_hash_compatible() + { + QueryEngine engine = Fresh(); + const string sql = + "SELECT T.Id FROM T INNER JOIN (SELECT A + 1 AS K FROM T) AS d ON T.A = d.K"; + Assert.False(ContainsHashJoin(engine.PlanFor(sql))); + Assert.Equal(1, engine.ExecuteQuery(sql).Rows.Count()); + } + + [Fact] + public void Cross_kind_equality_is_not_hashed() + { + QueryEngine engine = Fresh(); + const string sql = "SELECT T.Id FROM T INNER JOIN T AS R ON T.A = R.V"; + Assert.False(ContainsHashJoin(engine.PlanFor(sql))); + Assert.Equal(2, engine.ExecuteQuery(sql).Rows.Count()); + } + + [Theory] + [InlineData("BINARY(8)", "0x0102030405060708")] + [InlineData("GUID", "{00112233-4455-6677-8899-AABBCCDDEEFF}")] + [InlineData("DATETIME", "#2020-01-02#")] + public void Same_kind_binary_guid_and_temporal_keys_can_hash(string storeType, string literal) + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery($"CREATE TABLE L (K {storeType})"); + engine.ExecuteNonQuery($"CREATE TABLE R (K {storeType})"); + engine.ExecuteNonQuery($"INSERT INTO L (K) VALUES ({literal})"); + engine.ExecuteNonQuery($"INSERT INTO R (K) VALUES ({literal})"); + + const string sql = "SELECT L.K FROM L INNER JOIN R ON L.K = R.K"; + Assert.True(ContainsHashJoin(engine.PlanFor(sql))); + Assert.Single(engine.ExecuteQuery(sql).Rows); + } + + [Theory] + [InlineData("SELECT DISTINCT Id FROM T WHERE Id = 1")] + [InlineData("SELECT TOP 1 Id FROM T WHERE Id = 1 ORDER BY Id")] + [InlineData("SELECT Id FROM (SELECT Id FROM T WHERE Id = 1) AS d")] + public void Row_preserving_wrappers_do_not_hide_a_safe_seek(string sql) + => Assert.True(ContainsSeek(Fresh().PlanFor(sql))); + + private static PlanNode? FindSeek(PlanNode node) + => node is IndexSeekNode ? node : node.Children.Select(FindSeek).FirstOrDefault(n => n is not null); +} diff --git a/test/LibRed.Engine.Tests/IndexSplitTests.cs b/test/LibRed.Engine.Tests/IndexSplitTests.cs index e8c1d675..22f751ab 100644 --- a/test/LibRed.Engine.Tests/IndexSplitTests.cs +++ b/test/LibRed.Engine.Tests/IndexSplitTests.cs @@ -21,8 +21,7 @@ public class IndexSplitTests private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"split-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "split-"); return path; } @@ -62,6 +61,6 @@ public void Leaf_splitting_keeps_every_key_in_order_and_findable() Assert.Equal("r1234", rs.Rows.Single()[0]); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/InformationSchemaTests.cs b/test/LibRed.Engine.Tests/InformationSchemaTests.cs index d9b4a3f8..f6abad6d 100644 --- a/test/LibRed.Engine.Tests/InformationSchemaTests.cs +++ b/test/LibRed.Engine.Tests/InformationSchemaTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; /// The Jet-flavoured INFORMATION_SCHEMA views are engine-native virtual tables over the catalog, so EF's /// migration existence checks (SELECT * FROM `INFORMATION_SCHEMA.TABLES` WHERE `TABLE_NAME` = '…') work. -public class InformationSchemaTests +public class InformationSchemaTests : TempDatabaseTest { private static QueryEngine Seeded() { - string path = Path.Combine(Path.GetTempPath(), $"is-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "is-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE Widget (Id LONG PRIMARY KEY, Name TEXT(50))"); return e; } @@ -49,9 +48,8 @@ public class IfThenStatementTests { private static QueryEngine Seeded() { - string path = Path.Combine(Path.GetTempPath(), $"if-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "if-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE Existing (Id LONG PRIMARY KEY)"); return e; } diff --git a/test/LibRed.Engine.Tests/InstrEdgeCasesTests.cs b/test/LibRed.Engine.Tests/InstrEdgeCasesTests.cs index 2ab48d6d..12c5bb8e 100644 --- a/test/LibRed.Engine.Tests/InstrEdgeCasesTests.cs +++ b/test/LibRed.Engine.Tests/InstrEdgeCasesTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // InStr([start,] string1, string2 [, compare]) — the documented edge cases, each verified byte-identical to ACE: // case-insensitive default, not-found → 0, empty/null args, start beyond length, and the compare modes. -public class InstrEdgeCasesTests +public class InstrEdgeCasesTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"instr-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "instr-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)"); return e; diff --git a/test/LibRed.Engine.Tests/InstrRevEdgeCasesTests.cs b/test/LibRed.Engine.Tests/InstrRevEdgeCasesTests.cs index ee7a00f9..4c2665b8 100644 --- a/test/LibRed.Engine.Tests/InstrRevEdgeCasesTests.cs +++ b/test/LibRed.Engine.Tests/InstrRevEdgeCasesTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // InStrRev(string1, string2, [start=-1], [compare]) — note the argument order differs from InStr (start is 3rd, // default -1 = end) and start bounds the search to Left(string1, start). All values verified byte-identical to // ACE, including its quirks (empty needle → start position; NULL → error, not NULL; start=0 → error). -public class InstrRevEdgeCasesTests +public class InstrRevEdgeCasesTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"irev-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "irev-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)"); return e; diff --git a/test/LibRed.Engine.Tests/JetTypeMappingTests.cs b/test/LibRed.Engine.Tests/JetTypeMappingTests.cs new file mode 100644 index 00000000..35e7f259 --- /dev/null +++ b/test/LibRed.Engine.Tests/JetTypeMappingTests.cs @@ -0,0 +1,100 @@ +using LibRed.Catalog; +using LibRed.Engine.Schema; +using Xunit; + +namespace LibRed.Engine.Tests; + +public class JetTypeMappingTests +{ + public static TheoryData ScalarTypes => new() + { + { JetDataType.Boolean, "bit", typeof(bool) }, + { JetDataType.Byte, "byte", typeof(byte) }, + { JetDataType.Int16, "smallint", typeof(short) }, + { JetDataType.Int32, "integer", typeof(int) }, + { JetDataType.Int64, "bigint", typeof(long) }, + { JetDataType.Single, "single", typeof(float) }, + { JetDataType.Double, "double", typeof(double) }, + { JetDataType.Currency, "currency", typeof(decimal) }, + { JetDataType.DateTime, "datetime", typeof(DateTime) }, + { JetDataType.DateTimeExtended, "datetime2", typeof(DateTime) }, + { JetDataType.Guid, "guid", typeof(Guid) }, + { JetDataType.Memo, "longchar", typeof(string) }, + { JetDataType.Ole, "longbinary", typeof(byte[]) }, + }; + + private static ColumnDef Column( + JetDataType type, int length = 0, bool fixedLength = false, bool autoNumber = false, + byte precision = 0, byte scale = 0) + => new() + { + Name = "Value", + Type = type, + Length = length, + IsFixedLength = fixedLength, + IsAutoNumber = autoNumber, + Precision = precision, + Scale = scale, + }; + + [Theory] + [MemberData(nameof(ScalarTypes))] + public void Scalar_type_names_store_types_and_clr_types_agree( + JetDataType type, string storeType, Type clrType) + { + ColumnDef column = Column(type); + Assert.Equal(storeType, JetStoreType.TypeName(column)); + Assert.Equal(storeType, JetStoreType.StoreType(column)); + Assert.Null(JetStoreType.MaxLength(column)); + Assert.Equal(clrType, JetClrTypeMap.ToClrType(type)); + } + + [Theory] + [InlineData(false, "varchar", "varchar(20)")] + [InlineData(true, "char", "char(20)")] + public void Text_length_is_reported_in_characters(bool fixedLength, string name, string storeType) + { + ColumnDef column = Column(JetDataType.Text, length: 40, fixedLength: fixedLength); + Assert.Equal(name, JetStoreType.TypeName(column)); + Assert.Equal(20, JetStoreType.MaxLength(column)); + Assert.Equal(storeType, JetStoreType.StoreType(column)); + Assert.Equal(typeof(string), JetClrTypeMap.ToClrType(column.Type)); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(1, 1)] + [InlineData(255, 255)] + public void Binary_length_is_in_bytes_and_fixed_binary_still_presents_as_varbinary(int length, int expected) + { + ColumnDef column = Column(JetDataType.Binary, length, fixedLength: true); + Assert.Equal("varbinary", JetStoreType.TypeName(column)); + Assert.Equal(expected, JetStoreType.MaxLength(column)); + Assert.Equal($"varbinary({expected})", JetStoreType.StoreType(column)); + Assert.Equal(typeof(byte[]), JetClrTypeMap.ToClrType(column.Type)); + } + + [Fact] + public void Decimal_and_counter_facets_are_formatted_canonically() + { + Assert.Equal("decimal(18,4)", + JetStoreType.StoreType(Column(JetDataType.FixedPoint, precision: 18, scale: 4))); + + ColumnDef counter = Column(JetDataType.Int32, autoNumber: true); + Assert.Equal("counter", JetStoreType.TypeName(counter)); + Assert.Equal("counter", JetStoreType.StoreType(counter)); + Assert.False(JetStoreType.IsNullable(counter)); + } + + [Fact] + public void Nullability_and_unknown_types_have_defined_fallbacks() + { + Assert.True(JetStoreType.IsNullable(Column(JetDataType.Text))); + + var unknown = (JetDataType)byte.MaxValue; + 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)); + } +} diff --git a/test/LibRed.Engine.Tests/JoinedUpdateDeleteTests.cs b/test/LibRed.Engine.Tests/JoinedUpdateDeleteTests.cs index 576f38f4..f5be77d1 100644 --- a/test/LibRed.Engine.Tests/JoinedUpdateDeleteTests.cs +++ b/test/LibRed.Engine.Tests/JoinedUpdateDeleteTests.cs @@ -10,13 +10,12 @@ namespace LibRed.Engine.Tests; /// over the ON equi-conditions, not a full cartesian product, and must correctly rewrite/remove exactly the /// matched rows. Includes the all-fixed-column-table case that previously overflowed on re-encode. /// -public class JoinedUpdateDeleteTests +public class JoinedUpdateDeleteTests : TempDatabaseTest { private static QueryEngine Seeded() { - string path = Path.Combine(Path.GetTempPath(), $"jud-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "jud-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); // Parent P and child C, plus an ALL-FIXED-COLUMN table F (no variable columns) to cover the re-encode path. e.ExecuteNonQuery("CREATE TABLE P (Id LONG PRIMARY KEY, Flag LONG)"); e.ExecuteNonQuery("CREATE TABLE C (Id LONG PRIMARY KEY, Pid LONG, Amt LONG)"); diff --git a/test/LibRed.Engine.Tests/LeftRightEdgeCasesTests.cs b/test/LibRed.Engine.Tests/LeftRightEdgeCasesTests.cs index 60bfb830..2de5eca2 100644 --- a/test/LibRed.Engine.Tests/LeftRightEdgeCasesTests.cs +++ b/test/LibRed.Engine.Tests/LeftRightEdgeCasesTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // Left/Right edge cases, verified byte-identical to ACE — including where ACE errors (negative length → Invalid // procedure call; null length → Data type mismatch) rather than clamping. A null string propagates. Also: Split() // is not a scalar SQL function in ACE ("Undefined function", it returns an array), so LibRed rejects it too. -public class LeftRightEdgeCasesTests +public class LeftRightEdgeCasesTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"lr-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "lr-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)"); return e; @@ -47,5 +46,5 @@ public void Error_cases(string expr) [Fact] public void Split_is_not_a_scalar_function() // matches ACE ("Undefined function 'Split'") - => Assert.ThrowsAny(() => Eval("Split('a,b,c', ',')")); + => Assert.Throws(() => Eval("Split('a,b,c', ',')")); } diff --git a/test/LibRed.Engine.Tests/LibRed.Engine.Tests.csproj b/test/LibRed.Engine.Tests/LibRed.Engine.Tests.csproj index f61eb314..80c6ad4b 100644 --- a/test/LibRed.Engine.Tests/LibRed.Engine.Tests.csproj +++ b/test/LibRed.Engine.Tests/LibRed.Engine.Tests.csproj @@ -13,6 +13,8 @@ true $(MSBuildThisFileDirectory)..\..\Key.snk AnyCPU;x86;x64 + + $(NoWarn);CA1416 @@ -27,10 +29,18 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/test/LibRed.Engine.Tests/LikeTests.cs b/test/LibRed.Engine.Tests/LikeTests.cs index a3c9392d..21551d7b 100644 --- a/test/LibRed.Engine.Tests/LikeTests.cs +++ b/test/LibRed.Engine.Tests/LikeTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // Access/Jet LIKE wildcards, including the bracket char class [ ... ] / [! ... ] and the # digit wildcard. // EF escapes literal special chars by bracketing them (Contains("C#") -> LIKE '%C[#]%'), so [#] must match // a literal '#', not the three characters "[#]". -public class LikeTests +public class LikeTests : TempDatabaseTest { private static QueryEngine Fresh(params string[] values) { - string path = Path.Combine(Path.GetTempPath(), $"like-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "like-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id long PRIMARY KEY, V text(50))"); for (int i = 0; i < values.Length; i++) e.ExecuteNonQuery("INSERT INTO T (Id, V) VALUES (@id, @v)", diff --git a/test/LibRed.Engine.Tests/LvalReclamationTests.cs b/test/LibRed.Engine.Tests/LvalReclamationTests.cs index 7460d142..f854e0cd 100644 --- a/test/LibRed.Engine.Tests/LvalReclamationTests.cs +++ b/test/LibRed.Engine.Tests/LvalReclamationTests.cs @@ -11,8 +11,7 @@ public class LvalReclamationTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"lval-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "lval-"); return path; } @@ -47,7 +46,7 @@ public void Repeated_memo_updates_reclaim_old_chained_pages() using (var db = JetDatabase.Open(path)) Assert.Equal(Big((char)('b' + 29 % 20)), new QueryEngine(db).ExecuteQuery("SELECT M FROM T").Rows.Single()[0]); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -81,7 +80,7 @@ public void Updating_another_column_does_not_re_materialise_the_memo() Assert.Equal(Big('a'), e.ExecuteQuery("SELECT M FROM T").Rows.Single()[0]); // memo intact } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -113,6 +112,6 @@ public void Deleting_a_row_reclaims_its_memo_pages() Assert.True(afterChurn - afterInserts < 150_000, $"file grew {afterChurn - afterInserts} bytes over 20 delete+insert cycles — deleted memo pages not reclaimed?"); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/MSysAcesViewTests.cs b/test/LibRed.Engine.Tests/MSysAcesViewTests.cs index df4c9029..7e90749a 100644 --- a/test/LibRed.Engine.Tests/MSysAcesViewTests.cs +++ b/test/LibRed.Engine.Tests/MSysAcesViewTests.cs @@ -15,8 +15,7 @@ public class MSysAcesViewTests public void Created_view_gets_the_query_permission_rows_access_writes() { string northwind = Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"); - string path = Path.Combine(Path.GetTempPath(), $"aces-{Guid.NewGuid():N}.accdb"); - File.Copy(northwind, path); + string path = TemporaryDatabase.CopyPath(northwind, "aces-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -44,7 +43,7 @@ public void Created_view_gets_the_query_permission_rows_access_writes() Assert.Equal(("690C", 0xF00FE), (rows[1].Sid, rows[1].Acm)); // owner → query mask Assert.All(rows, r => Assert.Equal(false, r.Inh)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } private static int Col(Table t, string name) => t.Definition.FindColumn(name)!.Index; diff --git a/test/LibRed.Engine.Tests/MathFunctionTests.cs b/test/LibRed.Engine.Tests/MathFunctionTests.cs index b5bba1d2..53f1de8f 100644 --- a/test/LibRed.Engine.Tests/MathFunctionTests.cs +++ b/test/LibRed.Engine.Tests/MathFunctionTests.cs @@ -8,8 +8,7 @@ public class MathFunctionTests { private static double Eval(string expr) { - string path = Path.Combine(Path.GetTempPath(), $"math-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "math-"); try { using var db = JetDatabase.Open(path, readOnly: false); @@ -18,7 +17,7 @@ private static double Eval(string expr) object? v = engine.ExecuteQuery($"SELECT {expr} AS X FROM Shippers").Rows.First()[0]; return Convert.ToDouble(v); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Theory] diff --git a/test/LibRed.Engine.Tests/MathFunctionTypeTests.cs b/test/LibRed.Engine.Tests/MathFunctionTypeTests.cs index aa91921f..0d646f24 100644 --- a/test/LibRed.Engine.Tests/MathFunctionTypeTests.cs +++ b/test/LibRed.Engine.Tests/MathFunctionTypeTests.cs @@ -10,8 +10,7 @@ public class MathFunctionTypeTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"mathfn-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "mathfn-"); return path; } @@ -26,7 +25,7 @@ private static string Fresh() e.ExecuteNonQuery("INSERT INTO M (D, C) VALUES (3.7, 3.7)"); return e.ExecuteQuery($"SELECT {expr} FROM M").Rows.First()[0]; } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] diff --git a/test/LibRed.Engine.Tests/MemoIndexTests.cs b/test/LibRed.Engine.Tests/MemoIndexTests.cs index 00303d96..8102a82c 100644 --- a/test/LibRed.Engine.Tests/MemoIndexTests.cs +++ b/test/LibRed.Engine.Tests/MemoIndexTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // A Memo (Long Text) column is indexable in Access; its index key is the text collation key over the first // 255 characters. Exercise the insert path (RowInserter → IndexKeyEncoder) end-to-end through the engine. -public class MemoIndexTests +public class MemoIndexTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"memoidx-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "memoidx-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE MK (Id long PRIMARY KEY, M memo)"); e.ExecuteNonQuery("CREATE INDEX IX_M ON MK (M)"); return e; diff --git a/test/LibRed.Engine.Tests/MidReplaceEdgeCasesTests.cs b/test/LibRed.Engine.Tests/MidReplaceEdgeCasesTests.cs index d71ac815..cf5fd36e 100644 --- a/test/LibRed.Engine.Tests/MidReplaceEdgeCasesTests.cs +++ b/test/LibRed.Engine.Tests/MidReplaceEdgeCasesTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // Mid(string, start, [length]) and Replace(string1, find, replacement, [start], [count], [compare]) edge cases, // all verified byte-identical to ACE — including where ACE ERRORS rather than clamping/propagating: start < 1, // negative length, start=0, and a null Replace argument. (Mid propagates null on the string; Replace does not.) -public class MidReplaceEdgeCasesTests +public class MidReplaceEdgeCasesTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"mr-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "mr-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)"); return e; diff --git a/test/LibRed.Engine.Tests/MultiTableUpdateDeleteTests.cs b/test/LibRed.Engine.Tests/MultiTableUpdateDeleteTests.cs index 846c2e9e..fe6de0f9 100644 --- a/test/LibRed.Engine.Tests/MultiTableUpdateDeleteTests.cs +++ b/test/LibRed.Engine.Tests/MultiTableUpdateDeleteTests.cs @@ -11,8 +11,7 @@ public class MultiTableUpdateDeleteTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"mtud-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "mtud-"); return path; } @@ -50,7 +49,7 @@ public void Multi_table_update_touches_both_tables_and_accumulates_on_the_one_si Assert.All(e.ExecuteQuery("SELECT CName FROM C WHERE ParentId = 1").Rows, r => Assert.Equal("child", r[0])); Assert.Equal("c12", e.ExecuteQuery("SELECT CName FROM C WHERE Id = 12").Rows.Single()[0]); // untouched } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -69,7 +68,7 @@ public void Multi_table_delete_removes_only_the_targeted_table() Assert.Equal(new[] { 12 }, e.ExecuteQuery("SELECT Id FROM C").Rows.Select(r => Convert.ToInt32(r[0])).OrderBy(x => x)); Assert.Equal(2, e.ExecuteQuery("SELECT Id FROM P").Rows.Count()); // both parents remain } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // `DELETE *` (bare star) is fine for a single table, but a join DELETE without a `table.*` target is @@ -93,6 +92,6 @@ public void Delete_star_needs_a_table_target_on_a_join() Assert.Throws(() => e.ExecuteNonQuery("DELETE FROM C INNER JOIN P ON C.ParentId = P.Id WHERE P.PName = 'p2'")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/NestedJoinViewTests.cs b/test/LibRed.Engine.Tests/NestedJoinViewTests.cs index 56e3c337..374600e1 100644 --- a/test/LibRed.Engine.Tests/NestedJoinViewTests.cs +++ b/test/LibRed.Engine.Tests/NestedJoinViewTests.cs @@ -28,8 +28,7 @@ FROM Shippers INNER JOIN private static string Fresh() { - string p = Path.Combine(Path.GetTempPath(), $"nested-view-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), p); + string p = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "nested-view-"); return p; } @@ -42,7 +41,7 @@ public void Nested_join_view_with_aliases_is_created() using var db = JetDatabase.Open(path, readOnly: false); new QueryEngine(db).ExecuteNonQuery(InvoicesView); // parses, flattens the joins, stores it } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -64,6 +63,6 @@ public void Access_multi_join_views_read_back_through_libred() Assert.Equal("Steven Buchanan", row[0]); // FirstName + ' ' + LastName Assert.Equal(168.00m, Convert.ToDecimal(row[1])); // CCur(14*12*(1-0)/100)*100 } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/NotLikeTests.cs b/test/LibRed.Engine.Tests/NotLikeTests.cs index f34bb2ce..b3724c08 100644 --- a/test/LibRed.Engine.Tests/NotLikeTests.cs +++ b/test/LibRed.Engine.Tests/NotLikeTests.cs @@ -8,8 +8,7 @@ public class NotLikeTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"notlike-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "notlike-"); return path; } @@ -27,7 +26,7 @@ public void Not_like_is_the_complement_of_like() Assert.True(like > 0); Assert.Equal(total - like, notLike); // NOT LIKE = the rest (non-null names) } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // The .All() shape: NOT EXISTS (… WHERE ContactName NOT LIKE 'A%' OR ContactName IS NULL). @@ -46,6 +45,6 @@ public void All_top_level_shape_with_not_like() // Not all contact names start with 'A' → All(...) is false. Assert.Equal(false, all); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/ParenthesizedJoinTests.cs b/test/LibRed.Engine.Tests/ParenthesizedJoinTests.cs index 279fd032..ef9655d7 100644 --- a/test/LibRed.Engine.Tests/ParenthesizedJoinTests.cs +++ b/test/LibRed.Engine.Tests/ParenthesizedJoinTests.cs @@ -13,8 +13,7 @@ public class ParenthesizedJoinTests { private static string Fresh() { - string p = Path.Combine(Path.GetTempPath(), $"parenjoin-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), p); + string p = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "parenjoin-"); return p; } @@ -40,6 +39,6 @@ public void Parenthesized_join_group_matches_the_flat_join() Assert.Equal(2155, flat); // Customers ⋈ Orders ⋈ Order Details Assert.Equal(flat, nested); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/PreEpochDateOrderingTests.cs b/test/LibRed.Engine.Tests/PreEpochDateOrderingTests.cs index 0ea60373..f701c628 100644 --- a/test/LibRed.Engine.Tests/PreEpochDateOrderingTests.cs +++ b/test/LibRed.Engine.Tests/PreEpochDateOrderingTests.cs @@ -23,15 +23,14 @@ namespace LibRed.Engine.Tests; /// disagree, the same query returns different answers depending on whether the planner picks a seek or a scan. /// These tests pin that they agree, and record which convention the agreement follows. /// -public class PreEpochDateOrderingTests +public class PreEpochDateOrderingTests : TempDatabaseTest { /// Two tables with identical rows: T indexed on the date column, U not — so the same /// query exercises the index path and the scan path. private static QueryEngine Seeded() { - string path = Path.Combine(Path.GetTempPath(), $"preepoch-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "preepoch-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id LONG PRIMARY KEY, D DATETIME)"); e.ExecuteNonQuery("CREATE INDEX IX_T_D ON T (D)"); e.ExecuteNonQuery("CREATE TABLE U (Id LONG PRIMARY KEY, D DATETIME)"); diff --git a/test/LibRed.Engine.Tests/PredicatePushdownTests.cs b/test/LibRed.Engine.Tests/PredicatePushdownTests.cs index 30385c5f..cc8d47cf 100644 --- a/test/LibRed.Engine.Tests/PredicatePushdownTests.cs +++ b/test/LibRed.Engine.Tests/PredicatePushdownTests.cs @@ -1,7 +1,7 @@ -using System.Diagnostics; using System.Linq; using LibRed; using LibRed.Engine; +using LibRed.Engine.Plan; using Xunit; namespace LibRed.Engine.Tests; @@ -11,13 +11,15 @@ namespace LibRed.Engine.Tests; /// filters inside the nested loop instead of materializing the full cross product. Without this a 4-table /// comma-join is O(product of table sizes) — catastrophic for real queries like Northwind's CustOrderHist. /// -public class PredicatePushdownTests +public class PredicatePushdownTests : TempDatabaseTest { + private static bool HasJoinPredicate(PlanNode node) + => node is JoinNode { On: not null } || node.Children.Any(HasJoinPredicate); + private static QueryEngine FourTables(int rowsEach) { - string path = Path.Combine(Path.GetTempPath(), $"pd-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "pd-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); foreach (string t in new[] { "A", "B", "C", "D" }) { e.ExecuteNonQuery($"CREATE TABLE {t} (k LONG PRIMARY KEY, v LONG)"); @@ -29,16 +31,17 @@ private static QueryEngine FourTables(int rowsEach) [Fact] public void Comma_join_equi_chain_is_correct_and_does_not_materialize_the_cross_product() { - // 60^4 = 12.96M cross product; a pushed equi-join returns 60. If pushdown regressed this test would - // still finish (bounded) but take orders of magnitude longer — the 2s cap guards against that. + // 60^4 = 12.96M cross product; assert the optimisation structurally so instrumentation overhead cannot + // turn planner correctness into a machine-speed test. var e = FourTables(60); - var sw = Stopwatch.StartNew(); + const string sql = + "SELECT A.v, D.v FROM A, B, C, D WHERE A.k = B.k AND B.k = C.k AND C.k = D.k"; + Assert.True(HasJoinPredicate(e.PlanFor(sql)), "expected WHERE equalities folded into join predicates"); + var rows = e.ExecuteQuery( - "SELECT A.v, D.v FROM A, B, C, D WHERE A.k = B.k AND B.k = C.k AND C.k = D.k").Rows.ToList(); - sw.Stop(); + sql).Rows.ToList(); Assert.Equal(60, rows.Count); - Assert.True(sw.ElapsedMilliseconds < 2000, $"comma-join took {sw.ElapsedMilliseconds} ms — cross product not pushed down?"); } [Fact] diff --git a/test/LibRed.Engine.Tests/PrimaryKeyNameTests.cs b/test/LibRed.Engine.Tests/PrimaryKeyNameTests.cs index a24d4ea0..e20316ae 100644 --- a/test/LibRed.Engine.Tests/PrimaryKeyNameTests.cs +++ b/test/LibRed.Engine.Tests/PrimaryKeyNameTests.cs @@ -8,13 +8,12 @@ namespace LibRed.Engine.Tests; // the constraint), so it round-trips back through the catalog — this is what the scaffolder reports. When no // name is given, LibRed picks the stable "PrimaryKey" as its own engine fallback (ACE-via-SQL instead // generates a random "Index_" — no fixed value to reproduce, and nothing downstream depends on it). -public class PrimaryKeyNameTests +public class PrimaryKeyNameTests : TempDatabaseTest { private static (QueryEngine Engine, JetDatabase Db) Setup() { - string path = Path.Combine(Path.GetTempPath(), $"pkname-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var db = JetDatabase.Open(path, readOnly: false); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "pkname-"); + var db = TemporaryDatabase.OpenTracked(path, readOnly: false); return (new QueryEngine(db), db); } diff --git a/test/LibRed.Engine.Tests/QualifiedStarTests.cs b/test/LibRed.Engine.Tests/QualifiedStarTests.cs index 3f844366..db95f294 100644 --- a/test/LibRed.Engine.Tests/QualifiedStarTests.cs +++ b/test/LibRed.Engine.Tests/QualifiedStarTests.cs @@ -17,8 +17,7 @@ public class QualifiedStarTests private static string Fresh() { - string p = Path.Combine(Path.GetTempPath(), $"qstar-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), p); + string p = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "qstar-"); return p; } @@ -37,7 +36,7 @@ public void Qualified_star_expands_to_the_sources_columns() Assert.Equal("CategoryName", rs.ColumnNames[^1]); Assert.Equal(69, rs.Rows.Count()); // the not-discontinued products } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -67,6 +66,6 @@ public void A_qualified_star_view_round_trips_and_matches_access_own_view() Assert.Equal(69, rs.Rows.Count()); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/RandomAutoNumberTests.cs b/test/LibRed.Engine.Tests/RandomAutoNumberTests.cs index d2162cd1..b326e214 100644 --- a/test/LibRed.Engine.Tests/RandomAutoNumberTests.cs +++ b/test/LibRed.Engine.Tests/RandomAutoNumberTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // A "Random" AutoNumber — an AutoNumber column with DEFAULT GenUniqueID() (Access's "New Values = Random"). // LibRed persists the GenUniqueID() default to the column's LvProp (byte-identical to a UI-authored one) and, // on insert, assigns a random Int32 per row instead of the sequential seed/increment counter. -public class RandomAutoNumberTests +public class RandomAutoNumberTests : TempDatabaseTest { private static (QueryEngine Engine, JetDatabase Db) Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"rand-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var db = JetDatabase.Open(path, readOnly: false); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "rand-"); + var db = TemporaryDatabase.OpenTracked(path, readOnly: false); return (new QueryEngine(db), db); } diff --git a/test/LibRed.Engine.Tests/ReaderWriterIsolationTests.cs b/test/LibRed.Engine.Tests/ReaderWriterIsolationTests.cs new file mode 100644 index 00000000..9de9d8c4 --- /dev/null +++ b/test/LibRed.Engine.Tests/ReaderWriterIsolationTests.cs @@ -0,0 +1,172 @@ +using LibRed; +using LibRed.Crypto; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +public class ReaderWriterIsolationTests +{ + [Fact] + public void Reader_crossing_a_multi_page_commit_sees_one_complete_generation() + => RunCrossingCommit(password: null); + + [Fact] + public void Encrypted_reader_crossing_a_multi_page_commit_sees_one_complete_generation() + => RunCrossingCommit("Reader-Commit-S3cret!"); + + // The isolation above is bought with a per-file scope, so it matters that the scope is SHARED for reads: + // if it were exclusive, statement-level isolation would come at the price of serializing every reader on + // the file. Deterministic, not timing-based — two readers must be inside their scopes at once for the + // barrier to release, so an exclusive scope deadlocks the barrier and the wait times out. + [Fact] + public void Two_readers_on_one_file_are_inside_their_scopes_at_the_same_time() + { + string path = CreateDatabase("reader-parallel-"); + try + { + using var firstDb = JetDatabase.Open(path, readOnly: true); + using var secondDb = JetDatabase.Open(path, readOnly: true); + using var bothInside = new Barrier(2); + + bool Read(JetDatabase db) => db.ReadConsistent(() => bothInside.SignalAndWait(TimeSpan.FromSeconds(10))); + + Task first = Task.Run(() => Read(firstDb)); + Task second = Task.Run(() => Read(secondDb)); + + Assert.True(Task.WaitAll([first, second], TimeSpan.FromSeconds(20)), "a reader never entered its scope"); + Assert.True(first.Result && second.Result, "the two readers did not overlap — the read scope is exclusive"); + } + finally { TemporaryDatabase.Delete(path); } + } + + // The other half of the contract: a writing statement's scope excludes readers for its whole duration, + // which is what makes a multi-page write atomic to them. Sound in one direction — a correct implementation + // always blocks, so only a regression can make this fail. + [Fact] + public void A_writer_holding_its_scope_blocks_a_reader_until_it_finishes() + { + string path = CreateDatabase("writer-excludes-"); + try + { + using var writerDb = JetDatabase.Open(path, readOnly: false); + using var readerDb = JetDatabase.Open(path, readOnly: true); + using var writerInside = new ManualResetEventSlim(); + using var releaseWriter = new ManualResetEventSlim(); + + Task writer = Task.Run(() => writerDb.WriteExclusive(() => + { + writerInside.Set(); + releaseWriter.Wait(TimeSpan.FromSeconds(10)); + return null; + })); + + Assert.True(writerInside.Wait(TimeSpan.FromSeconds(10)), "the writer never entered its scope"); + Task blockedReader = Task.Run(() => readerDb.ReadConsistent(() => 1)); + Assert.False(blockedReader.Wait(TimeSpan.FromMilliseconds(250)), "the reader entered while a writer held the file"); + + releaseWriter.Set(); + Assert.True(Task.WaitAll([writer, blockedReader], TimeSpan.FromSeconds(10)), "the reader was not released"); + } + finally { TemporaryDatabase.Delete(path); } + } + + // A statement misclassified as read-only that then writes cannot upgrade the shared scope. That is a bug + // in the classification, so it must announce itself rather than surface as a bare LockRecursionException. + [Fact] + public void Writing_inside_a_read_scope_is_rejected_with_a_diagnosable_error() + { + string path = CreateDatabase("upgrade-guard-"); + try + { + using var db = JetDatabase.Open(path, readOnly: false); + var error = Assert.Throws( + () => db.ReadConsistent(() => db.WriteExclusive(() => 0))); + Assert.Contains("exclusive scope", error.Message, StringComparison.OrdinalIgnoreCase); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Preloaded_reader_cache_sees_committed_multi_page_update_but_not_uncommitted_pages() + { + string path = CreateDatabase("reader-cache-"); + try + { + using var writerDb = JetDatabase.Open(path, readOnly: false); + using var readerDb = JetDatabase.Open(path, readOnly: false); + var writer = new QueryEngine(writerDb); + var reader = new QueryEngine(readerDb); + + AssertGeneration(reader, "old"); // preload every data/index page into the shared read path + writer.ExecuteNonQuery("BEGIN"); + writer.ExecuteNonQuery("UPDATE IsolationRows SET ValueText = 'new'"); + AssertGeneration(reader, "old"); + writer.ExecuteNonQuery("COMMIT"); + AssertGeneration(reader, "new"); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void RunCrossingCommit(string? password) + { + string path = CreateDatabase("reader-crossing-"); + try + { + if (password is not null) + DatabaseEncryption.SetPassword(path, password, AccessEncryption.Agile); + + using var writerDb = JetDatabase.Open(path, readOnly: false, password: password); + using var readerDb = JetDatabase.Open(path, readOnly: false, password: password); + var writer = new QueryEngine(writerDb); + var reader = new QueryEngine(readerDb); + + AssertGeneration(reader, "old"); + writer.ExecuteNonQuery("BEGIN TRANSACTION"); + writer.ExecuteNonQuery("UPDATE IsolationRows SET ValueText = 'new'"); + + using var start = new ManualResetEventSlim(); + Task commit = Task.Run(() => + { + start.Set(); + writer.ExecuteNonQuery("COMMIT"); + }); + start.Wait(); + + // Every SELECT is a statement-level snapshot. It may land on either side of the commit, but a + // multi-page publish must never produce a mixture of old and new rows. + while (!commit.IsCompleted) + AssertSingleGeneration(reader); + commit.GetAwaiter().GetResult(); + AssertGeneration(reader, "new"); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static string CreateDatabase(string prefix) + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), prefix); + using var db = JetDatabase.Open(path, readOnly: false); + var engine = new QueryEngine(db); + engine.ExecuteNonQuery("CREATE TABLE IsolationRows (Id LONG PRIMARY KEY, ValueText TEXT(200))"); + for (int i = 1; i <= 240; i++) + engine.ExecuteNonQuery($"INSERT INTO IsolationRows (Id, ValueText) VALUES ({i}, 'old')"); + return path; + } + + private static void AssertSingleGeneration(QueryEngine reader) + { + var values = reader.ExecuteQuery("SELECT ValueText FROM IsolationRows ORDER BY Id") + .Rows.Select(row => (string)row[0]!).Distinct().ToArray(); + Assert.Single(values); + Assert.Contains(values[0], new[] { "old", "new" }); + } + + private static void AssertGeneration(QueryEngine reader, string expected) + { + var values = reader.ExecuteQuery("SELECT ValueText FROM IsolationRows ORDER BY Id").Rows; + Assert.Equal(240, values.Count()); + Assert.All(values, row => Assert.Equal(expected, row[0])); + } +} diff --git a/test/LibRed.Engine.Tests/ReferentialActionTests.cs b/test/LibRed.Engine.Tests/ReferentialActionTests.cs index 7ef10bc0..97cefe91 100644 --- a/test/LibRed.Engine.Tests/ReferentialActionTests.cs +++ b/test/LibRed.Engine.Tests/ReferentialActionTests.cs @@ -10,8 +10,7 @@ public class ReferentialActionTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"ri-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "ri-"); return path; } @@ -31,7 +30,7 @@ private static void Run(string fkClause, Action act) { string path = Fresh(); try { using var db = JetDatabase.Open(path, readOnly: false); act(SetUp(db, fkClause)); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } private static int[] ChildParents(QueryEngine e) => @@ -111,7 +110,7 @@ public void On_update_set_null_throws_not_implemented() "CREATE TABLE C (Id long PRIMARY KEY, ParentId long, CONSTRAINT FK_C FOREIGN KEY (ParentId) REFERENCES P (Id) ON UPDATE SET NULL ON DELETE SET NULL)")); Assert.Contains("ON UPDATE SET NULL", ex.Message); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -126,6 +125,6 @@ public void Set_null_action_persists_and_reads_back() Assert.True(fk.DeleteSetNull); Assert.False(fk.CascadeDelete); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/RndValFunctionTests.cs b/test/LibRed.Engine.Tests/RndValFunctionTests.cs index fe3efb35..58033f43 100644 --- a/test/LibRed.Engine.Tests/RndValFunctionTests.cs +++ b/test/LibRed.Engine.Tests/RndValFunctionTests.cs @@ -5,13 +5,12 @@ namespace LibRed.Engine.Tests; // Val (whitespace-stripping + &H/&O prefixes) and Rnd (VBA 24-bit LCG) — verified byte-identical to ACE. -public class RndValFunctionTests +public class RndValFunctionTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"rv-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "rv-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)"); return e; diff --git a/test/LibRed.Engine.Tests/SchemaVisibilityTests.cs b/test/LibRed.Engine.Tests/SchemaVisibilityTests.cs new file mode 100644 index 00000000..af8a801e --- /dev/null +++ b/test/LibRed.Engine.Tests/SchemaVisibilityTests.cs @@ -0,0 +1,84 @@ +using LibRed; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +// Each connection parses the catalog once and caches it, so DDL committed by one connection has to reach the +// others somehow: PageChannel keeps a per-file schema generation that a schema-changing commit advances, and +// JetCatalog re-reads when the generation it last saw has moved on. Without that a second connection keeps +// serving a catalog from before the CREATE and reports the table as missing. +public class SchemaVisibilityTests +{ + [Fact] + public void A_table_created_on_one_connection_is_visible_to_another() + { + string path = Fresh("schema-create-"); + try + { + using var firstDb = JetDatabase.Open(path, readOnly: false); + using var secondDb = JetDatabase.Open(path, readOnly: false); + var first = new QueryEngine(firstDb); + var second = new QueryEngine(secondDb); + + // Make the second connection cache a catalog that predates the new table. + Assert.NotEmpty(second.ExecuteQuery("SELECT CustomerID FROM Customers").Rows); + Assert.DoesNotContain("Later", secondDb.Catalog.Tables.Select(t => t.Name)); + + first.ExecuteNonQuery("CREATE TABLE Later (Id LONG PRIMARY KEY, V TEXT(10))"); + first.ExecuteNonQuery("INSERT INTO Later (Id, V) VALUES (1, 'a')"); + + Assert.Contains("Later", secondDb.Catalog.Tables.Select(t => t.Name)); + Assert.Equal("a", second.ExecuteQuery("SELECT V FROM Later").Rows.Single()[0]); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void A_table_dropped_on_one_connection_stops_being_visible_to_another() + { + string path = Fresh("schema-drop-"); + try + { + using var firstDb = JetDatabase.Open(path, readOnly: false); + using var secondDb = JetDatabase.Open(path, readOnly: false); + var first = new QueryEngine(firstDb); + var second = new QueryEngine(secondDb); + + first.ExecuteNonQuery("CREATE TABLE Doomed (Id LONG PRIMARY KEY)"); + Assert.Contains("Doomed", secondDb.Catalog.Tables.Select(t => t.Name)); // caches it + + first.ExecuteNonQuery("DROP TABLE Doomed"); + + Assert.DoesNotContain("Doomed", secondDb.Catalog.Tables.Select(t => t.Name)); + Assert.ThrowsAny(() => second.ExecuteQuery("SELECT Id FROM Doomed")); + } + finally { TemporaryDatabase.Delete(path); } + } + + // The counterpart guard: plain DML must not invalidate anyone's catalog, or every INSERT would cost every + // other connection a full re-parse of MSysObjects. + [Fact] + public void Ordinary_dml_on_one_connection_does_not_invalidate_another_catalog() + { + string path = Fresh("schema-dml-"); + try + { + using var firstDb = JetDatabase.Open(path, readOnly: false); + using var secondDb = JetDatabase.Open(path, readOnly: false); + var first = new QueryEngine(firstDb); + + first.ExecuteNonQuery("CREATE TABLE Rows1 (Id LONG PRIMARY KEY)"); + var cached = secondDb.Catalog.Tables.Single(t => t.Name == "Rows1"); + + first.ExecuteNonQuery("INSERT INTO Rows1 (Id) VALUES (1)"); + + // Same TableDef instance: the DML did not force the second connection to re-read the catalog. + Assert.Same(cached, secondDb.Catalog.Tables.Single(t => t.Name == "Rows1")); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static string Fresh(string prefix) => + TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), prefix); +} diff --git a/test/LibRed.Engine.Tests/SelectPredicateTests.cs b/test/LibRed.Engine.Tests/SelectPredicateTests.cs index 35e2b1a6..5e95cf2e 100644 --- a/test/LibRed.Engine.Tests/SelectPredicateTests.cs +++ b/test/LibRed.Engine.Tests/SelectPredicateTests.cs @@ -10,13 +10,12 @@ namespace LibRed.Engine.Tests; /// Row counts are the ones the ACE engine itself returns for the same queries (probed against Northwind and, /// for the DISTINCTROW edge case, a purpose-built table). /// -public class SelectPredicateTests +public class SelectPredicateTests : TempDatabaseTest { private static QueryEngine Northwind() { - string path = Path.Combine(Path.GetTempPath(), $"pred-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "pred-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } private static int Count(QueryEngine e, string sql) => e.ExecuteQuery(sql).Rows.Count(); diff --git a/test/LibRed.Engine.Tests/SelfRefInsertTests.cs b/test/LibRed.Engine.Tests/SelfRefInsertTests.cs index 20434000..72256770 100644 --- a/test/LibRed.Engine.Tests/SelfRefInsertTests.cs +++ b/test/LibRed.Engine.Tests/SelfRefInsertTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // Repro for the ComplexNavigations seed failure: a REQUIRED self-referencing FK (LevelOne.Inverse1Id -> // LevelOne.Id). EF inserts parent rows before children, so immediate FK enforcement should pass. Probing // which order/shape our RI enforcement wrongly rejects. -public class SelfRefInsertTests +public class SelfRefInsertTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"selfref-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var db = JetDatabase.Open(path, readOnly: false); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "selfref-"); + var db = TemporaryDatabase.OpenTracked(path, readOnly: false); var e = new QueryEngine(db); e.ExecuteNonQuery("CREATE TABLE L1 (Id long PRIMARY KEY, Pid long, " + "CONSTRAINT FK_Self FOREIGN KEY (Pid) REFERENCES L1 (Id))"); diff --git a/test/LibRed.Engine.Tests/SortBoundTests.cs b/test/LibRed.Engine.Tests/SortBoundTests.cs index e183e58d..af168734 100644 --- a/test/LibRed.Engine.Tests/SortBoundTests.cs +++ b/test/LibRed.Engine.Tests/SortBoundTests.cs @@ -8,15 +8,14 @@ namespace LibRed.Engine.Tests; // it keeps only the n smallest instead of ordering everything. Both change HOW rows are ordered internally — the // second replaced a stable sort with a total order over (keys, input position) — so these pin that the observable // order is unchanged, in particular that ties still come out in input order. -public class SortBoundTests +public class SortBoundTests : TempDatabaseTest { private const int Rows = 500; private static QueryEngine Ties() { - string path = Path.Combine(Path.GetTempPath(), $"sortb-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "sortb-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); // Grp deliberately coarse (5 values over 500 rows) so every ORDER BY on it is 100-way tied, and Id // ascending is the insertion order — so "ties in input order" is checkable. diff --git a/test/LibRed.Engine.Tests/SortPushdownTests.cs b/test/LibRed.Engine.Tests/SortPushdownTests.cs index a646636e..20f467c6 100644 --- a/test/LibRed.Engine.Tests/SortPushdownTests.cs +++ b/test/LibRed.Engine.Tests/SortPushdownTests.cs @@ -1,5 +1,7 @@ using LibRed; using LibRed.Engine; +using LibRed.Engine.Plan; +using LibRed.Tests.Shared; using Xunit; namespace LibRed.Engine.Tests; @@ -7,13 +9,22 @@ namespace LibRed.Engine.Tests; // An ORDER BY whose keys all come from one side of a join is applied to that side, so the join streams in order // instead of its product being built and sorted. These pin that the observable order is identical — including the // tie behaviour, which is the whole reason the rewrite is sound — and that the shapes it must not touch decline. -public class SortPushdownTests +public class SortPushdownTests : TempDatabaseTest { + private static bool HasSortBelowJoin(PlanNode node, bool belowJoin = false) + => node is SortNode && belowJoin + || node.Children.Any(child => HasSortBelowJoin(child, belowJoin || node is JoinNode)); + + private static bool ContainsJoin(PlanNode node) + => node is JoinNode || node.Children.Any(ContainsJoin); + + private static bool HasSortAboveJoin(PlanNode node) + => node is SortNode sort && ContainsJoin(sort.Input) || node.Children.Any(HasSortAboveJoin); + private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"sortpd-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "sortpd-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); // Grp deliberately ties in pairs, and is anti-correlated with Id, so an ORDER BY Grp genuinely reorders // and the tie order is observable. @@ -69,24 +80,36 @@ public void A_three_way_join_sinks_the_sort_past_both_joins() [Fact] public void A_key_from_both_sides_stays_above_the_join() + { // Cannot be pushed to either side, so it sorts the product — and must still order it correctly: // by Grp, then by r.Id descending within each L row's group. - => Assert.Equal([2, 2, 2, 4, 4, 4, 1, 1, 1, 3, 3, 3], - Col(Fresh(), "SELECT l.Id FROM L AS l, R AS r ORDER BY l.Grp, l.Id, r.Id DESC")); + QueryEngine e = Fresh(); + const string sql = "SELECT l.Id FROM L AS l, R AS r ORDER BY l.Grp, l.Id, r.Id DESC"; + Assert.True(HasSortAboveJoin(e.PlanFor(sql))); + Assert.Equal([2, 2, 2, 4, 4, 4, 1, 1, 1, 3, 3, 3], Col(e, sql)); + } [Fact] public void A_key_from_the_right_side_only_stays_above_the_join() + { // Sorting the RIGHT side would not drive the output order (the left does), so this must not be pushed. // Ordering by r.Rk descending puts R 30 (Rk 3) first, then the two Rk 1 rows, for each L row in scan order. - => Assert.Equal([30, 10, 20], - Col(Fresh(), "SELECT r.Id FROM L AS l, R AS r WHERE l.Id = 1 ORDER BY r.Rk DESC, r.Id")); + QueryEngine e = Fresh(); + const string sql = "SELECT r.Id FROM L AS l, R AS r WHERE l.Id = 1 ORDER BY r.Rk DESC, r.Id"; + Assert.True(HasSortAboveJoin(e.PlanFor(sql))); + Assert.Equal([30, 10, 20], Col(e, sql)); + } [Fact] public void An_unqualified_key_stays_above_the_join() + { // A bare name could bind to either side, and only the evaluator's resolver knows which — so it declines. // `Grp` exists only on L, so the answer matches the qualified form; what is pinned is that it is correct. - => Assert.Equal([2, 2, 2, 4, 4, 4, 1, 1, 1, 3, 3, 3], - Col(Fresh(), "SELECT l.Id FROM L AS l, R AS r ORDER BY Grp")); + QueryEngine e = Fresh(); + const string sql = "SELECT l.Id FROM L AS l, R AS r ORDER BY Grp"; + Assert.True(HasSortAboveJoin(e.PlanFor(sql))); + Assert.Equal([2, 2, 2, 4, 4, 4, 1, 1, 1, 3, 3, 3], Col(e, sql)); + } [Fact] public void A_top_over_a_pushed_sort_returns_the_same_rows() @@ -101,26 +124,24 @@ public void A_descending_pushed_sort_reverses_correctly() => Assert.Equal([1, 1, 1, 3, 3, 3, 2, 2, 2, 4, 4, 4], Col(Fresh(), "SELECT l.Id FROM L AS l, R AS r ORDER BY l.Grp DESC")); - // The correctness tests above pass whether or not the sort is pushed, so this pins that it IS: ordering the - // 679,770-row cross product to return one row took 1,363 ms, against 172 ms sorting the 91 customers and - // letting the TOP stop the join early. + // The correctness tests above pass whether or not the sort is pushed, so pin its plan position directly. [Fact] public void The_pushdown_engages_on_a_three_way_cross_join_over_northwind() { - string path = Path.Combine(Path.GetTempPath(), $"sortpdperf-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + using var temp = TemporaryDatabase.CopyOf( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "sortpdplan"); + var e = new QueryEngine(temp.Open()); - var sw = System.Diagnostics.Stopwatch.StartNew(); - int rows = e.ExecuteQuery( + const string sql = """ SELECT TOP 1 `c`.`CustomerID`, `c`.`Address`, `c`.`City`, `c`.`CompanyName` FROM `Customers` AS `c`, `Orders` AS `o`, `Employees` AS `e` ORDER BY `c`.`CustomerID` - """).Rows.Count(); - sw.Stop(); + """; + + Assert.True(HasSortBelowJoin(e.PlanFor(sql)), "expected the SortNode below a JoinNode"); + int rows = e.ExecuteQuery(sql).Rows.Count(); Assert.Equal(1, rows); - Assert.True(sw.ElapsedMilliseconds < 600, $"took {sw.ElapsedMilliseconds} ms — the sort was not pushed below the join"); } } diff --git a/test/LibRed.Engine.Tests/SqlCommentTests.cs b/test/LibRed.Engine.Tests/SqlCommentTests.cs index eb1c9f8e..de5cd5a1 100644 --- a/test/LibRed.Engine.Tests/SqlCommentTests.cs +++ b/test/LibRed.Engine.Tests/SqlCommentTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; /// SQL comments are skipped by the lexer — EF Core query tags prepend a -- tag line comment /// to the statement (and block comments can appear too). -public class SqlCommentTests +public class SqlCommentTests : TempDatabaseTest { private static QueryEngine Engine() { - string path = Path.Combine(Path.GetTempPath(), $"cmt-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "cmt-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id LONG PRIMARY KEY)"); e.ExecuteNonQuery("INSERT INTO T (Id) VALUES (1)"); return e; diff --git a/test/LibRed.Engine.Tests/SqlTransactionControlTests.cs b/test/LibRed.Engine.Tests/SqlTransactionControlTests.cs index a69ae48e..12897096 100644 --- a/test/LibRed.Engine.Tests/SqlTransactionControlTests.cs +++ b/test/LibRed.Engine.Tests/SqlTransactionControlTests.cs @@ -1,19 +1,20 @@ using System.Linq; +using System.Data.Common; using LibRed; using LibRed.Engine; +using LibRed.Data; using Xunit; namespace LibRed.Engine.Tests; // SQL BEGIN/COMMIT/ROLLBACK [TRANSACTION|WORK] drive the same transaction as the ADO API, and nest onto the // savepoint stack (Jet/DAO semantics: commit/rollback act on the innermost level). -public class SqlTransactionControlTests +public class SqlTransactionControlTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"txnctl-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "txnctl-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE t ( id LONG PRIMARY KEY )"); // autocommit, before any BEGIN return e; } @@ -73,4 +74,49 @@ public void Commit_with_no_transaction_open_throws() var e = Fresh(); Assert.Throws(() => e.ExecuteNonQuery("COMMIT")); } + + [Fact] + public void Sql_outer_transaction_and_ado_inner_transaction_share_one_controller() + { + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "txnctl-ado-"); + using var connection = new LibRedConnection($"Data Source={path}"); + connection.Open(); + using (var command = connection.CreateCommand()) + { + command.CommandText = "CREATE TABLE t (id LONG PRIMARY KEY); BEGIN TRANSACTION"; + command.ExecuteNonQuery(); + } + + using (var inner = connection.BeginTransaction()) + { + using var command = connection.CreateCommand(); + command.Transaction = inner; + command.CommandText = "INSERT INTO t (id) VALUES (1)"; + command.ExecuteNonQuery(); + inner.Commit(); + } + + using (var command = connection.CreateCommand()) + { + command.CommandText = "ROLLBACK; SELECT COUNT(*) FROM t"; + Assert.Equal(0, Convert.ToInt32(command.ExecuteScalar())); + } + } + + [Fact] + public void Sql_commit_completes_the_active_ado_handle() + { + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "txnctl-ado-"); + using var connection = new LibRedConnection($"Data Source={path}"); + connection.Open(); + using var transaction = connection.BeginTransaction(); + using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = "COMMIT TRANSACTION"; + command.ExecuteNonQuery(); + Assert.Throws(() => transaction.Commit()); + + using DbTransaction next = connection.BeginTransaction(); + next.Rollback(); + } } diff --git a/test/LibRed.Engine.Tests/StableSortTests.cs b/test/LibRed.Engine.Tests/StableSortTests.cs index 0a200245..28646457 100644 --- a/test/LibRed.Engine.Tests/StableSortTests.cs +++ b/test/LibRed.Engine.Tests/StableSortTests.cs @@ -10,13 +10,12 @@ namespace LibRed.Engine.Tests; /// Server behave this way, so an ORDER BY that does not fully disambiguate (e.g. several orders per customer, /// ordered only by customer) must return the tied rows in scan/insertion order — a List.Sort would not. /// -public class StableSortTests +public class StableSortTests : TempDatabaseTest { private static QueryEngine Seeded() { - string path = Path.Combine(Path.GetTempPath(), $"stable-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "stable-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id LONG PRIMARY KEY, K TEXT(5), Seq LONG)"); // Same key K='A' for several rows, inserted in a known Seq order; a stable ORDER BY K keeps that order. (int id, string k, int seq)[] rows = diff --git a/test/LibRed.Engine.Tests/StatementAtomicityTests.cs b/test/LibRed.Engine.Tests/StatementAtomicityTests.cs index 91cb2592..4a91b77b 100644 --- a/test/LibRed.Engine.Tests/StatementAtomicityTests.cs +++ b/test/LibRed.Engine.Tests/StatementAtomicityTests.cs @@ -1,5 +1,6 @@ using LibRed; using LibRed.Engine; +using System.Data.OleDb; using Xunit; namespace LibRed.Engine.Tests; @@ -7,13 +8,13 @@ namespace LibRed.Engine.Tests; // Every DML/DDL statement is atomic on its own, even with no user transaction open: a failure partway // through must leave the database exactly as it was before the statement ran (no half-written rows, index // entries, or catalog metadata). The engine wraps each writing statement in an implicit transaction. -public class StatementAtomicityTests +[Collection(AceCollection.Name)] +public class StatementAtomicityTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"atomic-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "atomic-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } [Fact] @@ -26,7 +27,8 @@ public void Failed_update_rolls_back_rows_it_already_changed() // Row id=1 is updated to 90 first (valid, written); id=2 would become -5, which violates the check and // throws mid-statement. Without atomicity id=1 would be left at the partially-applied 90. - Assert.ThrowsAny(() => e.ExecuteNonQuery("UPDATE t SET amt = amt - 10")); + var error = Assert.Throws(() => e.ExecuteNonQuery("UPDATE t SET amt = amt - 10")); + Assert.Contains("ck", error.Message, StringComparison.OrdinalIgnoreCase); Assert.Equal(100.0, Convert.ToDouble(e.ExecuteQuery("SELECT amt FROM t WHERE id = 1").Rows.Single()[0])); Assert.Equal(5.0, Convert.ToDouble(e.ExecuteQuery("SELECT amt FROM t WHERE id = 2").Rows.Single()[0])); @@ -42,7 +44,10 @@ public void Failed_insert_leaves_no_row_visible_to_a_scan() // Passes the primary key (id=2 is new) but duplicates the unique index on name — the row heap may be // written before the index insert rejects it, so a leaked partial row would show up in a full scan. - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO t (id, name) VALUES (2, 'x')")); + var error = Assert.Throws(() => + e.ExecuteNonQuery("INSERT INTO t (id, name) VALUES (2, 'x')")); + Assert.Equal("ux_name", error.ConstraintName, ignoreCase: true); + Assert.False(error.IsPrimaryKey); Assert.Equal(1, e.ExecuteQuery("SELECT COUNT(*) FROM t").Rows.Single()[0]); Assert.Single(e.ExecuteQuery("SELECT id FROM t").Rows); @@ -55,10 +60,97 @@ public void A_committed_statement_persists_across_a_later_failure() e.ExecuteNonQuery("CREATE TABLE t ( id LONG PRIMARY KEY, amt DOUBLE, CONSTRAINT ck CHECK (amt > 0) )"); e.ExecuteNonQuery("INSERT INTO t (id, amt) VALUES (1, 50)"); // its own implicit transaction, committed - Assert.ThrowsAny(() => e.ExecuteNonQuery("INSERT INTO t (id, amt) VALUES (2, -5)")); // rolled back + var error = Assert.Throws(() => + e.ExecuteNonQuery("INSERT INTO t (id, amt) VALUES (2, -5)")); // rolled back + Assert.Contains("ck", error.Message, StringComparison.OrdinalIgnoreCase); // The first insert's autocommit is independent of the second's rollback. Assert.Equal(1, e.ExecuteQuery("SELECT COUNT(*) FROM t").Rows.Single()[0]); Assert.Equal(50.0, Convert.ToDouble(e.ExecuteQuery("SELECT amt FROM t WHERE id = 1").Rows.Single()[0])); } + + [Fact] + public void Failed_multirow_update_rolls_back_grown_index_keys_byte_for_byte_and_ace_can_seek() + { + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "atomic-split-"); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var e = new QueryEngine(db); + e.ExecuteNonQuery( + "CREATE TABLE AtomicSplit (Id LONG PRIMARY KEY, Code TEXT(100), " + + "CONSTRAINT CK_Last CHECK (Id < 900 OR Code NOT LIKE 'expanded-*'))"); + e.ExecuteNonQuery("CREATE UNIQUE INDEX UX_AtomicSplit_Code ON AtomicSplit (Code)"); + for (int i = 1; i <= 900; i++) + e.ExecuteNonQuery($"INSERT INTO AtomicSplit (Id, Code) VALUES ({i}, 'k{i}')"); + } + byte[] before = File.ReadAllBytes(path); + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var e = new QueryEngine(db); + Assert.Throws(() => + e.ExecuteNonQuery("UPDATE AtomicSplit SET Code = 'expanded-' & Code")); + Assert.Equal(900, Convert.ToInt32(e.ExecuteQuery("SELECT COUNT(*) FROM AtomicSplit").Rows.Single()[0])); + Assert.Equal("k1", e.ExecuteQuery("SELECT Code FROM AtomicSplit WHERE Id = 1").Rows.Single()[0]); + Assert.Equal("k900", e.ExecuteQuery("SELECT Code FROM AtomicSplit WHERE Id = 900").Rows.Single()[0]); + } + + Assert.Equal(before, File.ReadAllBytes(path)); + using var connection = AceTestDatabase.Open(path); + AssertScalar(connection, "SELECT COUNT(*) FROM AtomicSplit", 900); + AssertScalar(connection, "SELECT COUNT(*) FROM AtomicSplit WHERE Code LIKE 'expanded-*'", 0); + AssertScalar(connection, "SELECT COUNT(*) FROM AtomicSplit WHERE Id = 900 AND Code = 'k900'", 1); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Failed_multirow_update_rolls_back_lval_allocation_byte_for_byte_and_ace_reads_original() + { + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "atomic-lval-"); + string original = new('A', 5000); + string allocated = new('B', 24000); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var e = new QueryEngine(db); + e.ExecuteNonQuery("CREATE TABLE AtomicLval (Id LONG PRIMARY KEY, Code LONG, M MEMO)"); + e.ExecuteNonQuery("CREATE UNIQUE INDEX UX_AtomicLval_Code ON AtomicLval (Code)"); + e.ExecuteNonQuery($"INSERT INTO AtomicLval (Id, Code, M) VALUES (1, 1, '{original}')"); + e.ExecuteNonQuery("INSERT INTO AtomicLval (Id, Code, M) VALUES (2, 2, 'second')"); + } + byte[] before = File.ReadAllBytes(path); + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var e = new QueryEngine(db); + Assert.Throws(() => + e.ExecuteNonQuery($"UPDATE AtomicLval SET Code = 99, M = '{allocated}'")); + var row = e.ExecuteQuery("SELECT Id, M FROM AtomicLval"); + Assert.Equal(2, row.Rows.Count()); + Assert.Equal(original, row.Rows.Single(r => Convert.ToInt32(r[0]) == 1)[1]); + Assert.Equal("second", row.Rows.Single(r => Convert.ToInt32(r[0]) == 2)[1]); + } + + Assert.Equal(before, File.ReadAllBytes(path)); + using var connection = AceTestDatabase.Open(path); + AssertScalar(connection, "SELECT COUNT(*) FROM AtomicLval", 2); + AssertScalar(connection, "SELECT COUNT(*) FROM AtomicLval WHERE Code = 99", 0); + Assert.Equal(original, ExecuteScalar(connection, "SELECT M FROM AtomicLval WHERE Id = 1")); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static object? ExecuteScalar(OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } + + private static void AssertScalar(OleDbConnection connection, string sql, int expected) => + Assert.Equal(expected, Convert.ToInt32(ExecuteScalar(connection, sql))); } diff --git a/test/LibRed.Engine.Tests/StatisticalAggregatesTests.cs b/test/LibRed.Engine.Tests/StatisticalAggregatesTests.cs index 15d52128..c5404a99 100644 --- a/test/LibRed.Engine.Tests/StatisticalAggregatesTests.cs +++ b/test/LibRed.Engine.Tests/StatisticalAggregatesTests.cs @@ -8,16 +8,22 @@ namespace LibRed.Engine.Tests; // {2,4,4,4,5,5,7,9} (mean 5). Sample forms divide by n-1 (NULL for a single value); population forms by n. public class StatisticalAggregatesTests { - private static QueryEngine Seeded() + private sealed class SeededDatabase : IDisposable { - string path = Path.Combine(Path.GetTempPath(), $"stat-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); - e.ExecuteNonQuery("CREATE TABLE S ( K LONG PRIMARY KEY, V DOUBLE )"); - int k = 1; - foreach (var v in new[] { 2, 4, 4, 4, 5, 5, 7, 9 }) - e.ExecuteNonQuery($"INSERT INTO S (K, V) VALUES ({k++}, {v})"); - return e; + private readonly TemporaryDatabase _temporary = TemporaryDatabase.CopyOf( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "stat-"); + + public SeededDatabase() + { + Engine = new QueryEngine(_temporary.Open()); + Engine.ExecuteNonQuery("CREATE TABLE S ( K LONG PRIMARY KEY, V DOUBLE )"); + int k = 1; + foreach (var v in new[] { 2, 4, 4, 4, 5, 5, 7, 9 }) + Engine.ExecuteNonQuery($"INSERT INTO S (K, V) VALUES ({k++}, {v})"); + } + + public QueryEngine Engine { get; } + public void Dispose() => _temporary.Dispose(); } private static object? Agg(QueryEngine e, string expr) => e.ExecuteQuery($"SELECT {expr} FROM S").Rows.Single()[0]; @@ -28,35 +34,42 @@ private static QueryEngine Seeded() [InlineData("Var(V)", 4.571428571428571)] // sample variance (32/7) [InlineData("VarP(V)", 4.0)] // population variance public void Statistical_aggregates_match_ace(string expr, double expected) - => Assert.Equal(expected, Convert.ToDouble(Agg(Seeded(), expr)), 10); + { + using var seeded = new SeededDatabase(); + Assert.Equal(expected, Convert.ToDouble(Agg(seeded.Engine, expr)), 10); + } [Theory] [InlineData("StdDev(V)", 2.138089935299395)] // "StdDev" alias of StDev (sample stddev) [InlineData("StdDevP(V)", 2.0)] // "StdDevP" alias of StDevP public void Alias_spellings_work(string expr, double expected) - => Assert.Equal(expected, Convert.ToDouble(Agg(Seeded(), expr)), 10); + { + using var seeded = new SeededDatabase(); + Assert.Equal(expected, Convert.ToDouble(Agg(seeded.Engine, expr)), 10); + } [Fact] public void Sample_forms_are_null_for_a_single_value() { - var e = Seeded(); - Assert.Null(e.ExecuteQuery("SELECT Var(V) FROM S WHERE K = 1").Rows.Single()[0]); - Assert.Null(e.ExecuteQuery("SELECT StDev(V) FROM S WHERE K = 1").Rows.Single()[0]); + using var seeded = new SeededDatabase(); + Assert.Null(seeded.Engine.ExecuteQuery("SELECT Var(V) FROM S WHERE K = 1").Rows.Single()[0]); + Assert.Null(seeded.Engine.ExecuteQuery("SELECT StDev(V) FROM S WHERE K = 1").Rows.Single()[0]); } [Fact] public void Population_forms_are_zero_for_a_single_value() { - var e = Seeded(); - Assert.Equal(0.0, Convert.ToDouble(e.ExecuteQuery("SELECT VarP(V) FROM S WHERE K = 1").Rows.Single()[0]), 10); + using var seeded = new SeededDatabase(); + Assert.Equal(0.0, Convert.ToDouble( + seeded.Engine.ExecuteQuery("SELECT VarP(V) FROM S WHERE K = 1").Rows.Single()[0]), 10); } [Fact] public void Grouped_statistical_aggregate() { - var e = Seeded(); - // two groups by parity of V; just assert it runs and returns a row per group with a numeric StDevP. - var rows = e.ExecuteQuery("SELECT V, StDevP(V) FROM S GROUP BY V ORDER BY V").Rows; - Assert.NotEmpty(rows); + using var seeded = new SeededDatabase(); + var rows = seeded.Engine.ExecuteQuery("SELECT V, StDevP(V) FROM S GROUP BY V ORDER BY V").Rows + .Select(r => (Value: Convert.ToDouble(r[0]), Deviation: Convert.ToDouble(r[1]))).ToList(); + Assert.Equal([(2d, 0d), (4d, 0d), (5d, 0d), (7d, 0d), (9d, 0d)], rows); } } diff --git a/test/LibRed.Engine.Tests/StrCompStrConvTests.cs b/test/LibRed.Engine.Tests/StrCompStrConvTests.cs index 139bfa89..56790d41 100644 --- a/test/LibRed.Engine.Tests/StrCompStrConvTests.cs +++ b/test/LibRed.Engine.Tests/StrCompStrConvTests.cs @@ -5,13 +5,12 @@ namespace LibRed.Engine.Tests; // StrComp(a, b, [compare]) and the byte-reinterpretation StrConv modes (64/128) — verified byte-identical to ACE. -public class StrCompStrConvTests +public class StrCompStrConvTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"scv-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "scv-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)"); return e; diff --git a/test/LibRed.Engine.Tests/StringComparisonTests.cs b/test/LibRed.Engine.Tests/StringComparisonTests.cs index 364bcce6..890f4ca4 100644 --- a/test/LibRed.Engine.Tests/StringComparisonTests.cs +++ b/test/LibRed.Engine.Tests/StringComparisonTests.cs @@ -8,8 +8,7 @@ public class StringComparisonTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"strcmp-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "strcmp-"); return path; } @@ -24,7 +23,7 @@ private static string Fresh() e.ExecuteNonQuery("INSERT INTO One (Id) VALUES (1)"); return e.ExecuteQuery($"SELECT IIF({expr}, 1, 0) FROM One").Rows.First()[0]; } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Access text comparison is case-insensitive and ignores trailing spaces (verified vs ACE). @@ -76,7 +75,7 @@ public void Distinct_and_group_by_are_case_insensitive() Assert.Equal(2, e.ExecuteQuery("SELECT DISTINCT City FROM C").Rows.Count()); // London*, Paris Assert.Equal(2, e.ExecuteQuery("SELECT City, COUNT(*) FROM C GROUP BY City").Rows.Count()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // A real column filter is case-insensitive: matching 'london' finds the 'London' customers. @@ -93,6 +92,6 @@ public void Column_filter_matches_regardless_of_case() Assert.True(exact > 0); Assert.Equal(exact, lower); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/StringFunctionTests.cs b/test/LibRed.Engine.Tests/StringFunctionTests.cs index 82bb243c..502e07eb 100644 --- a/test/LibRed.Engine.Tests/StringFunctionTests.cs +++ b/test/LibRed.Engine.Tests/StringFunctionTests.cs @@ -8,8 +8,7 @@ public class StringFunctionTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"strfn-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "strfn-"); return path; } @@ -24,7 +23,7 @@ private static string Fresh() e.ExecuteNonQuery("INSERT INTO One (Id) VALUES (1)"); return e.ExecuteQuery($"SELECT {expr} FROM One").Rows.First()[0]; } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -92,6 +91,6 @@ public void Left_function_and_left_join_coexist() "LEFT JOIN Orders AS o ON c.CustomerID = o.CustomerID").Rows.Count(); Assert.True(rows > 0); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/StringLiteralEscapeTests.cs b/test/LibRed.Engine.Tests/StringLiteralEscapeTests.cs index 3b170c7e..43b2135a 100644 --- a/test/LibRed.Engine.Tests/StringLiteralEscapeTests.cs +++ b/test/LibRed.Engine.Tests/StringLiteralEscapeTests.cs @@ -8,8 +8,7 @@ public class StringLiteralEscapeTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"strlit-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "strlit-"); return path; } @@ -32,6 +31,6 @@ public void Doubled_quote_is_an_escaped_quote(string literal, string expected) e.ExecuteNonQuery($"INSERT INTO S (Id, V) VALUES (1, {literal})"); Assert.Equal(expected, e.ExecuteQuery("SELECT V FROM S").Rows.First()[0]); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/SwitchFunctionTests.cs b/test/LibRed.Engine.Tests/SwitchFunctionTests.cs index 5136b74f..23032ad2 100644 --- a/test/LibRed.Engine.Tests/SwitchFunctionTests.cs +++ b/test/LibRed.Engine.Tests/SwitchFunctionTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // Access Switch(cond-1, value-1, cond-2, value-2, …): returns the value of the first true condition, NULL if none // match, and requires an even argument count. Semantics verified against ACE. Exercised via DEFAULT expressions. -public class SwitchFunctionTests +public class SwitchFunctionTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"switch-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - return new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "switch-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); } private static object? DefaultOf(string type, string def) diff --git a/test/LibRed.Engine.Tests/TopParameterTests.cs b/test/LibRed.Engine.Tests/TopParameterTests.cs index 98a3e99f..9a871677 100644 --- a/test/LibRed.Engine.Tests/TopParameterTests.cs +++ b/test/LibRed.Engine.Tests/TopParameterTests.cs @@ -8,8 +8,7 @@ public class TopParameterTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"top-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "top-"); return path; } @@ -25,7 +24,7 @@ public void Top_accepts_a_parameter() new Dictionary { ["n"] = 3 }).Rows.Count(); Assert.Equal(3, rows); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -40,7 +39,7 @@ public void Top_accepts_a_parameter_expression() new Dictionary { ["a"] = 2, ["b"] = 3 }).Rows.Count(); Assert.Equal(5, rows); // @a + @b, and the SELECT star is not swallowed by the TOP expression } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -52,7 +51,7 @@ public void Top_literal_with_star_still_parses() using var db = JetDatabase.Open(path); Assert.Equal(4, new QueryEngine(db).ExecuteQuery("SELECT TOP 4 * FROM Customers").Rows.Count()); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -65,6 +64,6 @@ public void View_with_a_parameterized_top_is_rejected() Assert.Throws(() => new QueryEngine(db).ExecuteNonQuery( "CREATE VIEW `V` AS SELECT TOP @n `CustomerID` FROM `Customers`")); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/TransactionIsolationTests.cs b/test/LibRed.Engine.Tests/TransactionIsolationTests.cs index ff1ebc9f..d3cde8fc 100644 --- a/test/LibRed.Engine.Tests/TransactionIsolationTests.cs +++ b/test/LibRed.Engine.Tests/TransactionIsolationTests.cs @@ -1,6 +1,7 @@ using System.Linq; using LibRed; using LibRed.Engine; +using System.Data.OleDb; using Xunit; namespace LibRed.Engine.Tests; @@ -11,12 +12,12 @@ namespace LibRed.Engine.Tests; // shared-store tests, each mutating inside a rolled-back transaction, from leaking into concurrent readers. // Before the deferred-write overlay, the shared write-through page cache exposed the uncommitted page and a // rolled-back "Updated" was dirty-read by other tests (see the 2026-07-24 cross-platform CI investigation). -public class TransactionIsolationTests +[Collection(AceCollection.Name)] +public class TransactionIsolationTests : TempDatabaseTest { private static string FreshDb() { - string path = Path.Combine(Path.GetTempPath(), $"txniso-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "txniso-"); return path; } @@ -27,8 +28,8 @@ private static string Contact(QueryEngine e, string id) => public void A_second_connection_does_not_see_an_uncommitted_update_and_rollback_leaks_nothing() { string path = FreshDb(); - var writerDb = JetDatabase.Open(path, readOnly: false); - var readerDb = JetDatabase.Open(path, readOnly: false); + var writerDb = TemporaryDatabase.OpenTracked(path, readOnly: false); + var readerDb = TemporaryDatabase.OpenTracked(path, readOnly: false); try { var writer = new QueryEngine(writerDb); @@ -51,15 +52,15 @@ public void A_second_connection_does_not_see_an_uncommitted_update_and_rollback_ Assert.Equal(original, Contact(writer, "ALFKI")); Assert.Equal(original, Contact(reader, "ALFKI")); } - finally { writerDb.Dispose(); readerDb.Dispose(); File.Delete(path); } + finally { writerDb.Dispose(); readerDb.Dispose(); TemporaryDatabase.Delete(path); } } [Fact] public void A_committed_update_becomes_visible_to_the_other_connection() { string path = FreshDb(); - var writerDb = JetDatabase.Open(path, readOnly: false); - var readerDb = JetDatabase.Open(path, readOnly: false); + var writerDb = TemporaryDatabase.OpenTracked(path, readOnly: false); + var readerDb = TemporaryDatabase.OpenTracked(path, readOnly: false); try { var writer = new QueryEngine(writerDb); @@ -71,15 +72,15 @@ public void A_committed_update_becomes_visible_to_the_other_connection() writer.ExecuteNonQuery("COMMIT"); Assert.Equal("Committed", Contact(reader, "ALFKI")); // now visible } - finally { writerDb.Dispose(); readerDb.Dispose(); File.Delete(path); } + finally { writerDb.Dispose(); readerDb.Dispose(); TemporaryDatabase.Delete(path); } } [Fact] public void An_uncommitted_insert_is_invisible_until_commit() { string path = FreshDb(); - var writerDb = JetDatabase.Open(path, readOnly: false); - var readerDb = JetDatabase.Open(path, readOnly: false); + var writerDb = TemporaryDatabase.OpenTracked(path, readOnly: false); + var readerDb = TemporaryDatabase.OpenTracked(path, readOnly: false); try { var writer = new QueryEngine(writerDb); @@ -99,6 +100,308 @@ int Count(QueryEngine e) => Assert.Equal(before, Count(writer)); Assert.Equal(before, Count(reader)); } - finally { writerDb.Dispose(); readerDb.Dispose(); File.Delete(path); } + finally { writerDb.Dispose(); readerDb.Dispose(); TemporaryDatabase.Delete(path); } } + + [Fact] + public void Writers_updating_different_pages_can_both_commit() + { + string path = FreshDb(); + try + { + CreateWideWriterTable(path); + AssertRowsAreOnDifferentPages(path, 1, 12); + + using var firstDb = JetDatabase.Open(path, readOnly: false); + using var secondDb = JetDatabase.Open(path, readOnly: false); + var first = new QueryEngine(firstDb); + var second = new QueryEngine(secondDb); + + first.ExecuteNonQuery("BEGIN TRANSACTION"); + second.ExecuteNonQuery("BEGIN TRANSACTION"); + first.ExecuteNonQuery("UPDATE WideWriters SET A = 'first-committed' WHERE Id = 1"); + second.ExecuteNonQuery("UPDATE WideWriters SET A = 'second-committed' WHERE Id = 12"); + + first.ExecuteNonQuery("COMMIT"); + second.ExecuteNonQuery("COMMIT"); + + Assert.Equal("first-committed", WideValue(first, 1, "A")); + Assert.Equal("second-committed", WideValue(first, 12, "A")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Writers_updating_different_rows_on_the_same_page_get_a_defined_conflict_not_a_lost_update() + { + string path = FreshDb(); + try + { + using (var setupDb = JetDatabase.Open(path, readOnly: false)) + { + var setup = new QueryEngine(setupDb); + setup.ExecuteNonQuery("CREATE TABLE SamePageWriters (Id LONG PRIMARY KEY, A TEXT(100))"); + setup.ExecuteNonQuery("INSERT INTO SamePageWriters (Id, A) VALUES (1, 'one')"); + setup.ExecuteNonQuery("INSERT INTO SamePageWriters (Id, A) VALUES (2, 'two')"); + var table = setupDb.OpenTable("SamePageWriters"); + int id = table.Definition.FindColumn("Id")!.Index; + var locations = table.Rows().WithIds().ToDictionary(r => Convert.ToInt32(r.Values[id]), r => r.Id.Page); + Assert.Equal(locations[1], locations[2]); + } + + using var firstDb = JetDatabase.Open(path, readOnly: false); + using var secondDb = JetDatabase.Open(path, readOnly: false); + var first = new QueryEngine(firstDb); + var second = new QueryEngine(secondDb); + first.ExecuteNonQuery("BEGIN TRANSACTION"); + second.ExecuteNonQuery("BEGIN TRANSACTION"); + first.ExecuteNonQuery("UPDATE SamePageWriters SET A = 'first' WHERE Id = 1"); + second.ExecuteNonQuery("UPDATE SamePageWriters SET A = 'second' WHERE Id = 2"); + + first.ExecuteNonQuery("COMMIT"); + var conflict = Assert.Throws(() => second.ExecuteNonQuery("COMMIT")); + Assert.Contains("write conflict", conflict.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(secondDb.InTransaction); + second.ExecuteNonQuery("ROLLBACK"); + + Assert.Equal("first", Scalar(first, "SELECT A FROM SamePageWriters WHERE Id = 1")); + Assert.Equal("two", Scalar(first, "SELECT A FROM SamePageWriters WHERE Id = 2")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Writers_moving_entries_in_the_same_index_leaf_get_a_defined_conflict() + { + string path = FreshDb(); + try + { + CreateWideWriterTable(path); + AssertRowsAreOnDifferentPages(path, 1, 12); + + using var firstDb = JetDatabase.Open(path, readOnly: false); + using var secondDb = JetDatabase.Open(path, readOnly: false); + var first = new QueryEngine(firstDb); + var second = new QueryEngine(secondDb); + first.ExecuteNonQuery("BEGIN TRANSACTION"); + second.ExecuteNonQuery("BEGIN TRANSACTION"); + first.ExecuteNonQuery("UPDATE WideWriters SET K = 'first-key' WHERE Id = 1"); + second.ExecuteNonQuery("UPDATE WideWriters SET K = 'second-key' WHERE Id = 12"); + + first.ExecuteNonQuery("COMMIT"); + var conflict = Assert.Throws(() => second.ExecuteNonQuery("COMMIT")); + Assert.Contains("write conflict", conflict.Message, StringComparison.OrdinalIgnoreCase); + second.ExecuteNonQuery("ROLLBACK"); + + Assert.Equal("first-key", WideValue(first, 1, "K")); + Assert.Equal("key-12", WideValue(first, 12, "K")); + Assert.Single(first.ExecuteQuery("SELECT Id FROM WideWriters WHERE K = 'first-key'").Rows); + Assert.Empty(first.ExecuteQuery("SELECT Id FROM WideWriters WHERE K = 'second-key'").Rows); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Writers_updating_the_same_row_preserve_the_first_commit_and_reject_the_stale_second_one() + { + string path = FreshDb(); + var firstDb = TemporaryDatabase.OpenTracked(path, readOnly: false); + var secondDb = TemporaryDatabase.OpenTracked(path, readOnly: false); + try + { + var first = new QueryEngine(firstDb); + var second = new QueryEngine(secondDb); + string original = Contact(first, "ALFKI"); + + first.ExecuteNonQuery("BEGIN TRANSACTION"); + second.ExecuteNonQuery("BEGIN TRANSACTION"); + first.ExecuteNonQuery("UPDATE Customers SET ContactName = 'first-wins' WHERE CustomerID = 'ALFKI'"); + second.ExecuteNonQuery("UPDATE Customers SET ContactName = 'stale-second' WHERE CustomerID = 'ALFKI'"); + + first.ExecuteNonQuery("COMMIT"); + var conflict = Assert.Throws(() => second.ExecuteNonQuery("COMMIT")); + Assert.Contains("write conflict", conflict.Message, StringComparison.OrdinalIgnoreCase); + second.ExecuteNonQuery("ROLLBACK"); + + Assert.Equal("first-wins", Contact(first, "ALFKI")); + Assert.NotEqual(original, Contact(first, "ALFKI")); + } + finally { firstDb.Dispose(); secondDb.Dispose(); TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Rolling_back_inner_disjoint_page_removes_its_baseline_so_outer_commit_does_not_false_conflict() + { + string path = FreshDb(); + try + { + CreateWideWriterTable(path); + AssertRowsAreOnDifferentPages(path, 1, 12); + using var outerDb = JetDatabase.Open(path, readOnly: false); + using var otherDb = JetDatabase.Open(path, readOnly: false); + var outer = new QueryEngine(outerDb); + var other = new QueryEngine(otherDb); + + outer.ExecuteNonQuery("BEGIN TRANSACTION"); + outer.ExecuteNonQuery("UPDATE WideWriters SET A = 'outer-page' WHERE Id = 1"); + outer.ExecuteNonQuery("BEGIN TRANSACTION"); + outer.ExecuteNonQuery("UPDATE WideWriters SET A = 'discarded-inner' WHERE Id = 12"); + outer.ExecuteNonQuery("ROLLBACK"); + + other.ExecuteNonQuery("BEGIN TRANSACTION"); + other.ExecuteNonQuery("UPDATE WideWriters SET A = 'other-page' WHERE Id = 12"); + other.ExecuteNonQuery("COMMIT"); + + outer.ExecuteNonQuery("COMMIT"); + Assert.Equal("outer-page", WideValue(outer, 1, "A")); + Assert.Equal("other-page", WideValue(outer, 12, "A")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Rolling_back_inner_change_to_outer_page_preserves_baseline_and_detects_later_conflict() + { + string path = FreshDb(); + try + { + CreateWideWriterTable(path); + using var outerDb = JetDatabase.Open(path, readOnly: false); + using var otherDb = JetDatabase.Open(path, readOnly: false); + var outer = new QueryEngine(outerDb); + var other = new QueryEngine(otherDb); + + outer.ExecuteNonQuery("BEGIN TRANSACTION"); + outer.ExecuteNonQuery("UPDATE WideWriters SET A = 'outer-value' WHERE Id = 1"); + outer.ExecuteNonQuery("BEGIN TRANSACTION"); + outer.ExecuteNonQuery("UPDATE WideWriters SET B = 'discarded-inner' WHERE Id = 1"); + outer.ExecuteNonQuery("ROLLBACK"); + + other.ExecuteNonQuery("BEGIN TRANSACTION"); + other.ExecuteNonQuery("UPDATE WideWriters SET C = 'other-wins' WHERE Id = 1"); + other.ExecuteNonQuery("COMMIT"); + + var conflict = Assert.Throws(() => outer.ExecuteNonQuery("COMMIT")); + Assert.Contains("write conflict", conflict.Message, StringComparison.OrdinalIgnoreCase); + outer.ExecuteNonQuery("ROLLBACK"); + Assert.Equal("other-wins", WideValue(other, 1, "C")); + Assert.NotEqual("outer-value", WideValue(other, 1, "A")); + Assert.NotEqual("discarded-inner", WideValue(other, 1, "B")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Concurrent_catalog_allocations_conflict_then_retry_without_losing_either_committed_table() + { + string path = FreshDb(); + try + { + using (var firstDb = JetDatabase.Open(path, readOnly: false)) + using (var secondDb = JetDatabase.Open(path, readOnly: false)) + { + var first = new QueryEngine(firstDb); + var second = new QueryEngine(secondDb); + first.ExecuteNonQuery("BEGIN TRANSACTION"); + second.ExecuteNonQuery("BEGIN TRANSACTION"); + first.ExecuteNonQuery("CREATE TABLE FirstSchema (Id LONG PRIMARY KEY)"); + second.ExecuteNonQuery("CREATE TABLE StaleSchema (Id LONG PRIMARY KEY)"); + + first.ExecuteNonQuery("COMMIT"); + var conflict = Assert.Throws(() => second.ExecuteNonQuery("COMMIT")); + Assert.Contains("write conflict", conflict.Message, StringComparison.OrdinalIgnoreCase); + second.ExecuteNonQuery("ROLLBACK"); + + second.ExecuteNonQuery("BEGIN TRANSACTION"); + second.ExecuteNonQuery("CREATE TABLE SecondSchema (Id LONG PRIMARY KEY)"); + second.ExecuteNonQuery("INSERT INTO SecondSchema (Id) VALUES (2)"); + second.ExecuteNonQuery("COMMIT"); + + first.ExecuteNonQuery("INSERT INTO FirstSchema (Id) VALUES (1)"); + Assert.Null(firstDb.Catalog.FindTable("StaleSchema")); + Assert.NotNull(firstDb.Catalog.FindTable("FirstSchema")); + Assert.NotNull(firstDb.Catalog.FindTable("SecondSchema")); + } + + using var ace = AceTestDatabase.Open(path); + using (var firstCount = ace.CreateCommand()) + { + firstCount.CommandText = "SELECT COUNT(*) FROM FirstSchema"; + Assert.Equal(1, Convert.ToInt32(firstCount.ExecuteScalar())); + } + using (var secondCount = ace.CreateCommand()) + { + secondCount.CommandText = "SELECT COUNT(*) FROM SecondSchema"; + Assert.Equal(1, Convert.ToInt32(secondCount.ExecuteScalar())); + } + using var stale = ace.CreateCommand(); + stale.CommandText = "SELECT COUNT(*) FROM StaleSchema"; + Assert.Throws(() => stale.ExecuteScalar()); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Repeated_same_page_conflicts_can_be_rolled_back_and_retried_without_poisoning_either_connection() + { + string path = FreshDb(); + try + { + using var firstDb = JetDatabase.Open(path, readOnly: false); + using var secondDb = JetDatabase.Open(path, readOnly: false); + var first = new QueryEngine(firstDb); + var second = new QueryEngine(secondDb); + + for (int cycle = 1; cycle <= 12; cycle++) + { + string winner = $"winner-{cycle}"; + string stale = $"stale-{cycle}"; + string retry = $"retry-{cycle}"; + first.ExecuteNonQuery("BEGIN TRANSACTION"); + second.ExecuteNonQuery("BEGIN TRANSACTION"); + first.ExecuteNonQuery($"UPDATE Customers SET ContactName = '{winner}' WHERE CustomerID = 'ALFKI'"); + second.ExecuteNonQuery($"UPDATE Customers SET ContactName = '{stale}' WHERE CustomerID = 'ALFKI'"); + + first.ExecuteNonQuery("COMMIT"); + var conflict = Assert.Throws(() => second.ExecuteNonQuery("COMMIT")); + Assert.Contains("write conflict", conflict.Message, StringComparison.OrdinalIgnoreCase); + second.ExecuteNonQuery("ROLLBACK"); + Assert.Equal(winner, Contact(second, "ALFKI")); + + second.ExecuteNonQuery("BEGIN TRANSACTION"); + second.ExecuteNonQuery($"UPDATE Customers SET ContactName = '{retry}' WHERE CustomerID = 'ALFKI'"); + second.ExecuteNonQuery("COMMIT"); + Assert.Equal(retry, Contact(first, "ALFKI")); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void CreateWideWriterTable(string path) + { + using var db = JetDatabase.Open(path, readOnly: false); + var e = new QueryEngine(db); + e.ExecuteNonQuery( + "CREATE TABLE WideWriters (Id LONG PRIMARY KEY, K TEXT(40), A TEXT(255), B TEXT(255), " + + "C TEXT(255), D TEXT(255), E TEXT(255), F TEXT(255), G TEXT(255))"); + e.ExecuteNonQuery("CREATE UNIQUE INDEX UX_WideWriters_K ON WideWriters (K)"); + string wide = new('x', 240); + for (int i = 1; i <= 12; i++) + e.ExecuteNonQuery( + $"INSERT INTO WideWriters (Id,K,A,B,C,D,E,F,G) VALUES ({i},'key-{i}','{wide}','{wide}','{wide}','{wide}','{wide}','{wide}','{wide}')"); + } + + private static void AssertRowsAreOnDifferentPages(string path, int first, int second) + { + using var db = JetDatabase.Open(path); + var table = db.OpenTable("WideWriters"); + int id = table.Definition.FindColumn("Id")!.Index; + var pages = table.Rows().WithIds().ToDictionary(r => Convert.ToInt32(r.Values[id]), r => r.Id.Page); + Assert.NotEqual(pages[first], pages[second]); + } + + private static object? WideValue(QueryEngine e, int id, string column) => + Scalar(e, $"SELECT {column} FROM WideWriters WHERE Id = {id}"); + + private static object? Scalar(QueryEngine e, string sql) => e.ExecuteQuery(sql).Rows.Single()[0]; } diff --git a/test/LibRed.Engine.Tests/TransactionalDdlRollbackAccessTests.cs b/test/LibRed.Engine.Tests/TransactionalDdlRollbackAccessTests.cs new file mode 100644 index 00000000..f272560b --- /dev/null +++ b/test/LibRed.Engine.Tests/TransactionalDdlRollbackAccessTests.cs @@ -0,0 +1,166 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Catalog; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +[Collection(AceCollection.Name)] +public class TransactionalDdlRollbackAccessTests +{ + [Fact] + public void Full_rollback_removes_created_index_view_table_and_column_alter_byte_for_byte() + { + string path = Fresh("ddl-create-rollback-"); + try + { + CreateBaseline(path); + byte[] before = File.ReadAllBytes(path); + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var e = new QueryEngine(db); + e.ExecuteNonQuery("BEGIN TRANSACTION"); + e.ExecuteNonQuery("CREATE UNIQUE INDEX UX_DdlTxn_Code ON DdlTxn (Code)"); + e.ExecuteNonQuery("CREATE VIEW DdlTxnView AS SELECT Id, V FROM DdlTxn"); + e.ExecuteNonQuery("CREATE TABLE TransientDdl (Id LONG PRIMARY KEY)"); + e.ExecuteNonQuery("ALTER TABLE DdlTxn ALTER COLUMN V TEXT(80)"); + + TableDef changed = db.Catalog.FindTable("DdlTxn")!; + Assert.Contains(changed.Indexes, i => i.Name == "UX_DdlTxn_Code"); + Assert.Equal(160, changed.FindColumn("V")!.Length); + Assert.NotNull(db.Catalog.FindTable("TransientDdl")); + Assert.Single(e.ExecuteQuery("SELECT Id FROM DdlTxnView").Rows); + + e.ExecuteNonQuery("ROLLBACK"); + TableDef restored = db.Catalog.FindTable("DdlTxn")!; + Assert.DoesNotContain(restored.Indexes, i => i.Name == "UX_DdlTxn_Code"); + Assert.Equal(40, restored.FindColumn("V")!.Length); + Assert.Null(db.Catalog.FindTable("TransientDdl")); + Assert.Throws(() => e.ExecuteQuery("SELECT Id FROM DdlTxnView")); + } + + Assert.Equal(before, File.ReadAllBytes(path)); + using var connection = AceTestDatabase.Open(path); + Execute(connection, "INSERT INTO DdlTxn (Id, Code, N, V) VALUES (2, 10, 7, 'duplicate allowed')"); + AssertScalar(connection, "SELECT COUNT(*) FROM DdlTxn WHERE Code = 10", 2); + Assert.Throws(() => ExecuteScalar(connection, "SELECT * FROM DdlTxnView")); + Assert.Throws(() => ExecuteScalar(connection, "SELECT * FROM TransientDdl")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Full_rollback_restores_dropped_objects_and_rebuilt_column_byte_for_byte() + { + string path = Fresh("ddl-drop-rollback-"); + try + { + CreateBaseline(path, withObjects: true); + byte[] before = File.ReadAllBytes(path); + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var e = new QueryEngine(db); + e.ExecuteNonQuery("BEGIN TRANSACTION"); + e.ExecuteNonQuery("DROP INDEX UX_DdlTxn_Code ON DdlTxn"); + e.ExecuteNonQuery("DROP VIEW DdlTxnView"); + e.ExecuteNonQuery("ALTER TABLE DdlTxn ALTER COLUMN N DOUBLE"); + e.ExecuteNonQuery("DROP TABLE KeptTable"); + + Assert.DoesNotContain(db.Catalog.FindTable("DdlTxn")!.Indexes, i => i.Name == "UX_DdlTxn_Code"); + Assert.Equal(JetDataType.Double, db.Catalog.FindTable("DdlTxn")!.FindColumn("N")!.Type); + Assert.Null(db.Catalog.FindTable("KeptTable")); + Assert.Throws(() => e.ExecuteQuery("SELECT Id FROM DdlTxnView")); + + e.ExecuteNonQuery("ROLLBACK"); + TableDef restored = db.Catalog.FindTable("DdlTxn")!; + Assert.Contains(restored.Indexes, i => i.Name == "UX_DdlTxn_Code"); + Assert.Equal(JetDataType.Int32, restored.FindColumn("N")!.Type); + Assert.NotNull(db.Catalog.FindTable("KeptTable")); + Assert.Single(e.ExecuteQuery("SELECT Id FROM DdlTxnView").Rows); + } + + Assert.Equal(before, File.ReadAllBytes(path)); + using var connection = AceTestDatabase.Open(path); + AssertScalar(connection, "SELECT COUNT(*) FROM DdlTxnView", 1); + AssertScalar(connection, "SELECT COUNT(*) FROM KeptTable", 1); + Assert.Throws(() => + Execute(connection, "INSERT INTO DdlTxn (Id, Code, N, V) VALUES (2, 10, 7, 'duplicate')")); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Inner_ddl_rollback_restores_outer_created_objects_which_then_commit_for_ace() + { + string path = Fresh("ddl-savepoint-"); + try + { + CreateBaseline(path); + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var e = new QueryEngine(db); + e.ExecuteNonQuery("BEGIN TRANSACTION"); + e.ExecuteNonQuery("CREATE UNIQUE INDEX UX_DdlTxn_Code ON DdlTxn (Code)"); + e.ExecuteNonQuery("CREATE VIEW DdlTxnView AS SELECT Id, V FROM DdlTxn"); + + e.ExecuteNonQuery("BEGIN TRANSACTION"); + e.ExecuteNonQuery("DROP INDEX UX_DdlTxn_Code ON DdlTxn"); + e.ExecuteNonQuery("DROP VIEW DdlTxnView"); + e.ExecuteNonQuery("ALTER TABLE DdlTxn ALTER COLUMN N DOUBLE"); + e.ExecuteNonQuery("CREATE TABLE InnerOnly (Id LONG PRIMARY KEY)"); + e.ExecuteNonQuery("ROLLBACK"); + + TableDef restoredOuter = db.Catalog.FindTable("DdlTxn")!; + Assert.Contains(restoredOuter.Indexes, i => i.Name == "UX_DdlTxn_Code"); + Assert.Equal(JetDataType.Int32, restoredOuter.FindColumn("N")!.Type); + Assert.Null(db.Catalog.FindTable("InnerOnly")); + Assert.Single(e.ExecuteQuery("SELECT Id FROM DdlTxnView").Rows); + + e.ExecuteNonQuery("COMMIT"); + } + + using var connection = AceTestDatabase.Open(path); + AssertScalar(connection, "SELECT COUNT(*) FROM DdlTxnView", 1); + Assert.Throws(() => ExecuteScalar(connection, "SELECT * FROM InnerOnly")); + Assert.Throws(() => + Execute(connection, "INSERT INTO DdlTxn (Id, Code, N, V) VALUES (2, 10, 7, 'duplicate')")); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static string Fresh(string prefix) => + TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), prefix); + + private static void CreateBaseline(string path, bool withObjects = false) + { + using var db = JetDatabase.Open(path, readOnly: false); + var e = new QueryEngine(db); + e.ExecuteNonQuery("CREATE TABLE DdlTxn (Id LONG PRIMARY KEY, Code LONG, N LONG, V TEXT(20))"); + e.ExecuteNonQuery("INSERT INTO DdlTxn (Id, Code, N, V) VALUES (1, 10, 42, 'original')"); + if (!withObjects) return; + e.ExecuteNonQuery("CREATE UNIQUE INDEX UX_DdlTxn_Code ON DdlTxn (Code)"); + e.ExecuteNonQuery("CREATE VIEW DdlTxnView AS SELECT Id, V FROM DdlTxn"); + e.ExecuteNonQuery("CREATE TABLE KeptTable (Id LONG PRIMARY KEY)"); + e.ExecuteNonQuery("INSERT INTO KeptTable (Id) VALUES (1)"); + } + + private static object? ExecuteScalar(OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } + + private static void Execute(OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + private static void AssertScalar(OleDbConnection connection, string sql, int expected) => + Assert.Equal(expected, Convert.ToInt32(ExecuteScalar(connection, sql))); +} diff --git a/test/LibRed.Engine.Tests/TrimFunctionsTests.cs b/test/LibRed.Engine.Tests/TrimFunctionsTests.cs index a320864d..c34e6474 100644 --- a/test/LibRed.Engine.Tests/TrimFunctionsTests.cs +++ b/test/LibRed.Engine.Tests/TrimFunctionsTests.cs @@ -6,13 +6,12 @@ namespace LibRed.Engine.Tests; // Trim/LTrim/RTrim are single-argument and remove ONLY spaces (not tabs or other whitespace, and no trim-char // parameter) — verified vs ACE. NULL-propagating. -public class TrimFunctionsTests +public class TrimFunctionsTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"trim-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "trim-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T ( K LONG PRIMARY KEY )"); e.ExecuteNonQuery("INSERT INTO T (K) VALUES (1)"); return e; diff --git a/test/LibRed.Engine.Tests/TypeAliasTests.cs b/test/LibRed.Engine.Tests/TypeAliasTests.cs index 69461b09..aeac246f 100644 --- a/test/LibRed.Engine.Tests/TypeAliasTests.cs +++ b/test/LibRed.Engine.Tests/TypeAliasTests.cs @@ -10,13 +10,12 @@ namespace LibRed.Engine.Tests; /// CREATE TABLE type-name aliases, mapped to the on-disk storage ACE itself produces (audited against the /// ACE engine: it accepts these names and folds them onto Jet's base types, without a file-format upgrade). /// -public class TypeAliasTests +public class TypeAliasTests : TempDatabaseTest { private static ColumnDef Column(string sqlType) { - string path = Path.Combine(Path.GetTempPath(), $"alias-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "alias-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery($"CREATE TABLE T (C {sqlType})"); return e.Database.OpenTable("T").Definition.Columns.First(c => c.Name == "C"); } diff --git a/test/LibRed.Engine.Tests/UncorrelatedSubqueryHoistingTests.cs b/test/LibRed.Engine.Tests/UncorrelatedSubqueryHoistingTests.cs index 645b4c28..8ff379ef 100644 --- a/test/LibRed.Engine.Tests/UncorrelatedSubqueryHoistingTests.cs +++ b/test/LibRed.Engine.Tests/UncorrelatedSubqueryHoistingTests.cs @@ -8,13 +8,12 @@ namespace LibRed.Engine.Tests; // row. These pin the semantics: hoisting must never change an answer, and must NOT engage where the result does // depend on the outer row — including when the dependence is written as a BARE column name, which only the // evaluator's own resolver can settle. -public class UncorrelatedSubqueryHoistingTests +public class UncorrelatedSubqueryHoistingTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"hoist-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "hoist-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); // Extra exists ONLY on O, so a bare `Extra` inside a subquery over P must resolve outward. e.ExecuteNonQuery("CREATE TABLE O ( Id LONG PRIMARY KEY, K LONG, Extra LONG )"); diff --git a/test/LibRed.Engine.Tests/UnionDerivedTableTests.cs b/test/LibRed.Engine.Tests/UnionDerivedTableTests.cs index 8f32fd68..cec753a5 100644 --- a/test/LibRed.Engine.Tests/UnionDerivedTableTests.cs +++ b/test/LibRed.Engine.Tests/UnionDerivedTableTests.cs @@ -18,8 +18,7 @@ public class UnionDerivedTableTests private static string Fresh() { - string p = Path.Combine(Path.GetTempPath(), $"union-derived-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), p); + string p = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "union-derived-"); return p; } @@ -34,7 +33,7 @@ public void Union_in_a_derived_table_is_queryable() "SELECT u.City FROM (SELECT City FROM Customers UNION SELECT City FROM Suppliers) AS u"); Assert.True(rs.Rows.Count() > 0); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } [Fact] @@ -58,6 +57,6 @@ public void Create_view_over_a_union_derived_table_round_trips() Assert.Equal(viaBase, viaView); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/UnionTests.cs b/test/LibRed.Engine.Tests/UnionTests.cs index ca8db7c9..a28e48a0 100644 --- a/test/LibRed.Engine.Tests/UnionTests.cs +++ b/test/LibRed.Engine.Tests/UnionTests.cs @@ -58,7 +58,6 @@ public void Intersect_keeps_rows_in_both() "INTERSECT " + "SELECT CustomerID, City FROM Customers WHERE City = 'London' OR City = 'Madrid'", out _); - Assert.NotEmpty(rows); Assert.All(rows, r => Assert.Equal("London", r[1])); // only the shared London rows survive Assert.Equal(6, rows.Count); } diff --git a/test/LibRed.Engine.Tests/UniqueIndexEnforcementTests.cs b/test/LibRed.Engine.Tests/UniqueIndexEnforcementTests.cs index 270de24d..a124d6e3 100644 --- a/test/LibRed.Engine.Tests/UniqueIndexEnforcementTests.cs +++ b/test/LibRed.Engine.Tests/UniqueIndexEnforcementTests.cs @@ -7,13 +7,12 @@ namespace LibRed.Engine.Tests; // UNIQUE / PRIMARY index uniqueness is enforced on insert: a duplicate non-null key is rejected, but Jet // treats NULLs as distinct so a unique index allows multiple nulls (both verified vs ACE). Dropping the // index lifts the constraint. -public class UniqueIndexEnforcementTests +public class UniqueIndexEnforcementTests : TempDatabaseTest { private static QueryEngine Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"uq-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); - var e = new QueryEngine(JetDatabase.Open(path, readOnly: false)); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "uq-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); e.ExecuteNonQuery("CREATE TABLE T (Id long PRIMARY KEY, Code long)"); e.ExecuteNonQuery("CREATE UNIQUE INDEX UX_Code ON T (Code)"); return e; diff --git a/test/LibRed.Engine.Tests/UpdateTests.cs b/test/LibRed.Engine.Tests/UpdateTests.cs index 81498bbd..3b5e618c 100644 --- a/test/LibRed.Engine.Tests/UpdateTests.cs +++ b/test/LibRed.Engine.Tests/UpdateTests.cs @@ -8,8 +8,7 @@ public class UpdateTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"update-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "update-"); return path; } @@ -45,7 +44,7 @@ public void Update_sets_values_with_where_and_current_value_expressions() Assert.Equal(3, Convert.ToInt32(e.ExecuteQuery("SELECT @@ROWCOUNT").Rows.Single()[0])); Assert.All(e.ExecuteQuery("SELECT N FROM T").Rows, row => Assert.Equal(0, Convert.ToInt32(row[0]))); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Growing a row so it no longer fits its page relocates it (Access's overflow-forwarding: the slot @@ -71,7 +70,7 @@ public void Update_that_grows_a_row_past_its_page_relocates_it() Assert.Equal(big, e.ExecuteQuery("SELECT A FROM T WHERE Id = 3").Rows.Single()[0]); // the grown row Assert.Equal(mid, e.ExecuteQuery("SELECT A FROM T WHERE Id = 4").Rows.Single()[0]); // a neighbour, untouched } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Updating an indexed column moves its index entry (old key removed, new key added), so a seek by the @@ -98,6 +97,6 @@ public void Update_of_an_indexed_column_moves_the_index_entry() Assert.Equal(1, e.ExecuteNonQuery("UPDATE T SET Id = 99, N = 990 WHERE Id = 20")); Assert.Equal(990, Convert.ToInt32(e.ExecuteQuery("SELECT N FROM T WHERE Id = 99").Rows.Single()[0])); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Engine.Tests/WideTableTests.cs b/test/LibRed.Engine.Tests/WideTableTests.cs index db9e400b..022e4c0e 100644 --- a/test/LibRed.Engine.Tests/WideTableTests.cs +++ b/test/LibRed.Engine.Tests/WideTableTests.cs @@ -9,8 +9,7 @@ public class WideTableTests { private static string Fresh() { - string path = Path.Combine(Path.GetTempPath(), $"wide-{Guid.NewGuid():N}.accdb"); - File.Copy(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), path); + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "wide-"); return path; } @@ -45,7 +44,7 @@ public void Create_table_with_a_multi_page_definition() Assert.Equal("z", row[1]); } } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } // Jet/ACE caps a table at 255 columns. LibRed rejects a 256-column table up front rather than writing @@ -64,6 +63,6 @@ public void Create_table_beyond_255_columns_is_rejected() var ex = Assert.Throws(() => new QueryEngine(db).ExecuteNonQuery(ddl.ToString())); Assert.Contains("255", ex.Message); } - finally { try { File.Delete(path); } catch (IOException) { } } + finally { TemporaryDatabase.Delete(path); } } } diff --git a/test/LibRed.Shared/AceTestDatabase.cs b/test/LibRed.Shared/AceTestDatabase.cs new file mode 100644 index 00000000..d8bdacf6 --- /dev/null +++ b/test/LibRed.Shared/AceTestDatabase.cs @@ -0,0 +1,43 @@ +// Explicit usings — see the note in TemporaryDatabase.cs. +using System; +using System.Data.OleDb; +using System.Threading; + +namespace LibRed.Tests.Shared; + +/// Opens test databases through an installed ACE OLE DB provider with consistent retry behavior. +internal static class AceTestDatabase +{ + private static readonly string[] Providers = ["Microsoft.ACE.OLEDB.16.0", "Microsoft.ACE.OLEDB.12.0"]; + + public static OleDbConnection Open(string path, string? password = null, int attempts = 12) + { + ArgumentException.ThrowIfNullOrEmpty(path); + if (attempts < 1) throw new ArgumentOutOfRangeException(nameof(attempts)); + + Exception? last = null; + for (int attempt = 0; attempt < attempts; attempt++) + { + foreach (string provider in Providers) + { + try + { + string passwordPart = password is null ? "" : $"Jet OLEDB:Database Password={password};"; + var connection = new OleDbConnection( + $"Provider={provider};Data Source={path};{passwordPart}OLE DB Services=-4;"); + connection.Open(); + return connection; + } + catch (Exception ex) when (ex is OleDbException or InvalidOperationException) + { + last = ex; + } + } + + if (attempt + 1 < attempts) + Thread.Sleep(40); + } + + throw new InvalidOperationException("No Microsoft ACE OLE DB provider could open the test database.", last); + } +} diff --git a/test/LibRed.Shared/TempDatabaseTest.cs b/test/LibRed.Shared/TempDatabaseTest.cs new file mode 100644 index 00000000..0d374f7b --- /dev/null +++ b/test/LibRed.Shared/TempDatabaseTest.cs @@ -0,0 +1,26 @@ +// Explicit usings — see the note in TemporaryDatabase.cs. +using System; + +namespace LibRed.Tests.Shared; + +/// +/// Base class for tests that copy a database into %TEMP%. xunit builds a fresh instance of the test +/// class for every test and disposes it when that test ends, which is the only per-test hook available — +/// TestContext identifies the running test but offers no place to register a disposable. So this turns +/// "the test finished" into "release what it copied". +/// +/// +/// Inherit this from any class whose helpers call or +/// without keeping the handle — the static Fresh() shape that +/// returns only a QueryEngine, where the test body has neither a path to delete nor a database to +/// close. A class that already scopes its own copies with using does not need it. Without it the copies +/// survive until the process exits, which is fine for a single run and 22 GB of Northwind copies over many. +/// +public abstract class TempDatabaseTest : IDisposable +{ + public virtual void Dispose() + { + TemporaryDatabase.ReleaseCurrentTest(); + GC.SuppressFinalize(this); + } +} diff --git a/test/LibRed.Shared/TemporaryDatabase.cs b/test/LibRed.Shared/TemporaryDatabase.cs new file mode 100644 index 00000000..b89af1aa --- /dev/null +++ b/test/LibRed.Shared/TemporaryDatabase.cs @@ -0,0 +1,180 @@ +// Explicit usings, not implicit ones: this file has been globbed wholesale into projects that build with +// ImplicitUsings disabled, and the cost of being defensive here is two lines. +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using System.Threading; +using LibRed; +using Xunit; + +namespace LibRed.Tests.Shared; + +/// Owns a test database copy, its optional LibRed handle, and deterministic cleanup. +public sealed class TemporaryDatabase : IDisposable +{ + private static readonly ConcurrentDictionary TrackedPaths = + new(StringComparer.OrdinalIgnoreCase); + + // Databases opened through OpenTracked, which the caller never disposes (a static Fresh()-style helper + // that hands back only a QueryEngine has nowhere to put the handle). Windows will not delete a file with + // an open handle, so without this the copy survives every cleanup path and leaks permanently. + private static readonly ConcurrentBag TrackedDatabases = []; + + // Per-test buckets, so a copy is released when its test ends rather than at process exit. xunit gives a + // per-test identity (TestContext.Current.Test) but no disposal hook, so the release is driven by the test + // class's own Dispose — see TempDatabaseTest. Work outside a test (a fixture, a static initializer) finds + // no current test and falls back to the process-exit sweep. + private static readonly ConcurrentDictionary PerTest = []; + + private sealed class TestResources + { + public ConcurrentBag Databases { get; } = []; + public ConcurrentDictionary Paths { get; } = new(StringComparer.OrdinalIgnoreCase); + } + + private static TestResources? CurrentTest => + TestContext.Current.Test is object test ? PerTest.GetOrAdd(test, _ => new TestResources()) : null; + + /// Closes and deletes everything the currently running test copied or opened. Called from + /// , which xunit runs after each test. + public static void ReleaseCurrentTest() + { + if (TestContext.Current.Test is not object test) return; + if (!PerTest.TryRemove(test, out TestResources? resources)) return; + + // Close before deleting: Windows will not delete a file that still has an open handle. + foreach (JetDatabase database in resources.Databases) + try { database.Dispose(); } catch (Exception) { /* already closed, or the test left it faulted */ } + + foreach (string path in resources.Paths.Keys) + Delete(path); + } + + private bool _preserve; + private JetDatabase? _database; + + private TemporaryDatabase(string path) => Path = path; + + public string Path { get; } + + public JetDatabase Database => _database + ?? throw new InvalidOperationException("The temporary database has not been opened."); + + public static TemporaryDatabase CopyOf(string source, string prefix) + { + string path = CopyPath(source, prefix); + return new TemporaryDatabase(path); + } + + /// Creates and tracks a database copy for legacy helpers that return a live object and therefore + /// cannot hand a disposable lease back to the caller. Prefer in ordinary test bodies. + public static string CopyPath(string source, string prefix, bool overwrite = false) + { + string path = NewPath(prefix, System.IO.Path.GetExtension(source)); + File.Copy(source, path, overwrite); + Track(path); + return path; + } + + /// Opens a tracked copy and keeps the handle, for the static Fresh()-style helpers that + /// return only a QueryEngine and so have nowhere to keep the database. The handle is closed and the + /// file deleted by . Prefer + + /// in a using where the test body can hold the lease — this exists so an abandoned handle leaks for + /// the length of the run rather than forever. + public static JetDatabase OpenTracked(string path, bool readOnly = false, string? password = null) + { + JetDatabase database = JetDatabase.Open(path, readOnly, password); + Track(path); + // Per-test bucket AND the process-wide bag, exactly as Track does for paths: the bucket closes the + // handle when the test ends, the bag is the backstop for a class that has not opted into + // TempDatabaseTest — without it that handle would never close and its file could never be deleted. + // Disposing a JetDatabase twice is a no-op, so the overlap is free. + CurrentTest?.Databases.Add(database); + TrackedDatabases.Add(database); + return database; + } + + /// Reserves and tracks a unique, currently nonexistent path for a database creator. + public static string CreatePath(string prefix, string extension = ".accdb") + { + string path = NewPath(prefix, extension); + Track(path); + return path; + } + + /// Best-effort deletion, for the finally blocks that clean a test up. It deliberately + /// never throws: a `finally` that throws REPLACES the assertion failure that is the real result of the + /// test, turning "expected 4, got 3" into "the file was locked". A copy that resists deletion stays + /// tracked and is swept at process exit instead. + public static void Delete(string path) + { + if (!File.Exists(path)) + { + TrackedPaths.TryRemove(path, out _); + return; + } + + for (var attempt = 0; attempt < 4; attempt++) + { + try + { + File.Delete(path); + TrackedPaths.TryRemove(path, out _); + return; + } + catch (IOException) + { + Thread.Sleep(25 * (attempt + 1)); + } + } + } + + public JetDatabase Open(bool readOnly = false, string? password = null) + { + if (_database is not null) throw new InvalidOperationException("The temporary database is already open."); + return _database = JetDatabase.Open(Path, readOnly, password); + } + + /// Leaves the file behind for post-failure inspection and returns its path. + public string Preserve() + { + _preserve = true; + return Path; + } + + public void Dispose() + { + _database?.Dispose(); + _database = null; + if (_preserve) TrackedPaths.TryRemove(Path, out _); + else Delete(Path); // best-effort by contract — see Delete + } + + /// Records a path against the running test when there is one (released at the end of that test) + /// and always against the process-wide set, which is the backstop for anything the per-test release + /// misses. + private static void Track(string path) + { + CurrentTest?.Paths.TryAdd(path, 0); + TrackedPaths.TryAdd(path, 0); + } + + private static string NewPath(string prefix, string extension) => System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"{prefix.TrimEnd('-')}-{Guid.NewGuid():N}{extension}"); + + [ModuleInitializer] + internal static void RegisterProcessCleanup() + => AppDomain.CurrentDomain.ProcessExit += (_, _) => + { + // Close first, delete second: a file with a live handle cannot be deleted on Windows, so skipping + // this leaves every OpenTracked copy behind — which is how ~22 GB of Northwind copies once + // accumulated in %TEMP%. + foreach (JetDatabase database in TrackedDatabases) + try { database.Dispose(); } catch (Exception) { /* already closed or mid-fault */ } + + foreach (string path in TrackedPaths.Keys) + Delete(path); // best-effort; process exit cannot report a failure anyway + }; +} From 50ee96db40f258e477de8f284535b2e9c4251b18 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 17:25:17 +0800 Subject: [PATCH 04/48] LibRed: the General (v1) text collation, and the complex-column system tables Access 2010 added a second "General" sort order, and the collation version byte selects a genuinely different weight table rather than being metadata: the same values indexed in a v0 and a v1 database differ in all 28 samples. v1's primaries are the Windows NLS (Script Member, Alphabetic Weight) pair verbatim - "apple" is 0E02 0E7E 0E7E 0E48 0E21 - where General Legacy compacts the same ordering into one byte per character. That is why v1 can be derived from a published table while v0 has to be measured. Also records what ACE needs MSysComplexColumns for, which is its own feature rather than part of the collation work. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Ado/LibRedConnection.cs | 7 +- src/LibRed/LibRed.Core/Catalog/Collation.cs | 11 +- src/LibRed/LibRed.Core/JetDatabase.cs | 14 +- src/LibRed/LibRed.Core/LibRed.Core.csproj | 7 + .../LibRed.Core/Resources/SortKeyTableV1.bin | Bin 0 -> 15970 bytes .../LibRed.Core/Storage/DatabaseCreator.cs | 147 ++++++- .../LibRed.Core/Storage/IndexKeyEncoder.cs | 17 +- .../LibRed.Core/Storage/JetTextCollation.cs | 30 +- .../LibRed.Core/Storage/JetTextCollationV1.cs | 210 ++++++++++ .../LibRed.Core/Storage/TableCreator.cs | 6 +- .../Storage/Internal/LibRedDatabaseCreator.cs | 2 +- src/LibRed/README.md | 11 +- src/LibRed/docs/format/page-00-database.md | 32 ++ .../docs/format/page-03-04-index-btree.md | 61 +++ src/LibRed/docs/format/system-catalog.md | 33 ++ .../AceDdlOnLibRedDatabaseProbeTest.cs | 299 ++++++++++++++ test/LibRed.Core.Tests/CollationTests.cs | 31 +- .../CollationVersionDiffProbeTest.cs | 106 +++++ .../ComplexSystemTableLayoutProbeTest.cs | 77 ++++ .../DaoDatabaseCreationProbeTest.cs | 150 +++++++ .../DaoPageLayoutProbeTest.cs | 90 ++++ .../GeneralV1CollationAccessTests.cs | 164 ++++++++ .../GeneralV1CollationTests.cs | 129 ++++++ .../Latin1SymbolCollationAccessTests.cs | 90 ++++ .../SortKeyComparisonProbeTest.cs | 383 ++++++++++++++++++ tools/sortkey-table/generate.ps1 | 129 ++++++ 26 files changed, 2193 insertions(+), 43 deletions(-) create mode 100644 src/LibRed/LibRed.Core/Resources/SortKeyTableV1.bin create mode 100644 src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs create mode 100644 test/LibRed.Core.Tests/AceDdlOnLibRedDatabaseProbeTest.cs create mode 100644 test/LibRed.Core.Tests/CollationVersionDiffProbeTest.cs create mode 100644 test/LibRed.Core.Tests/ComplexSystemTableLayoutProbeTest.cs create mode 100644 test/LibRed.Core.Tests/DaoDatabaseCreationProbeTest.cs create mode 100644 test/LibRed.Core.Tests/DaoPageLayoutProbeTest.cs create mode 100644 test/LibRed.Core.Tests/GeneralV1CollationAccessTests.cs create mode 100644 test/LibRed.Core.Tests/GeneralV1CollationTests.cs create mode 100644 test/LibRed.Core.Tests/Latin1SymbolCollationAccessTests.cs create mode 100644 test/LibRed.Core.Tests/SortKeyComparisonProbeTest.cs create mode 100644 tools/sortkey-table/generate.ps1 diff --git a/src/LibRed/LibRed.Ado/LibRedConnection.cs b/src/LibRed/LibRed.Ado/LibRedConnection.cs index 64a26cd2..391f276f 100644 --- a/src/LibRed/LibRed.Ado/LibRedConnection.cs +++ b/src/LibRed/LibRed.Ado/LibRedConnection.cs @@ -114,13 +114,16 @@ public static string GetConnectionString(string fileNameOrConnectionString) /// Produces an ACE 2007-format (.accdb) database that LibRed reads and writes fully; the /// remaining Access-compatibility system tables are still being filled in. /// - public static void CreateDatabase(string connectionString) + /// 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. + public static void CreateDatabase(string connectionString, Catalog.Collation? collation = null) { string path = ParseDataSource(connectionString); if (string.IsNullOrEmpty(path)) throw new ArgumentException("The connection string is missing a Data Source.", nameof(connectionString)); - Storage.DatabaseCreator.CreateEmpty(path); + Storage.DatabaseCreator.CreateEmpty(path, collation: collation); CreateDualTable(path); } diff --git a/src/LibRed/LibRed.Core/Catalog/Collation.cs b/src/LibRed/LibRed.Core/Catalog/Collation.cs index 6e2b2879..ea8e7383 100644 --- a/src/LibRed/LibRed.Core/Catalog/Collation.cs +++ b/src/LibRed/LibRed.Core/Catalog/Collation.cs @@ -58,11 +58,12 @@ public readonly record struct Collation(CollatingOrder Order, byte Version) /// can encode index keys for. public static Collation GeneralLegacy => new(CollatingOrder.General, 0); - /// The Access-2010+ default "General" order — locale 1033, version 1. LibRed reads and - /// distinguishes it, but does not yet encode its (different) index keys — see . + /// The Access-2010+ default "General" order — locale 1033, version 1. Its keys use the Windows + /// NLS weights directly rather than General-Legacy's compacted table; see JetTextCollationV1. public static Collation General => new(CollatingOrder.General, GeneralVersion); - /// Whether LibRed can encode index keys for this collation. Only General legacy is implemented; - /// see JetTextCollation and the format spec §10.4. - public bool IsIndexKeyEncodable => this == GeneralLegacy; + /// Whether LibRed can encode index keys for this collation. Both General orders are implemented + /// (v0 via JetTextCollation, v1 via JetTextCollationV1); other locales are not — see the + /// format spec §10.4. + public bool IsIndexKeyEncodable => this == GeneralLegacy || this == General; } diff --git a/src/LibRed/LibRed.Core/JetDatabase.cs b/src/LibRed/LibRed.Core/JetDatabase.cs index 7d723bb0..9675c801 100644 --- a/src/LibRed/LibRed.Core/JetDatabase.cs +++ b/src/LibRed/LibRed.Core/JetDatabase.cs @@ -44,12 +44,14 @@ private JetDatabase(PageChannel channel) /// The database default sort-order version (0 = General Legacy, 1 = General), from page 0. public byte DefaultCollationVersion => DefinitionPage.DefaultCollationVersion; - /// The database's default text collating order — the source of truth for the LCID and - /// sort-order version written into new columns, in place of a hardcoded constant. Defaults to General - /// legacy (locale 1033, version 0), which is what every file LibRed currently handles uses; decoding - /// the actual value from the page-0 sort order (obfuscated region) is a follow-up, after which this - /// property would be populated from . - public Collation Collation { get; internal set; } = Collation.GeneralLegacy; + /// The database's default text collating order — the LCID and sort-order version written into + /// new columns. Read from the page-0 sort order, so a table created in a General (v1) database gets v1 + /// columns, as Access would create them. (This used to be hardcoded to General legacy while the page-0 + /// decode was pending; the decode landed in / + /// but this was left behind, which silently gave every new column + /// v0 weights even in a v1 database.) + public Collation Collation => + new((CollatingOrder)DefaultCollationLcid, DefaultCollationVersion); /// Reads and decodes the table definition (TDEF) page at . public TableDefinitionPage ReadTableDefinition(int pageNumber) diff --git a/src/LibRed/LibRed.Core/LibRed.Core.csproj b/src/LibRed/LibRed.Core/LibRed.Core.csproj index 3d4baea0..7c24198f 100644 --- a/src/LibRed/LibRed.Core/LibRed.Core.csproj +++ b/src/LibRed/LibRed.Core/LibRed.Core.csproj @@ -8,4 +8,11 @@ AnyCPU;x86;x64 + + + + + + diff --git a/src/LibRed/LibRed.Core/Resources/SortKeyTableV1.bin b/src/LibRed/LibRed.Core/Resources/SortKeyTableV1.bin new file mode 100644 index 0000000000000000000000000000000000000000..8b9c7c7504ef2f1d0f777abeff61aa65863ad2b8 GIT binary patch literal 15970 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
  • d*s4g_z6$~A?+PhkYaLx@j<<=^MW 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 literal 0 HcmV?d00001 diff --git a/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs b/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs index f0db3fbd..027af28e 100644 --- a/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs +++ b/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs @@ -149,6 +149,57 @@ public static byte[] BuildDefinitionPage( new("szRelationship", JetDataType.Text, 510, false, ColumnId: 0, SystemFlags: Sys), ]; + + // ---- Complex-column system tables (ACE 12 / Access 2007 and later) -------------------------------------- + // + // Complex columns are Access's multi-value and attachment columns. Their registry is MSysComplexColumns, + // and each supported element type gets a flat storage table. Jet 4 (.mdb) has none of this — the feature + // arrived with ACE 12 — so these are only created from version byte 0x02 up. + // + // MSysComplexColumns is not optional even for a database that never uses a complex column: ACE consults it + // on every CREATE TABLE, and without it DDL through the OLE DB provider fails with "Cannot find table or + // constraint" (isolated in AceDdlOnLibRedDatabaseProbeTest by dropping exactly this table from a working + // DAO-created database). ACE never writes to it — it only has to resolve. + // + // Column ids are the ones the real engine assigns (creation order, which is not the alphabetical order the + // descriptors are stored in), so a byte-comparison against a DAO-created file lines up. + + private static readonly ColumnSpec[] MSysComplexColumnsColumns = + [ + new("ColumnName", JetDataType.Text, 510, false, ColumnId: 0, SystemFlags: Sys), + new("ComplexID", JetDataType.Int32, 4, true, IsAutoNumber: true, ColumnId: 4, SystemFlags: Sys), + new("ComplexTypeObjectID", JetDataType.Int32, 4, true, ColumnId: 1, SystemFlags: Sys), + new("ConceptualTableID", JetDataType.Int32, 4, true, ColumnId: 3, SystemFlags: Sys), + new("FlatTableID", JetDataType.Int32, 4, true, ColumnId: 2, SystemFlags: Sys), + ]; + + /// The flat storage tables, in the order the engine creates them. Each holds a single + /// Value column of its element type; Attachment is the exception, carrying the file metadata. + private static readonly (string Name, ColumnSpec[] Columns)[] MSysComplexTypeTables = + [ + ("MSysComplexType_UnsignedByte", [new("Value", JetDataType.Byte, 1, true, ColumnId: 0, SystemFlags: Sys)]), + ("MSysComplexType_Short", [new("Value", JetDataType.Int16, 2, true, ColumnId: 0, SystemFlags: Sys)]), + ("MSysComplexType_Long", [new("Value", JetDataType.Int32, 4, true, ColumnId: 0, SystemFlags: Sys)]), + ("MSysComplexType_IEEESingle", [new("Value", JetDataType.Single, 4, true, ColumnId: 0, SystemFlags: Sys)]), + ("MSysComplexType_IEEEDouble", [new("Value", JetDataType.Double, 8, true, ColumnId: 0, SystemFlags: Sys)]), + ("MSysComplexType_GUID", [new("Value", JetDataType.Guid, 16, true, ColumnId: 0, SystemFlags: Sys)]), + ("MSysComplexType_Decimal", [new("Value", JetDataType.FixedPoint, 9, false, ColumnId: 0, SystemFlags: Sys)]), + ("MSysComplexType_Text", [new("Value", JetDataType.Text, 510, false, ColumnId: 0, SystemFlags: Sys)]), + ("MSysComplexType_Attachment", + [ + new("FileData", JetDataType.Ole, 0, false, ColumnId: 3, SystemFlags: Sys), + new("FileFlags", JetDataType.Int32, 4, true, ColumnId: 5, SystemFlags: Sys), + new("FileName", JetDataType.Text, 510, false, ColumnId: 1, SystemFlags: Sys), + new("FileTimeStamp", JetDataType.DateTime, 8, true, ColumnId: 4, SystemFlags: Sys), + new("FileType", JetDataType.Text, 510, false, ColumnId: 2, SystemFlags: Sys), + new("FileURL", JetDataType.Memo, 0, false, ColumnId: 0, SystemFlags: Sys), + ]), + ]; + + /// MSysObjects.Flags for the complex tables, as the real engine writes them: the registry carries + /// the plain system flag, the flat storage tables an extra 0x00030000. + private const int ComplexStorageFlags = unchecked((int)0x80030000); + private const int SystemFlag = unchecked((int)0x80000000); // Per-file SID cluster. A database's on-disk 2-byte SIDs are the DEFAULT WORKGROUP's account SIDs XOR'd with @@ -179,21 +230,29 @@ public static byte[] BuildDefinitionPage( /// are added through the ordinary writers. Produces a LibRed-openable, round-trippable file (Access-level /// fidelity — the remaining system tables and the 0xE00 map — is a follow-up). /// - public static void CreateEmpty(string path, byte version = 0x02) + /// 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 for the order Access 2010+ offers as "General". + /// Only the two General orders can have their index keys encoded — see IndexKeyEncoder. + public static void CreateEmpty(string path, byte version = 0x02, Collation? collation = null) { + Collation sortOrder = collation ?? Collation.GeneralLegacy; JetFormatBase format = JetFormatBase.FromVersionByte(version); // The four core system tables live at the exact pages the page-0 bootstrap pointers name (2/3/4/5); // their usage maps follow at 6..9. Access uses those pointers to find the catalog. const int objPage = 2, acesPage = 3, queriesPage = 4, relPage = 5; - var (objTdef, objMap) = BuildSystemTable(format, MSysObjectsColumns, usageMapPage: 6); - var (acesTdef, acesMap) = BuildSystemTable(format, MSysAcesColumns, usageMapPage: 7); - var (queriesTdef, queriesMap) = BuildSystemTable(format, MSysQueriesColumns, usageMapPage: 8); - var (relTdef, relMap) = BuildSystemTable(format, MSysRelationshipsColumns, usageMapPage: 9); + // The system tables take the database collation too: Access writes v1 descriptors on MSys* in a + // General (v1) database, so anything else would be a mixed-collation file it never produces. + var (objTdef, objMap) = BuildSystemTable(format, MSysObjectsColumns, usageMapPage: 6, sortOrder); + var (acesTdef, acesMap) = BuildSystemTable(format, MSysAcesColumns, usageMapPage: 7, sortOrder); + var (queriesTdef, queriesMap) = BuildSystemTable(format, MSysQueriesColumns, usageMapPage: 8, sortOrder); + var (relTdef, relMap) = BuildSystemTable(format, MSysRelationshipsColumns, usageMapPage: 9, sortOrder); const int seedPages = 10; // page 0, page 1, 4 core TDEFs (2..5), 4 usage maps (6..9) byte[][] seed = [ - BuildDefinitionPage(version, isAccdb: true, 1252, 1033, 0, BitConverter.Int64BitsToDouble(SeedCreationDateBits)), + BuildDefinitionPage(version, isAccdb: true, 1252, (int)sortOrder.Order, sortOrder.Version, + BitConverter.Int64BitsToDouble(SeedCreationDateBits)), BuildFreeMapPage(format, seedPages), // page 1: global free-pages map objTdef, acesTdef, queriesTdef, relTdef, // pages 2..5: core TDEFs objMap, acesMap, queriesMap, relMap, // pages 6..9: their usage maps @@ -255,6 +314,9 @@ void Ace(int objectId, byte[] sid, int acm, bool inherit = false) db.CreateIndex("MSysRelationships", "szObject", [("szObject", false)]); db.CreateIndex("MSysRelationships", "szReferencedObject", [("szReferencedObject", false)]); + // Complex-column system tables — ACE 12 and later only (see CreateComplexSystemTables). + if (version >= 0x02) CreateComplexSystemTables(db); + // Note: MSysAccessStorage and the MSysNavPane* tables are deliberately NOT created here. Verified across // ~135 pure-DAO reference files: none of them carry those tables — Access creates them (plus the nav-pane // long SID) itself on first open. Emitting them ourselves both diverged from real DAO output and produced @@ -262,6 +324,74 @@ void Ace(int objectId, byte[] sid, int acm, bool inherit = false) // DAO: core catalog only, and Access augments on first open. } + /// + /// Creates MSysComplexColumns and the MSysComplexType_* storage tables — the complex-column + /// (multi-value / attachment) infrastructure Access 2007 / ACE 12 introduced. Jet 4 has none of it, + /// so the caller gates this on version byte 0x02 or later. + /// + /// The registry table is required even in a database that never uses a complex column: ACE consults + /// it on every CREATE TABLE, and a database without it rejects DDL through the OLE DB provider with + /// "Cannot find table or constraint". It stays empty — ACE reads it, never writes it (both facts isolated + /// in AceDdlOnLibRedDatabaseProbeTest). The storage tables are created for completeness so a + /// complex column added later has somewhere to live. + /// + /// These go through the ordinary writers, so each gets its TDEF, usage map, catalog row and index + /// roots the same way a user table does; the rows are then corrected to the system flags and owner the + /// real engine writes. Page numbers therefore follow LibRed's own allocation rather than matching a + /// DAO-created file position for position — DAO's numbering is a consequence of how it lays out the core + /// four tables' usage maps and index roots, which LibRed does differently. + /// + private static void CreateComplexSystemTables(JetDatabase db) + { + db.CreateTable("MSysComplexColumns", MSysComplexColumnsColumns); + // Index names, order and flags as the engine writes them: the ComplexID primary key first, then the + // two non-unique lookups the engine uses to find a table's complex columns. + db.CreateIndex("MSysComplexColumns", "IdxID", [("ComplexID", false)], + isUnique: true, isPrimary: true, disallowNull: true, ignoreNulls: true); + db.CreateIndex("MSysComplexColumns", "IdxConceptualTableID", [("ConceptualTableID", false)], + disallowNull: true, ignoreNulls: true); + db.CreateIndex("MSysComplexColumns", "IdxFlatTableID", [("FlatTableID", false)], + disallowNull: true, ignoreNulls: true); + MarkAsSystemTable(db, "MSysComplexColumns", SystemFlag); + + foreach ((string name, ColumnSpec[] columns) in MSysComplexTypeTables) + { + db.CreateTable(name, columns); + MarkAsSystemTable(db, name, ComplexStorageFlags); + } + } + + /// Turns a table the ordinary writers just created into a system object: the MSysObjects row gets + /// the engine's flags and owner, and the TDEF's table-type byte becomes 'S'. Creating it as a user table + /// first and correcting it reuses all the allocation, usage-map and index machinery. + private static void MarkAsSystemTable(JetDatabase db, string name, int flags) + { + TableDef definition = db.Catalog.FindTable(name) + ?? throw new InvalidOperationException($"'{name}' was not found after creating it."); + + Table msysObjects = db.OpenTable("MSysObjects"); + TableDef objectsDef = msysObjects.Definition; + int idIndex = objectsDef.FindColumn("Id")!.Index; + int flagsIndex = objectsDef.FindColumn("Flags")!.Index; + int ownerIndex = objectsDef.FindColumn("Owner")!.Index; + + foreach ((RowId rowId, object?[] values) in msysObjects.Rows().WithIds()) + { + if (values[idIndex] is not { } id || Convert.ToInt32(id) != definition.DefinitionPage) continue; + values[flagsIndex] = flags; + values[ownerIndex] = SidEngine; + msysObjects.Update(rowId, values, new HashSet { flagsIndex, ownerIndex }); + break; + } + + // TDEF table type: 'N' user -> 'S' system. + IO.PageChannel channel = msysObjects.Channel; + byte[] tdef = channel.ReadPageShared(definition.DefinitionPage).Span.ToArray(); + tdef[channel.Format.TdefTableTypeOffset] = (byte)TableType.System; + channel.WritePage(definition.DefinitionPage, tdef); + db.Catalog.Invalidate(); + } + private static void InsertCatalogRow(Table msysObjects, int id, string name, short type, int flags, int parentId = 0, byte[]? owner = null) { var values = new object?[msysObjects.Definition.Columns.Count]; @@ -307,7 +437,7 @@ private static byte[] BuildFreeMapPage(JetFormatBase format, int usedPages) /// row that stores a long value — e.g. an MSysObjects catalog row carrying an LvProp blob — /// has somewhere to record its LVAL page. private static (byte[] Tdef, byte[] UsageMap) BuildSystemTable( - JetFormatBase format, IReadOnlyList columns, int usageMapPage) + JetFormatBase format, IReadOnlyList columns, int usageMapPage, Collation collation) { var longValueCols = columns.Select((c, pos) => (c, id: c.ColumnId ?? pos)) .Where(x => x.c.Type is JetDataType.Memo or JetDataType.Ole).ToList(); @@ -315,7 +445,8 @@ private static (byte[] Tdef, byte[] UsageMap) BuildSystemTable( for (int j = 0; j < longValueCols.Count; j++) longValueSpecs.Add(new LongValueColumnSpec(longValueCols[j].id, UsedRow: 2 + 2 * j, FreeRow: 3 + 2 * j, MapPage: usageMapPage)); - byte[] tdef = TdefBuilder.Build(format, TableType.System, columns, longValueColumns: longValueSpecs).Page; + byte[] tdef = TdefBuilder.Build(format, TableType.System, columns, longValueColumns: longValueSpecs, + collation: collation).Page; tdef[format.TdefOwnedPagesOffset] = 0; WriteInt24(tdef, format.TdefOwnedPagesOffset + 1, usageMapPage); tdef[format.TdefFreePagesOffset] = 1; WriteInt24(tdef, format.TdefFreePagesOffset + 1, usageMapPage); var tdefPage = new byte[format.PageSize]; diff --git a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs index 40a2152d..587db18b 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs @@ -54,21 +54,26 @@ public static byte[] Encode(IReadOnlyList<(ColumnDef Column, bool Ascending)> co // produces byte-for-byte the key of its 255-character prefix. if (column.Type is JetDataType.Text or JetDataType.Memo) { - // Index-key weights are only implemented for General legacy; refuse other collations up front - // rather than emit wrong bytes with the General-v0 table (e.g. a 2010+ General-v1 column, or a - // non-English locale). The collation is read per-column from the descriptor (0x0B–0x0E). + // Weights are implemented for the two General orders (v0 legacy, v1). Refuse anything else up + // front — a non-English locale — rather than emit wrong bytes with an English table. The + // collation is read per-column from the descriptor (0x0B–0x0E). if (!column.Collation.IsIndexKeyEncodable) throw new NotSupportedException( $"Index key encoding for column '{column.Name}' uses collation {column.Collation.Order} " + - $"version {column.Collation.Version}, which is not implemented yet (only General legacy is)."); + $"version {column.Collation.Version}, which is not implemented yet (only General is)."); string text = (string)value; if (column.Type == JetDataType.Memo && text.Length > MemoKeyMaxChars) text = text[..MemoKeyMaxChars]; var ascendingKey = new List { IndexKeyFlags.AscStart }; - if (!JetTextCollation.TryEncode(text, ascendingKey)) - throw new NotSupportedException($"Text index key '{text}' contains a character whose collation weight is not implemented yet."); + bool encoded = column.Collation.Version == Collation.GeneralVersion + ? JetTextCollationV1.TryEncode(text, ascendingKey) + : JetTextCollation.TryEncode(text, ascendingKey); + if (!encoded) + throw new NotSupportedException( + $"Text index key '{text}' contains a character with no weight in the {column.Collation.Order} " + + $"v{column.Collation.Version} collation table."); if (ascending) { diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs index 823fe4e3..0fd7d3a7 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs @@ -28,6 +28,7 @@ internal static class JetTextCollation private const byte InlineMid = 0x06; private const byte ApostropheCode = 0x80; private const byte HyphenCode = 0x82; + private const byte SoftHyphenCode = 0x83; private const byte DefaultSecondary = 0x02; // a character with no accent // Secondary (diacritic) weight per Unicode combining mark — depends only on the accent, not the base @@ -49,6 +50,10 @@ internal static class JetTextCollation { ['Ø'] = ('O', 0x21), ['Ð'] = ('D', 0x68), + // Ordinal indicators: the base letter's primary with a distinguishing secondary, so they sort beside + // 'a'/'o' rather than with the symbols. Harvested from ACE (7F 4A 01 03 00 / 7F 64 01 03 00). + ['ª'] = ('A', 0x03), + ['º'] = ('O', 0x03), }; // Letters that sort as a multi-letter expansion (each expanded letter weighs its normal primary, no @@ -77,6 +82,25 @@ internal static class JetTextCollation ['<'] = [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], + // 0x33 mathematical NLS 0x0817..0x081D (both skip the same slots) + ['±'] = [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], + // 0x37 fractions NLS 0x0D0D/0x0D11/0x0D15 (step 4 in both) + ['¼'] = [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], }; /// @@ -102,12 +126,16 @@ public static bool TryEncode(string value, List output) char u = char.ToUpperInvariant(c); if (u == '\'') { inline.Add((primaries.Count, ApostropheCode)); continue; } if (u == '-') { inline.Add((primaries.Count, HyphenCode)); continue; } + if (u == '­') { inline.Add((primaries.Count, SoftHyphenCode)); continue; } // soft hyphen if (u is >= 'A' and <= 'Z') Add(Letters[u - 'A']); else if (u is >= '0' and <= '9') Add((byte)(0x36 + 2 * (u - '0'))); - else if (Symbols.TryGetValue(u, out byte[]? weights)) + // Look the symbol up by the original character as well as the uppercased one: uppercasing is for + // letters, and it corrupts some symbols — char.ToUpperInvariant('µ') is GREEK CAPITAL LETTER MU, + // which is not what ACE weighs it as (ACE gives it a symbol weight in the 0x34 group). + else if (Symbols.TryGetValue(c, out byte[]? weights) || Symbols.TryGetValue(u, out weights)) foreach (byte w in weights) Add(w); else if (!TryAddAccented(u, Add)) return false; // not handled yet diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs new file mode 100644 index 00000000..acff5a09 --- /dev/null +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs @@ -0,0 +1,210 @@ +using System.Buffers.Binary; +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using System.Reflection; + +namespace LibRed.Storage; + +/// +/// Index-key weights for the Access-2010+ "General" text collation (sort-order version 1). +/// +/// Unlike General-Legacy (), whose weights are a Jet-era compaction into +/// one byte per character, v1 uses the Windows NLS weights verbatim: the primary is the two-byte +/// (Script Member, Alphabetic Weight) pair and the secondary is the Diacritic Weight, exactly as +/// published in Microsoft's sorting weight tables. The Case Weight is dropped, which is why case and +/// character width both fold (a full-width and A differ only in that discarded weight). +/// +/// The table is the Windows Server 2008 one, frozen: reconstructing measured ACE v1 keys scores +/// 25/25 against it and lower against every other published version. See tools/sortkey-table/generate.ps1 +/// for provenance and how the embedded resource is built. +/// +internal static class JetTextCollationV1 +{ + private const byte EndPrimary = 0x01; + private const byte EndKey = 0x00; + private const byte InlineStart = 0x80; + private const byte DefaultSecondary = 0x02; + + /// Script member 6 is Windows' "word sort" class: characters that carry no primary weight but are + /// recorded positionally so co-op stays beside coop. The apostrophe and hyphen live here + /// (their 0x80/0x82 inline codes are simply their Alphabetic Weights), which is why exactly + /// those two are special — it is the platform's rule, not an Access one. + private const byte WordSortScriptMember = 6; + + private static readonly Lazy Table = new(Load, LazyThreadSafetyMode.ExecutionAndPublication); + + /// + /// Appends the collation key body for — everything after the start flag. Returns + /// false if any character has no weight in the table (the caller reports it rather than emitting a key + /// that would sort wrongly). + /// + public static bool TryEncode(string value, List output) + { + WeightTable table = Table.Value; + ReadOnlySpan text = value.AsSpan().TrimEnd(' '); + + var primaries = new List(); + var secondaries = new List(); + // Position is counted in primary *weights*, not bytes. In v0 the two coincide (one byte per weight); + // here a weight is two bytes, and ACE still counts weights — verified against ACE (`O'Brien` puts the + // apostrophe at 0x0B = 0x07 + 4x1 in both orders, though v1 has emitted twice as many bytes by then). + var inline = new List<(int Position, byte ScriptMember, byte AlphabeticWeight)>(); + + foreach (char character in text) + { + if (table.TryExpand(character, out char[]? sequence)) + { + foreach (char expanded in sequence) + if (!Append(expanded)) return false; + } + else if (!Append(character)) + { + return false; + } + } + + bool Append(char character) + { + if (!table.TryGetWeight(character, out byte scriptMember, out byte alphabetic, out byte diacritic)) + return false; + + // A wholly zero entry is an ignorable with no record at all (e.g. the soft hyphen, which v0 + // instead records inline as 0x83 — a real difference between the two weight tables). + if (scriptMember == 0 && alphabetic == 0) return true; + + if (scriptMember == WordSortScriptMember) + { + inline.Add((primaries.Count / 2, scriptMember, alphabetic)); + return true; + } + + primaries.Add(scriptMember); + primaries.Add(alphabetic); + secondaries.Add(diacritic); + return true; + } + + output.AddRange(primaries); + output.Add(EndPrimary); + + // Secondary section: emitted up to and including the last character carrying a non-default accent. + int lastAccent = secondaries.FindLastIndex(weight => weight != DefaultSecondary); + for (int i = 0; i <= lastAccent; i++) output.Add(secondaries[i]); + + if (inline.Count > 0) + { + output.Add(EndPrimary); + output.Add(EndPrimary); + output.Add(EndPrimary); + foreach ((int position, byte scriptMember, byte alphabetic) in inline) + { + output.Add(InlineStart); + output.Add((byte)(0x07 + 4 * position)); + output.Add(scriptMember); + output.Add(alphabetic); + } + } + + output.Add(EndKey); + return true; + } + + private static WeightTable Load() + { + using Stream stream = typeof(JetTextCollationV1).Assembly + .GetManifestResourceStream("LibRed.Resources.SortKeyTableV1.bin") + ?? throw new InvalidOperationException("The v1 sorting weight table resource is missing from the assembly."); + + Span header = stackalloc byte[8]; + stream.ReadExactly(header); + int weightCount = BinaryPrimitives.ReadInt32LittleEndian(header); + int expansionCount = BinaryPrimitives.ReadInt32LittleEndian(header[4..]); + + byte[] deltas = ReadSection(stream); + byte[] scriptMembers = ReadSection(stream); + byte[] alphabetics = ReadSection(stream); + byte[] diacritics = ReadSection(stream); + byte[] expansionBytes = ReadSection(stream); + + if (scriptMembers.Length != weightCount || alphabetics.Length != weightCount || diacritics.Length != weightCount) + throw new InvalidDataException("The v1 sorting weight table resource is inconsistent with its header."); + + var codePoints = new ushort[weightCount]; + int offset = 0, current = 0; + for (int i = 0; i < weightCount; i++) + { + current += ReadVarInt(deltas, ref offset); + codePoints[i] = (ushort)current; + } + + var expansions = new Dictionary(expansionCount); + offset = 0; current = 0; + for (int i = 0; i < expansionCount; i++) + { + current += ReadVarInt(expansionBytes, ref offset); + int length = expansionBytes[offset++]; + var sequence = new char[length]; + for (int j = 0; j < length; j++) + { + sequence[j] = (char)BinaryPrimitives.ReadUInt16LittleEndian(expansionBytes.AsSpan(offset)); + offset += 2; + } + expansions[(char)current] = sequence; + } + + return new WeightTable(codePoints, scriptMembers, alphabetics, diacritics, expansions); + } + + private static byte[] ReadSection(Stream stream) + { + Span length = stackalloc byte[4]; + stream.ReadExactly(length); + var compressed = new byte[BinaryPrimitives.ReadInt32LittleEndian(length)]; + stream.ReadExactly(compressed); + + using var source = new MemoryStream(compressed); + using var inflate = new ZLibStream(source, CompressionMode.Decompress); + using var target = new MemoryStream(); + inflate.CopyTo(target); + return target.ToArray(); + } + + private static int ReadVarInt(byte[] data, ref int offset) + { + int value = 0, shift = 0; + while (true) + { + byte b = data[offset++]; + value |= (b & 0x7F) << shift; + if ((b & 0x80) == 0) return value; + shift += 7; + } + } + + /// Code points sorted ascending with their weights in parallel arrays — a binary search over + /// ~58k entries, rather than a dictionary, to keep the table near 300 KB resident instead of several MB. + private sealed class WeightTable( + ushort[] codePoints, byte[] scriptMembers, byte[] alphabetics, byte[] diacritics, + Dictionary expansions) + { + public bool TryGetWeight(char character, out byte scriptMember, out byte alphabetic, out byte diacritic) + { + int index = Array.BinarySearch(codePoints, (ushort)character); + if (index < 0) + { + scriptMember = alphabetic = diacritic = 0; + return false; + } + scriptMember = scriptMembers[index]; + alphabetic = alphabetics[index]; + diacritic = diacritics[index]; + return true; + } + + /// The character's expansion (e.g. ßs,s) when it has one. Deliberately + /// not "return the character in a one-element buffer": a shared buffer would race between the + /// concurrent readers the engine now allows, and allocating one per character would be worse. + public bool TryExpand(char character, [NotNullWhen(true)] out char[]? sequence) => + expansions.TryGetValue(character, out sequence); + } +} diff --git a/src/LibRed/LibRed.Core/Storage/TableCreator.cs b/src/LibRed/LibRed.Core/Storage/TableCreator.cs index 3f8b7f59..d93c3942 100644 --- a/src/LibRed/LibRed.Core/Storage/TableCreator.cs +++ b/src/LibRed/LibRed.Core/Storage/TableCreator.cs @@ -70,7 +70,11 @@ public void Create( int tdefPage = _allocator.Allocate(); int usageMapPage = _allocator.Allocate(); - var longValueCols = columns.Select((c, i) => (Column: c, Id: i)) + // Key the long-value maps by the column's *id*, not its position. The two coincide on an ordinary + // CREATE TABLE, but a spec can carry an explicit id — the faithful-rebuild path does, and ids are + // never reused after a DROP COLUMN — and the TDEF's long-value map is read back by id, so using the + // position there silently points a Memo/OLE column's usage maps at the wrong column. + var longValueCols = columns.Select((c, i) => (Column: c, Id: c.ColumnId ?? i)) .Where(x => x.Column.Type is JetDataType.Memo or JetDataType.Ole) .ToList(); diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDatabaseCreator.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDatabaseCreator.cs index e15b328a..b562e191 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDatabaseCreator.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDatabaseCreator.cs @@ -25,7 +25,7 @@ public class LibRedDatabaseCreator( // connection used to run a CREATE/DROP DATABASE migration command. LibRedRelationalConnection // doesn't support that (there's nothing to connect to before the file exists), so these bypass // it entirely and go straight through LibRedConnection's own bootstrap (see its CreateDatabase - // remarks: it hands off to JetConnection.CreateDatabase/DAO-ADOX for now). + // remarks: DatabaseCreator.CreateEmpty synthesises the file from scratch - no DAO/ADOX). public override void Create() => LibRedConnection.CreateDatabase(_connection.DbConnection.ConnectionString); diff --git a/src/LibRed/README.md b/src/LibRed/README.md index 911f3708..e267bf00 100644 --- a/src/LibRed/README.md +++ b/src/LibRed/README.md @@ -131,9 +131,14 @@ LibRed-side `CHECK` enforcement, self-pointing self-references, and writing Memo `DateTimeExtended`/DATETIME2 (`0x14`) is **read-only** (no write codec), which also blocks indexing it. The **ACE-16 auto-upgrade** (using BIGINT/DATETIME2 bumps the format version) is deliberately refused until we probe what else the upgrade changes. -- **Version-1 "General" text collation** (Access 2010+) — the v1 weight table needs an Access-2010+ - fixture to reverse-engineer; the encoder gates (throws) on any non-legacy collation meanwhile. Also: - populate `JetDatabase.Collation` from the page-0 sort order (needs page-0 de-obfuscation). +- **Non-English text collations** — index-key weights exist for the two General (1033) orders only: + General-Legacy (v0) and General (v1). Any other locale is refused by `IndexKeyEncoder` rather than encoded + with the English table. *(The v1 gap is closed: v1 keys are the Windows NLS weights verbatim, from the + Windows Server 2008 sorting weight table that ACE froze — see `docs/format/page-03-04-index-btree.md` §10.4 + and `tools/sortkey-table/generate.ps1`. v0's ancestor table is still unidentified: its ordering matches + every published Windows version across the range LibRed covers, so discriminating it needs characters whose + weights actually moved.)* + - **Computed / calculated columns** (ACE 14) — the evaluation half exists; the gap is the on-disk TDEF/`LvProp` storage of the expression (and persisted-vs-virtual semantics). - **`LvProp` properties not modelled** — `ValidationRule`/`ValidationText` (UI-authored validation, diff --git a/src/LibRed/docs/format/page-00-database.md b/src/LibRed/docs/format/page-00-database.md index 350a21bd..be6d936e 100644 --- a/src/LibRed/docs/format/page-00-database.md +++ b/src/LibRed/docs/format/page-00-database.md @@ -76,6 +76,31 @@ TDEF page). LibRed reads `0x20` into `DatabaseDefinitionPage.CatalogRootPage` an > file must **not** hand-create the `MSysAccessStorage` / `MSysNavPane*` tables — real DAO files omit them and > Access adds them (with the nav-pane long SID) on first open (verified across ~135 pure-DAO files). +> **How the reference engine lays out a new file** (DAO-created ACE 12, 42 pages — `DaoPageLayoutProbeTest`). +> Per table the allocation order is **TDEF → usage-map page → one page per index root**, in table-creation +> order; both usage maps share one page (owned = row 0, free = row 1, inline), which is what every TDEF's +> `0x37`/`0x3B` pointers show. **Data pages are allocated lazily on first insert**, so they appear out of +> sequence and an empty table has none at all. +> +> | pages | contents | +> | --- | --- | +> | `0`, `1` | database definition; global free-pages map | +> | `2`–`5` | the four core TDEFs — fixed, because page 0's bootstrap pointers name them | +> | `6`, `9`, `11`, `13` | usage maps for MSysObjects / MSysACEs / MSysQueries / MSysRelationships | +> | `7`, `8`, `10`, `12`, `14`–`16` | their index roots (2 + 1 + 1 + 3), each a leaf page | +> | `17` | MSysObjects' first data page — the catalog rows | +> | `18`–`22` | MSysComplexColumns: TDEF, usage map, three index roots | +> | `23`–`40` | the nine `MSysComplexType_*` tables: TDEF, then usage map, in pairs | +> | `41` | MSysACEs' data page — the ACL rows | +> +> MSysQueries and MSysRelationships are empty in a fresh database and own no data page. +> +> **Column descriptors are stored sorted by name, while column ids follow creation order** — the two do not +> agree, and code that treats a column's position as its id is wrong wherever the TDEF is keyed by id (the +> long-value usage-map block is). E.g. `MSysComplexColumns.ComplexID` is the 2nd descriptor with id 4, and +> `MSysComplexType_Attachment.FileURL` is the last descriptor with id 0. Sorting is by name, not +> fixed-before-variable: `ColumnName` is variable-length and still sorts first. + ### 2.1 The obfuscated header (`0x18`–`0x98`) From `0x18` for **128 bytes** (Jet 4 / ACE; 126 for Jet 3), page 0 is obfuscated by XOR-ing the @@ -144,6 +169,13 @@ CF 65 ED FF 07 C7 46 A1 78 16 0C ED E9 2D 62 D4 ; 0x88 - **ACE `.accdb`**: real encryption — this region is an encryption **verifier**, not recoverable plaintext (an actual password decodes to random-looking bytes under the Jet 4 scheme). Recovering it is a crypto attack, not format work. +- **Writing it:** `DatabaseCreator.CreateEmpty(path, version, collation)` (and + `LibRedConnection.CreateDatabase(connectionString, collation)`) set this pair, defaulting to General-Legacy. + The chosen collation goes into page 0 *and* into the system tables' column descriptors, and + `JetDatabase.Collation` reads it back so every table created later inherits it — matching Access, which + writes v1 descriptors on `MSys*` in a General database. LibRed is currently the only way to create a v1 + database programmatically: DAO writes v0 whatever the application setting says, and Access honours its + "New database sort order" option only through its own UI. - **Collation sort order (`0x6E`, 4 bytes)** → `DefaultCollationLcid` (LCID at `0x6E`) + `DefaultCollationVersion` (the byte at `0x71`, 0 = General Legacy, 1 = General). The version here **matches each column descriptor's `0x0E`** — the sort version lives both database-wide (page 0) 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 083d3488..0909a180 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -115,6 +115,36 @@ Then the value, transformed: its 255-character prefix, so two memos differing only past character 255 share a key (fine for a non-unique index). Index keys are therefore encoded from the **logical** row values, before memo/OLE values are materialised into their `LongValueDescriptor`s. +> **Two General orders, two weight tables.** Everything in this Text section describes **General-Legacy** +> (sort-order version `0`). The Access-2010+ **General** order (version `1`, the byte at column `0x0E` / +> page-0 `0x71`) uses the *same framing* — start flag, primary weights, `0x01`, secondary section, inline +> word-sort records, `0x00` — but different weights: +> +> | | General-Legacy (v0) | General (v1) | +> |---|---|---| +> | primary | **1 byte**, a Jet-era compaction | **2 bytes**: the Windows NLS `(Script Member, Alphabetic Weight)` verbatim | +> | secondary | the NLS Diacritic Weight | the same | +> | inline position | counts primary **bytes** | counts primary **weights** (so `O'Brien` is `0x0B` in both, though v1 has emitted twice as many bytes) | +> | soft hyphen | inline record, code `0x83` | wholly ignorable, no record | +> +> v1's table is the **Windows Server 2008** sorting weight table, frozen — identified by reconstructing +> measured ACE v1 keys from every published Windows table (Server 2008 scores 25/25; Win7/2008R2 24/25, +> Vista 23/25, Win8+ 22/25, NT4-2003 18/25, the discriminators being `1` = `13 25` vs `13 26`, its DW `2` +> vs `3`, and `½` = `13 24 214` vs `13 17 2`). Access 2010 shipped with the then-current weights and froze +> them when Windows 7/8 moved them — the "major NLS version, re-index everything" event described in +> [MS-UCODEREF] and *Handling Sorting in Your Applications*. +> +> This also explains the framing generally: **script member 6 is the word-sort class**, and the apostrophe's +> `0x80` and hyphen's `0x82` inline codes are simply their Alphabetic Weights — so the inline record is +> `80 `, not a bespoke Access encoding. And because the NLS **Case Weight** is the tertiary +> section this format truncates, case *and* character width fold for free (`A` U+FF21 and `A` share the +> primary `0E02` and differ only in that discarded weight). +> +> LibRed encodes both: `JetTextCollation` (v0, hand-built tables) and `JetTextCollationV1` (v1, from an +> embedded copy of the Server 2008 table — see `tools/sortkey-table/generate.ps1`). Other locales are still +> refused. Tests: `GeneralV1CollationTests` (keys measured from ACE) and `GeneralV1CollationAccessTests` +> (live oracle, plus ACE seeking an index LibRed wrote in a v1 database). + - **Text:** Jet's "General" collation. The key is the start flag, then one or two **primary-weight** bytes per character, then a `01 00` terminator. Weights are **case-folded** (lowercase weighs the same as uppercase), **trailing spaces are dropped**, and an internal @@ -134,6 +164,37 @@ Then the value, transformed: `Aß-B` → `7F 4A 6B 6B 4C 01 01 01 01 80 13 06 82 00`, hyphen at position **3** because ß expands to two primary bytes `SS`). + > **Why those two characters specifically:** this is Windows' documented **word sort**, the default for + > the NLS sorting functions — *"all punctuation marks and other nonalphanumeric characters, except for the + > hyphen and the apostrophe, come before any alphanumeric character. The hyphen and the apostrophe are + > treated differently … to ensure that words such as 'coop' and 'co-op' stay together in a sorted list"* + > ([Handling Sorting in Your Applications](https://learn.microsoft.com/en-us/windows/win32/intl/handling-sorting-in-your-applications)). + > The alternative, `SORT_STRINGSORT`, treats both as ordinary punctuation sorting before alphanumerics — + > which is *not* what ACE's keys show. So the ignorable pair is the platform default rather than an Access + > invention. (The soft hyphen `U+00AD`, code `0x83`, is ignorable for a different reason: it carries no + > weight of its own. The same page notes the Arabic kashida likewise produces no sort-key value.) + + **Latin-1 punctuation and symbols** weigh two bytes, in groups that mirror the Win32 NLS primary order + in ACE's own compacted numbering — harvested from ACE's stored keys character by character + (`Latin1SymbolCollationAccessTests`): + `¡ ¦ ¨ ¯ ´ ¸ ¿` = `2B 10`…`2B 16` (continuing the `^_\`{|}~` group); + `± « » × ÷` = `33 04/05/07/09/0A`; `¢ £ ¤ ¥ § © ¬ ® ° µ ¶ ·` = `34 A6`…`34 B1`; + `¼ ½ ¾` = `37 12/16/1A`. The **ordinal indicators** `ª`/`º` are not symbols at all: they take their base + letter's primary with a distinguishing **secondary** `0x03` (`ª` = `7F 4A 01 03 00`), like an accent. + The **superscript digits** `¹ ² ³` take the *same* primary as `1 2 3` with no distinguishing secondary, so + ACE sorts and compares them **equal** to the base digit (`¹` and `1` are both `7F 38 01 00`) — which makes + them duplicates in a unique index. The **soft hyphen** `U+00AD` is a third ignorable, code `0x83` + (alongside apostrophe `0x80` and hyphen `0x82`). + + > **Width and case insensitivity are a consequence of the truncated key, not a normalisation pass.** + > A Win32 NLS sort key carries case *and* width in its **tertiary** section, which this format discards: + > `LCMapStringEx` gives `A` (U+FF21) and `A` the identical primary `0E02`, differing only at the tertiary + > weight. So full-width forms, half-width katakana, and case all collapse for free. ACE does **not** + > pre-map with `LCMAP_HALFWIDTH`: `U+3000` (ideographic space) keeps its own key `7F 07 01 00` rather than + > becoming a space and being dropped by the trailing-space trim, which is what a width pre-mapping would + > produce. Ligatures need no special handling either — NLS itself expands `fi` to `f` + `i`. (Probed in + > `SortKeyComparisonProbeTest`.) + **A few letters expand to multiple base letters** (each expanded letter weighs its normal primary, no accent): `ß`→`SS`, `Þ`/`þ`→`TH`, `Æ`→`AE` — verified against ACE (`ß` = `7F 6B 6B 01 00`, same as `SS`). Because the ignorable-position count is by primary byte, an expansion counts as its expanded diff --git a/src/LibRed/docs/format/system-catalog.md b/src/LibRed/docs/format/system-catalog.md index 7a9f42fa..5cdeda76 100644 --- a/src/LibRed/docs/format/system-catalog.md +++ b/src/LibRed/docs/format/system-catalog.md @@ -258,6 +258,39 @@ > 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. > + > **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.** Verified against a DAO-created ACE 12 database: + > + > | table | columns (id) | indexes | `MSysObjects.Flags` | + > | --- | --- | --- | --- | + > | `MSysComplexColumns` | `ColumnName` Text(510) (0), `ComplexID` Long **AutoNumber** (4), `ComplexTypeObjectID` Long (1), `ConceptualTableID` Long (3), `FlatTableID` Long (2) | `IdxID`(ComplexID, unique+PK), `IdxConceptualTableID`, `IdxFlatTableID` — all required + ignore-nulls | `0x80000000` | + > | `MSysComplexType_{UnsignedByte,Short,Long,IEEESingle,IEEEDouble,GUID,Decimal,Text}` | a single `Value` of the matching type (0) | none | `0x80030000` | + > | `MSysComplexType_Attachment` | `FileData` OLE (3), `FileFlags` Long (5), `FileName` Text(510) (1), `FileTimeStamp` DateTime (4), `FileType` Text(510) (2), `FileURL` Memo (0) | none | `0x80030000` | + > + > Column **ids are creation order, not descriptor order** — descriptors are stored alphabetically, so the + > two differ (e.g. `ComplexID` is the 2nd descriptor but id 4). All are `ParentId` = the Tables container, + > `Type` = 1, owner = the Engine SID. + > + > **`MSysComplexColumns` is load-bearing for object creation even when unused.** ACE consults it whenever + > it creates a new catalog object, and only then. Verified by dropping it from a working DAO-created + > database and exercising the surface (`AceDdlOnLibRedDatabaseProbeTest`): + > + > | operation | without `MSysComplexColumns` | + > | --- | --- | + > | `SELECT` / `INSERT` / `UPDATE` / `DELETE` | OK | + > | `CREATE INDEX`, `ALTER TABLE ADD COLUMN`, `DROP TABLE` | OK | + > | `CREATE TABLE` | *"Cannot find table or constraint."* | + > | `CREATE VIEW` | *"…could not find the object 'MSysComplexColumns'."* | + > + > So it is exactly the two statements that add an `MSysObjects` row that need it — not the DDL surface as a + > whole, and not a fixed system-table bind (the `CREATE VIEW` error names the table outright). It is + > **read-only** from ACE's side: a `CREATE TABLE`, a table of seven varied column types, and a + > `CREATE INDEX` all leave it at **0 rows**. Isolated against the other system tables — dropping + > `MSysComplexType_Text` changes nothing, dropping `MSysQueries` gives a different error. LibRed creates all + > ten in `DatabaseCreator.CreateEmpty` for version ≥ `0x02`, which is what lets ACE run DDL in a + > 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`. diff --git a/test/LibRed.Core.Tests/AceDdlOnLibRedDatabaseProbeTest.cs b/test/LibRed.Core.Tests/AceDdlOnLibRedDatabaseProbeTest.cs new file mode 100644 index 00000000..c0db6796 --- /dev/null +++ b/test/LibRed.Core.Tests/AceDdlOnLibRedDatabaseProbeTest.cs @@ -0,0 +1,299 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// Why would ACE not run DDL against a LibRed-created database? ACE opened one, read it, and INSERTed into a +// table LibRed made (AceCreatedDatabaseTests) — but CREATE TABLE through the OLE DB provider failed with +// "Cannot find table or constraint", on both collation versions. +// +// ANSWERED: the file was missing MSysComplexColumns. ACE consults it whenever it creates a catalog object — +// CREATE TABLE and CREATE VIEW, and only those; DML, CREATE INDEX, ALTER ADD COLUMN and DROP TABLE all work +// without it. DatabaseCreator.CreateEmpty now writes it (and the nine MSysComplexType_* tables) for version +// >= 0x02. See docs/format/system-catalog.md. +// +// Ace_runs_ddl_against_a_libred_created_database is the regression guard and asserts; the rest report. +// Keep the guard: LibRed reading its own file back proves nothing about whether Access will accept it. +public class AceDdlOnLibRedDatabaseProbeTest(ITestOutputHelper output) +{ + [Theory] + [InlineData("v0", 0)] + [InlineData("v1", 1)] + public void Ace_runs_ddl_against_a_libred_created_database(string label, byte version) + { + string path = TemporaryDatabase.CreatePath($"ace-ddl-{label}-"); + try + { + DatabaseCreator.CreateEmpty(path, collation: new Collation(CollatingOrder.General, version)); + + using var connection = AceTestDatabase.Open(path); + output.WriteLine($"{label}: ACE opened the database"); + + foreach ((string what, string sql) in new[] + { + ("CREATE TABLE", "CREATE TABLE AceMade (K TEXT(30), V LONG)"), + ("CREATE TABLE + PK", "CREATE TABLE AceMade2 (K TEXT(30) CONSTRAINT PK PRIMARY KEY)"), + ("CREATE INDEX", "CREATE INDEX IX_AceMade ON AceMade (K)"), + }) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + Exception? error = Record.Exception(() => command.ExecuteNonQuery()); + output.WriteLine($" {what,-18} {(error is null ? "OK" : $"{error.GetType().Name}: {error.Message.Trim()}")}"); + Assert.Null(error); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + // Which system tables does an Access-authored database carry that a LibRed-created one does not? The + // Access-authored side needs a real file, so this reports what it can and says so when it cannot. + [Fact] + public void Probe_system_tables_libred_creates_versus_access() + { + string libred = TemporaryDatabase.CreatePath("systables-libred-"); + try + { + DatabaseCreator.CreateEmpty(libred); + string[] mine = SystemTables(libred); + output.WriteLine($"LibRed-created ({mine.Length}): {string.Join(", ", mine)}"); + + string? authored = Environment.GetEnvironmentVariable("LIBRED_V1_FIXTURE") is { } f && File.Exists(f) ? f : null; + if (authored is null) + { + output.WriteLine("Access-authored: LIBRED_V1_FIXTURE not set — cannot compare."); + return; + } + + string[] theirs = SystemTables(authored); + output.WriteLine($"Access-authored ({theirs.Length}): {string.Join(", ", theirs)}"); + output.WriteLine($"missing from LibRed: {string.Join(", ", theirs.Except(mine))}"); + output.WriteLine($"only in LibRed: {string.Join(", ", mine.Except(theirs))}"); + } + finally { TemporaryDatabase.Delete(libred); } + } + + // The control: DAO creates a database through the real engine, and its files are known to omit the + // NavPane/AccessStorage tables that Access adds on first open. If ACE will run DDL in a DAO-created + // database, those tables are not the blocker and the difference is elsewhere. + [Fact] + public void Probe_ace_ddl_against_a_dao_created_database() + { + object? engine = null; + foreach (int n in new[] { 170, 160, 150, 140, 130, 120 }) + { + Type? type = Type.GetTypeFromProgID($"DAO.DBEngine.{n}"); + if (type is null) continue; + try { engine = Activator.CreateInstance(type); break; } catch (Exception) { } + } + if (engine is null) { output.WriteLine("DAO unavailable."); return; } + + string path = TemporaryDatabase.CreatePath("ace-ddl-dao-"); + File.Delete(path); // DAO creates the file itself and refuses an existing one + try + { + object workspace = Invoke(engine, "CreateWorkspace", "", "admin", "", 2)!; + object database = Invoke(workspace, "CreateDatabase", path, ";LANGID=0x0409;CP=1252;COUNTRY=0", 128)!; + Invoke(database, "Close"); + + output.WriteLine($"DAO-created system tables: {string.Join(", ", SystemTables(path))}"); + + using var connection = AceTestDatabase.Open(path); + try + { + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TABLE AceMade (K TEXT(30), V LONG)"; + command.ExecuteNonQuery(); + output.WriteLine(" CREATE TABLE OK -> the missing system tables are NOT the blocker"); + } + catch (Exception ex) { output.WriteLine($" CREATE TABLE {ex.GetType().Name}: {ex.Message.Trim()}"); } + } + finally { TemporaryDatabase.Delete(path); } + } + + // Isolate the required table: start from a DAO-created database (where ACE DDL works), drop one system + // table with LibRed, and see whether ACE then refuses. Whatever flips it is what CREATE TABLE needs. + [Theory] + [InlineData("MSysComplexColumns")] + [InlineData("MSysComplexType_Text")] + [InlineData("MSysQueries")] + public void Probe_which_system_table_ace_ddl_needs(string drop) + { + object? engine = null; + foreach (int n in new[] { 170, 160, 150, 140, 130, 120 }) + { + Type? type = Type.GetTypeFromProgID($"DAO.DBEngine.{n}"); + if (type is null) continue; + try { engine = Activator.CreateInstance(type); break; } catch (Exception) { } + } + if (engine is null) { output.WriteLine("DAO unavailable."); return; } + + string path = TemporaryDatabase.CreatePath($"ace-ddl-drop-"); + File.Delete(path); + try + { + object workspace = Invoke(engine, "CreateWorkspace", "", "admin", "", 2)!; + object database = Invoke(workspace, "CreateDatabase", path, ";LANGID=0x0409;CP=1252;COUNTRY=0", 128)!; + Invoke(database, "Close"); + + try + { + using var db = JetDatabase.Open(path, readOnly: false); + db.DropTable(drop); + } + catch (Exception ex) { output.WriteLine($"dropping {drop}: {ex.GetType().Name}: {ex.Message.Trim()}"); return; } + + using var connection = AceTestDatabase.Open(path); + try + { + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TABLE AceMade (K TEXT(30), V LONG)"; + command.ExecuteNonQuery(); + output.WriteLine($"without {drop,-22} CREATE TABLE still OK"); + } + catch (Exception ex) { output.WriteLine($"without {drop,-22} CREATE TABLE {ex.GetType().Name}: {ex.Message.Trim()}"); } + } + finally { TemporaryDatabase.Delete(path); } + } + + // Does ACE actually WRITE to MSysComplexColumns when it creates an ordinary table, or does it only need + // the table to exist? That decides whether LibRed must populate it or merely provide an empty one — and + // the dumped schema is what LibRed would have to build. + [Fact] + public void Probe_what_ace_writes_to_complex_columns() + { + object? engine = null; + foreach (int n in new[] { 170, 160, 150, 140, 130, 120 }) + { + Type? type = Type.GetTypeFromProgID($"DAO.DBEngine.{n}"); + if (type is null) continue; + try { engine = Activator.CreateInstance(type); break; } catch (Exception) { } + } + if (engine is null) { output.WriteLine("DAO unavailable."); return; } + + string path = TemporaryDatabase.CreatePath("complex-cols-"); + File.Delete(path); + try + { + object workspace = Invoke(engine, "CreateWorkspace", "", "admin", "", 2)!; + object database = Invoke(workspace, "CreateDatabase", path, ";LANGID=0x0409;CP=1252;COUNTRY=0", 128)!; + Invoke(database, "Close"); + + DumpSchema(path); + output.WriteLine($"rows before any DDL: {RowCount(path)}"); + + using (var connection = AceTestDatabase.Open(path)) + foreach (string sql in new[] + { + "CREATE TABLE Plain (K TEXT(30), V LONG)", + "CREATE TABLE Typed (A LONG, B TEXT(20), C MEMO, D DATETIME, E CURRENCY, F GUID, G OLEOBJECT)", + "CREATE INDEX IX_Plain ON Plain (K)", + }) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + output.WriteLine($"after {sql[..Math.Min(34, sql.Length)],-36} rows = {RowCount(path)}"); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + // NOT probed by dropping indexes: removing MSysComplexColumns' indexes with LibRed and reopening makes + // ACE fault natively (0xC0000005) rather than report anything, so that route says the file is malformed, + // not whether the indexes are required. The constructive test — build the table *without* indexes in + // DatabaseCreator and see whether ACE DDL works — belongs with the implementation. + + // WHAT is ACE doing with MSysComplexColumns? It reads it and never writes it, so the question is which + // operations need it at all. If only CREATE fails, ACE consults it per-creation; if the whole DDL surface + // fails while DML keeps working, it is binding the table as part of a fixed system-table set and the + // "lookup" is really a bind of the catalog the DDL path expects to exist. + [Fact] + public void Probe_which_operations_need_complex_columns() + { + object? engine = null; + foreach (int n in new[] { 170, 160, 150, 140, 130, 120 }) + { + Type? type = Type.GetTypeFromProgID($"DAO.DBEngine.{n}"); + if (type is null) continue; + try { engine = Activator.CreateInstance(type); break; } catch (Exception) { } + } + if (engine is null) { output.WriteLine("DAO unavailable."); return; } + + string path = TemporaryDatabase.CreatePath("complex-need-"); + File.Delete(path); + try + { + object workspace = Invoke(engine, "CreateWorkspace", "", "admin", "", 2)!; + object database = Invoke(workspace, "CreateDatabase", path, ";LANGID=0x0409;CP=1252;COUNTRY=0", 128)!; + Invoke(database, "Close"); + + // Pre-build the objects the later statements act on, while the registry still exists. + using (var setup = AceTestDatabase.Open(path)) + foreach (string sql in new[] + { + "CREATE TABLE Existing (K TEXT(30), V LONG)", + "CREATE TABLE Doomed (K TEXT(30))", + "CREATE TABLE Altered (K TEXT(30))", + "INSERT INTO Existing (K, V) VALUES ('a', 1)", + }) + { + using var command = setup.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + using (var db = JetDatabase.Open(path, readOnly: false)) db.DropTable("MSysComplexColumns"); + output.WriteLine("dropped MSysComplexColumns; now exercising the surface:"); + + using var connection = AceTestDatabase.Open(path); + foreach ((string what, string sql) in new[] + { + ("SELECT", "SELECT COUNT(*) FROM Existing"), + ("INSERT", "INSERT INTO Existing (K, V) VALUES ('b', 2)"), + ("UPDATE", "UPDATE Existing SET V = 3 WHERE K = 'a'"), + ("DELETE", "DELETE FROM Existing WHERE K = 'b'"), + ("CREATE TABLE", "CREATE TABLE Fresh (K TEXT(30))"), + ("CREATE INDEX", "CREATE INDEX IX_Existing ON Existing (K)"), + ("ALTER ADD COL", "ALTER TABLE Altered ADD COLUMN Extra LONG"), + ("CREATE VIEW", "CREATE VIEW V1 AS SELECT K FROM Existing"), + ("DROP TABLE", "DROP TABLE Doomed"), + }) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + Exception? error = Record.Exception(() => command.ExecuteNonQuery()); + output.WriteLine($" {what,-14} {(error is null ? "OK" : error.Message.Trim())}"); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + private static int RowCount(string path) + { + using var db = JetDatabase.Open(path); + return db.OpenTable("MSysComplexColumns").Rows().Count(); + } + + private void DumpSchema(string path) + { + using var db = JetDatabase.Open(path); + TableDef table = db.Catalog.Tables.Single(t => t.Name == "MSysComplexColumns"); + output.WriteLine("MSysComplexColumns schema:"); + foreach (ColumnDef c in table.Columns.OrderBy(c => c.Index)) + output.WriteLine($" {c.Index} {c.Name,-16} {c.Type,-12} len={c.Length,-4} fixed={c.IsFixedLength} nullable={c.IsNullable}"); + foreach (IndexDef i in table.Indexes) + output.WriteLine($" index {i.Name,-16} unique={i.IsUnique} pk={i.IsPrimaryKey} cols={string.Join(",", i.Columns.Select(c => c.Column.Name))}"); + } + + private static object? Invoke(object target, string member, params object?[] args) => + target.GetType().InvokeMember(member, System.Reflection.BindingFlags.InvokeMethod, null, target, args); + + private static string[] SystemTables(string path) + { + using var db = JetDatabase.Open(path); + return [.. db.Catalog.Tables.Select(t => t.Name).OrderBy(n => n, StringComparer.OrdinalIgnoreCase)]; + } +} diff --git a/test/LibRed.Core.Tests/CollationTests.cs b/test/LibRed.Core.Tests/CollationTests.cs index 23183d61..d2825f9d 100644 --- a/test/LibRed.Core.Tests/CollationTests.cs +++ b/test/LibRed.Core.Tests/CollationTests.cs @@ -7,9 +7,9 @@ namespace LibRed.Core.Tests; /// /// Text collation is threaded from the database object into new column descriptors instead of being a -/// hardcoded constant. LibRed currently handles only General legacy (LCID 1033, version 0) — the order -/// every file it opens uses — so this verifies the plumbing is byte-faithful and that the index-key path -/// refuses any other collation rather than emitting wrong bytes. +/// hardcoded constant, so a table created in a General (v1) database gets v1 columns. Both General orders +/// encode index keys (v0 via JetTextCollation, v1 via JetTextCollationV1); other locales are refused rather +/// than encoded with the English weight table. This verifies the plumbing is byte-faithful. /// public class CollationTests { @@ -88,20 +88,31 @@ public void The_written_locale_bytes_are_byte_identical_to_the_old_hardcoded_con [Fact] public void Index_key_encoding_refuses_an_unsupported_collation() { - // A column whose collation isn't General legacy (e.g. a 2010+ General v1, or a non-English locale) - // must be rejected, not encoded with the wrong (General-v0) weight table. - var v1 = new ColumnDef + // A non-English locale must be rejected rather than encoded with the English weight table. + var cyrillic = new ColumnDef { Name = "C", Type = JetDataType.Text, - Collation = new Collation(CollatingOrder.General, 1), + Collation = new Collation(CollatingOrder.Cyrillic, 0), }; var ex = Assert.Throws(() => - IndexKeyEncoder.Encode([(v1, true)], ["abc"])); + IndexKeyEncoder.Encode([(cyrillic, true)], ["abc"])); Assert.Contains("not implemented", ex.Message); + } - // The same column at General legacy encodes fine. + [Fact] + public void Index_key_encoding_supports_both_general_orders() + { + // Both General orders encode, and to *different* bytes: v0 is the compacted one-byte-per-character + // table, v1 the Windows NLS weights verbatim (see GeneralV1CollationTests). var v0 = new ColumnDef { Name = "C", Type = JetDataType.Text, Collation = Collation.GeneralLegacy }; - Assert.NotEmpty(IndexKeyEncoder.Encode([(v0, true)], ["abc"])); + var v1 = new ColumnDef { Name = "C", Type = JetDataType.Text, Collation = Collation.General }; + + byte[] legacy = IndexKeyEncoder.Encode([(v0, true)], ["abc"]); + byte[] general = IndexKeyEncoder.Encode([(v1, true)], ["abc"]); + + Assert.NotEmpty(legacy); + Assert.NotEmpty(general); + Assert.NotEqual(Convert.ToHexString(legacy), Convert.ToHexString(general)); } } diff --git a/test/LibRed.Core.Tests/CollationVersionDiffProbeTest.cs b/test/LibRed.Core.Tests/CollationVersionDiffProbeTest.cs new file mode 100644 index 00000000..bef892e6 --- /dev/null +++ b/test/LibRed.Core.Tests/CollationVersionDiffProbeTest.cs @@ -0,0 +1,106 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: do the General-Legacy (v0) and General (v1) sort orders actually produce different index keys? +// +// The version byte at column 0x0E / page-0 0x71 is known to exist and to read 0/1. What has never been shown +// from bytes in this repo is that it selects a *weight table* rather than being metadata. This builds the same +// indexed text column, with the same values, in a v0 and a v1 database (via ACE so the engine does the +// encoding) and diffs the stored keys. +// +// The v1 database comes from LIBRED_V1_PROBE — Access itself must create it (Access.Application +// .NewCurrentDatabase honours the "New Database Sort Order" option; DAO ignores it and always writes v0). +public class CollationVersionDiffProbeTest(ITestOutputHelper output) +{ + private static readonly string[] Samples = + [ + "apple", "Apple", "cafe", "café", "O'Brien", "Anne-Marie", "a b", "a1", + "Ä", "ß", "Æ", "Ø", "ª", "¹", "£", "©", "«", "½", + "Α", "α", // Greek capital/small alpha + "А", "а", // Cyrillic capital/small A + "A", " ", "fi", // fullwidth A, ideographic space, ligature fi + "­", "coop", "co-op", + ]; + + [Fact] + public void Probe_v0_versus_v1_index_keys() + { + string? v1Source = Environment.GetEnvironmentVariable("LIBRED_V1_PROBE"); + if (v1Source is null || !File.Exists(v1Source)) + { + output.WriteLine("LIBRED_V1_PROBE not set to an existing v1 database — skipping."); + return; + } + + Dictionary v0 = KeysFor(TestDatabases.NorthwindAccdb, "collation-v0-", out byte v0Version); + Dictionary v1 = KeysFor(v1Source, "collation-v1-", out byte v1Version); + output.WriteLine($"v0 database reports version {v0Version}; v1 database reports version {v1Version}"); + output.WriteLine(""); + + int differing = 0; + foreach (string sample in Samples) + { + v0.TryGetValue(sample, out string? a); + v1.TryGetValue(sample, out string? b); + bool same = a is not null && a == b; + if (!same) differing++; + output.WriteLine($"{Describe(sample),-26} v0 {a ?? "(none)",-30} v1 {b ?? "(none)",-30} {(same ? "" : " <-- DIFFERS")}"); + } + + output.WriteLine(""); + output.WriteLine(differing == 0 + ? "=> identical keys for every sample: the version byte did not change the encoding for these values." + : $"=> {differing} of {Samples.Length} samples encode differently: the version byte selects a weight table."); + } + + /// Builds an indexed text column in a copy of through ACE, then reads the + /// stored index keys back with LibRed, mapped by the value that produced them. + private static Dictionary KeysFor(string source, string prefix, out byte version) + { + string path = TemporaryDatabase.CopyPath(source, prefix); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE CollProbe (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_CollProbe ON CollProbe (K)"); + for (int i = 0; i < Samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO CollProbe (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", Samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + + using var db = JetDatabase.Open(path); + version = db.DefaultCollationVersion; + var table = db.OpenTable("CollProbe"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_CollProbe"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + var keys = new Dictionary(); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values)) + keys[(string?)values[keyColumn.Index] ?? ""] = Convert.ToHexString(stored); + return keys; + } + finally { TemporaryDatabase.Delete(path); } + } + + private static string Describe(string s) => + s.All(c => c is >= ' ' and <= '~') ? $"\"{s}\"" : string.Concat(s.Select(c => $"U+{(int)c:X4}")); + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Core.Tests/ComplexSystemTableLayoutProbeTest.cs b/test/LibRed.Core.Tests/ComplexSystemTableLayoutProbeTest.cs new file mode 100644 index 00000000..553e04da --- /dev/null +++ b/test/LibRed.Core.Tests/ComplexSystemTableLayoutProbeTest.cs @@ -0,0 +1,77 @@ +using LibRed; +using LibRed.Catalog; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: the exact shape of the complex-type system tables in a database the real engine created, so +// DatabaseCreator can reproduce them rather than approximate them — page numbers, column layout, indexes, +// and the MSysObjects catalog rows. +// +// Complex columns (multi-value / attachment) arrived with Access 2007 / ACE 12, so these tables exist only +// from version byte 0x02 up; a Jet 4 (.mdb) database has none of them. +public class ComplexSystemTableLayoutProbeTest(ITestOutputHelper output) +{ + [Fact] + public void Probe_complex_system_table_layout() + { + object? engine = null; + foreach (int n in new[] { 170, 160, 150, 140, 130, 120 }) + { + Type? type = Type.GetTypeFromProgID($"DAO.DBEngine.{n}"); + if (type is null) continue; + try { engine = Activator.CreateInstance(type); break; } catch (Exception) { } + } + if (engine is null) { output.WriteLine("DAO unavailable."); return; } + + string path = TemporaryDatabase.CreatePath("complex-layout-"); + File.Delete(path); + try + { + object workspace = Invoke(engine, "CreateWorkspace", "", "admin", "", 2)!; + object database = Invoke(workspace, "CreateDatabase", path, ";LANGID=0x0409;CP=1252;COUNTRY=0", 128)!; + Invoke(database, "Close"); + + using var db = JetDatabase.Open(path); + output.WriteLine($"file: {new FileInfo(path).Length / 4096} pages of 4096"); + output.WriteLine(""); + + output.WriteLine("page table rows indexes"); + foreach (TableDef t in db.Catalog.Tables.OrderBy(t => t.DefinitionPage)) + output.WriteLine($"{t.DefinitionPage,4} {t.Name,-30} {db.OpenTable(t.Name).Rows().Count(),4} " + + $"{string.Join(", ", t.Indexes.Select(i => $"{i.Name}({string.Join("+", i.Columns.Select(c => c.Column.Name))}{(i.IsUnique ? ",unique" : "")}{(i.IsPrimaryKey ? ",pk" : "")})"))}"); + + output.WriteLine(""); + foreach (TableDef t in db.Catalog.Tables.Where(t => t.Name.StartsWith("MSysComplex", StringComparison.OrdinalIgnoreCase)) + .OrderBy(t => t.DefinitionPage)) + { + output.WriteLine($"== {t.Name} (page {t.DefinitionPage})"); + foreach (ColumnDef c in t.Columns.OrderBy(c => c.Index)) + output.WriteLine($" {c.Index} {c.Name,-22} {c.Type,-12} len={c.Length,-4} fixed={c.IsFixedLength,-5} " + + $"nullable={c.IsNullable,-5} id={c.ColumnId} auto={c.IsAutoNumber}"); + foreach (IndexDef i in t.Indexes) + output.WriteLine($" index {i.Name,-24} root={i.RootPage} unique={i.IsUnique} pk={i.IsPrimaryKey} " + + $"required={i.Required} ignoreNulls={i.IgnoreNulls} cols={string.Join(",", i.Columns.Select(c => $"{c.Column.Name}{(c.Ascending ? "" : " DESC")}"))}"); + } + + output.WriteLine(""); + output.WriteLine("MSysObjects rows for the complex tables (Id, ParentId, Name, Type, Flags):"); + var objects = db.OpenTable("MSysObjects"); + var def = objects.Definition; + int idIdx = def.FindColumn("Id")!.Index, parentIdx = def.FindColumn("ParentId")!.Index; + int nameIdx = def.FindColumn("Name")!.Index, typeIdx = def.FindColumn("Type")!.Index; + int flagsIdx = def.FindColumn("Flags")!.Index; + foreach (object?[] row in objects.Rows()) + { + string name = (string?)row[nameIdx] ?? ""; + if (!name.StartsWith("MSys", StringComparison.OrdinalIgnoreCase)) continue; + output.WriteLine($" Id={Convert.ToInt32(row[idIdx]),6} ParentId=0x{Convert.ToInt32(row[parentIdx]):X8} " + + $"{name,-30} Type={row[typeIdx]} Flags=0x{Convert.ToInt32(row[flagsIdx] ?? 0):X8}"); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + private static object? Invoke(object target, string member, params object?[] args) => + target.GetType().InvokeMember(member, System.Reflection.BindingFlags.InvokeMethod, null, target, args); +} diff --git a/test/LibRed.Core.Tests/DaoDatabaseCreationProbeTest.cs b/test/LibRed.Core.Tests/DaoDatabaseCreationProbeTest.cs new file mode 100644 index 00000000..44a66981 --- /dev/null +++ b/test/LibRed.Core.Tests/DaoDatabaseCreationProbeTest.cs @@ -0,0 +1,150 @@ +using System.Reflection; +using LibRed.Catalog; +using LibRed; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: can DAO's DBEngine.CreateDatabase produce a database with the Access-2010 "General" (v1) sort order? +// +// The v1 weight table is the one piece of the text collation LibRed cannot encode, and the blocker is that no +// v1 database exists to reverse-engineer. Access's UI can make one ("New database sort order" = General), but +// that is a manual step. DAO is the programmatic creator the repo already uses (DaoDatabaseCreator), so the +// question is whether its connect string or database-type argument can select the sort version too — the +// documented string carries only LANGID/CP/COUNTRY, i.e. the locale, not the version. +// +// Each attempt is created, then opened with LibRed to read the sort-order version byte (page 0, 0x71). +public class DaoDatabaseCreationProbeTest(ITestOutputHelper output) +{ + private const int UseJet = 2; + + [Fact] + public void Probe_dao_created_database_sort_order_versions() + { + object? engine = CreateDbEngine(out string progId); + if (engine is null) + { + output.WriteLine("No DAO.DBEngine ProgID could be instantiated — DAO is unavailable in this process."); + return; + } + + output.WriteLine($"DAO engine: {progId}"); + object workspace = Invoke(engine, "CreateWorkspace", "", "admin", "", UseJet)!; + + // (label, connect string, database-type argument). 0 = omit the argument entirely. + (string Label, string Connect, int Type)[] attempts = + [ + ("default (ACE 12)", ";LANGID=0x0409;CP=1252;COUNTRY=0", 128), + ("no type argument", ";LANGID=0x0409;CP=1252;COUNTRY=0", 0), + ("Jet 4", ";LANGID=0x0409;CP=1252;COUNTRY=0", 64), + ("general-legacy spelled out", ";LANGID=0x0409;CP=1252;COUNTRY=0;SORTORDER=GeneralLegacy", 128), + ("general spelled out", ";LANGID=0x0409;CP=1252;COUNTRY=0;SORTORDER=General", 128), + ("NLS version requested", ";LANGID=0x0409;CP=1252;COUNTRY=0;NLSVERSION=1", 128), + ]; + + foreach ((string label, string connect, int type) in attempts) + { + string path = TemporaryDatabase.CreatePath("dao-probe-"); + try + { + object database = type > 0 + ? Invoke(workspace, "CreateDatabase", path, connect, type)! + : Invoke(workspace, "CreateDatabase", path, connect)!; + Invoke(database, "Close"); + + using var db = JetDatabase.Open(path); + output.WriteLine($" {label,-28} -> created; collation version = {db.DefaultCollationVersion}"); + } + catch (TargetInvocationException ex) + { + output.WriteLine($" {label,-28} -> rejected: {ex.InnerException?.Message.Trim()}"); + } + catch (Exception ex) + { + output.WriteLine($" {label,-28} -> {ex.GetType().Name}: {ex.Message.Trim()}"); + } + finally { TemporaryDatabase.Delete(path); } + } + } + + // CompactDatabase is the documented way to give an existing database a different collating order: it takes + // a destination locale. If the sort *version* can be selected anywhere in DAO, this is the other candidate. + [Fact] + public void Probe_dao_compact_with_destination_locale() + { + object? engine = CreateDbEngine(out string progId); + if (engine is null) { output.WriteLine("DAO unavailable."); return; } + output.WriteLine($"DAO engine: {progId}"); + + string[] locales = + [ + ";LANGID=0x0409;CP=1252;COUNTRY=0", + ";LANGID=0x0409;CP=1252;COUNTRY=0;SORTORDER=General", + ";LANGID=0x0809;CP=1252;COUNTRY=0", // en-GB, a different LANGID entirely + ]; + + foreach (string locale in locales) + { + string source = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "dao-compact-src-"); + string destination = TemporaryDatabase.CreatePath("dao-compact-dst-"); + try + { + Invoke(engine, "CompactDatabase", source, destination, locale); + using var db = JetDatabase.Open(destination); + output.WriteLine($" {locale,-52} -> version {db.DefaultCollationVersion}, lcid {db.DefaultCollationLcid}"); + } + catch (TargetInvocationException ex) + { + output.WriteLine($" {locale,-52} -> rejected: {ex.InnerException?.Message.Trim()}"); + } + catch (Exception ex) + { + output.WriteLine($" {locale,-52} -> {ex.GetType().Name}: {ex.Message.Trim()}"); + } + finally { TemporaryDatabase.Delete(source); TemporaryDatabase.Delete(destination); } + } + } + + // Read the sort order of a database created by Access itself (via Access.Application.NewCurrentDatabase), + // which — unlike DAO — honours the application's "New Database Sort Order" option. Path comes from the + // LIBRED_V1_PROBE environment variable so the COM automation stays outside the test. + [Fact] + public void Probe_sort_order_of_an_access_created_database() + { + string? path = Environment.GetEnvironmentVariable("LIBRED_V1_PROBE"); + if (path is null || !File.Exists(path)) + { + output.WriteLine("LIBRED_V1_PROBE not set to an existing file — nothing to inspect."); + return; + } + + using var db = JetDatabase.Open(path); + output.WriteLine($"{Path.GetFileName(path)}"); + output.WriteLine($" page-0 sort order : lcid {db.DefaultCollationLcid}, version {db.DefaultCollationVersion}"); + foreach (var table in db.Catalog.Tables.Where(t => t.Columns.Any(c => c.Type is JetDataType.Text or JetDataType.Memo))) + { + var collations = table.Columns + .Where(c => c.Type is JetDataType.Text or JetDataType.Memo) + .Select(c => $"{c.Collation.Order} v{c.Collation.Version}") + .Distinct(); + output.WriteLine($" {table.Name,-24} {string.Join(", ", collations)}"); + } + } + + private static object? CreateDbEngine(out string progId) + { + 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; + } + + private static object? Invoke(object target, string member, params object?[] args) => + target.GetType().InvokeMember(member, BindingFlags.InvokeMethod, null, target, args); +} diff --git a/test/LibRed.Core.Tests/DaoPageLayoutProbeTest.cs b/test/LibRed.Core.Tests/DaoPageLayoutProbeTest.cs new file mode 100644 index 00000000..57287534 --- /dev/null +++ b/test/LibRed.Core.Tests/DaoPageLayoutProbeTest.cs @@ -0,0 +1,90 @@ +using System.Buffers.Binary; +using LibRed; +using LibRed.Catalog; +using LibRed.Pages; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: what the real engine puts on every page of a freshly created database, and in what order — the page +// budget behind a DAO-created ACE 12 file. Useful for judging how closely LibRed's own bootstrap should +// follow it, and for reading a hex dump of one without guessing. +// +// Every page is labelled from its own header (type byte, and the owning TDEF for data/index pages) and +// cross-referenced against the catalog: each table's TDEF page, its index roots, and the usage-map pages its +// TDEF points at. +public class DaoPageLayoutProbeTest(ITestOutputHelper output) +{ + [Fact] + public void Probe_dao_created_page_layout() + { + object? engine = null; + foreach (int n in new[] { 170, 160, 150, 140, 130, 120 }) + { + Type? type = Type.GetTypeFromProgID($"DAO.DBEngine.{n}"); + if (type is null) continue; + try { engine = Activator.CreateInstance(type); break; } catch (Exception) { } + } + if (engine is null) { output.WriteLine("DAO unavailable."); return; } + + string path = TemporaryDatabase.CreatePath("dao-layout-"); + File.Delete(path); + try + { + object workspace = Invoke(engine, "CreateWorkspace", "", "admin", "", 2)!; + object database = Invoke(workspace, "CreateDatabase", path, ";LANGID=0x0409;CP=1252;COUNTRY=0", 128)!; + Invoke(database, "Close"); + + byte[] file = File.ReadAllBytes(path); + using var db = JetDatabase.Open(path); + int pageSize = db.Format.PageSize; + int pages = file.Length / pageSize; + + // Label what the catalog knows about: TDEF pages and index roots. + var owners = new Dictionary(); + var labels = new Dictionary(); + foreach (TableDef t in db.Catalog.Tables) + { + owners[t.DefinitionPage] = t.Name; + labels[t.DefinitionPage] = $"TDEF {t.Name}"; + foreach (IndexDef i in t.Indexes) + if (i.RootPage > 0) labels[i.RootPage] = $"index root {t.Name}.{i.Name}"; + } + + output.WriteLine($"{pages} pages of {pageSize} bytes ({file.Length:N0} bytes)"); + output.WriteLine(""); + output.WriteLine("page type owner label"); + for (int p = 0; p < pages; p++) + { + ReadOnlySpan page = file.AsSpan(p * pageSize, pageSize); + var type = (PageType)page[0]; + // Data, index and usage-map pages carry the owning TDEF page at offset 4. + int owner = type is PageType.DataPage or PageType.IntermediateIndexPage + or PageType.LeafIndexPage or PageType.PageUsageBitmap + ? BinaryPrimitives.ReadInt32LittleEndian(page[4..]) + : 0; + string ownerName = owner > 0 && owners.TryGetValue(owner, out string? n) ? $"{owner} {n}" : owner > 0 ? owner.ToString() : ""; + labels.TryGetValue(p, out string? label); + if (label is null && p == 0) label = "database definition"; + if (label is null && p == 1) label = "global free-pages map"; + output.WriteLine($"{p,4} {type,-22} {ownerName,-24} {label}"); + } + + // Which pages does each TDEF name as its usage maps? Those are the "unlabelled" data pages. + output.WriteLine(""); + output.WriteLine("usage-map pointers held in each TDEF (row:page):"); + foreach (TableDef t in db.Catalog.Tables.OrderBy(t => t.DefinitionPage)) + { + ReadOnlySpan tdef = file.AsSpan(t.DefinitionPage * pageSize, pageSize); + // Each pointer is a 1-byte row index then a 3-byte page number. + int owned = BinaryPrimitives.ReadInt32LittleEndian(tdef[db.Format.TdefOwnedPagesOffset..]); + int free = BinaryPrimitives.ReadInt32LittleEndian(tdef[db.Format.TdefFreePagesOffset..]); + output.WriteLine($" {t.Name,-30} owned=page {owned >> 8} row {owned & 0xFF} free=page {free >> 8} row {free & 0xFF}"); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + private static object? Invoke(object target, string member, params object?[] args) => + target.GetType().InvokeMember(member, System.Reflection.BindingFlags.InvokeMethod, null, target, args); +} diff --git a/test/LibRed.Core.Tests/GeneralV1CollationAccessTests.cs b/test/LibRed.Core.Tests/GeneralV1CollationAccessTests.cs new file mode 100644 index 00000000..b0d397d7 --- /dev/null +++ b/test/LibRed.Core.Tests/GeneralV1CollationAccessTests.cs @@ -0,0 +1,164 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// End-to-end oracle for the "General" (v1) collation. The unit tests pin the encoder against keys measured +// from ACE; this re-derives them from a live database, so a change in ACE (or a wrong assumption about which +// weight table it uses) is caught rather than absorbed. +// +// The v1 database is one LibRed creates itself (DatabaseCreator.CreateEmpty with Collation.General) — which +// is also the point: nothing else here can make one. DAO always writes v0, ignoring the application setting, +// and Access only honours "New database sort order" through its own UI. So this doubles as the test that +// LibRed's create-with-collation produces a file ACE accepts as a General database. +public class GeneralV1CollationAccessTests +{ + private static readonly string[] Samples = + [ + "apple", "Apple", "cafe", "café", "O'Brien", "Anne-Marie", "a b", "a1", + "Ä", "ß", "Æ", "ª", "¹", "£", "©", "«", "½", "Α", "А", "A", "fi", "coop", "co-op", + ]; + + /// A fresh, empty database whose default sort order is General (v1), created by LibRed. + private static string CreateV1Database(string prefix) + { + string path = TemporaryDatabase.CreatePath(prefix); + DatabaseCreator.CreateEmpty(path, collation: Collation.General); + return path; + } + + // ACE authors the table and index here, in a v1 database LibRed created — so the keys being compared are + // ACE's own, written by ACE, in a file LibRed made. No external fixture involved. + [Fact] + public void Libred_reproduces_ace_index_keys_in_a_v1_database() + { + string path = CreateV1Database("v1-collation-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE V1K (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_V1K ON V1K (K)"); + for (int i = 0; i < Samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO V1K (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", Samples[i]); + insert.Parameters.AddWithValue("v", i); + insert.ExecuteNonQuery(); + } + } + + using var db = JetDatabase.Open(path); + Assert.Equal(Collation.GeneralVersion, db.DefaultCollationVersion); + + var table = db.OpenTable("V1K"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_V1K"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + Assert.Equal(Collation.General, keyColumn.Collation); + + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + int checkedKeys = 0; + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + { + if (!rows.TryGetValue(rowId, out object?[]? values)) continue; + string value = (string?)values[keyColumn.Index] ?? ""; + + var aligned = new object?[table.Definition.Columns.Count]; + aligned[keyColumn.Index] = values[keyColumn.Index]; + + Assert.Equal( + (value, Convert.ToHexString(stored)), + (value, Convert.ToHexString(IndexKeyEncoder.Encode(index.Columns, aligned)))); + checkedKeys++; + } + + Assert.Equal(Samples.Length, checkedKeys); + } + finally { TemporaryDatabase.Delete(path); } + } + + // A LibRed-written index in a v1 database must be one ACE agrees with — the keys have to be right, and in + // the right order, for ACE's own seeks to find the rows. + [Fact] + public void Ace_reads_an_index_libred_wrote_in_a_v1_database() + { + var column = new ColumnDef { Name = "K", Type = JetDataType.Text, Index = 0, Collation = Collation.General }; + string[] unique = Samples + .GroupBy(v => Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [v]))) + .Select(g => g.First()) + .ToArray(); + + string path = CreateV1Database("v1-written-"); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + { + db.CreateTable("W1", + [new ColumnSpec("K", JetDataType.Text, 50, IsFixedLength: false), + new ColumnSpec("V", JetDataType.Int32, 4, IsFixedLength: true)], + primaryKey: ["K"]); + var table = db.OpenTable("W1"); + // Dedupe by encoded key, not by string: the collation folds case, so "apple" and "Apple" + // are the *same* key and a unique primary key rightly rejects the second. + for (int i = 0; i < unique.Length; i++) table.Insert([unique[i], i]); + } + + using var connection = AceTestDatabase.Open(path); + using (var count = connection.CreateCommand()) + { + count.CommandText = "SELECT COUNT(*) FROM W1"; + Assert.Equal(unique.Length, Convert.ToInt32(count.ExecuteScalar())); + } + + // Seek each value through ACE: it resolves these against the index LibRed built. + foreach (string sample in unique) + { + using var seek = connection.CreateCommand(); + seek.CommandText = "SELECT COUNT(*) FROM W1 WHERE K = ?"; + seek.Parameters.AddWithValue("k", sample); + Assert.Equal(1, Convert.ToInt32(seek.ExecuteScalar())); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + // The create-with-collation option itself: LibRed makes a General (v1) database, and both LibRed and + // ACE agree that is what it is. + [Fact] + public void Libred_creates_a_v1_database_that_ace_opens() + { + string path = CreateV1Database("v1-created-"); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + { + Assert.Equal(Collation.General, db.Collation); + Assert.Equal(Collation.GeneralVersion, db.DefaultCollationVersion); + + // A table created in it inherits v1, rather than defaulting to the engine's legacy order. + db.CreateTable("T", + [new ColumnSpec("K", JetDataType.Text, 30, IsFixedLength: false)], + primaryKey: ["K"]); + var table = db.OpenTable("T"); + Assert.Equal(Collation.General, table.Definition.FindColumn("K")!.Collation); + table.Insert(["Α"]); // a character the v0 table cannot even encode + } + + using var connection = AceTestDatabase.Open(path); + using var read = connection.CreateCommand(); + read.CommandText = "SELECT K FROM T"; + Assert.Equal("Α", read.ExecuteScalar()); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Core.Tests/GeneralV1CollationTests.cs b/test/LibRed.Core.Tests/GeneralV1CollationTests.cs new file mode 100644 index 00000000..639ebc30 --- /dev/null +++ b/test/LibRed.Core.Tests/GeneralV1CollationTests.cs @@ -0,0 +1,129 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// The Access-2010+ "General" (v1) text collation. Its weights are the Windows NLS weights verbatim — the +// two-byte (Script Member, Alphabetic Weight) primary and the Diacritic Weight secondary — taken from the +// Windows Server 2008 sorting weight table, which is the one ACE froze. +// +// The expected keys below were measured from a database Access itself created with the General sort order, +// by reading the bytes ACE stored in its own index. They are ground truth, not this encoder's output. +public class GeneralV1CollationTests +{ + private static byte[] Encode(string value, Collation collation, bool ascending = true) + { + var column = new ColumnDef { Name = "t", Type = JetDataType.Text, Index = 0, Collation = collation }; + return IndexKeyEncoder.Encode([(column, ascending)], [value]); + } + + private static string Hex(byte[] bytes) => Convert.ToHexString(bytes); + + [Theory] + // Plain letters: two bytes per character, and case folds (the Case Weight is the section ACE truncates). + [InlineData("apple", "7F0E020E7E0E7E0E480E210100")] + [InlineData("Apple", "7F0E020E7E0E7E0E480E210100")] + [InlineData("cafe", "7F0E0A0E020E230E210100")] + // Accent: the secondary section carries the Diacritic Weight (acute = 0x0E). + [InlineData("café", "7F0E0A0E020E230E21010202020E00")] + // Space has a real primary weight (07 02) inside a string; trailing spaces are trimmed. + [InlineData("a b", "7F0E0207020E090100")] + [InlineData("a1", "7F0E020D190100")] + [InlineData("Ä", "7F0E02011300")] + // Expansions come from the table's EXPANSION section: sharp s -> s,s and AE -> A,E. + [InlineData("ß", "7F0E910E910100")] + [InlineData("Æ", "7F0E020E210100")] + [InlineData("Ø", "7F0E7C012100")] + // Ordinal indicator: base letter primary with a distinguishing secondary. + [InlineData("ª", "7F0E02010300")] + // Superscript one shares Digit One's primary — 0D19, the weight that identifies the Server 2008 table. + [InlineData("¹", "7F0D190100")] + [InlineData("£", "7F07980100")] + [InlineData("©", "7F0A070100")] + [InlineData("«", "7F08180100")] + [InlineData("½", "7F0D1801D600")] + // Scripts beyond Latin, which the v0 table cannot encode at all. + [InlineData("Α", "7F0F020100")] + [InlineData("α", "7F0F020100")] + [InlineData("А", "7F10020100")] + // Width folds, because width lives in the discarded Case Weight. + [InlineData("A", "7F0E020100")] + [InlineData(" ", "7F07020100")] + // Ligature expands to f + i. + [InlineData("fi", "7F0E230E320100")] + // Soft hyphen is wholly ignorable in this table (v0 records it inline as 0x83 instead). + [InlineData("­", "7F0100")] + [InlineData("coop", "7F0E0A0E7C0E7C0E7E0100")] + // Word-sort ignorables: no primary weight, an inline record instead. The position counts primary + // *weights*, not bytes — 0x0B = 0x07 + 4x1 after one character, even though two bytes were emitted. + [InlineData("co-op", "7F0E0A0E7C0E7C0E7E01010101800F068200")] + [InlineData("O'Brien", "7F0E7C0E090E8A0E320E210E7001010101800B068000")] + [InlineData("Anne-Marie", "7F0E020E700E700E210E510E020E8A0E320E21010101018017068200")] + public void Encodes_the_bytes_ace_stores(string value, string expected) => + Assert.Equal(expected, Hex(Encode(value, Collation.General))); + + [Fact] + public void The_two_general_orders_encode_differently() + { + Assert.NotEqual( + Hex(Encode("apple", Collation.GeneralLegacy)), + Hex(Encode("apple", Collation.General))); + } + + // The whole point of a key: memcmp order must be value order. + [Fact] + public void Keys_sort_in_value_order() + { + string[] values = ["", "a", "ab", "apple", "b", "cafe", "café", "z", "Α", "А"]; + var keys = values.Select(v => Encode(v, Collation.General)).ToList(); + + for (int i = 0; i + 1 < values.Length; i++) + Assert.True(Compare(keys[i], keys[i + 1]) < 0, + $"'{values[i]}' should sort before '{values[i + 1]}'"); + + static int Compare(byte[] a, byte[] b) + { + int n = Math.Min(a.Length, b.Length); + for (int i = 0; i < n; i++) if (a[i] != b[i]) return a[i].CompareTo(b[i]); + return a.Length.CompareTo(b.Length); + } + } + + [Fact] + public void Descending_inverts_every_byte_and_appends_a_terminator() + { + byte[] ascending = Encode("apple", Collation.General); + byte[] descending = Encode("apple", Collation.General, ascending: false); + + Assert.Equal(ascending.Length + 1, descending.Length); + for (int i = 0; i < ascending.Length; i++) Assert.Equal((byte)~ascending[i], descending[i]); + Assert.Equal(0x00, descending[^1]); + } + + // A character with no weight must fail loudly rather than produce a key that sorts wrongly. U+0378 is + // unassigned in Unicode and absent from the table; private-use characters (U+E000) do have weights. + [Fact] + public void An_unweighted_character_is_refused() + { + var error = Assert.Throws(() => Encode("a͸b", Collation.General)); + Assert.Contains("no weight", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void An_empty_string_encodes_to_an_empty_key() + { + Assert.Equal("7F0100", Hex(Encode("", Collation.General))); + Assert.Equal("7F0100", Hex(Encode(" ", Collation.General))); // trailing spaces are trimmed + } + + // Non-English locales are still refused: the embedded table is the English (1033) one. + [Fact] + public void A_non_english_locale_is_still_refused() + { + var error = Assert.Throws( + () => Encode("a", new Collation(CollatingOrder.Cyrillic, 1))); + Assert.Contains("not implemented", error.Message, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/test/LibRed.Core.Tests/Latin1SymbolCollationAccessTests.cs b/test/LibRed.Core.Tests/Latin1SymbolCollationAccessTests.cs new file mode 100644 index 00000000..0d8bd679 --- /dev/null +++ b/test/LibRed.Core.Tests/Latin1SymbolCollationAccessTests.cs @@ -0,0 +1,90 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// The Latin-1 punctuation/symbol block: ACE writes an index key for every one of these, and LibRed used to +// refuse them outright ("collation weight is not implemented yet"), so a text column holding "£100" or "Café ©" +// could not be indexed. ACE is the oracle here — it builds the index, LibRed re-encodes the same value and must +// reproduce the stored bytes exactly. +public class Latin1SymbolCollationAccessTests +{ + // Every printable Latin-1 character outside A–Z/a–z/0–9 and the ASCII punctuation LibRed already knew, + // plus the soft hyphen (which carries no primary weight at all). + private const string Latin1Symbols = + "¡¢£¤¥¦§¨©ª«¬­®¯" + + "°±²³´µ¶·¸¹º»¼½¾¿" + + "×÷"; + + [Fact] + public void Libred_reproduces_ace_index_keys_for_every_latin1_symbol() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "latin1-collation-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE L1 (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_L1 ON L1 (K)"); + for (int i = 0; i < Latin1Symbols.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO L1 (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", Latin1Symbols[i].ToString()); + insert.Parameters.AddWithValue("v", i); + insert.ExecuteNonQuery(); + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("L1"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_L1"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + int checkedKeys = 0; + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + { + if (!rows.TryGetValue(rowId, out object?[]? values)) continue; + string value = (string?)values[keyColumn.Index] ?? ""; + + var aligned = new object?[table.Definition.Columns.Count]; + aligned[keyColumn.Index] = values[keyColumn.Index]; + + Assert.Equal( + (value, Convert.ToHexString(stored)), + (value, Convert.ToHexString(IndexKeyEncoder.Encode(index.Columns, aligned)))); + checkedKeys++; + } + + Assert.Equal(Latin1Symbols.Length, checkedKeys); + } + finally { TemporaryDatabase.Delete(path); } + } + + // Superscript digits take their base digit's primary weight and no distinguishing secondary, and ACE's key + // format stops after the secondary section — so these collate *equal*, which matters for a unique index. + [Theory] + [InlineData('¹', '1')] + [InlineData('²', '2')] + [InlineData('³', '3')] + public void A_superscript_digit_encodes_identically_to_its_base_digit(char superscript, char digit) + { + var column = new ColumnDef + { + Name = "t", Type = JetDataType.Text, Index = 0, Collation = Collation.GeneralLegacy, + }; + Assert.Equal( + Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [digit.ToString()])), + Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [superscript.ToString()]))); + } + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Core.Tests/SortKeyComparisonProbeTest.cs b/test/LibRed.Core.Tests/SortKeyComparisonProbeTest.cs new file mode 100644 index 00000000..f95be7cf --- /dev/null +++ b/test/LibRed.Core.Tests/SortKeyComparisonProbeTest.cs @@ -0,0 +1,383 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE (no assertions about desired behaviour): how do .NET's sort keys relate to the text index keys ACE +// actually writes? +// +// LibRed encodes text index keys from hand-built weight tables (JetTextCollation), verified value by value +// against ACE. The open question is whether a platform API could produce them instead — which matters most +// for the General (v1) order, whose keys LibRed reads but cannot yet encode (Collation.IsIndexKeyEncodable). +// +// Four encodings per string, so the shapes can be compared directly: +// 1. ACE - the bytes ACE stored in its own index (ground truth). +// 2. LibRed - IndexKeyEncoder over the same value. +// 3. ICU/NLS - CompareInfo.GetSortKey, which is what .NET gives you. Since .NET 5 this is ICU on every +// platform unless System.Globalization.UseNls is set, so it is NOT the Win32 sort key. +// 4. LCMapStringEx(LCMAP_SORTKEY) - the Win32 NLS API Jet itself used, called directly so the comparison +// does not depend on which globalization backend .NET happens to be using. +public class SortKeyComparisonProbeTest(ITestOutputHelper output) +{ + private const uint LcmapSortkey = 0x00000400; + private const uint NormIgnoreCase = 0x00000001; + + [DllImport("kernel32.dll", EntryPoint = "LCMapStringEx", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern int LCMapStringEx( + string localeName, uint mapFlags, string src, int srcLen, + byte[]? dest, int destLen, IntPtr versionInformation, IntPtr reserved, IntPtr sortHandle); + + private static readonly string[] Samples = + [ + "apple", "Apple", "APPLE", "banana", + "cafe", "café", "CAFÉ", + "O'Brien", "OBrien", "Anne-Marie", "AnneMarie", + "a b", "ab", "a1", "Ä", "ä", "z", "", + ]; + + [Fact] + public void Probe_sort_keys_against_ace_index_keys() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "sortkey-probe-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE SortProbe (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_SortProbe ON SortProbe (K)"); + for (int i = 0; i < Samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO SortProbe (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", Samples[i]); + insert.Parameters.AddWithValue("v", i); + insert.ExecuteNonQuery(); + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("SortProbe"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_SortProbe"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + ColumnDef valueColumn = table.Definition.FindColumn("V")!; + + output.WriteLine($"collation: {keyColumn.Collation.Order} v{keyColumn.Collation.Version} " + + $"(.NET globalization backend: {(UsingIcu() ? "ICU" : "NLS")})"); + output.WriteLine(""); + + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + { + if (!rows.TryGetValue(rowId, out object?[]? values)) continue; + string value = (string?)values[keyColumn.Index] ?? ""; + + var aligned = new object?[table.Definition.Columns.Count]; + aligned[keyColumn.Index] = values[keyColumn.Index]; + byte[] libred = IndexKeyEncoder.Encode(index.Columns, aligned); + + output.WriteLine($"\"{value}\" (row V={values[valueColumn.Index]})"); + output.WriteLine($" ACE {Hex(stored)}"); + output.WriteLine($" LibRed {Hex(libred)}{(Hex(libred) == Hex(stored) ? " == ACE" : " != ACE")}"); + output.WriteLine($" GetSort {Hex(SortKey(value))}"); + output.WriteLine($" LCMap {Hex(WinSortKey(value))}"); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + // The single-character mapping, across a wide set: ACE's primary weight (LibRed's table, verified equal to + // ACE above) against the Win32 NLS primary for the same character. If ACE's table is an order-preserving + // compaction of the NLS weights, sorting by one must sort by the other. + [Fact] + public void Probe_primary_weight_mapping_against_win32() + { + var mapping = new List<(char Ch, int Ace, int Nls)>(); + foreach (char ch in "abcdefghijklmnopqrstuvwxyz0123456789") + { + byte[] ace = JetTextPrimary(ch); + byte[] nls = WinSortKey(ch.ToString()); + if (ace.Length != 1 || nls.Length < 2) continue; + mapping.Add((ch, ace[0], (nls[0] << 8) | nls[1])); + } + + output.WriteLine("char ACE NLS"); + foreach ((char ch, int ace, int nls) in mapping) + output.WriteLine($" {ch} {ace:X2} {nls:X4}"); + + var byAce = mapping.OrderBy(m => m.Ace).Select(m => m.Ch).ToArray(); + var byNls = mapping.OrderBy(m => m.Nls).Select(m => m.Ch).ToArray(); + output.WriteLine(""); + output.WriteLine($"order by ACE weight: {new string(byAce)}"); + output.WriteLine($"order by NLS weight: {new string(byNls)}"); + output.WriteLine(byAce.SequenceEqual(byNls) + ? "=> the two orders agree: ACE's table is an order-preserving compaction of the NLS weights." + : "=> the orders DIVERGE: ACE is not simply a compaction of NLS."); + } + + /// The primary-weight bytes ACE stores for a single character (the section between the 0x7F start + /// flag and the 0x01 end-of-primary marker). + private static byte[] JetTextPrimary(char ch) + { + var column = new ColumnDef { Name = "t", Type = JetDataType.Text, Index = 0, Collation = Collation.GeneralLegacy }; + byte[] key = IndexKeyEncoder.Encode([(column, true)], [ch.ToString()]); + int end = Array.IndexOf(key, (byte)0x01, 1); + return end < 0 ? [] : key[1..end]; + } + + // Which sort-order version do the committed fixtures actually use? Encoding v1 (General) keys is blocked + // on having a database that contains them to check against. + [Fact] + public void Probe_fixture_collation_versions() + { + foreach (string file in Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "Data"), "*.accdb") + .Concat(Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "Data"), "*.mdb"))) + { + try + { + using var db = JetDatabase.Open(file); + var collations = db.Catalog.Tables + .SelectMany(t => t.Columns) + .Where(c => c.Type is JetDataType.Text or JetDataType.Memo) + .Select(c => $"{c.Collation.Order} v{c.Collation.Version}") + .Distinct() + .OrderBy(x => x) + .ToArray(); + output.WriteLine($"{Path.GetFileName(file),-28} {string.Join(", ", collations)}"); + } + catch (Exception ex) { output.WriteLine($"{Path.GetFileName(file),-28} <{ex.GetType().Name}>"); } + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct NlsVersionInfoEx + { + public uint dwNLSVersionInfoSize; + public uint dwNLSVersion; + public uint dwDefinedVersion; + public uint dwEffectiveId; + public Guid guidCustomVersion; + } + + [DllImport("kernel32.dll", EntryPoint = "GetNLSVersionEx", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetNLSVersionEx(int function, string localeName, ref NlsVersionInfoEx version); + + // ESE records the NLS sort version with each Unicode index so it can tell when Windows changed the weights + // underneath it. Jet Red pins a table instead (General-Legacy vs General). Either way the sort version is + // the thing that identifies a weight table, so: what does this machine report? + [Fact] + public void Probe_windows_nls_sort_version() + { + foreach (string locale in new[] { "en-US", "en-GB", "de-DE", "" }) + { + var version = new NlsVersionInfoEx { dwNLSVersionInfoSize = (uint)Marshal.SizeOf() }; + const int compareString = 0x00000001; + bool ok = GetNLSVersionEx(compareString, locale.Length == 0 ? "" : locale, ref version); + output.WriteLine(ok + ? $"{(locale.Length == 0 ? "(invariant)" : locale),-12} sortVersion=0x{version.dwNLSVersion:X8} " + + $"definedVersion=0x{version.dwDefinedVersion:X8} effectiveId=0x{version.dwEffectiveId:X8}" + : $"{locale,-12} GetNLSVersionEx failed ({Marshal.GetLastWin32Error()})"); + } + } + + // Where does ACE's frozen v0 table disagree with the NLS table this Windows ships (0x00060502)? Those + // characters are where NLS changed after the legacy table was frozen — the candidate list for what the + // v1 "General" order actually alters, obtainable without a v1 database to compare against. + // Caveat: a disagreement can also be a gap in LibRed's hand-built table, so anything here needs ACE as + // the oracle before it means anything. + [Fact] + public void Probe_where_v0_diverges_from_modern_nls() + { + var comparable = new List<(char Ch, int Ace, int Nls)>(); + var unsupported = new List(); + for (char ch = ' '; ch <= 'ÿ'; ch++) + { + if (char.IsControl(ch)) continue; + byte[] nls = WinSortKey(ch.ToString()); + if (nls.Length < 2) continue; + byte[] ace; + try { ace = JetTextPrimary(ch); } + catch (Exception) { unsupported.Add(ch); continue; } + if (ace.Length != 1) continue; // multi-weight or ignorable: not comparable here + comparable.Add((ch, ace[0], (nls[0] << 8) | nls[1])); + } + + var byAce = comparable.OrderBy(m => m.Ace).ThenBy(m => m.Ch).Select(m => m.Ch).ToArray(); + var byNls = comparable.OrderBy(m => m.Nls).ThenBy(m => m.Ch).Select(m => m.Ch).ToArray(); + + output.WriteLine($"comparable single-weight characters: {comparable.Count}"); + output.WriteLine($"not encodable by LibRed: {unsupported.Count}" + + (unsupported.Count > 0 ? $" -> {new string(unsupported.ToArray())}" : "")); + output.WriteLine(""); + + var divergences = byAce.Zip(byNls).Select((pair, i) => (i, pair.First, pair.Second)) + .Where(x => x.First != x.Second).ToArray(); + if (divergences.Length == 0) + { + output.WriteLine("ACE v0 and NLS 0x00060502 agree on the order of every comparable character."); + return; + } + + output.WriteLine($"order diverges at {divergences.Length} position(s):"); + output.WriteLine($" by ACE: {new string(byAce)}"); + output.WriteLine($" by NLS: {new string(byNls)}"); + foreach ((int i, char a, char n) in divergences.Take(40)) + output.WriteLine($" position {i,3}: ACE has '{a}' (U+{(int)a:X4}), NLS has '{n}' (U+{(int)n:X4})"); + } + + // The Latin-1 punctuation/symbol block that JetTextCollation has no weights for, so an index insert + // throws. ACE is the oracle: give it each character in an indexed column and read back the key it stores. + [Fact] + public void Probe_ace_weights_for_characters_libred_cannot_encode() + { + const string missing = "¡¢£¤¥¦§¨©ª«¬­®¯" + + "°±²³´µ¶·¸¹º»¼½¾¿" + + "×÷"; + + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "missing-weights-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE W (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_W ON W (K)"); + for (int i = 0; i < missing.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO W (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", missing[i].ToString()); + insert.Parameters.AddWithValue("v", i); + insert.ExecuteNonQuery(); + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("W"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_W"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + output.WriteLine("char U+ ACE key NLS primary"); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + { + if (!rows.TryGetValue(rowId, out object?[]? values)) continue; + string value = (string?)values[keyColumn.Index] ?? ""; + if (value.Length != 1) continue; + byte[] nls = WinSortKey(value); + output.WriteLine($" {value} {(int)value[0]:X4} {Hex(stored),-18} {(nls.Length >= 2 ? $"{nls[0]:X2}{nls[1]:X2}" : "-")}"); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + private const uint LcmapHalfwidth = 0x00400000; + private const uint LcmapFullwidth = 0x00800000; + + // Does ACE normalise width before building the key? If it applies LCMAP_HALFWIDTH first, a full-width + // character must produce the same index key as its half-width counterpart. Each pair is inserted through + // ACE and its stored keys compared; the Win32 mapping is shown alongside so the two can be told apart. + [Fact] + public void Probe_whether_ace_folds_width_like_lcmap_halfwidth() + { + (string Wide, string Narrow, string What)[] pairs = + [ + ("A", "A", "fullwidth A"), + ("1", "1", "fullwidth 1"), + ("$", "$", "fullwidth $"), + (" ", " ", "ideographic space"), + ("ア", "ア", "halfwidth katakana A vs fullwidth"), + ("fi", "fi", "ligature fi"), + ]; + + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "halfwidth-"); + try + { + var inserted = new List<(string Text, string What, int Id)>(); + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE HW (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_HW ON HW (K)"); + int id = 0; + foreach ((string wide, string narrow, string what) in pairs) + foreach (string text in new[] { wide, narrow }) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO HW (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", text); + insert.Parameters.AddWithValue("v", id); + try { insert.ExecuteNonQuery(); inserted.Add((text, what, id)); } + catch (Exception ex) { output.WriteLine($" insert of {what} '{Describe(text)}' rejected: {ex.Message.Trim()}"); } + id++; + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("HW"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_HW"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + var keys = new Dictionary(); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values)) + keys[(string?)values[keyColumn.Index] ?? ""] = Convert.ToHexString(stored); + + foreach ((string wide, string narrow, string what) in pairs) + { + keys.TryGetValue(wide, out string? wideKey); + keys.TryGetValue(narrow, out string? narrowKey); + output.WriteLine($"{what}:"); + output.WriteLine($" ACE {Describe(wide),-12} {wideKey ?? "(not stored)"}"); + output.WriteLine($" ACE {Describe(narrow),-12} {narrowKey ?? "(not stored)"}"); + output.WriteLine($" -> {(wideKey is not null && wideKey == narrowKey ? "SAME key: ACE folded the width" : "different keys: no width folding")}"); + output.WriteLine($" LCMap plain {Hex(WinSortKey(wide))}"); + output.WriteLine($" LCMap +HALFWIDTH {Hex(WinMap(wide, LcmapHalfwidth))} (mapped form of the input)"); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + /// LCMapStringEx as a character mapping (no sort key) — returns the transformed string's bytes. + private static byte[] WinMap(string value, uint flags) + { + int size = LCMapStringEx("en-US", flags, value, value.Length, null, 0, 0, 0, 0); + if (size <= 0) return []; + var buffer = new byte[size * 2]; + int written = LCMapStringEx("en-US", flags, value, value.Length, buffer, size, 0, 0, 0); + return written <= 0 ? [] : buffer[..(written * 2)]; + } + + private static string Describe(string s) => string.Concat(s.Select(c => $"U+{(int)c:X4}")); + + /// .NET's own sort key, case-insensitive to match Jet's index semantics. + private static byte[] SortKey(string value) => + CultureInfo.GetCultureInfo("en-US").CompareInfo.GetSortKey(value, CompareOptions.IgnoreCase).KeyData; + + /// The Win32 NLS sort key — the API Jet used to build these keys. + private static byte[] WinSortKey(string value) + { + if (value.Length == 0) return []; + int size = LCMapStringEx("en-US", LcmapSortkey | NormIgnoreCase, value, value.Length, null, 0, 0, 0, 0); + if (size <= 0) return []; + var buffer = new byte[size]; + int written = LCMapStringEx("en-US", LcmapSortkey | NormIgnoreCase, value, value.Length, buffer, size, 0, 0, 0); + return written <= 0 ? [] : buffer[..written]; + } + + /// Whether .NET is using ICU rather than Win32 NLS — decided by comparing the two directly. + private static bool UsingIcu() => !SortKey("a").AsSpan().SequenceEqual(WinSortKey("a")); + + private static string Hex(byte[] bytes) => bytes.Length == 0 ? "(empty)" : Convert.ToHexString(bytes); + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/tools/sortkey-table/generate.ps1 b/tools/sortkey-table/generate.ps1 new file mode 100644 index 00000000..ab2e588f --- /dev/null +++ b/tools/sortkey-table/generate.ps1 @@ -0,0 +1,129 @@ +<# +.SYNOPSIS + Builds LibRed.Core's embedded sorting-weight table for the "General" (v1) text collation. + +.DESCRIPTION + ACE's General sort order is the Windows Server 2008 (Vista SP1-era) NLS sorting weight table, frozen — + identified by reconstructing measured ACE v1 index keys from every published Windows table and scoring + 25/25 against Server 2008 alone (Win7/2008R2 24/25, Vista 23/25, Win8+ 22/25, NT4-2003 18/25). + + The source is Microsoft's published "Windows Server 2008 Sorting Weight Table", linked from + [MS-UCODEREF]: Windows Protocols Unicode Reference. Its Open Specifications notice permits copying to + develop implementations and distributing portions within them, and extends that permission to referenced + documents. + + Input columns are `codepoint SM AW DW CW`: + SM Script Member ) together the 2-byte primary weight + AW Alphabetic Weight) + DW Diacritic Weight - the secondary weight ACE keeps + CW Case Weight - the tertiary weight ACE truncates, which is why case and width fold + + CW is therefore dropped here. Only the DEFAULT SORTKEY block (to ENDSORTKEY) and the EXPANSION section + are read: the per-locale COMPRESSION tables that follow redefine the same code points and will silently + corrupt a naive parse. + + Output is five Deflate-compressed streams (codepoint deltas, SM, AW, DW, expansions). Splitting them + matters: interleaved records compress to ~194 KB, separate homogeneous streams to ~16 KB. + +.PARAMETER SourceTable + Path to "Windows Server 2008 Sorting Weight Table.txt". + +.PARAMETER OutputPath + Where to write the binary resource (default: the LibRed.Core Resources folder). +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $SourceTable, + [string] $OutputPath = (Join-Path $PSScriptRoot '..\..\src\LibRed\LibRed.Core\Resources\SortKeyTableV1.bin') +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $SourceTable)) { throw "Source table not found: $SourceTable" } + +$weights = [System.Collections.Generic.SortedDictionary[int, int[]]]::new() +$expansions = [System.Collections.Generic.SortedDictionary[int, int[]]]::new() +$section = $null + +foreach ($line in [System.IO.File]::ReadLines($SourceTable)) { + $trimmed = $line.Trim() + if ($trimmed.StartsWith('SORTKEY')) { $section = 'weights'; continue } + if ($trimmed.StartsWith('ENDSORTKEY')) { $section = $null; continue } + if ($trimmed.StartsWith('EXPANSION')) { $section = 'expansions'; continue } + if ($trimmed -match '^(COMPRESSION|MULTIPLEWEIGHTS|REVERSEDIACRITICS|SORTTABLES|ENDSORTTABLES)') { $section = $null; continue } + if (-not $line.StartsWith('0x')) { continue } + + $fields = ($line -split ';')[0].Trim() -split '\s+' + if ($section -eq 'weights' -and $fields.Count -eq 5) { + # codepoint SM AW DW CW - CW dropped. + $weights[[Convert]::ToInt32($fields[0], 16)] = @([int]$fields[1], [int]$fields[2], [int]$fields[3]) + } + elseif ($section -eq 'expansions' -and $fields.Count -ge 2 -and ($fields | ForEach-Object { $_.StartsWith('0x') }) -notcontains $false) { + $expansions[[Convert]::ToInt32($fields[0], 16)] = + @($fields[1..($fields.Count - 1)] | ForEach-Object { [Convert]::ToInt32($_, 16) }) + } +} + +Write-Host "parsed $($weights.Count) weights, $($expansions.Count) expansions" +if ($weights.Count -lt 50000) { throw "Only $($weights.Count) weights parsed - the DEFAULT block looks truncated." } + +function Write-VarInt([System.Collections.Generic.List[byte]] $target, [int] $value) { + while ($value -ge 0x80) { $target.Add([byte](($value -band 0x7F) -bor 0x80)); $value = $value -shr 7 } + $target.Add([byte]$value) +} + +$deltas = [System.Collections.Generic.List[byte]]::new() +$sm = [System.Collections.Generic.List[byte]]::new() +$aw = [System.Collections.Generic.List[byte]]::new() +$dw = [System.Collections.Generic.List[byte]]::new() +$previous = 0 +foreach ($cp in $weights.Keys) { + Write-VarInt $deltas ($cp - $previous); $previous = $cp + $sm.Add([byte]$weights[$cp][0]); $aw.Add([byte]$weights[$cp][1]); $dw.Add([byte]$weights[$cp][2]) +} + +$exp = [System.Collections.Generic.List[byte]]::new() +$previous = 0 +foreach ($cp in $expansions.Keys) { + Write-VarInt $exp ($cp - $previous); $previous = $cp + $sequence = $expansions[$cp] + $exp.Add([byte]$sequence.Count) + foreach ($c in $sequence) { $exp.Add([byte]($c -band 0xFF)); $exp.Add([byte](($c -shr 8) -band 0xFF)) } +} + +function Compress([byte[]] $data) { + $output = [System.IO.MemoryStream]::new() + $deflate = [System.IO.Compression.ZLibStream]::new($output, [System.IO.Compression.CompressionLevel]::SmallestSize) + $deflate.Write($data, 0, $data.Length); $deflate.Dispose() + return $output.ToArray() +} + +# NB the comma: piping byte[] through ForEach-Object unrolls it into individual bytes, which silently +# produces five "streams" of one byte each. +$streams = @() +foreach ($stream in @($deltas, $sm, $aw, $dw, $exp)) { $streams += , (Compress $stream.ToArray()) } + +$blob = [System.IO.MemoryStream]::new() +$writer = [System.IO.BinaryWriter]::new($blob) +$writer.Write([int]$weights.Count) +$writer.Write([int]$expansions.Count) +# The explicit (byte[], int, int) overload: Write($stream) alone binds to Write(byte) and emits one byte. +foreach ($stream in $streams) { $writer.Write([int]$stream.Length); $writer.Write([byte[]]$stream, 0, $stream.Length) } +$writer.Flush() + +$directory = Split-Path -Parent $OutputPath +if (-not (Test-Path $directory)) { New-Item -ItemType Directory -Path $directory | Out-Null } +[System.IO.File]::WriteAllBytes($OutputPath, $blob.ToArray()) + +# Read the file back and check every section is present and the right size. Both bugs this script hit during +# development wrote a structurally valid but empty file, and both would have shipped silently. +$check = [System.IO.BinaryReader]::new([System.IO.MemoryStream]::new([System.IO.File]::ReadAllBytes($OutputPath))) +$checkWeights = $check.ReadInt32(); $checkExpansions = $check.ReadInt32() +if ($checkWeights -ne $weights.Count -or $checkExpansions -ne $expansions.Count) { throw 'Header counts do not round-trip.' } +for ($i = 0; $i -lt $streams.Count; $i++) { + $length = $check.ReadInt32() + if ($length -ne $streams[$i].Length) { throw "Stream $i length is $length, expected $($streams[$i].Length)." } + if ($check.ReadBytes($length).Length -ne $length) { throw "Stream $i is truncated in the output file." } +} +Write-Host ("wrote {0} ({1:N0} bytes; {2} weights, {3} expansions)" -f ` + $OutputPath, $blob.Length, $weights.Count, $expansions.Count) From 8e133cdd0b8c140678207d20537f152790bddd55 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 17:25:18 +0800 Subject: [PATCH 05/48] LibRed: the collation field is a 32-bit LCID, and what the v0 letter gaps are for Bytes 0x0B-0x0E (page-0 0x6E-0x71) are ONE Windows LCID with the sort-order version in its otherwise-unused top byte: LANGID at 0x0B, sort id at 0x0D, version at 0x0E. The sort id is what separates a Windows alternate sort order from its base locale - German Phone Book 0x00010407 against German 0x00000407 - and they differ in nothing else. The spec had warned for months that 0x0D was "0 in every file seen, keep an eye on it". The gaps in the v0 letter table are insertion slots for language letters. It steps by +2 everywhere except B-C, Q-R and X-Y, and Spanish lands on exactly the free value in each relevant gap. The second byte is a SUB-POSITION ordering letters that share a slot, proved by locales that put several in one. Version 1 is not General-only: Croatian and Romanian ship in both generations. Five of DAO's collating orders are dead metadata, byte-identical to General, so appearing in the UI does not imply an implementation. DAO can author a locale but not a sort-order version, which is what fixtures need Access itself for. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Core/Catalog/Collation.cs | 50 +++-- src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs | 10 +- .../LibRed.Core/Formats/JetFormatBase.cs | 23 +- src/LibRed/LibRed.Core/JetDatabase.cs | 10 +- .../Pages/DatabaseDefinitionPage.cs | 16 +- .../LibRed.Core/Pages/TableDefinitionPage.cs | 9 +- .../LibRed.Core/Storage/DatabaseCreator.cs | 12 +- src/LibRed/docs/format/appendix-structures.md | 5 +- src/LibRed/docs/format/page-00-database.md | 13 +- src/LibRed/docs/format/page-02b-columns.md | 50 +++-- .../docs/format/page-03-04-index-btree.md | 159 +++++++++++++- .../DaoLocaleCollationProbeTest.cs | 207 ++++++++++++++++++ test/LibRed.Core.Tests/Data/Bosnian.accdb | Bin 0 -> 495616 bytes test/LibRed.Core.Tests/Data/Croatian.accdb | Bin 0 -> 458752 bytes .../Data/CroatianLegacy.accdb | Bin 0 -> 458752 bytes test/LibRed.Core.Tests/Data/Czech.accdb | Bin 0 -> 458752 bytes test/LibRed.Core.Tests/Data/Estonian.accdb | Bin 0 -> 458752 bytes test/LibRed.Core.Tests/Data/French.accdb | Bin 0 -> 458752 bytes .../Data/GeorgianModern.accdb | Bin 0 -> 458752 bytes .../Data/GermanPhoneBook.accdb | Bin 0 -> 430080 bytes test/LibRed.Core.Tests/Data/Hungarian.accdb | Bin 0 -> 471040 bytes .../Data/HungarianTechnical.accdb | Bin 0 -> 462848 bytes test/LibRed.Core.Tests/Data/Icelandic.accdb | Bin 0 -> 430080 bytes test/LibRed.Core.Tests/Data/Indic.accdb | Bin 0 -> 491520 bytes test/LibRed.Core.Tests/Data/Latvian.accdb | Bin 0 -> 458752 bytes test/LibRed.Core.Tests/Data/Lithuanian.accdb | Bin 0 -> 458752 bytes test/LibRed.Core.Tests/Data/Macedonian.accdb | Bin 0 -> 458752 bytes .../Data/NorwegianDanish.accdb | Bin 0 -> 458752 bytes test/LibRed.Core.Tests/Data/Polish.accdb | Bin 0 -> 458752 bytes test/LibRed.Core.Tests/Data/Romanian.accdb | Bin 0 -> 430080 bytes .../Data/RomanianLegacy.accdb | Bin 0 -> 425984 bytes test/LibRed.Core.Tests/Data/Serbian.accdb | Bin 0 -> 458752 bytes test/LibRed.Core.Tests/Data/Slovak.accdb | Bin 0 -> 458752 bytes test/LibRed.Core.Tests/Data/Slovenian.accdb | Bin 0 -> 458752 bytes .../Data/SpanishModern.accdb | Bin 0 -> 430080 bytes .../Data/SpanishTraditional.accdb | Bin 0 -> 401408 bytes .../Data/SwedishFinnish.accdb | Bin 0 -> 458752 bytes test/LibRed.Core.Tests/Data/Thai.accdb | Bin 0 -> 458752 bytes test/LibRed.Core.Tests/Data/Turkish.accdb | Bin 0 -> 458752 bytes test/LibRed.Core.Tests/Data/Ukrainian.accdb | Bin 0 -> 458752 bytes test/LibRed.Core.Tests/Data/Vietnamese.accdb | Bin 0 -> 425984 bytes .../LibRed.Core.Tests/DatabaseCreatorTests.cs | 7 +- .../GlobalReferenceFreeMapTests.cs | 4 +- .../LegacyJetPasswordTests.cs | 4 +- .../LibRed.Core.Tests.csproj | 25 +-- .../LocaleFixtureCollationProbeTest.cs | 191 ++++++++++++++++ .../SpanishCollationProbeTest.cs | 147 +++++++++++++ test/LibRed.Core.Tests/TestDatabases.cs | 16 ++ 48 files changed, 853 insertions(+), 105 deletions(-) create mode 100644 test/LibRed.Core.Tests/DaoLocaleCollationProbeTest.cs create mode 100644 test/LibRed.Core.Tests/Data/Bosnian.accdb create mode 100644 test/LibRed.Core.Tests/Data/Croatian.accdb create mode 100644 test/LibRed.Core.Tests/Data/CroatianLegacy.accdb create mode 100644 test/LibRed.Core.Tests/Data/Czech.accdb create mode 100644 test/LibRed.Core.Tests/Data/Estonian.accdb create mode 100644 test/LibRed.Core.Tests/Data/French.accdb create mode 100644 test/LibRed.Core.Tests/Data/GeorgianModern.accdb create mode 100644 test/LibRed.Core.Tests/Data/GermanPhoneBook.accdb create mode 100644 test/LibRed.Core.Tests/Data/Hungarian.accdb create mode 100644 test/LibRed.Core.Tests/Data/HungarianTechnical.accdb create mode 100644 test/LibRed.Core.Tests/Data/Icelandic.accdb create mode 100644 test/LibRed.Core.Tests/Data/Indic.accdb create mode 100644 test/LibRed.Core.Tests/Data/Latvian.accdb create mode 100644 test/LibRed.Core.Tests/Data/Lithuanian.accdb create mode 100644 test/LibRed.Core.Tests/Data/Macedonian.accdb create mode 100644 test/LibRed.Core.Tests/Data/NorwegianDanish.accdb create mode 100644 test/LibRed.Core.Tests/Data/Polish.accdb create mode 100644 test/LibRed.Core.Tests/Data/Romanian.accdb create mode 100644 test/LibRed.Core.Tests/Data/RomanianLegacy.accdb create mode 100644 test/LibRed.Core.Tests/Data/Serbian.accdb create mode 100644 test/LibRed.Core.Tests/Data/Slovak.accdb create mode 100644 test/LibRed.Core.Tests/Data/Slovenian.accdb create mode 100644 test/LibRed.Core.Tests/Data/SpanishModern.accdb create mode 100644 test/LibRed.Core.Tests/Data/SpanishTraditional.accdb create mode 100644 test/LibRed.Core.Tests/Data/SwedishFinnish.accdb create mode 100644 test/LibRed.Core.Tests/Data/Thai.accdb create mode 100644 test/LibRed.Core.Tests/Data/Turkish.accdb create mode 100644 test/LibRed.Core.Tests/Data/Ukrainian.accdb create mode 100644 test/LibRed.Core.Tests/Data/Vietnamese.accdb create mode 100644 test/LibRed.Core.Tests/LocaleFixtureCollationProbeTest.cs create mode 100644 test/LibRed.Core.Tests/SpanishCollationProbeTest.cs diff --git a/src/LibRed/LibRed.Core/Catalog/Collation.cs b/src/LibRed/LibRed.Core/Catalog/Collation.cs index ea8e7383..e9f53114 100644 --- a/src/LibRed/LibRed.Core/Catalog/Collation.cs +++ b/src/LibRed/LibRed.Core/Catalog/Collation.cs @@ -3,33 +3,42 @@ namespace LibRed.Catalog; /// /// A text collating order, identified by its Windows locale id (LCID) — the value Jet/ACE stores in a /// column descriptor's locale bytes (0x0B/0x0C) and, database-wide, in the page-0 sort order. -/// These mirror DAO's CollatingOrderEnum; each name is the LCID Access records. +/// These mirror DAO's CollatingOrderEnum; each name is the LCID Access records. That is a Jet-3.5-era +/// list and no longer matches what ACE offers, in both directions: Access's "New Database Sort Order" adds +/// Bosnian, Croatian, Serbian, Macedonian, Ukrainian, Estonian, Latvian, Lithuanian, Slovak, Romanian, +/// Georgian Modern, Vietnamese, Indic, French, German Phone Book, Hungarian Technical and the CJK variants, +/// and offers none of the five marked inert below. Those five are still creatable through DAO and are +/// recorded faithfully on page 0 and in column descriptors, but ACE encodes **General** keys for them +/// regardless — verified over 31 samples in DaoLocaleCollationProbeTest. Treat them as metadata. /// /// /// The LCID alone does not pin the on-disk key bytes: "General" (1033) has a legacy order (version 0, /// Access 2000–2007) and a different default order (version 1, Access 2010+). The version lives in a -/// separate descriptor byte — see . Paradox-ISAM variants that DAO lists share -/// LCIDs with these (e.g. dbSortPDXIntl == 1033) and are omitted; they are link-only and irrelevant here. +/// separate descriptor byte — see . The two axes are independent: both Spanish orders +/// are version 0, so the version selects a weight-table generation rather than a locale variant. Paradox-ISAM +/// variants that DAO lists share LCIDs with these (e.g. dbSortPDXIntl == 1033) and are omitted; they are +/// link-only and irrelevant here. /// public enum CollatingOrder { Undefined = -1, Neutral = 1024, - Arabic = 1025, + Arabic = 1025, // inert: recorded, but ACE encodes General keys — see the remarks below ChineseTraditional = 1028, Czech = 1029, NorwegianDanish = 1030, - Greek = 1032, + Greek = 1032, // inert General = 1033, // English, German, French, Portuguese — the default - Spanish = 1034, - Hebrew = 1037, + Spanish = 1034, // Spanish Traditional: "ch" and "ll" are letters (DAO's dbSortSpanish) + SpanishModern = 3082, // The 1994 reform: "ch"/"ll" are letter pairs. No DAO name — it postdates the enum + Hebrew = 1037, // inert Hungarian = 1038, Icelandic = 1039, Japanese = 1041, Korean = 1042, - Dutch = 1043, + Dutch = 1043, // inert Polish = 1045, - Cyrillic = 1049, + Cyrillic = 1049, // inert SwedishFinnish = 1053, Thai = 1054, Turkish = 1055, @@ -42,15 +51,22 @@ public enum CollatingOrder /// that selects between weight tables sharing that LCID. Determines the index-key /// bytes for text/memo columns, and is written into their column descriptors. /// -/// The collating order (LCID). -/// The sort-order version — the byte at column-descriptor 0x0E (the high byte of -/// a nominally 2-byte version field at 0x0D). Verified vs Access: General Legacy (Access 2000–2007) = -/// 0; the "General" order Access 2010 made default = 1. The low byte at 0x0D is 0 -/// in every file seen and is not modelled — but the field is nominally 2 bytes, so keep an eye on it: if a -/// database ever carries a non-zero 0x0D we are truncating a wider value (cf. the AutoNumber increment -/// at TDEF 0x18, which looked like a 1-byte flag but was a full signed int32). -public readonly record struct Collation(CollatingOrder Order, byte Version) +/// The collating order's LANGID — column descriptor 0x0B/0x0C. +/// The sort-order version — the byte at column-descriptor 0x0E. Verified vs +/// Access: the legacy compacted table (Access 2000–2007) = 0; the "General" order Access 2010 made +/// default = 1. Not a General-only axis: Croatian and Romanian each ship in both versions. +/// The LCID's high word — column descriptor 0x0D, page-0 0x70. Zero for a +/// locale's default order; non-zero selects a Windows alternate sort order, which is a different +/// ordering for the same LANGID. Without it Hungarian Technical (0x0001040E) is +/// indistinguishable from Hungarian (0x0000040E), and German Phone Book from German. This byte was +/// documented as "0 in every file seen — keep an eye on it" until fixtures for those two orders showed it +/// carrying 0x01. +public readonly record struct Collation(CollatingOrder Order, byte Version, byte SortId = 0) { + /// The full 32-bit Windows LCID: (SortId << 16) | LANGID. The version is not part of + /// it — Jet stores that in the LCID's unused top byte, but Windows does not define it. + public int Lcid => (SortId << 16) | (int)Order; + /// The sort-order version byte for the Access-2010+ "General" order. public const byte GeneralVersion = 1; diff --git a/src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs b/src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs index 346260ed..4abec58c 100644 --- a/src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs +++ b/src/LibRed/LibRed.Core/Catalog/TdefBuilder.cs @@ -479,12 +479,12 @@ public static byte[] BuildColumnDescriptor(ColumnDef c, JetFormatBase format) } else { - // Non-numeric columns store the text-collation LCID in the precision/scale bytes (0x0B/0x0C) and the - // sort-order version as the HIGH byte (0x0E) of the 2-byte field at 0x0D. General legacy is LCID 1033 - // (0x0409), version 0; the Access-2010 "General" order is version 1. The low byte 0x0D is left as-is - // (0 on a fresh column, preserved from the original descriptor otherwise) — see Collation.Version. + // Non-numeric columns use the precision/scale bytes (0x0B/0x0C) onward for the text collation: + // 0x0B/0x0C LANGID, 0x0D sort id, 0x0E sort-order version. Together a 32-bit LCID with the version + // in its unused top byte. General legacy is LANGID 1033 (0x0409), sort id 0, version 0. BinaryPrimitives.WriteUInt16LittleEndian(d.AsSpan(format.ColumnLocaleOffset, 2), (ushort)c.Collation.Order); - d[format.ColumnCollationVersionOffset + 1] = c.Collation.Version; + d[format.ColumnCollationSortIdOffset] = c.Collation.SortId; + d[format.ColumnCollationVersionOffset] = c.Collation.Version; } // Compose the flag byte (0x0F) from EVERY documented bit; only the undocumented bits survive from the // original (zero in every file observed). Likewise the extended-flag byte (0x10). diff --git a/src/LibRed/LibRed.Core/Formats/JetFormatBase.cs b/src/LibRed/LibRed.Core/Formats/JetFormatBase.cs index d8d9e44a..6b36cc45 100644 --- a/src/LibRed/LibRed.Core/Formats/JetFormatBase.cs +++ b/src/LibRed/LibRed.Core/Formats/JetFormatBase.cs @@ -68,11 +68,17 @@ public abstract class JetFormatBase /// creation-date-derived value, so an empty password does not read as zeroes). public const int PasswordOffset = 0x42; - /// Offset of the 4-byte default text collating sort order: LCID (2 bytes LE, e.g. - /// 0x0409 = 1033 en-US) followed by the sort-order version at 0x71 (0 = General - /// Legacy, 1 = General). The version here matches each column's descriptor byte 0x0E. + /// Offset of the 4-byte default text collating sort order — a 32-bit Windows LCID whose + /// otherwise-unused top byte carries the sort-order version. Byte for byte it mirrors a column + /// descriptor's 0x0B..0x0E: LANGID at 0x6E (2 bytes LE, 0x0409 = 1033 en-US), + /// sort id at 0x70, version at 0x71. public const int CollationSortOrderOffset = 0x6E; + /// Offset of the collation's 1-byte sort id — the LCID's high word, which is what distinguishes + /// an alternate sort order from its base locale (German Phone Book 0x00010407 vs German + /// 0x00000407; Hungarian Technical 0x0001040E vs Hungarian 0x0000040E). + public const int CollationSortIdOffset = 0x70; + /// Offset of the 1-byte collation sort-order version within the sort-order field (0/1). public const int CollationVersionOffset = 0x71; @@ -170,10 +176,15 @@ public abstract class JetFormatBase public virtual int ColumnVariableIndexOffset => 0x07; // position among variable columns (0 for fixed) public virtual int ColumnPrecisionOffset => 0x0B; // Decimal/Numeric columns only public virtual int ColumnScaleOffset => 0x0C; // Decimal/Numeric columns only - // Non-numeric columns instead use 0x0B/0x0C for the text-collation LCID (a little-endian UInt16 read at - // 0x0B), and 0x0D for its sort-order version (0 = General legacy, 1 = the Access 2010 order). + // Non-numeric columns instead use 0x0B..0x0E for the text collation, and the four bytes together are a + // 32-bit Windows LCID with the sort-order version in its otherwise-unused top byte: + // 0x0B/0x0C LANGID, little-endian (0x0409 = General/en-US) + // 0x0D sort id — the high word of the LCID, which is what separates an alternate sort order from + // its base locale (German Phone Book = 0x00010407, Hungarian Technical = 0x0001040E) + // 0x0E sort-order version (0 = the legacy compacted table, 1 = the Access 2010 NLS order) public virtual int ColumnLocaleOffset => 0x0B; - public virtual int ColumnCollationVersionOffset => 0x0D; + public virtual int ColumnCollationSortIdOffset => 0x0D; + public virtual int ColumnCollationVersionOffset => 0x0E; public virtual int ColumnFlagsOffset => 0x0F; /// Extended column flags (0x10): bit 0x01 = compressed-Unicode capable, 0xC0 = calculated column. public virtual int ColumnExtendedFlagsOffset => 0x10; diff --git a/src/LibRed/LibRed.Core/JetDatabase.cs b/src/LibRed/LibRed.Core/JetDatabase.cs index 9675c801..b1f406f7 100644 --- a/src/LibRed/LibRed.Core/JetDatabase.cs +++ b/src/LibRed/LibRed.Core/JetDatabase.cs @@ -41,17 +41,21 @@ private JetDatabase(PageChannel channel) /// The database's default collation LCID (e.g. 1033 = en-US), decoded from page 0. public int DefaultCollationLcid => DefinitionPage.DefaultCollationLcid; - /// The database default sort-order version (0 = General Legacy, 1 = General), from page 0. + /// The database default sort-order version (0 = the legacy compacted table, 1 = the Access-2010 + /// NLS order), from page 0. public byte DefaultCollationVersion => DefinitionPage.DefaultCollationVersion; + /// The database default collation's sort id (page-0 0x70) — the LCID's high word, non-zero + /// only for a Windows alternate sort order such as German Phone Book or Hungarian Technical. + public byte DefaultCollationSortId => DefinitionPage.DefaultCollationSortId; + /// The database's default text collating order — the LCID and sort-order version written into /// new columns. Read from the page-0 sort order, so a table created in a General (v1) database gets v1 /// columns, as Access would create them. (This used to be hardcoded to General legacy while the page-0 /// decode was pending; the decode landed in / /// but this was left behind, which silently gave every new column /// v0 weights even in a v1 database.) - public Collation Collation => - new((CollatingOrder)DefaultCollationLcid, DefaultCollationVersion); + public Collation Collation => DefinitionPage.Collation; /// Reads and decodes the table definition (TDEF) page at . public TableDefinitionPage ReadTableDefinition(int pageNumber) diff --git a/src/LibRed/LibRed.Core/Pages/DatabaseDefinitionPage.cs b/src/LibRed/LibRed.Core/Pages/DatabaseDefinitionPage.cs index bb318016..f1171904 100644 --- a/src/LibRed/LibRed.Core/Pages/DatabaseDefinitionPage.cs +++ b/src/LibRed/LibRed.Core/Pages/DatabaseDefinitionPage.cs @@ -1,4 +1,5 @@ using System.Buffers.Binary; +using LibRed.Catalog; using LibRed.IO; namespace LibRed.Pages; @@ -28,10 +29,20 @@ public sealed class DatabaseDefinitionPage : Page /// obfuscated sort-order field at . public int DefaultCollationLcid { get; internal set; } - /// The database default sort-order version: 0 = General Legacy, 1 = General (2010+). - /// Matches each column descriptor's byte 0x0E. + /// The database default sort-order version: 0 = the legacy compacted table, 1 = the Access-2010 + /// NLS order. Matches each column descriptor's byte 0x0E. public byte DefaultCollationVersion { get; internal set; } + /// The default collation's sort id — the LCID's high word, from 0x70. Non-zero only for a + /// Windows alternate sort order (German Phone Book, Hungarian Technical), which shares its LANGID with the + /// base locale and is distinguishable by nothing else. Matches column descriptor byte 0x0D. + public byte DefaultCollationSortId { get; internal set; } + + /// The default text collating order, assembled from the three fields of the 0x6E block. + /// This is what a column created in the database inherits. + public Collation Collation => + new((CollatingOrder)DefaultCollationLcid, DefaultCollationVersion, DefaultCollationSortId); + /// Page number of the MSysObjects TDEF (the catalog root), read from the bootstrap /// pointer at . 2 in every observed file. public int CatalogRootPage { get; internal set; } @@ -53,6 +64,7 @@ public override void Read(PageBuffer buffer, Formats.JetFormatBase format) CodePage = BinaryPrimitives.ReadUInt16LittleEndian(clear.Slice(Formats.JetFormatBase.CodePageOffset - b, 2)); DatabaseKey = BinaryPrimitives.ReadInt32LittleEndian(clear.Slice(Formats.JetFormatBase.DatabaseKeyOffset - b, 4)); DefaultCollationLcid = BinaryPrimitives.ReadUInt16LittleEndian(clear.Slice(Formats.JetFormatBase.CollationSortOrderOffset - b, 2)); + DefaultCollationSortId = clear[Formats.JetFormatBase.CollationSortIdOffset - b]; DefaultCollationVersion = clear[Formats.JetFormatBase.CollationVersionOffset - b]; CatalogRootPage = BinaryPrimitives.ReadInt32LittleEndian(clear.Slice(Formats.JetFormatBase.CatalogRootPointerOffset - b, 4)); double days = BinaryPrimitives.ReadDoubleLittleEndian(clear.Slice(Formats.JetFormatBase.CreationDateOffset - b, 8)); diff --git a/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs b/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs index df993643..600837e5 100644 --- a/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs +++ b/src/LibRed/LibRed.Core/Pages/TableDefinitionPage.cs @@ -294,11 +294,12 @@ private int ReadColumns(PageBuffer buffer, JetFormatBase format, int columnBlock // rows, so a survivor keeps its original variable index even though ranking would shift it. buffer.ReadUInt16(entry + format.ColumnVariableIndexOffset), numeric ? Collation.GeneralLegacy + // 0x0B..0x0E are one 32-bit LCID with the sort-order version in the top byte: LANGID, + // then the sort id at 0x0D (non-zero only for a Windows alternate sort order, e.g. + // Hungarian Technical), then the version at 0x0E (0 = legacy table, 1 = Access-2010). : new Collation((CollatingOrder)buffer.ReadUInt16(entry + format.ColumnLocaleOffset), - // The sort-order version is the HIGH byte of the 2-byte field at 0x0D — i.e. the byte at - // 0x0E (0 = General legacy, 1 = Access-2010 General). The low byte 0x0D is 0 in every file - // seen; reading it alone (as LibRed used to) hid v1. Watch 0x0D — see Collation.Version. - buffer.ReadByte(entry + format.ColumnCollationVersionOffset + 1))); + buffer.ReadByte(entry + format.ColumnCollationVersionOffset), + buffer.ReadByte(entry + format.ColumnCollationSortIdOffset))); } // Pass 2: column names, in the same order, immediately after the descriptor block. diff --git a/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs b/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs index 027af28e..956c6704 100644 --- a/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs +++ b/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs @@ -29,7 +29,7 @@ public static class DatabaseCreator /// 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 ). public static byte[] BuildDefinitionPage( - byte version, bool isAccdb, int codePage, int collationLcid, byte collationVersion, double creationDays) + byte version, bool isAccdb, int codePage, Collation collation, double creationDays) { var page = new byte[4096]; @@ -64,9 +64,11 @@ public static byte[] BuildDefinitionPage( clear[JetFormatBase.PasswordOffset - b + i] = dateMask[i % 4]; // 0x6A fixed sentinel constant. BinaryPrimitives.WriteInt32LittleEndian(clear[(0x6A - b)..], 0x000011A6); - // 0x6E..0x71 collating sort order: LCID + version byte at 0x71. - BinaryPrimitives.WriteUInt16LittleEndian(clear[(JetFormatBase.CollationSortOrderOffset - b)..], (ushort)collationLcid); - clear[JetFormatBase.CollationVersionOffset - b] = collationVersion; + // 0x6E..0x71 collating sort order: LANGID, sort id at 0x70, version at 0x71 — a 32-bit LCID carrying + // the sort-order version in its unused top byte. Mirrors a column descriptor's 0x0B..0x0E. + BinaryPrimitives.WriteUInt16LittleEndian(clear[(JetFormatBase.CollationSortOrderOffset - b)..], (ushort)collation.Order); + clear[JetFormatBase.CollationSortIdOffset - b] = collation.SortId; + clear[JetFormatBase.CollationVersionOffset - b] = collation.Version; // 0x72..0x79 creation date (OLE double). BinaryPrimitives.WriteDoubleLittleEndian(clear[(JetFormatBase.CreationDateOffset - b)..], days); @@ -251,7 +253,7 @@ public static void CreateEmpty(string path, byte version = 0x02, Collation? coll const int seedPages = 10; // page 0, page 1, 4 core TDEFs (2..5), 4 usage maps (6..9) byte[][] seed = [ - BuildDefinitionPage(version, isAccdb: true, 1252, (int)sortOrder.Order, sortOrder.Version, + BuildDefinitionPage(version, isAccdb: true, 1252, sortOrder, BitConverter.Int64BitsToDouble(SeedCreationDateBits)), BuildFreeMapPage(format, seedPages), // page 1: global free-pages map objTdef, acesTdef, queriesTdef, relTdef, // pages 2..5: core TDEFs diff --git a/src/LibRed/docs/format/appendix-structures.md b/src/LibRed/docs/format/appendix-structures.md index c7bb6a79..a0f50b7b 100644 --- a/src/LibRed/docs/format/appendix-structures.md +++ b/src/LibRed/docs/format/appendix-structures.md @@ -38,7 +38,7 @@ All integers little-endian unless noted; offsets are hex, relative to the struct | `0x3E` | 4 | Database/encryption key (`0` = not encrypted) | | `0x42` | 40 | Password (Jet4; Jet3 = 20) — also XOR `(int)creationDate` | | `0x6A` | 4 | Fixed constant `0x000011A6` | -| `0x6E` | 4 | Collation: LCID (2, LE) + sort-order version at `0x71` (`0` Legacy, `1` General) | +| `0x6E` | 4 | Collation, a 32-bit LCID with the version in its top byte: LANGID (2, LE), sort id at `0x70`, sort-order version at `0x71` (`0` legacy table, `1` Access-2010) | | `0x72` | 8 | Creation timestamp — OLE `double` (days from 1899-12-30) | | `0x98` | 4 | Fixed constant `0x00000654` (past the masked window) | | `0x9C` | 4 | Engine version string `"4.0"` (ASCII, NUL-term) | @@ -115,7 +115,8 @@ Variable section (`varOffsetTable`+`numVar`) omitted when the table has no varia | `0x09` | 2 | Column number (= id, until an `ALTER COLUMN` burns a new id at `0x05`) | | `0x0B` | 1 | Precision (Decimal) — else locale low byte `0x09` | | `0x0C` | 1 | Scale (Decimal) — else locale high byte `0x04` | -| `0x0D` | 2 | Sort-order version (0 = General legacy) | +| `0x0D` | 1 | Collation sort id — the LCID's high word (`0x01` = an alternate sort order, e.g. Hungarian Technical) | +| `0x0E` | 1 | Sort-order version (`0` legacy table, `1` Access-2010) | | `0x0F` | 1 | Flags: `0x01` fixed, `0x02` updatable, `0x04` auto-number, `0x40` auto-number GUID, `0x80` hyperlink | | `0x10` | 1 | Extended flags: `0x01` compressed-Unicode capable, `0xC0` calculated | | `0x11` | 4 | Unknown (zero) | diff --git a/src/LibRed/docs/format/page-00-database.md b/src/LibRed/docs/format/page-00-database.md index be6d936e..fe25ed21 100644 --- a/src/LibRed/docs/format/page-00-database.md +++ b/src/LibRed/docs/format/page-00-database.md @@ -21,7 +21,7 @@ | `0x3E` | 4 | **Database (encryption) key** — 0 when there is no password | | `0x42` | 40 | **Password** (Jet 4; Jet 3 = 20 bytes) — additionally masked by a creation-date-derived value, so an empty password does not read as zeroes | | `0x6A` | 4 | Fixed constant `0x000011A6` — invariant across the entire Jet 4 lineage (every version/engine/collation/language tested); likely a validation sentinel/marker (cf. the `0x0659` TDEF record marker, §3.1), exact purpose unconfirmed | -| `0x6E` | 4 | **Default text collating sort order** — LCID (2, LE, `0x0409` = 1033 en-US) + **sort-order version** at `0x71` (0 = General Legacy, 1 = General) | +| `0x6E` | 4 | **Default text collating sort order** — a 32-bit LCID with the version in its unused top byte: LANGID (2, LE, `0x0409` = 1033 en-US), **sort id** at `0x70`, **sort-order version** at `0x71` (0 = legacy table, 1 = the Access-2010 order). Byte-for-byte the same layout as a column descriptor's `0x0B`–`0x0E` | | `0x72` | 8 | **Database creation timestamp** — OLE automation `double` (days from 1899-12-30) | | `0x98` | 4 | **Past the masked window** (cleartext). Fixed constant `0x00000654` (1620), undecoded | | `0x9C` | 4 | ASCII **engine/format version string `"4.0"`** (NUL-terminated) — the Jet **4.0** version, present in both `.mdb` (Jet 4) and `.accdb` (ACE, which is Jet-4-based) | @@ -176,10 +176,13 @@ CF 65 ED FF 07 C7 46 A1 78 16 0C ED E9 2D 62 D4 ; 0x88 writes v1 descriptors on `MSys*` in a General database. LibRed is currently the only way to create a v1 database programmatically: DAO writes v0 whatever the application setting says, and Access honours its "New database sort order" option only through its own UI. -- **Collation sort order (`0x6E`, 4 bytes)** → `DefaultCollationLcid` (LCID at `0x6E`) + - `DefaultCollationVersion` (the byte at `0x71`, 0 = General Legacy, 1 = General). The version here - **matches each column descriptor's `0x0E`** — the sort version lives both database-wide (page 0) - and per column (see [page-02b-columns.md](page-02b-columns.md)). +- **Collation sort order (`0x6E`, 4 bytes)** → `DefaultCollationLcid` (the LANGID at `0x6E`), + `DefaultCollationSortId` (`0x70` — the LCID's high word, non-zero only for a Windows *alternate* sort + order such as German Phone Book `0x00010407` or Hungarian Technical `0x0001040E`), and + `DefaultCollationVersion` (`0x71`, 0 = the legacy compacted table, 1 = the Access-2010 NLS order). + `DatabaseDefinitionPage.Collation` assembles the three. All three **match each column descriptor's + `0x0B`–`0x0E`** — the sort order lives both database-wide (page 0) and per column (see + [page-02b-columns.md](page-02b-columns.md)), and the two blocks have identical layout. - **Creation date (`0x72`, 8 bytes)** → `CreationDate` — an OLE `double`. Matches the earliest `MSysObjects.DateCreate`; on an *edited* database (e.g. Northwind) it is the **file's** creation instant and can differ from the first object's by minutes. **Unlike a normal Jet/ACE `DateTime` diff --git a/src/LibRed/docs/format/page-02b-columns.md b/src/LibRed/docs/format/page-02b-columns.md index 18f07c7a..25252d99 100644 --- a/src/LibRed/docs/format/page-02b-columns.md +++ b/src/LibRed/docs/format/page-02b-columns.md @@ -48,22 +48,33 @@ > which reads precision = 12, scale = 3; for every other type it reads the constant `0x0409` > (the en-US LCID / text collation). > -> **Sort-order version — the version number is the byte at `0x0E`.** For non-numeric columns `0x0B`–`0x0E` -> is a 4-byte **sort-order descriptor**: locale `0x0409` (1033) at `0x0B`, then a nominally 2-byte **version** -> at `0x0D`. The version *number* is the **high byte, `0x0E`**: -> - **General Legacy** (Access 2000–2007) → `0x0D`-`0x0E` = `00 00`, i.e. version `0`. -> - **General** (the Access-2010 default, a *different* key encoding, §10.4) → `00 01`, version `1`. +> **`0x0B`–`0x0E` is a 32-bit Windows LCID with the sort-order version in its unused top byte.** For +> non-numeric columns the four bytes are: > -> **Verified** against three databases authored with each order (`México`/`O'Brien`/`a`/`A` fixtures): a -> v1 text column has `0x0E = 01` and produces index keys unlike the v0 encoder. LibRed reads the version from -> `0x0E` as a byte. (This was a real bug: it used to read the byte at `0x0D` — which is `0` in *both* orders — -> and so reported every database as v0.) +> | offset | size | meaning | +> |---|---|---| +> | `0x0B` | 2 | **LANGID**, little-endian (`0x0409` = 1033 en-US) — the low word of the LCID | +> | `0x0D` | 1 | **Sort id** — the LCID's high word | +> | `0x0E` | 1 | **Sort-order version**: `0` = the legacy compacted table, `1` = the Access-2010 NLS order | > -> > **⚠ Watch the low byte `0x0D`.** It is `0` in every file observed, so LibRed doesn't model it (it rides -> > through the raw descriptor unchanged on a rewrite). But the field is *nominally 2 bytes* — if a database -> > ever carries a non-zero `0x0D`, the version is wider than one byte and we're truncating it. This is the -> > same trap as the AutoNumber increment at TDEF `0x18`, which looked like a 1-byte flag but is a full -> > signed int32; re-check `0x0D` if a new collation ever behaves oddly. +> so the full LCID is `(0x0D << 16) | LANGID`, and Jet reuses the LCID's otherwise-unused top byte for the +> version. Windows does not define that byte, which is what makes the reuse safe. +> +> **The sort id is what separates an alternate sort order from its base locale.** `German Phone Book` is +> `0x00010407` against German's `0x00000407`; `Hungarian Technical` is `0x0001040E` against Hungarian's +> `0x0000040E`; `Georgian Modern` is `0x00010437`. All share their LANGID with the base locale and differ in +> **nothing else** — verified from Access-authored fixtures in `LocaleFixtureCollationProbeTest`, where the +> whole four-byte field is printed raw and reconciled against the parse. +> +> > This byte was documented here for a long time as *"`0` in every file observed… if a database ever carries +> > a non-zero `0x0D`, we are truncating a wider value"*. That is exactly what happened, and until the +> > fixtures arrived LibRed read Hungarian Technical as plain Hungarian. Kept as a note because the warning +> > paid for itself: the same reasoning applies to any field we observe as constant-zero. +> +> **Verified** against databases authored with each order (`México`/`O'Brien`/`a`/`A` fixtures): a +> v1 text column has `0x0E = 01` and produces index keys unlike the v0 encoder. (Reading the *version* was +> once a real bug too: LibRed read the byte at `0x0D` — `0` in both General orders — and so reported every +> database as v0.) > > **The collation is stored in two places, and they agree.** The `(LCID, version)` sort order lives > *both* per column (here: locale at `0x0B`–`0x0C`, version at `0x0E`) *and* database-wide in the @@ -83,12 +94,11 @@ > or datetime2) can still be v0 — so the collation must be read from `0x0D`-`0x0E`, never inferred from the > format version. > -> **LibRed model.** The `(locale, version)` pair is a `Collation` value (`CollatingOrder` enum = the DAO -> LCIDs; `Version` is the `byte` at `0x0E` — `0` legacy, `1` General). It is **read** per column into -> `ColumnDef.Collation` (numeric columns, whose `0x0B/0x0C` are precision/scale, carry none) and **written** -> from `JetDatabase.Collation` (the database default) as that one byte; the low byte `0x0D` is left as-is (0 -> on a fresh column, preserved from the original otherwise). The write is byte-identical for General legacy -> (verified). `IndexKeyEncoder` **gates** on the collation: it refuses (throws) anything but General legacy +> **LibRed model.** The triple is a `Collation` value — `Order` (the LANGID, as a `CollatingOrder`), +> `Version` (`0x0E`), `SortId` (`0x0D`) — with `Collation.Lcid` assembling the 32-bit LCID. It is **read** +> per column into `ColumnDef.Collation` (numeric columns, whose `0x0B/0x0C` are precision/scale, carry none) +> and **written** from `JetDatabase.Collation` (the database default), all three bytes explicitly. The write +> is byte-identical for General legacy (verified). `IndexKeyEncoder` **gates** on the collation: it refuses (throws) anything but General legacy > rather than emit v0 key bytes for a v1 or non-English column. > LibRed reads and distinguishes v1, but does not yet **encode** its index keys (the v1 weight table is the > remaining work, §10.4). 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 0909a180..418e02a1 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -73,15 +73,13 @@ into an index. The encoder is verified **byte-for-byte against Access**: re-enco decoded from Access's own stored key reproduces the exact bytes, and after a LibRed insert Access satisfies an indexed primary-key seek over the entry LibRed wrote. -> **Text keys are the version-0 "General legacy" collation only.** The text weights below are the -> Access 2000–2007 **General** sort order = (locale 1033, **version 0**), selected by the column -> descriptor's sort-order version (`0x0D`, §3.4). Access 2010+ introduced a *new* default **General** -> order = (1033, **version 1**) with different key bytes (the old one was renamed "General legacy"). -> LibRed implements version 0 only; a version-1 column/index (a database created by Access 2010+ with -> the default order, `0x0D = 1`) would need a separate weight table for both decode and encode. -> Before writing/seeking a text index key, a version-aware implementation should check `0x0D` and -> refuse (or switch tables) on version 1 rather than emit version-0 bytes. Not yet handled — no -> version-1 fixture available to reverse-engineer against. +> **The text weights below are one specific collation: General, version 0.** A text column's collation is +> the `(LANGID, sort id, version)` triple in its descriptor at `0x0B`–`0x0E` (§3.4) — here +> (1033, 0, **0**), the Access 2000–2007 order Access later renamed "General legacy". The other orders use +> the *same framing* and different weights: see **Two General orders** and **Locale-specific orders** below. +> Encoding must **gate on the whole triple** rather than assume, which is what `IndexKeyEncoder` does — it +> throws on anything it has no table for instead of emitting General bytes. That matters more than it +> sounds: a wrong key does not fail, it silently disagrees with ACE's. Non-boolean columns are prefixed by a **flag byte**: @@ -218,8 +216,149 @@ Then the value, transformed: `80 B5 FE FF 00`). The inverted start flag is `~0x7F = 0x80`, matching the descending flag of the fixed-type keys. + **Locale-specific orders.** A database can be created with a sort order other than General; Access exposes + them as the "New Database Sort Order" list. Verified against **29 Access-authored fixtures — every non-CJK + entry in that list**, in `Data/`, each diffed against General v0 by having ACE encode the same 193 samples + and reading the stored keys back (`LocaleFixtureCollationProbeTest`, plus `DaoLocaleCollationProbeTest` for + orders only DAO can name): + + - The stored value is a **true LCID**, not a small enum — Spanish Traditional is `1034` (`0x040A`) and + Spanish **Modern** is `3082` (`0x0C0A`). DAO's `CollatingOrderEnum` lists only `dbSortSpanish = 1034`; + the Modern order postdates it and has no DAO name. Both files are **sort-order version `0`**, so the + version is **orthogonal to the locale** — though in practice few locales have both generations. Access's + "New Database Sort Order" list names a legacy order separately (`General - Legacy`, `Romanian - Legacy`, + `Croatian - Legacy`, `Japanese - Legacy`), and **neither Spanish order has a `- Legacy` twin**: a second + generation exists only where the Windows tailoring actually changed. + + - **Version 1 is not a General-only thing.** Five of the fixtures stamp version `1` — Bosnian, Croatian, + Indic, Romanian, Serbian — and all encode with **2-byte NLS primaries** exactly as General v1 does + (`a` = `7F 0E 02 01 00`, `c` = `7F 0E 0A 01 00`, `d` = `7F 0E 1A 01 00`). Every one of 193 samples differs + from General v0, because the whole key shape changes rather than individual letters moving. Croatian and + Romanian are the ones Access offers in both generations, and their `- Legacy` twins are ordinary v0 files + with the same LANGID; Bosnian, Indic and Serbian have no legacy twin at all. + + - **The whole four-byte field is one 32-bit LCID** (§3.4). Several entries in Access's list are Windows + *alternate sort orders*, which live in the LCID's high word and share their LANGID with the base locale: + + | fixture | raw `0x6E`..`0x71` | LANGID | sort id | version | LCID | + |---|---|---|---|---|---| + | `German Phone Book` | `07 04 01 00` | 1031 | `01` | 0 | `0x00010407` | + | `Hungarian Technical` | `0E 04 01 00` | 1038 | `01` | 0 | `0x0001040E` | + | `Hungarian` | `0E 04 00 00` | 1038 | `00` | 0 | `0x0000040E` | + | `Georgian Modern` | `37 04 01 00` | 1079 | `01` | 0 | `0x00010437` | + | `Croatian` / `Croatian - Legacy` | `1A 04 00 01` / `1A 04 00 00` | 1050 | `00` | 1 / 0 | `0x0000041A` | + + Hungarian and Hungarian Technical differ **only** in the sort id, so an implementation that reads the + LANGID alone cannot tell them apart — LibRed could not, until these fixtures. + + - **German Phone Book is an expansion, not an insertion**: `ä` = `7F 4A 51 01 00`, i.e. primaries `a` + `e` + (General has `7F 4A 01 13 00`, `a` + umlaut secondary); likewise `ö` → `o`+`e` and `ü` → `u`+`e`. It uses + the same primitive as `ß`→`SS` above, so it needs no new machinery. + - The **framing is unchanged**: start flag, primaries, `0x01`, secondaries, `0x00`. Only the weights move. + + - **A language letter takes a two-byte primary — a free value from the letter table, plus a sub-position.** + The General letter table steps by +2 almost everywhere (only `B→C`, `Q→R` and `X→Y` are consecutive), and + tailorings land in those gaps, always in the linguistically correct place: + + | locale | letter | key | slot sits between | + |---|---|---|---| + | Spanish Traditional | `ch` | `7F 4E 04 01 00` | C `4D`, D `4F` | + | Spanish Traditional | `ll` | `7F 5F 04 01 00` | L `5E`, M `60` | + | Spanish (both) | `ñ` | `7F 63 04 01 00` | N `62`, O `64` — General has `7F 62 01 19 00`, `n` + tilde | + | Czech / Slovak | `ch` | `7F 58 03 01 00` | H `57`, I `59` — Czech sorts `ch` after `h`, not after `c` | + | Turkish | `ı` | `7F 58 06 01 00` | H `57`, I `59` — dotless `ı` before `i` | + | Lithuanian | `y` | `7F 5A 02 01 00` | I `59`, J `5B` — Lithuanian `y` follows `į` | + | Estonian | `z` | `7F 6C 07 01 00` | S `6B`, T `6D` — Estonian `z` sits between `s` and `t` | + | Croatian Legacy | `lj` / `nj` | `7F 5F 03 01 00` / `7F 63 04 01 00` | L–M, N–O | + + **The second byte orders letters that share a slot.** That is settled by the locales which put several + into one: Hungarian Technical fits five in the A–B gap `0x4B` — `á` `02`, `â` `03`, `ä` `04`, `ă` `05`, + `ą` `06` — and three in `0x4E` (`ç` `02`, `ć` `03`, `č` `04`). Estonian's `0x6C` holds `š` `06`, `z` `07`, + `ž` `08`, in exactly Estonian alphabet order. Swedish/Finnish and Norwegian/Danish both stack their three + extra vowels after Z: `å` `05` / `ä` `07` / `ö` `08` for Swedish, `æ` `04` / `ø` `06` / `å` `09` for + Norwegian — each language's own order. (An earlier revision here said cross-locale disagreement ruled a + sub-position out. It does not: it only ruled out a *fixed marker*. The values are per-locale ordinals.) + How a specific value is chosen is still unknown — they are ordered but not dense, and Latvian uses `0x12` + for `ķ` and `0x0C` for `ņ`. + + - **The after-Z letters use the `0x79` page**, which is where General already keeps Greek and Cyrillic: + Czech `ž` = `79 05`, Polish `ż` = `79 04`, Icelandic `þ` `03` / `æ` `04` / `ö` `05`. Same two-byte + primary + sub-position shape. + + - **Tailoring is not only insertion.** Five other devices appear, all within the existing framing: + + - **Contraction** — two characters, one primary. The Spanish and Croatian digraphs above; Hungarian's + full set (`cs` `4E 05`, `gy` `56 03`, `ny` `63 06`, `sz` `6C 08`, `zs` `79 09`, `ty` `6E 06`), including + the doubling rule: `ggy` = `56 03 56 03` and `ccs` = `4E 05 4E 05`, each half weighing as the digraph. + - **Expansion** — one character, several primaries. German Phone Book: `ä` = `7F 4A 51 01 00`, primaries + `a`+`e` (General has `a` + umlaut secondary); likewise `ö`→`o`+`e`, `ü`→`u`+`e`. Same primitive as + `ß`→`SS` above, so it needs no new machinery. + - **Secondary retune** — a letter stays on a base primary but changes its *secondary*. Swedish/Finnish + makes `w` a variant of `v` (`7F 71 01 03 00`, `v`'s primary plus secondary `03`) and `ü` a variant of + `y`; Estonian does the same for `w`. Danish folds `aa` onto `å` (`79 09 01 03`). Lithuanian leaves its + ogonek letters as secondaries but *changes the weight* from `0x1B` to `0x0F`. Slovak and Croatian Legacy + go the other way and **demote** letters General gives distinct diacritics. + - **Remapping the base table.** Estonian is the extreme: `v` moves to `70 03`, and `õ` and `ö` take over + the *bare* one-byte primaries `0x71` and `0x73` that General uses for `v` and `w`. So a locale can + rewrite the base alphabet, not merely extend it. + - **Reordering.** Thai is the only order here that changes a *sequence* rather than weights: `เ` and `ก` + each match General on their own, but the pair `เก` is `7F 7C 99 01 03 00` against General's + `7F 7C 93 7C 98 01 03 03 00` — the leading vowel, written before the consonant it follows + phonetically, is folded with it. + + - Promotion is selective, and non-promoted letters keep the ordinary base-plus-secondary form: `ż` is a + letter in Polish (`7F 79 04 01 00`) but merely `z` + accent in Czech (`7F 78 01 04 00`). **Turkish** + resolves case in the tailoring rather than by folding: `ı` takes its own slot, and `İ` collapses onto + plain `i` (`7F 59 01 00`, no secondary at all) where General gives it a secondary `0x10`. + + - **Every order is General plus a small tailoring** — including the version-1 ones. Compared against the + General order of **its own version** (the v1 baseline is a database LibRed creates with + `Collation.General`, which ACE then encodes into), no order departs in more than 47 of 193 samples, and + a version-1 order is *not* a wholesale reweighting — it only looked like one against a v0 baseline, + because the key shape changes. `LocaleFixtureCollationProbeTest` reports both. + + | departure | orders | + |---|---| + | 47 | Hungarian Technical | + | 14–16 | Bosnian, Croatian, Croatian Legacy, Estonian, Serbian (16), Slovak (15), Czech, Hungarian (14) | + | 6–11 | Icelandic (11), Lithuanian, Norwegian/Danish, Polish, Vietnamese (9), Latvian, Slovenian, Swedish/Finnish (8), Romanian (7), Turkish (6) | + | 1–4 | Romanian Legacy (4), German Phone Book, Spanish Traditional (3), Macedonian (2), French, Spanish Modern, Thai, Ukrainian (1) | + | **0 — indistinguishable from General** | **Georgian Modern**, **Indic** | + + Croatian, Bosnian and Serbian depart in the same 16 as Croatian Legacy — the same letter set tailored in + both generations, so a locale's *character list* is version-independent even though its weights are not. + + Two caveats on reading this as effort. 193 samples are a sample, not an alphabet: `0 differ` means + *indistinguishable over these*, and a real implementation needs a fuller sweep per locale. And **French + is under-measured** — its one difference is in the *secondary section*, consistent with French ordering + accents from the end of the word, which single-character samples cannot exercise. + + - **Some orders are recorded but unimplemented — including one Access itself lists.** `Arabic` (1025), + `Greek` (1032), `Hebrew` (1037), `Dutch` (1043) and `Cyrillic` (1049) are created happily by DAO, land on + page 0 with the right LCID, get stamped onto the columns ACE itself creates, and ACE opens and runs DDL + against them — yet the keys are **byte-identical to General across 57 samples**, chosen to include what a + tailoring would actually move (Greek tonos and final sigma, Cyrillic `ё`/`й`/`ь`/`ъ`, Hebrew final forms, + Arabic hamza forms, the `ij` ligature). Access's list offers none of those five. But `Georgian Modern` + **is** in the list, carries sort id `0x01`, and is likewise indistinguishable from General over 193 + samples — so appearing in the UI does not imply an implementation, and the sort id can be recorded for an + order that does nothing. + + - **DAO can author a locale order**, even though it cannot author a sort-order *version* + (`DaoDatabaseCreationProbeTest`). A DAO-created `LANGID=0x040A` database reproduces the Access-authored + `SpanishTraditional.accdb` keys byte-for-byte, so locale fixtures need no manual Access step. + - `ñ` is a **letter in both Spanish orders** and an accented `n` in General — so **Modern = General plus + that one letter**, and **Traditional = Modern plus the two digraphs**. Every other sample encodes + byte-identically across all three orders. + - Traditional's digraphs are a **contraction**: two characters producing one primary, the inverse of the + expansions above. `chico` is `7F 4E 04 59 4D 64 01 00` — five characters, four primaries. Case folds as + usual, so `ch`, `Ch` and `CH` share a key. + + LibRed **reads** these databases; `Collation.IsIndexKeyEncodable` is false for any locale other than + General v0/v1, so it refuses to write their index keys rather than writing wrong ones. Neither encoder + implements contraction. + *Not yet handled:* characters outside ASCII + the accented Latin-1 set above (and a key mixing an - accent with an ignorable apostrophe/hyphen is untested). + accent with an ignorable apostrophe/hyphen is untested); every locale other than General (above). - **GUID:** the start flag `0x7F`, then the 16 GUID bytes in **canonical string order** (i.e. `guid.ToString("N")` bytes — **not** the mixed-endian `.ToByteArray()` storage layout), split into two 8-byte halves by a constant `0x09` marker, and terminated by `0x08` — a fixed **19-byte** key. Data diff --git a/test/LibRed.Core.Tests/DaoLocaleCollationProbeTest.cs b/test/LibRed.Core.Tests/DaoLocaleCollationProbeTest.cs new file mode 100644 index 00000000..5b8fd234 --- /dev/null +++ b/test/LibRed.Core.Tests/DaoLocaleCollationProbeTest.cs @@ -0,0 +1,207 @@ +using System.Reflection; +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: the five collating orders DAO names but Access's "New Database Sort Order" list does not offer — +// Arabic, Greek, Hebrew, Dutch and Cyrillic. +// +// CollatingOrder mirrors DAO's CollatingOrderEnum, which is a Jet-3.5-era list. Access's own list is a +// different set: it has no Arabic/Greek/Hebrew/Dutch/Cyrillic entry, but adds Bosnian, Croatian, Serbian, +// Macedonian, Ukrainian, Estonian, Latvian, Lithuanian, Slovak, Romanian, Georgian Modern, Vietnamese, Indic, +// French, German Phone Book, Hungarian Technical and the CJK variants. So the two disagree in both +// directions, and it is worth knowing which of DAO's names still do anything. +// +// For each: does DAO accept the locale, what LCID lands on disk, will ACE open the result at all, and do the +// index keys actually differ from General v0? DAO can only author version 0 (DaoDatabaseCreationProbeTest), +// so any difference here is a locale difference, not a sort-order-version one. +public class DaoLocaleCollationProbeTest(ITestOutputHelper output) +{ + private const int UseJet = 2; + private const int Ace12 = 128; + private const int Jet4 = 64; + + // (label, DAO locale string, the CollatingOrder the DAO enum claims for it). The last four are the + // positive controls: Access *does* list Spanish/Czech/Polish/Turkish, and Spanish Traditional's keys are + // already known to differ from General (SpanishCollationProbeTest). If those come back identical too, + // the finding is about DAO's locale argument being inert, not about the five orders being unimplemented. + private static readonly (string Label, string Locale, int ExpectedLcid)[] Locales = + [ + ("General (control)", ";LANGID=0x0409;CP=1252;COUNTRY=0", 1033), + ("Arabic", ";LANGID=0x0401;CP=1256;COUNTRY=0", 1025), + ("Greek", ";LANGID=0x0408;CP=1253;COUNTRY=0", 1032), + ("Hebrew", ";LANGID=0x040D;CP=1255;COUNTRY=0", 1037), + ("Dutch", ";LANGID=0x0413;CP=1252;COUNTRY=0", 1043), + ("Cyrillic", ";LANGID=0x0419;CP=1251;COUNTRY=0", 1049), + ("Spanish [control]", ";LANGID=0x040A;CP=1252;COUNTRY=0", 1034), + ("Czech [control]", ";LANGID=0x0405;CP=1250;COUNTRY=0", 1029), + ("Polish [control]", ";LANGID=0x0415;CP=1250;COUNTRY=0", 1045), + ("Turkish [control]", ";LANGID=0x041F;CP=1254;COUNTRY=0", 1055), + ]; + + // Latin controls; the digraphs Spanish/Czech treat as letters; the one Dutch is supposed to; then the + // letters Polish and Turkish add, and one pair per non-Latin script. + private static readonly string[] Samples = + [ + "a", "c", "z", "e", "é", + "ch", "ll", "ñ", "č", "ř", "ž", + "ij", "ijsbeer", "y", "yak", + "ł", "ą", "ż", "ı", "i", "İ", + // Per script, the letters whose ordering the tailoring would actually move — not just the first two + // letters of the alphabet, which sort the same in every order and so prove nothing. + "α", "β", "Α", "ά", "σ", "ς", "ω", "ώ", // Greek: tonos, and final vs medial sigma + "а", "б", "А", "е", "ё", "и", "й", "ь", "ъ", // Cyrillic: yo after ye, short i, the signs + "א", "ב", "כ", "ך", "מ", "ם", "צ", "ץ", // Hebrew: medial vs final forms + "ا", "ب", "أ", "إ", "آ", "ة", "ه", "ى", "ي", // Arabic: hamza forms, ta marbuta, alef maqsura + "ij", "IJ", // Dutch: the IJ ligature as well as the pair + ]; + + [Fact] + public void Probe_dao_only_collating_orders() + { + object? engine = CreateDbEngine(out string progId); + if (engine is null) { output.WriteLine("DAO unavailable in this process."); return; } + output.WriteLine($"DAO engine: {progId}"); + object workspace = Invoke(engine, "CreateWorkspace", "", "admin", "", UseJet)!; + + // Jet 4 first: these are Jet-era orders, so an .mdb is the format they were designed for. + output.WriteLine(""); + output.WriteLine("Jet 4 (.mdb) — creation and the LCID that lands on disk:"); + foreach ((string label, string locale, int expected) in Locales) + output.WriteLine($" {label,-18} {Create(workspace, locale, Jet4, ".mdb", expected)}"); + + output.WriteLine(""); + output.WriteLine("ACE 12 (.accdb) — creation, then whether ACE will use the file:"); + Dictionary> keys = []; + foreach ((string label, string locale, int expected) in Locales) + { + string path = TemporaryDatabase.CreatePath("dao-locale-", ".accdb"); + try + { + object database = Invoke(workspace, "CreateDatabase", path, locale, Ace12)!; + Invoke(database, "Close"); + + using (var db = JetDatabase.Open(path)) + output.WriteLine($" {label,-18} lcid {db.DefaultCollationLcid} " + + $"(expected {expected}){(db.DefaultCollationLcid == expected ? "" : " <-- MISMATCH")}" + + $", version {db.DefaultCollationVersion}, order {db.Collation.Order}"); + + keys[label] = KeysFor(label, path); + } + catch (TargetInvocationException ex) + { + output.WriteLine($" {label,-18} rejected by DAO: {ex.InnerException?.Message.Trim()}"); + } + catch (Exception ex) + { + output.WriteLine($" {label,-18} {ex.GetType().Name}: {ex.Message.Trim()}"); + } + finally { TemporaryDatabase.Delete(path); } + } + + if (!keys.TryGetValue("General (control)", out Dictionary? general)) return; + output.WriteLine(""); + output.WriteLine("index keys vs the General control (only differing samples listed):"); + foreach ((string label, Dictionary theirs) in keys) + { + if (label == "General (control)") continue; + var different = Samples + .Where(s => general.GetValueOrDefault(s) != theirs.GetValueOrDefault(s)) + .ToList(); + if (different.Count == 0) { output.WriteLine($" {label,-18} identical to General for all {Samples.Length} samples"); continue; } + output.WriteLine($" {label}:"); + foreach (string s in different) + output.WriteLine($" {Describe(s),-14} General {general.GetValueOrDefault(s) ?? "(none)",-26} " + + $"{label} {theirs.GetValueOrDefault(s) ?? "(none)"}"); + } + } + + private string Create(object workspace, string locale, int type, string extension, int expected) + { + string path = TemporaryDatabase.CreatePath("dao-locale-", extension); + try + { + object database = Invoke(workspace, "CreateDatabase", path, locale, type)!; + Invoke(database, "Close"); + using var db = JetDatabase.Open(path); + return $"created; lcid {db.DefaultCollationLcid} (expected {expected})" + + $"{(db.DefaultCollationLcid == expected ? "" : " <-- MISMATCH")}, version {db.DefaultCollationVersion}"; + } + catch (TargetInvocationException ex) { return $"rejected by DAO: {ex.InnerException?.Message.Trim()}"; } + catch (Exception ex) { return $"{ex.GetType().Name}: {ex.Message.Trim()}"; } + finally { TemporaryDatabase.Delete(path); } + } + + /// Has ACE build and populate an indexed text column, then reads the stored keys back with + /// LibRed. An empty result means ACE would not work with the database at all — which is itself the + /// answer for an order ACE no longer lists. + private Dictionary KeysFor(string label, string path) + { + var keys = new Dictionary(); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE CollProbe (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_CollProbe ON CollProbe (K)"); + for (int i = 0; i < Samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO CollProbe (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", Samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + } + catch (Exception ex) + { + output.WriteLine($" {"",-18} ACE refused the file: {ex.GetType().Name}: {ex.Message.Trim()}"); + return keys; + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("CollProbe"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_CollProbe"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + // Which collation ACE stamped on the column it just created: the database default, or General? That + // separates "the order is recorded but unimplemented" from "ACE overrode it at column creation". + output.WriteLine($" {"",-18} ACE stamped the new column {keyColumn.Collation.Order} " + + $"v{keyColumn.Collation.Version}"); + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values)) + keys[(string?)values[keyColumn.Index] ?? ""] = Convert.ToHexString(stored); + return keys; + } + + private static string Describe(string s) => + s.All(c => c is >= ' ' and <= '~') ? $"\"{s}\"" : string.Concat(s.Select(c => $"U+{(int)c:X4}")); + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + private static object? CreateDbEngine(out string progId) + { + 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; + } + + private static object? Invoke(object target, string member, params object?[] args) => + target.GetType().InvokeMember(member, BindingFlags.InvokeMethod, null, target, args); +} diff --git a/test/LibRed.Core.Tests/Data/Bosnian.accdb b/test/LibRed.Core.Tests/Data/Bosnian.accdb new file mode 100644 index 0000000000000000000000000000000000000000..8b11411798ed9dec71a1564d3b5577f5f9946e6d GIT binary patch literal 495616 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 literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/Croatian.accdb b/test/LibRed.Core.Tests/Data/Croatian.accdb new file mode 100644 index 0000000000000000000000000000000000000000..02033df5dd563fec44907739e93ff4a09a77ef35 GIT binary patch literal 458752 zcmeI52VfM{+J?_;HieYk5RnpEfY4h)2L;jyMS3V=gFr$EBq1aLiBbaSRj}e!5W865 z+O8FqBI>nZZ=iw&`)|RDU4;Mr&dh8}AwZH2RIHtu}+#wpuB``@bc zzm{zr-gCsRp1Xdy;m>owZu7-z$FfPE$DQ9J?YW%~JePD*>Vk$3biHs}>6tBzdF^id zXx^%ZO|RXPyLIL}Bi8cne)e|>5Eg*nePZJqY!cduoCyD#+l^jqiFjr{hjj+3{J z2~nzBrv$}=1W14cNPq-LfCNZ@1W14cNT6m2oMk9}PGACtk>y5pUmp&&!9B-aGv$h* zkN^pg011!)36KB@kN^pg011!)3H%cTZhGR^-}fEx;V?do5N%?eN=-nYdrM0-yG_8> zG^4hPP_>l<-{n+rYT%Tky?ODIBPFa_DhP%M*@*Et(H3Dk41bF-@d2lnkCU{Ia22Ms zMR0&c3@n`DBh(X==_3|lgo{Nu1pmnQ8rp}cibQVukXR$cM-&3{_n}OSXarWFg&Rq% z(82}v0xh&wnw=k#y+$pGJj9d0F7PRbrLLI~UUi+ex{;CxenEk_1Qnrz11$C$L1Gc) ziNj2}WP?kks+p<)pKLWnm8fWyrZREORFiQQ!?ccNk`0$d_%61-rejs1%CbzuRl3T9 zoCBYAA4hPwQxo&7yi5DRFU`}h0~?N@jn75 z$`t;G;xyIq=sdi;4uEZ(yu!YnrauA=WA?X%3`Fs`_$Vtp8V45i1`P zD@__2|B(O*kN^pg011!)36KB@kN^oBeFPlFH&LQh6ndt8E4$_D|LAW=2c{TnD-0Si z^uPgck{sgSTWL4@n5D@;_xT4`Fc?7hN{1j|-xh;l^h#S@`~DW8aHu=cz5UqsxPrZg zws3j|19&Z}ZeysZKRyO+3?=wTyTW92Lz~^*(n(#YJDH2oxt*&!l*2sT-GLq1;hv7_ zM6@W_U9(*(Xq}Lb>>{*MNIQnrja)_VvlGlCE#!;z)i=}7YwOpI+`L>{NcofjH^L0U z$<_;RS*An+BtQZrKmsH{0wh2JBtQbk9RY{&lCDp7#87>yN}3g7yB_aNZbleJm51p% zybS8ch+<{vh6Aq`F}A(?%9V}_&=#`xf^YR-hU04sZ!O;*t!`ESrGdbI;Jc^#ZwKYA z{_A!DX*sCSM@YFw2`ebkKhTE@BMPRV-k*myfqV=n566J=BD4_5=mQu2``SgLjo>^z zwBB}Yn)r@Qh}UjLqvk(V*ZyVr`V>f6e*P+VktA5d=T>Y7t&Pn8;9tA1C}o>+tP(>_#KqaO7>j&~EGksY_J_ zc2+>rbYZu9XrlvRU44pdAMx;}KmsH{0wh2JBtQZrKmsIiED|t`pY0LvDn_sWm+0Q* zTH~7RYV3M9>fcerqiRJx6S*+5PvkEV_eac#Xcf@ue=Me%I+FkikN^pg011!)36MZ_ z2Nu>zO<5Iov^rD*(#@w-(xGKkjRbP2DWU%pfPn7TJk)d^S|CnidT3Q4$jxA= zB>f+EmU)Gt`G>FiFuQbNmZZO$|Lt$~U;?|}H{fdCtdM`4%&A z?1@=BhpE2>T;NF3|IL5CvXAlcDlWVNDO>1@bvazCa5`M!_%w4Z$7cb)wG>&9)s78t zSqrwyJ+_8r+rl-^wahij@^i9fItr$-uA5zhJnrjTHp^f#2qCRn>7echp+W-V3|uQYx!uR#N1fuY7v?Sn-;LEg)nPH z#YQ!YY8urzsv$m&qT-?&!PEhhdSaqoG>obzZlmH{&7vAaHHm715G;;5#^zSYpn(Rn zjeI;H~GHv8ZJyKwj&1xmv6PJ}uS(#{&yqdmNA(eMpexj@C=Vr-$q^u>b z!l)K<8R2McYXyW+i2RcLmV8^FU9_yg#cKTokkDEp1;Ts*a$D|IF_BbBj+iB`VKx1m zt{YX;wcI1~kdp3*Cmivbz8hER+t)oxnB}KkfFEC1W~j~p7fX@eW`1c532;&Mvn#X$ z*UL4Si6o0$nn}Ii-5Tt zwd;z2wv^iSM8Mtwwd;$3$sn~Gh=7RzXdQrn_3LXl5dq62)^576%nZjVf`k(+f`k(y zf`k()0_I}W4iljsgjymrfDkT1LkJNfG=>laZOgLiWv0O+e18rfDEesX`V-4C4v4zuYVW=$(69K~z+77pc5tfjsY{3u#BNP%NY$4JXqC~)$ zg+!MvxNSiXC=eP-1_Fps*A`ldAQ1Ej1rhY{0ZFnRsvv?MM<7BAaV#m-gBL`Iw@vgA z0}O&_Ylw*m^=zTF2pAU8)?o|vZJ~__7%0&;$QBydLR%3U!b*?45Fyq!X(s{(Q?%7% zF+|YAB1C8}R!t%3(HSD>ff6Eg5GxG0XsgF-h@gi~h|p22T0qbvI7Db-n{*NZgEHFc zF&!f4At54k7Ap+XXsbtgh@gjHh>##w7`V~4yDjvvg`Of{C`Y2EA%af32s))0+>xl$ zC4x?c2wG2s))AB+6)M14%X~LO?KQQ9$rmb=NPq-LfCNZ@1W14cNZ^e3Tt> z2nZmlCIT_Th^vX94muF0F)a$ymG^V_2453-u%GB?U1J3P!EK=WVqZaf|Efy&i`uV? z3U`3&%R*LTC4d2u011!)36KB@kN^pg011$QNx*45QDyxfOQOlaDFP;kWA1-7s_0VJ zu1;N^HT7dI_o*-mkN^pg011!)36KB@kN^pI2{?_7Ro4IYn(9h%;X*UJEM-YY_H&=#tPL zpnLlwE20D^}{1mxN0lxJ|-KFxee9+ z*OdcWtoS0yzJ|kQn95M4s#pzInQD$2iT?#ETa8eY)pT5^s1h|8Tcj1MR~HRYJPZ*PBG6u{{HKREQ0%v61$xouRyxp&t*cVu zWmDBem8{ZL3O=bgyQto(r|Pb{;?oTQcY|p%OcJz-xabO5-1brfRHEvE&qSmy6RFIE zGKEkn@KfMb20O(CDD%X#rc&V;8e@YTjnoyZnQFEw0zQ#nU397{|LNlmIN1|$*wto$ zSDgQ|PAx3d80a=x03}FrE^e-0zW0*l!hIWN~t=hvQ-WuE>gJ~?*5b% zrRJ*gpE+KouuGtouQs1Fi&tRD_1+*}P8CrYgG>wjjP^#@-4kKt7ycPjod_jGX(+XU zpA>Hd*e%gYksZN-q^KgyuPz#=%74aqqr}FXWr)=sq_*ue(_~*^>bb{~aZ8vtMX`R? z52-S6^8qIJT^`W*-VC;E-4ybe!Zf!k^c;qgm4)Ijcbjld!1E&T z)5)tB2Ev7{6oDLp0%Fe3izxd&1qga30$r?BQfNDgRtl7S{G+iVkVhZ(7PW4GaSxkX+gIN^%(paVAd}>& zl8L8FHX;kWyX3pi9fn;@tq2~x)r`QtV!PNf`=OMIAoG!JQk>@EiBJkTTmNX4=%1V^ z_;HqB!vcH@d|sH}1=(gPx7blNAi{^Nh^rIf7_C5w8dTxO6G@9hibM**6chr8i76SI1Vs0P9gJB#j`=RSoodjn%f${s59THKZMUm% zV!j0L%1G(HeX6oQ2NGLHgHY-ceHA}Qa%CQ19t1!3#^1|4en6uNKTi0mYsF*w@y&gx z2fsL+@`y1@hTO<Qv`-;XH*3(NEXjnj@PrKv0lkN^pg011!)36KB@ zkN^pgK#db%{lCVM9#sKK{Xb19*8h*HT%j@~KmsH{0wh2JBtQZrKmsIi_zAH7fB2an z&KRZspU(RK;Y7~3NPq-LfCNZ@1W14cNPq-L;HV_vFs_n?1hLMpxyqZZr0+t0Vm4e1 zd<^U38%~G++J3S;pd5w@$C}Zy7OX5i$ram7D*R6ZBtQZrKmsH{0wh2JBtQZrKmtdK zfMI0m`oF~QP#fHHJl~F#MvRvPNPq-LfCNZ@1W14cNPq-LfCNaOrU@Ku|9>nx`EdqS z-2d-j|9?$q6vHC{5+DH*AOR8}0TLhq5+DH*Ab~?5!2bV3fJd(+KmsH{0wh2JBtQZr zKmsH{0wi#Z5IETW|3>Kl$LXxN|KG{}|6?TosU``K011!)36KB@kN^pg011%5i9vw< z|0hO1GnFJj0wh2JBtQZrKmsH{0wh2JCpLkD?f-9r{(qby?EgQp%MVje0wh2JBtQZr zKmsH{0wh2JByemHVE_NIkx$f;1W14cNPq-LfCNZ@1W14cNPq+mg}}k~|Hq;KA7^OA z{r_Q{|F3wE011!)36KB@kN^pg011!)36Q|iLxBDNM^9c*6A~Z+5+DH*AOR8}0TLhq z5+DH*_@@XQZ2y0A^#9{@SKR*}&Hn#?D#I8q36KB@kN^pg011!)36KB@kN^oB1OfK{ z9|SadApsH~0TLhq5+DH*AOR8}0TLjAV~oH7^#2>m@Fp%E{r@BFJd7V!5G=~dX*0;>|Jbd`(22iYnOgAht^#&}H9VVsXJg zG4L-Y8QOQgD)ezZMwQ}wwsLt~k5!}9P(W~kNH9(;gn<7+I63seE)cwu011!)36KB@ zkN^pg011!)2^>)ZhA~Lj|3!%qwZT2do#@`>TH~7RF+HN{F>(?h0TLhq5+DH*AOR8} z0TLhq5+H#ZAYfq5zVzP9F;ac&-r;`U{jmF1_vP;M-P7D--6`(Q?ndrf?q6M>x?Xo} za^2-x>AJu*%k^i}lBme2Ya*LOu8By9csx8M{H0oBYJCtkHSGJ)_d~NoM~C(g?GV}^ zG$eF)$VVZsgscx)6LNLPqLA>A--646^MlU_{?z%pbCdHf=SpXsGs^jA(EkM$2Av%= zB&cW5la6~G*Eue7L_7XAzBb-5CKA&n-EB$$5MJ&UP--Ym-6c4P3i%eVh0`0V>qo89r$_iJ~a zbLp&`c0ax$G;Ut(s?Hru|DNpN|6Z6hCjZKK1iuN|!qUkn}z z4DZ93CvT(cx4M(E_G>-(D{!AEU#p zNo!3?`MH~ z!E2R$#61rZAc3Qpz_8I>)J@V*fmt*I)mSwc^I;lU3v=eGEIl_SSItxfn7=Yx&zGs# zb_`~_%*9NYx@v?f!pxX#_>fsOC6L8;q%{+!1U5x@sEVsBEvI0vOpNFI(WXz468%OX z1ukXJ0UCyM6(Xf+(0?jq;Zg!-lIn9+FPP_I?ogqgMO5lBFV>-CsA=#~1eYVhC0A3* zz*V@!;1_icmMJ%++O6YOF=%)C0ATBfZ7>|jmoA_!3+iAGU{HAooCwVy-|HW=DQkbk$EIC<#&_s7{sJ6dU_{+mt zl|D^<;;V4u)i42kvNZ=`l7~DRuQe{P=ugpF7r@OFtzQ;2v}bx1YnRzD9|-M) zQ=Vp#4eH)>SERq>tvOrF8;0-z_P{Td^EAp!M&5@y4jaO9rfMlNZ~3+;68#L_uam z$_NqRb<7qGRa;c}%dWBFGth^a#r<|&YKs+LB#A7?7!<3?$n0#DXd0Bu2=oS(3rRB0 zJObHO;+V=BcnGgBB+MZKlB*2318Or@B{w1@*&;;Rwo#TXT!y%`yd_0s zw^w#XLl(d=ch>gGAu(PzAt!11i4+OEj<^=bC&Lg`BrVs~4wRB#+$ZqTU#9Q2;h4`l zSjDMPxC`ZeD}`*|KW%)Taak}opM3d{{O>z?ijKt{ldb*A{UrBeG0v&DH{)1M|KP67vJP!T}w23?^B;LCEcW(|P zf0y#a^Ts_++uP5!IQ{!R2fxdi2u*aC800CDgHVM;Kd_kw`CQn^yTBYgE6nGn`E-o) z@hi_5(Ni>!)4z^IXl6C33`60QCzJV8etBkU2J+N4^FUaApzk~LVI!qn{tT;>8y?wkI>btw8&1r` z_Q+-s!zuT67Q&6g1Hp}FVhzFtOikf{=;*+9Fs# zTJ`*WD-f&_;6Jh7OsizPUTz>nC*IeGwGRPu3PJc;7&<7Q#RIH3P%0!2pOp(d7Ijd3 z&09pM%8f3M39so`+#+VxDH|FMhU`SKo`dwrV?&B;w0=~WFHG`kodrAlb!v(H1StdS z0#x?r1lEq0HcRzY{5Vh+EB6)*Kb;V9ZHEEfFFhgo_XfAwmQU zCP|1C0j);~Q6gX#Pl8JXj15V^7ak-)0wh2JBtQZrKmsH{0wh2JM}mOUNQ|tk{;#Wt zDoLw_76u41ydPCYYm~oSV6F{dsSlaGBvr;T^DF-!VFFU|HWs?VU87vFu39dKYnf}g zv=*5ad2MsEYq@KcYaU#*gzY@n&8`KO8(n{gn^^c;h0}p>!d=baemUGY;2yFLO}7U4 zxRjWddu(+R0c=|!^ks<0^rPDyVA=&H&0KftFq*7C3laGsC|pZ>LNg2x5+DH*AOR8}0TLhq5+DH*sDyyiNUF5|KM56m ztZ7i;rIO%ie3TI|>;Gjwg8=*o0O)UMvN1;i=x?5(0Q#F}Jb?b@85E$uc}524Z=T@+ z`kQBrfd1wgD4@T2#s=tbo&f^767CtJpew+haRvID9zkGMdyyhN)Zi$?O?60s1W14c zNPq-LfCNZ@1W4ehC14n5N7()U(dssLlKTtS-L5=WN7r{zk4DXnY8&-Mp^vEx z0+NNtQ?Ls8OeicP7bTf?%=WdFPC{KQ-!41VNh07;94E-+(+ZW2gTnQ}G7`g(Jzm7I zjdU{B>6VQI%BS0gTVcAS98lk%k81u0X zw&+t-h4&8d8n5%9j@LW--mv>n3YLnmEc4>Er#hO1hl4CXo`J>t?d@eMSGvMj(Hm&( zE@18#b%5Sw;Oc0gD%306mmFxCWoWs*_d(?`x6wtmK9+2c_TrL7>+Ovd>~bLBq|A+b z*uT`HT$aJub2xG#O56i0x}zin`_}U<7XF(yOa%2*q&*oc!KY#aC-Eim+i~01k{Q|0wh2JBtQZraKHqd*vm<&pK-pnC=v_u7z#UB zi_sC-M_i(ng@b(d(v}_m_U*;K++O8uqnFP->6{+3BgU4zGVGoWvo84Jzq?<$_VeeK zoWJC}J%8stwY7fnX-RJuWo^4Lc-!UM{}X)neW}gvcg*vXjaSBPb8?ANv&&rHltnbb@NNJ!p{jEGJWWGt@m8?*WN;#i5;zva_IreVTW+r z=x{3M6RoWrjM4&>L$=yRhf_J9Xl>}FfN~OTfO1`65TKkG z9RcMe+5qLy)?=2r3(Q{#JY8%fyJWf@Myy`v|3kDHm^)!}p#f)3I8XYK!IH(RK# zXuiNHhuwS-F8+Tg!;w>|AV1PxTOVYrzB?w#l1M1cfIfCNZ@ z1W14cNPq-LfCNr>0*2ApvnG3xwg2A<4}}0ofCNZ@1W14cNPq-LfCNZ@1W3S#fVB+1 zkIBJ^NeAO8(9b~!;xvL?=Fow_ATc+>iLoL)j%fmV{XeDp+57+X(@XAQ$VNXIR=|S< zNPq-LfCNZ@1W14cNPq-L;6x$dFkG?>hx!`TK^b)}>txsqYxpv-#{bJI2)g3f1LM(g z$|26}!=4MEN8MLCJz(6Yb|lLMcC|V2Lcvp$rV#uk6nO;5{wU<~#xM-TWoP;zi z{4x-86U>ETD(5=Rv(SaizL|Iwa9ho+y@($Uss*8fEa7ZZ%tHyZ*(&9_U z)*1u*oB!|fnQLzD1c#6fZ+OzuM=Tys2nZ8Qi34EOa z2Rn0La@S|4!9Wsb`o~mbw*4@bfmINSRb_Md$AG6VJI>G7naF=6KmsH{0wh2JBtQZr zKmsK24-(+~|9=oJgC+qIAOR8}0TLhq5+DH*AOR9M;sk1B@4lMZ9?$MGKO`mx8;H|* zI7$|Pujufd|6e1#DvE?=|0n@#{C|=?|3Ar||DR;f|4*{#|0mh=|C8+b|4H`z|0H|< zf08}_Kgpi|pQI-gU<^o-?>-R!Xqp%}36KB@kN^pg011!)36KB@kidZxVEzBVnH@_$ ztl9nn^?yv^Fzf#!*wg;a`oEai)Ber+znJLyzX-biFM_WBi(oG(!218OR4%A136KB@ zkN^pg011!)36Q{vN5C*TxUlxSJ{;;~cR%j^cj6TSrkDgsfCNZ@1W14cNPq-LfCTnS z00ZD_d)JEF->P55`&~J#{JLQK3$whBfvO0ri_KPrN>*gspZ{XG%2Hd@GitTET1{3i z?1Z-Oz><}!GF6GnRyl~aNaaG>e?qI{!kZBP7)q;^Fn>R$R+7~B)w}9;^)E;|sfIDj z-Nnl1AoZSmo%=%fezTnpNPq-LfCNZ@1W14cNPq-LfCNZ@1P+`)s9wZA6^$0fXi1o< z@?~FkZ6;wgQL(D7{={H+b_X_Q&k4oFJnU->s`KIc6Jj~a4)rVuZ!Zo|fg{6sBTANh zmm@;G<-XHh>K^KD>i)^~tm|6W2v>90FM59f(*^&N011!)36KB@kN^pg011!)2^>=d zLQ&N-yX}*4l`5pYW0=%5&03;ejhr8fTBWx#DqDn!W2vB4=vcM`_pQms`D~{arxHE2 zTHjcEs4l9LYNuMM=2qo(VW^VD_3YX$HaFAXaj7`gP#?!KoL#=lQK5WtheN^aJ+6H8?c~L`T`>4>d)c>VtS{#dP z9~C;5`o0Wf7sqzpUp!RkSnB)jaf)NR?hmnI$5P*q!6}aIx>+O-zTU)axC1Q;a#}45 zDY)YFJ)B!+2B;+-p%R7!eNEI*N_e6(@-GO^{k0J%t=U8OSC^@-#)ZjRQ%^a zys1{iOZC+oF}~VOSx%>pr;Zw_XJzDLmPQdar9TbxGfE|39wa~lBtQZrKmsH{0wh2J zBtQZraN-a!jK-e%{y}PkdyYHN^Zmr3z?6~z36KB@kN^pg011!)36KB@kU&)ge8(kJ zc`1mgYD`xMf}dfwAAR+v6kom56+Cv5R1MN=r@s#I zq-!Fk(ezYZG36xzQzm*Nl?kv*!PyO;6r8>z@5k&*F&=Ytn>MMR+^HbqBr&ruJdzfW;!nvarSin5z)_`q*oP zNl-zau>htICluC$1e_iVhZWRbBT{?>dj}%;M;?O45c3GJ1VpYTdOF~v^_qow7&PD= zY9Ol^ib*>DXQ~_wIPki%2OGqC{EdNY8GewV2P))a0D{-GJs`p5aXnUz#=wJk3?#_F zV1;6Q%f|ni*fFw5P0_MfHyOqdQspp)5aiq-D}Ck)isA?RMOHENAryWFTYfIqSIZBw zqcU${@H50O9^WEX(a;AOQ=!LIc!xe{|K5^fJUAb-vAokJI440K-GFF>C~yK>r||82cjsadNnQM_R1-SXNFZf3p~*o8;xy{H(kqF-MoLX3Vbl2V5Lj5I zZ`=`wCpN}H0wh2JBtQZrKmsH{0wh2JByhA6VEzAS&6%3fi}nAS;mA-(fCNZ@1W14c zNPq-LfCNb3SSR2#eu}EB|6kIUbgU=v1lM2J{};OExmw`U#TD*q7V2=Va&>SubB%)B zRm-DXcgo-%4iY%Q3k#D^0wh2JBtQZrKmsH{0wi!;6EKX|+%Z-P4YkvKTql-^BLNa1 z0TLhq5+DH*AOR8}0TLjAEyN7tB+MN_})~;|BF>c2j~A+MCI5r zv&R3AJhrr^wj@9TBtQZrKmsH{0wh2JBtQZ+O@Q_PnkIU5g~$5;(Ul+6h6G4}1W14c zNPq-LfCNZ@1dbE|!}u_0=(yye62GCo3p!GIFkTWMfq$BS3;;S0dwR}2=dzXAW3Jv= z_qmI{yR!b|w3O*tZEF9kjxj2B@Exh?DeF(Za>ekkMo%78tL+^ZF0Y?b|GLv$ErXVX z4LPOo$LD_9JK@6(-|fDy_|@H?pE~2_O}}2WGXk5`%&m9Hmy=5!yVP*CGvc?T8_TXV zk|N_a{cwYN*)i<-KQ7#`%&lIZctfiponE~uA!kIy-#PasxVtu+*!uZL2M3)IR{M<4 zQ?I%C))|YZ_kQHuL8S@JHvPCMcyH6cx;(yU%U3&Mmb{gE*{J!?j&|MBA}f7Y!icHQ zZo2Ho+l}SRGggn=w5H9In}XNR44HVr^w-A3rUyUN<)t&8%I&hC+oO;El-gqPnA;vK z`FO!M_pOV4r0B<`-|bpD;I`IR{T}qmhZ~b0i~G7+=No%}aqrsr7f!zY&8@M?Q=;B$ z@%VpdJm28`mzMtW%1<$8%-nYMpww^rwYp-;X;*yTY|*lmzlY8HD`kLV)E}woXaD(p zhwcr(NSJl?+^-zZHyO~O@#=qdd2#Z)E0zo>+?3j?bNDHz-`Z`Z zuTFZr^XBCKJ?c-3nfu1_-NlPf`+M<-74!a>`*D1y*wbdsI^(hbHR^l8g$usTto1)v zSnU@tzVU~3Bgd4z+Gg>p=g+Kv)7tp6>Z_#Pp*v|*yt;FvC+{hm{LF(_?I~-#7RV_=Yy}$T=&%KUyQp(&KXq4 zIX<_};5%+xdczw9+wL9xaB{QUf!nUmnK_|2bVdB*6NWyJ^VtI(22Z>6+o0KNZdoz5 z)*V}R_qkxpq?_jCOzNIBY}M-xu3MeCdHRhL+ihI^&Wh8++c(|ZchvT$ZkY7+z6WNU(fiEpPrtu#SlH*cwCcTh$*-~ zJIDOl^P0Oqh#pwd`O`knt!yx%>A=lvcl_V2Cl$_bp1ASB%$ue>m^^85acGyBA3pPK zT)pCbA3yhQv$+#WPb*v5XyQ+=J$vT3yB?l07{lQ@DfRoBPX;W!=CbZhmW6gdJGb>$ z|9*US^D%Ec*m>exeMaYRc;%CvrMLGRIcnGo>%X`+XHEM#H(t58@Z(w0aZ_))f9d@r z5>{Wo^4(M3?Y8Ebd&k{c_xskpdp#Z#_V)4#weI}tfd$RG_1}B{DNo(9;r*x1|8d?g z>3bb-BI=sTm}l}%r@7&q>n zPX}Lm*V;{kvffU6Wl;B|rW;c3Db0J}*3ieUdGC%^uil>X*V@Hd7jIs9O$*hv%lFaG zg%xdI``r3b*Y7&zo1S~32i@@SwF9q=Yqj(At@B%S-jRRh?*F?a@4ic23%ArBziG*R zVcXWf`sbFFZDQ}~GwMMKmqT*aMiwr8^V^g70G;>Am<_p*LD7^fCN%b734By=P^cy-qGymhh4~0FG|I*Fh-?@0u znT>A#*F!hYKV`yMyLzRzF1f*#+A(WIQQPj_FYoeL*F6t!e#$+ibb9=xwC$rydz>EaSkC=1vkn67OctxYZ z)00;`dBr!O1uGKHX*g|lozv!C{MA+GzdiS@{A<^3pFQoI|K#OA@YbTpfB%`h@5vpt zTb{Y)wQ2S430>Ydc^VDcH+*Cmc#-#Pi=%pWdZch$&OeopVaaKWrKb0**3 z;J3d!4!kIP@>{pW+E?9cig#1f?-f;WS%R8q$K5zNz zpS!M`xZ~Q=t*bVEfBEo*=e^PY<l^6XS18y5zT4K7aT3|GLI~9GcZPa%ymg zz7abg+1}>*#^2`b**2o2&1G+2d*%<1-nX@1kJH<{F!IGO`n~e>3;ir` zS(`O4UQ}@H$;00sk-yKev(d8q>m;ACZ1C=vo__w+FRys|yBn_B)oJI~@7%WH^0U{U zz25zJ&c%cK-7w|u4`bgybL**-o^#zE^l8%RzdZA5kJ|XwGfQ3` zKd0XvecI0}>fdu?#JnwUefeSkhwt6~$$-LdZpwWA?q`}#_-5i=3B%H-G}*Il{JCG> z(c-K#*FGFGY4Z6SE_%J}yhk#ZUtQ<@yWZLQ^jSea=RLM**j;;n89r{s=YM%ai$KQLLx4VsFRIkql#ol%I zHQzkCF>+RR@{`S)G=J*jRz2DcnppQwSEu`Cd@v_#-MPCj%xLz+*w;>ZZ|=CW`d)I* z7d>`=Hn!zAgD?Hywf8O!zT);f&sq3<`llbAH)iW4dvBTgf7@52-S+F`x32#2%k&l( zzH?UB)}eWc&4+f&cxck9pzgzR@4NllmGz#CtP{Vq?c3iMUcV(Mw83o+=DS@hR_|E1 z=EZ;K7LVS%WZ|S|66U>k{j7ffnf%j$mfbo`c|#4}J!{dVXJ)-~ZCug9+?liAdZ=XY zr}eMQyKK=bBNs5q;g}_dfh?SJoG0=Z)R-(T2sFFZui44u5?2 z`EzqVx$}?5PdRV$c`ZKem|65rpG!7By6CcovqtyM3p+jk-I(*fzt%BeYwXnx?tP%R z+nHB~d^2k5&iPrB!>7ErpmF+jyE|O?-V3Ym`n2JqJB`NEw>|dUX&=7cH}}==)0De|?iN`Tg(iyyVxr2j;kzUf<=;3!dp2 z(d&#?@9o>tmHqjE@LTeG1%J5XoAIBihFkl_H!U6CwdI}e&eh!8%>{FZ^=o)Ict|(bM}V5@hA67 z{OHHVt(SigyKdRCD}NaEVd&#ovp;|Cu18*S-Tv4Qy?ZRE8!}?R2d8wurtGc%T)zF! z1;egOymnZ^D;?)O@a+12O>bY@Y5LY7_f7ii!xfW1sCSlY_Gj}>dws}xw?*x`VBDBS ztMhh$Jg?zvS>ty0{qe!Nrz{$O?;DR!{BXif=ftAZFP*dPwNKwKTtD%go^L;sl~cRr zdp{=rGJo@nyB>dMNyqv9Z+!8h&n85LXFr$y%nz-vId4+ui$c1;@MhtKu951OD>n4b z{N%Cu&Tk!UU;O6Mc9+in@#^bl-1*DEu66FbZo<0aM>g*($!Ik??a$T?Z;ik4#-eFa zcaL9Gy1mXTb>Gk2*r#v%;rC{pI`yRSLvDG$sNpyJwq3Dw&t1Q@b)6kF^nW2Xun%hT0T**vGA6N z^FG?}Oz6J&^)Fq#yYtg;#eW}@8FotKkIiP!YB1x@VV~T1MWg>TY8`gw$!o5<^8em` zsvu)*@}LWw-?O9Zw`aaty!_`YW_G%G$2AXKHDt=@4<77uW4}qCe*dN8k=0YP@}_r4 z{p9Rh-n(?&;IH>wxAFb;ds0?6_-^aG>HjTCU;WD!OYVw~I4N@c#u&&oj-M+HcfWWqZE~=esuEJ6KcdDjg#-qB< z|2zIq2=S�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 literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/CroatianLegacy.accdb b/test/LibRed.Core.Tests/Data/CroatianLegacy.accdb new file mode 100644 index 0000000000000000000000000000000000000000..482a306b3c7d4a5893048b63ecd4bea825cc82f6 GIT binary patch literal 458752 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 literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/Czech.accdb b/test/LibRed.Core.Tests/Data/Czech.accdb new file mode 100644 index 0000000000000000000000000000000000000000..d24a9ce25f44ed52712fb03a8977335664788b1a GIT binary patch literal 458752 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 DJ?e=g literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/Estonian.accdb b/test/LibRed.Core.Tests/Data/Estonian.accdb new file mode 100644 index 0000000000000000000000000000000000000000..a42071f18688bfb6c6e9ac9f028096490577c4b7 GIT binary patch literal 458752 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 literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/French.accdb b/test/LibRed.Core.Tests/Data/French.accdb new file mode 100644 index 0000000000000000000000000000000000000000..5b51c14963c3325db7e326bf42544b94f18c9664 GIT binary patch literal 458752 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 literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/GeorgianModern.accdb b/test/LibRed.Core.Tests/Data/GeorgianModern.accdb new file mode 100644 index 0000000000000000000000000000000000000000..3dfde7ad52f4dc0a8e3e5392e3ab790a918b19d1 GIT binary patch literal 458752 zcmeI52VfM{+J?{UZVD;8AtJp*h*ZG@=?Ekt1c(VCgn+>U2`PkBl7PeuCV*bWu2-=) zEO>3d6$>Eh^(uB$6vcx5TCn#5{O@;WXLqyNgccFxoy}yvJ@q@!IWs$_D?_QQn%wgI z+^YPT#IfUIl9QB8DfK|PJ#SX4C$c~LcTrWs7hDacj3ef6eSS zKl;x=$W@mlQ( zJ&Z+tZ+vgj6+^mRvHk0BoiAqG`p?3dm;SPGdF&ZQPj%~+{K{7^6@0lfq>M&Ha$BDKG)!{U>2o)a= zG)9>|4%0ruRH)JxL4Fp|uyBZv5KmC1kB$fm+Qp?#RDNaUsui8Wk&L?W<; zK9p$@g}?$@xRAtv7EY+=Z=to)>>421VYHISgFOix2%mDe)ipE1tFFURH$w8DK~Nwr zL4~UzKZ_lPT`cULILwqwHaOK-RiVo9DNwUjjfzsqDi_yWm4~w$rfu9N1#nq~?_%p~ znyo5TzS}fRrKnQKMex}ca)tI$jPK=Y63ols)1fB9y+fs`1&Ce#k5dZ~W)(XBy6OXwlx! zJWI`iO%+^*s;R0LF%+m}CFcjsu8I2DHWe4>*Kl}YpNhYd8@?1 zDiPJtbX4S0bmeXiDr{A%7!^3Fuu6q73KhdVH5ZapXQeV2tg>)T))hO%+Ds+Qh7?Iv zUyif%|K=)Ujbmb^Nkij55+DH*AOR8}0TLhq5+DH*Ab|sqfX(P~cTKejclV27bC9G<}dUW?}27y|XjN286Q1|Mlx$U`@@+1)Lj)RnrExf-3@#kxZ| z)YIMV-;o{W>8OrJi-OfPJ1{`&gmh$Ap_M|~G2GqA&GbGyz&yf@e2KpLW*T~J8+0Rg zZ`2mjIAy?%Fzqy!O4R9~u+W`)?Q z$9t2T6^c>ip}Gz)gZeR|SQ)zE!0SbfE$_Z^lj8!kg{-~cTm6^e_}ao-%eO|WyQ}}w zK+tgDyQlhZ1?8>&>vjQYISA+@xKX2oJ1EiL--i<;3TC6;Uy3$?G7KjV!+`QCv=GSX z11J9b+C`y_;8Z=d-g0f4_>N78)o!Ms=3l65|1x}iHl+Lp{+f2>8&+peSTg*+#@#+q zjp6OZXtmh&@N^sUQjY^cs+a1tiyt)JG&5Sl4nNjv5nex-$k2=*2mD0o@U3{PMkC#D zvsOJCOii+62vu5#u%yExy7{8!|p$X1b0M$|=&i1;!5?(mZEo_?MF2V6z3u3+jS(pnmwHoQ>BO~(sky3|+%pL{>v3v9TP4Uh1E-DwZsb~z zQ+EN&7o3J=qQmwWz%l_R4y(V|{Ko%#M4;{hIIqh{ElW*JPtPnVDNN1IERif}-O+26 zmRjOAG)%*+{Nh=rtHVu$S@{_iC%fIWH;sxjPfjh%$OSLwOJO0`Nd_XQCrif zB)vGN&~4PtZM2{SA!toHm?mYZX;u_UOpLm$e)bW`=|e_jC5@htF?z!2v=O6ICybbo zdTi>j5gDnO$BxcO=x3L->0r8>o@SLSC^6GoQkGwgtW-0Bt|r`MSLDdL8>+Rl z2?sfunVA{+72u+sMKesPXFhdKpz zWoA(4z?NnC`DrI)rn^g9mqw0E+nfwDkiK4fUvCbt@tlfGk3PX(jmqV-`SpzhGRjR?r`)`yFLxg4$Aih#zM*6l>V-T|%K zi-5@>tviT-i2!IUfPmJZ)?G!wGKsCb)zzEfI7E*ggme)w457~?OUQ5w@yZem5imj_KHL%_EFn?^j9G|xT7t_G^ne1Pp=2O{ z2yHE)rw9T;k5CXn4u>!AuF=y3!h^bp6AQayM7vV~rQz3%Z8N)r|wT2>S zeMHc)CqmGpMMTh}MMOAE;!1|l#u5&<1YH0yNF-iolL$IfM9^7)VI%Q6IU?vHA%ZRm z7)TPY(=LKesR;2h>fS(-%~>80%=sV?JXQnsIDeSvc`8!a^aKkL^aK+Tj?ts{Aq;X4 zNa=H!CA6`G!xaw_AOR8}0TLhq5+DH*Xoi4cTwtyHj{1vt{l8|s(24{|fCNZ@1W14c zNPq-LfCNZ@1pZtCTnVnl`8tdkCps{BkN^pg011!)36KB@kN^pg011!)3H*%-u>b#W zoViRI36KB@kN^pg011!)36KB@9JB;5i*lcj`h5-o0VK3UAZ8dbEfLgS2jVc+MPj;g z(}y`Y028Zu9E=3^bdRu6s*#W8w+9>568Gf6NR~R2011!)36KB@kN^pg011%5!9~Df zeA-O?U+Q+O-yK{z+EClZwRy?U0JZ!Edw#Q0-;g4|kpKyh011!)36KB@kN^pgz`;+z zVf?R|`oFA*jz1m;ErCY$|Mb+d%Ig zOQGeV9YQyTEDISD@>%e;!HK~y29*W<;V5!!vM;vB+Ap(R_QYjRB-xUlFh6gxDjAUc zpKjP%VfQf^zTq;|#P&Cx*ki>f3Dz|XHt8x$)v9VWN#&{qYBK(ps{)mw^3+^hXR8`D z5nH5HtGE%Vs_|!nic#Ter&6sI_CIq+v*p<G)jygt1e}hDDiP=~)%Y{S8z}Z$ za|e3$>*I8w*SN1rh1D0Tnb<0Bs2Z!{a7|G|)F3q++RFbyDhXzTR3hYoI1|+|IO8EF z<2nxC20eRwQO@(fGsuV%RBdAM5R6_8{Vy@H({TZNqKS!Ct zCLl+!*Dw^l$3tt;y%Kuw%EeT;^erfo(gi3){+}9eN-=|I zA5t}ktl{oTNl|K{YW!K?RSLTVy7SfIbAg+ef64XUAYM*^C`?7B`G2N( zBOJU>5lV`ZQEL4^N!|#sTcSHf)9np&es@w72y@(sX{zyOsy9k(%vq0EU3O(fX zAH`qpHsKtHCyW1QfLAX}i)$vZEF@KmX(b*nyAc>}Rd--9+mrR(Z(RXUN%AUnx!oU@1DFGOg87s`00_cNMthp7b2e*v{Int(=Zx$1oG&^-lFas;D(n? zt>4&eUVU!>-_n?cOp>QcE}kw0h|K@)lJ7ou7_I*hLn|~!0!TMhkc493x!}~B3i8Tq#DrVm!n$64+|85&!Y%GLc0+`CiImjwfWW*Kdj-S)BNW1gvO82 zZUmZ$SMPCJ$ANgU_W#6b2)c5BUy$7huHnn%E_FnhQS#GdQx;LHVgnlw*<{?gaU=3t zsRT6(<~Vlo+Ki;T;0kXtRPBl*@9(qxp!D=JvwpLEi>A2iuHBXEmsxqgo3DTKyvW5( zTU1F)VP1pZcB={}=F9M|j0Er8shaw;A+c>V2&K-^SMh@+H{}85cKES2{yy8|2Q-@T z+E(Jm|K|J zRv4O!SrPI|0wh2JBtQZrKmsH{0wh2JB(Qq~xc>j{aiS{{AOR8}0TLhq5+DH*AOR8} zf&ED!p(P@VX^B9hS|Sj47%j56{$2*+Fxok-9(n&H>E2#{Lw-M~2-MZ<0UF00R7z7> z5+DH*AOR8}0TLhq5+DH*Ab}Pq!1{lSBR!x3l=^?NQmp?UP`N^7NPq-LfCNZ@1W14c zNPq-LVE+?f{eS;6-=8r`{Xd2E|NV)aaghKCkN^pg011!)36KB@kiY>+z-C-53khPK zU2~N;OG)2_{KRaM82A{r$2Xj|hHLxD@_=#}Dhz8z%UZCq^dwhoGpX=D36KB@kN^pg z011!)36KB@kN^q%RRjzpU)TR7ew$k3THyKiS82p}Nq_`MfCNZ@1W14cNPq-LfCNZ@ z1X`NF-uC}@L?=H^d*J?m8~guTI-?jK36KB@kN^pg011!)36KB@kN^qn0|EB`?*lw~ zB>@s30TLhq5+DH*AOR8}0TLjAgM`4|_WyTA|36Mg;QoIH`~MG;{HK~EKmsH{0wh2J zBtQZrKmsH{0*3|x_WvIm`OH+3011!)36KB@kN^pg011!)2^`u4_O}1OEBgO&2DAVF z&@MkrJqeHi36KB@kN^pg011!)36Q|SL4f`L2S+|pOA;Uf5+DH*AOR8}0TLhq5+DH* z*cSqO+y5Vf{(qbyf&2eMIsaerAOR8}0TLhq5+DH*AOR8}0TLjA1BU?n{|}tJpe7_h z0wh2JBtQZrKmsH{0wh2JB=Bbu*xUa9?&$x==?dKcAI1LvKP$r+E(wqT36KB@kN^pg z011!)36KB@>;(b#|L+AfdLaQ4AOR8}0TLhq5+DH*AOR8}frE^|9`yek%J3#G7XAM? zqrCn9(Xh0+9kgQqzZVrH5+DH*AOR8}0TLhq5+DH*AORBi%L%am|1Zx1DnJ4xKmsH{ z0wh2JBtQZrKmsH{0)IMzz3u<+iT;0_Vc!1#p$@~-|G&csQ`1zgDpm7Uf!ewAPfrFT zAOR8}0TLhq5+DH*AOR8}0TLhq5@?11`~RDPLn{&>0TLhq5+DH*AOR8}0TLhq68M`C zuo;&*B`LX>+wbkmN9Vp@`X&V{hYI!@_J$Lj;o`@Rk5an-Kgx395Q`xAF)i%cA{5{C zhyimc0bd_Gj8O66@bjTmsOiI|#}+j75#d$X*8-~&s1#LSKqLq!7DB*(J5COL zunGjPBtQZrKmsH{0wh2JBtQZrKmva$0mGP}>;Ixeuv+6<;EH!`bFOmcc})LO^%yw` zkN^pg011!)36KB@kN^pg011#l3lK02o9?}rpeCy?U0YmFyY6@0;5y&+57!)5wkyeX zw5zkLmFp+xht7@8b3=7KVNu z@^(l;$dr&VAxDLD2ni1PKKQ-h7lI!NUKM;v@RH!L;Gcu)gUW(t1byh(=ve2t&2gC{ z#u4fG&Hg`orTrv(s(q;aG25NCD{W`mqHNoZ&yCH-EF;|*Ve~TYhK>#uqE!9Poh~)- zy^d#o@k@07Uj3I=48Lzv-h`?lbFQ0s$JWQ5Zq;sje)WsTmu(;0>he_&FCDOLP4UuW zwpYKF_tTr}wA zB{x1e{io5#XV0s|P6mc@d83UV5n&l&yn@yweoP~yxW=ybv%VhXyqW|x*4Hxf(bqEC z&-z-?`tZfz5r4jxk&nKX(SFv~iq=P8jDqpk*D~_a*D~7A`dZQY=!@Mt{PnereDt-9 z_OrfLv_AS`Op>XuyGVy14;mZwgPA-ZERS$y8ST#`_(W^Lgb{MV<*OVXESAy!R1VPs zrfcU8FD4(O{j5nZWLO)VJ{fjoW1}98jgDw+)UB~mm&Qh&8XI+JY}Br?k?e8frz*;+ zzY!i~fsKOq>yH+So7^CN`kF@jT@#dYH5EM!`?G8TfLXTod$eZx-S5$w_sD*a*1Qk* zd$i`gzTcxYpC|i0TJxE;-=j63i~BuV^Vz%KqcxxJ`#oCu4fzWmj)*nNx&NQZX0#1{ z?=4My-ju+^C0)`!M)Ia)G&b4+hdxy>>&3?M=zg*Bb0xpg3PZRpSMDFn#xcTG1101a z#m`8d_I^h4B=$3sr=*{eJi+{oUb@IM9eDXM(~ZlXsT%yYr6qlcMSN6W6gnt(8c?tITOiL41;z6Y*c{79xd-I>nNcIIDboc5l|FsZVS(ZoC@Cflq&hK9Zl!`)sSs@aBVQIdqj+YoJF%bMIZz zzJYr)H-&z=yWCYHU7~v>Lh;^VqDh(7&`)*E`>rl^r}S}9jl)t(K_!dVOyi*uBk@3 zYY&DE?;C63-zXjWJh(2_Vdbl?x-3X3Iz`QZuPR7sI+h|$ud_S1t$R%vOIa0nrMP1! zBGz#@D|E^2>i(XkLy%G{+7@{1WAXeeN8D0>dewbNM7)UIx{8ej8}hm(B%wTDDh4 zE15&MdV17@qwl>o?d(1ubp34Bgoh$9rLP^_?8G5*d&qd%RB&u`5D9{)^*17rEgBS>+0)Xzv7(Zp9>VVgyR#nVZ@p` z7{-whumH1R?}3PV)PM5yRY%>l2W`0%V7a(d)3UU&09yNFPn1S zxU`w~93~Oj&4`o{EW$?25DifqRM_**Z1L&uL(H~3d*zHCD?UjOS&peFR(Z(m0u^r> zG|C9{05u9pGR{2w*#&a#%iQhF?*ihOzUqGnuTUh+CIXVH47dYoGgl=y!mad2ShkUF zTeu8%YI#GF$S$w!h=MGDVeV+{m4lQk~ z+?z4Dr)TT?PVT7~HBiOjGZd2CcXGd(Zw+RqimCiw$*)Rp#3LCkzn>DPWgY{6<=RA^ z6cTS+{kyjSQbU*W#Ph~IP1{?~wix~Uz5u_=xd=^kml)(JQG`&1#AvXY1Nju#$-BS; zJS)uSrulS?@$oCq7|~NSkI}!5RcKl@sidRu$&<-^%EWj=O@_&AaF<_Qd5W1uyBhIM z)=#Ax*vYd<;t`@UtGEj8B#&g~v1loCmBodXHO(M@-Nv%Blt=I=3mBQA1 zBd9_Q@=P?-osU$CHs0c2jW9A0ul1`EqhpPMe|ZnEa!kUO?~QZEXng-a;{YM^eJyK?q8k?%|M>oW*!Kucl3Q{K5V3v%b(#c<%UN# z95%7i=7s~auRXHa@U7$l8y18odz>1s2B^Wf;;wYaOm@pxh&wcSbjgh^4@*21^^I*f zyf?OC(>Jzwk8pbtS?+C9F~cGEc0R(5!2`jCXJiI$jzXQyk~HtjgxsESa90n-y0w5QhY2JWiBfo>8xK(8wg_Q}E2{v=D_mc=F;sV56y&9% zT4z(GE*NqHrNU<^KDEkbOpBDTSK@&o)9GcNzMPUO-(yk;I^IL{vqqjb>L@tDDTg?> zjzF^C#D230LMHZWiy-}&)${k=fnb$@h7}ou z;sNeBP(s9q&&mZJi#90A=It^TbmgX(M~2rl8@G#DjVgdf6CpcL#1|kv@(7XQ8>Jr` z=F5}3X6M7sdbL_6KS#>Ix&Tf6Ie@jlTbt$jDt>GzmrZ*Mf}a6yKTCZ5G!s=Y{2blD zkC!@(D=?$iIuh!N7|vEok^%oBO*ejeIrwgV6d`5J3CG;pr8@+ z9|@2E36KB@kN^pg011!)3G6cg!!SHE{X-he|L@{4-{(je011!)36KB@kN^pg011!) z36Q|SLZH4S6isM}Kw?@VkOL?T-D$e7`s{rm4&%Z|nI0SHusK4bViM{xG{O=BxuV&D zOrWk__shpA5zu)bXNZ6y9C0=g91!dxV60o5Lj?5m#|4RiK?iZcB7{N+5dkC5;zC8h z_>;I+B7{Q-6CnaZxCj_b5*Hx?T94u)MZhecIHw308xn^vJV<~9NPq-LfCNZ@1W14c zNPq-wfuT55^gP}lp7 zx~%--S*9!9j$qW~%*vc!Qka=;8tR(AQ8z0;qvB+@N8P#rqml(BW<*e=7;*Kp7w6}f zX&rTIg;7^noL?yVXfxejVbm387Umb1nStvD3!|v@W zH$8&Dtb`*)dZ@u6p1n*y36KB@kN^pg011!)36KB@>^1?zI4Ruf|Bq5Px)NNUIB$2B zI{Q1nihM9~VPv1kPa^J%I5i?Z;;Zm;P>bV10wh2JBtQZrKmsH{0wmBP1VZ%qT054X zmfbR}eM#h<8+{_`_ru5OZ4#_2HtVYQjn)yBj+NPKvD9}m)@_$#GM0qyi9G;1V)cA1 zsEi4nbs;hv6Dv}e>CHg0^n&xV;YEfs%ISJHSw~`BWg-9GDh8{Kw|56ICPZd-Vqx+5 z?nTAR^qwoSihE@<0S!?@v^L$LkEsg+l7+{UunKtv6qd1+l1wXR>)KN%p)Hnjm!0Y) z5%4IE<7Dz_K&53ka(l3hz}RGu7jbMM&BHp~vZ;VK-EV9*(`~^Gm~QlFd4llWbwKtP zkTv=n2WJI)tU6W=!jkE|RjgMdbe92R8P>rTeX5%AeiXdM>O5%U^^U$b>_n7=W#g^P zvtqNZvD#A`O~S)LmLJc;LjKlPGfgWUFjn*ix)+)^_m0{_?=p;b3Q$$*mFsKvG|lLz z^z4Jsbj-bVk!_D9+oQa=WYKzSlLf0B2skNo7BK6Ia#;^!&tWTuC~=PubVo@D)~)AT zEE;a$FcZ|rBJGJ-34Sa#a}r+?zZIu-oeV7}s00+4VP56M;qF8+1)C0JAcCe!L@E~k zPf{arFAlT4*gk^_iKO6Xy6!u)Jhoso^AOR8}0TLhq z5+DH**kb|??B%4?4>;pTL|{Q4LtzJNG5QPk5tnFX;UJ&Av}L!yojb5Ew^upKXyAJt z&-~(-=>EO>FRd7U-=@3?RYT@nH}Q_Gk3HS0-SqtG7mqL7KDO26s~%oDVB4DFrN?Zq zel72@&pyvPXJhFfHwUf0FXn>}l3Yaz1z~rdIromzaXS-!?$>k8s=m1kE>5{<(9271 zd~o_tqmR#?S7|Y^qV-WO#ZNix5N;XmPvv}~wUmQVvY&FuR?BFAD(4fer5ubB8!KlS z?PukzXnmAJ&7ywi&IV=9GTP6|Nwl)xypM9IuKO!z8SQ7~Bw9b^h9g`39wlP*7d%QN zTE9ohU>Nu*Cq{pPauThdasy%Dr<@r51{TZRh6>s_~YA&1~n_l`&%=oV36KB@kN^pg011!)36KB@9P$JVql;%vcDsB3ze64h0gwO* zkN^pg011!)36KB@kN^pgfDZvIN7e#ILJNd}2?Kk1IE)~tIdq_L0ND1gu^~+kDgt`_ zKcz-n`~QvBOYULF#%LKZLjm&*d`ACw z^;ytlEM^4MU{rsRUaz7S^8%6^%?{{}B^SJ7|6?%sqgJo065|t2R0B5>O5-^N%VcQ| zWnj>LQ-3x9AL=G;&Jz$n*od(y4=}gG&oH;2dVRHKdB_(%qd?}|_{=Ski3EBwfp>0! zIBj;`frKf=Lj2j}pEN1C!!+k4BxB*1@tB)nE)>%=*J+-GE@bw_0?fR~RprQmru-Je zu6mb#s)Z>J5+DH*AOR8}0TLhq5+DH*IHU;}#_M|izd&%f$Nz8hm>kk*n0OK(0TLhq z5+DH*AOR8}0TLjAzX1W=!?};?sNYBbruP;JWv>Hq80#X69B4B5ZF%A*XlRqX%1Pm=06D$HIfs6?e0n3_> z2^9f5p^RxI0)~E!k!?|UkN^pg011!)36KB@kN^pg00|uE1X%w+&~s?t^pjdT+Pcj8 zzX)Msg0cE$Lx2dnAwUG(5FmnX2oOOx1c;y;0z}Xa0hr}sHUx-(RskL)KmsH{0wh2J zBtQZrKmsH{0tY++n^CUk|DTP@oy_8nfW~tAkB@NA|CjmwaH-S?IKe50IJXYV+g{H9 zZ*pfpGyDLX1lslXgT6}%xHkzjbP{|m+-nT%YW}~|XRf)qlix(_@>hXb=#BRU^qu*? z*R%S8aip8yZqMq6pJUyA>h)FfPT=bd*xQ-=lDj@T4f>NX(?4}LX4|K$EUbc1t(uy{ zKNUQE*>Qfp&P4tr0TLhq5+DH*AOR8}0TLjAKal|E|Nn__88iuy011!)36KB@kN^pg z011%5UrwM!_U>z$?eVNW^L=8nw}Ci}`y*um_&|s6{Qnl=6(|y#{iy`py|0h`U{}ZhF{|VOo{{%gu0AoNBeD{I)Q`5x2Nq_`M zfCNZ@1W14cNPq-LfCToO0PFvI&g@|F;hyd9SO3Qp4zvC*f;H{mtpAINHSOQ5|BH#P z|BImO|03x6zX;ZX0<8ZZOyz>Qk^l*i011!)36KB@kN^oBdISvPC@0o_*N06V?i$U# z{|>!Ez!Z}J36KB@kN^pg011!)36Q`p319$xYwucdyIS?DdY3DQHC`8NS7A2xFOsyoTZ>u-dP3oVJbW#mto@<~xpY7@`*J{@pu3ct39gqMCkN^pg011!) z36KB@kN^pg0150lfe^ij{a7?wRHG%KLY2wB?AlDi>Z&@bw)zu|-Pvu}n7t?j7xS>L z(WuUc=})lRQ9+1jL3nF%fB=pR>C=yoi! zbp&)QZ3D8{yr?0vbp&)Q^?&J^7RMr6M?l9?-j>yr>im{Ns~tc*1aNFK zcIp}bzrtGfkhczBZ(=sdh86`m-7O1AxZ?CZ9A5uJ9fl_x94gGaIlWle++pu9WJCF6 z6vz}kYoZi$65`b|oqfj6ooKZP{AWYFW8D!i*H>@E_-Zv}IS>_&Hfpk-l~IOS8dcbo z{y5CfsFi?ukN^pg011!)36KB@kN^pg011%5p+mqhx_IXM+tnJ^0$04}`=LXDDJ20C zAOR8}0TLhq5+DH*AOR8}f#wMKj!S6H-GOW8Fk+mE2d;Y5j08x41W14cNPq-LfCNZ@ z1W14cNT3A>*ozy@ zTBtW?D96%@#aLRg96HH12%%~c?8`72AXH7ogn&v&GWEcrnw@%pB&WGmUJhbvH>NAt z;V0elqp#kS;;VPMg2zshszG|~^w(5Rx@M|Dm;y0e#bLt4F-T()Cch+M8pU9k4^%^8 zCU(jAJ`Ud!)ex8rhW&8Z9;1fgG%0Wzgoe(4BtQZrKmsH{0wh2JBtQZra0n1EjMw%2 ze@UvtJ^p{2$K((Iz$B6Y36KB@kN^pg011!)36KB@{LKl(v_$%%S|X4JJ?MKKpzb2x zW8XI8X}ut1JXRp|@BjA=Y*Hw?~0J9SO9YsLSG*{j8F;6?imYU`fxyDJxIXev9P&=+F?Y9k09?r zgocp^qcOxhg53flS2H~w@KJir!cq(x@D4SQRSd->1^;tZ5e6K1U0H(-I(qy~g=-mp zkfjGIlwkmZ*R?eu!Rc|Gt)^h$K`aImWMQyEHNF+#e+71otWvYJ?A1+%F$6a|j3F2~ zH^H4ga|K25gZ(0#T5=KQA@DQN?dL3gb^AefH03Q6eo`C6<6Fc64SkR?6?$BScj$xm z?=30#>b29w%{O0S_27<$;C7tfx{q1ll&f>5%7^1vz1wD`&bNi|aUA@YV|YjbhK)cI z=x@h~u`lu;CkIUE?BG8VAOR8}0TLhq5+DH*Ac2FCfMHa7cK2)L>HlBl%yV{ez7hE^ zkKMtTHtI|QBtQZrKmsH{0wh2JBtQZO6#=Usa-WpeU26Lj0zCWn?e1$>2Y~;Cme8R^ z0;z8aP4+SnhtbZN(nS0%QfitAo5uTxKwZ7QaWnQ$Y>b5jNPq-LfCNZ@1W14cNPq-L z;6Nq7`u~BNGcBVR>;Emok)ePtS!XpLbco)JwLued^4wE^MEdoHRGTck6$)F)}+&ym@R&(j$joxMI?0Q}QOX z>T~lM7qw4nf8}w`BkjvVQ%6>Q`_y+ka^79@)%SN*zxe&f$Cg~T?x!=ihGUbOh3(Gy zG_TgSO-)i;!+%b=rv5@BAtGkoH&?6YZRt<{dd8ZwU25aZt9zync=6h}qKxqEMR&!y zjyYmxucsfJXrB?1 z%*Fqm;=H~`e#*AEjKcq}JMWsCjEgSHS~+dqs@{*S3wop?c;@MIUz*x6CFs6^&&_ze zc;IP+9(?e-u|1Yfz46|f_fPxcuGJkMsQPyKSKF44yRp~Bzt}%`cWvTBF`plC^fkjj zx%1Z8XAZyVl}#NJXGgx(uN<1YC6h$Uwy zZBJkHd(t>t=C5N@PWtWXqXu{SByQd%3qP|x-F4hiT~_{c;InyetXMX#a^2XTM~96Z zS^Uu(Bf8D(^!3rXqi=n!_QhEbAH6d8~(pI&lw=g01-%6szOi~p$avgL%kdc-+8oILf3v2#E8 z;+^pVw{OVax?7XLB#_$u~eB$l8^w5v5 z?>T(wvY(tIVz&007yR2pXUu%_kp3us6iEZ=ij;qGpaq?w-NvuKb<6*5IIMDU_xQE< z=3YDd-o#l;t3w7>y!+&rG3}~%zW>x4M=Z>#J+A(;&NIJz>AxpTyY2qj6EPfafKtD# z`e0n$rRNRqdUnX*lZtzN_OFNMcc1$5y+_Y{b;OjiH7|TnwEU*glQYwwdE}Eji&php zaLt8FE8m|N6;pWa-OKOJh+BEpWp9jpW6-K6@0@l++pl{KANFu`=<65dw7TW9drs>< zXv~hgM?QZ2nztYS$G3}qOxa<3xoBd)-*c|)vht_1;wu+i-0SoA<|jGUE_}NG!b@&e zuYB32{pOnUBOVxb!lV7ud)<~j_TG>c(`zmcnK9PpV&fd`bJp0$% z)0S*|EcB&+-f~Tw+6~oxg1*{5B>38)A?@q#{Gj;4Q$Np{^LE#hf4Sq)+b;=SlydXh zUj6U*;`^eTZ@qa{_!(D>etf}wtzWu!);qIr|IdP&(XF?49v<_?&Wm3h-Rs2T=XC6l zKKb{muZ;2+)_vAv+O*9dPCWOvTh~p|StG12&V(1@H6Ry7h^6?kO^xS&l!?wdZI_U5@;R&`2=Z?ovu*=PM2{%LB_tr3;W zU-|N$i_hJ*GW(9gb#J|s*{{z#uU&Te>m8Fl%@eaq4bCv?8KtF79ma zwscO{utj{j~$LkAUExUa6oAc+K z{O{7TdtO}<@vq+!cRseI^^qrRcxg_%J3=lR75vN(lh&7X?Ya2O#dl46{@xFIZyM2g z_|1PjGdgF`_pNWcHuK8MwuP>KVce;&fBNaXS00#O{puU%ypuPh;L7+Zj~tzNf9^MD zt-g5j3qPbBU3c2NRSWWN>hSaS{^QRq$b0qr=v&UcYC!k1u5NqP+FpY{2~B(A@bcvs z=ailM!wv*<72N$IOpdV zK7Qku|8q`zKO}!tL}AcTqr$g7@MiC;x_nvm$7>liz0Z67@)N#!@UBgxhaBJgnaR(7 zGWvxlo*A9>$79F;W8G^Pf7E-`vuBoHe)y!+qi5y)W6hZx>rZ_k_o7SMynWl|O;4O?|Dp7u zb?LY5_;J#-6;lfS?}~TNo^s7wn|}Fxap*thz4`o-Pd8M}>@x4M6OWibtLoM}*Phuu z<>UUp3_11t-nPtPA5G|Z+wGTr@!;Bsc?F4&9nrP>P3P>m zzVLrk9ke^y0g2y8NOm)568uGG;nv0Zd115PI;~5}mERwA##_&lr;dAfA{zVLsK-Fy1RX=nAz`y^-M)(yj-zWuwqe)+D$mQ5qJzy12rD@MK0_Mcy5<-PsY zE$94n`}iW~@~Z~ka{7})!-vgy@y=04ItxA?7j}Kwu%LIhd@=na)oIhH*lxAck2!MH z^4IPfx~0Qg{ZC3}@4x7~WyJvr2ZO?il>a_Wf{(O6<`|Jz9$$U5D;r#g@KXuy!&pB^;=$qj~ zPHP*SG47p_gDd4_Yc`tgD_!*f4)XtCoHa+$Fx2rmRvGcVH zmj7|v&wZRH+0)*+bQZcCh>K``S9({S?o0HC8_}R-@ zH?*7cO~~l$la73}d~N0R;ita0=E;zqv5!1=*7rw0@oMbX(Yc``BfdRi{=5z)x1@h? z%>|wR-MLrj35Tz`_`?6a{&;y-cH)FHy5F(om@iLwrTU^DE~prA)|N}}yEt|Bly~kO zan0yiAAbF*?SYkr`K5D@8vDUX*S~e{>WQEKapl^#ANeC`WrwdeEt>m(RVgcfykOaF zvEheBOn*4+oWp;ckuue}&i3xM!ViBvrTW2~SAR=a$G_4w|C-J3$L8Evdd8be3X4XB zbv@?&c;O_|G8_7y39V{m~SeUzqkF2 zvyPs*_W4WJ|99!m^{u|IdZpm8-|u{8%#q7?^!f0+s6Spee%(1L+O=u8J@yIXF z4_LZj=gSwax1arco39PyUoLC^KSSNf`Tu`*6^!AN011!)36KB@kN^pg011!)36MZw z0-+6zqa#!T`q=c5p$hRSRO#<{i+$|0sqbX=lz`1j$$Akh2kN^pg011!) z36KB@kN^pg011%5fl9zIzVh_>W4-^4`0yYB5+DH*AOR8}0TLhq5+DH*AORBin-SRC z{{N}y|HsMx|G!yzU~))+1W14cNPq-LfCNZ@1W14c{+hAQslj0uB{s#v<|f`dF&M#loQ@_qBkTSlD#fz7`G@s>Z8qH4)1hI`BUq z-zTYDy#S)qBc;Qn5;jv+Ev^M{X@lBwaOi@`f&WQ>1W14cNPq-LfCNZ@1W14c4g~^+ z(Zw_W->%lU7P#U)-w%cKGmRub0wh2JBtQZrKmsH{0wh2JB(Qq~8g9?Gdw%>~`LDL` zZ@*jL_bL#M|KBaPbVLFqKmsH{0wh2JBtQZrKmsH{0tYn#!&v3YcBcU2{$>6Djq5l9 z;GoV^Do+9=KmsH{0wh2JBtQZrKmsK2HzN?FXUk8;6!$z;iplNqNS}-XXnWPns?A$R zrZ1av-ng`x_Z;?=qb$hCd}|jfCNZ@1W14cNPq-L zfCNZ@1W4eoC(z3H#+5cLF-ODpxi5R)Ixws^Kmh{|mI= zVyucI=G8b0;U)&L%*LkxCe`>hP{rXh6w++CC{vYKYNtjOyO-M$(_EYqZ*RmSOYr0( zJc-jXkAc5(ZBnY^DNt>Zjsmp+XG51A8^k?L+gBql)BhxdxIj(D|8m%vG1NdRg1wL! z4K{Nip8`A6Z87XjZOst8ezV|D^c2lwRFan4xT&P8WH7d<#&|+aM%c5#UHA%3bJ3w{ z#5)-}&W6c+geaO##ZBz-kHkFszTEpwrfxUlj@wCkv*!z>T63Ukr9+!Ol%a1^$) zRSDv&LJZ}Q#h2vqB&|&f{1kwEK71#u48$w>W2RVQje%`B{FnkOMXh~GF2L` zhOy9!0+MRcu{amIamU7seIU5TNO7W2} z&A23H$;%p$HeFV$&OWYEU?)Djw!M99lVDp6+ZvRUa->Vxl!C9Ms2ZvB#@5hI@={8# zlmWT7yza6{SUOM}lc9fwe{?)!#rq$I`YJd)?* zLY(<(&@Nn0(&_2%=d157d`amqgHO3H+auScWXatnPhRtxDbI%~p66~7G%wO|$lX;4 zSMnq*g=snNbopO|?^0IeNiHd^)NbU?Htq7=IhCg4kmr%u%!ez7Q#0=~T7FIBcS&-s zVKqOvUEy`8@rF}aLdt3g$1cF{IU*L1Jqz##bvOM%Mi#CSu17OLPdgL z;8cdV40F2_+i;IeoOF~OF$Vc>P*+bsy zx`PDY>iq9F|9h8jb%*X--Pii2v!1qc>mEE$Ti)ST9fWr{>uu)ydEz z6duB3tX_qx@p#oXt4(I5MP6s+wbi_Brr+?+)t-j7Q(?0ZZrkErt_nWP1_`h4 z2)qWF6;x@0aB5dl1*+07xzT>#Gf;7G>K9*m(NBjKl?YjyBfL!j)@$-Gm>1)vyb{{w z;)U5VuSS^Vx{@T7-~u#(RKT57OR{j4m+@#_QI^W3G(k;){V8}C_0>~CNP~Hckx?B1+TQy;S_B5>$l4dcK^8mRI$)lzVt4(%nEP5M%u zE5}Eg3?!_4q|&Nrd#mo_p`CCttCdo1_NF_K{*t$nUf=4!SrM0NU~?&Nt1F?UD6?hI z%3C#SXztC?_6^*dxheF^-Q}(t=@Q+&O<7W+g?5?N&6}9kHfVIEJA*U;~oiuZr_gouIy;|RS(!6K76Xuw=}5u z-fNO)QdY%XDehQlq#TFSZ2s-){w__M;>y%k?q}0J7SEe=*{lf8tMvoPrB#11> zR1`SxlJ{cbpAqOZXtWHzWSn{UvkTp)qK-d8Z6|1%V24+P-;Vx$S$w!h=MGDVeV+{m4l~5d%)n{|8teBtQZrKmsH{0wh2JBtQZrKmsIipc62R^MkGa ze?whsb~hgAN>e`)AOR8}fxkHcj{oO45|00GI8UJQI1-Nk_nphY@&D!=0U2_^@&7WE zL^}OB{=Xo=_FBtQZrKmsH{0wh2JBtQZrKmyGXFpQph{r|iA-)lG25La_< zX-5JiKmsH{0wh2JBtQZrKmsH{0*4*}S@|yx=V*7*cW2!~S-($?hU*s&F%5TX4MTIC zK&2O<^v1nA>lw=8mA}`i25U)TAwXlbXaB#h4c7nn-3ws1WOpk3FEWR7F4h&`YX4jd zoNIwMwH7$%aB>c3<9(huhf{W4+mkt*=j)$XYu60!QKT1eHjHNXDB>?PS6SdH3tVME z7TD)5i{^#}++{Ha8zq< zd)9V-JT^(J#(Ie{*eB22`g1C_Oq6X=2g0mSm$n%9hMFa?k*ykI;8HdZjnOVE-6hhp zvsw_YG~{=bt@5&)qG7a?jrT-owpHHfD$cM)phI?q{1iL*iQFBsz!bTMj~okG4lzH{ zNABq(_wtc@yJgn@H8%bu0TLhq5+H#Chyd&V?3riJJbUKZGd~UWO!mzC*Q;B$XP)(c zMOAlz3x#J5+DH* zAc4O%0jbr0#9n>8Sg}{1z549cXRp4wV_}-^LoUXM2APFmmWvqGu;$MhaeF!=?$4{$ zcU2S6DpT#Q7?8Ou^M?;t!|Q3PBp}iwaglNmNBL27_VHZoyMrnUZ*K=TBv4XnX{p4EH?ETh=H*~)F3q+A5tjH7Lc$LKsbK$)}(`*ob zFCTAX;jj#;Err8{o-xBSkuf#+rJ2ES`KmVmE>qkD@GPTs$|3zuRtjo`~WppJY&)kFp??8d2!72LWa2Vg5yd z1M0LJ0i{6`Svjy#aysmWXYZny;(ZYARS!xTxo$lynkp07h`AaGD8jV>qs^1>Xpu51 zfhD12dIF1XGB62R7NRKAL5ar!s;}3h>bXB zL5a_|;^AFMTeRz_ro&|(VwFmY`4(x#J^-nd1)*lE62!+@{hZa$S^aXC#OV3pWw={7 zt6!>)@;s6?3=Z72`gb*}zq4OcoIKx4kuupiAYa{W$PcJFDO}c~#}sj0yN-Zhcx1z2 zgDi)*vHQp}4_ppZz{7%67%KR~QNbRJtGEpIaTx+xBE#}~wos8|9X_1qn)zXFmtq?( zw&Fd)?M386Zlyo(oPSAvUyBd-|I_7-|44uYNPq-LpdkUN*LP*TUU9WP*6Xcy8)+qx z77^KP%xshCtXodZ-RZK?qLPlwa$Ol*`ktL)EIMk4xzT_u5GtjkGLDr?S*dy&-1lRv_uA%|%GZ z!CnR(IN_oapNVMK;Trr6SK{MpeR2onqJ85~sMmLFRO_FPdnF$P=BTx(UbD_GEf3NJ zabT|V=Ue9&xgYBPn~`hm|8MxV$8RJ+0wh2JBw!L?{om@9XTLuC_1Uk_etq`q%f9#4 z-uA5jv;L22e_Pi7`Nl{BBtQZrKmrFg0jblcvQM9V`W&n8?vIy#dG_hctaBKL^Xy?P>& Tli)o6gK^BijQ{WNp8x;<)&(9D literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/GermanPhoneBook.accdb b/test/LibRed.Core.Tests/Data/GermanPhoneBook.accdb new file mode 100644 index 0000000000000000000000000000000000000000..8f4dd0cbfb86b386c5fbf35e12ab58e0bc29c778 GIT binary patch literal 430080 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 Q@%9v0CU|@LgKtm&4_^@!>Hq)$ literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/Hungarian.accdb b/test/LibRed.Core.Tests/Data/Hungarian.accdb new file mode 100644 index 0000000000000000000000000000000000000000..b7521122552e8d3d386de80fc5781dcecfb51b34 GIT binary patch literal 471040 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 I?_dA_fA2=*?EnA( literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/HungarianTechnical.accdb b/test/LibRed.Core.Tests/Data/HungarianTechnical.accdb new file mode 100644 index 0000000000000000000000000000000000000000..f2b79e4d949d02647badef72beaa0034018cef17 GIT binary patch literal 462848 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 a)KWRl-#F06`AeODsAtZ<$iqAh5dRM&<*s!A literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/Icelandic.accdb b/test/LibRed.Core.Tests/Data/Icelandic.accdb new file mode 100644 index 0000000000000000000000000000000000000000..b96b8d5b22563616be4155f996e4bd261ba297aa GIT binary patch literal 430080 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

    ;EEH3ktCQe=wB`>PiA6 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 literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/Macedonian.accdb b/test/LibRed.Core.Tests/Data/Macedonian.accdb new file mode 100644 index 0000000000000000000000000000000000000000..b6f2467aa183cc52ec01e22759fa802b155e7532 GIT binary patch literal 458752 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 literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/Polish.accdb b/test/LibRed.Core.Tests/Data/Polish.accdb new file mode 100644 index 0000000000000000000000000000000000000000..d54c5ab2ba205eba6bbfc425a0ebace09e733cc7 GIT binary patch literal 458752 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%0A5nG05?p5%;eCKdiC0TLhq5+DH* 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 literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/RomanianLegacy.accdb b/test/LibRed.Core.Tests/Data/RomanianLegacy.accdb new file mode 100644 index 0000000000000000000000000000000000000000..e97a09b706e8d10720c0d524483432cdf0a89738 GIT binary patch literal 425984 zcmeI52VfM{+J?{UZVD;8AtHnxAW{S|K{^5nga9!igb*-TAYqdbQ%M361rtD2?0OZu zSFzy5tG~SgqF%3JZ`iQcYr%@W!2f<{c6K+LO+bhU^3G;5-<q4{DgQiw$BN67{#*CJ7m4fNdHSlk zuYdTT%hG;5bN!TISzCu~{rbvZPW-Iz$E$5;&iN?*t6x$p?);rSt7ata!QZ zm|n)>L$7;x@ns#mU;6vU#qVy+y6Lo{*%$xx{K|wC#gBFGlltP9&lP^LBkb|CYZtYT z`r^~W3N}vP6#7HEFvH@j(oeJeVD39H8aAeuESC{O7fslP#`Wr zMXHbhi|vM8EbQJm%#=$uIMsMnsVeX*RC86WiczU5AIE%EfV~E$?K~!ha9NFWvGq62 zRaMIEF^y1Zstj^5e71*NseP2-yh2TZc{zMK)MU7Ks0_6bvCIDnY7xS$hJB1a&xAj> zT7ZxWRg^ZF3foG=6YdQqLS?BFwbs?}H5GnEw+e(Kp|@Al5SPdmst7(auvb8fxB&AU zH4iq`a2c+qt2)F`sFtG?;Dew|89R2Upnox7DMdxLrTV}4fv=#hO~bbEBV|H1(~snc zWUkI&xp2kG;n1gI=iPjE+Q{S=*Cfpr-!{qt-yu3a8~)p|N2_rBZ-YHXIq}~myNbg9 zXzWfEf&Y=%QKs-e47;h0R|g6tKmsH{0wh2JBtQZrKmvaQ0yg7cPDw@<>K#(+sPd#q zS3~^Lmm^hkvwE(rQ1LaS@{7%?|LfW{s!cFzTwOJSRX(NS(`#e z8k&iUT$--j%|nH)T9u#zClywyFvg%_SfEaTB-L4|42G&498-104zadSNwXnEQq`C3 z4E?{QidfT_SZUJG_>TlgfCNZ@1W14cNPq-LfCNZj-y>i%K93fyqR}$#U)gO^|3`Z} z8ZgCJTcOi{p*s%vl4KMAzDm2<#w?!pHx2>PL@aW#|tFJ}+WydH0u_?H8aeWbOt3>c4cy*A~87zSUdZQ~j3@ z1dThsd#nFeP`>KF{w^S24ubj!ZSqmV6O`y5=);K~1#?mFFT*#1a&#w;K!@^bd?AqD z2TuI=w~N6yf|GRDddszG;@>wRLA#lTntze5{Y&@txscqA{59{&Kddg^u%!Eat>^nh z4Z63N;H$;XyQkZbm%1MaQoT&4UHstVO$)sx?C@jF7UA=QfebD9allWE4&RE$`e>v- z9QnRqXg5}Y)TJtjohKmqbYXq>&_;iR^{!Lo_Yp5|3M4=RBtQZrKmsH{0wh2J`y&Cv z_|EF_j#2tD|6h`8t8}a*81f-cyspJNI*xi4v63FgGPJagw0o|^- zr|I3ZKpe)KZJG}OGnN@>ZAXi3yh!0VG?@BIBd$$M%~{Rlnn}jqfL#mPDp}49>^cUx zkz)gP-2^Nr*bU1>hwU|hWiobbR(r8|#{YkaK>c}eUZ0g-o}QkWnO$01l%AViDp}ID zlg}z6z0_l9n1(s-k~yZUgH3}u?ySn=J#OMmqmu07)64VR1*J3dicGgQ%c96#Qf?Zx zH;qa&OY(|5Mjbpx3ri7#)}*6pQl6e+MWMvRsL$zdAD5Yvm71QMnlWzpkz*#Mj>||t zGBaZ+#Ni_+r4P*<+21Z{)4}vKJOcC3&74>Kxpa znL%BGTb8@s8OLO2dP-Z@CXP(oyeu=2Lw)xC-W)#Td6n5-eL{W4O^bh+zh$YrvIN(g zPHPX-LYMF1+9X~~Z1qx9v#rH<;l#M38VoM38VoMUZg9M8H^#w&5alfY3&S zju0Y5=nNrJgsu>xMCcA7T7(`DoFeps;1U5VQ?!i{p$~+%BJ_t4E5aZMokbV|p^FG3 zAaoTW1wy6UPmC1hAarU>YU&~J(*WO;-nWeJ7|=%J7lX$eu55G?}wEF?KC!DR`$LxIpx(h)#} z_Lk6F1c9J?D2SlD4@i=AR|OGtKLQbYiDOBr?z|vEf@Pw+7@!kGKSNAJ=wJzbL_oKQ zel|;pvxL4Pprb@TyCrnAgnlA)hL!GlAwnn1J*LWIM_suu*^gF}RFmdQX7&?%#z?$aTH?h+!x;bMhu8vS%H z4-s@13=tB=3LQ844Y7owmM}~Nbmd6WG(^y87eS{Koja0rxCA{-=fr9xf{eNG| z4eCMyBtQZrKmsH{0wh2JBtQZrU=qM6%01p1_BaFtklY%9m|?`XMo_yQh{MgxF+{UvecObNPq-LfCNZ@1W14cNPqB#@aTm%}ag;spU6X^P83W#uRx*0wh2JBtQZrKmsH{0wh2J`#%AP z@xK=8|1u*w{{V2r$k0)n~uWaw<*&BtQZrKmsH{0wh2J zBtQZ|2^hwShE>DwukLoaU6HQ4oU@%V&UMkVqJNG$HL7dWeUTF*Ux}C>5gKuAo5VKH zg;#`k41YdsdDzgfPeZQ`O$mKAq&(y|N3mnGeThB6eu?dpM=p6}oNe4A=I;$wC4-Xx z(+%4ytUe~)H(Z8lnD@(oUaLM%wvG|7$y7P2PSvO>Dqk&BQ}Mq-6{;*%piaPXuBug& zu|!&p`f)|NYWka`;#H*Dp;TLi_0K%fOnm|=crUfLy?-ThN|%@4H^zpDQXD*kAq2)IzkOrBXBkp=P5WFk7J_8GzrVg zjeuz)Oa~!^RH#ym{l^tmP$}?N;Zr8Hi87W_5m*D!0yzz-t5KC|fvN^Rk$+rqq-y#b z=?gf;8}NO7%v7HH`SlthEYx)9R-nodR1$)^I7}sn9xvufjnuz^%KtaO3^o}#g2fIS zuwrf@lv^8n1Jz zP!%KMYE`1)?o3HhYLROCTj)~?s|0!qnZ>8p!z-}l`YsS3r(hJOBhvzZ(|i#Q*`o+0 zMX4yYfxmIS2(VgWK#HuC?MjM*Vg7N&4At~E-4`Vm=4?Q$F1reyWEM8-2vhGhmV!&d zyeMk)y{6c6fh1!rUEptwFYwS712=DAa@~~yjsL}9$)1ZsZc`ZM)`FgyC|Pb4f4SO( zb0Y2%fxm%1y&NqDmV=~9F|ESwWfua&rP_jKlDsNi?sq9LJk47OEKLVguC*GVn*I*; zCDA}Y2+S4PaOo&BU#%mu6*v7F=v9sUEmTEFsysW~;MVk}J{{{`S*8gr0Ia#zX6 z-K7wb1zufp?stV@6;n?H{SP-Iu#Q+Rw!yk7jYp8>$TlfXi*QG%gItJ5P6{HEN6uU| z5B7yfVQrK9g?axFWE-X2Vnx+}2nTo~-jxWaYXxf6q@X`G2j0~KatUkfwnBtC`bsL+#bzfF zcHW~1Gmx?h6!@KB?6^mfj(>P1m@FAUPMcDp40%E zJUMFA52pu&EcZbHqXcq!a!7KCxt>YlPN(cTD zH3VHbz!PK_f@}OTxlu`)INuCMtJ zvW}vuuihX*S+7F=NAGKq%}9-g7j+kK=HX=v0eg2bx3(!EFb*Y+YN_UIJAGUkeh%~Q83XKhS$Oqk7Z#Gun4y-YylPsNVP*b^GcB|E%1rXT;@hkEdf z%^|lKvt-DHEPF6JG1i;UpDqb~@u+s={YUbkWnx`CE`xK6@Yo7NQ!y(-9!Y=%NPq-L zfCNZ@1W14cNPq-(jR5EW-!)EjMFJ#10wh2JBtQZrKmsH{0wl0E2_&~hWbv&LNK9)4 z;t8WwCfDE1KpaK~r_~}Km?YiW8)(S${fa<+gYKYl)PAKjl_dcZAOR8}0TLhq5+DH* zAOR9+bpovaw>r{&DnP0Krz*wz|2~x~RE7jdfCNZ@1W14cNPq-LfCTnF0oMQbKJ&d9 zqtyS?SpVOf$Qc(2kN^pg011!)36KB@kN^qnlLTzWg))&K=Gir8d9#%CpU6+lrig)` zVI0n|+Zxa9C({GUW~d0v87*_c%G8sbvCX8y|0F;HBtQZrKmsH{0wh2JBtQZr@K+Hq z47aZTOZ+yq&b82c_E%}dcu9Z+NPq-LfCNZ@1W14cNPq-LfCO5b!0xvHcS0jSc6;#l ze;eEXTRWo|9tn^D36KB@kN^pg011!)36KB@>;VC`|L*}jdL;o8AOR8}0TLhq5+DH* zAOR8}f&GNQ?zaDTLHj>;NAUK42iyPmll-TeBtQZrKmsH{0wh2JBtQZrKmrE_0k;1i z82QXpk^l*i011!)36KB@kN^pg00|t}1a`OmzZ=^Bv4^t#|G+LkOg#yZ011!)36KB@ zkN^pg011%5{y~84|NBQiQA-ja0TLhq5+DH*AOR8}0TLhq64(<0yW9RBkM@7;VZqz~ z!#Vz6aU%f|AOR8}0TLhq5+DH*AOR8}fqjPn+yD2Syr3o|KmsH{0wh2JBtQZrKmsH{ z0wnNf5!l`K|DI_7$L6;R&94gdj*cVQ)M~WXieoE=~{}{`KLo7nz$F#6(i*TIl9s}l70{%X>8{y)^5#U3q zaMOoP&n?i{N0d)te+$e?pwd(cIv*6ORCGe9#UAT5NrQR5DpoaquBO1G9IiWgT}_97 zG0D-s%T<-1>*=Zv=Y`7Yb)BoGsSH4HfJg{-bcDcvJ9c(`unGi^BtQZrKmsH{0wh2J zBtQZrKmva$0mGQ2>;Ixes9NV*=t^>Jb*^z1cuoIO^%yw`kN^pg011!)36KB@kN^pg z011#lD-bXYn{K_Apr)!XTw7d^yY6yb>pItUvTL3z*EP;{xT}k+jq3;J2hQi68=PyM zmpD&#&UgM2y*xT9`r@c=QEMU-BkzwG7x7e^>22N$FAD!E?9H&kuxVjq!v=(P3=0k0 z7W!`JGocTJt_i&;bZKZr=#L={A>|>nLOyUj@7Um2>$t=b?}&E%V*j7L%6^2(&%H{1|1zLOsR$)J6!7f*{)}vdF|voK0Wos--`R) ze8s$q`-d(c;rQg&5C0b%a;f{-?`OTV{qce)F1i25&xRZo>8#!O>yMY8mv>H5?$kFw zNSU>4-36WAivKd}w9r=vjJbd5gXf-?J8MYveZ3~n5Bq3)Rs1jZZ5D!|roNsc z9fmw;YSbTQa(}Qq!j)yTHe$q%LsKJJ<0e2=l+!>X+{%I*h3?gd7K)o( zAOZTCMtfZolyWs4Eew0JYyp5-w)T3oX8GOg(VExDUXRwi4)=Pr=C!`pqc!g*dp%n7 zp0(GbHSddiJzDeLyVs*N@9%p(T6u>21vf{;8spsi_hd8LM$dao6TdelFmXwfw4af@ zDH%mKk~?vLk=!K% zjN}d$U?g{!03*5b1B~RV3^0-_CcsEabAXYQv_?jTk?P8rk&+?C2P^fw|8KEt@pH}n z#633>Ac1|BK<2bT>T3B+fiW}_Rj!(h(J);+({h$5w;mZ&qAFDdMz1W;qh*5IPRCf6 zMHmOuUS+9jjEgCR4;fQa3t4K)I5i1j3g0POCmHEf2z}fbp)?M@=c-b~ zWyT)sH8FJ)UtM534@Vi@l!o{uFDK)_*eyZ|Q*??YCo2$|=QkS@`^ z3ZeL}Fwvx3Yv_c3DW#UJlr8fLmAos#*tQCkpcEX7wf4o@m!!fRx7JQgKu%X_yBcsY z<7lU;*LX_2iLl31PhRj{p9a@EwJ+{J-m2>kswsoxuV=1fRt_)Y~WW+iFd!;V9-8|Wm;JyxUvjdyRagg!aso>b?C=vu)R}o}-7@*kNMy6GywQ@y(J_yc5f20ZMhnj^5OD<>afLxOT$e{yR>Qi0o!W$_N$Vd5jee zQ=3%8)6QJ+8R$dICcb~nfL^OUP8M0V=_pnO$m~LuWEwQd2($$?2}v@}+ydDJbM4RE z<1OF-;uy#pxCyUtB+MoPlB*2318Or@B{w3i^ha5?(H>j440URG(>Re`KG_iiSpdV_ z(bgx2#`@fZ9;D?5$4M;h#I-m+7>1}IX}P_2pp-muAH*wvgTC6PU^MGw6|b^!70UHi z2idxQ`ug4D+%PxqeC3cDUw3jB&BYZ{sQt?IB-djN_99%H@wlev>g!IfsdzO=CE_;> zl3aIky_s(fW~Pd%Jg?-b(iibaM$7Y4;W*+1dVJGhb3vsV7@0;e`G2YLw++##f(L7#1 z9joz?)ufV%!Y6ko^DYzb4K)=ebHQDnx^fpYi*^m-ovQCjwXlMlFjAD{mwPo$$OV#6=?aT}iFn`>ogqB9+3{d?Tnv402C2)9pqoMH^r7uR$1D zh}U|m#Oqk&;a}bZtQ?cDcDCDL1^b;joF7Ha8p?hwYWkPvA;!un{4+ zvnQ$%YM>f|Bd$t^jA^%gg?U1gTbEqea_b87Sk>%Pp6*C-i zZMzX}JZ=as+#|DaaTMunmZbS!Cgk!=gu4bPCa-L@`VxpjL6(QA6i0cTwjNbdbn8$& zWHW(uLZT#T){9F4GDa?uBy}ik4AoN?0C|4Di1hWJ* z9@uZDRwho6b3SuWYr*qF|CoXik5l7l# zfSpOGT@xZgC9a97MKDy^pq(8%Cs5Fc`HuuhfCNZ@1W14cNPq-LfCToKfMFQkk^W(g z#{YNqn(uKW41fekfCNZ@1W14cNPq-LfCNZje<9G&8j2>jMj-L65y(CihHf<7Q+;+n z5QlMov<#08w%HuvG4aU_=n!EE!CcX7UnWrBpxfmWl?Z6OPc%e87mh@m2%7E&_U;B(@PD5<-LsQ4k_UKxdM~C=u}WC^1?D zjN(ajih#Z$i8$d#0wh2JBtQZrKmsH{0wh2JB=A=da2WrIYOek-H9!0%%YRhfY`Uj^ zgPg$#9nzt1vN(vxkK4XUKq}rwede63jP#OH$uYAA52MP`a`&9v5_e^hX|5~lMtxR# zd3t(gW_D?5QF?B+X{GD>rd39IsmD;)`;Gb>cgY;nmHv)k)aT8~UQk+;ooO2Cn!iy$ z$DLJqyvL*dx&Wing{5XhP^1KL^|zO}-Q`+G{k6iVFDh{ti9Xs)f3Gm=OR|gHCFN$| z`h$g0Uz%BxSL89$Uo4FJ`5C4s{oTSAEG~FTFVCE*gVx_fBI}3h6a5(@vOX)XL`RJd zG7Vv&G5|2qB`~AvGmdv>XqWit!Hon+fCNZ@1W14cNPq-LfCTm%0uCd&+4lcrRQNHg zLD0i~lfVO9QM3Nv;MWPDaR&f>=IsTb&%9j$^qIFmfIjnf3eacXo&oyI+dV*^dHV?H zGjB%$edg^OpwGM=1auAG+ebmy!@d0q^qKBKU{=DBBHh*C0MA|~p9DyN1W14cNPq-L zfCNZ@1a_H#VH_7}wg1Pc>s-mMkDWI=%bbTfzl^>&dQo)0=#QiBj5;YQDeB9}vrvoU zMgk;20wh2JBtQZrKmsJtDg?rG|5`hypO!T;tVzA)of~Z;YVEAG{bg|k>)2jl-f+Lo z5tE7e*y}LWcPeIUmu)Jhgzk-*|2tvMd`zf}0iE?>G8z*zQkUx`Kyvgn^K;=vx--gt z=_oT2>nPLr_f_#&G$zgy#Mm$y*@>yK7kDNWFV`!r$lUE!Ed(@F4b|H8gg&M&2uLO# zABS1UE1|ITrIch^F#G(RD&^PdS8{`(+JIFz*vrXutlHh7Q7FD z*94sh?R?(R_J;L`#$mGf<}xoqdupdicsa=Q<2jhV-&$a%d8LEKiq=5S`U2*PQGd|8 zbfcXHR8@Lz``SO6X0%g!S3hVz=Dxbf#$io{7#}W~wBA};!72v=E?AjExom)yce9m1 zl(;7byP_ln>(cWt7LAu}m<{UVk@ghK1V0{2IEgQb-zp;3aVoT&q>@o!hWnHkhr1HR zG%Picg$SA}5$Txtf1DbLYjKbjxM8HV(G&V3ib3iN*A~~~uBooku0vgSIKL^;^1R5!ks*=aMtu9E@Apo`yolV0+$V$o#zj2U z<~}L*+(>`~NPq-LfCNZ@1pY7qbA_qz;O~bOQJ9d&P*}m*jnlti9dU_PCJypjOIz0X z+p!(%a{H9CjJ}`kdghtePQK&QQ&0S@xZllJ%&WM6=<*ScPk#OIf3YE#x}W`i)=S$T zFL>gT`+xjw$Wf8b+Ks>dc=>sG=OpD$ee;8qSVh zhD6`jYx4ZCkG5CE|6>1U>17{3RaaMKF|nfcQ!Xt)Ijj(F8SPEw{Gzp#gHdXLa>!Q8 zXm2X#7pN+jBVTgea@1Sls)e}QrmZGduvU=X0382tsxNwfjV;aiVc<{Hc= z1nw@DkySEXHlvfC=l^YdGcZ@g)3d|rPY60h&p*%lf7WLUbp$?NV3*DMe4y9hGZ$R+ zUxRPExdva;Rr@?^@R@;#5Bazxo1?kq{5(N5T{5tR<@}mm0I>10!Ul<5z7HHhC_ zv(H+MPS*32#Ybg@Mr%1xSp-xTEVY)P&cau`6Fld#0_`7J@y`a3gUzI?mHH?-feg`` zAa>3X4KOSR0>h!!S z@qXdNG;$-MG#yj0T&Ctw20HCG_h$p}VII=vI05m4g&3Rj0CPM14EOkH&_`>Ohn(mU z1v2KwZ)|}KB+!Eid}9m5X^Z0yBux39ApUIfPd+Jm!ZgPuq+;Tii5Qz;P88ET*BRc4 zE@bq@LX5n~R~5*C=KPkxu4boxYJ@2_5+DH*AOR8}0TLhq5+DH*IG_m_#w&XKzd&$! z`u}hBnjFw*n0OK(0TLhq5+DH*AOR8}0TLjAzX1W=!nud(XxKylruS9}Ww!%y7#pI^ zc@3K;2L1nI;*%TDO3Z-(P3i0(CZ7KPW3B%GW3B%GV{KxE{v2cNB4A~Xu?`W?l0P;? z1avJJ8!7?@fs73k0n?g}4Hp3`p^R-K0=j;Tm1R-5kpKyh011!)36KB@kN^pg0152t z1X%yy*K=sk^pjdTzIB=Pe-R?Y1by|*4*??R4*??R4*??R4*??R4*??R4*??R4*?kE zVSWe@0bd2UkpKyh011!)36KB@kN^pg0152#1Z+ly9{+zPDt9u9I|>@h9@sy^GyY%3 z_rs-9BjE(QY~tM7EN{Cx{=eCk{mk%#ED~tf%MbcbCE!^k(9lWnxA4p{u(R?1PQS6{ z=1P8(vC3a1Mxi%d7tnv?|89@!2gXqze!D!XAAXMX_-W8b$vc6sGhlZ|?o00atuz=& z!c714T^Mbjsd6w2LXB!}4F7cS^k>KM`8pH%j|5171W14cNPq-LfCNZ@1pY(<9RL3( z!e!7TKmsH{0wh2JBtQZrKmsH{0)IJyR$05Rb(Y7o+RXQe$?gW?Fz$+$3E+cm{^S2! zg;%ghX!fTP@bv#rw#NS_TjT$et?~cK*7*NqYy5w*HU2-@8vmbcjsH)!#{VZ<}Qom{`O9&HBHX==#41y8bVMuK$Z*O(?+n|Nc}is4EGO011!)36KB@kN^pg zz=21=Fa|g=_q*O~>R{IxuKjo56#}N11W14cNPq-LfCNZ@1W14cc1i#p;M@A8p&&5@F(4DyTsn%W~lUHQ9K-<<#O;lDAgtAL~#xNDWkn zs@|%nr}A1KretzGt9FaU&Gb1A!D9{cb1dE2i*&(sAH+`_s1@dt-3$N;EtufAB$ZaTXlbk!5v#42*j~f_ixs*^?^Vfi)?K{b+#PI zQFdz!>R9UhmP6}1fOrVv*kEwE6lC{&Sr~^ScK^-c^B;%A zTiM`H5x&Lg#lq$Zd%GbE%BP}0rs+`=Wf+r?q?T)c#*Q8MY7zX;hIq$&B3`MFzKC&Z zeadn;bUf|UR6Q!A9HTUAC)Xvy8aYv(ZHohkdS zden>rNPq-LfCNZ@1W14cNPq-LfCNaO6$sdjpUlzy7%$-Krf+ug_w>`(u}lfUfQAt7 zr~)+sPO!`7@63{8B`k*?10hBMV*%`Xyn`MKphHj!V*z}f_{G8+ir^hRAi*^Dp@$XN zv`URF!ZD~omSo7sz>NuNkzSmk0#hrNU~0t*=p@S^gsUmAFUMeja5WtR0;(X%&;y5R zap(b(oZ(S)l%oTJ&$ZPd!Rd9KtEQpjK>|7wG&t)&1Id z+yB=%3!Gh@uSNgEYqvk9jXIM636KB@kN^pg011!)36Q{kMZju@+#{uRliD7I0MGty zyL%eeKHxvOHFRi|KpI*@lidu&VRUe&H4}fUl$vJ3rt#h(P~V_0+^oG58)G2>5+DH* zAOR8}0TLhq5+DH**jEX#{=cv0OzY^y`hV+iWGEy+0wh2JBtQZrKmsH{0wl1%6L1*z zX50Ujir?Rpcfjkf>;LucoSeMO?6C5g*>keHl+T>AK>G5qPrw0RItYLSNPq-LfCNZ@ z1W14cNZ>#sU>MK2Vm%2q)H|*NDeX)v36KB@kN^pg011!)36KB@kN^q%O$dajK1hiS z@K41qt-jWL^_^5K&bv$hf3XU-ar}QUD*Kn2r~m)d{Yz_VO9CW70wh2JBtQZrKmsH{ z0wmDd1X%xXZKC^Dc&z{LTlqn4NPq-LfCNZ@1W14cNPq-L;IATJ7;oD%W~5|D{D%6{ z{#WV2cu9Z+{%iu$0q7*G>AC3mb1o^Ie$hMaA3Oca^WzFq$DQEr+xE0}Ms}ykH;hjk z_rSsDubT4dw1P=(`rWYNg1B*UmmlRk#J)T{eN@#qkA1s6@9lM8Zo9SS*=-*kS^Ccn zKb-zfBo?Vz)Zwg83hHcI)fDwk4> z_b=V_>6X~#FO5GZd&z&NIj`yEPTQK8RrKEt=UjEYalr*St7mLj)AykbArDlB&OY^o z=cac`3%PU9Q?ni}8Fb3vd++^re6MBGue+o6y;DBF^`1_5SAVne%dIOXT-WEqpX~3y zy*}l>_|JMAe$|MNZ@DSqiG#0yadW4XxzTU*y8r)5AMg0)Q!BrJ=G)j=l`mg3Y5eD7 zdY?D1pT*VU&Dda~fP zRm&$-Z5ZGC@Q6{PN4nwBxk-cUCw95YMWO}^o}l~=w{@$xOx?n>!VGV$e$ ziYxPK!d4~RpO zc*0e)4_&|d)m2AF^zXiL=!;*!wmU| zr1$-9ThITb-ZS#zwh5=7dT6Ji3GIH~e#O}99n*gqcJa+`#Z0U{{DYB?UD7eH`^1em zZTa7|2URWUnY8|n{Hy2QkuqmlP1vByw;%l?zC+E9_a1w#$D+KtqZ%&hGW*--{(H=f zwRg>(jP7s)mHKJT`xELfK4(a`GsA`)SJLOxf84*I=kyouIDGa?Bd3+Gd*=P(mDi7% znw|N?10UZ~yr%!ctIl6m_1^rL_@b+CTX|bn;_53dd2Q5dgV#KI%ZzK=f7NHi@cU!K zU%4Q!&5fVleoD{5W4GTn>fvkFz4`FT-z@$rDg&AYto>L1QXs#W1 zFwU`l(c_0Ly66V=;ur1WUadVh>h9skJa|}UpS8K;?+9Bpv-ZNUSz}-7@Qd@t_1k-< zJb%vmxtsc)WB=;+jHO#23V-gj8?S0tx2dLI$d|tl4ZV6;SX})r@0Xl^(r0<|-t2b# zPyc@K=8M7?r`@o=&td=md|UAiH{Gx%a>Zq19$t87+vl#H^VZy(|Ff`mOxxePjEH}2 z$A!<1>2vJS^E!3Roce3^mqx`i8$RtdW5%l=Og?+&E@NuI%<} z!|uEIjT?GDdwub*H!X9YvGJ0Nd#NJ^eHHUqc=hWyJ@!EM6BRHn zd%tt^<|Vxj-%@`5w*Q?~cI(;B`b}+TZdiV6_{$GG`^%fC=9ZFu9Y?Ed}Udijz|U+I*3Pk8zdmuwildCf8B zt=my#d-C=>->v^;!KUJCLuPGw?Ud?!j(g#SrN^9GH+#v9t%dDge5!I)+n)9J46Qo% zKgk_zqo!=^bM%#mKf2_-(RYSFTK?2Ozq)bRq+`1L^Rzqvxnxw{v0I0a?^AoFbNpfM zRn`553^{kueMkIu*T#okbL&n>n3MYYw7Q|A7O(zhW@_E_Nj+9XO}g&pJ_*O3(`(B= zE;u$JH)%?{FP3z%cV9NITSVl-gVQfR|FHAAOgkUisvc`7hqRpys95&U&k0 zR^jDI(;hfH<*xj%&$#Epsn2|uc6j|M^VcjaxW40$zaKX7^umIdu8F?;QLJmbpt zSFGioxVezbeu=d;%)W~R;U_S?%dPyFnLUdJAD(_OK13Qk^k`tuDZ-JO5I zMeW{P`|9RLje*fA zKXh!51#_xzx@G<8J<~oq?5Ck8ZR=~x9{%B^PHS(z`15<$N6jxxd8kLXo)5p*d+4E) zX1D*vIq=rfw-&nZIdR*HoE{J6J~!%(MKg{aeb(_G58d`*?jfH~KKrfb-Z(qtyz6f~ zzW(vF58gd#`sTB?UsLqI*H@)p_d~%;7k%?dTCWwa9(zQeu(G6{8H01~oO79dNM^~c z*I#-`hlirtC9LfC%2!oaY_f-SysqOCmvhzXE%&T>@*gEN(>5-zpYv$q;y13CKjvQr z-%dDW@PN55sL9*rFP-z~{8ulHudXktT=3GJwc9_4JHPClrO!-V7WLSm+wXen$__s- z>2*WQ;87QTPu&-afysoa`zqUV5X^^@Nx2d+eyUpC4WF?8RLZzQ3iebMIeIx*@~yzb-egez0)$9sSD_ zI?lW8x2sc+ySwc>ee&xAdEX^GGwj~~ zKK`GdKkfhTVWo?frcLeh-%l66Y|LB#&BZSa|1n}p`){B8^&Wf9BQd8ec<{%YJI|PK z-#<=%ni{5(bjOkrgmu-7*ap&jUGu|2f%^mGWEuDGG z3-```JMSIG?CPV>Uik8JAG}%h!0h9Pz4EBLxa}cte3SJ3l8sMpz5mtahbgJITQ?&A{ri?UzOeOs^7FF~J$u177hPU@ zUlvPVZ@{zx3&RZNgPoRnLpQdFIl(*V{eQ{>}XLBS-h2a*O-O zqJw6pU-M>l=g)V%eBR36*8bSfd7M4tt&4u07qd2Z#JNd}o%LTP#r_!k`^S}o8?OBB zRojCvEP8#)xr;u1A?Ml-^S%xnbIrIz9;{ejbxq_+@2-0^Y)8TaPo1&t@JC)s_$oF( zd{opoJr>OGSbAgT`&XUU9JxfAfLg#;xx7 z<>tjF{9kq2>hI56zBVE9ps1PmN1S!=FSF97J2%+g-dgm*&nMR0oA=T$nd;~lyScA= z^}U3=>&jNVzO<-#WJI?k-b?*0Wc2c*UcK|FA>qGfUVg>c!))7suRU^c%0u-Nw{QH+ zHCA0>A9%#ql`G%FI&X7VdcA{EhZAe{J`bVf@2ot^a4J>p1@Z&#ripk#TI4N=6%--m+8?ekE$ID#TR%)73m2%drnswUEozBJWWe z{$$TklQ087syb42Qce}BUN(%|UDo)2Lp{v;ze$$=Nq_`MfCNZ@1W14cNPq-LfCNZj z-y#sA>-AE%Z|-YA;J1K5>QL2FMWFWYP~F3gdv%@vC;ZO~^(9RL^xr&R9K1%;@I3+h zjvZbT3M4=RBtQZrKmsH{0wh2JBtQZrKmz+J0mJyx+vbn?{-4K>8wrpA36KB@kN^pg z011!)36KB@kig%J!0xvHPe=PdcDDcj&B_ClLjoi~0wh2JBtQZrKmsH{0wnPFB*6Cn zzh}NORU|+HBtQZrKmsH{0wh2JBtQZr;7`D2{N|MN->}R0e;N0W)x7i(2k$l|M9I&) z&43f^F7ac>PpLR~!w3Ss#VChwG6Au$W)^U$2s0Lm$KS^?6)6@D9l5^+%*4W`!}hmu zsBkq=<*Lb;&d`DXZk$h1`Fa9Gr&r2^Nfm6St2!JD;nD`RWnafLxOT$e{yR>2Owv79sh=KPYaguosyNjSBl2w+&)X85mkN^pg011!)36KB@ zkN^pgKr0Z~-S+=u(f*H}?fj@Y}Z&${Ql#EX>HGrXpa^}CkK1-+p36KB@kN^pg z011!)36KB@kib4gAOzYZVjtttDsa+0F`n?)^#gjX`Z(F+L{H8qa^orchI*_&+R*>5 zQy#i?!n);KhR2SG>JbL>z)&$qzuqRRR2;3ii*w7W>-%=d+VJ7p+RK+GEDq)~!s9bd zm0+sCGUYa>3p6H%OZ{IgBR%u~o2vknhL>(Ke;$v%L*}$WD$cG{gX*SI)D)GYCaZXr ztrj5QYE=h0Q&l2@Ld-QdUCqO>9J?FlX)v$GS-ct#sY2D_tP;x9z+Eo>7izyHn5jb z9{wt{NtuqPP_;)o3e`gFja_zX6!#2mUxTBPz$LT_CjI|*vx}` zBJ51JC9pHKHAC?E&4EABQ#6lP(SK`q!LF-Zk zHw%!XQYL1>t>k_@+?qL8rh5C8w|F%T=2C`)fs`II4}?`WH3=crK(iwJ$`Fg>Uj~jf z2)jmg@#K~-ccdIiiIg;UYGOMRKHcy+Pwmi`NvQrGrDTFmhraAZHoUUou!$A6C}lY8 zezLub|a{GWkk3zJ*bIA3XnkaUBbAB*kdqpXqpbKttSczEf(^t2!Xc-tC;iFz*~b{ zJ-jve-5T!0O|Lu^gX_?8QbZ&DJ_EA_P9+|!>9S= z^ZAa#?WsgRBjj1<)UM=Rq#DoEIu#pmo`pNVQ{T_!xsVAhst~d~_k7Qq89J5|)o_^0 z6QT;C=OZl3T%IEp`k5@x??OClE8$L_$2mC4b1@b-(Mm{?)*3vQnT)7jrH4o@zjRS2C65sL3g8>8PD%%`#$d#u+)-a;f~Vd}e`%M)L;oQ(fsw+Jar z@sgA0vFI*uB4()8o1DD6$xb$RlZLPj) zYVbA~2ZoKW8!7KnD~Qpd&xc&1!*c7ILm6!4N|*%~@^&ikaTU;4-lMvBa@)Grq|Ev1 z3r@tIiF8=?jBcKDd50EPrnYiDoAr7VGB9vk8jn}zy zd3Ch)@z~k=La@Wc=qHYLUE{kXrI=V=HVaT-{Z%5en-M7^RD|bI@eEU&RK(Lxy%1fX z4+(AY(~axVjqA_iA}}L@)qu@9v$zgq7tFOkbMe!31942DAGirGc^$WjfaJ>SxS%$3 zRdPeWj*Ea7XVX^R#btxbPi;iG1G4}BzMfmuj|5171W14cNPq-LfCNZ@1V~`-6WEph|AGA$q|IKo#{C!eUjP4J z>s6z^BuedW`+ou2|FN_E-^@_{Cjk;50TLhq5+DH*AOR8}0TLjAeVPF0|KF#xhsu!v z36KB@kN^pg011!)36KB@kU*;tVE_MC!N@>JfCNZ@1W14cNPq-LfCNZ@1pc-J{vQ4R zZN@ac=HHFTr#wvE*uj%u)<^{_`=;12hoHKF+2RHs>rn?h} zVa$$jd!p%}UUuE)TH%`FI@qM>U@pv7P=7Yv*%+S_d=D0RoYL$ zaW2++mu0mngH}+f(=b7+crJcrOfqAdfG&n6%v(-?n3!jG2 zhN%=a1pmjuBuO2i2CES`8;bK3oQ=mZ(PNsV2Ei@`=i?#GhTnW>l&_;W8XZ`@QKb55 zGfgi#Ux}sWtC0X%fL^-ECa8Fy^As5PPZBN;+G83=>BU^1N1i z%ZK+uji*ukef+$Qhr@EDwhRszdHW2{MyAy2teC6j!M+g1rd9{kDE>osA)x6xVAFG> z64q-MJg36*T%^7nG|F`R!%>{P+1O(jf|!V+EM;8EK%@A(?}F#aYJ&cyg3w)Ai&A>7TQ`g5%0w<= zu0aBdaV*5`WgKoTwH_klz{DF^Y_ow$&}Bebu7i?93{;2uJj$p5OAEQq%k))HqSIUh zZZX`egBtQZrurCwHoHj_Eiicu@>V|i|DR_09tm2jR5Uqop ziKnj{kJP?+JDi83ybZcxo~BJp5H?lQJDoA)e$@ z;BF!I#x6TGihG8(m-iXdf8%<98H2ptm~Y^9c)u}UpH4(vrdxRvGPN~Bu&P(_aB1o( zn#bckNpx=Kp^}N0(aBz_;#OYDPK0SCQYx=T=G(SefvG{fQ#Hj}*j2+W9_K=|3bMS) zNgg$R;rHc@XxB;2gv$biFLfpJ?aj*5f#4`?=c-cVS~X&*(8ML(ZlqGQNrNAG6LZ6N zDr!VkI)BU*OEn`NwiQUB`F3oU7de;w6wR_#296WpzXU$z^)o^1QUo^(5U!MoSumH} zkB3_`=gL%Xzw#EZ0&9I{9tf*$Y7#=Kfo4Vcl_3^+JI%nc24UBrrrI=Tt&-vfoj}`} z@acxnd3Z4mv};@|6Y}E1)FROB41`mNe3ksKgO-8zIhuu}UhF#Nr!&N z7umqOrC3KP!(o&6Opk2$lVt>`Y_H qp~XUe6(NY+&d>Z{$cS9=_Qcy0Z%@9@Q}MW&^Y&!ky!L_H)BgiAE<<+! literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/Serbian.accdb b/test/LibRed.Core.Tests/Data/Serbian.accdb new file mode 100644 index 0000000000000000000000000000000000000000..11768d9a57aac66edf4529f2b0ae0f8fc865b1f4 GIT binary patch literal 458752 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 Bi1Ppd literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/Slovenian.accdb b/test/LibRed.Core.Tests/Data/Slovenian.accdb new file mode 100644 index 0000000000000000000000000000000000000000..b1d8fac825251a081e6c5f08424426a6311f5fad GIT binary patch literal 458752 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 literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/SpanishModern.accdb b/test/LibRed.Core.Tests/Data/SpanishModern.accdb new file mode 100644 index 0000000000000000000000000000000000000000..25346b2f90d7725a95b58112ad0ffd65ad440c40 GIT binary patch literal 430080 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 literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/SpanishTraditional.accdb b/test/LibRed.Core.Tests/Data/SpanishTraditional.accdb new file mode 100644 index 0000000000000000000000000000000000000000..f1c091395fb12aa997770051500addb00aafa173 GIT binary patch literal 401408 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 Z9LCCe9xIF66Wt;BX7SID%WdrT^#4xpW)%Pc literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/SwedishFinnish.accdb b/test/LibRed.Core.Tests/Data/SwedishFinnish.accdb new file mode 100644 index 0000000000000000000000000000000000000000..efb150467d906616b50823b7387d65c40df1ceaa GIT binary patch literal 458752 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 literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/Turkish.accdb b/test/LibRed.Core.Tests/Data/Turkish.accdb new file mode 100644 index 0000000000000000000000000000000000000000..7bdfb8e4de5bd088a8349779f9bc6f1d24460ac2 GIT binary patch literal 458752 zcmeI52VfM{+J?{UZVD;8Ax3(M5UGL*(h*2P2oMuO2m!G{LJBdJBp~sEiJ(`p>jm`M z#e%=>TCo6P7dt98#NHJv_5%O=o!QylY&HQQB9V7Cllk`4?>y(s?3}I)rLt>s%ky)q z@?(?6jgL)9RyL*7J>~Yi*=-)l`RxCSsuIVl%TKxLf}N*dn(%n-17F6kd*|6J=DhLY zqgSN6+bEg{!uDXn+h4P;hGCl%v0SX&^^PSZ$OYhTRczw8x3I2-V> zAU}&8hFvV|o;b{uOEx&wI8~v_@hMPqRE>&KDJmD&T$P8j8m2LBlLEM`!gsOtHO*0# zD&K7yrczZY0P6raDP$T?JoL;8%1jM>rCCJ2e$?iCnG<;WHg)Ikf2DXP&L* z!lnu?L)A1@ix>*jQj`KT2-=jfbEgXU7X_A5ROC2S{}(^-70|V5*cN`IOvqvSksOiC z)fp`Jtyno6`m5M^4zHayGWm6AlxB->8|8rSARV6#|Lr&9a8J4bf-yI zLwwSgEmd=~dakWd@inCKi^Hn_>)JJ{O)zR$T{Qw#KBeN*WPM!MV@(w#C~uWGSS6tv znt_U3s;=D4MTM+CtV|@U8yKaC~jyt>s&z)!o&9X&`7g z@ZD4Ww}SFk|8={7v>XKV5!|Rz!X1?8@9)Ej5e0Kl?=MB0KpBRUhhac@6

    n^nnxq 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 C#MEm5 literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/Vietnamese.accdb b/test/LibRed.Core.Tests/Data/Vietnamese.accdb new file mode 100644 index 0000000000000000000000000000000000000000..bb197392d5ae5c9fa74045720564d7d4d3653d2a GIT binary patch literal 425984 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 PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - + + PreserveNewest diff --git a/test/LibRed.Core.Tests/LocaleFixtureCollationProbeTest.cs b/test/LibRed.Core.Tests/LocaleFixtureCollationProbeTest.cs new file mode 100644 index 00000000..e92665f7 --- /dev/null +++ b/test/LibRed.Core.Tests/LocaleFixtureCollationProbeTest.cs @@ -0,0 +1,191 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Formats; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: every Access-authored sort-order fixture at once — what lands on disk, and how the keys differ. +// +// Two questions this set can answer that Spanish alone could not: +// +// 1. Is the collation field a full 32-bit LCID? Several entries in Access's list are Windows *alternate +// sort orders*, which live in the high word: German Phone Book is 0x00010407, Hungarian Technical is +// 0x0001040E. We read the LCID as 16 bits from page-0 0x6E, and the byte at 0x70 (the page-0 analogue +// of a column descriptor's 0x0D) is flagged in Collation.cs as "0 in every file seen — keep an eye on +// it". A sort ID would sit exactly there, so this dumps the raw de-masked bytes rather than the parse. +// +// 2. Does the v1 scheme generalise beyond General? Romanian and Croatian are the only Latin-script orders +// Access offers in "- Legacy" / current pairs, so they are the cheapest test of whether a non-General +// order ever reaches sort-order version 1 and 2-byte NLS primaries. +public class LocaleFixtureCollationProbeTest(ITestOutputHelper output) +{ + ///

    Everything in Data\ that is a fixture for something else. Anything else is treated as a + /// sort-order fixture, so adding one is a matter of dropping the file in — same rule as the csproj glob. + /// + private static readonly HashSet NotSortOrderFixtures = new(StringComparer.OrdinalIgnoreCase) + { + "Ace16Types", "BigTable", "BuiltInDataTypes", "Database4", "Decimals", "EncryptedTest", + "EverythingIsBytes", "Northwind", "WideTable", + }; + + private static readonly string[] Samples = + [ + // Base Latin, so a wholesale reweighting (a version-1 order) is visible as everything moving. + "a", "c", "d", "e", "g", "h", "i", "j", "l", "n", "o", "r", "s", "t", "u", "v", "w", "y", "z", + // Multi-character letters — the contractions. + "ch", "ll", "dz", "dž", "lj", "nj", "cs", "gy", "ny", "sz", "zs", "ty", "ggy", "ccs", "dzs", + // Latin diacritics, grouped by base letter. + "á", "à", "â", "ä", "ã", "å", "ā", "ă", "ą", "æ", "aa", "ae", + "ç", "ć", "č", "ď", "đ", + "é", "è", "ê", "ë", "ē", "ė", "ę", "ě", + "ğ", "ģ", "í", "î", "ï", "ī", "į", "ı", "ķ", + "ł", "ĺ", "ľ", "ļ", "ń", "ň", "ņ", "ñ", + "ó", "ò", "ô", "ö", "õ", "ø", "ō", "ơ", "œ", "oe", + "ŕ", "ř", "ś", "š", "ş", "ß", "ť", "ţ", "ș", "ț", + "ú", "ù", "û", "ü", "ū", "ů", "ų", "ư", "ue", "ý", "ÿ", + "ź", "ż", "ž", "þ", "ð", "ő", "ű", + // Cyrillic, including the letters only some of these locales add. + "а", "б", "в", "г", "ґ", "д", "е", "ё", "є", "ж", "з", "и", "і", "ї", "й", + "ъ", "ы", "ь", "э", "ю", "я", "ђ", "ј", "љ", "њ", "ћ", "џ", "ѓ", "ќ", "ѕ", + // Greek, Hebrew, Arabic — the characters a tailoring would actually move. + "α", "β", "ά", "σ", "ς", "ω", "ώ", + "א", "ב", "כ", "ך", "מ", "ם", + "ا", "ب", "أ", "إ", "آ", "ة", "ى", + // Thai: leading vowels are written before the consonant they follow phonetically, so a Thai order + // has to reorder them — the one tailoring here that is not a reweighting. + "ก", "ข", "ค", "ง", "เ", "แ", "โ", "ใ", "ไ", "เก", "กเ", "ะ", "า", "ิ", + // Vietnamese (tone marks stack on top of the letter) and Devanagari for the Indic order. + "ắ", "ầ", "ế", "ộ", "ớ", "ự", + "अ", "आ", "इ", "क", "ख", "ग", + "ა", "ბ", "გ", + ]; + + [Fact] + public void Probe_every_sort_order_fixture() + { + string[] fixtures = Directory + .EnumerateFiles(Path.Combine(AppContext.BaseDirectory, "Data"), "*.accdb") + .Select(Path.GetFileNameWithoutExtension) + .Where(n => n is not null && !NotSortOrderFixtures.Contains(n)) + .Select(n => n!) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + output.WriteLine("page-0 sort order — raw de-masked bytes at 0x6E..0x71, then how we parse them:"); + output.WriteLine($" {"fixture",-20} {"raw",-10} {"langid",-8} {"sortid",-8} {"ver",-5} {"full LCID",-12} order"); + Report("General", TestDatabases.NorthwindAccdb); + foreach (string name in fixtures) Report(name, TestDatabases.Data($"{name}.accdb")); + + // Two baselines. A version-1 order compared against General *v0* trivially differs everywhere, + // because the key shape changes; what matters is how far it departs from General *v1*, which is the + // table we already implement. LibRed can author the v1 baseline itself. + Dictionary generalV0 = KeysFor(TestDatabases.NorthwindAccdb, "general-v0"); + string v1Path = TemporaryDatabase.CreatePath("general-v1-"); + DatabaseCreator.CreateEmpty(v1Path, collation: Collation.General); + Dictionary generalV1 = KeysFor(v1Path, "general-v1", copy: false); + TemporaryDatabase.Delete(v1Path); + + var differences = new Dictionary>(); + var baselines = new Dictionary(); + foreach (string name in fixtures) + { + string path = TestDatabases.Data($"{name}.accdb"); + using (var db = JetDatabase.Open(path)) + baselines[name] = db.DefaultCollationVersion == Collation.GeneralVersion ? "v1" : "v0"; + Dictionary baseline = baselines[name] == "v1" ? generalV1 : generalV0; + Dictionary theirs = KeysFor(path, name); + differences[name] = Samples + .Where(s => baseline.GetValueOrDefault(s) != theirs.GetValueOrDefault(s)) + .Select(s => $"{Describe(s),-16} General {baseline.GetValueOrDefault(s) ?? "(none)",-26} " + + $"{theirs.GetValueOrDefault(s) ?? "(none)"}") + .ToList(); + } + + output.WriteLine(""); + output.WriteLine($"departure from the General order of the SAME version, over {Samples.Length} samples:"); + foreach ((string name, List diff) in differences.OrderByDescending(d => d.Value.Count)) + output.WriteLine($" {name,-20} {baselines[name]} {diff.Count,4} differ" + + (diff.Count == 0 ? " <-- indistinguishable from General" : "")); + + foreach ((string name, List diff) in differences) + { + if (diff.Count == 0) continue; + output.WriteLine(""); + output.WriteLine($" {name} — {diff.Count} of {Samples.Length}:"); + foreach (string line in diff.Take(DetailLimit)) output.WriteLine($" {line}"); + if (diff.Count > DetailLimit) output.WriteLine($" … and {diff.Count - DetailLimit} more"); + } + } + + /// Per-fixture cap on printed detail — a version-1 order moves nearly every sample, and the + /// count in the summary is the interesting part for those. + private const int DetailLimit = 30; + + /// Dumps the sort-order field straight out of page 0, de-masked but otherwise unparsed, so a + /// value in the byte we do not model is visible rather than silently dropped. + private void Report(string label, string path) + { + if (!File.Exists(path)) { output.WriteLine($" {label,-20} missing"); return; } + + byte[] header = new byte[0x80]; + using (var stream = File.OpenRead(path)) stream.ReadExactly(header); + ReadOnlySpan mask = JetFormatBase.PageZeroHeaderMask; + int start = JetFormatBase.PageZeroHeaderMaskStart; + byte[] field = new byte[4]; + for (int i = 0; i < 4; i++) + field[i] = (byte)(header[JetFormatBase.CollationSortOrderOffset + i] ^ mask[JetFormatBase.CollationSortOrderOffset + i - start]); + + using var db = JetDatabase.Open(path); + Collation c = db.Collation; + output.WriteLine($" {label,-20} {Convert.ToHexString(field),-10} {(int)c.Order,-8} " + + $"0x{c.SortId:X2} {c.Version,-5} 0x{c.Lcid:X8} {c.Order}" + + $"{(Convert.ToHexString(field) == $"{c.Lcid & 0xFF:X2}{(c.Lcid >> 8) & 0xFF:X2}{c.SortId:X2}{c.Version:X2}" ? "" : " <-- field not fully accounted for")}"); + } + + /// Has ACE build and populate an indexed text column in a copy, then reads the stored keys back + /// with LibRed, mapped by the value that produced them. + private static Dictionary KeysFor(string source, string label, bool copy = true) + { + var keys = new Dictionary(); + string path = copy ? TemporaryDatabase.CopyPath(source, $"locale-{label.ToLowerInvariant()}-") : source; + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE CollProbe (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_CollProbe ON CollProbe (K)"); + for (int i = 0; i < Samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO CollProbe (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", Samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("CollProbe"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_CollProbe"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values)) + keys[(string?)values[keyColumn.Index] ?? ""] = Convert.ToHexString(stored); + return keys; + } + finally { if (copy) TemporaryDatabase.Delete(path); } + } + + private static string Describe(string s) => + s.All(c => c is >= ' ' and <= '~') ? $"\"{s}\"" : string.Concat(s.Select(c => $"U+{(int)c:X4}")); + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Core.Tests/SpanishCollationProbeTest.cs b/test/LibRed.Core.Tests/SpanishCollationProbeTest.cs new file mode 100644 index 00000000..8bf64ff7 --- /dev/null +++ b/test/LibRed.Core.Tests/SpanishCollationProbeTest.cs @@ -0,0 +1,147 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: what does a non-General sort order look like on disk, and how is a contraction encoded? +// +// Spanish Traditional treats "ch" and "ll" as single letters sorting after "c" and "l"; Spanish Modern (the +// 1994 reform) sorts them as the plain letter pairs. Access offers both, and they are otherwise the same +// locale — so diffing their index keys isolates *contraction* (several characters collapsing to one primary +// weight), the one primitive neither JetTextCollation nor JetTextCollationV1 implements. +// +// Two things are open before any encoder work: +// 1. Which LCID each is recorded as. DAO's enum has a single dbSortSpanish = 1034 (0x040A = Spanish +// Traditional). Modern is 0x0C0A = 3082 in Windows, which is not in the enum at all — so either ACE +// records 3082, or it records 1034 for both and distinguishes them with the sort-order *version* byte, +// the way it already does for General v0/v1. +// 2. Whether the digraphs and "n" take the primary weights left free by the v0 letter table. It steps by +// +2 almost everywhere, leaving 0x4E between C and D, 0x5F between L and M, and 0x63 between N and O. +// Gaps are the norm rather than a Spanish reservation, so landing on exactly those three would be a +// real result: it would make the compacted v0 table's gaps insertion slots for language letters. +public class SpanishCollationProbeTest(ITestOutputHelper output) +{ + // Single letters and digraphs read the weights directly; the words show the ordering they produce. + private static readonly string[] Samples = + [ + "c", "ch", "d", "l", "ll", "m", "n", "ñ", "o", + "C", "CH", "Ch", "L", "LL", "Ll", "N", "Ñ", + "cielo", "cuna", "chico", "danza", + "luna", "lupa", "llama", "mano", + "nube", "nuez", "ñu", "orilla", + ]; + + [Fact] + public void Probe_spanish_traditional_versus_modern() + { + foreach ((string label, string path) in Fixtures()) + ReportHeader(label, path); + + var keys = new Dictionary>(); + foreach ((string label, string path) in Fixtures()) + keys[label] = KeysFor(label, path); + + string[] labels = Fixtures().Select(f => f.Label).ToArray(); + output.WriteLine(""); + output.WriteLine("index keys (ACE-encoded, read back by LibRed):"); + output.WriteLine($" {"value",-10} {string.Join(" ", labels.Select(l => $"{l,-28}"))}"); + int differing = 0; + foreach (string sample in Samples) + { + string?[] row = labels.Select(l => keys[l].GetValueOrDefault(sample)).ToArray(); + bool same = row.All(k => k is not null && k == row[0]); + if (!same) differing++; + output.WriteLine($" {sample,-10} {string.Join(" ", row.Select(k => $"{k ?? "(none)",-28}"))}" + + (same ? "" : " <-- DIFFERS")); + } + + output.WriteLine(""); + output.WriteLine(differing == 0 + ? "=> identical keys throughout: the orders encode the same, so the difference is not in the keys." + : $"=> {differing} of {Samples.Length} samples encode differently across the {labels.Length} orders."); + } + + private static IEnumerable<(string Label, string Path)> Fixtures() + { + yield return ("Traditional", TestDatabases.SpanishTraditionalAccdb); + yield return ("Modern", TestDatabases.SpanishModernAccdb); + // The General (v0) baseline, so "n-with-tilde is a letter in Spanish" is read off bytes rather than + // inferred from JetTextCollation taking the decomposition path for it. + yield return ("General v0", TestDatabases.NorthwindAccdb); + } + + /// Reports the database-wide sort order and every text column's own collation, so a per-column + /// override would be visible rather than assumed away. + private void ReportHeader(string label, string path) + { + if (!File.Exists(path)) { output.WriteLine($"{label}: missing ({path})"); return; } + + using var db = JetDatabase.Open(path); + output.WriteLine($"{label}: LCID {db.DefaultCollationLcid} (0x{db.DefaultCollationLcid:X4}) " + + $"version {db.DefaultCollationVersion} [{db.Collation}]"); + + foreach (TableDef definition in db.Catalog.UserTables) + { + var text = definition.Columns + .Where(c => c.Type is JetDataType.Text or JetDataType.Memo) + .ToList(); + if (text.Count == 0) continue; + output.WriteLine($" table {definition.Name}: " + + string.Join(", ", text.Select(c => $"{c.Name} {c.Collation}"))); + } + } + + /// Builds an indexed text column through ACE — so ACE's own engine does the encoding — then reads + /// the stored keys back with LibRed, mapped by the value that produced them. + private Dictionary KeysFor(string label, string source) + { + var keys = new Dictionary(); + if (!File.Exists(source)) return keys; + + string path = TemporaryDatabase.CopyPath(source, $"spanish-{label.ToLowerInvariant()}-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE CollProbe (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_CollProbe ON CollProbe (K)"); + for (int i = 0; i < Samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO CollProbe (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", Samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + + using var select = connection.CreateCommand(); + select.CommandText = "SELECT K FROM CollProbe ORDER BY K"; + using var reader = select.ExecuteReader(); + var ordered = new List(); + while (reader.Read()) ordered.Add(reader.GetString(0)); + output.WriteLine($"{label} ORDER BY: {string.Join(" ", ordered)}"); + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("CollProbe"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_CollProbe"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values)) + keys[(string?)values[keyColumn.Index] ?? ""] = Convert.ToHexString(stored); + return keys; + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Core.Tests/TestDatabases.cs b/test/LibRed.Core.Tests/TestDatabases.cs index 525cc8fc..6d83baf6 100644 --- a/test/LibRed.Core.Tests/TestDatabases.cs +++ b/test/LibRed.Core.Tests/TestDatabases.cs @@ -3,6 +3,12 @@ namespace LibRed.Core.Tests; /// Paths to the real database files copied alongside the test assembly. internal static class TestDatabases { + /// The path to a checked-in fixture by file name — every Data\*.accdb is copied + /// alongside the test assembly. Use this for the sort-order fixtures, one per Access "New database sort + /// order" entry, rather than adding a property each. + public static string Data(string fileName) => + Path.Combine(AppContext.BaseDirectory, "Data", fileName); + /// An Access 2007 (ACE 12 / ACCDB) Northwind sample. public static string NorthwindAccdb { get; } = Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"); @@ -28,6 +34,16 @@ internal static class TestDatabases public static string EverythingIsBytesAccdb { get; } = Path.Combine(AppContext.BaseDirectory, "Data", "EverythingIsBytes.accdb"); + /// An Access-authored ACCDB using the Spanish Traditional sort order, where "ch" and "ll" are + /// single letters sorting after "c" and "l". + public static string SpanishTraditionalAccdb { get; } = + Path.Combine(AppContext.BaseDirectory, "Data", "SpanishTraditional.accdb"); + + /// An Access-authored ACCDB using the Spanish Modern sort order, which sorts "ch" and "ll" as + /// the plain letter pairs. Differs from in that alone. + public static string SpanishModernAccdb { get; } = + Path.Combine(AppContext.BaseDirectory, "Data", "SpanishModern.accdb"); + /// A password-encrypted ACCDB (Office Agile encryption; the password is "Test"). public static string EncryptedAccdb { get; } = Path.Combine(AppContext.BaseDirectory, "Data", "EncryptedTest.accdb"); From f7b9ab14cc526f4d53660d6768a21eca47114fd4 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 17:25:19 +0800 Subject: [PATCH 06/48] LibRed: implement the locale sort orders, including contraction JetLocaleTailoring keys per-locale overrides by STRING rather than character, so a contraction works: a digraph weighing as one letter is the inverse of the existing expansions, and the one primitive neither encoder had. Matching is greedy longest-first with no backtracking, and looks up the ORIGINAL text before the uppercased text, which is what lets Turkish disagree with invariant casing. Only Hungarian doubles, and that test must run before the longest match. Tailoring is not only insertion. Six devices, all inside the existing framing: insertion, contraction, expansion, secondary retune, remapping the base table, and reordering. Empty tailorings mean "measured to need no change", which is different from having none. Also fills in the General diacritic table and fixes the long s, which is a letter of its own rather than a fold onto s - found by testing each locale against a set far wider than its own tailoring. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Core/Catalog/Collation.cs | 40 ++- .../LibRed.Core/Storage/IndexKeyEncoder.cs | 13 +- .../LibRed.Core/Storage/JetLocaleTailoring.cs | 277 ++++++++++++++++++ .../LibRed.Core/Storage/JetTextCollation.cs | 67 ++++- .../docs/format/page-03-04-index-btree.md | 91 +++++- .../LibRed.Core.Tests/ContractionProbeTest.cs | 115 ++++++++ .../LocaleCollationAccessTests.cs | 145 +++++++++ .../TailoringGeneratorProbeTest.cs | 183 ++++++++++++ 8 files changed, 908 insertions(+), 23 deletions(-) create mode 100644 src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs create mode 100644 test/LibRed.Core.Tests/ContractionProbeTest.cs create mode 100644 test/LibRed.Core.Tests/LocaleCollationAccessTests.cs create mode 100644 test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs diff --git a/src/LibRed/LibRed.Core/Catalog/Collation.cs b/src/LibRed/LibRed.Core/Catalog/Collation.cs index e9f53114..f123771e 100644 --- a/src/LibRed/LibRed.Core/Catalog/Collation.cs +++ b/src/LibRed/LibRed.Core/Catalog/Collation.cs @@ -28,22 +28,38 @@ public enum CollatingOrder Czech = 1029, NorwegianDanish = 1030, Greek = 1032, // inert + German = 1031, // with sort id 1 = "German Phone Book" General = 1033, // English, German, French, Portuguese — the default Spanish = 1034, // Spanish Traditional: "ch" and "ll" are letters (DAO's dbSortSpanish) - SpanishModern = 3082, // The 1994 reform: "ch"/"ll" are letter pairs. No DAO name — it postdates the enum + French = 1036, Hebrew = 1037, // inert - Hungarian = 1038, + Hungarian = 1038, // with sort id 1 = "Hungarian Technical" Icelandic = 1039, Japanese = 1041, Korean = 1042, Dutch = 1043, // inert + Norwegian = 1044, // Access's "Norwegian/Danish" — note DAO's dbSortNorwDan is Danish 1030 instead Polish = 1045, + Romanian = 1048, Cyrillic = 1049, // inert + Croatian = 1050, + Slovak = 1051, SwedishFinnish = 1053, Thai = 1054, Turkish = 1055, + Ukrainian = 1058, Slovenian = 1060, + Estonian = 1061, + Latvian = 1062, + Lithuanian = 1063, + Vietnamese = 1066, + Macedonian = 1071, + Georgian = 1079, // with sort id 1 = "Georgian Modern" + Indic = 1081, ChineseSimplified = 2052, + Serbian = 2074, + SpanishModern = 3082, // The 1994 reform: "ch"/"ll" are letter pairs. No DAO name — it postdates the enum + Bosnian = 5146, } /// @@ -78,8 +94,20 @@ public readonly record struct Collation(CollatingOrder Order, byte Version, byte /// NLS weights directly rather than General-Legacy's compacted table; see JetTextCollationV1. public static Collation General => new(CollatingOrder.General, GeneralVersion); - /// Whether LibRed can encode index keys for this collation. Both General orders are implemented - /// (v0 via JetTextCollation, v1 via JetTextCollationV1); other locales are not — see the - /// format spec §10.4. - public bool IsIndexKeyEncodable => this == GeneralLegacy || this == General; + /// Whether LibRed can encode index keys for this collation: both General orders (v0 via + /// JetTextCollation, v1 via JetTextCollationV1), plus every locale with an entry in + /// JetLocaleTailoring. Anything else is refused rather than encoded with the English table — see + /// the format spec §10.4. + public bool IsIndexKeyEncodable + { + get + { + if (this == GeneralLegacy || this == General) return true; + var tailoring = Storage.JetLocaleTailoring.For(this); + if (tailoring is null) return false; + // The v1 encoder has no tailoring hook — its primaries are 2-byte NLS values, a different shape — + // so a version-1 order is encodable only where it was measured to need no tailoring at all. + return Version != GeneralVersion || tailoring.Entries.Count == 0; + } + } } diff --git a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs index 587db18b..95bfceb1 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs @@ -54,13 +54,16 @@ public static byte[] Encode(IReadOnlyList<(ColumnDef Column, bool Ascending)> co // produces byte-for-byte the key of its 255-character prefix. if (column.Type is JetDataType.Text or JetDataType.Memo) { - // Weights are implemented for the two General orders (v0 legacy, v1). Refuse anything else up - // front — a non-English locale — rather than emit wrong bytes with an English table. The - // collation is read per-column from the descriptor (0x0B–0x0E). + // Weights are implemented for the two General orders plus the locale tailorings in + // JetLocaleTailoring. Refuse anything else up front rather than emit wrong bytes with the + // English table — a wrong key does not fail, it silently disagrees with ACE's. The collation + // is read per-column from the descriptor (0x0B–0x0E). if (!column.Collation.IsIndexKeyEncodable) throw new NotSupportedException( $"Index key encoding for column '{column.Name}' uses collation {column.Collation.Order} " + - $"version {column.Collation.Version}, which is not implemented yet (only General is)."); + $"version {column.Collation.Version}" + + (column.Collation.SortId == 0 ? "" : $" sort id {column.Collation.SortId}") + + ", which is not implemented yet."); string text = (string)value; if (column.Type == JetDataType.Memo && text.Length > MemoKeyMaxChars) @@ -69,7 +72,7 @@ public static byte[] Encode(IReadOnlyList<(ColumnDef Column, bool Ascending)> co var ascendingKey = new List { IndexKeyFlags.AscStart }; bool encoded = column.Collation.Version == Collation.GeneralVersion ? JetTextCollationV1.TryEncode(text, ascendingKey) - : JetTextCollation.TryEncode(text, ascendingKey); + : JetTextCollation.TryEncode(text, ascendingKey, JetLocaleTailoring.For(column.Collation)); if (!encoded) throw new NotSupportedException( $"Text index key '{text}' contains a character with no weight in the {column.Collation.Order} " + diff --git a/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs new file mode 100644 index 00000000..c4eb2e87 --- /dev/null +++ b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs @@ -0,0 +1,277 @@ +using LibRed.Catalog; + +namespace LibRed.Storage; + +/// +/// The weights one tailoring entry contributes: the primary byte(s), and the secondary (diacritic) weight of +/// the first of them. A language letter typically takes a two-byte primary — a value from a gap in the +/// General letter table plus a sub-position ordering the letters that share that gap — and the default +/// secondary, because it is a letter in its own right rather than an accented one. Some entries carry a real +/// secondary as well (Croatian , Danish aa), and an expansion instead contributes several +/// ordinary primaries (German Phone Book's ä = a+e). +/// +internal readonly record struct TailoredWeight(byte[] Primaries, byte Secondary); + +/// +/// A locale's overrides on top of the General weights, keyed by the character or character sequence +/// they replace. A sort order other than General is General plus a small tailoring — no order measured +/// 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 +/// 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). +internal sealed class LocaleTailoring +{ + public LocaleTailoring(IReadOnlyDictionary entries, bool doublesDigraphs = false) + { + Entries = entries; + DoublesDigraphs = doublesDigraphs; + MaxLength = entries.Count == 0 ? 0 : entries.Keys.Max(k => k.Length); + } + + public IReadOnlyDictionary Entries { get; } + + public bool DoublesDigraphs { get; } + + /// Longest key in , bounding how far a match may look ahead. Derived in the + /// constructor, so it cannot fall out of step with the entries it describes — as it would if this were a + /// record whose with copy kept a stale value while the dictionary grew. + public int MaxLength { get; } + + /// + /// Matches the longest entry starting at , or the doubled-digraph form when the + /// locale uses one. Lookup is by the original text before the uppercased text: storing the + /// uppercase form is what folds case, and matching the original first is what lets a locale disagree + /// with invariant casing — which Turkish does, where I is the dotless letter and i is not + /// its lowercase. + /// + /// 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) + { + // Doubling is tested first, so it does not depend on whether the locale also tailors the single + // letter: "ggy" is g followed by the digraph gy, and weighs as that digraph twice. + if (DoublesDigraphs && start + 2 < text.Length && + char.ToUpperInvariant(text[start]) == char.ToUpperInvariant(text[start + 1]) && + TryLongest(text, start + 1, out weight, out consumed) && consumed >= 2) + { + consumed += 1; + repeat = true; + return true; + } + + repeat = false; + return TryLongest(text, start, out weight, out consumed); + } + + private bool TryLongest(ReadOnlySpan text, int start, out TailoredWeight weight, out int consumed) + { + for (int length = Math.Min(MaxLength, text.Length - start); length >= 1; length--) + { + ReadOnlySpan candidate = text.Slice(start, length); + if (Entries.TryGetValue(candidate.ToString(), out weight) || + Entries.TryGetValue(candidate.ToString().ToUpperInvariant(), out weight)) + { + consumed = length; + return true; + } + } + weight = default; + consumed = 0; + return false; + } +} + +/// +/// The locale tailorings LibRed can encode. Only orders expressible with the primitives here are listed; +/// one needing reordering (Thai folds a leading vowel with the consonant it precedes in writing) is +/// still unsupported, as are Ukrainian and Macedonian — single-character tailorings, but of Cyrillic +/// characters the General v0 table does not cover at all. +/// +/// +/// Every weight here was measured from ACE: an indexed text column built by ACE inside a database carrying +/// the order, with the stored index keys read back (ContractionProbeTest, +/// LocaleFixtureCollationProbeTest) and then asserted byte-for-byte against this encoder over the +/// whole of printable ASCII, Latin-1 and Latin Extended-A (LocaleCollationAccessTests). +/// +internal static class JetLocaleTailoring +{ + private const byte DefaultSecondary = 0x02; + + /// The tailoring for a collation, or null when it has none — either because it is General + /// itself, or because LibRed cannot express it. An empty tailoring is meaningful and not the same + /// as null: it records that the order was measured to be indistinguishable from General. + public static LocaleTailoring? For(Collation collation) => Tailorings.GetValueOrDefault(collation); + + private static readonly Dictionary Tailorings = new() + { + // --- Orders measured to be indistinguishable from General; recorded on disk, but no tailoring. --- + [new Collation(CollatingOrder.Georgian, 0, SortId: 1)] = Table([]), + [new Collation(CollatingOrder.Indic, Collation.GeneralVersion)] = Table([]), + + // --- Spanish Modern: General plus ñ as a letter of its own, between n (0x62) and o (0x64). --- + [new Collation(CollatingOrder.SpanishModern, 0)] = Table([ + ("Ñ", [0x63, 0x04])]), + + // --- Spanish Traditional: Modern plus the two digraphs the 1994 reform dropped. --- + [new Collation(CollatingOrder.Spanish, 0)] = Table([ + ("CH", [0x4E, 0x04]), ("LL", [0x5F, 0x04]), ("Ñ", [0x63, 0x04])]), + + // --- German Phone Book: the umlauts expand to the vowel plus e, exactly as ß expands to SS. --- + [new Collation(CollatingOrder.German, 0, SortId: 1)] = Table([ + ("Ä", [0x4A, 0x51]), // a + e + ("Ö", [0x64, 0x51]), // o + e + ("Ü", [0x6F, 0x51])]), // u + e + + // --- Polish: nine letters, each into the gap after its base. --- + [new Collation(CollatingOrder.Polish, 0)] = Table([ + ("Ą", [0x4B, 0x03]), ("Ć", [0x4E, 0x02]), ("Ę", [0x52, 0x02]), + ("Ł", [0x5F, 0x05]), ("Ń", [0x63, 0x03]), ("Ó", [0x65, 0x02]), + ("Ś", [0x6C, 0x07]), ("Ź", [0x79, 0x03]), ("Ż", [0x79, 0x04])]), + + // --- Romanian Legacy: only the cedilla forms move; â and the comma-below forms keep General's. --- + [new Collation(CollatingOrder.Romanian, 0)] = Table([ + ("Ă", [0x4B, 0x07]), ("Î", [0x5A, 0x03]), + ("Ş", [0x6C, 0x08]), ("Ţ", [0x6E, 0x06])]), + + // --- Turkish: six letters, plus the dotted/dotless i, where the locale disagrees with invariant + // casing. Uppercase I is the DOTLESS letter and sorts before i; dotted İ folds onto plain i with + // no secondary at all, where General gives it 0x10. Both cases are listed explicitly so the + // original-text lookup wins before invariant uppercasing can conflate them. --- + [new Collation(CollatingOrder.Turkish, 0)] = Table([ + ("Ç", [0x4E, 0x03]), ("Ğ", [0x56, 0x02]), ("Ö", [0x65, 0x02]), + ("Ş", [0x6C, 0x07]), ("Ü", [0x70, 0x03]), + ("ı", [0x58, 0x06]), ("I", [0x58, 0x06]), + ("i", [0x59]), ("İ", [0x59]), + // The IJ ligature expands, and follows the locale's casing: uppercase onto the dotless I, + // lowercase onto the dotted i. + ("IJ", [0x58, 0x06, 0x5B]), ("ij", [0x59, 0x5B])]), + + // --- Czech: ch is a letter between h and i, not after c; and the diaeresis is retuned from the + // General 0x13 to 0x05, which is a change to the accent rather than to any letter. --- + [new Collation(CollatingOrder.Czech, 0)] = Table( + [("CH", [0x58, 0x03]), ("Č", [0x4E, 0x03]), ("Ř", [0x6A, 0x04]), ("Š", [0x6C, 0x07]), + ("Ž", [0x79, 0x05])], + [("Ä", [0x4A], 0x05), ("Ë", [0x51], 0x05), ("Ï", [0x59], 0x05), ("Ö", [0x64], 0x05), + ("Ü", [0x6F], 0x05), ("Ÿ", [0x76], 0x05), ("Ċ", [0x4D], 0x04), ("Ė", [0x51], 0x04), + ("Ġ", [0x55], 0x04), ("İ", [0x59], 0x04), ("Ŀ", [0x5E], 0x04), ("Ż", [0x78], 0x04)]), + + // --- Slovak: Czech's ch and letters, with ä and ô of its own. --- + [new Collation(CollatingOrder.Slovak, 0)] = Table( + [("CH", [0x58, 0x03]), ("Ä", [0x4B, 0x02]), ("Ô", [0x65, 0x02]), ("Č", [0x4E, 0x03]), + ("Ř", [0x6A, 0x04]), ("Š", [0x6C, 0x07]), ("Ž", [0x79, 0x05])], + [("Ë", [0x51], 0x05), ("Ï", [0x59], 0x05), ("Ö", [0x64], 0x05), ("Ü", [0x6F], 0x05), + ("Ċ", [0x4D], 0x04), ("Ė", [0x51], 0x04), ("Ġ", [0x55], 0x04), ("İ", [0x59], 0x04), + ("Ŀ", [0x5E], 0x04), ("Ÿ", [0x76], 0x05), ("Ż", [0x78], 0x04)]), + + // --- Croatian Legacy: three digraphs, and dž carries a real secondary of its own. --- + [new Collation(CollatingOrder.Croatian, 0)] = Table( + [("LJ", [0x5F, 0x03]), ("NJ", [0x63, 0x04]), ("Ć", [0x4E, 0x03]), ("Č", [0x4E, 0x02]), + ("Đ", [0x50, 0x05]), ("Š", [0x6C, 0x07]), ("Ž", [0x79, 0x05])], + [("DŽ", [0x50, 0x04], 0x04), + ("Ă", [0x4A], 0x05), ("Ď", [0x4F], 0x04), ("Ĕ", [0x51], 0x05), ("Ě", [0x51], 0x04), + ("Ğ", [0x55], 0x05), ("Ĭ", [0x59], 0x05), ("Ľ", [0x5E], 0x04), ("Ň", [0x62], 0x04), + ("Ŏ", [0x64], 0x05), ("Ř", [0x69], 0x04), ("Ť", [0x6D], 0x04), ("Ŭ", [0x6F], 0x05)]), + + // --- Slovenian: the same letters as Croatian, at different sub-positions. --- + [new Collation(CollatingOrder.Slovenian, 0)] = Table( + [("Ć", [0x4E, 0x10]), ("Č", [0x4E, 0x0F]), ("Đ", [0x50, 0x07]), ("Ś", [0x6C, 0x08]), + ("Š", [0x6C, 0x07]), ("Ź", [0x79, 0x05]), ("Ž", [0x79, 0x04])]), + + // --- Norwegian/Danish: æ ø å after z; "aa" weighs as å with a secondary marking the spelling, and + // ä/ö are æ/ø with an umlaut while ü rides on y. --- + [new Collation(CollatingOrder.Norwegian, 0)] = Table( + [("Æ", [0x79, 0x04]), ("Ø", [0x79, 0x06]), ("Å", [0x79, 0x09])], + [("AA", [0x79, 0x09], 0x03), + ("Ä", [0x79, 0x04], 0x13), ("Ö", [0x79, 0x06], 0x13), ("Ü", [0x76], 0x7B), + ("Ő", [0x79, 0x06], 0x1B), ("Ű", [0x76], 0x1B)]), + + // --- Swedish/Finnish: å ä ö after z, w is a variant of v, and ü rides on y. --- + [new Collation(CollatingOrder.SwedishFinnish, 0)] = Table( + [("Ä", [0x79, 0x07]), ("Å", [0x79, 0x05]), ("Ö", [0x79, 0x08])], + [("W", [0x71], 0x03), ("Ŵ", [0x71], 0x12), ("Ø", [0x79, 0x08], 0x1E), + ("Ü", [0x76], 0x7B), ("Ő", [0x79, 0x08], 0x1B), ("Ű", [0x76], 0x1B)]), + + // --- Icelandic: the accented vowels are letters, and þ æ ö close the alphabet after z. --- + [new Collation(CollatingOrder.Icelandic, 0)] = Table( + [("Á", [0x4B, 0x03]), ("Æ", [0x79, 0x04]), ("É", [0x52, 0x02]), ("Í", [0x5A, 0x02]), + ("Ð", [0x50, 0x02]), ("Ó", [0x65, 0x02]), ("Ö", [0x79, 0x05]), ("Ú", [0x70, 0x02]), + ("Ý", [0x77, 0x02]), ("Þ", [0x79, 0x03])], + [("Ø", [0x79, 0x05], 0x1E)]), + + // --- Estonian: the most radical of these. It rewrites the base alphabet rather than extending it — + // z moves between s and t, v moves down, and õ and ö take over the bare one-byte primaries + // General uses for v and w. --- + [new Collation(CollatingOrder.Estonian, 0)] = Table( + [("V", [0x70, 0x03]), ("Z", [0x6C, 0x07]), ("Ä", [0x72, 0x02]), ("Õ", [0x71]), + ("Ö", [0x73]), ("Ü", [0x74, 0x02]), ("Š", [0x6C, 0x06]), ("Ž", [0x6C, 0x08])], + [("W", [0x70, 0x03], 0x03), ("Ź", [0x6C, 0x07], 0x0E), ("Ż", [0x6C, 0x07], 0x10), + // 0x6C is Estonian's š/z/ž slot, so the long s cannot live there as it does in General; it + // falls back onto s with a secondary. Lowercase-only, so it is matched as itself. + ("ſ", [0x6B], 0x03)]), + + // --- Latvian: seven letters, and the widest sub-positions seen (ķ at 0x12, ņ at 0x0C). --- + [new Collation(CollatingOrder.Latvian, 0)] = Table( + [("Č", [0x4E, 0x02]), ("Ģ", [0x56, 0x02]), ("Ķ", [0x5D, 0x12]), ("Ļ", [0x5F, 0x02]), + ("Ņ", [0x63, 0x0C]), ("Š", [0x6C, 0x07]), ("Ž", [0x79, 0x03])]), + + // --- Lithuanian: y follows i rather than closing the alphabet, and the ogonek letters stay + // secondaries but at 0x0F instead of General's 0x1B. --- + [new Collation(CollatingOrder.Lithuanian, 0)] = Table( + [("Y", [0x5A, 0x02])], + [("Ą", [0x4A], 0x0F), ("Ę", [0x51], 0x0F), ("Į", [0x59], 0x0F), ("Ų", [0x6F], 0x0F)]), + + // --- Vietnamese: nine digraphs, and p and r shift to make room. Note "gh" and "ngh" are NOT letters + // — they fall out of greedy matching as g+h and ng+h, which is what ACE stores. --- + [new Collation(CollatingOrder.Vietnamese, 0)] = Table( + [("CH", [0x4E, 0x04]), ("GI", [0x56, 0x02]), ("KH", [0x5D, 0x02]), ("NG", [0x63, 0x02]), + ("NH", [0x63, 0x03]), ("PH", [0x67, 0x03]), ("QU", [0x69]), ("TH", [0x6E, 0x02]), + ("TR", [0x6E, 0x03]), + ("P", [0x67, 0x02]), ("R", [0x6A, 0x02]), ("Â", [0x4B, 0x02]), + ("Ê", [0x52, 0x02]), ("Ô", [0x65, 0x02]), ("Ă", [0x4B, 0x03]), ("Đ", [0x50, 0x02])]), + + // --- Hungarian: the full digraph set, the only order that doubles them, plus ö and ü as letters. --- + [new Collation(CollatingOrder.Hungarian, 0)] = Table( + [("CS", [0x4E, 0x05]), ("DZ", [0x50, 0x03]), ("DZS", [0x50, 0x05]), ("GY", [0x56, 0x03]), + ("LY", [0x5F, 0x05]), ("NY", [0x63, 0x06]), ("SZ", [0x6C, 0x08]), ("TY", [0x6E, 0x06]), + ("ZS", [0x79, 0x09]), ("Ö", [0x65, 0x02]), ("Ü", [0x70, 0x03])], + [("Ő", [0x65, 0x02], 0x1B), ("Ű", [0x70, 0x03], 0x1B)], + doublesDigraphs: true), + + // --- Hungarian Technical: NOT a digraph order at all, despite the name suggesting a variant of the + // one above. It tailors 46 individual letters — plain g becomes 0x56 03, so "gy" is that g + // followed by an ordinary y rather than a contraction. --- + [new Collation(CollatingOrder.Hungarian, 0, SortId: 1)] = Table( + [("F", [0x56, 0x02]), ("G", [0x56, 0x03]), ("P", [0x67, 0x04]), ("V", [0x73]), + ("W", [0x74, 0x02]), ("Á", [0x4B, 0x02]), ("Â", [0x4B, 0x03]), ("Ä", [0x4B, 0x04]), + ("Ç", [0x4E, 0x02]), ("É", [0x52, 0x02]), ("Ë", [0x53]), ("Í", [0x5A, 0x02]), + ("Î", [0x5A, 0x03]), ("Ó", [0x65, 0x02]), ("Ô", [0x66]), ("Ö", [0x67, 0x02]), + ("Ú", [0x70, 0x02]), ("Ü", [0x70, 0x03]), ("Ý", [0x77, 0x02]), ("ß", [0x6C, 0x05]), + ("Ă", [0x4B, 0x05]), ("Ą", [0x4B, 0x06]), ("Ć", [0x4E, 0x03]), ("Č", [0x4E, 0x04]), + ("Ď", [0x50, 0x02]), ("Đ", [0x50, 0x03]), ("Ę", [0x54, 0x02]), ("Ě", [0x55]), + ("Ĺ", [0x5F, 0x02]), ("Ľ", [0x5F, 0x03]), ("Ł", [0x5F, 0x04]), ("Ń", [0x63, 0x02]), + ("Ň", [0x63, 0x03]), ("Ő", [0x67, 0x03]), ("Ŕ", [0x6A, 0x02]), ("Ř", [0x6A, 0x03]), + ("Ś", [0x6C, 0x02]), ("Ş", [0x6C, 0x03]), ("Š", [0x6C, 0x04]), ("Ţ", [0x6E, 0x02]), + ("Ť", [0x6E, 0x03]), ("Ů", [0x71]), ("Ű", [0x72, 0x02]), ("Ź", [0x79, 0x02]), + ("Ż", [0x79, 0x03]), ("Ž", [0x79, 0x04])]), + }; + + /// Builds a tailoring. take the default secondary — they are letters + /// in their own right; carry one of their own, which is how a locale retunes + /// a diacritic (Czech moves the diaeresis from 0x13 to 0x05) or marks a spelling (Danish + /// aa is å with secondary 0x03). + private static LocaleTailoring Table( + (string Text, byte[] Primaries)[] letters, + (string Text, byte[] Primaries, byte Secondary)[]? accented = null, + bool doublesDigraphs = false) + { + var table = new Dictionary(StringComparer.Ordinal); + foreach ((string text, byte[] primaries) in letters) + table[text] = new TailoredWeight(primaries, DefaultSecondary); + foreach ((string text, byte[] primaries, byte secondary) in accented ?? []) + table[text] = new TailoredWeight(primaries, secondary); + return new LocaleTailoring(table, doublesDigraphs); + } +} diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs index 0fd7d3a7..6d9ed8ec 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs @@ -37,11 +37,17 @@ internal static class JetTextCollation { ['́'] = 0x0E, // acute ['̀'] = 0x0F, // grave + ['̇'] = 0x10, // dot above ['̂'] = 0x12, // circumflex ['̈'] = 0x13, // diaeresis / umlaut + ['̌'] = 0x14, // caron / háček + ['̆'] = 0x15, // breve + ['̄'] = 0x17, // macron ['̃'] = 0x19, // tilde ['̊'] = 0x1A, // ring above + ['̨'] = 0x1B, // ogonek ['̧'] = 0x1C, // cedilla + ['̋'] = 0x1D, // double acute }; // Atomic accented letters that have no Unicode canonical decomposition: base letter + secondary weight. @@ -50,12 +56,37 @@ internal static class JetTextCollation { ['Ø'] = ('O', 0x21), ['Ð'] = ('D', 0x68), + // A stroke through the letter is its own diacritic weight — 0x1E on D and H, 0x1F on L. + ['Đ'] = ('D', 0x1E), + ['Ħ'] = ('H', 0x1E), + ['Ł'] = ('L', 0x1F), + ['Ŀ'] = ('L', 0x11), // L with middle dot + ['ĸ'] = ('K', 0x03), // kra; has no uppercase, so it is matched as itself + ['ʼn'] = ('N', 0x48), // n preceded by apostrophe; likewise has no uppercase // Ordinal indicators: the base letter's primary with a distinguishing secondary, so they sort beside // 'a'/'o' rather than with the symbols. Harvested from ACE (7F 4A 01 03 00 / 7F 64 01 03 00). ['ª'] = ('A', 0x03), ['º'] = ('O', 0x03), }; + // Letters that are NOT an A–Z fold: they carry a primary of their own. Looked up by the original + // character, because invariant uppercasing would send them to the base letter and lose the distinction. + private static readonly Dictionary ExtraLetters = new() + { + // U+017F LATIN SMALL LETTER LONG S. ACE gives it its own two-byte primary in the S–T gap rather + // than folding it onto 's' — verified against ACE in every v0 order, General included. + ['ſ'] = new([0x6C, 0x06], DefaultSecondary), + // U+0131 DOTLESS I: the letter i's primary with a secondary of its own. It has to be matched on the + // original character, because invariant uppercasing turns it into a plain 'I'. + ['ı'] = new([0x59], 0x03), + // U+014A ENG, in the N–O gap. + ['Ŋ'] = new([0x63, 0x05], DefaultSecondary), + // U+0166 T WITH STROKE: a two-byte primary that also carries the stroke as a secondary. + ['Ŧ'] = new([0x6E, 0x06], 0x1E), + // U+00A0 NO-BREAK SPACE: a two-byte primary rather than the ordinary space's 0x07. + [' '] = new([0x08, 0x02], DefaultSecondary), + }; + // Letters that sort as a multi-letter expansion (each expanded letter weighs its normal primary, no // accent). Verified against ACE: ß = SS, Þ/þ = TH, Æ = AE. private static readonly Dictionary Expansions = new() @@ -63,6 +94,8 @@ internal static class JetTextCollation ['Æ'] = "AE", ['ß'] = "SS", ['Þ'] = "TH", + ['IJ'] = "IJ", + ['Œ'] = "OE", }; // Primary weight for 'A'..'Z' (general collation; mostly +2 with a few +1 steps). @@ -109,7 +142,9 @@ internal static class JetTextCollation /// codes, and the terminator). Trailing spaces are dropped. Returns false if any character is /// not yet supported. /// - public static bool TryEncode(string value, List output) + /// 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) { ReadOnlySpan s = value.AsSpan().TrimEnd(' '); @@ -121,14 +156,28 @@ public static bool TryEncode(string value, List output) // multi-byte expansion like ß→SS counts as 2 — verified against ACE). var inline = new List<(int Position, byte Code)>(); - foreach (char c in s) + // Indexed rather than foreach, because a tailoring entry can consume several characters: a + // contraction is a digraph weighing as one letter (Czech "ch", Hungarian "gy", Danish "aa"). + for (int position = 0; position < s.Length; position++) { + char c = s[position]; char u = char.ToUpperInvariant(c); if (u == '\'') { inline.Add((primaries.Count, ApostropheCode)); continue; } if (u == '-') { inline.Add((primaries.Count, HyphenCode)); continue; } if (u == '­') { inline.Add((primaries.Count, SoftHyphenCode)); continue; } // soft hyphen - if (u is >= 'A' and <= 'Z') + // A locale tailoring overrides everything below it. + if (tailoring is not null && + tailoring.TryMatch(s, position, out TailoredWeight tailored, out int consumed, out bool repeat)) + { + for (int emit = repeat ? 2 : 1; emit > 0; emit--) + AddWeight(tailored.Primaries, tailored.Secondary); + position += consumed - 1; + } + else if (ExtraLetters.TryGetValue(c, out TailoredWeight own) || + ExtraLetters.TryGetValue(u, out own)) + AddWeight(own.Primaries, own.Secondary); + else if (u is >= 'A' and <= 'Z') Add(Letters[u - 'A']); else if (u is >= '0' and <= '9') Add((byte)(0x36 + 2 * (u - '0'))); @@ -136,7 +185,7 @@ public static bool TryEncode(string value, List output) // letters, and it corrupts some symbols — char.ToUpperInvariant('µ') is GREEK CAPITAL LETTER MU, // which is not what ACE weighs it as (ACE gives it a symbol weight in the 0x34 group). else if (Symbols.TryGetValue(c, out byte[]? weights) || Symbols.TryGetValue(u, out weights)) - foreach (byte w in weights) Add(w); + AddWeight(weights, DefaultSecondary); else if (!TryAddAccented(u, Add)) return false; // not handled yet } @@ -172,6 +221,16 @@ void Add(byte primary, byte secondary = DefaultSecondary) primaries.Add(primary); secondaries.Add(secondary); } + + // A primary WEIGHT may be one or two bytes, and the secondary section has one entry per weight — + // not per byte. Measured against ACE: Norwegian "ö" is 7F 79 06 01 13 00, two primary bytes and a + // single secondary. (The inline apostrophe/hyphen section counts differently, by primary *bytes* — + // hence `primaries.Count` there rather than `secondaries.Count`.) + void AddWeight(ReadOnlySpan weight, byte secondary) + { + foreach (byte b in weight) primaries.Add(b); + secondaries.Add(secondary); + } } /// Emits the primary+secondary weight(s) for an accented or special Latin-1 letter (uppercased): 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 418e02a1..d2b53f3a 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -193,19 +193,43 @@ Then the value, transformed: > produce. Ligatures need no special handling either — NLS itself expands `fi` to `f` + `i`. (Probed in > `SortKeyComparisonProbeTest`.) + **The long s `ſ` (U+017F) is a letter of its own**, not a fold onto `s`: it takes the two-byte primary + `6C 06` — the S–T gap — in **every** v0 order measured, General included (`LocaleCollationAccessTests`). + Uppercasing it invariantly gives `S`, so it has to be matched on the original character or the + distinction is lost. + **A few letters expand to multiple base letters** (each expanded letter weighs its normal primary, no accent): `ß`→`SS`, `Þ`/`þ`→`TH`, `Æ`→`AE` — verified against ACE (`ß` = `7F 6B 6B 01 00`, same as `SS`). Because the ignorable-position count is by primary byte, an expansion counts as its expanded length (above). + **Diacritic secondary weights**, each depending only on the mark and not the base letter — derived from + ACE by `TailoringGeneratorProbeTest`: acute `0x0E`, grave `0x0F`, **dot above `0x10`**, circumflex `0x12`, + diaeresis `0x13`, **caron `0x14`**, **breve `0x15`**, **macron `0x17`**, tilde `0x19`, ring `0x1A`, + **ogonek `0x1B`**, cedilla `0x1C`, **double acute `0x1D`**. Atomic letters that do not decompose carry one + directly: `Ø`→`O`+`0x21`, `Ð`→`D`+`0x68`, **stroke** `Đ`→`D`+`0x1E`, `Ħ`→`H`+`0x1E`, `Ł`→`L`+`0x1F`, + `Ŀ`→`L`+`0x11`, `ĸ`→`K`+`0x03`, `ʼn`→`N`+`0x48`. **Expansions**: `Æ`→`AE`, `ß`→`SS`, `Þ`→`TH`, `IJ`→`IJ`, + `Œ`→`OE`. **Own primaries**: `ſ` `6C 06`, `ŋ` `63 05`, `ŧ` `6E 06`+`0x1E`, `ı` `59`+`0x03`, and + NBSP `08 02` (against the ordinary space's `0x07`). + **Accented Latin-1 letters** sort with their **base letter's primary weight** and record the accent in a **secondary section**. Each character has a secondary weight (default `0x02`); an accented letter carries the weight of its diacritic instead (verified against ACE, and the weight depends only on the accent, not the base letter): **acute `0x0E`, grave `0x0F`, circumflex `0x12`, diaeresis/umlaut `0x13`, tilde `0x19`, ring `0x1A`, cedilla `0x1C`**; plus atomic `Ø`→base `O`+`0x21`, - `Ð`→base `D`+`0x68`, and the ligature `Æ`→primaries `A E` (no accent). The section is emitted only - when some character is accented: after the primary's `0x01` end marker it lists the secondary weight - of **every byte from the first up to and including the last accented one**, e.g. `México D.F.` → + `Ð`→base `D`+`0x68`, and the ligature `Æ`→primaries `A E` (no accent). + + > **The secondary section has one entry per primary *weight*, not per primary *byte*.** A weight may be + > one byte or two, and a two-byte weight still takes a single slot — Norwegian `ö` is + > `7F 79 06 01 13 00`: two primary bytes, one secondary. This only becomes visible once two-byte primaries + > and accents appear together, which is why it surfaced with the locale tailorings + > (`Ångström` in Norwegian, where `å` and `ö` are both two-byte). Note the contrast with the **inline** + > apostrophe/hyphen section below, which counts primary **bytes** — the two sections index differently. + > An expansion is several *weights* (`ß`→`SS` is two one-byte weights), so it takes two slots. + + The section is emitted only when some character is accented: after the primary's `0x01` end marker it + lists the secondary weight of **every weight from the first up to and including the last accented one**, + e.g. `México D.F.` → `7F 60 51 75 59 4D 64 07 4F 1C 53 1C 01 02 0E 00` (é = primary `0x51` = E, secondary `0x0E`), and `Montréal` (é at position 5) → `… 01 02 02 02 02 02 0E 00`. LibRed decomposes via Unicode NFD (base letter + combining mark) plus the small atomic table above; `JetTextCollation` reproduces these keys @@ -288,8 +312,15 @@ Then the value, transformed: - **Tailoring is not only insertion.** Five other devices appear, all within the existing framing: - **Contraction** — two characters, one primary. The Spanish and Croatian digraphs above; Hungarian's - full set (`cs` `4E 05`, `gy` `56 03`, `ny` `63 06`, `sz` `6C 08`, `zs` `79 09`, `ty` `6E 06`), including - the doubling rule: `ggy` = `56 03 56 03` and `ccs` = `4E 05 4E 05`, each half weighing as the digraph. + full set (`cs` `4E 05`, `gy` `56 03`, `ny` `63 06`, `sz` `6C 08`, `zs` `79 09`, `ty` `6E 06`). + + Matching is **greedy longest-first**, left to right, and does not backtrack: Hungarian `dzs` is the + three-character letter `50 05`, not `dz`+`s`; Spanish `lll` is `ll`+`l` (`5F 04 5E`) and `llll` is + `ll`+`ll`. **Only Hungarian doubles** — a doubled digraph is written by doubling its first letter, so + `ggy` is `gy`+`gy` (`56 03 56 03`) and `ssz` is `sz`+`sz`, while Czech `cch` is plainly `c`+`ch` + (`4D 58 03`) and Spanish `cch` is `c`+`ch`. Doubling has to be tested *before* the plain longest match, + or `ggy` degrades to `g`+`gy`. A contraction can carry a secondary of its own: Croatian `dž` is + `50 04` with secondary `04`, Danish `aa` is `å`'s primary with secondary `03`. - **Expansion** — one character, several primaries. German Phone Book: `ä` = `7F 4A 51 01 00`, primaries `a`+`e` (General has `a` + umlaut secondary); likewise `ö`→`o`+`e`, `ü`→`u`+`e`. Same primitive as `ß`→`SS` above, so it needs no new machinery. @@ -353,9 +384,53 @@ Then the value, transformed: expansions above. `chico` is `7F 4E 04 59 4D 64 01 00` — five characters, four primaries. Case folds as usual, so `ch`, `Ch` and `CH` share a key. - LibRed **reads** these databases; `Collation.IsIndexKeyEncodable` is false for any locale other than - General v0/v1, so it refuses to write their index keys rather than writing wrong ones. Neither encoder - implements contraction. + **LibRed implements the tailorings whose every difference is a single character** — `JetLocaleTailoring`, + a per-locale `char` → primaries override consulted ahead of the General tables, looked up by the *original* + character before the uppercased one (which is what lets Turkish disagree with invariant casing, where `I` + is the dotless letter). Implemented and asserted byte-for-byte against ACE over 345 values — the whole of + printable ASCII, Latin-1 and Latin Extended-A, plus words (`LocaleCollationAccessTests`): + + | order | tailoring | + |---|---| + | Spanish Modern | `ñ` | + | Spanish Traditional | `ñ`, and the digraphs `ch` `ll` | + | German Phone Book | `ä ö ü` as expansions | + | Romanian Legacy | `ă î ş ţ` | + | Turkish | `ç ğ ö ş ü`, `ı`/`I` dotless and `İ`/`i` dotted, and the `ij` ligature following that casing | + | Polish | `ą ć ę ł ń ó ś ź ż` | + | Czech | the digraph `ch`, four letters, and twelve accent retunes (the diaeresis moves `0x13`→`0x05`) | + | Slovak | Czech's `ch`, plus `ä ô` of its own | + | Croatian Legacy | the digraphs `lj nj dž`, five letters, twelve accent retunes | + | Slovenian | Croatian's letters at different sub-positions | + | Norwegian/Danish | `æ ø å`, the contraction `aa`→`å`, and `ä ö ü ő ű` riding on them | + | Swedish/Finnish | `å ä ö`, `w` as a variant of `v`, `ü` on `y` | + | Icelandic | ten letters; `þ æ ö` close the alphabet after `z` | + | Estonian | rewrites the base alphabet — see above | + | Latvian | seven letters, the widest sub-positions seen (`ķ` at `0x12`) | + | Lithuanian | `y` after `i`, and the ogonek retuned `0x1B`→`0x0F` | + | Vietnamese | nine digraphs, and `p r` shifted to make room | + | Hungarian | the nine digraphs, doubling, and `ö ü ő ű` | + | Hungarian Technical | 46 individual letters and **no digraphs at all** | + | Georgian Modern, Indic | *empty* — measured to be indistinguishable from General | + + An **empty** tailoring is meaningful and different from none: it says the order was measured to need no + change, so the order can be encoded rather than refused. + + > **"Technical" is not a variant of the digraph order.** Hungarian Technical tailors plain `g` to `56 03`, + > so its `gy` is that tailored `g` followed by an ordinary `y` — not a contraction. It is the largest + > single-character tailoring measured and contains no multi-character entry. + + > **A single-character sweep cannot find a digraph.** Vietnamese looked like a single-character order until + > `Ångström` came out three weights short: `ng` and `tr` each weigh as one letter. Its set is + > `ch gi kh ng nh ph qu th tr` — and note `gh` and `ngh` are *not* letters, they fall out of greedy + > matching as `g`+`h` and `ng`+`h`, which is exactly what ACE stores. + + Everything else stays refused — `Collation.IsIndexKeyEncodable` gates on it, because a wrong key is silent. + What remains: **Thai** needs reordering; **Bosnian, Croatian and Serbian at version 1** need the v1 encoder + to grow a tailoring hook (its primaries are 2-byte NLS values, a different shape); **Ukrainian and + Macedonian** need the **Cyrillic block in the General table first**, which v0 does not have; and **French** + is unclassified, its tailoring being in the secondary section where single-character samples do not + exercise it. *Not yet handled:* characters outside ASCII + the accented Latin-1 set above (and a key mixing an accent with an ignorable apostrophe/hyphen is untested); every locale other than General (above). diff --git a/test/LibRed.Core.Tests/ContractionProbeTest.cs b/test/LibRed.Core.Tests/ContractionProbeTest.cs new file mode 100644 index 00000000..48de7e5d --- /dev/null +++ b/test/LibRed.Core.Tests/ContractionProbeTest.cs @@ -0,0 +1,115 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: exactly how a contraction is encoded, before implementing one. +// +// A contraction is several characters weighing as one letter. Ten sort orders need it and nothing else, so +// it is the single primitive that unlocks the most. But the summary diff left one thing unreconciled: +// Hungarian "ny" is a clean single primary (63 06, no trailing y) while "gy" looked like 56 03 76 - a +// two-byte primary AND a trailing y. Either the digraph set is not uniform, or that reading was wrong. +// +// So: for each order, the component letters on their own, every digraph, the doubled forms (Hungarian writes +// a doubled digraph by doubling only its first letter - "ggy" is "gy"+"gy", not "g"+"gy"), and real words. +// Printed in full, no capping, so the structure is visible rather than inferred. +public class ContractionProbeTest(ITestOutputHelper output) +{ + private static readonly (string Fixture, string[] Samples)[] Cases = + [ + ("Hungarian", [ + "c", "s", "z", "d", "g", "y", "n", "t", "l", + "cs", "dz", "dzs", "gy", "ly", "ny", "sz", "ty", "zs", + "ccs", "ddz", "ggy", "lly", "nny", "ssz", "tty", "zzs", + "cukor", "csak", "gyar", "nagy", "meggy", "asszony", "gy", "gz", "gyy", + ]), + ("Czech", [ + "c", "h", "s", "z", "r", "ch", "cch", "chch", "hc", + "cukr", "chata", "hodina", "chch", + ]), + ("CroatianLegacy", [ + "d", "z", "l", "n", "j", "dz", "dž", "lj", "nj", "ddž", "llj", "nnj", "dzz", + "ljubav", "njegov", "džem", + ]), + ("SpanishTraditional", [ + "c", "h", "l", "ch", "ll", "cch", "lll", "chh", "llll", + "chico", "llama", "coche", "calle", + ]), + // Vietnamese turned out to be a digraph order too — "Ångström" showed "ng" and "tr" each weighing as + // one letter, which a single-character sweep could never have revealed. + ("Vietnamese", [ + "c", "g", "h", "i", "k", "n", "p", "q", "t", "u", "r", + "ch", "gh", "gi", "kh", "ng", "ngh", "nh", "ph", "qu", "th", "tr", + "nng", "ngg", "ngstr", "nghi", "nghe", "nga", "nhe", "quy", "tre", "thu", + ]), + ("NorwegianDanish", [ + "a", "aa", "aaa", "å", "aab", "ab", "baa", "Aa", "AA", + // Where does the secondary land relative to a TWO-BYTE primary? "å" and "æ" are two-byte + // primaries with a default secondary; "ö" is a two-byte primary carrying 0x13. Vary how many of + // each precede the accented one, and the index the accent lands on tells us what the section + // counts: characters, primary weights, or primary bytes. + "ö", "bö", "öb", "bbö", "aö", "åö", "ååö", "æö", "aaö", "bäb", "Ångström", "ånö", "nåö", + ]), + ]; + + [Fact] + public void Probe_how_contractions_encode() + { + foreach ((string fixture, string[] samples) in Cases) + { + string source = TestDatabases.Data($"{fixture}.accdb"); + if (!File.Exists(source)) { output.WriteLine($"{fixture}: missing"); continue; } + + string path = TemporaryDatabase.CopyPath(source, $"contraction-{fixture.ToLowerInvariant()}-"); + try + { + Dictionary keys = AceKeys(path, samples); + using var db = JetDatabase.Open(path); + output.WriteLine(""); + output.WriteLine($"{fixture} — {db.Collation.Order} v{db.Collation.Version}:"); + foreach (string sample in samples.Distinct()) + output.WriteLine($" {sample,-10} {keys.GetValueOrDefault(sample) ?? "(refused)"}"); + } + finally { TemporaryDatabase.Delete(path); } + } + } + + private static Dictionary AceKeys(string path, string[] samples) + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE Contr (K TEXT(60), V LONG)"); + Exec(connection, "CREATE INDEX IX_Contr ON Contr (K)"); + int i = 0; + foreach (string sample in samples.Distinct()) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Contr (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", sample); + insert.Parameters.AddWithValue("v", i++); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("Contr"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_Contr"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + var keys = new Dictionary(); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values) && values[keyColumn.Index] is string text) + keys[text] = Convert.ToHexString(stored); + return keys; + } + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs b/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs new file mode 100644 index 00000000..dfdbc03c --- /dev/null +++ b/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs @@ -0,0 +1,145 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// Conformance: for every locale sort order LibRed claims to encode, its index keys must be byte-identical to +// the ones ACE writes in a database carrying that order. +// +// This is the check that matters for locales, because the failure mode is silent. A wrong key does not throw +// and does not corrupt anything visibly — ACE simply writes its own keys into the same index and the two +// disagree, so a seek misses rows. The only way to know a tailoring is right is to have ACE encode the same +// values and compare bytes. +// +// The sample set is deliberately much wider than the tailoring: the whole ASCII range, every Latin-1 and +// Latin Extended-A letter, and words. A tailoring is only trustworthy if it is also correct for the +// characters it does *not* mention. +public class LocaleCollationAccessTests(ITestOutputHelper output) +{ + public static TheoryData Fixtures() => + [ + // General itself, so a base-table gap is attributed to the base table rather than to a tailoring. + "Northwind", + "SpanishModern", "GermanPhoneBook", "Polish", "RomanianLegacy", "Turkish", "GeorgianModern", "Indic", + // Contraction locales. + "SpanishTraditional", "Czech", "CroatianLegacy", "NorwegianDanish", "Hungarian", + // Single-character locales. + "Estonian", "Icelandic", "Latvian", "Lithuanian", "Slovenian", "SwedishFinnish", + "Slovak", "Vietnamese", "HungarianTechnical", + ]; + + [Theory] + [MemberData(nameof(Fixtures))] + public void Encodes_the_same_index_keys_as_ace(string fixture) + { + string source = TestDatabases.Data($"{fixture}.accdb"); + Assert.SkipWhen(!File.Exists(source), $"{fixture}.accdb is not present"); + + string[] samples = Samples(); + string path = TemporaryDatabase.CopyPath(source, $"conformance-{fixture.ToLowerInvariant()}-"); + try + { + Collation collation; + using (var db = JetDatabase.Open(path)) collation = db.Collation; + Assert.True(collation.IsIndexKeyEncodable, + $"{fixture} reports collation {collation}, which LibRed does not claim to encode."); + + Dictionary ace = AceKeys(path, samples); + Assert.NotEmpty(ace); + + var mismatches = new List(); + var unencodable = new List(); + foreach (string sample in samples) + { + if (!ace.TryGetValue(sample, out string? expected)) continue; // ACE refused the value + string actual; + try { actual = Convert.ToHexString(Encode(sample, collation)); } + catch (NotSupportedException) { unencodable.Add(Describe(sample)); continue; } + if (actual != expected) + mismatches.Add($"{Describe(sample),-16} ACE {expected,-28} LibRed {actual}"); + } + + output.WriteLine($"{fixture}: collation {collation}"); + output.WriteLine($" {ace.Count} values encoded by ACE, {mismatches.Count} mismatched, " + + $"{unencodable.Count} that LibRed does not encode at all"); + foreach (string line in mismatches.Take(40)) output.WriteLine($" {line}"); + if (unencodable.Count > 0) + output.WriteLine($" not encodable: {string.Join(" ", unencodable.Take(40))}"); + + Assert.Empty(mismatches); + } + finally { TemporaryDatabase.Delete(path); } + } + + /// Encodes through the real index-key path, so the test covers the gate and the routing as well + /// as the weight table. + private static byte[] Encode(string value, Collation collation) + { + var column = new ColumnDef { Name = "K", Type = JetDataType.Text, Index = 0, Collation = collation }; + return IndexKeyEncoder.Encode([(column, true)], [value]); + } + + /// Printable ASCII, all of Latin-1 and Latin Extended-A, and a few words — so a tailoring is + /// tested well beyond the handful of characters it actually overrides. + private static string[] Samples() + { + var samples = new List(); + for (char c = ' '; c <= '~'; c++) samples.Add(c.ToString()); + for (char c = ' '; c <= 'ſ'; c++) samples.Add(c.ToString()); + samples.AddRange([ + "apple", "Apple", "APPLE", "cafe", "café", "Ångström", "O'Brien", "Anne-Marie", "co-op", "coop", + "Łódź", "Kraków", "İstanbul", "Isparta", "ırmak", "Ğğ", "München", "Grüße", "Bär", "Baer", + "România", "Timișoara", "Iași", "señor", "senor", "mañana", + // Digraphs, the strings that must NOT contract, and the doubled forms. + "ch", "cch", "chh", "ll", "lll", "llll", "cs", "dz", "dzs", "gy", "ly", "ny", "sz", "ty", "zs", + "ccs", "ddz", "ggy", "lly", "nny", "ssz", "tty", "zzs", "gyy", "hc", "dzz", + "lj", "nj", "dž", "ddž", "llj", "nnj", "aa", "aaa", "aab", "baa", "Aa", "AA", + "chico", "llama", "coche", "calle", "chata", "hodina", "cukr", + "ljubav", "njegov", "džem", "meggy", "asszony", "nagy", "cukor", "csak", + ]); + return [.. samples]; + } + + /// Has ACE build and populate an indexed text column in the database, then reads the stored + /// index keys back with LibRed, mapped by the value that produced them. + private static Dictionary AceKeys(string path, string[] samples) + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE CollConf (K TEXT(100), V LONG)"); + Exec(connection, "CREATE INDEX IX_CollConf ON CollConf (K)"); + for (int i = 0; i < samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO CollConf (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("CollConf"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_CollConf"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + var keys = new Dictionary(); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values) && values[keyColumn.Index] is string text) + keys[text] = Convert.ToHexString(stored); + return keys; + } + + private static string Describe(string s) => + s.All(c => c is >= ' ' and <= '~') ? $"\"{s}\"" : string.Concat(s.Select(c => $"U+{(int)c:X4}")); + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs b/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs new file mode 100644 index 00000000..60f80605 --- /dev/null +++ b/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs @@ -0,0 +1,183 @@ +using System.Text; +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: derive tailoring tables from ACE rather than transcribing them by hand. +// +// For each sort-order fixture it has ACE encode every character in a broad range, reads the stored index +// keys back, and prints the entries needed to reproduce them — ready to paste into JetLocaleTailoring. Hand +// transcription of hex is exactly the kind of work that introduces a wrong byte nobody notices, because a +// wrong index key is silent. +// +// It also reports what GENERAL itself cannot encode. Most of those are Latin Extended-A letters that +// decompose to a base letter plus a combining mark whose secondary weight is simply missing from +// JetTextCollation.DiacriticWeights — so the probe derives that weight too, which fixes the base table for +// every order at once. +public class TailoringGeneratorProbeTest(ITestOutputHelper output) +{ + [Fact] + public void Generate_diacritic_weights_missing_from_general() + { + Dictionary ace = AceKeys(TestDatabases.NorthwindAccdb, "general", Characters()); + var derived = new SortedDictionary(); + var unexplained = new List(); + + foreach ((string text, string key) in ace) + { + if (Encodable(text, Collation.GeneralLegacy)) continue; + string nfd = text.Normalize(NormalizationForm.FormD); + (byte[] primaries, byte secondary) = Decode(key); + // A base letter plus one combining mark, weighing as that letter's primary: the difference is + // wholly in the secondary, so the mark's weight is all that is missing. + if (nfd.Length == 2 && char.IsLetter(nfd[0]) && primaries.Length == 1) + derived.TryAdd(nfd[1], (secondary, $"{text} U+{(int)text[0]:X4}")); + else + unexplained.Add($"{Describe(text)} -> {key}"); + } + + output.WriteLine("Combining marks whose secondary weight General is missing:"); + foreach ((char mark, (byte weight, string from)) in derived) + output.WriteLine($" ['\\u{(int)mark:X4}'] = 0x{weight:X2}, // {from}"); + output.WriteLine(""); + output.WriteLine($"Not explained by base letter + one mark ({unexplained.Count}):"); + foreach (string line in unexplained.Take(40)) output.WriteLine($" {line}"); + } + + [Theory] + [InlineData("SpanishModern")] + [InlineData("SpanishTraditional")] + [InlineData("GermanPhoneBook")] + [InlineData("Polish")] + [InlineData("RomanianLegacy")] + [InlineData("Turkish")] + [InlineData("Czech")] + [InlineData("CroatianLegacy")] + [InlineData("NorwegianDanish")] + [InlineData("Hungarian")] + [InlineData("Estonian")] + [InlineData("Icelandic")] + [InlineData("Latvian")] + [InlineData("Lithuanian")] + [InlineData("Slovenian")] + [InlineData("SwedishFinnish")] + [InlineData("Slovak")] + [InlineData("Vietnamese")] + [InlineData("HungarianTechnical")] + public void Generate_tailoring_for(string fixture) + { + string source = TestDatabases.Data($"{fixture}.accdb"); + Assert.SkipWhen(!File.Exists(source), $"{fixture}.accdb is not present"); + + string[] characters = Characters(); + Dictionary locale = AceKeys(source, fixture, characters); + Dictionary general = AceKeys(TestDatabases.NorthwindAccdb, "general", characters); + + var letters = new List(); + var accented = new List(); + foreach (string text in characters) + { + if (!locale.TryGetValue(text, out string? key)) continue; + if (general.TryGetValue(text, out string? baseline) && baseline == key) continue; + // Only the uppercase form is needed; the lowercase folds onto it. + if (text != text.ToUpperInvariant()) continue; + + (byte[] primaries, byte secondary) = Decode(key); + string bytes = string.Join(", ", primaries.Select(b => $"0x{b:X2}")); + if (secondary == 0x02) letters.Add($"(\"{text}\", [{bytes}])"); + else accented.Add($"(\"{text}\", [{bytes}], 0x{secondary:X2})"); + } + + output.WriteLine($"// --- {fixture} ---"); + output.WriteLine($"letters ({letters.Count}):"); + foreach (string line in Wrap(letters)) output.WriteLine($" {line}"); + output.WriteLine($"accented ({accented.Count}):"); + foreach (string line in Wrap(accented)) output.WriteLine($" {line}"); + } + + /// Printable ASCII, Latin-1 and Latin Extended-A — the range these orders tailor. The C1 + /// controls are skipped: ACE treats them as ignorables with an inline record rather than as letters, + /// so they are a separate topic and would otherwise fill the report. + private static string[] Characters() + { + var characters = new List(); + for (char c = ' '; c <= 'ſ'; c++) + if (c is < '' or > 'Ÿ') + characters.Add(c.ToString()); + return [.. characters]; + } + + /// Splits an index key into its primary bytes and its single secondary weight (the default + /// 0x02 when the section is empty). Reads from the end: the key is + /// 7F primaries… 01 secondaries… 00, and no observed secondary weight is 0x01. + private static (byte[] Primaries, byte Secondary) Decode(string hex) + { + byte[] key = Convert.FromHexString(hex); + int end = key.Length - 1; // the 0x00 terminator + int split = end - 1; + while (split > 0 && key[split] != 0x01) split--; + byte[] primaries = key[1..split]; + byte secondary = end - split == 1 ? (byte)0x02 : key[split + 1]; + return (primaries, secondary); + } + + private static bool Encodable(string text, Collation collation) + { + var column = new ColumnDef { Name = "K", Type = JetDataType.Text, Index = 0, Collation = collation }; + try { IndexKeyEncoder.Encode([(column, true)], [text]); return true; } + catch (NotSupportedException) { return false; } + } + + private static IEnumerable Wrap(List entries) + { + for (int i = 0; i < entries.Count; i += 4) + yield return string.Join(", ", entries.Skip(i).Take(4)) + ","; + } + + private static Dictionary AceKeys(string source, string label, string[] samples) + { + string path = TemporaryDatabase.CopyPath(source, $"gen-{label.ToLowerInvariant()}-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE Gen (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_Gen ON Gen (K)"); + for (int i = 0; i < samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Gen (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("Gen"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_Gen"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + var keys = new Dictionary(); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values) && values[keyColumn.Index] is string text) + keys[text] = Convert.ToHexString(stored); + return keys; + } + finally { TemporaryDatabase.Delete(path); } + } + + private static string Describe(string s) => + s.All(c => c is >= ' ' and <= '~') ? $"\"{s}\"" : string.Concat(s.Select(c => $"U+{(int)c:X4}")); + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} From 1dbab5f3db6456f2f447a8bea24a250d732b1673 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 17:25:40 +0800 Subject: [PATCH 07/48] LibRed: extend General Legacy across the blocks, and identify its provenance v0 IS the NT4-era NLS order, renumbered into one byte, and the compaction is ORDER-PRESERVING. Sorting every character by the Windows NT 4.0 - Server 2003 table's (SM, AW) primary and checking v0's bytes come out non-decreasing keeps 507 of 510 strictly-ordered pairs and 947 of 955 ties, with 12 of 14 blocks perfect. So the +2 stride, the language-letter insertion gaps and the 0x79 page are one decision rather than three observations. Jet also NARROWED it: 88 of the 552 v0 ignorables are weighted by NLS and dropped anyway, an editorial call no published table would reveal. Locales SHARE the block tables, with per-locale deltas in their own tailoring, which is what makes 21 orders cost 27 entries between them. A ligature weighs as its DECOMPOSITION - there is no ligature mechanism in the format. Components are weighed individually and never re-enter the contraction matcher, and decomposition sits below the tailoring because some locales do not decompose at all. The last gap was the word-sort ignorables, 20 of them rather than 3: every dash, the Arabic harakat, and fullwidth apostrophe and hyphen, which share their ASCII counterparts codes exactly - the one place fullwidth really does collapse onto ASCII, unlike the letters. Coverage is 2147/2147 for all 23 orders. Co-Authored-By: Claude Opus 5 --- .../LibRed.Core/Storage/JetLocaleTailoring.cs | 38 ++- .../LibRed.Core/Storage/JetTextCollation.cs | 138 +++++++++-- .../Storage/JetTextCollationBlocks.cs | 218 ++++++++++++++++++ .../docs/format/page-03-04-index-btree.md | 83 ++++++- .../LibRed.Core.Tests/ContractionProbeTest.cs | 74 ++++++ .../LocaleCollationAccessTests.cs | 24 +- .../SortOrderProvenanceProbeTest.cs | 178 ++++++++++++++ .../TailoringGeneratorProbeTest.cs | 204 +++++++++++++--- 8 files changed, 895 insertions(+), 62 deletions(-) create mode 100644 src/LibRed/LibRed.Core/Storage/JetTextCollationBlocks.cs create mode 100644 test/LibRed.Core.Tests/SortOrderProvenanceProbeTest.cs diff --git a/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs index c4eb2e87..cc36e0ef 100644 --- a/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs +++ b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs @@ -166,14 +166,20 @@ [new Collation(CollatingOrder.Slovak, 0)] = Table( ("Ċ", [0x4D], 0x04), ("Ė", [0x51], 0x04), ("Ġ", [0x55], 0x04), ("İ", [0x59], 0x04), ("Ŀ", [0x5E], 0x04), ("Ÿ", [0x76], 0x05), ("Ż", [0x78], 0x04)]), - // --- Croatian Legacy: three digraphs, and dž carries a real secondary of its own. --- + // --- Croatian Legacy: three digraphs, and dž carries a real secondary of its own. The ligature + // characters DŽ/LJ/NJ ARE encodable here, unlike in General: Croatian makes each digraph a single + // letter, so the ligature is one weight rather than two. --- [new Collation(CollatingOrder.Croatian, 0)] = Table( [("LJ", [0x5F, 0x03]), ("NJ", [0x63, 0x04]), ("Ć", [0x4E, 0x03]), ("Č", [0x4E, 0x02]), - ("Đ", [0x50, 0x05]), ("Š", [0x6C, 0x07]), ("Ž", [0x79, 0x05])], - [("DŽ", [0x50, 0x04], 0x04), + ("Đ", [0x50, 0x05]), ("Š", [0x6C, 0x07]), ("Ž", [0x79, 0x05]), + ("LJ", [0x5F, 0x03]), ("NJ", [0x63, 0x04])], + [("DŽ", [0x50, 0x04], 0x04), ("DŽ", [0x50, 0x04], 0x04), ("Ă", [0x4A], 0x05), ("Ď", [0x4F], 0x04), ("Ĕ", [0x51], 0x05), ("Ě", [0x51], 0x04), ("Ğ", [0x55], 0x05), ("Ĭ", [0x59], 0x05), ("Ľ", [0x5E], 0x04), ("Ň", [0x62], 0x04), - ("Ŏ", [0x64], 0x05), ("Ř", [0x69], 0x04), ("Ť", [0x6D], 0x04), ("Ŭ", [0x6F], 0x05)]), + ("Ŏ", [0x64], 0x05), ("Ř", [0x69], 0x04), ("Ť", [0x6D], 0x04), ("Ŭ", [0x6F], 0x05), + // Latin Extended-B caron letters, which Croatian retunes away from General's 0x14. + ("Ǎ", [0x4A], 0x04), ("Ǐ", [0x59], 0x04), ("Ǒ", [0x64], 0x04), ("Ǔ", [0x6F], 0x04), + ("Ǧ", [0x55], 0x04), ("Ǩ", [0x5C], 0x04), ("Ǯ", [0x79, 0x02], 0x04), ("ǰ", [0x5B], 0x04)]), // --- Slovenian: the same letters as Croatian, at different sub-positions. --- [new Collation(CollatingOrder.Slovenian, 0)] = Table( @@ -186,20 +192,24 @@ [new Collation(CollatingOrder.Norwegian, 0)] = Table( [("Æ", [0x79, 0x04]), ("Ø", [0x79, 0x06]), ("Å", [0x79, 0x09])], [("AA", [0x79, 0x09], 0x03), ("Ä", [0x79, 0x04], 0x13), ("Ö", [0x79, 0x06], 0x13), ("Ü", [0x76], 0x7B), - ("Ő", [0x79, 0x06], 0x1B), ("Ű", [0x76], 0x1B)]), + ("Ő", [0x79, 0x06], 0x1B), ("Ű", [0x76], 0x1B), + ("Ǣ", [0x79, 0x04], 0x03)]), // Æ with a macron rides on the locale's own Æ // --- Swedish/Finnish: å ä ö after z, w is a variant of v, and ü rides on y. --- [new Collation(CollatingOrder.SwedishFinnish, 0)] = Table( [("Ä", [0x79, 0x07]), ("Å", [0x79, 0x05]), ("Ö", [0x79, 0x08])], [("W", [0x71], 0x03), ("Ŵ", [0x71], 0x12), ("Ø", [0x79, 0x08], 0x1E), - ("Ü", [0x76], 0x7B), ("Ő", [0x79, 0x08], 0x1B), ("Ű", [0x76], 0x1B)]), + ("Ü", [0x76], 0x7B), ("Ő", [0x79, 0x08], 0x1B), ("Ű", [0x76], 0x1B), + // Wynn follows w onto v's primary. Keyed lowercase deliberately: its uppercase U+01F7 is + // ignorable in General, so folding to it would lose the weight. + ("ƿ", [0x71], 0x7B)]), // --- Icelandic: the accented vowels are letters, and þ æ ö close the alphabet after z. --- [new Collation(CollatingOrder.Icelandic, 0)] = Table( [("Á", [0x4B, 0x03]), ("Æ", [0x79, 0x04]), ("É", [0x52, 0x02]), ("Í", [0x5A, 0x02]), ("Ð", [0x50, 0x02]), ("Ó", [0x65, 0x02]), ("Ö", [0x79, 0x05]), ("Ú", [0x70, 0x02]), ("Ý", [0x77, 0x02]), ("Þ", [0x79, 0x03])], - [("Ø", [0x79, 0x05], 0x1E)]), + [("Ø", [0x79, 0x05], 0x1E), ("Ǣ", [0x79, 0x04], 0x16)]), // --- Estonian: the most radical of these. It rewrites the base alphabet rather than extending it — // z moves between s and t, v moves down, and õ and ö take over the bare one-byte primaries @@ -220,7 +230,9 @@ [new Collation(CollatingOrder.Latvian, 0)] = Table( // --- Lithuanian: y follows i rather than closing the alphabet, and the ogonek letters stay // secondaries but at 0x0F instead of General's 0x1B. --- [new Collation(CollatingOrder.Lithuanian, 0)] = Table( - [("Y", [0x5A, 0x02])], + // Fullwidth Y follows the tailoring too — a locale can retailor a fullwidth form, and Estonian + // proves the converse by leaving fullwidth V on General's weight, so these are per-locale facts. + [("Y", [0x5A, 0x02]), ("Y", [0x5A, 0x02])], [("Ą", [0x4A], 0x0F), ("Ę", [0x51], 0x0F), ("Į", [0x59], 0x0F), ("Ų", [0x6F], 0x0F)]), // --- Vietnamese: nine digraphs, and p and r shift to make room. Note "gh" and "ngh" are NOT letters @@ -230,7 +242,15 @@ [new Collation(CollatingOrder.Vietnamese, 0)] = Table( ("NH", [0x63, 0x03]), ("PH", [0x67, 0x03]), ("QU", [0x69]), ("TH", [0x6E, 0x02]), ("TR", [0x6E, 0x03]), ("P", [0x67, 0x02]), ("R", [0x6A, 0x02]), ("Â", [0x4B, 0x02]), - ("Ê", [0x52, 0x02]), ("Ô", [0x65, 0x02]), ("Ă", [0x4B, 0x03]), ("Đ", [0x50, 0x02])]), + ("Ê", [0x52, 0x02]), ("Ô", [0x65, 0x02]), ("Ă", [0x4B, 0x03]), ("Đ", [0x50, 0x02]), + ("Ơ", [0x66]), ("Ư", [0x70, 0x02])]), // the horned vowels, in Latin Extended-B + + // --- Ukrainian and Macedonian: Cyrillic orders, and the smallest tailorings of the lot. They were + // blocked until General v0 carried the Cyrillic block at all; now they are one and two entries. + [new Collation(CollatingOrder.Ukrainian, 0)] = Table([ + ("Ь", [0x79, 0x5D])]), + [new Collation(CollatingOrder.Macedonian, 0)] = Table([ + ("Ѓ", [0x79, 0x34]), ("Ќ", [0x79, 0x4C])]), // --- Hungarian: the full digraph set, the only order that doubles them, plus ö and ü as letters. --- [new Collation(CollatingOrder.Hungarian, 0)] = Table( diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs index 6d9ed8ec..5402492b 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs @@ -29,6 +29,39 @@ internal static class JetTextCollation private const byte ApostropheCode = 0x80; private const byte HyphenCode = 0x82; private const byte SoftHyphenCode = 0x83; + + /// + /// The word-sort ignorables and their inline codes. These add no primary weight at all; each + /// appends a 80 <pos> 06 <code> record to the trailing section instead, which is what + /// keeps coop and co-op together. Every dash, the Arabic harakat and the fullwidth + /// apostrophe and hyphen are treated the same way — the fullwidth pair share their ASCII counterparts' + /// codes exactly. + /// + /// Written as code points rather than literals: several of these are invisible or are the very + /// characters an editor normalises, and a wrong one here is a silently wrong key. + private static readonly Dictionary Ignorables = new() + { + [(char)0x0027] = ApostropheCode, // ' + [(char)0xFF07] = ApostropheCode, // fullwidth ' + [(char)0x002D] = HyphenCode, // - + [(char)0xFF0D] = HyphenCode, // fullwidth - + [(char)0x00AD] = SoftHyphenCode, // soft hyphen + [(char)0x2010] = 0x84, // hyphen + [(char)0x2011] = 0x85, // non-breaking hyphen + [(char)0x2027] = 0x86, // hyphenation point + [(char)0x2043] = 0x87, // hyphen bullet + [(char)0x2012] = 0x88, // figure dash + [(char)0x2013] = 0x89, // en dash + [(char)0x2014] = 0x8B, // em dash + [(char)0x2015] = 0x8C, // horizontal bar + [(char)0x064B] = 0xA0, // Arabic fathatan + [(char)0x064C] = 0xA1, // dammatan + [(char)0x064D] = 0xA2, // kasratan + [(char)0x064E] = 0xA3, // fatha + [(char)0x064F] = 0xA4, // damma + [(char)0x0650] = 0xA5, // kasra + [(char)0x0652] = 0xA6, // sukun + }; private const byte DefaultSecondary = 0x02; // a character with no accent // Secondary (diacritic) weight per Unicode combining mark — depends only on the accent, not the base @@ -136,6 +169,35 @@ internal static class JetTextCollation ['¹'] = [0x38], ['²'] = [0x3A], ['³'] = [0x3C], }; + /// + /// Ligature characters, which ACE weighs as their decomposition rather than as anything of their own — + /// DŽ encodes exactly as the string , and Ǣ exactly as ĀĒ, so its macron + /// lands on both letters and the key carries two secondary slots. Keyed by the UPPERCASE form, which is + /// what case folding leaves; the title-case and lower-case forms encode identically. + /// + /// Written as code points rather than literals: these are exactly the characters an editor or a + /// tool is liable to normalise into something else, and a wrong one here is a silently wrong key. + private static readonly Dictionary Ligatures = BuildLigatures(); + + private static Dictionary BuildLigatures() + { + var ligatures = new Dictionary(); + void Add(int ligature, params int[] components) + { + string decomposition = new([.. components.Select(component => (char)component)]); + // The upper/title/lower trio all fold to the same key, so all three map to the same components. + for (int form = ligature; form < ligature + 3; form++) ligatures[(char)form] = decomposition; + } + + Add(0x01C4, 0x0044, 0x017D); // DŽ Dž dž = D Ž + Add(0x01C7, 0x004C, 0x004A); // LJ Lj lj = L J + Add(0x01CA, 0x004E, 0x004A); // NJ Nj nj = N J + Add(0x01F1, 0x0044, 0x005A); // DZ Dz dz = D Z + ligatures[(char)0x01E2] = new([(char)0x0100, (char)0x0112]); // Ǣ = Ā Ē (macron on both) + ligatures[(char)0x01FC] = new([(char)0x00C1, (char)0x00C9]); // Ǽ = Á É (acute on both) + return ligatures; + } + /// /// Appends the order-preserving collation key body for (everything /// after the start flag: primary weights, end-of-primary marker, any ignorable-char inline @@ -162,9 +224,11 @@ public static bool TryEncode(string value, List output, LocaleTailoring? t { char c = s[position]; char u = char.ToUpperInvariant(c); - if (u == '\'') { inline.Add((primaries.Count, ApostropheCode)); continue; } - if (u == '-') { inline.Add((primaries.Count, HyphenCode)); continue; } - if (u == '­') { inline.Add((primaries.Count, SoftHyphenCode)); continue; } // soft hyphen + if (Ignorables.TryGetValue(c, out byte code)) + { + inline.Add((primaries.Count, code)); + continue; + } // A locale tailoring overrides everything below it. if (tailoring is not null && @@ -174,19 +238,18 @@ public static bool TryEncode(string value, List output, LocaleTailoring? t AddWeight(tailored.Primaries, tailored.Secondary); position += consumed - 1; } - else if (ExtraLetters.TryGetValue(c, out TailoredWeight own) || - ExtraLetters.TryGetValue(u, out own)) - AddWeight(own.Primaries, own.Secondary); - else if (u is >= 'A' and <= 'Z') - Add(Letters[u - 'A']); - else if (u is >= '0' and <= '9') - Add((byte)(0x36 + 2 * (u - '0'))); - // Look the symbol up by the original character as well as the uppercased one: uppercasing is for - // letters, and it corrupts some symbols — char.ToUpperInvariant('µ') is GREEK CAPITAL LETTER MU, - // which is not what ACE weighs it as (ACE gives it a symbol weight in the 0x34 group). - else if (Symbols.TryGetValue(c, out byte[]? weights) || Symbols.TryGetValue(u, out weights)) - AddWeight(weights, DefaultSecondary); - else if (!TryAddAccented(u, Add)) + // A ligature character weighs as its decomposition, one component at a time — ACE stores DŽ + // exactly as it stores the string "DŽ", and Ǣ exactly as "ĀĒ" (A and E each carrying the macron, + // hence two secondary slots). The components are weighed INDIVIDUALLY, never re-entering the + // contraction matcher: expand DZ in a Hungarian database and its "dz" digraph would otherwise + // fire, giving 50 03 where ACE stores 4F 78. And this sits below the tailoring, because some + // locales do not decompose at all — Icelandic's Ǣ is its own Æ plus a secondary. + else if (Ligatures.TryGetValue(u, out string? components)) + { + foreach (char component in components) + if (!WeighCharacter(component)) return false; + } + else if (!WeighCharacter(c)) return false; // not handled yet } @@ -216,6 +279,49 @@ public static bool TryEncode(string value, List output, LocaleTailoring? t output.Add(EndKey); return true; + // One character's weights, with no contraction and no tailoring: the path a ligature's components + // take, and the tail of the ordinary path once a tailoring has declined the character. + bool WeighCharacter(char character) + { + char upper = char.ToUpperInvariant(character); + // The locale still applies to a single character — only the contraction matcher is bypassed. + // A ligature's components take the locale's letters: Slovenian's DŽ is D plus SLOVENIAN's ž. + if (tailoring is not null && + (tailoring.Entries.TryGetValue(character.ToString(), out TailoredWeight tailoredOne) || + tailoring.Entries.TryGetValue(upper.ToString(), out tailoredOne))) + AddWeight(tailoredOne.Primaries, tailoredOne.Secondary); + else if (ExtraLetters.TryGetValue(character, out TailoredWeight own) || + ExtraLetters.TryGetValue(upper, out own)) + AddWeight(own.Primaries, own.Secondary); + else if (upper is >= 'A' and <= 'Z') + Add(Letters[upper - 'A']); + else if (upper is >= '0' and <= '9') + Add((byte)(0x36 + 2 * (upper - '0'))); + // Look the symbol up by the original character as well as the uppercased one: uppercasing is for + // letters, and it corrupts some symbols — char.ToUpperInvariant('µ') is GREEK CAPITAL LETTER MU, + // which is not what ACE weighs it as (ACE gives it a symbol weight in the 0x34 group). + else if (Symbols.TryGetValue(character, out byte[]? weights) || Symbols.TryGetValue(upper, out weights)) + AddWeight(weights, DefaultSecondary); + // The measured block tables for Greek, Cyrillic, the Latin extensions, punctuation and the rest. + // They cover no character the hand-verified Latin-1 / Latin Extended-A tables do, so they cannot + // override anything already proven — but they DO take precedence over the decomposition below, + // which is guesswork by comparison: a measured weight beats a derived one. A null weight means + // ACE stores nothing at all for the character, not even a secondary slot. + // + // Locales share them. A locale CAN reweigh a character in these blocks, but measuring all 21 + // against General showed the departures are tiny — most add one or two entries across the whole + // range, Croatian eleven — and every one of them is listed in its tailoring, which is consulted + // first. `LocaleCollationAccessTests` asserts the whole range for every locale, so a missed + // departure fails rather than writing a silently wrong key. + else if (JetTextCollationBlocks.TryGet(character, out TailoredWeight? block)) + { + if (block is { } weight) AddWeight(weight.Primaries, weight.Secondary); + } + else if (!TryAddAccented(upper, Add)) + return false; + return true; + } + void Add(byte primary, byte secondary = DefaultSecondary) { primaries.Add(primary); diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationBlocks.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationBlocks.cs new file mode 100644 index 00000000..015f413e --- /dev/null +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationBlocks.cs @@ -0,0 +1,218 @@ +namespace LibRed.Storage; + +/// +/// The General v0 weights for the Unicode blocks beyond Latin-1 and Latin Extended-A — Greek, Cyrillic, +/// Hebrew, Arabic, the Latin extensions, punctuation, currency and the fullwidth forms. Consulted by +/// only after its own tables, so nothing here can change a weight that was +/// already verified by hand. +/// +/// +/// Every byte was measured from ACE and is regenerated by TailoringGeneratorProbeTest: it has ACE +/// encode each character in a block, reads the stored index keys back, and prints these strings. They are +/// data, not something to hand-edit — around 1,500 entries as a dictionary literal would be neither readable +/// nor reviewable, and transcribing hex by hand is how a wrong byte gets in unnoticed. +/// +/// Each string is start| then one token per consecutive code point: +/// +/// -ignorable: ACE stores no weight at all for it, so it contributes nothing to a key, +/// not even a secondary slot. Romanian's comma-below ș/ț are in this class, which is why they +/// keep "General weights" — General has none for them. +/// ? — no data: ACE would not store the character, so the encoder refuses it rather than +/// guessing. +/// HEX — the primary weight bytes, with the default secondary. +/// HEX,SS — primary bytes and a secondary of its own. +/// +/// Note the non-Latin scripts nearly all live on the two-byte 0x79 page, the same page the locale +/// tailorings use for letters that sort after Z. +/// +/// +internal static class JetTextCollationBlocks +{ + /// The weight for a character, or null when it is ignorable. False when the block tables have + /// nothing for it, in which case the caller must refuse the value rather than emit a guess. + public static bool TryGet(char c, out TailoredWeight? weight) => Entries.Value.TryGetValue(c, out weight); + + // Lazy, because a static field initialiser would run before Tables below is assigned. + private static readonly Lazy> Entries = new(Parse); + + private static Dictionary Parse() + { + var entries = new Dictionary(); + foreach (string table in Tables) + { + int bar = table.IndexOf('|'); + int start = Convert.ToInt32(table[..bar], 16); + foreach (string token in table[(bar + 1)..].Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + char c = (char)start++; + if (token == "?") continue; + if (token == "-") { entries[c] = null; continue; } + int comma = token.IndexOf(','); + byte[] primaries = Convert.FromHexString(comma < 0 ? token : token[..comma]); + byte secondary = comma < 0 ? (byte)0x02 : Convert.ToByte(token[(comma + 1)..], 16); + entries[c] = new TailoredWeight(primaries, secondary); + } + } + return entries; + } + + private static readonly string[] Tables = + [ + // Latin Extended-B — U+0180..U+024F + "0180|" + + "4C,1E 4C,43 4C,68 4C,68 4C,87 4C,87 4D,7D 4D,43 4D,43 5002,04 4F,43 4F,1E 4F,1E 4F,7C 51,7D 51,7E " + + "51,7B 53,43 53,7B 55,43 55,7B 57,7B 59,7B 59,1E 5C,43 5C,43 5E,1E 5E,20 60,7B 62,43 62,7B 64,20 " + + "64,52 64,52 64,7C 64,7C 66,43 66,43 69,7B 6B,87 6B,87 6B,7C 6B,7F 6D,57 6D,43 6D,43 6D,59 6F,52 " + + "6F,52 6F,7B 71,7B 76,43 76,43 78,1E 78,1E 7902 7902,7C 7902,7C 7902,7D 3A,03 4102,03 4102,03 790A 73,7B " + + // U+01C4..U+01CC are the DŽ/LJ/NJ ligature characters. Each is TWO weights, not one, and the second + // may carry its own accent — ACE stores DŽ as 7F 4F 78 01 02 14 00, i.e. D then Ž. A single block + // entry cannot express that, so they are refused rather than encoded as one weight, which would be + // silently wrong in any string where a later character is accented. + "330B 330C 330D 2B17 ? ? ? ? ? ? ? ? ? 4A,14 4A,14 59,14 " + + "59,14 64,14 64,14 6F,14 6F,14 6F,28 6F,28 6F,1F 6F,1F 6F,25 6F,25 6F,20 6F,20 51,7D 4A,28 4A,28 " + + // U+01E2/E3 (Ǣ, AE with macron) is an accented expansion — two weights each carrying the accent — + // and the letters it expands to are locale-dependent: Icelandic gives it its own Æ, 79 04, with a + // different secondary again. Refused rather than guessed, like the ligatures above. + "4A,25 4A,25 ? ? 55,1E 55,1E 55,14 55,14 5C,14 5C,14 64,1B 64,1B 64,30 64,30 7902,14 7902,14 " + + "5B,14 ? ? ? 55,0E 55,0E - - - - 4A,26 4A,26 ? ? 64,2B 64,2B " + + "4A,44 4A,44 4A,46 4A,46 51,44 51,44 51,46 51,46 59,44 59,44 59,46 59,46 64,44 64,44 64,46 64,46 " + + "69,44 69,44 69,46 69,46 6F,44 6F,44 6F,46 6F,46 - - - - - - - - " + + "- - - - - - - - - - - - - - - - " + + "- - - - - - - - - - - - - - - - " + + "- - - - - - - - - - - - - - - - ", + + // Spacing modifiers — U+02B0..U+02FF + "02B0|" + + "57,7E 57,7F 5B,7E 69,81 69,82 69,83 69,84 73,7E 76,7E ,0C ,19 ,45 ,46 ,47 ,77 ,78 " + + ",3F ,79 ,7A ,7B ,7C ,7D 2B02,03 2B18 ,40 2B13,03 2B14,03 2B07,03 ,5A ,62 ,7E ,7F " + + "2B99 ,81 ,82 ,83 ,50 ,51 ,52 ,53 2B18,03 2B19 2B1A 2B1B 2B1C 2B1D ,84 - " + + "55,7E 5E,7E 6B,7E 75,7E ,85 ,86 ,87 ,88 ,89 ,8A - - - - - - " + + "- - - - - - - - - - - - - - - - ", + + // Greek — U+0370..U+03FF + "0370|" + + ",91 ,92 ,93 - 2B1E 2B1F - - - - 2B20 - - - 2B21 - " + + "- - - - 2B22 2B23 790C,05 - 7910,05 7912,05 7914,05 - 791A,05 - 791F,05 7923,05 " + + "7914,16 790C 790D 790E 790F 7910 7911 7912 7913 7914 7915 7916 7917 7918 7919 791A " + + "791B 791C - 791D 791E 791F 7920 7921 7922 7923 7914,13 791F,13 790C,05 7910,05 7912,05 7914,05 " + + "791F,16 790C 790D 790E 790F 7910 7911 7912 7913 7914 7915 7916 7917 7918 7919 791A " + + "791B 791C 791D 791D 791E 791F 7920 7921 7922 7923 7914,13 791F,13 791A,05 791F,05 7923,05 - " + + "790D,03 7913,03 791F,1B 791F,44 791F,13 7920,03 791B,03 - - - 7924 7924 7925 7925 7926 7926 " + + "7927 7927 7928 7928 7929 7929 792A 792A 792B 792B 792C 792C 792D 792D 792E 792E " + + "7915,03 791C,03 791D,04 - - - - - - - - - - - - - ", + + // Cyrillic — U+0400..U+04FF + "0400|" + + "- 7936,13 7935 7932,0E 7937 793A 793C 793D 793F 7942 7945 794B 7940,0E - 794E 7954 " + + "792F 7930 7931 7932 7933 7936 7938 7939 793B 793E 7940 7941 7943 7944 7946 7947 " + + "7948 7949 794A 794D 7950 7951 7952 7953 7955 7956 7957 7958 7959 795A 795B 795C " + + "792F 7930 7931 7932 7933 7936 7938 7939 793B 793E 7940 7941 7943 7944 7946 7947 " + + "7948 7949 794A 794D 7950 7951 7952 7953 7955 7956 7957 7958 7959 795A 795B 795C " + + "- 7936,13 7935 7932,0E 7937 793A 793C 793D 793F 7942 7945 794B 7940,0E - 794E 7954 " + + "795E 795E 795F 7944,03 7960 7960 7961 7961 7962 7962 7963 7963 7964 7964 7965 7965 " + + "7966 7966 7967 7967 7968 7968 7968,46 7968,46 7969 7969 796A 796A 796B 796B 796C 796C " + + "796D 796D 4926,37 ,94 ,95 ,96 ,97 - - - - - - - - - " + + "7932,03 7932,03 7932,1A 7932,1A 7932,05 7932,05 7938,07 7938,07 7939,1A 7939,1A 7940,17 7940,17 7940,09 7940,09 7940,04 7940,04 " + + "7940,0A 7940,0A 7944,07 7944,07 7944,08 7944,08 7947,05 7947,05 7946,05 7946,05 7949,1A 7949,1A 794A,07 794A,07 794D,0B 794D,0B " + + "794F 794F 7951,17 7951,17 7952,03 7952,03 7953,08 7953,07 7953,09 7953,09 7944,09 7944,09 7936,05 7936,05 7936,17 7936,17 " + + "793C,03 7938,15 7938,15 7940,05 7940,05 7940,06 7940,06 7944,0A 7944,0A 7951,06 7951,06 7953,0D 7953,0D - - - " + + "- - - - - - - - - - - - - - - - " + + "- - - - - - - - - - - - - - - - " + + "- - - - - - - - - - - - - - - - ", + + // Cyrillic Supplement — U+0500..U+052F: present in the file format, but ACE weighs none of it. + "0500|" + + "- - - - - - - - - - - - - - - - " + + "- - - - - - - - - - - - - - - - " + + "- - - - - - - - - - - - - - - - ", + + // Hebrew — U+0590..U+05FF + "0590|" + + "- ,03 ,04 ,05 ,06 ,07 ,08 ,09 ,0A ,0B ,0C ,0D ,0E ,0F ,10 ,11 " + + ",12 ,13 ,14 ,15 ,16 ,17 ,18 ,19 ,1A ,1B ,1C ,1D ,1E ,1F ,20 ,21 " + + ",22 ,23 ,24 ,25 ,26 ,27 ,28 ,29 ,2A ,2B ,2C ,2D ,2E ,2F 2B2A ,30 " + + ",31 ,32 ,33 2B2B - - - - - - - - - - - - " + + "7994 7995 7996 7997 7998 7999 799A 799B 799C 799D 799E 799E 799F 79A0 79A0 79A1 " + + "79A1 79A2 79A3 79A4 79A4 79A5 79A5 79A6 79A7 79A8 79A9 - - - - - " + + "79997999 7999799D 799D799D 2B2C 2B2D - - - - - - - - - - - ", + + // Arabic — U+0600..U+06FF + "0600|" + + "- - - - - - - - - - - - 2B2E - - - " + + "- - - - - - - - - - - 2B2F - - - 2B30 " + + "- 79AA 79AA,03 79AA,04 79AA,05 79AA,06 79AA,07 79AB,08 79AC,08 79AE,08 79AE,08 79AF,08 79B0,08 79B2,08 79B3,08 79B4,08 " + + "79B5,08 79B6,08 79B7,08 79B9,08 79BA,08 79BB,08 79BC,08 79BD,08 79BE,08 79BF,08 79C0,08 - - - - - " + + "- 79C1,08 79C2,08 79C3,08 79C5,08 79C6,08 79C7,08 79C8,08 79C9,08 79CA,05 79CA,07 ? ? ? ? ? " + + "? FFFF ? - - - - - - - - - - - - - " + + "3702,38 3902,38 3B02,38 3D02,38 3F02,38 4103,38 4302,38 4502,38 4702,38 4902,38 2B31 2B32 2B33 - - - " + + "79CB 79CC 79CD 79CE 79CF 79D0 79D1 79D2 79D3 79D4 79D5 79D6 79D7 79D8 79AD,08 79D9 " + + "79DA 79DB 79DC 79DD 79DE 79DF 79B1,08 79E0 79E1 79E2 79E3 79E4 79E5 79E6 79E7 79E8 " + + "79E9 79EA 79EB 79EC 79ED 79EE 79EF 79F0 79B8,08 79F1 79F2 79F3 79F4 79F5 79F6 79F7 " + + "79F8 79F9 79FA 79FB 79FC 79FD 79FE 79FF 7A02 7A03 7A04 7A05 7A06 7A07 7A08 79C4,08 " + + "7A09 7A0A 7A0B 7A0C 7A0D 7A0E 7A0F 7A10 7A11 7A12 7A13 7A14 7A15 7A16 7A17 7A18 " + + "7A19 7A1A 7A1B 7A1C 7A1D 7A1E 7A1F 7A20 7A21 7A22 7A23 7A24 7A25 7A26 7A27 7A28 " + + "7A29 7A2A 7A2B 7A2C 2B34 7A2D - - - - - - - - - - " + + "- - - - - - - - - - - - - - - - " + + "3703,39 3903,39 3B03,39 3D03,39 3F03,39 4104,39 4303,39 4503,39 4703,39 4903,39 - - - - - - ", + + // Latin Extended Additional — U+1E00..U+1EFF (the Vietnamese precomposed letters live here) + "1E00|" + + "4A,5A 4A,5A 4C,10 4C,10 4C,58 4C,58 4C,55 4C,55 4D,28 4D,28 4F,10 4F,10 4F,58 4F,58 4F,55 4F,55 " + + "4F,1C 4F,1C 4F,60 4F,60 51,24 51,24 51,23 51,23 51,60 51,60 51,63 51,63 51,2F 51,2F 53,10 53,10 " + + "55,17 55,17 57,10 57,10 57,58 57,58 57,13 57,13 57,1C 57,1C 57,61 57,61 59,63 59,63 59,1F 59,1F " + + "5C,0E 5C,0E 5C,58 5C,58 5C,55 5C,55 5E,58 5E,58 5E,6E 5E,6E 5E,55 5E,55 5E,60 5E,60 60,0E 60,0E " + + "60,10 60,10 60,58 60,58 62,10 62,10 62,58 62,58 62,55 62,55 62,60 62,60 64,25 64,25 64,2A 64,2A " + + "64,24 64,24 64,23 64,23 66,0E 66,0E 66,10 66,10 69,10 69,10 69,58 69,58 69,6E 69,6E 69,55 69,55 " + + "6B,10 6B,10 6B,58 6B,58 6B,1D 6B,1D 6B,22 6B,22 6B,68 6B,68 6D,10 6D,10 6D,58 6D,58 6D,55 6D,55 " + + "6D,60 6D,60 6F,59 6F,59 6F,63 6F,63 6F,5A 6F,5A 6F,25 6F,25 6F,28 6F,28 71,19 71,19 71,58 71,58 " + + "73,0F 73,0F 73,0E 73,0E 73,13 73,13 73,10 73,10 73,58 73,58 75,10 75,10 75,13 75,13 76,10 76,10 " + + "78,12 78,12 78,58 78,58 78,55 78,55 57,55 6D,13 73,1A 76,1A 4A,69 - - - - - " + + "4A,59 4A,59 4A,43 4A,43 4A,1E 4A,1E 4A,1F 4A,1F 4A,55 4A,55 4A,29 4A,29 4A,6A 4A,6A 4A,21 4A,21 " + + "4A,22 4A,22 4A,58 4A,58 4A,2C 4A,2C 4A,6D 4A,6D 51,58 51,58 51,43 51,43 51,19 51,19 51,1E 51,1E " + + "51,1F 51,1F 51,55 51,55 51,29 51,29 51,6A 51,6A 59,43 59,43 59,58 59,58 64,58 64,58 64,43 64,43 " + + "64,1E 64,1E 64,1F 64,1F 64,55 64,55 64,29 64,29 64,6A 64,6A 64,60 64,60 64,61 64,61 64,95 64,95 " + + "64,69 64,69 64,AA 64,AA 6F,58 6F,58 6F,43 6F,43 6F,60 6F,60 6F,61 6F,61 6F,95 6F,95 6F,69 6F,69 " + + "6F,AA 6F,AA 76,0F 76,0F 76,58 76,58 76,44 76,44 76,19 76,19 - - - - - - ", + + // General punctuation — U+2000..U+206F + "2000|" + + "0808 0809 080A 080B 080C 080D 080E 080F 0810 0811 0812 0813 0814 - - - " + + "? ? ? ? ? ? 2B35 2B36 2B37 2B38 2B39 2B3A 2B3B 2B3C 2B3D 2B3E " + + "34B3 34B4 34B5 34B6 34B7 34B8 34B9 ? 0815 0816 - - - - - - " + + "34BA 34BB 2B3F 2B40 2B41 2B42 2B43 2B44 2B45 2B46 2B47 34BC 2B48 2B49 2B4A 2B4B " + + "34BD 34BE 34BF ? 2D05 27,1C 2A,1C - - - - - - - - - " + + "- - - - - - - - - - - - - - - - " + + "- - - - - - - - - - - - - - - - ", + + // Currency — U+20A0..U+20BF + "20A0|" + + "34C0 34C1 34C2 34C3 34C4 34C5 34C6 34C7 34C8 34C9 34CA 34CB 35A1 - - - " + + "- - - - - - - - - - - - - - - - ", + + // Letterlike — U+2100..U+214F + "2100|" + + "4B04 4B06 4D,03 4D,04 4E0A 4E0F 4E10 51,64 6C02 53,04 55,03 57,05 57,04 57,03 57,03 57,68 " + + "59,04 59,05 5E,04 5E,04 5F02 62,03 6302 35A0 66,04 66,03 68,03 69,04 69,05 69,03 6A02 6A03 " + + "6C03 6E02 6E04 7202 78,03 78,63 7923,03 7923,05 78,05 7923,04 5D02 4A,1A 4C,04 4D,05 51,05 51,04 " + + "51,04 53,05 53,03 60,04 64,04 7994 7995 7996 7997 - - - - - - - " + + "- - - - - - - - - - - - - - - - ", + + // Number forms — U+2150..U+218F + "2150|" + + "- - - 3713 3719 3711 3715 3717 371B 3710 371C 370F 3714 3718 371D 38,03 " + + "3910,47 3B10,47 3D10,47 3F10,47 4111,47 4310,47 4510,47 4710,47 4910,47 4914,47 4916,47 4918,47 4922,47 4924,47 4925,47 4928,47 " + + "3910,47 3B10,47 3D10,47 3F10,47 4111,47 4310,47 4510,47 4710,47 4910,47 4914,47 4916,47 4918,47 4922,47 4924,47 4925,47 4929,47 " + + "492A,47 492B,47 492C,47 - - - - - - - - - - - - - ", + + // Fullwidth forms — U+FF01..U+FF65. They repeat the ASCII weights exactly, which is the width folding + // described in §10.4 falling out of the table rather than being a normalisation pass. + "FF01|" + + "09 0A 0C 0E 10 12 ? 14 16 18 2C 1A ? 1C 1E 36 " + + "38 3A 3C 3E 40 42 44 46 48 20 22 2E 30 32 24 26 " + + "4A 4C 4D 4F 51 53 55 57 59 5B 5C 5E 60 62 64 66 " + + "68 69 6B 6D 6F 71 73 75 76 78 27 29 2A 2B02 2B03 2B07 " + + "4A 4C 4D 4F 51 53 55 57 59 5B 5C 5E 60 62 64 66 " + + "68 69 6B 6D 6F 71 73 75 76 78 2B09 2B0B 2B0D 2B0F - - " + + "1D02 2B59 2B5B 1B03 34B2 ", + ]; +} 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 d2b53f3a..0a6e0da4 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -125,6 +125,26 @@ Then the value, transformed: > | inline position | counts primary **bytes** | counts primary **weights** (so `O'Brien` is `0x0B` in both, though v1 has emitted twice as many bytes) | > | soft hyphen | inline record, code `0x83` | wholly ignorable, no record | > +> **v0 is the NT4-era NLS order, renumbered into one byte.** v1 could be *identified* because its primaries +> are the NLS `(SM, AW)` pair verbatim; v0's are a Jet-specific compaction, which is why its table had to be +> measured character by character instead. But the compaction turns out to be **order-preserving**, so the +> table is explained rather than merely recorded. Sorting every character by the primary in the +> **Windows NT 4.0 – Server 2003** table (the generation contemporary with Jet 3.5/Access 97 and Jet 4/Access +> 2000) and checking v0's bytes come out non-decreasing gives **507 of 510** strictly-ordered pairs kept, and +> **947 of 955** NT4 ties still tying (`SortOrderProvenanceProbeTest`, needs `LIBRED_NT4_TABLE`). By block: +> +> | | agreement | +> |---|---| +> | Cyrillic, Greek, Hebrew, both Latin extensions, punctuation, currency, letterlike, number forms, spacing modifiers, fullwidth | **100%** — every block, every pair | +> | Latin-1 + ASCII | 51/52; the single exception is `U+0651`, whose v0 primary is the anomalous `FF FF` | +> | Arabic | 149/151 — the only script where Jet genuinely renumbered against NLS | +> +> So the `+2` stride, the gaps that became language-letter insertion slots, and the `0x79` page for +> non-Latin scripts are all one decision: **compact the NT4 primary order into a byte, leaving room**. +> Jet also *narrowed* it — of the 552 characters v0 treats as ignorable, 464 (84%) are unweighted in the NT4 +> table too, but the remaining 88 are weighted by NLS and dropped by Jet, which is an editorial choice of its +> own and not something a published table would have told us. +> > v1's table is the **Windows Server 2008** sorting weight table, frozen — identified by reconstructing > measured ACE v1 keys from every published Windows table (Server 2008 scores 25/25; Win7/2008R2 24/25, > Vista 23/25, Win8+ 22/25, NT4-2003 18/25, the discriminators being `1` = `13 25` vs `13 26`, its DW `2` @@ -153,8 +173,16 @@ Then the value, transformed: string primary key). Decoding remains lossy (case is discarded — that is why a text primary key treats `'A'` and `'a'` as duplicates). - **Apostrophe and hyphen are "ignorable"** (so `O'Brien` sorts next to `OBrien`): they add **no - primary weight**, but each appends an inline record to a trailing section. After the primary's + **Twenty characters are "ignorable"** (so `O'Brien` sorts next to `OBrien`): they add **no + primary weight**, but each appends an inline record to a trailing section. + + > The full set and their codes, measured alone and inside a word so the position arithmetic is confirmed + > rather than assumed: apostrophe `0x80`, hyphen `0x82`, soft hyphen `0x83`, `U+2010` `0x84`, `U+2011` + > `0x85`, `U+2027` `0x86`, `U+2043` `0x87`, `U+2012` `0x88`, `U+2013` `0x89`, `U+2014` `0x8B`, `U+2015` + > `0x8C`, and the Arabic harakat `U+064B`–`U+0650` and `U+0652` running `0xA0`–`0xA6`. **The fullwidth + > apostrophe and hyphen share their ASCII counterparts' codes exactly** (`U+FF07` = `0x80`, `U+FF0D` = + > `0x82`) — the one place fullwidth really does collapse onto ASCII, unlike the letters. `0x8A` is unused + > by anything in the swept range. After the primary's `0x01` end marker, if any ignorable char is present the key adds `01 01 01` once, then per ignorable char four bytes `80 06 `, then the final `00`. ` = 0x07 + 4 × (count of **primary weight bytes** emitted before it)` and `` is `0x80` for apostrophe / `0x82` for @@ -203,6 +231,46 @@ Then the value, transformed: `SS`). Because the ignorable-position count is by primary byte, an expansion counts as its expanded length (above). + > **General v0 now covers every non-CJK block ACE weighs**, measured block by block and asserted + > byte-for-byte (`JetTextCollationBlocks`, generated by `TailoringGeneratorProbeTest`): Latin Extended-B and + > Additional, spacing modifiers, Greek, Cyrillic (+Supplement), Hebrew, Arabic, punctuation, currency, + > letterlike, number forms and the fullwidth forms. The non-Latin scripts nearly all live on the same + > **two-byte `0x79` page** the locale tailorings use for letters sorting after Z. + > + > Three categories emerged that the Latin-1 range never showed: + > - **Ignorable** — ACE stores *nothing at all* (key `7F 01 00`): no primary, not even a secondary slot. + > Romanian's comma-below `ș`/`ț` are in this class, which is why they appear to "keep General's weights": + > General has none for them. Ignorability held in every order measured. + > - **Secondary-only** — a combining mark contributes a secondary and *no* primary (`7F 01 ss 00`). All of + > Hebrew's niqqud, the Cyrillic combining marks and three Greek ones work this way. + > - **Locale-dependent expansion** — `DŽ` is `D`+`Ž` (two weights, the caron on the second: `7F 4F 78 01 02 + > 14 00`), and `Ǣ` is `Æ` with a macron whose letters differ per locale — Icelandic gives it its own `Æ` + > at `79 04`. These are **refused** rather than approximated, since one weight where ACE uses two is + > silently wrong in any string with a later accent. + > + > **Locales share the block tables**, because measuring all 21 against General showed the departures are + > tiny: **27 entries in total across every locale**, and most add only one or two over the entire extended + > range (Croatian eleven, the outlier). Each is listed in that locale's tailoring, which is consulted + > first — Lithuanian retailors fullwidth `Y`, Ukrainian moves `ь`, Swedish puts wynn on `v` because it + > makes `w` a variant of `v`. Estonian, by contrast, leaves fullwidth `V` on General's weight, so these + > really are per-locale facts rather than a rule. `LocaleCollationAccessTests` asserts the whole range for + > every order, so a missed departure fails the build rather than writing a silently wrong key. + > + > **A ligature character weighs as its decomposition**, one component at a time — there is no ligature + > mechanism in the format at all. `DŽ` encodes exactly as the string `DŽ` (`7F 4F 78 01 02 14 00`), `LJ` as + > `LJ`, `DZ` as `DZ`, and `Ǣ` as `ĀĒ` — the macron landing on *both* letters, hence `01 17 17`. Upper, title + > and lower case forms are identical, case having folded. Appending an accented letter proves the weight + > count: `DŽé` is `7F 4F 78 51 01 02 14 0E 00`, three primaries and three secondaries. + > + > Two rules make this work under a tailoring. The components are weighed **individually and never re-enter + > the contraction matcher** — expand `DZ` in a Hungarian database and its `dz` digraph would fire, giving + > `50 03` where ACE stores `4F 78`. And the decomposition sits **below** the tailoring, because some locales + > do not decompose: Icelandic's `Ǣ` is its own `Æ` plus a secondary, and Croatian's `DŽ` is its single-letter + > `dž`. The components do take the locale's letters, though — Slovenian's `DŽ` is `D` plus *Slovenian's* `ž`. + > + > **Coverage is complete: all 2,147 characters ACE encodes, for every one of the 23 orders, with zero + > mismatches.** Nothing in the swept range is refused and nothing disagrees with ACE. + **Diacritic secondary weights**, each depending only on the mark and not the base letter — derived from ACE by `TailoringGeneratorProbeTest`: acute `0x0E`, grave `0x0F`, **dot above `0x10`**, circumflex `0x12`, diaeresis `0x13`, **caron `0x14`**, **breve `0x15`**, **macron `0x17`**, tilde `0x19`, ring `0x1A`, @@ -411,6 +479,8 @@ Then the value, transformed: | Vietnamese | nine digraphs, and `p r` shifted to make room | | Hungarian | the nine digraphs, doubling, and `ö ü ő ű` | | Hungarian Technical | 46 individual letters and **no digraphs at all** | + | Ukrainian | `ь` — one entry | + | Macedonian | `ѓ ќ` | | Georgian Modern, Indic | *empty* — measured to be indistinguishable from General | An **empty** tailoring is meaningful and different from none: it says the order was measured to need no @@ -426,11 +496,10 @@ Then the value, transformed: > matching as `g`+`h` and `ng`+`h`, which is exactly what ACE stores. Everything else stays refused — `Collation.IsIndexKeyEncodable` gates on it, because a wrong key is silent. - What remains: **Thai** needs reordering; **Bosnian, Croatian and Serbian at version 1** need the v1 encoder - to grow a tailoring hook (its primaries are 2-byte NLS values, a different shape); **Ukrainian and - Macedonian** need the **Cyrillic block in the General table first**, which v0 does not have; and **French** - is unclassified, its tailoring being in the secondary section where single-character samples do not - exercise it. + What remains: **Thai** needs reordering, and **Bosnian, Croatian and Serbian at version 1** need the v1 + encoder to grow a tailoring hook (its primaries are 2-byte NLS values, a different shape). **French** is + unclassified, its tailoring being in the secondary section where single-character samples do not exercise + it — its one measured difference is on `DŽ`, which is refused anyway. *Not yet handled:* characters outside ASCII + the accented Latin-1 set above (and a key mixing an accent with an ignorable apostrophe/hyphen is untested); every locale other than General (above). diff --git a/test/LibRed.Core.Tests/ContractionProbeTest.cs b/test/LibRed.Core.Tests/ContractionProbeTest.cs index 48de7e5d..ffdfb4cd 100644 --- a/test/LibRed.Core.Tests/ContractionProbeTest.cs +++ b/test/LibRed.Core.Tests/ContractionProbeTest.cs @@ -54,6 +54,80 @@ private static readonly (string Fixture, string[] Samples)[] Cases = ]), ]; + // PROBE: the sixteen characters General v0 refuses — the DŽ/LJ/NJ/DZ ligatures and AE-with-accent. + // + // Each is known to be more than one primary weight, which is why a single table entry cannot express it. + // What is not established is the shape: how ACE splits them, and where an accent lands. Measured against + // the components on their own, and against strings that put an accented letter AFTER the ligature, since + // the secondary section's length is what reveals how many weights were emitted. + [Fact] + public void Probe_how_the_refused_ligatures_encode() + { + int[] ligatures = + [ + 0x01C4, 0x01C5, 0x01C6, // DŽ Dž dž + 0x01C7, 0x01C8, 0x01C9, // LJ Lj lj + 0x01CA, 0x01CB, 0x01CC, // NJ Nj nj + 0x01F1, 0x01F2, 0x01F3, // DZ Dz dz + 0x01E2, 0x01E3, // Ǣ ǣ (AE with macron) + 0x01FC, 0x01FD, // Ǽ ǽ (AE with acute) + ]; + + var samples = new List(); + foreach (int c in ligatures) samples.Add(((char)c).ToString()); + // The components, so the split can be read off rather than guessed. + samples.AddRange(["D", "Z", "Ž", "L", "J", "N", "A", "E", "Æ", "DZ", "DŽ", "LJ", "NJ", "AE"]); + // An accented letter AFTER the ligature: the secondary section then runs to that letter, and its + // length says how many weights the ligature contributed. + foreach (int c in ligatures) samples.Add((char)c + "é"); + samples.AddRange(["DŽé", "AEé", "DZé", "LJé"]); + + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "ligature-"); + try + { + Dictionary keys = AceKeys(path, [.. samples]); + foreach (string sample in samples) + output.WriteLine($" {Describe(sample),-22} {keys.GetValueOrDefault(sample) ?? "(refused)"}"); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static string Describe(string s) => + s.All(c => c is >= ' ' and <= '~') ? $"\"{s}\"" : string.Concat(s.Select(c => $"U+{(int)c:X4}")); + + // PROBE: the inline code for each remaining word-sort ignorable. + // + // An ignorable adds no primary weight; it appends a record to the trailing inline section instead — + // 80 06 , with the section introduced once by 01 01 01. LibRed knows three of them + // (apostrophe 0x80, hyphen 0x82, soft hyphen 0x83); ACE treats fourteen more the same way. Measured + // alone, then inside a word, so the position arithmetic is confirmed rather than assumed. + [Fact] + public void Probe_word_sort_ignorable_codes() + { + int[] ignorables = + [ + 0x0027, 0x002D, 0x00AD, // the three LibRed already knows + 0x064B, 0x064C, 0x064D, 0x064E, 0x064F, 0x0650, 0x0652, // Arabic harakat + 0x2010, 0x2011, 0x2012, 0x2013, 0x2014, 0x2015, // hyphens and dashes + 0x2027, 0x2043, // hyphenation point, hyphen bullet + 0xFF07, 0xFF0D, // fullwidth apostrophe and hyphen + ]; + + var samples = new List(); + foreach (int c in ignorables) samples.Add(((char)c).ToString()); + foreach (int c in ignorables) samples.Add("AB" + (char)c + "CD"); // position = 0x07 + 4x2 = 0x0F + samples.Add("AB"); + + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "ignorable-"); + try + { + Dictionary keys = AceKeys(path, [.. samples]); + foreach (string sample in samples) + output.WriteLine($" {Describe(sample),-26} {keys.GetValueOrDefault(sample) ?? "(refused)"}"); + } + finally { TemporaryDatabase.Delete(path); } + } + [Fact] public void Probe_how_contractions_encode() { diff --git a/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs b/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs index dfdbc03c..83abca3f 100644 --- a/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs +++ b/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs @@ -28,6 +28,8 @@ public static TheoryData Fixtures() => // Single-character locales. "Estonian", "Icelandic", "Latvian", "Lithuanian", "Slovenian", "SwedishFinnish", "Slovak", "Vietnamese", "HungarianTechnical", + // Cyrillic orders, encodable once General v0 carried the Cyrillic block. + "Ukrainian", "Macedonian", ]; [Theory] @@ -37,7 +39,11 @@ public void Encodes_the_same_index_keys_as_ace(string fixture) string source = TestDatabases.Data($"{fixture}.accdb"); Assert.SkipWhen(!File.Exists(source), $"{fixture}.accdb is not present"); - string[] samples = Samples(); + // The extended blocks are measured for version-0 orders only; the version-1 encoder is a separate + // table with its own coverage, so a v1 fixture is asserted over the range it was verified in. + byte version; + using (var probe = JetDatabase.Open(source)) version = probe.DefaultCollationVersion; + string[] samples = Samples(extendedBlocks: version != Collation.GeneralVersion); string path = TemporaryDatabase.CopyPath(source, $"conformance-{fixture.ToLowerInvariant()}-"); try { @@ -83,11 +89,25 @@ private static byte[] Encode(string value, Collation collation) /// Printable ASCII, all of Latin-1 and Latin Extended-A, and a few words — so a tailoring is /// tested well beyond the handful of characters it actually overrides. - private static string[] Samples() + private static string[] Samples(bool extendedBlocks) { var samples = new List(); for (char c = ' '; c <= '~'; c++) samples.Add(c.ToString()); for (char c = ' '; c <= 'ſ'; c++) samples.Add(c.ToString()); + // Every further block JetTextCollationBlocks covers — Greek, Cyrillic, Hebrew, Arabic, the Latin + // extensions, punctuation, currency and the fullwidth forms — so the whole measured range stays + // guarded rather than only the range a tailoring happens to mention. + (int First, int Last)[] blocks = + [ + (0x0180, 0x024F), (0x02B0, 0x02FF), (0x0370, 0x052F), (0x0590, 0x06FF), + (0x1E00, 0x1EFF), (0x2000, 0x206F), (0x20A0, 0x20BF), (0x2100, 0x218F), (0xFF01, 0xFF65), + ]; + if (extendedBlocks) + foreach ((int first, int last) in blocks) + for (int c = first; c <= last; c++) + if (!char.IsControl((char)c) && !char.IsSurrogate((char)c)) + samples.Add(((char)c).ToString()); + samples.AddRange([ "apple", "Apple", "APPLE", "cafe", "café", "Ångström", "O'Brien", "Anne-Marie", "co-op", "coop", "Łódź", "Kraków", "İstanbul", "Isparta", "ırmak", "Ğğ", "München", "Grüße", "Bär", "Baer", diff --git a/test/LibRed.Core.Tests/SortOrderProvenanceProbeTest.cs b/test/LibRed.Core.Tests/SortOrderProvenanceProbeTest.cs new file mode 100644 index 00000000..f8f8cacd --- /dev/null +++ b/test/LibRed.Core.Tests/SortOrderProvenanceProbeTest.cs @@ -0,0 +1,178 @@ +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: is General v0 the NT4-era NLS order, renumbered into one byte? +// +// v1 was identified outright: its primaries ARE the Windows NLS (Script Member, Alphabetic Weight) pair +// copied verbatim, so scoring measured ACE keys against every published table found Server 2008 at 25/25. +// v0 cannot be identified that way, because its primaries are a Jet-specific compaction into a SINGLE byte — +// which is why its table had to be measured character by character instead. +// +// But if that compaction is order-preserving, v0 stops being 1,500 measured facts and becomes one rule: +// "the NT4-era NLS order, renumbered into one byte with gaps left for language letters". Jet 3.5 shipped with +// Access 97 and Jet 4 with Access 2000, so NT 4.0–Server 2003 is the contemporary table. +// +// The test: sort every character by the NT4 table's primary (SM, AW) and check LibRed's v0 primary bytes come +// out non-decreasing. Set LIBRED_NT4_TABLE to "Windows NT 4.0 through Windows Server 2003 Sorting Weight +// Table.txt" (linked from [MS-UCODEREF]) to run it. +public class SortOrderProvenanceProbeTest(ITestOutputHelper output) +{ + [Fact] + public void Probe_whether_v0_preserves_the_nt4_primary_order() + { + string? path = Environment.GetEnvironmentVariable("LIBRED_NT4_TABLE"); + Assert.SkipWhen(path is null || !File.Exists(path), "LIBRED_NT4_TABLE is not set to the NT4–2003 table"); + + Dictionary nt4 = ParseTable(path!); + output.WriteLine($"NT4–2003 table: {nt4.Count} weighted code points"); + + // Every character LibRed encodes in the blocks the v0 tables cover. + var rows = new List<(char Character, int Nt4, byte[] V0)>(); + int ignorable = 0, ignorableAndUnweighted = 0, unencodable = 0; + foreach (char c in Characters()) + { + if (!TryPrimaries(c, out byte[]? primaries)) { unencodable++; continue; } + bool weighted = nt4.TryGetValue(c, out int primary) && primary != 0; + if (primaries.Length == 0) + { + // v0 stores nothing for it. Does the NT4 table agree it has no primary? + ignorable++; + if (!weighted) ignorableAndUnweighted++; + continue; + } + if (weighted) rows.Add((c, primary, primaries)); + } + + output.WriteLine($"{rows.Count} characters weighted by both; {unencodable} LibRed does not encode; " + + $"{ignorable} ignorable in v0, of which {ignorableAndUnweighted} " + + $"({(ignorable == 0 ? 0 : 100.0 * ignorableAndUnweighted / ignorable):F0}%) " + + $"are also unweighted in the NT4 table"); + + // Sorted by the NT4 primary, is the v0 primary sequence non-decreasing? + rows.Sort((a, b) => a.Nt4 != b.Nt4 ? a.Nt4.CompareTo(b.Nt4) : Compare(a.V0, b.V0)); + var violations = new List(); + int ties = 0, tiesAgreeing = 0; + for (int i = 1; i < rows.Count; i++) + { + (char previous, int previousNt4, byte[] previousV0) = rows[i - 1]; + (char current, int currentNt4, byte[] currentV0) = rows[i]; + if (previousNt4 == currentNt4) + { + ties++; + if (Compare(previousV0, currentV0) == 0) tiesAgreeing++; + continue; + } + if (Compare(previousV0, currentV0) > 0) + violations.Add($"{Describe(previous)} NT4 {previousNt4:X4} v0 {Convert.ToHexString(previousV0)} " + + $"> {Describe(current)} NT4 {currentNt4:X4} v0 {Convert.ToHexString(currentV0)}"); + } + + // Per block, so a script whose order Jet renumbered independently shows up as such rather than + // dragging down a single global figure. + var perBlock = new SortedDictionary(); + for (int i = 1; i < rows.Count; i++) + { + if (rows[i - 1].Nt4 == rows[i].Nt4) continue; + string block = BlockOf(rows[i].Character); + (int blockOrdered, int blockKept) = perBlock.GetValueOrDefault(block); + bool kept = Compare(rows[i - 1].V0, rows[i].V0) <= 0; + perBlock[block] = (blockOrdered + 1, blockKept + (kept ? 1 : 0)); + } + + int ordered = rows.Count - 1 - ties; + output.WriteLine(""); + output.WriteLine("agreement by block:"); + foreach ((string block, (int blockOrdered, int blockKept)) in perBlock) + output.WriteLine($" {block,-28} {blockKept,4}/{blockOrdered,-4} " + + $"{(blockOrdered == 0 ? 0 : 100.0 * blockKept / blockOrdered):F1}%"); + output.WriteLine(""); + output.WriteLine($"strictly-ordered NT4 pairs: {ordered}, of which {ordered - violations.Count} " + + $"({(ordered == 0 ? 0 : 100.0 * (ordered - violations.Count) / ordered):F2}%) " + + "keep their order in v0"); + output.WriteLine($"NT4 ties: {ties}, of which {tiesAgreeing} also tie in v0"); + output.WriteLine(""); + output.WriteLine($"order violations ({violations.Count}):"); + foreach (string line in violations.Take(40)) output.WriteLine($" {line}"); + } + + /// The DEFAULT SORTKEY block only: the per-locale COMPRESSION tables that follow redefine the + /// same code points and would silently corrupt the parse. Columns are + /// codepoint SM AW DW CW; the primary is (SM, AW). + private static Dictionary ParseTable(string path) + { + var weights = new Dictionary(); + bool inSortKey = false; + foreach (string line in File.ReadLines(path)) + { + string trimmed = line.Trim(); + if (trimmed.StartsWith("SORTKEY")) { inSortKey = true; continue; } + if (trimmed.StartsWith("ENDSORTKEY")) { inSortKey = false; continue; } + if (!inSortKey || !line.StartsWith("0x")) continue; + + string[] fields = line.Split(';')[0].Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (fields.Length != 5) continue; + int codePoint = Convert.ToInt32(fields[0], 16); + if (codePoint > 0xFFFF) continue; + weights[(char)codePoint] = (int.Parse(fields[1]) << 8) | int.Parse(fields[2]); + } + return weights; + } + + private static IEnumerable Characters() + { + (int First, int Last)[] blocks = + [ + (0x0020, 0x024F), (0x02B0, 0x02FF), (0x0370, 0x052F), (0x0590, 0x06FF), + (0x1E00, 0x1EFF), (0x2000, 0x206F), (0x20A0, 0x20BF), (0x2100, 0x218F), (0xFF01, 0xFF65), + ]; + foreach ((int first, int last) in blocks) + for (int c = first; c <= last; c++) + if (!char.IsControl((char)c) && !char.IsSurrogate((char)c)) + yield return (char)c; + } + + /// The primary weight bytes LibRed emits for a character under General v0, or false when it + /// refuses the character. An empty array means the character is ignorable. + private static bool TryPrimaries(char c, out byte[] primaries) + { + primaries = []; + var column = new ColumnDef + { + Name = "K", Type = JetDataType.Text, Index = 0, Collation = Collation.GeneralLegacy, + }; + byte[] key; + try { key = IndexKeyEncoder.Encode([(column, true)], [c.ToString()]); } + catch (NotSupportedException) { return false; } + + int split = key.Length - 2; + while (split > 0 && key[split] != 0x01) split--; + primaries = key[1..split]; + return true; + } + + private static int Compare(byte[] a, byte[] b) => a.AsSpan().SequenceCompareTo(b); + + private static string BlockOf(char c) => c switch + { + <= (char)0x00FF => "Latin-1 + ASCII", + <= (char)0x017F => "Latin Extended-A", + <= (char)0x024F => "Latin Extended-B", + <= (char)0x02FF => "Spacing modifiers", + <= (char)0x03FF => "Greek", + <= (char)0x052F => "Cyrillic", + <= (char)0x05FF => "Hebrew", + <= (char)0x06FF => "Arabic", + <= (char)0x1EFF => "Latin Extended Additional", + <= (char)0x206F => "General punctuation", + <= (char)0x20BF => "Currency", + <= (char)0x214F => "Letterlike", + <= (char)0x218F => "Number forms", + _ => "Fullwidth forms", + }; + + private static string Describe(char c) => + c is >= ' ' and <= '~' ? $"'{c}'" : $"U+{(int)c:X4}"; +} diff --git a/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs b/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs index 60f80605..42f75e15 100644 --- a/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs +++ b/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs @@ -6,29 +6,141 @@ namespace LibRed.Core.Tests; -// PROBE: derive tailoring tables from ACE rather than transcribing them by hand. +// PROBE: derive weight tables from ACE rather than transcribing them by hand. // -// For each sort-order fixture it has ACE encode every character in a broad range, reads the stored index -// keys back, and prints the entries needed to reproduce them — ready to paste into JetLocaleTailoring. Hand -// transcription of hex is exactly the kind of work that introduces a wrong byte nobody notices, because a -// wrong index key is silent. +// It has ACE encode every character in a range, reads the stored index keys back, and prints the entries +// needed to reproduce them — ready to paste into JetTextCollation or JetLocaleTailoring. Hand transcription +// of hex is exactly the kind of work that introduces a wrong byte nobody notices, because a wrong index key +// is silent: ACE simply writes its own keys into the same index and a seek misses rows. // -// It also reports what GENERAL itself cannot encode. Most of those are Latin Extended-A letters that -// decompose to a base letter plus a combining mark whose secondary weight is simply missing from -// JetTextCollation.DiacriticWeights — so the probe derives that weight too, which fixes the base table for -// every order at once. +// Three reports: +// Generate_general_coverage — everything General v0 does not yet encode, per block +// Generate_diacritic_weights_missing_from_general — combining marks whose secondary weight is missing +// Generate_tailoring_for — one locale's overrides against General public class TailoringGeneratorProbeTest(ITestOutputHelper output) { + /// The blocks worth sweeping for General v0 coverage: everything a Jet/ACE text column is + /// likely to hold that is not CJK. Each is (name, first, last). + private static readonly (string Name, int First, int Last)[] Blocks = + [ + ("Latin-1 + ASCII", 0x0020, 0x00FF), + ("Latin Extended-A", 0x0100, 0x017F), + ("Latin Extended-B", 0x0180, 0x024F), + ("Spacing modifiers", 0x02B0, 0x02FF), + ("Greek", 0x0370, 0x03FF), + ("Cyrillic", 0x0400, 0x04FF), + ("Cyrillic Supplement", 0x0500, 0x052F), + ("Hebrew", 0x0590, 0x05FF), + ("Arabic", 0x0600, 0x06FF), + ("Latin Extended Additional", 0x1E00, 0x1EFF), + ("General punctuation", 0x2000, 0x206F), + ("Currency", 0x20A0, 0x20BF), + ("Letterlike", 0x2100, 0x214F), + ("Number forms", 0x2150, 0x218F), + ("Fullwidth forms", 0xFF01, 0xFF65), + ]; + + // Sweeps every block against General v0 and classifies what ACE stores, so the base table can be filled + // in from measurement rather than a character at a time. Entries are grouped by the mechanism each needs. + [Fact] + public void Generate_general_coverage() + { + foreach ((string name, int first, int last) in Blocks) + { + string[] characters = Range(first, last); + Dictionary ace = AceKeys(TestDatabases.NorthwindAccdb, "cov", characters); + + var own = new List(); + var atomic = new List(); + var expansion = new List(); + var marks = new SortedDictionary(); + int correct = 0, refused = 0, ignorable = 0; + + foreach (string text in characters) + { + if (!ace.TryGetValue(text, out string? key)) { refused++; continue; } + if (Matches(text, key)) { correct++; continue; } + if (key.Contains("010101")) { ignorable++; continue; } // an inline (word-sort) record + + (byte[] primaries, byte secondary) = Decode(key); + string nfd = text.Normalize(NormalizationForm.FormD); + string bytes = string.Join(", ", primaries.Select(b => $"0x{b:X2}")); + + if (nfd.Length == 2 && char.IsLetter(nfd[0]) && primaries.Length == 1) + marks.TryAdd(nfd[1], secondary); + else if (primaries.Length == 1 && secondary != 0x02 && LetterFor(primaries[0]) is char b) + atomic.Add($"['{Escape(text[0])}'] = ('{b}', 0x{secondary:X2}),"); + else if (secondary == 0x02 && primaries.Length > 1 && primaries.All(p => LetterFor(p) is not null)) + expansion.Add($"['{Escape(text[0])}'] = \"{new string([.. primaries.Select(p => LetterFor(p)!.Value)])}\","); + else + own.Add($"['{Escape(text[0])}'] = new([{bytes}], 0x{secondary:X2}),".PadRight(48) + + $"// {key}"); + } + + output.WriteLine(""); + output.WriteLine($"=== {name}: {correct} correct, {own.Count + atomic.Count + expansion.Count} to add, " + + $"{marks.Count} new marks, {ignorable} ignorable, {refused} refused by ACE"); + foreach ((char mark, byte weight) in marks) output.WriteLine($" mark ['\\u{(int)mark:X4}'] = 0x{weight:X2},"); + foreach (string line in atomic.Take(30)) output.WriteLine($" atomic {line}"); + foreach (string line in expansion.Take(30)) output.WriteLine($" expand {line}"); + foreach (string line in own.Take(400)) output.WriteLine($" {line}"); + } + } + + // Emits each block as ONE compact string, in the format JetTextCollationBlocks parses: a start code + // point, then one token per consecutive character — + // - ignorable: ACE stores no weight at all for it (key 7F 01 00) + // ? no data: ACE refused the value, so the encoder must refuse it too + // primary bytes, default secondary + // , primary bytes and a secondary of its own + // 1500-odd entries as a dictionary literal would be unreadable and unreviewable; as runs of short tokens + // it stays diffable, and it is regenerated from ACE rather than hand-maintained. + [Fact] + public void Generate_block_tables() + { + foreach ((string name, int first, int last) in Blocks) + { + string[] characters = Range(first, last); + Dictionary ace = AceKeys(TestDatabases.NorthwindAccdb, "blk", characters); + + var tokens = new List(); + for (int c = first; c <= last; c++) + { + string text = ((char)c).ToString(); + if (char.IsControl((char)c) || char.IsSurrogate((char)c) || + !ace.TryGetValue(text, out string? key)) { tokens.Add("?"); continue; } + if (key == "7F0100") { tokens.Add("-"); continue; } + if (key.Contains("010101")) { tokens.Add("?"); continue; } // inline record; handled apart + (byte[] primaries, byte secondary) = Decode(key); + // A combining mark contributes a secondary weight and NO primary at all (key 7F 01 ss 00) — + // distinct from being ignorable, which contributes nothing whatsoever. + if (primaries.Length == 0 && secondary == 0x02) { tokens.Add("-"); continue; } + string hex = Convert.ToHexString(primaries); + tokens.Add(secondary == 0x02 ? hex : $"{hex},{secondary:X2}"); + } + + // Trim trailing no-data so a block does not carry a tail of question marks. + while (tokens.Count > 0 && tokens[^1] == "?") tokens.RemoveAt(tokens.Count - 1); + + output.WriteLine(""); + output.WriteLine($"// {name} — U+{first:X4}..U+{first + tokens.Count - 1:X4}"); + output.WriteLine($"\"{first:X4}|\" +"); + for (int i = 0; i < tokens.Count; i += 16) + output.WriteLine($" \"{string.Join(" ", tokens.Skip(i).Take(16))} \" +"); + } + } + [Fact] public void Generate_diacritic_weights_missing_from_general() { - Dictionary ace = AceKeys(TestDatabases.NorthwindAccdb, "general", Characters()); + string[] characters = Range(0x0020, 0x017F); + Dictionary ace = AceKeys(TestDatabases.NorthwindAccdb, "general", characters); var derived = new SortedDictionary(); var unexplained = new List(); foreach ((string text, string key) in ace) { - if (Encodable(text, Collation.GeneralLegacy)) continue; + if (Matches(text, key)) continue; string nfd = text.Normalize(NormalizationForm.FormD); (byte[] primaries, byte secondary) = Decode(key); // A base letter plus one combining mark, weighing as that letter's primary: the difference is @@ -67,17 +179,24 @@ public void Generate_diacritic_weights_missing_from_general() [InlineData("Slovak")] [InlineData("Vietnamese")] [InlineData("HungarianTechnical")] + [InlineData("Ukrainian")] + [InlineData("Macedonian")] + [InlineData("French")] + [InlineData("Thai")] public void Generate_tailoring_for(string fixture) { string source = TestDatabases.Data($"{fixture}.accdb"); Assert.SkipWhen(!File.Exists(source), $"{fixture}.accdb is not present"); - string[] characters = Characters(); + // Every block, not just Latin: the question is how far a locale departs from General across the + // whole range LibRed can encode, which is what decides whether the extended blocks can be shared. + string[] characters = [.. Blocks.SelectMany(b => Range(b.First, b.Last))]; Dictionary locale = AceKeys(source, fixture, characters); Dictionary general = AceKeys(TestDatabases.NorthwindAccdb, "general", characters); var letters = new List(); var accented = new List(); + var extended = new List(); foreach (string text in characters) { if (!locale.TryGetValue(text, out string? key)) continue; @@ -87,8 +206,15 @@ public void Generate_tailoring_for(string fixture) (byte[] primaries, byte secondary) = Decode(key); string bytes = string.Join(", ", primaries.Select(b => $"0x{b:X2}")); - if (secondary == 0x02) letters.Add($"(\"{text}\", [{bytes}])"); - else accented.Add($"(\"{text}\", [{bytes}], 0x{secondary:X2})"); + string entry = secondary == 0x02 + ? $"(\"{text}\", [{bytes}])" + : $"(\"{text}\", [{bytes}], 0x{secondary:X2})"; + + // Beyond Latin Extended-A the General block tables already carry a weight, so only the entries + // where the locale DIFFERS need adding — those are what stop a locale sharing those blocks. + if (text[0] > (char)0x017F) extended.Add($"{entry} // U+{(int)text[0]:X4}"); + else if (secondary == 0x02) letters.Add(entry); + else accented.Add(entry); } output.WriteLine($"// --- {fixture} ---"); @@ -96,20 +222,49 @@ public void Generate_tailoring_for(string fixture) foreach (string line in Wrap(letters)) output.WriteLine($" {line}"); output.WriteLine($"accented ({accented.Count}):"); foreach (string line in Wrap(accented)) output.WriteLine($" {line}"); + output.WriteLine($"EXTENDED ({extended.Count}) — beyond Latin Extended-A, i.e. what a locale needs on " + + "top of the General block tables:"); + foreach (string line in extended) output.WriteLine($" {line}"); } - /// Printable ASCII, Latin-1 and Latin Extended-A — the range these orders tailor. The C1 - /// controls are skipped: ACE treats them as ignorables with an inline record rather than as letters, - /// so they are a separate topic and would otherwise fill the report. - private static string[] Characters() + /// The printable characters of a code-point range. Controls are skipped: ACE treats them as + /// ignorables with an inline record rather than as letters, so they are a separate topic. + private static string[] Range(int first, int last) { var characters = new List(); - for (char c = ' '; c <= 'ſ'; c++) - if (c is < '' or > 'Ÿ') - characters.Add(c.ToString()); + for (int c = first; c <= last; c++) + if (!char.IsControl((char)c) && !char.IsSurrogate((char)c)) + characters.Add(((char)c).ToString()); return [.. characters]; } + /// Whether LibRed already encodes exactly as ACE did. + private static bool Matches(string text, string expected) + { + var column = new ColumnDef + { + Name = "K", Type = JetDataType.Text, Index = 0, Collation = Collation.GeneralLegacy, + }; + try { return Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [text])) == expected; } + catch (NotSupportedException) { return false; } + } + + /// The A–Z letter a primary weight belongs to, if any — used to recognise an atomic accent or + /// an expansion without hard-coding the letter table twice. + private static char? LetterFor(byte primary) + { + byte[] letters = + [ + 0x4A, 0x4C, 0x4D, 0x4F, 0x51, 0x53, 0x55, 0x57, 0x59, 0x5B, 0x5C, 0x5E, 0x60, + 0x62, 0x64, 0x66, 0x68, 0x69, 0x6B, 0x6D, 0x6F, 0x71, 0x73, 0x75, 0x76, 0x78, + ]; + int index = Array.IndexOf(letters, primary); + return index < 0 ? null : (char)('A' + index); + } + + private static string Escape(char c) => + c is >= ' ' and <= '~' && c != '\'' && c != '\\' ? c.ToString() : $"\\u{(int)c:X4}"; + /// Splits an index key into its primary bytes and its single secondary weight (the default /// 0x02 when the section is empty). Reads from the end: the key is /// 7F primaries… 01 secondaries… 00, and no observed secondary weight is 0x01. @@ -124,13 +279,6 @@ private static (byte[] Primaries, byte Secondary) Decode(string hex) return (primaries, secondary); } - private static bool Encodable(string text, Collation collation) - { - var column = new ColumnDef { Name = "K", Type = JetDataType.Text, Index = 0, Collation = collation }; - try { IndexKeyEncoder.Encode([(column, true)], [text]); return true; } - catch (NotSupportedException) { return false; } - } - private static IEnumerable Wrap(List entries) { for (int i = 0; i < entries.Count; i += 4) From ddb499fcc8a1647cb50cef34b1968c6cac2c3e0e Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 17:25:41 +0800 Subject: [PATCH 08/48] LibRed: fix reading an index where many rows share a key Prefix compression covers the WHOLE entry including its trailer, not just the key bytes. Reading it as key-only worked until a page held many equal keys, which is exactly what a full-BMP sweep produces - thousands of ignorable characters all encoding to the same empty key - and then the reader rejected the page outright. Found by the probes added here rather than by a test written for it, which is the argument for sweeping a whole range instead of sampling it. Co-Authored-By: Claude Opus 5 --- .../LibRed.Core/Storage/IndexPageReader.cs | 47 ++++--- .../docs/format/page-03-04-index-btree.md | 30 ++++- .../DuplicateIndexKeyProbeTest.cs | 117 ++++++++++++++++++ .../TailoringGeneratorProbeTest.cs | 41 ++++++ 4 files changed, 216 insertions(+), 19 deletions(-) create mode 100644 test/LibRed.Core.Tests/DuplicateIndexKeyProbeTest.cs diff --git a/src/LibRed/LibRed.Core/Storage/IndexPageReader.cs b/src/LibRed/LibRed.Core/Storage/IndexPageReader.cs index 579eaf43..e051cf92 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexPageReader.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexPageReader.cs @@ -46,6 +46,12 @@ public static CheckedIndexPage Read(PageChannel channel, int pageNumber, int? ex ValidatePageNumber(channel, tail, "node child-tail"); } + // The shared prefix is measured across the WHOLE entry, trailer included — not just the key. Where + // many rows share a key the trailer's leading bytes are common too (consecutive rows on one data + // page), so ACE compresses those away and the stored remainder can be as little as two bytes. Size + // limits therefore apply to the reconstructed entry, never to what is stored. + int compressed = buffer.ReadUInt16(CompressedByteCountOffset); + var ranges = new List<(int Start, int End)>(); int start = 0; for (int i = EntryMaskOffset; i < EntryDataOffset; i++) @@ -55,26 +61,34 @@ public static CheckedIndexPage Read(PageChannel channel, int pageNumber, int? ex { if ((mask & (1 << bit)) == 0) continue; int end = (i - EntryMaskOffset) * 8 + bit; - if (end - start < 4 || EntryDataOffset + end > buffer.Length) + if (EntryDataOffset + end > buffer.Length) + throw new InvalidDataException( + $"Index page {pageNumber} entry [{start}, {end}) runs past the end of the page."); + // The first entry is stored whole; every later one is the prefix plus what is stored. + int length = ranges.Count == 0 ? end - start : compressed + (end - start); + if (length < 4) throw new InvalidDataException( - $"Index page {pageNumber} entry [{start}, {end}) cannot contain its 4-byte trailer."); + $"Index page {pageNumber} entry [{start}, {end}) reconstructs to {length} bytes, " + + "too few for its 4-byte trailer."); ranges.Add((start, end)); start = end; } } - int compressed = buffer.ReadUInt16(CompressedByteCountOffset); if (ranges.Count == 0 && compressed != 0) throw new InvalidDataException($"Empty index page {pageNumber} declares a compressed prefix."); - if (ranges.Count > 0 && compressed > ranges[0].End - ranges[0].Start - 4) + if (ranges.Count > 0 && compressed > ranges[0].End - ranges[0].Start) throw new InvalidDataException( - $"Index page {pageNumber} compressed prefix {compressed} exceeds its first key."); + $"Index page {pageNumber} compressed prefix {compressed} exceeds its first entry."); + + var page = new CheckedIndexPage(buffer, type, owner, previous, next, tail, compressed, ranges); + // Node children have to be read from the RECONSTRUCTED entry, for the same reason. if (type == PageType.IntermediateIndexPage) - foreach ((_, int end) in ranges) - ValidatePageNumber(channel, ReadInt32BigEndian(buffer, EntryDataOffset + end - 4), "node child"); + foreach ((_, int child) in DecodeEntries(page)) + ValidatePageNumber(channel, child, "node child"); - return new CheckedIndexPage(buffer, type, owner, previous, next, tail, compressed, ranges); + return page; } public static int ReadInt32BigEndian(PageBuffer page, int offset) => @@ -84,18 +98,23 @@ public static int ReadInt32BigEndian(PageBuffer page, int offset) => /// entry is stored whole and its leading CompressedByteCount bytes are the prefix reapplied to every /// following entry. Yields the full key bytes and the 4-byte big-endian trailer (a leaf entry's row pointer /// or a node entry's child page). Shared by the cursor's leaf enumeration and the writer's parse so the - /// prefix rule lives in exactly one place. + /// prefix rule lives in exactly one place. + /// + /// The prefix covers the entry whole, so it can reach into the trailer — with many equal keys the + /// rows are consecutive on one data page and share the trailer's leading bytes too. Both the key and the + /// trailer are therefore taken from the reconstructed entry, never from the stored bytes. + /// public static IEnumerable<(byte[] Key, int Trailer)> DecodeEntries(CheckedIndexPage page) { byte[] prefix = []; bool first = true; foreach ((int start, int end) in page.EntryRanges) { - int trailer = ReadInt32BigEndian(page.Buffer, EntryDataOffset + end - 4); - ReadOnlySpan stored = page.Buffer.Slice(EntryDataOffset + start, end - start - 4); - byte[] key = first ? stored.ToArray() : Concat(prefix, stored); - if (first) { prefix = key[..page.CompressedByteCount]; first = false; } - yield return (key, trailer); + ReadOnlySpan stored = page.Buffer.Slice(EntryDataOffset + start, end - start); + byte[] entry = first ? stored.ToArray() : Concat(prefix, stored); + if (first) { prefix = entry[..page.CompressedByteCount]; first = false; } + int trailer = BinaryPrimitives.ReadInt32BigEndian(entry.AsSpan(entry.Length - 4)); + yield return (entry[..^4], trailer); } } 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 0a6e0da4..f4afb37a 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -34,7 +34,8 @@ Each entry ends with a **4-byte big-endian** trailing pointer: > **Reader/traversal guardrails.** LibRed validates every page number before I/O, requires page type > `0x03`/`0x04` and a consistent owning TDEF, bounds every bitmask-derived entry before reading its -> 4-byte trailer, and requires the compressed prefix to fit the first key. Node child/tail, leaf +> 4-byte trailer *after reconstruction* (§10.3), and requires the compressed prefix to fit the first +> entry. Node child/tail, leaf > previous/next, and indexed-row page pointers are checked against the file's page range; optional leaf > links must be zero or name an in-file page. Point/range seeks track every > descent and leaf-chain page and reject repeats; the full index cursor uses an iterative ordered walk @@ -46,10 +47,29 @@ Each entry ends with a **4-byte big-endian** trailing pointer: ### 10.3 Prefix compression -Entries on a page share a leading key prefix of `compressedByteCount` (`0x18`) bytes. The -**first** entry is stored in full; its first `compressedByteCount` bytes are the shared prefix, -which every subsequent entry omits. Reconstruct: `fullKey = prefix ++ storedKey`. (The trailing -pointer is never compressed, so reading row pointers needs none of this.) +Entries on a page share a leading prefix of `compressedByteCount` (`0x18`) bytes. The **first** entry is +stored in full; its first `compressedByteCount` bytes are the shared prefix, which every subsequent entry +omits. Reconstruct: `fullEntry = prefix ++ stored`. + +> **The prefix covers the entry whole — it can reach into the trailer.** An earlier revision of this section +> claimed "the trailing pointer is never compressed, so reading row pointers needs none of this". That is +> **wrong**, and it made LibRed reject pages ACE had written. When many rows share a key they are also +> consecutive on one data page, so the trailer's leading bytes are common too and ACE compresses them away. +> A leaf holding 500 rows all keyed `"same"`: +> +> ``` +> compressedByteCount = 9 +> entry 0 (11 bytes) 7F 6B 4A 60 51 01 00 | 00 01 62 00 key "same", then row (page 354, row 0) +> entry 1 (2 bytes) 62 01 → prefix ++ 62 01 = … 00 01 62 01, row 1 +> entry 2 (2 bytes) 62 02 → row 2 +> ``` +> +> The prefix `7F 6B 4A 60 51 01 00 00 01` is the seven-byte key **plus the first two bytes of the trailer**, +> leaving two stored bytes per entry. So **size limits apply to the reconstructed entry, never to what is +> stored** — a stored entry may be shorter than the 4-byte trailer, and the key may be empty. Take both the +> key and the trailer from the reconstruction. Likewise `compressedByteCount` is bounded by the first +> entry's **whole** length, not by its key. (`DuplicateIndexKeyProbeTest`; the old reading refused any index +> with ~500+ equal keys, which is ordinary for a non-unique index.) > **Compression is optional on leaves.** A `compressedByteCount` of 0 (every entry stored in full) > is a valid *leaf* that Access reads without complaint — verified by rewriting a leaf uncompressed diff --git a/test/LibRed.Core.Tests/DuplicateIndexKeyProbeTest.cs b/test/LibRed.Core.Tests/DuplicateIndexKeyProbeTest.cs new file mode 100644 index 00000000..b5c7cbd4 --- /dev/null +++ b/test/LibRed.Core.Tests/DuplicateIndexKeyProbeTest.cs @@ -0,0 +1,117 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// Reading an index where many rows share one key — ordinary for any non-unique index, and once a real bug. +// +// A full-BMP sweep died at U+4000 with "entry [7, 9) cannot contain its 4-byte trailer", IndexPageReader +// refusing a page ACE had written. CJK is largely ignorable in General v0, so thousands of rows shared the +// identical key, and once the index outgrew a single leaf the prefix compression became severe enough to +// break the reader's assumptions. 100 rows read fine; 500 and above read NOTHING. +// +// The cause was that the shared prefix covers the whole entry, trailer included — see IndexPageReader — so +// the stored remainder can be two bytes. These cases now assert, since nothing about them is exotic. +public class DuplicateIndexKeyProbeTest(ITestOutputHelper output) +{ + [Theory] + [InlineData(100)] + [InlineData(500)] + [InlineData(1000)] + [InlineData(2000)] + [InlineData(4000)] + public void Probe_reading_an_index_with_many_equal_keys(int rows) + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, $"dupkey-{rows}-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE Dup (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_Dup ON Dup (K)"); + for (int i = 0; i < rows; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Dup (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", "same"); + insert.Parameters.AddWithValue("v", i); + insert.ExecuteNonQuery(); + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("Dup"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_Dup"); + + int read = 0; + Exception? failure = null; + try + { + foreach ((byte[] _, RowId _) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + read++; + } + catch (Exception ex) { failure = ex; } + + output.WriteLine($"{rows} equal keys: read {read} entries" + + (failure is null ? " — OK" : $" then {failure.GetType().Name}: {failure.Message}")); + Assert.Null(failure); + Assert.Equal(rows, read); + } + finally { TemporaryDatabase.Delete(path); } + } + + // Dumps the raw bytes of the index root once duplicates have forced a second level, because the entry + // layout has to be read off the page rather than reasoned about: under the model LibRed implements — + // key suffix followed by a 4-byte trailer — a 2-byte entry cannot exist, yet ACE wrote one. + [Fact] + public void Probe_the_node_page_layout() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "dupkey-dump-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE Dup (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_Dup ON Dup (K)"); + for (int i = 0; i < 500; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Dup (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", "same"); + insert.Parameters.AddWithValue("v", i); + insert.ExecuteNonQuery(); + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("Dup"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_Dup"); + var page = table.Channel.ReadPageShared(index.RootPage); + + output.WriteLine($"root page {index.RootPage}: type 0x{page.ReadByte(0):X2}, " + + $"owner {page.ReadInt32(0x04)}, prev {page.ReadInt32(0x0C)}, " + + $"next {page.ReadInt32(0x10)}, tail {page.ReadInt32(0x14)}, " + + $"compressed {page.ReadUInt16(0x18)}, byte 0x1A 0x{page.ReadByte(0x1A):X2}"); + + var ends = new List(); + for (int i = 0x1B; i < 0x1E0 && ends.Count < 24; i++) + { + byte mask = page.ReadByte(i); + for (int bit = 0; bit < 8; bit++) + if ((mask & (1 << bit)) != 0) ends.Add((i - 0x1B) * 8 + bit); + } + output.WriteLine($"first entry ends: {string.Join(", ", ends)}"); + output.WriteLine($"entry data 0x1E0..+64: {Convert.ToHexString(page.Slice(0x1E0, 64))}"); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs b/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs index 42f75e15..60e81c52 100644 --- a/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs +++ b/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs @@ -40,6 +40,47 @@ private static readonly (string Name, int First, int Last)[] Blocks = ("Fullwidth forms", 0xFF01, 0xFF65), ]; + // RECONNAISSANCE: how much of the BMP does ACE actually weigh, and how much of it do we already have? + // + // The block list above is a guess at "what a Jet text column plausibly holds", so "complete coverage" so + // far means complete for that guess — it leaves out Devanagari, Thai, Georgian, the presentation forms + // (which contain fi), Greek Extended, CJK, Hangul and more. This sweeps all 65,536 code points in chunks + // and counts what falls where, which is what decides whether the compact per-block strings can scale or + // whether this needs a generated binary resource like the v1 table. + // + // Slow by nature: every character is a real INSERT through ACE. Run it deliberately, not in a suite. + [Fact] + public void Probe_full_bmp_coverage() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_FULL_BMP") == "1", + "set LIBRED_FULL_BMP=1 — this inserts ~63,000 rows through ACE and takes minutes"); + + int totalCorrect = 0, totalToAdd = 0, totalIgnorable = 0, totalRefused = 0; + output.WriteLine($" {"range",-14} {"correct",8} {"to add",8} {"ignorable",10} {"ACE refused",12}"); + for (int chunk = 0x0000; chunk <= 0xF000; chunk += 0x1000) + { + string[] characters = Range(chunk, chunk + 0x0FFF); + if (characters.Length == 0) continue; + Dictionary ace = AceKeys(TestDatabases.NorthwindAccdb, "bmp", characters); + + int correct = 0, toAdd = 0, ignorable = 0, refused = 0; + foreach (string text in characters) + { + if (!ace.TryGetValue(text, out string? key)) { refused++; continue; } + if (Matches(text, key)) { correct++; continue; } + if (key == "7F0100") { ignorable++; continue; } + toAdd++; + } + + totalCorrect += correct; totalToAdd += toAdd; totalIgnorable += ignorable; totalRefused += refused; + output.WriteLine($" U+{chunk:X4}..U+{chunk + 0xFFF:X4} {correct,8} {toAdd,8} {ignorable,10} {refused,12}"); + } + + output.WriteLine(""); + output.WriteLine($" BMP total: {totalCorrect} already correct, {totalToAdd} to add, " + + $"{totalIgnorable} ignorable-but-unhandled, {totalRefused} ACE would not store"); + } + // Sweeps every block against General v0 and classifies what ACE stores, so the base table can be filled // in from measurement rather than a character at a time. Entries are grouped by the mechanism each needs. [Fact] From 8f273448b0ac8c017adf7b36c3797c66e5301b6a Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 17:26:06 +0800 Subject: [PATCH 09/48] LibRed: General Legacy covers the whole Basic Multilingual Plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every character ACE stores a key for, LibRed now encodes identically - 63,422 of them. No published table describes v0, since its primaries are a Jet compaction rather than the NLS weights, so ACE ITSELF is the source: SortKeyTableV0GeneratorTest inserts every code point into an indexed text column, reads the stored keys back and writes the embedded resource (74 KB; 63,105 weights, 40 word-sort ignorables, 276 kana). Far past anything hand-maintainable, and hand-transcribing hex is exactly the work that introduces a wrong byte nobody notices. Two things only a full sweep shows: ACE weighs every CJK ideograph and the entire private-use area, and across all 65,536 code points it refused exactly one. KANA take a two-byte primary 7F , with voicing as an ordinary secondary and the small/normal distinction bit-packed into a section of its own - three per byte, two bits each, most significant first, under a 10 marker. Two rules only multi-character strings reveal: the halfwidth voicing marks are COMBINING (alone they look ignorable, which is what hid it), and the inline section's introducer becomes FF 01 when a kana section precedes it. The prolonged sound mark lengthens the preceding kana's VOWEL, which is what the character means - がー is "ga" lengthened by "a", not by "ga" - so the vowel is a property of each kana and has to be measured per character rather than derived. Also: inline positions count primary WEIGHTS, not bytes, and the hand-verified expansions stay ahead of the measured table because a key cannot show whether two bytes are one weight or two. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Core/LibRed.Core.csproj | 4 + .../LibRed.Core/Resources/SortKeyTableV0.bin | Bin 0 -> 74152 bytes .../LibRed.Core/Storage/JetTextCollation.cs | 178 +++++++-- .../Storage/JetTextCollationBlocks.cs | 218 ----------- .../Storage/JetTextCollationTableV0.cs | 158 ++++++++ .../LibRed.Core/Storage/JetTextCollationV1.cs | 6 + .../docs/format/page-03-04-index-btree.md | 84 ++++- .../LibRed.Core.Tests/ContractionProbeTest.cs | 214 +++++++++++ .../LocaleCollationAccessTests.cs | 12 + .../SortKeyTableV0GeneratorTest.cs | 351 ++++++++++++++++++ 10 files changed, 972 insertions(+), 253 deletions(-) create mode 100644 src/LibRed/LibRed.Core/Resources/SortKeyTableV0.bin delete mode 100644 src/LibRed/LibRed.Core/Storage/JetTextCollationBlocks.cs create mode 100644 src/LibRed/LibRed.Core/Storage/JetTextCollationTableV0.cs create mode 100644 test/LibRed.Core.Tests/SortKeyTableV0GeneratorTest.cs diff --git a/src/LibRed/LibRed.Core/LibRed.Core.csproj b/src/LibRed/LibRed.Core/LibRed.Core.csproj index 7c24198f..786c6c92 100644 --- a/src/LibRed/LibRed.Core/LibRed.Core.csproj +++ b/src/LibRed/LibRed.Core/LibRed.Core.csproj @@ -13,6 +13,10 @@ + + diff --git a/src/LibRed/LibRed.Core/Resources/SortKeyTableV0.bin b/src/LibRed/LibRed.Core/Resources/SortKeyTableV0.bin new file mode 100644 index 0000000000000000000000000000000000000000..a046e389ed4c465f8282a3f3f1a2a52c26141d60 GIT binary patch literal 74152 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�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 z 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 literal 0 HcmV?d00001 diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs index 5402492b..db3b1b4d 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs @@ -64,6 +64,14 @@ internal static class JetTextCollation }; private const byte DefaultSecondary = 0x02; // a character with no accent + /// The page byte every kana primary starts with: a kana weighs 7F <sound>. + private const byte KanaPage = 0x7F; + + /// Closes the kana section, after the FF that introduces the prolonged-mark flags. + /// Constant across hiragana, katakana, halfwidth, small and voiced forms in every string measured, so it + /// is emitted literally; what it denotes is not established. + private static ReadOnlySpan KanaSectionTail => [0x02, 0x80, 0xFF, 0x80]; + // Secondary (diacritic) weight per Unicode combining mark — depends only on the accent, not the base // letter (verified against ACE: acute weighs 0x0E on a/e/i/o/u/y alike, etc.). private static readonly Dictionary DiacriticWeights = new() @@ -213,10 +221,23 @@ public static bool TryEncode(string value, List output, LocaleTailoring? t // Build the primary weight bytes and a parallel secondary weight per byte (0x02 = no accent). var primaries = new List(); var secondaries = new List(); - // Apostrophe/hyphen carry no primary weight; they record (position, code) for the inline - // section, where position is the count of **primary weight bytes** emitted before them (so a - // multi-byte expansion like ß→SS counts as 2 — verified against ACE). + // Apostrophe/hyphen carry no primary weight; they record (position, code) for the inline section, + // where position is the count of primary **WEIGHTS** emitted before them — not bytes. A two-byte + // weight counts once: ACE puts the hyphen of "£-" at 0x0B (0x07 + 4x1) though £ is 34 A7, while + // "ß-" is 0x0F because ß expands to two one-byte weights. Latin-only strings cannot tell the two + // rules apart, which is why this read as "bytes" for so long. secondaries.Count is the weight count, + // since every weight contributes exactly one secondary slot. var inline = new List<(int Position, byte Code)>(); + // One entry per kana in the string: true for a small form. Emitted as a section of its own. + var kana = new List(); + // True where that kana is a prolonged sound mark. Packed like the small flags but with its own + // codes, into a section of its own. + var prolonged = new List(); + // Index of the weight the last kana produced, so a following halfwidth voicing mark can reach it, + // together with the vowel and small flag a following prolonged mark inherits. + int kanaWeight = -1; + byte kanaVowel = 0; + bool kanaSmall = false; // Indexed rather than foreach, because a tailoring entry can consume several characters: a // contraction is a digraph weighing as one letter (Czech "ch", Hungarian "gy", Danish "aa"). @@ -224,9 +245,52 @@ public static bool TryEncode(string value, List output, LocaleTailoring? t { char c = s[position]; char u = char.ToUpperInvariant(c); - if (Ignorables.TryGetValue(c, out byte code)) + // The hand-verified ignorables first, then the measured ones — 296 across the BMP, every dash and + // quotation form, the Arabic harakat, and the CJK and fullwidth punctuation. + if (Ignorables.TryGetValue(c, out byte code) || + JetTextCollationTableV0.TryGetInlineCode(c, out code)) + { + inline.Add((secondaries.Count, code)); + continue; + } + + // Kana take the two-byte primary 7F , with voicing as an ordinary secondary and the + // small/normal distinction recorded in a section of their own. Handled here rather than in + // WeighCharacter because a single kana changes the shape of the WHOLE key. + // The prolonged sound mark lengthens the preceding kana's VOWEL, so it takes that vowel's + // primary — がー is 7F 0A then 7F 02, "ga" lengthened by "a" — and inherits its small flag, + // while marking itself in a second packed section. With no kana ahead of it there is nothing to + // 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) { - inline.Add((primaries.Count, code)); + AddWeight([KanaPage, kanaVowel], DefaultSecondary); + kana.Add(kanaSmall); + prolonged.Add(true); + kanaWeight = secondaries.Count - 1; + continue; + } + + if (JetTextCollationTableV0.TryGetKana(c, out byte sound, out byte voicing, out bool small, + out byte vowel)) + { + AddWeight([KanaPage, sound], voicing); + kana.Add(small); + prolonged.Add(false); + kanaWeight = secondaries.Count - 1; + kanaVowel = vowel; + kanaSmall = small; + continue; + } + + // The halfwidth voicing marks are COMBINING: ACE folds them into the preceding kana's secondary + // rather than weighing them. ニホンゴ is four primaries with secondaries 02 02 02 03 — the コ voiced + // by the ゙ that follows it. Measured alone the marks look ignorable, which is what hid this. + // With no kana immediately before it there is nothing to voice, and the mark falls through to be + // weighed on its own — ACE stores it as ignorable. Both halves of the guard matter: for a lone + // mark kanaWeight and Count-1 are each -1, which would otherwise pass and index the list at -1. + if (c is (char)0xFF9E or (char)0xFF9F && kanaWeight >= 0 && kanaWeight == secondaries.Count - 1) + { + secondaries[kanaWeight] = c == (char)0xFF9E ? (byte)0x03 : (byte)0x04; continue; } @@ -262,12 +326,33 @@ public static bool TryEncode(string value, List output, LocaleTailoring? t for (int i = 0; i <= lastAccent; i++) output.Add(secondaries[i]); - // Apostrophe/hyphen inline (tertiary) section. - if (inline.Count > 0) + // Kana section: 01 01, the packed small/normal flags, then a constant. Present whenever the string + // holds any kana at all, even if every one of them is a normal form. + if (kana.Count > 0) { output.Add(0x01); output.Add(0x01); - output.Add(0x01); + AddKanaFlags(output, kana, marked: 0b10, unmarked: 0b11); + output.Add(0xFF); + AddKanaFlags(output, prolonged, marked: 0b11, unmarked: 0b01); + output.AddRange(KanaSectionTail); + } + + // Apostrophe/hyphen inline (tertiary) section. Its introducer depends on whether a kana section came + // first: 01 01 01 on its own, but FF 01 after one — measured from "あ-" and "-あ". + if (inline.Count > 0) + { + if (kana.Count > 0) + { + output.Add(0xFF); + output.Add(0x01); + } + else + { + output.Add(0x01); + output.Add(0x01); + output.Add(0x01); + } foreach (var (position, code) in inline) { output.Add(InlineStart); @@ -302,18 +387,27 @@ bool WeighCharacter(char character) // which is not what ACE weighs it as (ACE gives it a symbol weight in the 0x34 group). else if (Symbols.TryGetValue(character, out byte[]? weights) || Symbols.TryGetValue(upper, out weights)) AddWeight(weights, DefaultSecondary); - // The measured block tables for Greek, Cyrillic, the Latin extensions, punctuation and the rest. - // They cover no character the hand-verified Latin-1 / Latin Extended-A tables do, so they cannot - // override anything already proven — but they DO take precedence over the decomposition below, - // which is guesswork by comparison: a measured weight beats a derived one. A null weight means - // ACE stores nothing at all for the character, not even a secondary slot. + // Explicit hand-verified expansions and atomic accents come BEFORE the measured table, because a + // single-character measurement cannot tell one two-byte weight from two one-byte ones. ß is two + // weights (S+S); the table records it as the two bytes 6B 6B and would make it one, which is + // invisible until something counts weights — an accent after it, or an inline record's position. + else if (TryAddExplicit(upper, Add)) + { + // handled by the expansion / atomic-accent tables + } + // The measured table for the rest of the BMP — Greek, Cyrillic, Hebrew, Arabic, the Latin + // extensions, punctuation, CJK and the rest. It covers nothing the hand-verified Latin-1 and + // Latin Extended-A tables above do, so it cannot override anything already proven — but it DOES + // take precedence over the decomposition below, which is guesswork by comparison: a measured + // weight beats a derived one. A null weight means ACE stores nothing at all for the character, + // not even a secondary slot. // - // Locales share them. A locale CAN reweigh a character in these blocks, but measuring all 21 - // against General showed the departures are tiny — most add one or two entries across the whole - // range, Croatian eleven — and every one of them is listed in its tailoring, which is consulted - // first. `LocaleCollationAccessTests` asserts the whole range for every locale, so a missed - // departure fails rather than writing a silently wrong key. - else if (JetTextCollationBlocks.TryGet(character, out TailoredWeight? block)) + // Locales share it. A locale CAN reweigh a character here, but measuring all 21 against General + // showed the departures are tiny — most add one or two entries across the whole range, Croatian + // eleven — and every one is listed in its tailoring, which is consulted first. + // `LocaleCollationAccessTests` asserts the whole range for every locale, so a missed departure + // fails rather than writing a silently wrong key. + else if (JetTextCollationTableV0.TryGet(character, out TailoredWeight? block)) { if (block is { } weight) AddWeight(weight.Primaries, weight.Secondary); } @@ -330,8 +424,8 @@ void Add(byte primary, byte secondary = DefaultSecondary) // A primary WEIGHT may be one or two bytes, and the secondary section has one entry per weight — // not per byte. Measured against ACE: Norwegian "ö" is 7F 79 06 01 13 00, two primary bytes and a - // single secondary. (The inline apostrophe/hyphen section counts differently, by primary *bytes* — - // hence `primaries.Count` there rather than `secondaries.Count`.) + // single secondary. The inline apostrophe/hyphen section counts weights too, so both sections index + // the same way; `secondaries.Count` is the weight count for both. void AddWeight(ReadOnlySpan weight, byte secondary) { foreach (byte b in weight) primaries.Add(b); @@ -342,7 +436,35 @@ void AddWeight(ReadOnlySpan weight, byte secondary) /// Emits the primary+secondary weight(s) for an accented or special Latin-1 letter (uppercased): /// a multi-letter expansion (ß=SS, Þ=TH, Æ=AE), an atomic accent (Ø, Ð), or a Unicode canonical /// decomposition (base letter + combining mark). Returns false if the character is unknown. - private static bool TryAddAccented(char u, Action add) + /// + /// Packs the kana small/normal flags. Trailing normal forms are dropped — a string whose last small kana + /// is at index 1 encodes the same however many normal kana follow — and if none is small the section + /// carries no flag bytes at all. What remains goes three per byte, two bits each, most significant + /// first, under a 10 marker in the top two bits: 11 normal, 10 small, + /// 00 padding. So one small kana is A0, "normal small" is B8, and four kana take two + /// bytes, the second repeating the marker. Verified against ACE over all 30 combinations up to four kana. + /// + private static void AddKanaFlags(List output, List flags, int marked, int unmarked) + { + int last = flags.LastIndexOf(true); + for (int start = 0; start <= last; start += 3) + { + int packed = 0x80; + for (int slot = 0; slot < 3; slot++) + { + int index = start + slot; + int code = index > last ? 0b00 : flags[index] ? marked : unmarked; + packed |= code << (4 - 2 * slot); + } + output.Add((byte)packed); + } + } + + /// The explicit, hand-verified half: a multi-letter expansion (ß=SS, Þ=TH, Æ=AE) or an atomic + /// accent (Ø, Ð). Each expanded letter is its own weight — the part no single-character + /// measurement can capture, since a key cannot show whether two bytes are one weight or two — so this is + /// consulted ahead of the measured table. + private static bool TryAddExplicit(char u, Action add) { if (Expansions.TryGetValue(u, out string? expansion)) { @@ -354,6 +476,18 @@ private static bool TryAddAccented(char u, Action add) add(Letters[atomic.Base - 'A'], atomic.Secondary); return true; } + return false; + } + + /// The derived half: a Unicode canonical decomposition into a base A–Z letter plus one combining + /// mark we hold a weight for. Guesswork beside a measurement, so it runs last of all. + private static bool TryAddAccented(char u, Action add) + { + // Normalize throws on anything that is not a well-formed scalar — an unpaired surrogate, or a + // NONCHARACTER (U+FDD0..U+FDEF and any code point ending FFFE/FFFF). Those are legal in a .NET + // string, so refuse them rather than letting an ArgumentException escape a Try- method. + if (char.IsSurrogate(u) || u is >= (char)0xFDD0 and <= (char)0xFDEF || (u & 0xFFFE) == 0xFFFE) + return false; // Canonical decomposition: a base A–Z letter followed by one combining diacritic we know. string nfd = u.ToString().Normalize(System.Text.NormalizationForm.FormD); diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationBlocks.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationBlocks.cs deleted file mode 100644 index 015f413e..00000000 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollationBlocks.cs +++ /dev/null @@ -1,218 +0,0 @@ -namespace LibRed.Storage; - -/// -/// The General v0 weights for the Unicode blocks beyond Latin-1 and Latin Extended-A — Greek, Cyrillic, -/// Hebrew, Arabic, the Latin extensions, punctuation, currency and the fullwidth forms. Consulted by -/// only after its own tables, so nothing here can change a weight that was -/// already verified by hand. -/// -/// -/// Every byte was measured from ACE and is regenerated by TailoringGeneratorProbeTest: it has ACE -/// encode each character in a block, reads the stored index keys back, and prints these strings. They are -/// data, not something to hand-edit — around 1,500 entries as a dictionary literal would be neither readable -/// nor reviewable, and transcribing hex by hand is how a wrong byte gets in unnoticed. -/// -/// Each string is start| then one token per consecutive code point: -/// -/// -ignorable: ACE stores no weight at all for it, so it contributes nothing to a key, -/// not even a secondary slot. Romanian's comma-below ș/ț are in this class, which is why they -/// keep "General weights" — General has none for them. -/// ? — no data: ACE would not store the character, so the encoder refuses it rather than -/// guessing. -/// HEX — the primary weight bytes, with the default secondary. -/// HEX,SS — primary bytes and a secondary of its own. -/// -/// Note the non-Latin scripts nearly all live on the two-byte 0x79 page, the same page the locale -/// tailorings use for letters that sort after Z. -/// -/// -internal static class JetTextCollationBlocks -{ - /// The weight for a character, or null when it is ignorable. False when the block tables have - /// nothing for it, in which case the caller must refuse the value rather than emit a guess. - public static bool TryGet(char c, out TailoredWeight? weight) => Entries.Value.TryGetValue(c, out weight); - - // Lazy, because a static field initialiser would run before Tables below is assigned. - private static readonly Lazy> Entries = new(Parse); - - private static Dictionary Parse() - { - var entries = new Dictionary(); - foreach (string table in Tables) - { - int bar = table.IndexOf('|'); - int start = Convert.ToInt32(table[..bar], 16); - foreach (string token in table[(bar + 1)..].Split(' ', StringSplitOptions.RemoveEmptyEntries)) - { - char c = (char)start++; - if (token == "?") continue; - if (token == "-") { entries[c] = null; continue; } - int comma = token.IndexOf(','); - byte[] primaries = Convert.FromHexString(comma < 0 ? token : token[..comma]); - byte secondary = comma < 0 ? (byte)0x02 : Convert.ToByte(token[(comma + 1)..], 16); - entries[c] = new TailoredWeight(primaries, secondary); - } - } - return entries; - } - - private static readonly string[] Tables = - [ - // Latin Extended-B — U+0180..U+024F - "0180|" + - "4C,1E 4C,43 4C,68 4C,68 4C,87 4C,87 4D,7D 4D,43 4D,43 5002,04 4F,43 4F,1E 4F,1E 4F,7C 51,7D 51,7E " + - "51,7B 53,43 53,7B 55,43 55,7B 57,7B 59,7B 59,1E 5C,43 5C,43 5E,1E 5E,20 60,7B 62,43 62,7B 64,20 " + - "64,52 64,52 64,7C 64,7C 66,43 66,43 69,7B 6B,87 6B,87 6B,7C 6B,7F 6D,57 6D,43 6D,43 6D,59 6F,52 " + - "6F,52 6F,7B 71,7B 76,43 76,43 78,1E 78,1E 7902 7902,7C 7902,7C 7902,7D 3A,03 4102,03 4102,03 790A 73,7B " + - // U+01C4..U+01CC are the DŽ/LJ/NJ ligature characters. Each is TWO weights, not one, and the second - // may carry its own accent — ACE stores DŽ as 7F 4F 78 01 02 14 00, i.e. D then Ž. A single block - // entry cannot express that, so they are refused rather than encoded as one weight, which would be - // silently wrong in any string where a later character is accented. - "330B 330C 330D 2B17 ? ? ? ? ? ? ? ? ? 4A,14 4A,14 59,14 " + - "59,14 64,14 64,14 6F,14 6F,14 6F,28 6F,28 6F,1F 6F,1F 6F,25 6F,25 6F,20 6F,20 51,7D 4A,28 4A,28 " + - // U+01E2/E3 (Ǣ, AE with macron) is an accented expansion — two weights each carrying the accent — - // and the letters it expands to are locale-dependent: Icelandic gives it its own Æ, 79 04, with a - // different secondary again. Refused rather than guessed, like the ligatures above. - "4A,25 4A,25 ? ? 55,1E 55,1E 55,14 55,14 5C,14 5C,14 64,1B 64,1B 64,30 64,30 7902,14 7902,14 " + - "5B,14 ? ? ? 55,0E 55,0E - - - - 4A,26 4A,26 ? ? 64,2B 64,2B " + - "4A,44 4A,44 4A,46 4A,46 51,44 51,44 51,46 51,46 59,44 59,44 59,46 59,46 64,44 64,44 64,46 64,46 " + - "69,44 69,44 69,46 69,46 6F,44 6F,44 6F,46 6F,46 - - - - - - - - " + - "- - - - - - - - - - - - - - - - " + - "- - - - - - - - - - - - - - - - " + - "- - - - - - - - - - - - - - - - ", - - // Spacing modifiers — U+02B0..U+02FF - "02B0|" + - "57,7E 57,7F 5B,7E 69,81 69,82 69,83 69,84 73,7E 76,7E ,0C ,19 ,45 ,46 ,47 ,77 ,78 " + - ",3F ,79 ,7A ,7B ,7C ,7D 2B02,03 2B18 ,40 2B13,03 2B14,03 2B07,03 ,5A ,62 ,7E ,7F " + - "2B99 ,81 ,82 ,83 ,50 ,51 ,52 ,53 2B18,03 2B19 2B1A 2B1B 2B1C 2B1D ,84 - " + - "55,7E 5E,7E 6B,7E 75,7E ,85 ,86 ,87 ,88 ,89 ,8A - - - - - - " + - "- - - - - - - - - - - - - - - - ", - - // Greek — U+0370..U+03FF - "0370|" + - ",91 ,92 ,93 - 2B1E 2B1F - - - - 2B20 - - - 2B21 - " + - "- - - - 2B22 2B23 790C,05 - 7910,05 7912,05 7914,05 - 791A,05 - 791F,05 7923,05 " + - "7914,16 790C 790D 790E 790F 7910 7911 7912 7913 7914 7915 7916 7917 7918 7919 791A " + - "791B 791C - 791D 791E 791F 7920 7921 7922 7923 7914,13 791F,13 790C,05 7910,05 7912,05 7914,05 " + - "791F,16 790C 790D 790E 790F 7910 7911 7912 7913 7914 7915 7916 7917 7918 7919 791A " + - "791B 791C 791D 791D 791E 791F 7920 7921 7922 7923 7914,13 791F,13 791A,05 791F,05 7923,05 - " + - "790D,03 7913,03 791F,1B 791F,44 791F,13 7920,03 791B,03 - - - 7924 7924 7925 7925 7926 7926 " + - "7927 7927 7928 7928 7929 7929 792A 792A 792B 792B 792C 792C 792D 792D 792E 792E " + - "7915,03 791C,03 791D,04 - - - - - - - - - - - - - ", - - // Cyrillic — U+0400..U+04FF - "0400|" + - "- 7936,13 7935 7932,0E 7937 793A 793C 793D 793F 7942 7945 794B 7940,0E - 794E 7954 " + - "792F 7930 7931 7932 7933 7936 7938 7939 793B 793E 7940 7941 7943 7944 7946 7947 " + - "7948 7949 794A 794D 7950 7951 7952 7953 7955 7956 7957 7958 7959 795A 795B 795C " + - "792F 7930 7931 7932 7933 7936 7938 7939 793B 793E 7940 7941 7943 7944 7946 7947 " + - "7948 7949 794A 794D 7950 7951 7952 7953 7955 7956 7957 7958 7959 795A 795B 795C " + - "- 7936,13 7935 7932,0E 7937 793A 793C 793D 793F 7942 7945 794B 7940,0E - 794E 7954 " + - "795E 795E 795F 7944,03 7960 7960 7961 7961 7962 7962 7963 7963 7964 7964 7965 7965 " + - "7966 7966 7967 7967 7968 7968 7968,46 7968,46 7969 7969 796A 796A 796B 796B 796C 796C " + - "796D 796D 4926,37 ,94 ,95 ,96 ,97 - - - - - - - - - " + - "7932,03 7932,03 7932,1A 7932,1A 7932,05 7932,05 7938,07 7938,07 7939,1A 7939,1A 7940,17 7940,17 7940,09 7940,09 7940,04 7940,04 " + - "7940,0A 7940,0A 7944,07 7944,07 7944,08 7944,08 7947,05 7947,05 7946,05 7946,05 7949,1A 7949,1A 794A,07 794A,07 794D,0B 794D,0B " + - "794F 794F 7951,17 7951,17 7952,03 7952,03 7953,08 7953,07 7953,09 7953,09 7944,09 7944,09 7936,05 7936,05 7936,17 7936,17 " + - "793C,03 7938,15 7938,15 7940,05 7940,05 7940,06 7940,06 7944,0A 7944,0A 7951,06 7951,06 7953,0D 7953,0D - - - " + - "- - - - - - - - - - - - - - - - " + - "- - - - - - - - - - - - - - - - " + - "- - - - - - - - - - - - - - - - ", - - // Cyrillic Supplement — U+0500..U+052F: present in the file format, but ACE weighs none of it. - "0500|" + - "- - - - - - - - - - - - - - - - " + - "- - - - - - - - - - - - - - - - " + - "- - - - - - - - - - - - - - - - ", - - // Hebrew — U+0590..U+05FF - "0590|" + - "- ,03 ,04 ,05 ,06 ,07 ,08 ,09 ,0A ,0B ,0C ,0D ,0E ,0F ,10 ,11 " + - ",12 ,13 ,14 ,15 ,16 ,17 ,18 ,19 ,1A ,1B ,1C ,1D ,1E ,1F ,20 ,21 " + - ",22 ,23 ,24 ,25 ,26 ,27 ,28 ,29 ,2A ,2B ,2C ,2D ,2E ,2F 2B2A ,30 " + - ",31 ,32 ,33 2B2B - - - - - - - - - - - - " + - "7994 7995 7996 7997 7998 7999 799A 799B 799C 799D 799E 799E 799F 79A0 79A0 79A1 " + - "79A1 79A2 79A3 79A4 79A4 79A5 79A5 79A6 79A7 79A8 79A9 - - - - - " + - "79997999 7999799D 799D799D 2B2C 2B2D - - - - - - - - - - - ", - - // Arabic — U+0600..U+06FF - "0600|" + - "- - - - - - - - - - - - 2B2E - - - " + - "- - - - - - - - - - - 2B2F - - - 2B30 " + - "- 79AA 79AA,03 79AA,04 79AA,05 79AA,06 79AA,07 79AB,08 79AC,08 79AE,08 79AE,08 79AF,08 79B0,08 79B2,08 79B3,08 79B4,08 " + - "79B5,08 79B6,08 79B7,08 79B9,08 79BA,08 79BB,08 79BC,08 79BD,08 79BE,08 79BF,08 79C0,08 - - - - - " + - "- 79C1,08 79C2,08 79C3,08 79C5,08 79C6,08 79C7,08 79C8,08 79C9,08 79CA,05 79CA,07 ? ? ? ? ? " + - "? FFFF ? - - - - - - - - - - - - - " + - "3702,38 3902,38 3B02,38 3D02,38 3F02,38 4103,38 4302,38 4502,38 4702,38 4902,38 2B31 2B32 2B33 - - - " + - "79CB 79CC 79CD 79CE 79CF 79D0 79D1 79D2 79D3 79D4 79D5 79D6 79D7 79D8 79AD,08 79D9 " + - "79DA 79DB 79DC 79DD 79DE 79DF 79B1,08 79E0 79E1 79E2 79E3 79E4 79E5 79E6 79E7 79E8 " + - "79E9 79EA 79EB 79EC 79ED 79EE 79EF 79F0 79B8,08 79F1 79F2 79F3 79F4 79F5 79F6 79F7 " + - "79F8 79F9 79FA 79FB 79FC 79FD 79FE 79FF 7A02 7A03 7A04 7A05 7A06 7A07 7A08 79C4,08 " + - "7A09 7A0A 7A0B 7A0C 7A0D 7A0E 7A0F 7A10 7A11 7A12 7A13 7A14 7A15 7A16 7A17 7A18 " + - "7A19 7A1A 7A1B 7A1C 7A1D 7A1E 7A1F 7A20 7A21 7A22 7A23 7A24 7A25 7A26 7A27 7A28 " + - "7A29 7A2A 7A2B 7A2C 2B34 7A2D - - - - - - - - - - " + - "- - - - - - - - - - - - - - - - " + - "3703,39 3903,39 3B03,39 3D03,39 3F03,39 4104,39 4303,39 4503,39 4703,39 4903,39 - - - - - - ", - - // Latin Extended Additional — U+1E00..U+1EFF (the Vietnamese precomposed letters live here) - "1E00|" + - "4A,5A 4A,5A 4C,10 4C,10 4C,58 4C,58 4C,55 4C,55 4D,28 4D,28 4F,10 4F,10 4F,58 4F,58 4F,55 4F,55 " + - "4F,1C 4F,1C 4F,60 4F,60 51,24 51,24 51,23 51,23 51,60 51,60 51,63 51,63 51,2F 51,2F 53,10 53,10 " + - "55,17 55,17 57,10 57,10 57,58 57,58 57,13 57,13 57,1C 57,1C 57,61 57,61 59,63 59,63 59,1F 59,1F " + - "5C,0E 5C,0E 5C,58 5C,58 5C,55 5C,55 5E,58 5E,58 5E,6E 5E,6E 5E,55 5E,55 5E,60 5E,60 60,0E 60,0E " + - "60,10 60,10 60,58 60,58 62,10 62,10 62,58 62,58 62,55 62,55 62,60 62,60 64,25 64,25 64,2A 64,2A " + - "64,24 64,24 64,23 64,23 66,0E 66,0E 66,10 66,10 69,10 69,10 69,58 69,58 69,6E 69,6E 69,55 69,55 " + - "6B,10 6B,10 6B,58 6B,58 6B,1D 6B,1D 6B,22 6B,22 6B,68 6B,68 6D,10 6D,10 6D,58 6D,58 6D,55 6D,55 " + - "6D,60 6D,60 6F,59 6F,59 6F,63 6F,63 6F,5A 6F,5A 6F,25 6F,25 6F,28 6F,28 71,19 71,19 71,58 71,58 " + - "73,0F 73,0F 73,0E 73,0E 73,13 73,13 73,10 73,10 73,58 73,58 75,10 75,10 75,13 75,13 76,10 76,10 " + - "78,12 78,12 78,58 78,58 78,55 78,55 57,55 6D,13 73,1A 76,1A 4A,69 - - - - - " + - "4A,59 4A,59 4A,43 4A,43 4A,1E 4A,1E 4A,1F 4A,1F 4A,55 4A,55 4A,29 4A,29 4A,6A 4A,6A 4A,21 4A,21 " + - "4A,22 4A,22 4A,58 4A,58 4A,2C 4A,2C 4A,6D 4A,6D 51,58 51,58 51,43 51,43 51,19 51,19 51,1E 51,1E " + - "51,1F 51,1F 51,55 51,55 51,29 51,29 51,6A 51,6A 59,43 59,43 59,58 59,58 64,58 64,58 64,43 64,43 " + - "64,1E 64,1E 64,1F 64,1F 64,55 64,55 64,29 64,29 64,6A 64,6A 64,60 64,60 64,61 64,61 64,95 64,95 " + - "64,69 64,69 64,AA 64,AA 6F,58 6F,58 6F,43 6F,43 6F,60 6F,60 6F,61 6F,61 6F,95 6F,95 6F,69 6F,69 " + - "6F,AA 6F,AA 76,0F 76,0F 76,58 76,58 76,44 76,44 76,19 76,19 - - - - - - ", - - // General punctuation — U+2000..U+206F - "2000|" + - "0808 0809 080A 080B 080C 080D 080E 080F 0810 0811 0812 0813 0814 - - - " + - "? ? ? ? ? ? 2B35 2B36 2B37 2B38 2B39 2B3A 2B3B 2B3C 2B3D 2B3E " + - "34B3 34B4 34B5 34B6 34B7 34B8 34B9 ? 0815 0816 - - - - - - " + - "34BA 34BB 2B3F 2B40 2B41 2B42 2B43 2B44 2B45 2B46 2B47 34BC 2B48 2B49 2B4A 2B4B " + - "34BD 34BE 34BF ? 2D05 27,1C 2A,1C - - - - - - - - - " + - "- - - - - - - - - - - - - - - - " + - "- - - - - - - - - - - - - - - - ", - - // Currency — U+20A0..U+20BF - "20A0|" + - "34C0 34C1 34C2 34C3 34C4 34C5 34C6 34C7 34C8 34C9 34CA 34CB 35A1 - - - " + - "- - - - - - - - - - - - - - - - ", - - // Letterlike — U+2100..U+214F - "2100|" + - "4B04 4B06 4D,03 4D,04 4E0A 4E0F 4E10 51,64 6C02 53,04 55,03 57,05 57,04 57,03 57,03 57,68 " + - "59,04 59,05 5E,04 5E,04 5F02 62,03 6302 35A0 66,04 66,03 68,03 69,04 69,05 69,03 6A02 6A03 " + - "6C03 6E02 6E04 7202 78,03 78,63 7923,03 7923,05 78,05 7923,04 5D02 4A,1A 4C,04 4D,05 51,05 51,04 " + - "51,04 53,05 53,03 60,04 64,04 7994 7995 7996 7997 - - - - - - - " + - "- - - - - - - - - - - - - - - - ", - - // Number forms — U+2150..U+218F - "2150|" + - "- - - 3713 3719 3711 3715 3717 371B 3710 371C 370F 3714 3718 371D 38,03 " + - "3910,47 3B10,47 3D10,47 3F10,47 4111,47 4310,47 4510,47 4710,47 4910,47 4914,47 4916,47 4918,47 4922,47 4924,47 4925,47 4928,47 " + - "3910,47 3B10,47 3D10,47 3F10,47 4111,47 4310,47 4510,47 4710,47 4910,47 4914,47 4916,47 4918,47 4922,47 4924,47 4925,47 4929,47 " + - "492A,47 492B,47 492C,47 - - - - - - - - - - - - - ", - - // Fullwidth forms — U+FF01..U+FF65. They repeat the ASCII weights exactly, which is the width folding - // described in §10.4 falling out of the table rather than being a normalisation pass. - "FF01|" + - "09 0A 0C 0E 10 12 ? 14 16 18 2C 1A ? 1C 1E 36 " + - "38 3A 3C 3E 40 42 44 46 48 20 22 2E 30 32 24 26 " + - "4A 4C 4D 4F 51 53 55 57 59 5B 5C 5E 60 62 64 66 " + - "68 69 6B 6D 6F 71 73 75 76 78 27 29 2A 2B02 2B03 2B07 " + - "4A 4C 4D 4F 51 53 55 57 59 5B 5C 5E 60 62 64 66 " + - "68 69 6B 6D 6F 71 73 75 76 78 2B09 2B0B 2B0D 2B0F - - " + - "1D02 2B59 2B5B 1B03 34B2 ", - ]; -} diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationTableV0.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationTableV0.cs new file mode 100644 index 00000000..e4a3aef1 --- /dev/null +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationTableV0.cs @@ -0,0 +1,158 @@ +using System.IO.Compression; + +namespace LibRed.Storage; + +/// +/// The measured General v0 weights for the whole Basic Multilingual Plane — 63,208 code points, of which +/// 19,186 are ignorable. Consulted by only after its own hand-verified +/// tables, so nothing here can change a weight that was already proven byte for byte. +/// +/// +/// v1's table could be embedded from a published Microsoft file, because its primaries are the NLS +/// weights verbatim. v0's are a Jet-specific compaction of the NT4-era order (see +/// docs/format/page-03-04-index-btree.md §10.4), so no published file describes them and the only +/// source of truth is ACE: SortKeyTableV0GeneratorTest inserts every code point into an indexed text +/// column, reads the stored index keys back, and writes this resource. +/// +/// Layout mirrors the v1 resource — an entry count, then four separately-deflated streams. Splitting them +/// matters: each column is nearly constant on its own and compresses to almost nothing, where interleaved +/// records would not. A primary length of 0xFF marks an ignorable character, which contributes +/// no primary and no secondary slot at all; a length of 0 is a secondary-only combining mark. +/// +/// +internal static class JetTextCollationTableV0 +{ + private const byte IgnorableLength = 0xFF; + + /// The weight for a character, or null when it is ignorable. False when the table has no entry, + /// in which case the caller must refuse the value rather than emit a guess. + public static bool TryGet(char c, out TailoredWeight? weight) + { + Table table = Loaded.Value; + int index = Array.BinarySearch(table.CodePoints, c); + if (index < 0) { weight = null; return false; } + if (table.Lengths[index] == IgnorableLength) { weight = null; return true; } + + int start = table.PrimaryOffsets[index]; + int length = table.Lengths[index]; + weight = new TailoredWeight(table.Primaries[start..(start + length)], table.Secondaries[index]); + return true; + } + + /// The inline code for a word-sort ignorable — a character that adds no weight at all and + /// records 80 <pos> 06 <code> in the trailing section instead. There are 296 of them + /// across the BMP: every dash and quotation form, the Arabic harakat, and the CJK and fullwidth + /// punctuation. + public static bool TryGetInlineCode(char c, out byte code) + { + Table table = Loaded.Value; + int index = Array.BinarySearch(table.InlineCodePoints, c); + code = index < 0 ? (byte)0 : table.InlineCodes[index]; + return index >= 0; + } + + /// A kana's sound index, voicing secondary and small-form flag. Kana take the two-byte primary + /// 7F <sound>; hiragana, katakana and halfwidth katakana share a sound, so they encode + /// 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 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. + public static bool TryGetKana(char c, out byte sound, out byte secondary, out bool small, out byte vowel) + { + Table table = Loaded.Value; + int index = Array.BinarySearch(table.KanaCodePoints, c); + if (index < 0) { sound = 0; secondary = 0; small = false; vowel = 0; return false; } + sound = table.KanaSounds[index]; + secondary = table.KanaSecondaries[index]; + small = table.KanaSmall[index] != 0; + vowel = table.KanaVowels[index]; + return true; + } + + private sealed record Table( + char[] CodePoints, byte[] Lengths, int[] PrimaryOffsets, byte[] Primaries, byte[] Secondaries, + char[] InlineCodePoints, byte[] InlineCodes, + char[] KanaCodePoints, byte[] KanaSounds, byte[] KanaSecondaries, byte[] KanaSmall, + byte[] KanaVowels); + + // Lazy so the cost is paid only by a database that actually reaches beyond the hand-written tables. + private static readonly Lazy Loaded = new(Load); + + private static Table Load() + { + using Stream stream = typeof(JetTextCollationTableV0).Assembly + .GetManifestResourceStream("LibRed.Resources.SortKeyTableV0.bin") + ?? throw new InvalidOperationException("The v0 sorting weight table resource is missing from the assembly."); + + var reader = new BinaryReader(stream); + int count = reader.ReadInt32(); + int inlineCount = reader.ReadInt32(); + int kanaCount = reader.ReadInt32(); + byte[] deltas = ReadStream(reader); + byte[] lengths = ReadStream(reader); + byte[] primaries = ReadStream(reader); + byte[] secondaries = ReadStream(reader); + byte[] inlineDeltas = ReadStream(reader); + byte[] inlineCodes = ReadStream(reader); + byte[] kanaDeltas = ReadStream(reader); + byte[] kanaSounds = ReadStream(reader); + byte[] kanaSecondaries = ReadStream(reader); + byte[] kanaSmall = ReadStream(reader); + byte[] kanaVowels = ReadStream(reader); + + var codePoints = new char[count]; + var offsets = new int[count]; + int codePoint = 0, cursor = 0, primaryOffset = 0; + for (int i = 0; i < count; i++) + { + codePoint += ReadVarInt(deltas, ref cursor); + codePoints[i] = (char)codePoint; + offsets[i] = primaryOffset; + if (lengths[i] != IgnorableLength) primaryOffset += lengths[i]; + } + + var inlineCodePoints = new char[inlineCount]; + codePoint = 0; + cursor = 0; + for (int i = 0; i < inlineCount; i++) + { + codePoint += ReadVarInt(inlineDeltas, ref cursor); + inlineCodePoints[i] = (char)codePoint; + } + + var kanaCodePoints = new char[kanaCount]; + codePoint = 0; + cursor = 0; + for (int i = 0; i < kanaCount; i++) + { + codePoint += ReadVarInt(kanaDeltas, ref cursor); + kanaCodePoints[i] = (char)codePoint; + } + + return new Table(codePoints, lengths, offsets, primaries, secondaries, inlineCodePoints, inlineCodes, + kanaCodePoints, kanaSounds, kanaSecondaries, kanaSmall, kanaVowels); + } + + private static byte[] ReadStream(BinaryReader reader) + { + byte[] compressed = reader.ReadBytes(reader.ReadInt32()); + var output = new MemoryStream(); + using (var inflate = new ZLibStream(new MemoryStream(compressed), CompressionMode.Decompress)) + inflate.CopyTo(output); + return output.ToArray(); + } + + private static int ReadVarInt(byte[] source, ref int offset) + { + int value = 0, shift = 0; + while (true) + { + byte b = source[offset++]; + value |= (b & 0x7F) << shift; + if ((b & 0x80) == 0) return value; + shift += 7; + } + } +} diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs index acff5a09..75174b13 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs @@ -52,6 +52,12 @@ public static bool TryEncode(string value, List output) foreach (char character in text) { + // Kana get a section of their own in v0, measured from ACE; nothing equivalent has been measured + // for v1, and the NLS weights alone would not produce it. Refuse rather than emit a key that + // silently lacks the section. + if (character is >= (char)0x3040 and <= (char)0x30FF or >= (char)0xFF66 and <= (char)0xFF9F) + return false; + if (table.TryExpand(character, out char[]? sequence)) { foreach (char expanded in sequence) 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 f4afb37a..eeed8a0d 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -142,7 +142,7 @@ Then the value, transformed: > |---|---|---| > | primary | **1 byte**, a Jet-era compaction | **2 bytes**: the Windows NLS `(Script Member, Alphabetic Weight)` verbatim | > | secondary | the NLS Diacritic Weight | the same | -> | inline position | counts primary **bytes** | counts primary **weights** (so `O'Brien` is `0x0B` in both, though v1 has emitted twice as many bytes) | +> | inline position | counts primary **weights** | the same (so `O'Brien` is `0x0B` in both, though v1 has emitted twice as many bytes) | > | soft hyphen | inline record, code `0x83` | wholly ignorable, no record | > > **v0 is the NT4-era NLS order, renumbered into one byte.** v1 could be *identified* because its primaries @@ -196,8 +196,11 @@ Then the value, transformed: **Twenty characters are "ignorable"** (so `O'Brien` sorts next to `OBrien`): they add **no primary weight**, but each appends an inline record to a trailing section. - > The full set and their codes, measured alone and inside a word so the position arithmetic is confirmed - > rather than assumed: apostrophe `0x80`, hyphen `0x82`, soft hyphen `0x83`, `U+2010` `0x84`, `U+2011` + > Sixty across the BMP — twenty hand-verified below, and forty more measured into the resource (CJK and + > fullwidth punctuation, further dashes and quotation forms). + > + > The hand-verified set and their codes, measured alone and inside a word so the position arithmetic is + > confirmed rather than assumed: apostrophe `0x80`, hyphen `0x82`, soft hyphen `0x83`, `U+2010` `0x84`, `U+2011` > `0x85`, `U+2027` `0x86`, `U+2043` `0x87`, `U+2012` `0x88`, `U+2013` `0x89`, `U+2014` `0x8B`, `U+2015` > `0x8C`, and the Arabic harakat `U+064B`–`U+0650` and `U+0652` running `0xA0`–`0xA6`. **The fullwidth > apostrophe and hyphen share their ASCII counterparts' codes exactly** (`U+FF07` = `0x80`, `U+FF0D` = @@ -205,10 +208,16 @@ Then the value, transformed: > by anything in the swept range. After the primary's `0x01` end marker, if any ignorable char is present the key adds `01 01 01` once, then per ignorable char four bytes `80 06 `, then the final `00`. ` = 0x07 + 4 × (count - of **primary weight bytes** emitted before it)` and `` is `0x80` for apostrophe / `0x82` for + of **primary weights** emitted before it)` and `` is `0x80` for apostrophe / `0x82` for hyphen — verified against ACE (e.g. `ANNE-MARIE` → `… 80 17 06 82 …`, the hyphen at position 4; `Aß-B` → `7F 4A 6B 6B 4C 01 01 01 01 80 13 06 82 00`, hyphen at position **3** because ß expands to - two primary bytes `SS`). + two weights `S`+`S`). + + > **Weights, not bytes — an earlier revision said bytes and LibRed implemented that.** The two agree for + > everything Latin, which is why it stood so long. A two-byte weight settles it: `£-` puts the hyphen at + > `0x0B` (`0x07 + 4×1`) although `£` is `34 A7`, and `©`, `½`, `Ω`, `б` all behave the same, while `£A-` + > is `0x0F`. So both the secondary section and this one index by weight. Guarded by the `£-`/`Ω'A` family + > in `LocaleCollationAccessTests` — the older samples were Latin-only and could not see it. > **Why those two characters specifically:** this is Windows' documented **word sort**, the default for > the NLS sorting functions — *"all punctuation marks and other nonalphanumeric characters, except for the @@ -251,11 +260,60 @@ Then the value, transformed: `SS`). Because the ignorable-position count is by primary byte, an expansion counts as its expanded length (above). - > **General v0 now covers every non-CJK block ACE weighs**, measured block by block and asserted - > byte-for-byte (`JetTextCollationBlocks`, generated by `TailoringGeneratorProbeTest`): Latin Extended-B and - > Additional, spacing modifiers, Greek, Cyrillic (+Supplement), Hebrew, Arabic, punctuation, currency, - > letterlike, number forms and the fullwidth forms. The non-Latin scripts nearly all live on the same - > **two-byte `0x79` page** the locale tailorings use for letters sorting after Z. + > **General v0 covers the whole Basic Multilingual Plane.** Every character ACE stores a key for, LibRed + > encodes identically. + > The weights are in an embedded resource (`SortKeyTableV0.bin`, 74 KB): 63,105 of them, 19,186 ignorable, + > plus 40 word-sort ignorables and 276 kana — far past anything hand-maintainable. v1's table could be + > embedded from a published Microsoft file; v0's cannot, since its primaries are a Jet compaction rather + > than the NLS weights, so **ACE itself is the source**: `SortKeyTableV0GeneratorTest` inserts every code + > point into an indexed text column, reads the stored keys back and writes the resource + > (`LIBRED_GENERATE_V0=1`). Non-Latin scripts nearly all live on the same **two-byte `0x79` page** the + > locale tailorings use for letters sorting after Z. + > + > Two things that only a full sweep would show. **ACE weighs every CJK ideograph and the entire private-use + > area** — `U+5000`–`8FFF`, `B000`–`CFFF` and `E000`–`EFFF` are 4,096 for 4,096, none of it ignorable. And + > across all 65,536 code points **ACE refused exactly one**. + > + > **Kana are their own mechanism.** A kana takes the two-byte primary `7F `, and the key gains a + > section of its own — so a single kana changes the shape of the whole key: + > + > ``` + > U+3042 あ 7F 7F 02 01 01 01 FF 02 80 FF 80 00 + > U+30A2 ア 7F 7F 02 01 01 01 FF 02 80 FF 80 00 identical: the two scripts fold together + > U+FF71 ア 7F 7F 02 01 01 01 FF 02 80 FF 80 00 as does the halfwidth form + > U+3041 ぁ 7F 7F 02 01 01 01 A0 FF 02 80 FF 80 00 the small form adds a flag byte + > ``` + > + > Hiragana, katakana and halfwidth katakana share a sound, so what separates them lives in a level this + > format truncates — the same reason case and width fold. **Voicing is an ordinary secondary**: `03` + > dakuten, `04` handakuten, so `かが` is secondaries `02 03`. + > + > **The small/normal flags are bit-packed.** Trailing normal forms are dropped, then what remains goes + > three per byte, two bits each, most significant first, under a `10` marker in the byte's top two bits — + > `11` normal, `10` small, `00` padding. One small kana is `A0`, "normal small" is `B8`, four kana take two + > bytes with the marker repeated. Verified over all 30 combinations up to four kana. + > + > Two rules that only appear in multi-character strings. The **halfwidth voicing marks `U+FF9E`/`U+FF9F` + > are combining** — measured alone they look ignorable, but ACE folds them into the preceding kana's + > secondary, so `ニホンゴ` is four primaries with secondaries `02 02 02 03`. And when a kana section is + > present the **inline word-sort section is introduced by `FF 01`** rather than `01 01 01`. + > + > **The prolonged sound mark lengthens the preceding kana's VOWEL**, which is exactly what the character + > means — `がー` is `7F 0A` then `7F 02`, "ga" lengthened by "a", *not* by "ga". So the vowel is a property + > of each kana and has to be measured per character rather than derived. `ー` also inherits the preceding + > kana's small flag (`ぁー` packs as small+small), and marks itself in **a second packed section** using + > the same three-per-byte scheme with its own codes — ordinary `01`, prolonged `11`: + > + > ``` + > あー [01,11] 10|01 11 00 = 9C あーー [01,11,11] = 9F + > あいー [01,01,11] 10|01 01 11 = 97 あああー [01,01,01,11] = 95 B0 + > ``` + > + > So the section is `01 01 FF 02 80 FF 80`. With no kana before it, `ー` is + > nothing special and keeps the ordinary `FF FF` primary the table holds for it. + > + > Still refused: kana under **version 1**, which has no measured equivalent. What `02 80 FF 80` denotes is + > not established; it never varies, so it is emitted as a literal. > > Three categories emerged that the Latin-1 range never showed: > - **Ignorable** — ACE stores *nothing at all* (key `7F 01 00`): no primary, not even a secondary slot. @@ -311,9 +369,9 @@ Then the value, transformed: > one byte or two, and a two-byte weight still takes a single slot — Norwegian `ö` is > `7F 79 06 01 13 00`: two primary bytes, one secondary. This only becomes visible once two-byte primaries > and accents appear together, which is why it surfaced with the locale tailorings - > (`Ångström` in Norwegian, where `å` and `ö` are both two-byte). Note the contrast with the **inline** - > apostrophe/hyphen section below, which counts primary **bytes** — the two sections index differently. - > An expansion is several *weights* (`ß`→`SS` is two one-byte weights), so it takes two slots. + > (`Ångström` in Norwegian, where `å` and `ö` are both two-byte). The **inline** apostrophe/hyphen section + > below counts weights as well, so both sections index the same way. An expansion is several *weights* + > (`ß`→`SS` is two one-byte weights), so it takes two slots. The section is emitted only when some character is accented: after the primary's `0x01` end marker it lists the secondary weight of **every weight from the first up to and including the last accented one**, diff --git a/test/LibRed.Core.Tests/ContractionProbeTest.cs b/test/LibRed.Core.Tests/ContractionProbeTest.cs index ffdfb4cd..70e95caa 100644 --- a/test/LibRed.Core.Tests/ContractionProbeTest.cs +++ b/test/LibRed.Core.Tests/ContractionProbeTest.cs @@ -101,6 +101,220 @@ private static string Describe(string s) => // 80 06 , with the section introduced once by 01 01 01. LibRed knows three of them // (apostrophe 0x80, hyphen 0x82, soft hyphen 0x83); ACE treats fourteen more the same way. Measured // alone, then inside a word, so the position arithmetic is confirmed rather than assumed. + // PROBE: the inline records that are NOT the simple 7F 01 01 01 01 80 07 06 00 shape. + // + // Generating the v0 resource found 213 characters whose key carries an inline word-sort record, but only + // 40 in the shape a lone ignorable produces. The other 173 are something else, and guessing what would + // be exactly the way to plant a wrong key. + // PROBE: how kana encode. + // + // The one mechanism General v0 still refuses. A kana key is shaped unlike anything else in the format — + // a doubled start flag, then a section introduced by 01 01 rather than the 01 01 01 an inline record + // uses: + // + // U+3042 あ 7F 7F 02 01 01 01 FF 02 80 FF 80 00 + // U+3041 ぁ 7F 7F 02 01 01 01 A0 FF 02 80 FF 80 00 + // + // Hiragana, katakana and halfwidth katakana share a key, so what separates them must live in that + // trailing section. This measures the axes one at a time — vowel, consonant row, small form, voicing, + // script, width, the prolonged mark — and then in pairs, since only a two-character string shows how the + // section is positioned. + [Fact] + public void Probe_how_kana_encode() + { + (string Label, int[] CodePoints)[] groups = + [ + ("hiragana vowels", [0x3042, 0x3044, 0x3046, 0x3048, 0x304A]), + ("small vowels", [0x3041, 0x3043, 0x3045, 0x3047, 0x3049]), + ("ka row", [0x304B, 0x304D, 0x304F, 0x3051, 0x3053]), + ("ga row (voiced)", [0x304C, 0x304E, 0x3050, 0x3052, 0x3054]), + ("ha/ba/pa", [0x306F, 0x3070, 0x3071]), + ("katakana", [0x30A2, 0x30A4, 0x30AB, 0x30AC]), + ("halfwidth", [0xFF71, 0xFF72, 0xFF76]), + ("marks", [0x3063, 0x3083, 0x3093, 0x30FC, 0xFF70, 0x309B, 0x309C]), + ]; + + var samples = new List(); + foreach ((_, int[] codePoints) in groups) + foreach (int c in codePoints) samples.Add(((char)c).ToString()); + // Pairs: kana with kana, kana with Latin, and the same sound in different scripts. + samples.AddRange([ + "あい", "ああ", "あア", "アあ", "あア", + "あA", "Aあ", "あぁ", "かが", "あé", + ]); + + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "kana-"); + try + { + Dictionary keys = AceKeys(path, [.. samples]); + foreach ((string label, int[] codePoints) in groups) + { + output.WriteLine(""); + output.WriteLine($" {label}:"); + foreach (int c in codePoints) + { + string text = ((char)c).ToString(); + output.WriteLine($" U+{c:X4} {keys.GetValueOrDefault(text) ?? "(refused)"}"); + } + } + output.WriteLine(""); + output.WriteLine(" pairs:"); + foreach (string sample in samples.Where(s => s.Length > 1)) + output.WriteLine($" {Describe(sample),-30} {keys.GetValueOrDefault(sample) ?? "(refused)"}"); + } + finally { TemporaryDatabase.Delete(path); } + } + + // PROBE: the two things Probe_how_kana_encode left open. + // + // The shape is now known: 7F 01 01 01 00, where a kana takes the + // two-byte primary 7F and voicing is an ordinary secondary (03 dakuten, 04 handakuten). What is + // not known is (a) how a small kana's record is positioned — A0 alone, B8 when one kana precedes it, a + // step of 0x18 that could be per character or 0x0C per primary byte — and (b) whether the kana section + // and the word-sort inline section can coexist, and in what order. + [Fact] + public void Probe_kana_positions_and_sections() + { + string[] samples = + [ + "ぁ", // ぁ alone → A0 + "あぁ", // あぁ, one kana ahead (2 primary bytes) + "Aぁ", // Aぁ, one LATIN letter ahead (1 primary byte) — the discriminator + "ぁぁ", // ぁぁ, two records + "ああぁ", // ああぁ, two kana ahead + "ぁああ", // ぁああ, record first + "あいう", // あいう — is the trailer constant for three kana? + "あ-", "-あ", "あ'",// kana with a word-sort ignorable, both orders + "ーあ", "あー", // the prolonged mark, which alone is NOT kana + ]; + + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "kanapos-"); + try + { + Dictionary keys = AceKeys(path, samples); + foreach (string sample in samples) + output.WriteLine($" {Describe(sample),-34} {keys.GetValueOrDefault(sample) ?? "(refused)"}"); + } + finally { TemporaryDatabase.Delete(path); } + } + + // PROBE: does an inline record's position count primary WEIGHTS or primary BYTES? + // + // The spec says bytes, and LibRed implements that with primaries.Count. Every case tested so far had + // one-byte weights, where the two agree — but "あ-" puts the hyphen at 0x0B = 0x07 + 4x1, and あ is a + // TWO-byte primary. If that generalises to any two-byte primary then the rule is weights, and LibRed is + // wrong for symbols, Greek, Cyrillic and everything else on the 0x79 page. + [Fact] + public void Probe_whether_inline_position_counts_weights_or_bytes() + { + // £ © ½ are two-byte symbol primaries; ß expands to two ONE-byte weights, so it is the control that + // cannot tell the two rules apart. + string[] samples = + [ + "-", "A-", "AB-", + "£-", "©-", "½-", "£A-", "A£-", + "ß-", "Aß-", + "Ω-", "б-", + ]; + + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "inlinepos-"); + try + { + Dictionary keys = AceKeys(path, samples); + foreach (string sample in samples) + output.WriteLine($" {Describe(sample),-24} {keys.GetValueOrDefault(sample) ?? "(refused)"}"); + } + finally { TemporaryDatabase.Delete(path); } + } + + // PROBE: how the small-kana record packs. + // + // It is not one record per small kana — ぁぁ produces a single byte A8, not two records. Observed so + // far: {0}=A0, {1}=B8, {0,1}=A8, {2}=BE, and all-normal emits no byte at all. Sweeping every + // small/normal combination up to four kana should show the packing. + [Fact] + public void Probe_small_kana_packing() + { + var samples = new List(); + for (int length = 1; length <= 4; length++) + for (int bits = 0; bits < 1 << length; bits++) + { + var text = new char[length]; + for (int i = 0; i < length; i++) + text[i] = (char)((bits & (1 << i)) != 0 ? 0x3041 : 0x3042); // ぁ small : あ normal + samples.Add(new string(text)); + } + + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "kanapack-"); + try + { + Dictionary keys = AceKeys(path, [.. samples]); + foreach (string sample in samples) + { + string flags = string.Concat(sample.Select(c => c == (char)0x3041 ? 's' : 'n')); + string key = keys.GetValueOrDefault(sample) ?? "(refused)"; + // Print just the section between the 01 01 marker and the constant FF 02 80 FF 80 trailer. + int marker = key.IndexOf("010101", StringComparison.Ordinal); + string section = marker < 0 ? "?" : key[(marker + 6)..].Replace("FF0280FF8000", ""); + output.WriteLine($" {flags,-6} section {section,-8} {key}"); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + // PROBE: how the prolonged sound mark records its position. + // + // ー takes the PREVIOUS kana's primary — exactly what the character means, lengthen the preceding vowel — + // and inserts a record into the kana section: あー is 7F 7F02 7F02 … FF 9C 02 80 FF 80 00, where a plain + // あ has FF 02 80 FF 80. One sample cannot say whether 9C encodes position, so vary it. + [Fact] + public void Probe_prolonged_mark_records() + { + string[] samples = + [ + "ー", "ーあ", // alone, and with nothing to lengthen + "あー", "あいー", "あーい", // one mark at each position + "ああー", "あああー", + "あーー", "あーあー", // two marks + "ぁー", "がー", // after a small kana, and after a voiced one + "アー", "アー", // halfwidth and katakana + ]; + + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "prolonged-"); + try + { + Dictionary keys = AceKeys(path, samples); + foreach (string sample in samples) + output.WriteLine($" {Describe(sample),-34} {keys.GetValueOrDefault(sample) ?? "(refused)"}"); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Probe_unusual_inline_records() + { + var samples = new List(); + foreach ((int first, int last) in new[] { (0x2000, 0x2FFF), (0x3000, 0x3FFF), (0xFB00, 0xFFFF) }) + for (int c = first; c <= last; c++) + if (!char.IsControl((char)c) && !char.IsSurrogate((char)c)) + samples.Add(((char)c).ToString()); + + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "inline-"); + try + { + Dictionary keys = AceKeys(path, [.. samples]); + var odd = keys + .Where(k => k.Value.Contains("010101") && k.Value != "7F0100") + .Where(k => !(k.Value.Length == 20 && k.Value[10..16] == "800706")) + .OrderBy(k => k.Key) + .ToList(); + output.WriteLine($"{odd.Count} inline records of an unexpected shape:"); + foreach ((string text, string key) in odd.Take(30)) + output.WriteLine($" {Describe(text),-14} {key}"); + } + finally { TemporaryDatabase.Delete(path); } + } + [Fact] public void Probe_word_sort_ignorable_codes() { diff --git a/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs b/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs index 83abca3f..6cd7d46f 100644 --- a/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs +++ b/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs @@ -101,6 +101,7 @@ private static string[] Samples(bool extendedBlocks) [ (0x0180, 0x024F), (0x02B0, 0x02FF), (0x0370, 0x052F), (0x0590, 0x06FF), (0x1E00, 0x1EFF), (0x2000, 0x206F), (0x20A0, 0x20BF), (0x2100, 0x218F), (0xFF01, 0xFF65), + (0x3040, 0x30FF), (0xFF66, 0xFF9F), // kana: hiragana, katakana, halfwidth katakana ]; if (extendedBlocks) foreach ((int first, int last) in blocks) @@ -116,6 +117,17 @@ private static string[] Samples(bool extendedBlocks) "ch", "cch", "chh", "ll", "lll", "llll", "cs", "dz", "dzs", "gy", "ly", "ny", "sz", "ty", "zs", "ccs", "ddz", "ggy", "lly", "nny", "ssz", "tty", "zzs", "gyy", "hc", "dzz", "lj", "nj", "dž", "ddž", "llj", "nnj", "aa", "aaa", "aab", "baa", "Aa", "AA", + // An ignorable AFTER a two-byte primary: the inline position counts weights, not bytes, and only + // a non-Latin or symbol character ahead of it can tell those two rules apart. + "£-", "©-", "½-", "£A-", "A£-", "Ω-", "б-", "£'", "Ω'A", "€-B", + // Kana: voicing, small forms and their packing, mixed scripts, and a kana beside an ignorable — + // which changes the inline section's introducer. + "あい", "ぁ", "あぁ", "ぁぁ", "ああぁ", "ぁああ", "あいう", + "かが", "ぱば", "アイ", "アイ", "あア", "あA", "Aあ", "あé", "あ-", "-あ", "あ'", + "ニホンゴ", "にほんご", "ニホンゴ", "ちょっと", "キャッシュ", + // The prolonged mark: alone, with nothing to lengthen, and after every kind of kana. + "ー", "ーあ", "あー", "あいー", "あーい", "ああー", "あああー", "あーー", "あーあー", + "ぁー", "がー", "アー", "アー", "コーヒー", "コーヒー", "サーバー", "chico", "llama", "coche", "calle", "chata", "hodina", "cukr", "ljubav", "njegov", "džem", "meggy", "asszony", "nagy", "cukor", "csak", ]); diff --git a/test/LibRed.Core.Tests/SortKeyTableV0GeneratorTest.cs b/test/LibRed.Core.Tests/SortKeyTableV0GeneratorTest.cs new file mode 100644 index 00000000..74a5e8ac --- /dev/null +++ b/test/LibRed.Core.Tests/SortKeyTableV0GeneratorTest.cs @@ -0,0 +1,351 @@ +using System.IO.Compression; +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// GENERATOR: builds LibRed.Core's embedded General v0 sorting-weight resource by measuring ACE. +// +// v1's table came from a published Microsoft file, so a PowerShell script could parse it +// (tools/sortkey-table/generate.ps1). No such file exists for v0 — its weights are a Jet-specific +// compaction, so the only source of truth is ACE itself: insert every code point into an indexed text +// column, read the stored index keys back, and record what they say. +// +// Across the BMP that is ~42,600 characters carrying weights and ~18,700 ignorable — far past what the +// hand-written tables in JetTextCollation and the compact per-block strings can hold, hence a resource. +// +// Opt-in via LIBRED_GENERATE_V0=1: it inserts ~63,000 rows through ACE and rewrites a checked-in binary. +public class SortKeyTableV0GeneratorTest(ITestOutputHelper output) +{ + private const string ResourcePath = "src/LibRed/LibRed.Core/Resources/SortKeyTableV0.bin"; + + /// Sentinel primary length meaning ignorable: ACE stores nothing for the character, not + /// even a secondary slot. Distinct from a zero-length primary, which is a secondary-only combining + /// mark. + private const byte IgnorableLength = 0xFF; + + /// The constant that closes every kana key. Its meaning is unknown — it never varies across + /// hiragana, katakana, halfwidth, small or voiced forms — so it is emitted as a literal. + private const string KanaTail = "FF0280FF8000"; + + [Fact] + public void Generate_the_v0_sort_key_resource() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_GENERATE_V0") == "1", + "set LIBRED_GENERATE_V0=1 — this measures ~63,000 characters through ACE and rewrites a resource"); + + var entries = new SortedDictionary(); + var inline = new SortedDictionary(); + var kana = new SortedDictionary(); + int inlineSkipped = 0, refused = 0, altered = 0; + + for (int chunk = 0x0000; chunk <= 0xF000; chunk += 0x1000) + { + string[] characters = Measurable(chunk, chunk + 0x0FFF); + if (characters.Length == 0) continue; + Dictionary measured = AceKeys(characters); + refused += characters.Length - measured.Count; + foreach ((string text, string key) in measured) + { + // ACE did not store the value verbatim — something was trimmed or folded away on insert, so + // the key cannot be attributed to a code point. (SPACE is excluded up front for exactly this + // reason; this catches anything else that behaves the same way.) + if (text.Length != 1) { altered++; continue; } + // Kana first: their keys contain 01 01 01 too, so the inline test below would claim them. + // 7F | 7F | 01 | [secondary] | 01 01 | [A0 if small] | FF 02 80 FF 80 | 00 + if (key.StartsWith("7F7F", StringComparison.Ordinal) && key.EndsWith(KanaTail, StringComparison.Ordinal)) + { + byte[] b = Convert.FromHexString(key); + int tail = b.Length - KanaTail.Length / 2; + int i = 4; + byte voicing = 0x02; + if (b[i] != 0x01) voicing = b[i++]; + i += 2; // the 01 01 that introduces the kana section + kana[text[0]] = (b[2], voicing, i < tail, 0); // vowel filled by the second pass + continue; + } + // A word-sort ignorable records an inline 80 06 instead of a weight — no primary + // and no secondary, so it is kept in its own stream. Measured alone the record is always + // 7F 01 01 01 01 80 07 06 00; anything else means the shape is not what we think. + if (key.Contains("010101")) + { + byte[] bytes = Convert.FromHexString(key); + if (bytes.Length == 10 && bytes[5] == 0x80 && bytes[6] == 0x07 && bytes[7] == 0x06) + inline[text[0]] = bytes[8]; + else + inlineSkipped++; + continue; + } + if (key == "7F0100") { entries[text[0]] = ([], 0x02, true); continue; } + (byte[] primaries, byte secondary) = Decode(key); + entries[text[0]] = (primaries, secondary, false); + } + } + + // Second pass for the prolonged sound mark. ー takes the preceding kana's VOWEL, not its sound — + // が followed by ー is 7F 0A then 7F 02, "ga" lengthened by "a" — so the vowel has to be measured per + // kana rather than derived from the sound, which would mean knowing the row structure. + int measuredVowels = 0; + string[] lengthened = [.. kana.Keys.Select(c => (char)c + "ー")]; + foreach ((string text, string key) in AceKeys(lengthened)) + { + byte[] b = Convert.FromHexString(key); + // 7F | 7F | 7F | 01 … + if (b.Length <= 5 || b[1] != 0x7F || b[3] != 0x7F) continue; + (byte sound, byte secondary, bool small, byte _) = kana[text[0]]; + kana[text[0]] = (sound, secondary, small, b[4]); + measuredVowels++; + } + output.WriteLine($"measured the lengthened vowel for {measuredVowels} of {kana.Count} kana"); + + output.WriteLine($"measured {entries.Count} weighted code points " + + $"({entries.Count(e => e.Value.Ignorable)} ignorable), {inline.Count} word-sort " + + $"ignorables and {kana.Count} kana; skipped {inlineSkipped} of unexpected shape, " + + $"{altered} not stored verbatim, {refused} refused by ACE"); + + byte[] blob = Build(entries, inline, kana); + string path = Path.Combine(RepositoryRoot(), ResourcePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllBytes(path, blob); + output.WriteLine($"wrote {path} ({blob.Length:N0} bytes)"); + + // Read it straight back and check every entry survives, because both bugs the v1 generator hit + // wrote a structurally valid but empty file, and both would have shipped silently. + (var reloaded, var reloadedInline, var reloadedKana) = Parse(blob); + Assert.Equal(entries.Count, reloaded.Count); + foreach ((int codePoint, (byte[] primaries, byte secondary, bool ignorable)) in entries) + { + (byte[] gotPrimaries, byte gotSecondary, bool gotIgnorable) = reloaded[codePoint]; + Assert.Equal(ignorable, gotIgnorable); + Assert.Equal(primaries, gotPrimaries); + if (!ignorable) Assert.Equal(secondary, gotSecondary); + } + Assert.Equal(inline.Count, reloadedInline.Count); + foreach ((int codePoint, byte code) in inline) Assert.Equal(code, reloadedInline[codePoint]); + Assert.Equal(kana.Count, reloadedKana.Count); + foreach ((int codePoint, var entry) in kana) Assert.Equal(entry, reloadedKana[codePoint]); + output.WriteLine($"round-tripped {reloaded.Count} weights, {reloadedInline.Count} ignorables " + + $"and {reloadedKana.Count} kana"); + } + + /// The code points worth measuring. Controls and surrogates are excluded, and so is the plain + /// SPACE: measured alone it is trimmed away and would be recorded as ignorable, which it is not — it is + /// weight 0x07 inside a string. It is hand-verified in JetTextCollation regardless. + private static string[] Measurable(int first, int last) + { + var characters = new List(); + for (int c = first; c <= last; c++) + if (c != ' ' && !char.IsControl((char)c) && !char.IsSurrogate((char)c)) + characters.Add(((char)c).ToString()); + return [.. characters]; + } + + private static byte[] Build( + SortedDictionary entries, + SortedDictionary inline, + SortedDictionary kana) + { + // Homogeneous streams rather than interleaved records: the v1 table compressed to ~194 KB + // interleaved and ~16 KB split, because each column is nearly constant on its own. + var deltas = new List(); + var lengths = new List(); + var primaries = new List(); + var secondaries = new List(); + int previous = 0; + foreach ((int codePoint, (byte[] bytes, byte secondary, bool ignorable)) in entries) + { + WriteVarInt(deltas, codePoint - previous); + previous = codePoint; + lengths.Add(ignorable ? IgnorableLength : (byte)bytes.Length); + if (!ignorable) primaries.AddRange(bytes); + secondaries.Add(ignorable ? (byte)0 : secondary); + } + + var inlineDeltas = new List(); + var inlineCodes = new List(); + previous = 0; + foreach ((int codePoint, byte code) in inline) + { + WriteVarInt(inlineDeltas, codePoint - previous); + previous = codePoint; + inlineCodes.Add(code); + } + + var kanaDeltas = new List(); + var kanaSounds = new List(); + var kanaSecondaries = new List(); + var kanaSmall = new List(); + var kanaVowels = new List(); + previous = 0; + foreach ((int codePoint, (byte sound, byte secondary, bool small, byte vowel)) in kana) + { + WriteVarInt(kanaDeltas, codePoint - previous); + previous = codePoint; + kanaSounds.Add(sound); + // The small flag gets a stream of its own. Riding it in the secondary's top bit looked safe — + // voicing is 02, 03 or 04 — but at least one kana carries a secondary of 0xEE, which the flag + // then destroyed. The stream is all zeros and ones and compresses to nothing anyway. + kanaSecondaries.Add(secondary); + kanaSmall.Add(small ? (byte)1 : (byte)0); + // 0 where the vowel could not be measured — the encoder refuses ー after such a kana. + kanaVowels.Add(vowel); + } + + var blob = new MemoryStream(); + var writer = new BinaryWriter(blob); + writer.Write(entries.Count); + writer.Write(inline.Count); + writer.Write(kana.Count); + foreach (List stream in new[] + { deltas, lengths, primaries, secondaries, inlineDeltas, inlineCodes, + kanaDeltas, kanaSounds, kanaSecondaries, kanaSmall, kanaVowels }) + { + byte[] compressed = Compress([.. stream]); + writer.Write(compressed.Length); + writer.Write(compressed, 0, compressed.Length); + } + writer.Flush(); + return blob.ToArray(); + } + + private static (Dictionary Weights, + Dictionary Inline, + Dictionary Kana) Parse(byte[] blob) + { + var reader = new BinaryReader(new MemoryStream(blob)); + int count = reader.ReadInt32(); + int inlineCount = reader.ReadInt32(); + int kanaCount = reader.ReadInt32(); + var streams = new byte[11][]; + for (int i = 0; i < 11; i++) streams[i] = Decompress(reader.ReadBytes(reader.ReadInt32())); + + var entries = new Dictionary(count); + int offset = 0, codePoint = 0, primaryOffset = 0; + for (int i = 0; i < count; i++) + { + codePoint += ReadVarInt(streams[0], ref offset); + byte length = streams[1][i]; + bool ignorable = length == IgnorableLength; + byte[] bytes = []; + if (!ignorable) + { + bytes = streams[2][primaryOffset..(primaryOffset + length)]; + primaryOffset += length; + } + entries[codePoint] = (bytes, streams[3][i], ignorable); + } + + var inline = new Dictionary(inlineCount); + offset = 0; + codePoint = 0; + for (int i = 0; i < inlineCount; i++) + { + codePoint += ReadVarInt(streams[4], ref offset); + inline[codePoint] = streams[5][i]; + } + + var kana = new Dictionary(kanaCount); + offset = 0; + codePoint = 0; + for (int i = 0; i < kanaCount; i++) + { + codePoint += ReadVarInt(streams[6], ref offset); + kana[codePoint] = (streams[7][i], streams[8][i], streams[9][i] != 0, streams[10][i]); + } + return (entries, inline, kana); + } + + private static void WriteVarInt(List target, int value) + { + while (value >= 0x80) { target.Add((byte)((value & 0x7F) | 0x80)); value >>= 7; } + target.Add((byte)value); + } + + private static int ReadVarInt(byte[] source, ref int offset) + { + int value = 0, shift = 0; + while (true) + { + byte b = source[offset++]; + value |= (b & 0x7F) << shift; + if ((b & 0x80) == 0) return value; + shift += 7; + } + } + + private static byte[] Compress(byte[] data) + { + var output = new MemoryStream(); + using (var deflate = new ZLibStream(output, CompressionLevel.SmallestSize, leaveOpen: true)) + deflate.Write(data, 0, data.Length); + return output.ToArray(); + } + + private static byte[] Decompress(byte[] data) + { + var output = new MemoryStream(); + using (var inflate = new ZLibStream(new MemoryStream(data), CompressionMode.Decompress)) + inflate.CopyTo(output); + return output.ToArray(); + } + + private static (byte[] Primaries, byte Secondary) Decode(string hex) + { + byte[] key = Convert.FromHexString(hex); + int end = key.Length - 1; + int split = end - 1; + while (split > 0 && key[split] != 0x01) split--; + return (key[1..split], end - split == 1 ? (byte)0x02 : key[split + 1]); + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "EFCore.Jet.sln"))) + directory = directory.Parent; + return directory?.FullName ?? throw new InvalidOperationException("EFCore.Jet.sln not found above the test output."); + } + + private static Dictionary AceKeys(string[] samples) + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "v0gen-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE Gen (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_Gen ON Gen (K)"); + for (int i = 0; i < samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Gen (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("Gen"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_Gen"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + var keys = new Dictionary(); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values) && values[keyColumn.Index] is string text) + keys[text] = Convert.ToHexString(stored); + return keys; + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} From 81fa7569a8d58f7f519b1a772e0b83e99d5b18e5 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 17:26:06 +0800 Subject: [PATCH 10/48] LibRed: General covers the whole Basic Multilingual Plane v1 now encodes all 63,422 BMP characters exactly as ACE stores them, matching v0. It was 29,776 differences when the sweep started. Most of it was four structural rules rather than missing weights, which is why the sweep was worth running before generating anything: a Han character takes a FOUR-byte primary (the marker FD FF then its own weights) and no secondary, which alone was 28,200 wrong keys; Hangul jamo take (AW, DW) as the primary with no marker; a zero alphabetic weight means NO primary at all; and where something precedes it, such a weight FOLDS into the one before rather than taking a slot. The rest is that the published Server 2008 table is not quite what ACE carries. That identification came from 25 reconstructed keys, all Latin and symbols, and it holds for 57,793 characters and fails for 501 - Balinese and Canadian syllabics get Latin weights, and the Arabic harakat and several ligature blocks differ. Scripts added or reweighted since. Rather than hunt for the right NLS revision, the disagreements are measured and embedded (2.0 KB), along with 5,082 characters ACE treats as wholly ignorable that the published file has no entry for at all. An override stores raw primary and secondary bytes, not (SM, AW, DW) weights: that reading assumes a two-byte primary carrying one secondary, and ACE breaks it both ways - the harakat have a secondary and no primary, the Lao vowels take a one-byte primary. A primary byte can also BE 0x01, so the section delimiter is the last 0x01 in a key rather than the first. Splitting at the first made five characters look like an unknown mechanism; measuring them in combination showed ordinary two-weight expansions. Kana turn out to be shared: same sound weights, same section, byte for byte under both orders, so JetKanaSection is extracted rather than duplicated. Two narrow differences remain - v1 weighs a compatibility form by its base kana's sound where v0 gives it its own, and five kana absent from the v0 table come from v1's own script member 3, whose smallness cannot be inferred from reaching that path. A generator must run with its own resource suppressed. It records where the encoder DISAGREES with ACE, so measuring an encoder that already consults it would find no disagreements and write an empty file. Behaviour change: an unassigned character such as U+0378 no longer throws. ACE stores an empty key for it, so refusing would reject a value the engine accepts. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Core/LibRed.Core.csproj | 9 + .../Resources/SortKeyTableV1Overrides.bin | Bin 0 -> 1990 bytes .../LibRed.Core/Storage/JetKanaSection.cs | 63 ++++ .../LibRed.Core/Storage/JetTextCollation.cs | 51 +-- .../LibRed.Core/Storage/JetTextCollationV1.cs | 199 +++++++++- .../Storage/JetTextCollationV1Overrides.cs | 147 ++++++++ .../docs/format/page-03-04-index-btree.md | 77 +++- .../LibRed.Core.Tests/ContractionProbeTest.cs | 46 +++ .../GeneralV1CollationTests.cs | 19 +- .../LibRed.Core.Tests.csproj | 4 + .../SortKeyTableV1OverrideGeneratorTest.cs | 352 ++++++++++++++++++ .../TailoringGeneratorProbeTest.cs | 238 +++++++++++- 12 files changed, 1121 insertions(+), 84 deletions(-) create mode 100644 src/LibRed/LibRed.Core/Resources/SortKeyTableV1Overrides.bin create mode 100644 src/LibRed/LibRed.Core/Storage/JetKanaSection.cs create mode 100644 src/LibRed/LibRed.Core/Storage/JetTextCollationV1Overrides.cs create mode 100644 test/LibRed.Core.Tests/SortKeyTableV1OverrideGeneratorTest.cs diff --git a/src/LibRed/LibRed.Core/LibRed.Core.csproj b/src/LibRed/LibRed.Core/LibRed.Core.csproj index 786c6c92..ce7327eb 100644 --- a/src/LibRed/LibRed.Core/LibRed.Core.csproj +++ b/src/LibRed/LibRed.Core/LibRed.Core.csproj @@ -9,6 +9,12 @@ + + + + + @@ -17,6 +23,9 @@ Jet-specific compaction of the NT4-era order — so they are measured from ACE itself by SortKeyTableV0GeneratorTest (LIBRED_GENERATE_V0=1). --> + + diff --git a/src/LibRed/LibRed.Core/Resources/SortKeyTableV1Overrides.bin b/src/LibRed/LibRed.Core/Resources/SortKeyTableV1Overrides.bin new file mode 100644 index 0000000000000000000000000000000000000000..7d8047b63a0f501f6dd8ccf61e9cdc87903e1fc2 GIT binary patch literal 1990 zcmV;%2RZol0RRAX0RRAs0001Z+G}Nh&BDkS#RvlOX^c1d8DW5lk&zip0~w4ffD{u) z11F;{qYR@Qqb#E&kQM@x7L3x&e2iKG4;dM!aRSw_X)-Z0G9A*?0cmARVq}V8WcHQ7HcUu`5rFj`;PBBs&h~bqLB-2z*Mw$R&3iUISrOT+C2^^bXP!6R_0AqX- z+-m>;0C?JskP8mLAP7TUZ5r=?;_3XPGL!8|3?LsN14x}xt}_5x<%8TVI1=lTs?y9J zrY5qKy}&??Wsss9>K`^2XS6Jon;tvKn!Mv4kM;OTiPh$$Sp$Cq_QU{u>4@9WX*fkK z8LRCL?k5LbX#fBKc-n=L0S>?*2m|H#|DQJ)K^!crhD3z6B9Z`USO&jZe_&4Qy^JEb zsHFsb==Zh-Zv{(hnzMj@*ccoMr;?6kM0AZ}y;Zh5Y-{K9cer(SQcp?tDC)F5KEeHexy9_7A#kGek zHSs?P`T-_=Od6#KxDp&9!6I%2fmDICt_%X>KuDP)MXJ~soNs3K&Whvc^!E04Z+GX- zo0+TZue2*%HUGjlT;g{8YrFR!{>7K^*Z4Q>6}a5kbW@kQEMTe_xe{oC6pTRzrtXmY zOXdt6a0J#s6O2JdoAG9q{*?Zadqppy7cnL~ChmmQ+smlzuhc8`C%)!a{YS9ytGECC zu_RrOwB5qI0msYpF1-{oi9T<+UFq|7ldGp~(yq0){cfJSu zU=Pf|9GruNJ~xa{k7wiXb_&6fgDg zTouTbfGKSX-qDuKqo5>fa!;$5#b3wjlkI}i9hXz@%u+@geh6SA)&(A2Kh)LO*ejIFjGZZVrmh9XSkTnRb zQ+6rB)U}vv!EVGomFlM6upnxx?S&_07O6KyQvP`|jh0$VGE>uL3r&|)w4}1sX=$jM zc4gSH8OsY!bTTP~pMvwSA%`+gxy7|aP zh@--rrlp-DcPTRRh2X0qywxU>ImBe9D$9;=k%;Ghpd1!j5@(?t<9$XCw6t_}Gz;Y} z2}S9mC_=KfuU{s~EXe`BPK=*|dBAzV!XUce-u?lFQKFWb0001Z+GFC``g?0wctm7W zbPS& zFo}R63q;k%O`ErD-42u!VdCRsVi6a=c1;AT7OIQ|r~o7aloo-}e5Mfn{6J+0LqS47 z8Ge|3*TjJUs2n5()OHPM!?im=FGNH}Mny$OKVlLA0J}Fk%Vq%p0C?Ixi(M;3Q5c2W z?|$F+?7h!+&Y5GrG&9U`#tdolQ3fNCuTp9xrN}6`F>&SAl?#dZ2`=Q4Tv9Il14VuT zf5c46bFa2>`WyaBE#rG=r9pTVqY`5v2M36yGYvglK)%#cV5N#B+NzBToQP?qLZwp`GbAoVLD^-D1bUDgWLf3VGC^>*o zFat{q=1qX~=xNCHemY$zQ!&h}z{y^X4K|NOD zfA4r|Gf{W5*7J)qd0Hn57>-QsD~goIq0D7l8hd?3+r@5fD)|Zx!$CgPzH$1IUT7k> YZTsB)%)7-~-#%>izZ9JO0$<1t?togh>;M1& literal 0 HcmV?d00001 diff --git a/src/LibRed/LibRed.Core/Storage/JetKanaSection.cs b/src/LibRed/LibRed.Core/Storage/JetKanaSection.cs new file mode 100644 index 00000000..6342f526 --- /dev/null +++ b/src/LibRed/LibRed.Core/Storage/JetKanaSection.cs @@ -0,0 +1,63 @@ +namespace LibRed.Storage; + +/// +/// The kana section of an index key, which both sort-order versions build identically. +/// +/// +/// A kana weighs 7F <sound> with voicing as an ordinary secondary, and the small/normal +/// distinction lives in a section of its own rather than in either weight. That is measured behaviour for +/// General Legacy (v0), and it holds byte-for-byte for General (v1) as well: ACE encodes U+304C as +/// 7F 7F0A 01 03 0101 FF 02 80 FF 80 00 under both, the same sound weights and the same section. The +/// two versions disagree about a great deal in the base table, and about kana not at all — so this is shared +/// rather than duplicated, and a fix to it necessarily reaches both. +/// +internal static class JetKanaSection +{ + /// The page byte every kana primary starts with: a kana weighs 7F <sound>. + public const byte KanaPage = 0x7F; + + /// Closes the kana section, after the FF that introduces the prolonged-mark flags. + /// Constant across hiragana, katakana, halfwidth, small and voiced forms in every string measured, so it + /// is emitted literally; what it denotes is not established. + private static ReadOnlySpan Tail => [0x02, 0x80, 0xFF, 0x80]; + + /// + /// Appends 01 01, the packed small/normal flags, the prolonged-mark flags and the closing constant. + /// Emitted whenever the string holds any kana at all, even if every one of them is a normal form. + /// + public static void Append(List output, List small, List prolonged) + { + output.Add(0x01); + output.Add(0x01); + AddFlags(output, small, marked: 0b10, unmarked: 0b11); + output.Add(0xFF); + AddFlags(output, prolonged, marked: 0b11, unmarked: 0b01); + output.AddRange(Tail); + } + + /// + /// Packs one flag per kana, three to a byte, most significant first, under a 10 marker in + /// the top two bits: 11 normal, 10 small, 00 padding. So one small kana is + /// A0, "normal small" is B8, and four kana take two bytes, the second repeating the marker. + /// Verified against ACE over all 30 combinations up to four kana. + /// + /// Nothing is emitted at all when no flag is set, which is why a lone normal kana closes straight into + /// the tail. + /// + /// + private static void AddFlags(List output, List flags, int marked, int unmarked) + { + int last = flags.LastIndexOf(true); + for (int start = 0; start <= last; start += 3) + { + int packed = 0x80; + for (int slot = 0; slot < 3; slot++) + { + int index = start + slot; + int code = index > last ? 0b00 : flags[index] ? marked : unmarked; + packed |= code << (4 - 2 * slot); + } + output.Add((byte)packed); + } + } +} diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs index db3b1b4d..e9633e78 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs @@ -64,14 +64,6 @@ internal static class JetTextCollation }; private const byte DefaultSecondary = 0x02; // a character with no accent - /// The page byte every kana primary starts with: a kana weighs 7F <sound>. - private const byte KanaPage = 0x7F; - - /// Closes the kana section, after the FF that introduces the prolonged-mark flags. - /// Constant across hiragana, katakana, halfwidth, small and voiced forms in every string measured, so it - /// is emitted literally; what it denotes is not established. - private static ReadOnlySpan KanaSectionTail => [0x02, 0x80, 0xFF, 0x80]; - // Secondary (diacritic) weight per Unicode combining mark — depends only on the accent, not the base // letter (verified against ACE: acute weighs 0x0E on a/e/i/o/u/y alike, etc.). private static readonly Dictionary DiacriticWeights = new() @@ -263,7 +255,7 @@ public static bool TryEncode(string value, List output, LocaleTailoring? t // 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([KanaPage, kanaVowel], DefaultSecondary); + AddWeight([JetKanaSection.KanaPage,kanaVowel], DefaultSecondary); kana.Add(kanaSmall); prolonged.Add(true); kanaWeight = secondaries.Count - 1; @@ -273,7 +265,7 @@ public static bool TryEncode(string value, List output, LocaleTailoring? t if (JetTextCollationTableV0.TryGetKana(c, out byte sound, out byte voicing, out bool small, out byte vowel)) { - AddWeight([KanaPage, sound], voicing); + AddWeight([JetKanaSection.KanaPage,sound], voicing); kana.Add(small); prolonged.Add(false); kanaWeight = secondaries.Count - 1; @@ -326,17 +318,7 @@ public static bool TryEncode(string value, List output, LocaleTailoring? t for (int i = 0; i <= lastAccent; i++) output.Add(secondaries[i]); - // Kana section: 01 01, the packed small/normal flags, then a constant. Present whenever the string - // holds any kana at all, even if every one of them is a normal form. - if (kana.Count > 0) - { - output.Add(0x01); - output.Add(0x01); - AddKanaFlags(output, kana, marked: 0b10, unmarked: 0b11); - output.Add(0xFF); - AddKanaFlags(output, prolonged, marked: 0b11, unmarked: 0b01); - output.AddRange(KanaSectionTail); - } + if (kana.Count > 0) JetKanaSection.Append(output, kana, prolonged); // Apostrophe/hyphen inline (tertiary) section. Its introducer depends on whether a kana section came // first: 01 01 01 on its own, but FF 01 after one — measured from "あ-" and "-あ". @@ -433,33 +415,6 @@ void AddWeight(ReadOnlySpan weight, byte secondary) } } - /// Emits the primary+secondary weight(s) for an accented or special Latin-1 letter (uppercased): - /// a multi-letter expansion (ß=SS, Þ=TH, Æ=AE), an atomic accent (Ø, Ð), or a Unicode canonical - /// decomposition (base letter + combining mark). Returns false if the character is unknown. - /// - /// Packs the kana small/normal flags. Trailing normal forms are dropped — a string whose last small kana - /// is at index 1 encodes the same however many normal kana follow — and if none is small the section - /// carries no flag bytes at all. What remains goes three per byte, two bits each, most significant - /// first, under a 10 marker in the top two bits: 11 normal, 10 small, - /// 00 padding. So one small kana is A0, "normal small" is B8, and four kana take two - /// bytes, the second repeating the marker. Verified against ACE over all 30 combinations up to four kana. - /// - private static void AddKanaFlags(List output, List flags, int marked, int unmarked) - { - int last = flags.LastIndexOf(true); - for (int start = 0; start <= last; start += 3) - { - int packed = 0x80; - for (int slot = 0; slot < 3; slot++) - { - int index = start + slot; - int code = index > last ? 0b00 : flags[index] ? marked : unmarked; - packed |= code << (4 - 2 * slot); - } - output.Add((byte)packed); - } - } - /// The explicit, hand-verified half: a multi-letter expansion (ß=SS, Þ=TH, Æ=AE) or an atomic /// accent (Ø, Ð). Each expanded letter is its own weight — the part no single-character /// measurement can capture, since a key cannot show whether two bytes are one weight or two — so this is diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs index 75174b13..62e8a197 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO.Compression; using System.Reflection; +using System.Text; namespace LibRed.Storage; @@ -31,6 +32,21 @@ internal static class JetTextCollationV1 /// those two are special — it is the platform's rule, not an Access one. private const byte WordSortScriptMember = 6; + /// Script member 5 is the Han class — the CJK ideographs, their extensions, the compatibility + /// forms and the Kangxi radicals. ACE gives every one of them a four-byte primary FD FF AW DW and + /// no secondary, rather than the ordinary (SM, AW) primary with DW as a secondary. + private const byte HanScriptMember = 5; + + /// Script member 4 is the Hangul jamo. Like Han they put their weights straight into the + /// primary — (AW, DW), no secondary — but with no FD FF marker ahead of them. The composed + /// Hangul syllables are a different class and were always correct. + private const byte HangulJamoScriptMember = 4; + + /// The script member v1's table gives kana, whose (AW, DW) are the sound and its voicing. + /// Only reached for the five the measured v0 kana table does not carry — everything else is caught by that + /// table first, which additionally knows the small flag and the vowel. + private const byte KanaScriptMember = 3; + private static readonly Lazy Table = new(Load, LazyThreadSafetyMode.ExecutionAndPublication); /// @@ -50,13 +66,122 @@ public static bool TryEncode(string value, List output) // apostrophe at 0x0B = 0x07 + 4x1 in both orders, though v1 has emitted twice as many bytes by then). var inline = new List<(int Position, byte ScriptMember, byte AlphabeticWeight)>(); + // The kana small/normal and prolonged-mark flags, and the running state the prolonged mark needs. + var kana = new List(); + var prolonged = new List(); + int kanaWeight = -1; + byte kanaVowel = 0; + bool kanaSmall = false; + foreach (char character in text) { - // Kana get a section of their own in v0, measured from ACE; nothing equivalent has been measured - // for v1, and the NLS weights alone would not produce it. Refuse rather than emit a key that - // silently lacks the section. - if (character is >= (char)0x3040 and <= (char)0x30FF or >= (char)0xFF66 and <= (char)0xFF9F) - return false; + // Kana are weighed and sectioned exactly as in v0 — same sound weights, same section, verified + // byte-for-byte against ACE under both sort orders. Handled here rather than in Append because a + // single kana changes the shape of the WHOLE key. + // + // The prolonged sound mark lengthens the preceding kana's VOWEL, so it takes that vowel's primary + // and inherits its small flag, while marking itself in a second packed section. With no kana ahead + // of it there is nothing to lengthen, and it falls through to the ordinary table weight. + if (character is (char)0x30FC or (char)0xFF70 && kanaVowel != 0 && + kanaWeight == secondaries.Count - 1) + { + primaries.Add(JetKanaSection.KanaPage); + primaries.Add(kanaVowel); + secondaries.Add(DefaultSecondary); + kana.Add(kanaSmall); + prolonged.Add(true); + kanaWeight = secondaries.Count - 1; + continue; + } + + // The halfwidth voicing marks are COMBINING: ACE folds them into the preceding kana's secondary + // rather than weighing them. Measured alone they look ignorable, which is what hides this — so a + // single-character sweep cannot catch it, and it has to be carried over from v0 deliberately. + // Both halves of the guard matter: for a lone mark kanaWeight and Count-1 are each -1, which + // would otherwise pass and index the list at -1. + if (character is (char)0xFF9E or (char)0xFF9F && kanaWeight >= 0 && + kanaWeight == secondaries.Count - 1) + { + secondaries[kanaWeight] = character == (char)0xFF9E ? (byte)0x03 : (byte)0x04; + continue; + } + + if (JetTextCollationTableV0.TryGetKana( + character, out byte sound, out byte voicing, out bool small, out byte vowel)) + { + // Where the two versions part company: v1 weighs a compatibility form by the sound of the + // kana it decomposes to, while v0 gives it one of its own. The circled katakana are the + // case — ACE v1 weighs ㋐ as ア (02), where the v0 table holds 03 for it, and 46 and 2A + // where v1 wants 03 and 04. Everything else about the section is shared, which is why this + // is a substitution here rather than a second kana path. + string baseForm = character.ToString().Normalize(NormalizationForm.FormKD); + if (baseForm.Length == 1 && baseForm[0] != character && + JetTextCollationTableV0.TryGetKana(baseForm[0], out byte baseSound, out _, out _, out _)) + sound = baseSound; + + primaries.Add(JetKanaSection.KanaPage); + primaries.Add(sound); + secondaries.Add(voicing); + kana.Add(small); + prolonged.Add(false); + kanaWeight = secondaries.Count - 1; + kanaVowel = vowel; + kanaSmall = small; + continue; + } + + // A measured override wins over everything below it, because it came from ACE itself. Its bytes + // are appended verbatim — it states the finished bytes, not a table entry to interpret through + // the Han, Hangul and zero-AW rules below. + // + // Ahead of the kana fallback deliberately: script member 03 also collects characters that are not + // kana letters at all — the iteration marks, the lone prolonged mark, the double hyphen — which + // ACE gives the unweighted FF FF primary and no kana section. Those are measured, so they are + // simply recorded, and only what no measurement covers reaches the inference below. + if (JetTextCollationV1Overrides.IsIgnorable(character)) + { + // ACE contributes nothing for this character — it disappears from the key entirely. + continue; + } + + if (JetTextCollationV1Overrides.TryGet( + character, out ReadOnlySpan measuredPrimaries, + out ReadOnlySpan measuredSecondaries)) + { + foreach (byte b in measuredPrimaries) primaries.Add(b); + foreach (byte b in measuredSecondaries) secondaries.Add(b); + continue; + } + + // The kana the measured v0 table does not carry: the small hiragana ka and ke, the katakana + // phonetic extensions, and the enclosed (circled) katakana. v1's own table classifies them under + // script member 03, and its DW is the voicing — the enclosure rides along there, so a circled + // katakana is simply its kana with secondary EE. + // + // Its AW is the sound and its DW the voicing. Only characters absent from the measured v0 kana + // table reach here, and every one of them is its own base, so there is no decomposition to + // follow — the branch above owns the compatibility forms. + // + // The small flag cannot be assumed from reaching this path — the phonetic extensions are small + // and the circled forms are not, and marking all of them small put a spurious A0 in 45 keys. + // ACE's flag agrees exactly with the Unicode names: SMALL KA and SMALL KE, and the SMALL + // KU..SMALL RO run. The vowel is left at zero, because nothing measured covers a prolonged mark + // following one of these. + if (table.TryGetWeight(character, out byte member, out byte tableSound, out byte tableVoicing) && + member == KanaScriptMember) + { + bool isSmall = character is (char)0x3095 or (char)0x3096 + or >= (char)0x31F0 and <= (char)0x31FF; + primaries.Add(JetKanaSection.KanaPage); + primaries.Add(tableSound); + secondaries.Add(tableVoicing); + kana.Add(isSmall); + prolonged.Add(false); + kanaWeight = secondaries.Count - 1; + kanaVowel = 0; + kanaSmall = isSmall; + continue; + } if (table.TryExpand(character, out char[]? sequence)) { @@ -80,7 +205,51 @@ bool Append(char character) if (scriptMember == WordSortScriptMember) { - inline.Add((primaries.Count / 2, scriptMember, alphabetic)); + // secondaries.Count is the weight count: every weight contributes exactly one secondary slot, + // whereas primaries.Count/2 assumed each weight is two bytes — which the four-byte Han + // primary above breaks. + inline.Add((secondaries.Count, scriptMember, alphabetic)); + return true; + } + + // A Han character takes a FOUR-byte primary and no secondary at all: the fixed marker FD FF, + // then its own alphabetic and diacritic weights. Splitting it the ordinary way — (SM, AW) as the + // primary and DW as a secondary — is what made every CJK key wrong, some 28,200 of them. + // U+4E00 ACE 7F FD FF 3C 6A 01 00, where the NLS entry is SM 05, AW 3C, DW 6A. + if (scriptMember == HanScriptMember) + { + primaries.Add(0xFD); + primaries.Add(0xFF); + primaries.Add(alphabetic); + primaries.Add(diacritic); + secondaries.Add(DefaultSecondary); + return true; + } + + // Hangul jamo take a two-byte primary of (AW, DW) with no secondary — the same "weights straight + // into the primary" shape as Han above, but without the FD FF marker. U+1100 is C0 02, where the + // NLS entry is SM 04, AW C0, DW 02. + if (scriptMember == HangulJamoScriptMember) + { + primaries.Add(alphabetic); + primaries.Add(diacritic); + secondaries.Add(DefaultSecondary); + return true; + } + + // A zero alphabetic weight means the character carries NO primary — only its secondary. Emitting + // (SM, 0) as a primary put an extra weight into every such key, which is where the last of the + // Greek, Cyrillic, Hebrew and Indic differences came from. + // U+0483 ACE 7F 01 94 00, not 7F 01 00 01 94 00. + // + // And where something precedes it, it does not take a slot of its own either: it FOLDS into the + // preceding weight, adding its diacritic. That is the Hebrew and Arabic presentation forms, whose + // expansions are a letter followed by a point — + // U+FB30 ACE 7F 28 02 01 30 00, where the parts weigh 0x02 and 0x2E and 0x02 + 0x2E = 0x30. + if (alphabetic == 0) + { + if (secondaries.Count == 0) secondaries.Add(diacritic); + else secondaries[^1] = (byte)(secondaries[^1] + diacritic); return true; } @@ -97,11 +266,23 @@ bool Append(char character) int lastAccent = secondaries.FindLastIndex(weight => weight != DefaultSecondary); for (int i = 0; i <= lastAccent; i++) output.Add(secondaries[i]); + if (kana.Count > 0) JetKanaSection.Append(output, kana, prolonged); + + // The inline introducer depends on whether a kana section came first: 01 01 01 on its own, but FF 01 + // after one. if (inline.Count > 0) { - output.Add(EndPrimary); - output.Add(EndPrimary); - output.Add(EndPrimary); + if (kana.Count > 0) + { + output.Add(0xFF); + output.Add(EndPrimary); + } + else + { + output.Add(EndPrimary); + output.Add(EndPrimary); + output.Add(EndPrimary); + } foreach ((int position, byte scriptMember, byte alphabetic) in inline) { output.Add(InlineStart); diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1Overrides.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1Overrides.cs new file mode 100644 index 00000000..e57809c8 --- /dev/null +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1Overrides.cs @@ -0,0 +1,147 @@ +using System.IO.Compression; + +namespace LibRed.Storage; + +/// +/// The characters where ACE's version-1 weights disagree with the published table LibRed embeds. +/// +/// +/// v1's primaries are the Windows NLS (Script Member, Alphabetic Weight) pair, and the table was +/// identified as Windows Server 2008 by reconstructing measured ACE keys — 25 of 25 against every +/// published version. That held for what it was tested on, Latin and symbols, and it does not hold +/// everywhere: ACE gives Balinese and Canadian syllabics Latin weights, and differs on the Arabic +/// harakat and several ligature blocks. Those are scripts added or reweighted after Server 2008, so ACE's +/// real table is not exactly the file we parse. +/// +/// Rather than guess which NLS revision ACE carries, the disagreements are measured and embedded: +/// SortKeyTableV1OverrideGeneratorTest (LIBRED_GENERATE_V1=1) encodes every BMP character +/// through ACE and records the weights implied wherever the result differs. 446 characters, 1.2 KB — the +/// same answer v0 needed, at a fraction of the size, because v1 is right about the other 57,594. +/// +/// +/// An entry is a sequence of weights, since one character can imply several: ACE encodes +/// U+1B08 as 0E02 0E21. They are appended verbatim, bypassing the Han and Hangul and zero-AW +/// rules, because an override states the finished bytes rather than a table entry to interpret. +/// +/// +internal static class JetTextCollationV1Overrides +{ + /// + /// Suppresses the overrides, so the encoder falls back to the published table alone. + /// + /// + /// Only the generator sets this, and it must. The resource records where the encoder disagrees with + /// ACE, so measuring an encoder that already consults it would find no disagreements and write an empty + /// file — the measurement would erase its own subject. The generator therefore takes the bare table. + /// + internal static bool Suppressed { get; set; } + + /// + /// The primary and secondary bytes ACE gives this character, or false when it agrees with the table. + /// + public static bool TryGet(char c, out ReadOnlySpan primaries, out ReadOnlySpan secondaries) + { + primaries = secondaries = default; + if (Suppressed) return false; + Table table = Loaded.Value; + int index = Array.BinarySearch(table.CodePoints, c); + if (index < 0) return false; + primaries = table.PrimaryBytes.AsSpan(table.PrimaryOffsets[index], table.PrimaryLengths[index]); + secondaries = table.SecondaryBytes.AsSpan(table.SecondaryOffsets[index], table.SecondaryLengths[index]); + return true; + } + + /// + /// Whether ACE contributes nothing at all for this character — an empty key, 7F 01 00. + /// + /// + /// The published table has no entry for these at all, so without the measured set the encoder refuses + /// them. They are stored as runs rather than weights, since the only fact worth keeping is membership: + /// 5,029 characters collapse into a few hundred ranges. + /// + public static bool IsIgnorable(char c) + { + if (Suppressed) return false; + int[] starts = Loaded.Value.IgnorableStarts; + int index = Array.BinarySearch(starts, (int)c); + if (index >= 0) return true; + index = ~index - 1; + return index >= 0 && c < starts[index] + Loaded.Value.IgnorableLengths[index]; + } + + private sealed record Table( + char[] CodePoints, + byte[] PrimaryLengths, int[] PrimaryOffsets, byte[] PrimaryBytes, + byte[] SecondaryLengths, int[] SecondaryOffsets, byte[] SecondaryBytes, + int[] IgnorableStarts, int[] IgnorableLengths); + + private static readonly Lazy
    Loaded = new(Load); + + private static Table Load() + { + using Stream stream = typeof(JetTextCollationV1Overrides).Assembly + .GetManifestResourceStream("LibRed.Resources.SortKeyTableV1Overrides.bin") + ?? throw new InvalidOperationException("The v1 override resource is missing from the assembly."); + + var reader = new BinaryReader(stream); + int count = reader.ReadInt32(); + int rangeCount = reader.ReadInt32(); + byte[] deltas = ReadStream(reader); + byte[] primaryLengths = ReadStream(reader); + byte[] secondaryLengths = ReadStream(reader); + byte[] primaryBytes = ReadStream(reader); + byte[] secondaryBytes = ReadStream(reader); + byte[] rangeStarts = ReadStream(reader); + byte[] rangeLengths = ReadStream(reader); + + var codePoints = new char[count]; + var primaryOffsets = new int[count]; + var secondaryOffsets = new int[count]; + int codePoint = 0, cursor = 0, primary = 0, secondary = 0; + for (int i = 0; i < count; i++) + { + codePoint += ReadVarInt(deltas, ref cursor); + codePoints[i] = (char)codePoint; + primaryOffsets[i] = primary; + secondaryOffsets[i] = secondary; + primary += primaryLengths[i]; + secondary += secondaryLengths[i]; + } + var starts = new int[rangeCount]; + var lengths = new int[rangeCount]; + int start = 0, startCursor = 0, lengthCursor = 0; + for (int i = 0; i < rangeCount; i++) + { + start += ReadVarInt(rangeStarts, ref startCursor); + starts[i] = start; + lengths[i] = ReadVarInt(rangeLengths, ref lengthCursor); + } + + return new Table( + codePoints, + primaryLengths, primaryOffsets, primaryBytes, + secondaryLengths, secondaryOffsets, secondaryBytes, + starts, lengths); + } + + private static byte[] ReadStream(BinaryReader reader) + { + byte[] compressed = reader.ReadBytes(reader.ReadInt32()); + var output = new MemoryStream(); + using (var inflate = new ZLibStream(new MemoryStream(compressed), CompressionMode.Decompress)) + inflate.CopyTo(output); + return output.ToArray(); + } + + private static int ReadVarInt(byte[] source, ref int offset) + { + int value = 0, shift = 0; + while (true) + { + byte b = source[offset++]; + value |= (b & 0x7F) << shift; + if ((b & 0x80) == 0) return value; + shift += 7; + } + } +} 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 eeed8a0d..7660fe9d 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -165,12 +165,30 @@ Then the value, transformed: > table too, but the remaining 88 are weighted by NLS and dropped by Jet, which is an editorial choice of its > own and not something a published table would have told us. > -> v1's table is the **Windows Server 2008** sorting weight table, frozen — identified by reconstructing -> measured ACE v1 keys from every published Windows table (Server 2008 scores 25/25; Win7/2008R2 24/25, -> Vista 23/25, Win8+ 22/25, NT4-2003 18/25, the discriminators being `1` = `13 25` vs `13 26`, its DW `2` -> vs `3`, and `½` = `13 24 214` vs `13 17 2`). Access 2010 shipped with the then-current weights and froze -> them when Windows 7/8 moved them — the "major NLS version, re-index everything" event described in -> [MS-UCODEREF] and *Handling Sorting in Your Applications*. +> v1's table is **very nearly** the Windows Server 2008 sorting weight table, frozen — identified by +> reconstructing measured ACE v1 keys from every published Windows table (Server 2008 scores 25/25; +> Win7/2008R2 24/25, Vista 23/25, Win8+ 22/25, NT4-2003 18/25, the discriminators being `1` = `13 25` vs +> `13 26`, its DW `2` vs `3`, and `½` = `13 24 214` vs `13 17 2`). Access 2010 shipped with the then-current +> weights and froze them when Windows 7/8 moved them — the "major NLS version, re-index everything" event +> described in [MS-UCODEREF] and *Handling Sorting in Your Applications*. +> +> **"Very nearly" is load-bearing.** Those 25 discriminators were all Latin and symbols, and a full-BMP sweep +> shows the published file is not what ACE carries everywhere: it is right about 57,793 characters and wrong +> about 501, plus 5,082 that ACE treats as wholly ignorable and the published file has no entry for at all. +> The disagreements are concentrated in scripts added or reweighted after Server 2008 — ACE gives Balinese +> and Canadian syllabics *Latin* weights — and in the Arabic harakat and several ligature blocks. Rather +> than guess at which NLS revision ACE really carries, the differences are **measured and embedded** +> (`SortKeyTableV1Overrides.bin`, 2.0 KB, written by `SortKeyTableV1OverrideGeneratorTest` with +> `LIBRED_GENERATE_V1=1`) — the same answer v0 needed, at 3% of the size, because v1 is right about the rest. +> +> An override records the primary and secondary bytes **raw**, not as `(SM, AW, DW)` weights, because that +> reading assumes every primary is a two-byte pair carrying one secondary and ACE breaks it both ways: the +> Arabic harakat have a secondary and *no primary* (`U+064C` is `7F 01 56 00`), and the Lao vowel signs take +> a **one-byte** primary (`U+0EB0` is `7F 41 01 0A 00`). A primary byte can even *be* `0x01`: `U+0385`, +> `U+1B3B` and `U+FC25` weigh `07 53 01`, and `U+FC33` and `U+FCC2` weigh `29 0B 01`, so the section +> delimiter is the **last** `0x01` in a key, not the first. Splitting at the first made those five look like +> a key with an extra section bolted on; measuring them in combination (`aX`, `Xa`, `XaX`) showed they are +> ordinary two-weight expansions. > > This also explains the framing generally: **script member 6 is the word-sort class**, and the apostrophe's > `0x80` and hyphen's `0x82` inline codes are simply their Alphabetic Weights — so the inline record is @@ -178,10 +196,17 @@ Then the value, transformed: > section this format truncates, case *and* character width fold for free (`A` U+FF21 and `A` share the > primary `0E02` and differ only in that discarded weight). > -> LibRed encodes both: `JetTextCollation` (v0, hand-built tables) and `JetTextCollationV1` (v1, from an -> embedded copy of the Server 2008 table — see `tools/sortkey-table/generate.ps1`). Other locales are still -> refused. Tests: `GeneralV1CollationTests` (keys measured from ACE) and `GeneralV1CollationAccessTests` -> (live oracle, plus ACE seeking an index LibRed wrote in a v1 database). +> LibRed encodes both: `JetTextCollation` (v0, a measured table) and `JetTextCollationV1` (v1, the published +> table plus the measured overrides — see `tools/sortkey-table/generate.ps1`), sharing `JetKanaSection`. +> **Both now cover the whole Basic Multilingual Plane**: 63,422 characters each, every key byte-for-byte +> what ACE stores, nothing refused and nothing left unhandled (`Probe_full_bmp_coverage`, needs +> `LIBRED_FULL_BMP`). Other locales are still refused under v1. +> +> **The BMP is the limit of that claim.** Nothing above `U+FFFF` has been measured. A surrogate pair reaches +> the encoder as two chars that the table happens to weigh individually, so an astral character encodes +> rather than being refused — whether the result is what ACE stores is unknown, and worth a sweep of its own. Tests: `GeneralV1CollationTests` (keys +> measured from ACE) and `GeneralV1CollationAccessTests` (live oracle, plus ACE seeking an index LibRed +> wrote in a v1 database). - **Text:** Jet's "General" collation. The key is the start flag, then one or two **primary-weight** bytes per character, then a `01 00` terminator. Weights are **case-folded** @@ -263,13 +288,19 @@ Then the value, transformed: > **General v0 covers the whole Basic Multilingual Plane.** Every character ACE stores a key for, LibRed > encodes identically. > The weights are in an embedded resource (`SortKeyTableV0.bin`, 74 KB): 63,105 of them, 19,186 ignorable, - > plus 40 word-sort ignorables and 276 kana — far past anything hand-maintainable. v1's table could be - > embedded from a published Microsoft file; v0's cannot, since its primaries are a Jet compaction rather - > than the NLS weights, so **ACE itself is the source**: `SortKeyTableV0GeneratorTest` inserts every code - > point into an indexed text column, reads the stored keys back and writes the resource + > plus 40 word-sort ignorables and 276 kana — far past anything hand-maintainable. Most of v1's table can + > be embedded from a published Microsoft file; v0's cannot at all, since its primaries are a Jet compaction + > rather than the NLS weights, so **ACE itself is the source**: `SortKeyTableV0GeneratorTest` inserts every + > code point into an indexed text column, reads the stored keys back and writes the resource > (`LIBRED_GENERATE_V0=1`). Non-Latin scripts nearly all live on the same **two-byte `0x79` page** the > locale tailorings use for letters sorting after Z. > + > Both generators must run with the resource they are about to replace **suppressed** + > (`JetTextCollationV1Overrides.Suppressed`), and v1's shows why plainly: it records where the encoder + > *disagrees* with ACE, so measuring an encoder that already consults it would find no disagreements and + > write an empty file. Suppressing from the outset also means a generator never has to be able to *read* + > the resource it replaces, so it bootstraps from a stale or absent one. + > > Two things that only a full sweep would show. **ACE weighs every CJK ideograph and the entire private-use > area** — `U+5000`–`8FFF`, `B000`–`CFFF` and `E000`–`EFFF` are 4,096 for 4,096, none of it ignorable. And > across all 65,536 code points **ACE refused exactly one**. @@ -312,8 +343,22 @@ Then the value, transformed: > So the section is `01 01 FF 02 80 FF 80`. With no kana before it, `ー` is > nothing special and keeps the ordinary `FF FF` primary the table holds for it. > - > Still refused: kana under **version 1**, which has no measured equivalent. What `02 80 FF 80` denotes is - > not established; it never varies, so it is emitted as a literal. + > **Version 1 builds the kana section identically** — same sound weights, same framing, byte for byte: + > ACE encodes `U+304C` as `7F 7F0A 01 03 0101 FF 02 80 FF 80 00` under both orders. The two versions + > disagree about a great deal in the base table and about kana almost not at all, so `JetKanaSection` is + > shared rather than duplicated. Two differences remain, both narrow: + > + > - A **compatibility form takes its base kana's sound in v1** and its own in v0. The circled katakana are + > the case: v1 weighs `㋐` as `ア` (`02`), where the v0 table holds `03` for it, and `46` and `2A` where + > v1 wants `03` and `04`. The enclosure itself rides along as the secondary, `EE`. + > - Five kana are absent from the measured v0 table — the small hiragana ka and ke, and the katakana + > phonetic extensions. v1's own table classifies them under **script member 3**, whose `(AW, DW)` are the + > sound and voicing. The one fact it does not supply is the small flag, and that cannot be inferred from + > reaching that path: script member 3 also collects the circled forms, which are *not* small, and the + > iteration marks, the lone prolonged mark and the double hyphen, which are not kana letters at all and + > which ACE gives the unweighted `FF FF` primary and no kana section. + > + > What `02 80 FF 80` denotes is still not established; it never varies, so it is emitted as a literal. > > Three categories emerged that the Latin-1 range never showed: > - **Ignorable** — ACE stores *nothing at all* (key `7F 01 00`): no primary, not even a secondary slot. diff --git a/test/LibRed.Core.Tests/ContractionProbeTest.cs b/test/LibRed.Core.Tests/ContractionProbeTest.cs index 70e95caa..42c948da 100644 --- a/test/LibRed.Core.Tests/ContractionProbeTest.cs +++ b/test/LibRed.Core.Tests/ContractionProbeTest.cs @@ -290,6 +290,52 @@ public void Probe_prolonged_mark_records() finally { TemporaryDatabase.Delete(path); } } + // PROBE: the presentation forms, v1's last cluster of size. + // + // 276 of them differ, and one guess has already been wrong — that ACE prefers a direct NLS weight over + // an expansion, which changed nothing. Three examples were not enough to see the rule, so this dumps + // many, with the components alongside so the relationship is visible rather than inferred. + [Fact] + public void Probe_presentation_form_keys() + { + var samples = new List(); + for (int c = 0xFB00; c <= 0xFB4F; c++) samples.Add(((char)c).ToString()); // alphabetic forms + // Arabic Presentation Forms-A, the contextual isolated/initial/medial/final variants. This is where + // the bulk of the remaining differences live; the first pass sampled either side of it and missed it. + for (int c = 0xFB50; c <= 0xFB90; c++) samples.Add(((char)c).ToString()); + for (int c = 0xFD50; c <= 0xFD60; c++) samples.Add(((char)c).ToString()); + for (int c = 0xFE70; c <= 0xFEFC; c++) samples.Add(((char)c).ToString()); // Arabic forms-B + // The Hebrew letters and points the FB1x forms are built from, so a composed key can be read against + // its parts rather than guessed at. + samples.AddRange(["ו", "י", "א", "ִ", "ַ", "ּ", "ְ", + "ا", "ب", "َ", "ُ", "ِ", "ّ"]); + + string path = TemporaryDatabase.CreatePath("presentation-v1-"); + DatabaseCreator.CreateEmpty(path, collation: Collation.General); + try + { + Dictionary keys = AceKeys(path, [.. samples]); + var column = new ColumnDef + { + Name = "K", Type = JetDataType.Text, Index = 0, Collation = Collation.General, + }; + foreach (string sample in samples) + { + if (!keys.TryGetValue(sample, out string? ace)) continue; + string ours; + try { ours = Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [sample])); } + catch (NotSupportedException) { ours = "(refused)"; } + if (ours == ace) continue; + output.WriteLine($" {Describe(sample),-10} ACE {ace,-26} ours {ours}"); + } + output.WriteLine(""); + output.WriteLine(" components:"); + foreach (string sample in samples.Where(s => s[0] is >= (char)0x05B0 and <= (char)0x0651)) + output.WriteLine($" {Describe(sample),-10} ACE {keys.GetValueOrDefault(sample)}"); + } + finally { TemporaryDatabase.Delete(path); } + } + [Fact] public void Probe_unusual_inline_records() { diff --git a/test/LibRed.Core.Tests/GeneralV1CollationTests.cs b/test/LibRed.Core.Tests/GeneralV1CollationTests.cs index 639ebc30..ad22d36c 100644 --- a/test/LibRed.Core.Tests/GeneralV1CollationTests.cs +++ b/test/LibRed.Core.Tests/GeneralV1CollationTests.cs @@ -102,13 +102,22 @@ public void Descending_inverts_every_byte_and_appends_a_terminator() Assert.Equal(0x00, descending[^1]); } - // A character with no weight must fail loudly rather than produce a key that sorts wrongly. U+0378 is - // unassigned in Unicode and absent from the table; private-use characters (U+E000) do have weights. + // There is no longer a BMP character to refuse: the measured sweep covers all 63,422 ACE stores, so the + // test that used to assert refusal (on U+0378) now asserts what ACE actually does with it, below. + // Refusal is still tested for the case that keeps it — a non-English locale, further down. + // + // Astral characters are NOT covered by that claim: the sweep measured the BMP only, and a surrogate pair + // arrives as two chars that the table happens to weigh individually, so it encodes rather than refusing. + // Whether ACE agrees is unmeasured. + + // U+0378 is unassigned in Unicode, and refusing it looks like the safe answer — but ACE stores an empty + // key for it, so refusing would reject a value ACE accepts, and weighing it would sort it wrongly. + // Matching the engine beats both. Measured, not assumed: it is one of the 5,082 characters the override + // resource records as ignorable. Private-use characters (U+E000) do have weights and are not affected. [Fact] - public void An_unweighted_character_is_refused() + public void An_unassigned_character_is_ignorable_as_ACE_stores_it() { - var error = Assert.Throws(() => Encode("a͸b", Collation.General)); - Assert.Contains("no weight", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(Hex(Encode("ab", Collation.General)), Hex(Encode("a͸b", Collation.General))); } [Fact] diff --git a/test/LibRed.Core.Tests/LibRed.Core.Tests.csproj b/test/LibRed.Core.Tests/LibRed.Core.Tests.csproj index ab4495f3..b9ce77f3 100644 --- a/test/LibRed.Core.Tests/LibRed.Core.Tests.csproj +++ b/test/LibRed.Core.Tests/LibRed.Core.Tests.csproj @@ -8,6 +8,10 @@ enable false true + + true + $(MSBuildThisFileDirectory)..\..\Key.snk $(NoWarn);CA1416 AnyCPU;x86;x64 diff --git a/test/LibRed.Core.Tests/SortKeyTableV1OverrideGeneratorTest.cs b/test/LibRed.Core.Tests/SortKeyTableV1OverrideGeneratorTest.cs new file mode 100644 index 00000000..8bb48da2 --- /dev/null +++ b/test/LibRed.Core.Tests/SortKeyTableV1OverrideGeneratorTest.cs @@ -0,0 +1,352 @@ +using System.IO.Compression; +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// GENERATOR: the characters where ACE's v1 weights disagree with the published table we embed. +// +// v1's primaries ARE the Windows NLS (Script Member, Alphabetic Weight) pair, and the table was identified as +// Windows Server 2008 by reconstructing measured ACE keys — 25 of 25 against every published version. That +// held for what it was tested on, Latin and symbols. It does not hold everywhere: ACE gives Balinese and +// Canadian syllabics LATIN weights, and differs on the Arabic harakat and several ligature blocks. Those are +// scripts added or reweighted after Server 2008, so ACE's real table is not exactly the file we parse. +// +// Rather than guess at which NLS revision ACE carries, this measures the disagreements and embeds them: for +// every BMP character, encode it through ACE, compare with what JetTextCollationV1 produces, and record the +// weight ACE implies wherever they differ. That is the same answer v0 needed, at 1% of the size. +// +// Opt-in via LIBRED_GENERATE_V1=1: it inserts ~63,000 rows through ACE and rewrites a checked-in binary. +public class SortKeyTableV1OverrideGeneratorTest(ITestOutputHelper output) +{ + private const string ResourcePath = "src/LibRed/LibRed.Core/Resources/SortKeyTableV1Overrides.bin"; + + [Fact] + public void Generate_the_v1_override_resource() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_GENERATE_V1") == "1", + "set LIBRED_GENERATE_V1=1 — this measures ~63,000 characters through ACE and rewrites a resource"); + + // Suppressed for the whole run, before anything can touch the encoder. The resource records where the + // encoder disagrees with ACE, so measuring an encoder that consults it would find no disagreements and + // write an empty file. Suppressing from the outset also means the generator never has to be able to + // READ the resource it is about to replace — it bootstraps from a stale or absent one either way. + JetTextCollationV1Overrides.Suppressed = true; + string database = TemporaryDatabase.CreatePath("general-v1-gen-"); + DatabaseCreator.CreateEmpty(database, collation: Collation.General); + var column = new ColumnDef + { + Name = "K", Type = JetDataType.Text, Index = 0, Collation = Collation.General, + }; + + var overrides = new SortedDictionary(); + var ignorable = new SortedSet(); + var leftover = new SortedDictionary(); + int agreed = 0; + try + { + for (int chunk = 0x0000; chunk <= 0xF000; chunk += 0x1000) + { + string[] characters = Range(chunk, chunk + 0x0FFF); + if (characters.Length == 0) continue; + foreach ((string text, string key) in AceKeys(database, characters)) + { + if (text.Length != 1) continue; + // An empty key — ACE contributes nothing at all for this character. There is no weight to + // record, only membership, so these go in a set of ranges rather than the weight table. + // Tested before agreement, because membership is a fact about ACE alone: recording it only + // where the encoder currently differs would make the set shrink every regeneration. + if (key == EmptyKey) { ignorable.Add(text[0]); continue; } + + string? ours = null; + try { ours = Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [text])); } + catch (NotSupportedException) { } + if (ours == key) { agreed++; continue; } + + // Otherwise record the two sections verbatim. A key carrying a third section — kana, or a + // word-sort record — is a mechanism the encoder implements rather than data an override + // can carry, so report it for triage instead. + if (!TryReadSections(key, out byte[] primaries, out byte[] secondaries)) + { + leftover[text[0]] = key; + continue; + } + overrides[text[0]] = (primaries, secondaries); + } + } + + (int[] starts, int[] lengths) = ToRanges(ignorable); + output.WriteLine($"{agreed} characters already agree; {overrides.Count} weights overridden; " + + $"{ignorable.Count} ignorable in {starts.Length} ranges; " + + $"{leftover.Count} left over"); + foreach (IGrouping> shape in + leftover.GroupBy(e => ShapeOf(e.Value)).OrderByDescending(g => g.Count())) + output.WriteLine($" {shape.Count(),6} {shape.Key,-28} " + + string.Join(" ", shape.Take(4).Select(e => $"U+{e.Key:X4}={e.Value}"))); + + byte[] blob = Build(overrides, starts, lengths); + string path = Path.Combine(RepositoryRoot(), ResourcePath); + File.WriteAllBytes(path, blob); + output.WriteLine($"wrote {path} ({blob.Length:N0} bytes)"); + + (var reloaded, SortedSet reloadedIgnorable) = Parse(blob); + Assert.Equal(overrides.Count, reloaded.Count); + foreach ((int codePoint, (byte[] primaries, byte[] secondaries)) in overrides) + { + Assert.Equal(primaries, reloaded[codePoint].Item1); + Assert.Equal(secondaries, reloaded[codePoint].Item2); + } + Assert.Equal(ignorable, reloadedIgnorable); + output.WriteLine($"round-tripped {reloaded.Count} overrides and {reloadedIgnorable.Count} ignorables"); + } + finally + { + JetTextCollationV1Overrides.Suppressed = false; + TemporaryDatabase.Delete(database); + } + } + + /// + /// Splits 7F primaries 01 secondaries 00 into its two sections, kept as raw bytes. + /// + /// + /// Deliberately not parsed into (ScriptMember, Alphabetic, Diacritic) weights. That reading assumes + /// every primary is a two-byte pair carrying one secondary, and ACE breaks it in both directions: the + /// Arabic harakat have a secondary and no primary at all (U+064C is 7F 01 56 00), + /// while the Lao vowel signs take a one-byte primary (U+0EB0 is 7F 41 01 0A 00). + /// Raw bytes state what ACE actually stores, and an override is finished bytes rather than a table entry. + /// + /// The delimiter is the last 01, not the first, because a primary byte can itself be + /// 01. Five characters do exactly that — U+0385, U+1B3B, U+FC25 weigh + /// 07 53 01, and U+FC33, U+FCC2 weigh 29 0B 01. Splitting at the first + /// 01 made them look like a key with a third section bolted on, and they were misreported as an + /// unknown mechanism until measuring them in combination showed the truth: they are ordinary two-weight + /// expansions, and appending their bytes verbatim reproduces ACE in strings too — AX and + /// XA and XAX all check out. + /// + /// + /// Returns false for a key carrying a kana section, which combines across a whole string and so cannot be + /// carried per character. FF identifies one: it introduces the prolonged-mark flags and appears in + /// the closing constant, and never in a secondary weight. + /// + /// + private static bool TryReadSections(string key, out byte[] primaries, out byte[] secondaries) + { + primaries = secondaries = []; + byte[] b = Convert.FromHexString(key); + if (b.Length < 3 || b[0] != 0x7F || b[^1] != 0x00) return false; + + int end = Array.LastIndexOf(b, (byte)0x01, b.Length - 2); + if (end < 1) return false; + + byte[] rest = b[(end + 1)..^1]; + if (Array.IndexOf(rest, (byte)0xFF) >= 0) return false; // a kana section, not plain weights + + primaries = b[1..end]; + secondaries = rest; + return true; + } + + private const string EmptyKey = "7F0100"; + + /// Collapses the ignorable code points into runs, which is what makes them cheap to store. + private static (int[] Starts, int[] Lengths) ToRanges(SortedSet codePoints) + { + var starts = new List(); + var lengths = new List(); + foreach (int codePoint in codePoints) + { + if (starts.Count > 0 && starts[^1] + lengths[^1] == codePoint) lengths[^1]++; + else { starts.Add(codePoint); lengths.Add(1); } + } + return ([.. starts], [.. lengths]); + } + + /// Names the mechanism a key uses, so leftovers cluster by what they would need rather than by + /// code point. A kana section and an inline word-sort record are separate features, not stray weights. + private static string ShapeOf(string key) + { + byte[] b = Convert.FromHexString(key); + if (b.Length < 3 || b[0] != 0x7F || b[^1] != 0x00) return "not a sort key"; + int end = Array.IndexOf(b, (byte)0x01, 1); + if (end < 0) return "no secondary section"; + int primaries = (end - 1) / 2; + int secondaries = b.Length - end - 2; + if ((end - 1) % 2 != 0) return "odd primary byte count"; + if (key.Contains("010101")) return "inline (word-sort) record"; + return secondaries > primaries + ? $"{secondaries - primaries} extra secondary byte(s)" + : "unclassified"; + } + + private static string[] Range(int first, int last) + { + var characters = new List(); + for (int c = first; c <= last; c++) + if (c != ' ' && !char.IsControl((char)c) && !char.IsSurrogate((char)c)) + characters.Add(((char)c).ToString()); + return [.. characters]; + } + + private static byte[] Build( + SortedDictionary overrides, + int[] ignorableStarts, int[] ignorableLengths) + { + var deltas = new List(); + var primaryLengths = new List(); + var secondaryLengths = new List(); + var primaryBytes = new List(); + var secondaryBytes = new List(); + int previous = 0; + foreach ((int codePoint, (byte[] primaries, byte[] secondaries)) in overrides) + { + WriteVarInt(deltas, codePoint - previous); + previous = codePoint; + primaryLengths.Add(checked((byte)primaries.Length)); + secondaryLengths.Add(checked((byte)secondaries.Length)); + primaryBytes.AddRange(primaries); + secondaryBytes.AddRange(secondaries); + } + + var rangeStarts = new List(); + var rangeLengths = new List(); + previous = 0; + for (int i = 0; i < ignorableStarts.Length; i++) + { + WriteVarInt(rangeStarts, ignorableStarts[i] - previous); + WriteVarInt(rangeLengths, ignorableLengths[i]); + previous = ignorableStarts[i]; + } + + var blob = new MemoryStream(); + var writer = new BinaryWriter(blob); + writer.Write(overrides.Count); + writer.Write(ignorableStarts.Length); + foreach (List stream in new[] + { + deltas, primaryLengths, secondaryLengths, primaryBytes, secondaryBytes, + rangeStarts, rangeLengths, + }) + { + byte[] compressed = Compress([.. stream]); + writer.Write(compressed.Length); + writer.Write(compressed, 0, compressed.Length); + } + writer.Flush(); + return blob.ToArray(); + } + + private static (Dictionary Overrides, SortedSet Ignorable) Parse(byte[] blob) + { + var reader = new BinaryReader(new MemoryStream(blob)); + int count = reader.ReadInt32(); + int rangeCount = reader.ReadInt32(); + var streams = new byte[7][]; + for (int i = 0; i < 7; i++) streams[i] = Decompress(reader.ReadBytes(reader.ReadInt32())); + + var overrides = new Dictionary(count); + int offset = 0, codePoint = 0, primary = 0, secondary = 0; + for (int i = 0; i < count; i++) + { + codePoint += ReadVarInt(streams[0], ref offset); + overrides[codePoint] = ( + streams[3][primary..(primary += streams[1][i])], + streams[4][secondary..(secondary += streams[2][i])]); + } + + var ignorable = new SortedSet(); + int startOffset = 0, lengthOffset = 0, start = 0; + for (int i = 0; i < rangeCount; i++) + { + start += ReadVarInt(streams[5], ref startOffset); + int length = ReadVarInt(streams[6], ref lengthOffset); + for (int n = 0; n < length; n++) ignorable.Add(start + n); + } + return (overrides, ignorable); + } + + private static void WriteVarInt(List target, int value) + { + while (value >= 0x80) { target.Add((byte)((value & 0x7F) | 0x80)); value >>= 7; } + target.Add((byte)value); + } + + private static int ReadVarInt(byte[] source, ref int offset) + { + int value = 0, shift = 0; + while (true) + { + byte b = source[offset++]; + value |= (b & 0x7F) << shift; + if ((b & 0x80) == 0) return value; + shift += 7; + } + } + + private static byte[] Compress(byte[] data) + { + var output = new MemoryStream(); + using (var deflate = new ZLibStream(output, CompressionLevel.SmallestSize, leaveOpen: true)) + deflate.Write(data, 0, data.Length); + return output.ToArray(); + } + + private static byte[] Decompress(byte[] data) + { + var output = new MemoryStream(); + using (var inflate = new ZLibStream(new MemoryStream(data), CompressionMode.Decompress)) + inflate.CopyTo(output); + return output.ToArray(); + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "EFCore.Jet.sln"))) + directory = directory.Parent; + return directory?.FullName ?? throw new InvalidOperationException("EFCore.Jet.sln not found above the test output."); + } + + private static Dictionary AceKeys(string source, string[] samples) + { + string path = TemporaryDatabase.CopyPath(source, "v1gen-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE Gen (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_Gen ON Gen (K)"); + for (int i = 0; i < samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Gen (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("Gen"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_Gen"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + var keys = new Dictionary(); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values) && values[keyColumn.Index] is string text) + keys[text] = Convert.ToHexString(stored); + return keys; + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs b/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs index 60e81c52..2ddaf1c5 100644 --- a/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs +++ b/test/LibRed.Core.Tests/TailoringGeneratorProbeTest.cs @@ -49,25 +49,250 @@ private static readonly (string Name, int First, int Last)[] Blocks = // whether this needs a generated binary resource like the v1 table. // // Slow by nature: every character is a real INSERT through ACE. Run it deliberately, not in a suite. + // DIAGNOSTIC: are v1's ~29,800 mismatches a few structural rules, or 29,800 individual weights? + // + // v1's primaries ARE the NLS (Script Member, Alphabetic Weight) pair verbatim, and the table is embedded + // whole — so a mismatch is much more likely to be a rule we have wrong than a weight we lack. This + // classifies every mismatch across the BMP and shows examples per block, which decides whether v1 needs + // the full measure-and-embed treatment v0 got, or a handful of fixes. [Fact] - public void Probe_full_bmp_coverage() + public void Probe_v1_mismatch_shapes() { Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_FULL_BMP") == "1", "set LIBRED_FULL_BMP=1 — this inserts ~63,000 rows through ACE and takes minutes"); + string path = TemporaryDatabase.CreatePath("general-v1-diag-"); + DatabaseCreator.CreateEmpty(path, collation: Collation.General); + var column = new ColumnDef + { + Name = "K", Type = JetDataType.Text, Index = 0, Collation = Collation.General, + }; + + var byBlock = new SortedDictionary(); + var examples = new SortedDictionary>(); + try + { + for (int chunk = 0x0000; chunk <= 0xF000; chunk += 0x1000) + { + string[] characters = Range(chunk, chunk + 0x0FFF); + if (characters.Length == 0) continue; + Dictionary ace = AceKeys(path, "v1diag", characters); + + foreach (string text in characters) + { + if (!ace.TryGetValue(text, out string? key)) continue; + string block = BlockOf(text[0]); + (int correct, int aceIgnorable, int refused, int differ) = byBlock.GetValueOrDefault(block); + + string? ours = null; + try { ours = Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [text])); } + catch (NotSupportedException) { } + + if (ours == key) correct++; + else if (ours is null) refused++; + else if (key == "7F0100") aceIgnorable++; + else + { + differ++; + List shown = examples.TryGetValue(block, out var list) ? list : examples[block] = []; + if (shown.Count < 3) + shown.Add($"{Describe(text)} ACE {key,-30} ours {ours}"); + } + byBlock[block] = (correct, aceIgnorable, refused, differ); + } + } + + output.WriteLine($" {"block",-28} {"correct",8} {"ACE-ignorable",14} {"refused",8} {"differ",8}"); + foreach ((string block, (int correct, int aceIgnorable, int refused, int differ)) in byBlock) + output.WriteLine($" {block,-28} {correct,8} {aceIgnorable,14} {refused,8} {differ,8}"); + + foreach ((string block, List shown) in examples) + { + output.WriteLine(""); + output.WriteLine($" {block}:"); + foreach (string line in shown) output.WriteLine($" {line}"); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + // DIAGNOSTIC: group EVERY remaining v1 difference by the shape of the difference, not by block. + // + // Hunting sub-range by sub-range has hit diminishing returns and twice sampled the wrong place. A + // signature — how many weights each side emitted, and whether the primaries agree — clusters the whole + // tail by cause in one pass, so what is left is visible as N rules rather than 365 characters. + [Fact] + public void Probe_v1_difference_signatures() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_FULL_BMP") == "1", + "set LIBRED_FULL_BMP=1 — this inserts ~63,000 rows through ACE and takes minutes"); + + string path = TemporaryDatabase.CreatePath("general-v1-sig-"); + DatabaseCreator.CreateEmpty(path, collation: Collation.General); + var column = new ColumnDef + { + Name = "K", Type = JetDataType.Text, Index = 0, Collation = Collation.General, + }; + + var counts = new SortedDictionary(); + var examples = new SortedDictionary>(); + try + { + for (int chunk = 0x0000; chunk <= 0xF000; chunk += 0x1000) + { + string[] characters = Range(chunk, chunk + 0x0FFF); + if (characters.Length == 0) continue; + foreach ((string text, string key) in AceKeys(path, "v1sig", characters)) + { + string ours; + try { ours = Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [text])); } + catch (NotSupportedException) { continue; } // refusals are safe; chase wrong output + if (ours == key) continue; + + string signature = Signature(key, ours); + counts[signature] = counts.GetValueOrDefault(signature) + 1; + List shown = examples.TryGetValue(signature, out var list) ? list : examples[signature] = []; + if (shown.Count < 4) shown.Add($"{Describe(text)} ACE {key,-30} ours {ours}"); + } + } + + foreach ((string signature, int count) in counts.OrderByDescending(s => s.Value)) + { + output.WriteLine(""); + output.WriteLine($" {count,5} {signature}"); + foreach (string line in examples[signature]) output.WriteLine($" {line}"); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + /// A coarse description of HOW two keys differ: the weight counts on each side, and whether the + /// primary run is identical, a prefix, or unrelated. Keys are 7F primaries 01 secondaries 00. + private static string Signature(string ace, string ours) + { + (string acePrimaries, int aceSecondaries) = Split(ace); + (string ourPrimaries, int ourSecondaries) = Split(ours); + string primaries = + acePrimaries == ourPrimaries ? "primaries same" + : ourPrimaries.StartsWith(acePrimaries, StringComparison.Ordinal) ? "ACE primaries are a prefix of ours" + : acePrimaries.StartsWith(ourPrimaries, StringComparison.Ordinal) ? "our primaries are a prefix of ACE's" + : acePrimaries.Length == ourPrimaries.Length ? "primaries same length, different bytes" + : $"primaries {ourPrimaries.Length / 2}b vs {acePrimaries.Length / 2}b"; + return $"{primaries}; secondaries {ourSecondaries} vs {aceSecondaries}"; + + static (string Primaries, int Secondaries) Split(string key) + { + byte[] bytes = Convert.FromHexString(key); + int end = Array.IndexOf(bytes, (byte)0x01, 1); + if (end < 0) return (key, 0); + return (Convert.ToHexString(bytes[1..end]), Math.Max(0, bytes.Length - end - 2)); + } + } + + /// Coarser than the block list above — enough to see where mismatches cluster. + private static string BlockOf(char c) => c switch + { + <= (char)0x024F => "Latin", + <= (char)0x036F => "modifiers + marks", + <= (char)0x03FF => "Greek", + <= (char)0x052F => "Cyrillic", + <= (char)0x08FF => "Hebrew/Arabic/Syriac", + <= (char)0x0DFF => "Indic", + <= (char)0x0FFF => "Thai/Lao/Tibetan", + <= (char)0x1FFF => "Myanmar..Greek Ext", + <= (char)0x2BFF => "punctuation + symbols", + <= (char)0x2FFF => "CJK radicals", + <= (char)0x303F => "CJK punctuation", + <= (char)0x30FF => "kana", + <= (char)0x33FF => "kana ext + compat", + <= (char)0x4DBF => "CJK ext A", + <= (char)0x9FFF => "CJK unified", + <= (char)0xA4CF => "Yi", + <= (char)0xABFF => "Latin ext-D + syllabics", + <= (char)0xD7FF => "Hangul", + <= (char)0xDFFF => "surrogates", + <= (char)0xF8FF => "private use", + <= (char)0xFAFF => "CJK compat ideographs", + <= (char)0xFDFF => "presentation forms A", + <= (char)0xFEFF => "small + Arabic forms", + _ => "halfwidth/fullwidth", + }; + + /// + /// The five characters whose v1 key carries a THIRD section, measured in combination. + /// + /// + /// Alone, ACE gives U+0385, U+1B3B and U+FC25 the same key — 7F 0753 01 01 02 0C 00 — although + /// they are Greek, Balinese and an Arabic ligature respectively, and nothing about them is related. Three + /// unrelated characters collapsing onto one key is the sort of coincidence worth measuring rather than + /// theorising about, and a single-character sweep cannot say whether that trailing 02 0C is + /// positional (like the word-sort record) or fixed (like the kana tail). So: pair each with a letter on + /// either side, and with itself. + /// + [Fact] + public void Probe_v1_third_section() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_FULL_BMP") == "1", + "set LIBRED_FULL_BMP=1 — this probe needs ACE"); + + string path = TemporaryDatabase.CreatePath("general-v1-third-"); + DatabaseCreator.CreateEmpty(path, collation: Collation.General); + try + { + char[] subjects = [(char)0x0385, (char)0x1B3B, (char)0xFC25, (char)0xFC33, (char)0xFCC2]; + var samples = new List(); + foreach (char subject in subjects) + samples.AddRange([ + subject.ToString(), "A" + subject, subject + "A", "AA" + subject, + subject.ToString() + subject, subject + "A" + subject, + ]); + + Dictionary ace = AceKeys(path, "third", [.. samples]); + foreach (string sample in samples) + output.WriteLine($" {Describe(sample),-28} {(ace.TryGetValue(sample, out string? k) ? k : "(refused)")}"); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Probe_full_bmp_coverage(byte version) + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_FULL_BMP") == "1", + "set LIBRED_FULL_BMP=1 — this inserts ~63,000 rows through ACE and takes minutes"); + + // v1 has no fixture of its own for plain General, so LibRed makes one. That is safe here: ACE does + // the encoding, LibRed only supplies an empty database carrying the right sort order. + string source = TestDatabases.NorthwindAccdb; + string? created = null; + if (version == Collation.GeneralVersion) + { + created = TemporaryDatabase.CreatePath("general-v1-bmp-"); + DatabaseCreator.CreateEmpty(created, collation: Collation.General); + source = created; + } + + var column = new ColumnDef + { + Name = "K", Type = JetDataType.Text, Index = 0, + Collation = version == Collation.GeneralVersion ? Collation.General : Collation.GeneralLegacy, + }; + int totalCorrect = 0, totalToAdd = 0, totalIgnorable = 0, totalRefused = 0; + output.WriteLine($"General v{version}"); output.WriteLine($" {"range",-14} {"correct",8} {"to add",8} {"ignorable",10} {"ACE refused",12}"); for (int chunk = 0x0000; chunk <= 0xF000; chunk += 0x1000) { string[] characters = Range(chunk, chunk + 0x0FFF); if (characters.Length == 0) continue; - Dictionary ace = AceKeys(TestDatabases.NorthwindAccdb, "bmp", characters); + Dictionary ace = AceKeys(source, "bmp", characters); int correct = 0, toAdd = 0, ignorable = 0, refused = 0; foreach (string text in characters) { if (!ace.TryGetValue(text, out string? key)) { refused++; continue; } - if (Matches(text, key)) { correct++; continue; } + if (Matches(text, key, column)) { correct++; continue; } if (key == "7F0100") { ignorable++; continue; } toAdd++; } @@ -77,8 +302,9 @@ public void Probe_full_bmp_coverage() } output.WriteLine(""); - output.WriteLine($" BMP total: {totalCorrect} already correct, {totalToAdd} to add, " + + output.WriteLine($" BMP total (v{version}): {totalCorrect} already correct, {totalToAdd} to add, " + $"{totalIgnorable} ignorable-but-unhandled, {totalRefused} ACE would not store"); + if (created is not null) TemporaryDatabase.Delete(created); } // Sweeps every block against General v0 and classifies what ACE stores, so the base table can be filled @@ -280,9 +506,9 @@ private static string[] Range(int first, int last) } /// Whether LibRed already encodes exactly as ACE did. - private static bool Matches(string text, string expected) + private static bool Matches(string text, string expected, ColumnDef? column = null) { - var column = new ColumnDef + column ??= new ColumnDef { Name = "K", Type = JetDataType.Text, Index = 0, Collation = Collation.GeneralLegacy, }; From 30dd6939fea650acbd115931c7ad60d302de3cfa Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 17:26:24 +0800 Subject: [PATCH 11/48] LibRed: the 510-byte index entry limit, and collation above the BMP ACE stores an index entry of at most 510 bytes as built. At exactly 510 it comes back byte-for-byte; a value needing 511 comes back as 510 with the weights cut short and the last two bytes replaced by a value that varies with the string - a truncated key plus a checksum, which is why two long values never collide. The checksum function is not known, so LibRed cannot reproduce a truncated key and refuses the value instead. It was writing the full-length key, which put bytes in the index ACE would never write, and a wrong index key is silent: ACE writes its own into the same index and a seek misses rows. The cap is on the whole ENTRY, not per column. Two 200-character text columns weigh about 404 bytes of key each - comfortably under the cap individually - and ACE stores their combined entry hashed at 510. A per-column check would have let that through. Because it limits weights and not characters, what it buys varies with collation and script, which is the practical cost of General over General Legacy and is invisible in the schema: 255 characters for v0 Latin (the column limit is reached first), 253 for v1 Latin, 169 for v1 accented, 127 for v1 Han. Above the BMP the two orders disagree completely, measured over all of planes 1 and 2 and sampled across all sixteen. v0 ignores astral characters entirely - every one gets the empty key - so under General Legacy an astral character is invisible to the index. v1 weighs BOTH surrogate halves, each looked up like any other character: U+10000 is 7F B002 B4F8 01 3F 3F 00. Only the high surrogates to U+D87F carry weights, so from plane 3 upward the high half is ignorable and the low one stands alone, and those planes collapse onto 1,024 keys. The fix v1 needed was therefore narrow: an unweighted surrogate is ignorable rather than an error. The tempting reading of the plane-3 samples - "the high surrogate contributes nothing" - is wrong, and skipping every high surrogate breaks all 131,068 characters of planes 1 and 2. Co-Authored-By: Claude Opus 5 --- .../LibRed.Core/Storage/IndexKeyEncoder.cs | 33 ++ .../LibRed.Core/Storage/JetTextCollation.cs | 7 + .../LibRed.Core/Storage/JetTextCollationV1.cs | 14 + .../docs/format/page-03-04-index-btree.md | 42 +- .../AstralCollationProbeTest.cs | 425 ++++++++++++++++++ .../GeneralV1CollationTests.cs | 82 ++++ 6 files changed, 600 insertions(+), 3 deletions(-) create mode 100644 test/LibRed.Core.Tests/AstralCollationProbeTest.cs diff --git a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs index 95bfceb1..dcd8cae9 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs @@ -22,6 +22,19 @@ public static class IndexKeyEncoder /// Access indexes only the first 255 characters of a Memo (Long Text) value (verified vs ACE). private const int MemoKeyMaxChars = 255; + /// + /// The longest index entry ACE stores verbatim. Measured: an entry of exactly 510 bytes comes back + /// byte-for-byte, and one that would be 511 comes back as 510 — the weights cut short and the last two + /// bytes replaced by a value that varies with the string (…0E0602 for one 254-character value, + /// …0EDE2A for the 255-character one). That is a truncated key plus a checksum, which is why two + /// long values never collide, and it is why LibRed cannot simply cut its own key to match. + /// + /// It caps the whole entry rather than each column: two 200-character text columns are about 404 bytes + /// of key each and ACE stores their combined entry hashed at 510. + /// + /// + private const int MaxIndexKeyBytes = 510; + public static byte[] Encode(IReadOnlyList<(ColumnDef Column, bool Ascending)> columns, object?[] values) { var buffer = new List(); @@ -151,6 +164,26 @@ public static byte[] Encode(IReadOnlyList<(ColumnDef Column, bool Ascending)> co buffer.AddRange(raw); } + // Past 510 bytes ACE stops storing the key it built and stores a truncated one with a two-byte + // checksum in place of the tail. Reproducing that needs the checksum function, which is not known, so + // refuse rather than write the full-length key: it is not what ACE would have written, and a wrong + // index key is silent — ACE writes its own into the same index and a seek misses rows. + // + // The limit is on the WHOLE entry, not per column: two 200-character text columns weigh about 404 + // bytes each, comfortably under the cap individually, and ACE stores their combined entry hashed. + // + // How much text this allows is therefore a limit on WEIGHTS rather than characters, and it varies: + // General Legacy spends one primary byte per Latin character and reaches the 255-character column + // limit before this one, General spends two, and a Han character costs four. So the same column + // indexes roughly 255, 253 or 127 characters depending on collation and script. + if (buffer.Count > MaxIndexKeyBytes) + throw new NotSupportedException( + $"These values need a {buffer.Count}-byte index key across {columns.Count} column(s), and ACE " + + $"stores at most {MaxIndexKeyBytes} bytes verbatim — beyond that it truncates the key and " + + $"appends a checksum LibRed cannot reproduce. Index fewer or shorter columns: the limit is on " + + $"collation weights, so it buys fewer characters under General than General Legacy, and fewer " + + $"again for scripts whose characters weigh more than two bytes."); + return [.. buffer]; } diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs index e9633e78..5fad4725 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs @@ -237,6 +237,13 @@ public static bool TryEncode(string value, List output, LocaleTailoring? t { char c = s[position]; char u = char.ToUpperInvariant(c); + + // Astral characters are wholly ignorable in v0 — ACE stores the empty key 7F 01 00 for every one, + // measured across all of planes 1 and 2 and sampled across all sixteen. Not a gap in the table but + // the order's actual behaviour, and the opposite of v1, which weighs them by their low surrogate. + // Neither half contributes, so both are skipped and an astral character vanishes from the key. + if (char.IsSurrogate(c)) continue; + // The hand-verified ignorables first, then the measured ones — 296 across the BMP, every dash and // quotation form, the Arabic harakat, and the CJK and fullwidth punctuation. if (Ignorables.TryGetValue(c, out byte code) || diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs index 62e8a197..6d16f131 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs @@ -75,6 +75,20 @@ public static bool TryEncode(string value, List output) foreach (char character in text) { + // A surrogate the table has no weight for is IGNORABLE, not an error — which is the whole of what + // astral support needs here, because both halves are otherwise weighed like any other character. + // + // ACE weighs an astral character by BOTH halves where it has weights for both: U+10000 is + // 7F B002 B4F8 01 3F 3F 00, the high surrogate D800 weighing B002 and the low DC00 weighing B4F8. + // Only the high surrogates up to U+D87F carry weights, so from plane 3 upward the high half drops + // out and the low one stands alone — U+30000 is 7F B4F8 01 3F 00. Planes 1 and 2 are therefore + // fully distinguished, while planes 3 to 16 collapse onto 1,024 keys and U+30000, U+31000, + // U+34000 and U+40000 all share one. That is ACE's behaviour to reproduce, not to improve. + // + // Written as "skip every high surrogate" first, generalising from plane-3 samples where the high + // half is unweighted. That broke all 131,068 characters of planes 1 and 2, where it is not. + if (char.IsSurrogate(character) && !table.TryGetWeight(character, out _, out _, out _)) continue; + // Kana are weighed and sectioned exactly as in v0 — same sound weights, same section, verified // byte-for-byte against ACE under both sort orders. Handled here rather than in Append because a // single kana changes the shape of the WHOLE key. 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 7660fe9d..8751491a 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -202,9 +202,45 @@ Then the value, transformed: > what ACE stores, nothing refused and nothing left unhandled (`Probe_full_bmp_coverage`, needs > `LIBRED_FULL_BMP`). Other locales are still refused under v1. > -> **The BMP is the limit of that claim.** Nothing above `U+FFFF` has been measured. A surrogate pair reaches -> the encoder as two chars that the table happens to weigh individually, so an astral character encodes -> rather than being refused — whether the result is what ACE stores is unknown, and worth a sweep of its own. Tests: `GeneralV1CollationTests` (keys +> **Above the BMP** the two orders disagree completely, measured over all of planes 1 and 2 and sampled across +> all sixteen. **v0 ignores astral characters entirely** — every one gets the empty key `7F 01 00`, so under +> General Legacy an astral character is invisible to the index. **v1 weighs both surrogate halves**, each +> looked up in the table like any other character: `U+10000` is `7F B002 B4F8 01 3F 3F 00`, the high surrogate +> `D800` weighing `B002` and the low `DC00` weighing `B4F8`. +> +> Only the high surrogates up to `U+D87F` carry weights. From **plane 3 upward the high half is ignorable** +> and the low one stands alone — `U+30000` is `7F B4F8 01 3F 00`, and `U+31000`, `U+34000` and `U+40000` give +> the same. So planes 1 and 2 are fully distinguished, while planes 3 to 16 collapse onto **1,024 keys** and +> any two code points there congruent mod `0x400` share one. +> +> The only change v1 needed was to treat an **unweighted surrogate as ignorable rather than an error**. The +> tempting reading of the plane-3 samples — "the high surrogate contributes nothing" — is wrong, and skipping +> every high surrogate breaks all 131,068 characters of planes 1 and 2. `AstralCollationProbeTest`, needs +> `LIBRED_ASTRAL=1` (or `LIBRED_ASTRAL_FULL=1` for a whole plane). + +### 10.5 The 510-byte index entry limit + +**ACE stores an index entry of at most 510 bytes as built.** At exactly 510 it comes back byte-for-byte; a +value that would need 511 comes back as 510 with the weights cut short and the last two bytes replaced by a +value that varies with the string — `…0E0602` for one 254-character value, `…0EDE2A` for the 255-character +one. That is a truncated key plus a **checksum**, which is why two long values never collide in the index. +The checksum function is **not known**, so LibRed cannot reproduce a truncated key and **refuses the value +instead** — writing the full-length key would put bytes in the index that ACE would never write, and a wrong +index key is silent. + +The cap is on the **whole entry, not per column**: two 200-character text columns weigh about 404 bytes of +key each, comfortably under the cap individually, and ACE stores their combined entry hashed at 510. + +Because it limits **weights** rather than characters, the text it buys depends on collation and script — and +this is the practical cost of General over General Legacy, invisible in the schema: + +| | bytes per character | characters indexed in full | +|---|---|---| +| v0, Latin | 1 primary | **255** — the column limit is reached first | +| v0, accented / CJK | 2 | **254** | +| v1, Latin | 2 primary | **253** | +| v1, accented | 3 | **169** | +| v1, Han | 4 (`FD FF AW DW`) | **127** | Tests: `GeneralV1CollationTests` (keys > measured from ACE) and `GeneralV1CollationAccessTests` (live oracle, plus ACE seeking an index LibRed > wrote in a v1 database). diff --git a/test/LibRed.Core.Tests/AstralCollationProbeTest.cs b/test/LibRed.Core.Tests/AstralCollationProbeTest.cs new file mode 100644 index 00000000..06c0b5c6 --- /dev/null +++ b/test/LibRed.Core.Tests/AstralCollationProbeTest.cs @@ -0,0 +1,425 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: what ACE does above U+FFFF, and how much text an index key can actually hold. +// +// The full-BMP sweeps establish that both sort orders reproduce ACE byte-for-byte for all 63,422 characters +// ACE stores below U+10000. Nothing above that has ever been measured. A surrogate pair reaches the encoder +// as two chars, and the embedded v1 weight table holds entries for surrogate code points, so an astral +// character currently ENCODES rather than being refused — whether the result is what ACE stores is exactly +// what this measures. +// +// Both probes need ACE and are opt-in via LIBRED_ASTRAL=1. +public class AstralCollationProbeTest(ITestOutputHelper output) +{ + /// + /// A spread across all sixteen astral planes: the plane's first code points, a few interior ones, and its + /// last non-noncharacter. Reconnaissance before any full sweep — a million code points is sixteen times + /// the BMP, so it is worth knowing whether ACE stores them at all before paying for that. + /// + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Probe_astral_reconnaissance(byte version) + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_ASTRAL") == "1", + "set LIBRED_ASTRAL=1 — this probe needs ACE"); + + (string source, string? created, ColumnDef column) = Fixture(version); + try + { + var samples = new List(); + for (int plane = 1; plane <= 16; plane++) + foreach (int offset in (int[])[0x0000, 0x0001, 0x0040, 0x0300, 0x1000, 0x4000, 0xF000, 0xFFFD]) + samples.Add(char.ConvertFromUtf32(plane * 0x10000 + offset)); + + // Characters that actually mean something up there, rather than only round numbers. + foreach (int codePoint in (int[]) + [0x10000, 0x103A0, 0x10400, 0x1D400, 0x1D160, 0x1F600, 0x20000, 0x2A700, 0xE0001, 0xE0100]) + samples.Add(char.ConvertFromUtf32(codePoint)); + + Dictionary ace = AceKeys(source, "astral", [.. samples]); + + int stored = 0, refused = 0, matched = 0, differ = 0; + var examples = new List(); + foreach (string text in samples.Distinct()) + { + if (!ace.TryGetValue(text, out string? key)) { refused++; continue; } + stored++; + + string? ours = null; + try { ours = Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [text])); } + catch (NotSupportedException) { } + + if (ours == key) { matched++; continue; } + differ++; + if (examples.Count < 12) + examples.Add($" U+{char.ConvertToUtf32(text, 0):X5} ACE {key,-24} ours {ours ?? "(refused)"}"); + } + + output.WriteLine($"General v{version}: {samples.Distinct().Count()} sampled — " + + $"{stored} ACE stored, {refused} ACE would not store, {matched} match, {differ} differ"); + foreach (string line in examples) output.WriteLine(line); + } + finally { if (created is not null) TemporaryDatabase.Delete(created); } + } + + /// + /// Every code point in one astral plane, for the planes that carry assigned characters. Run after the + /// reconnaissance above says it is worth it. + /// + [Theory] + [InlineData(0, 1)] + [InlineData(1, 1)] + [InlineData(0, 2)] + [InlineData(1, 2)] + public void Probe_astral_plane(byte version, int plane) + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_ASTRAL_FULL") == "1", + "set LIBRED_ASTRAL_FULL=1 — this inserts 65,536 rows through ACE per plane and takes minutes"); + + (string source, string? created, ColumnDef column) = Fixture(version); + try + { + int stored = 0, refused = 0, matched = 0, differ = 0; + var shapes = new SortedDictionary(); + var examples = new List(); + + for (int chunk = 0; chunk < 0x10000; chunk += 0x1000) + { + var samples = new List(); + for (int offset = chunk; offset < chunk + 0x1000; offset++) + { + int codePoint = plane * 0x10000 + offset; + if ((codePoint & 0xFFFE) == 0xFFFE) continue; // noncharacters + samples.Add(char.ConvertFromUtf32(codePoint)); + } + + Dictionary ace = AceKeys(source, "aplane", [.. samples]); + foreach (string text in samples) + { + if (!ace.TryGetValue(text, out string? key)) { refused++; continue; } + stored++; + + string? ours = null; + try { ours = Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [text])); } + catch (NotSupportedException) { } + + if (ours == key) { matched++; continue; } + differ++; + shapes[key.Length <= 12 ? key : $"{key[..8]}… ({key.Length / 2} bytes)"] = + shapes.GetValueOrDefault(key.Length <= 12 ? key : $"{key[..8]}… ({key.Length / 2} bytes)") + 1; + if (examples.Count < 10) + examples.Add($" U+{char.ConvertToUtf32(text, 0):X5} ACE {key,-24} ours {ours ?? "(refused)"}"); + } + } + + output.WriteLine($"General v{version}, plane {plane}: {stored} ACE stored, {refused} refused, " + + $"{matched} match, {differ} differ"); + foreach ((string shape, int count) in shapes.OrderByDescending(e => e.Value).Take(8)) + output.WriteLine($" {count,6} ACE key {shape}"); + foreach (string line in examples) output.WriteLine(line); + } + finally { if (created is not null) TemporaryDatabase.Delete(created); } + } + + /// + /// How much text an index key can actually carry, measured rather than assumed. + /// + /// + /// The nominal limit is the column width — a Jet/ACE TEXT column holds 255 characters. The index key is + /// the real constraint, and it is not the same number: v1 spends TWO primary bytes per character where v0 + /// spends one, and accents add a secondary byte each on top. So the question is where ACE stops, and + /// whether it stops by rejecting the row or by silently truncating the key — truncation is the dangerous + /// answer, because two different long strings would then collide in the index. + /// + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Probe_maximum_indexable_length(byte version) + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_ASTRAL") == "1", + "set LIBRED_ASTRAL=1 — this probe needs ACE"); + + (string source, string? created, ColumnDef column) = Fixture(version); + try + { + foreach ((string name, string unit) in ((string, string)[]) + [("plain 'a'", "a"), ("accented 'á'", "á"), ("CJK", "一")]) + { + // One row per length, each padded to a distinct value so nothing dedupes. + var samples = new List(); + for (int length = 1; length <= 255; length++) samples.Add(string.Concat(Enumerable.Repeat(unit, length))); + + Dictionary ace = AceKeys(source, "maxlen", [.. samples]); + + int longestStored = 0, longestMatching = 0, previousKeyBytes = 0, firstTruncated = 0; + foreach (string text in samples) + { + if (!ace.TryGetValue(text, out string? key)) continue; + longestStored = text.Length; + + int keyBytes = key.Length / 2; + if (keyBytes == previousKeyBytes && firstTruncated == 0) firstTruncated = text.Length; + previousKeyBytes = keyBytes; + + string? ours = null; + try { ours = Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [text])); } + catch (NotSupportedException) { } + if (ours == key) longestMatching = text.Length; + } + + output.WriteLine( + $"v{version} {name,-14} longest ACE indexed {longestStored,4} chars " + + $"({previousKeyBytes,3} key bytes); LibRed matches to {longestMatching,4}; " + + $"key stopped growing at {(firstTruncated == 0 ? "never" : firstTruncated.ToString())}"); + } + } + finally { if (created is not null) TemporaryDatabase.Delete(created); } + } + + /// + /// The exact truncation boundary, and whether two longer strings end up with the SAME key. + /// + /// + /// Length alone cannot tell truncation from a key that simply stopped growing. What matters is whether + /// distinct values collide, because a collision is silent: the index holds one entry where it should hold + /// two, and a seek returns the wrong rows rather than an error. + /// + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Probe_key_truncation_boundary(byte version) + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_ASTRAL") == "1", + "set LIBRED_ASTRAL=1 — this probe needs ACE"); + + (string source, string? created, ColumnDef column) = Fixture(version); + try + { + foreach ((string name, string unit) in ((string, string)[]) + [("plain 'a'", "a"), ("accented 'á'", "á"), ("CJK", "一")]) + { + // Distinct values of each length: a run of the unit followed by a marker, so two lengths can + // only produce the same key if the tail was actually discarded. + var samples = new List(); + for (int length = 1; length <= 255; length++) + samples.Add(string.Concat(Enumerable.Repeat(unit, length - 1)) + "z"); + + Dictionary ace = AceKeys(source, "trunc", [.. samples]); + + int maxBytes = 0, lastGrowing = 0, firstCollision = 0, libredMatches = 0; + string previous = ""; + foreach (string text in samples) + { + if (!ace.TryGetValue(text, out string? key)) continue; + int bytes = key.Length / 2; + if (bytes > maxBytes) { maxBytes = bytes; lastGrowing = text.Length; } + if (key == previous && firstCollision == 0) firstCollision = text.Length; + previous = key; + + string? ours = null; + try { ours = Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [text])); } + catch (NotSupportedException) { } + if (ours == key) libredMatches = text.Length; + } + + output.WriteLine( + $"v{version} {name,-14} max key {maxBytes,3} bytes, reached at {lastGrowing,4} chars; " + + $"first identical-key collision at {(firstCollision == 0 ? "none" : firstCollision.ToString()),4}; " + + $"LibRed matches to {libredMatches,4}"); + } + } + finally { if (created is not null) TemporaryDatabase.Delete(created); } + } + + /// What ACE actually stores either side of the 510-byte boundary, byte for byte. + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Probe_key_at_the_boundary(byte version) + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_ASTRAL") == "1", + "set LIBRED_ASTRAL=1 — this probe needs ACE"); + + (string source, string? created, ColumnDef column) = Fixture(version); + try + { + foreach ((string name, string unit, int around) in ((string, string, int)[]) + [("plain 'a'", "a", 254), ("accented 'á'", "á", 170), ("CJK", "一", 128)]) + { + var samples = new List(); + for (int length = around - 2; length <= around + 2; length++) + samples.Add(string.Concat(Enumerable.Repeat(unit, length - 1)) + "z"); + + Dictionary ace = AceKeys(source, "bound", [.. samples]); + output.WriteLine($"--- v{version} {name}"); + foreach (string text in samples) + { + if (!ace.TryGetValue(text, out string? key)) { output.WriteLine($" {text.Length,4} (not stored)"); continue; } + string? ours = null; + try { ours = Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [text])); } + catch (NotSupportedException) { } + + // Only the tails differ, so show those rather than 500 identical bytes. + output.WriteLine($" {text.Length,4} ACE {key.Length / 2,3}B …{key[^24..]} " + + (ours is null ? "ours (refused)" + : ours == key ? "ours same" + : $"ours {ours.Length / 2,3}B …{ours[^24..]}")); + } + } + } + finally { if (created is not null) TemporaryDatabase.Delete(created); } + } + + /// + /// Whether a key of exactly 510 bytes is stored intact, or already hashed. + /// + /// + /// Uniform strings step over 510 — 253 'a' is 509 bytes and 254 is 511 — so the boundary itself is never + /// exercised by them, and "≤ 510 is safe" would be an assumption. Putting the only accent on the FIRST + /// character makes the secondary section exactly one byte long, which lands the key on any length wanted: + /// 1 (start) + 2n (primaries) + 1 (delimiter) + 1 (secondary) + 1 (terminator). + /// + [Theory] + [InlineData(0)] + [InlineData(1)] + public void Probe_exact_key_length_boundary(byte version) + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_ASTRAL") == "1", + "set LIBRED_ASTRAL=1 — this probe needs ACE"); + + (string source, string? created, ColumnDef column) = Fixture(version); + try + { + var samples = new List(); + for (int n = 250; n <= 255; n++) samples.Add("á" + new string('a', n - 1)); + + Dictionary ace = AceKeys(source, "exact", [.. samples]); + foreach (string text in samples) + { + string? ours = null; + try { ours = Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [text])); } + catch (NotSupportedException) { } + + string key = ace.GetValueOrDefault(text, ""); + output.WriteLine( + $"v{version} {text.Length,4} chars: ACE {(key.Length == 0 ? "(not stored)" : $"{key.Length / 2,3}B ending {key[^6..]}"),-22} " + + $"ours {(ours is null ? "(refused)" : $"{ours.Length / 2,3}B ending {ours[^6..]}")} " + + (ours == key ? "SAME" : "differ")); + } + } + finally { if (created is not null) TemporaryDatabase.Delete(created); } + } + + /// + /// Whether the 510-byte cap is on ONE text column's key or on the whole index entry. + /// + /// + /// Everything measured so far used a single-column index, so "510 per text column" would be an + /// extrapolation. It matters: a two-column index of two 400-byte keys is under the cap per column and far + /// over it per entry, and guessing wrong leaves exactly the silent-corruption case this is meant to close. + /// Two 200-character columns are ~400 bytes each under v1 — comfortably under per column, 800 combined. + /// + [Fact] + public void Probe_multi_column_key_limit() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_ASTRAL") == "1", + "set LIBRED_ASTRAL=1 — this probe needs ACE"); + + string path = TemporaryDatabase.CreatePath("general-v1-multi-"); + DatabaseCreator.CreateEmpty(path, collation: Collation.General); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE Two (A TEXT(255), B TEXT(255), V LONG)"); + Exec(connection, "CREATE INDEX IX_Two ON Two (A, B)"); + foreach (int length in (int[])[10, 100, 200, 255]) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Two (A, B, V) VALUES (?, ?, ?)"; + insert.Parameters.AddWithValue("a", new string('a', length)); + insert.Parameters.AddWithValue("b", new string('b', length)); + insert.Parameters.AddWithValue("v", length); + try { insert.ExecuteNonQuery(); } + catch (Exception error) { output.WriteLine($" {length,4}+{length,-4} ACE refused: {error.Message.Trim()}"); } + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("Two"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_Two"); + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + int valueColumn = table.Definition.FindColumn("V")!.Index; + + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values)) + output.WriteLine($" {values[valueColumn],4} chars each: entry {stored.Length,4} bytes, " + + $"ends {Convert.ToHexString(stored)[^6..]}"); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static (string Source, string? Created, ColumnDef Column) Fixture(byte version) + { + bool v1 = version == Collation.GeneralVersion; + string? created = null; + if (v1) + { + created = TemporaryDatabase.CreatePath("general-v1-astral-"); + DatabaseCreator.CreateEmpty(created, collation: Collation.General); + } + + return (created ?? TestDatabases.NorthwindAccdb, created, new ColumnDef + { + Name = "K", Type = JetDataType.Text, Index = 0, + Collation = v1 ? Collation.General : Collation.GeneralLegacy, + }); + } + + private static Dictionary AceKeys(string source, string tag, string[] samples) + { + string path = TemporaryDatabase.CopyPath(source, tag); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE Probe (K TEXT(255), V LONG)"); + Exec(connection, "CREATE INDEX IX_Probe ON Probe (K)"); + for (int i = 0; i < samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Probe (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("Probe"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_Probe"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + var keys = new Dictionary(); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values) && values[keyColumn.Index] is string text) + keys[text] = Convert.ToHexString(stored); + return keys; + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Core.Tests/GeneralV1CollationTests.cs b/test/LibRed.Core.Tests/GeneralV1CollationTests.cs index ad22d36c..962ba9ec 100644 --- a/test/LibRed.Core.Tests/GeneralV1CollationTests.cs +++ b/test/LibRed.Core.Tests/GeneralV1CollationTests.cs @@ -120,6 +120,88 @@ public void An_unassigned_character_is_ignorable_as_ACE_stores_it() Assert.Equal(Hex(Encode("ab", Collation.General)), Hex(Encode("a͸b", Collation.General))); } + // ACE stores an index entry of at most 510 bytes as built; past that it truncates the weights and + // appends a two-byte checksum, so LibRed refuses rather than write a key ACE would never have written. + // Both bounds are measured: 253 characters with the accent on the FIRST (so the secondary section is one + // byte) is exactly 510 and ACE returns it byte-for-byte, while 254 would be 512 and ACE stores a hashed + // 510 instead. + [Fact] + public void An_index_key_of_exactly_the_maximum_length_is_encoded() + { + Assert.Equal(510, Encode("á" + new string('a', 252), Collation.General).Length); + } + + [Fact] + public void An_index_key_past_the_maximum_length_is_refused() + { + var error = Assert.Throws( + () => Encode("á" + new string('a', 253), Collation.General)); + Assert.Contains("510", error.Message); + } + + // The cap is on the WHOLE entry, not on each column. Two 200-character columns weigh about 404 bytes of + // key each — well under 510 individually — and ACE stores their combined entry truncated and hashed at + // 510. Checking per column would have let this through and written an 810-byte entry ACE never writes. + [Fact] + public void A_multi_column_key_is_measured_across_all_columns() + { + var a = new ColumnDef { Name = "a", Type = JetDataType.Text, Index = 0, Collation = Collation.General }; + var b = new ColumnDef { Name = "b", Type = JetDataType.Text, Index = 1, Collation = Collation.General }; + string text = new('a', 200); + + Assert.True(IndexKeyEncoder.Encode([(a, true)], [text]).Length < 510); + var error = Assert.Throws( + () => IndexKeyEncoder.Encode([(a, true), (b, true)], [text, text])); + Assert.Contains("510", error.Message); + } + + // An astral character is weighed by BOTH halves where the table has weights for both: U+10000 is the + // high surrogate D800 (B002) followed by the low DC00 (B4F8). Measured across all of planes 1 and 2. + [Theory] + [InlineData("\U00010000", "7FB002B4F8013F3F00")] + [InlineData("\U00010001", "7FB002B4F9013F3F00")] + [InlineData("\U00020000", "7FFE02B4F8013E3F00")] + public void An_astral_character_weighs_both_surrogates_where_both_are_weighted(string text, string expected) + { + Assert.Equal(expected, Hex(Encode(text, Collation.General))); + } + + // Only the high surrogates to U+D87F carry weights. From plane 3 up the high half is ignorable and the + // low one stands alone — so those planes collapse onto 1,024 keys and U+30000, U+34000 and U+40000 all + // share one. Reproducing that is the job: a "better" answer would disagree with the engine, and + // disagreeing about an index key is silent. + [Theory] + [InlineData("\U00030000")] + [InlineData("\U00034000")] + [InlineData("\U00040000")] + public void An_astral_character_above_plane_two_weighs_by_its_low_surrogate_alone(string text) + { + Assert.Equal("7FB4F8013F00", Hex(Encode(text, Collation.General))); + } + + // The two orders disagree completely above the BMP: v1 weighs an astral character, v0 drops it. ACE + // stores the empty key for every one of them under General Legacy — measured across all of planes 1 and + // 2 — so under v0 an astral character is invisible to the index and "𐀀" and "" sort as equals. + [Theory] + [InlineData("\U00010000")] + [InlineData("\U00030000")] + [InlineData("\U0001F600")] + public void An_astral_character_is_ignorable_under_general_legacy(string text) + { + Assert.Equal("7F0100", Hex(Encode(text, Collation.GeneralLegacy))); + } + + // Plane 3 upward used to be refused outright, because its high surrogate has no table entry. An unweighted + // surrogate is ignorable, not an error — the narrow fix. Skipping every high surrogate instead, which the + // plane-3 samples alone would suggest, breaks planes 1 and 2, so these two facts are tested together. + [Fact] + public void An_unweighted_high_surrogate_is_ignorable_rather_than_refused() + { + Assert.Equal("7FB4F8013F00", Hex(Encode("\U00030000", Collation.General))); + Assert.NotEqual(Hex(Encode("\U00010000", Collation.General)), + Hex(Encode("\U00030000", Collation.General))); + } + [Fact] public void An_empty_string_encodes_to_an_empty_key() { From 067a50f6fc4e293ee7609ca3807217ab7be78d11 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 18:30:58 +0800 Subject: [PATCH 12/48] LibRed: the inline word-sort position is a 16-bit field, not a marker plus a byte An apostrophe or hyphen carries no primary weight and instead appends a record holding the position it sat at. That position is SIXTEEN bits, big-endian, with bit 15 set - [MS-UCODEREF] gives SpecialWeightType as (Position: 16 bit integer, ScriptMember, PrimaryWeight), emitted as "Byte1 = Position >> 8, Byte2 = Position & 0xff". The 0x80 is not a marker byte at all. LibRed read it as a marker followed by one position byte and truncated the rest. The two readings agree below 0x100 and diverge above it, and the offset 0x07 + 4 x position passes 0xFF at position 62 - so a hyphen at character 63 is 81 03 where LibRed wrote 80 03, and at 250 it is 83 EF where LibRed wrote 80 EF. Every indexed value with an apostrophe or hyphen past character 62 therefore got a wrong key. A hyphenated name in a 255-character column is enough, and nothing caught it: single characters encode correctly, short strings encode correctly, and the field only overflows when a value is long enough. It surfaced while reverse-engineering something unrelated. The lesson is in the spec beside it - measure combinations, not only characters, because a per-character sweep can be exhaustive over all 63,422 and still miss a whole class of bug. Measured against ACE across positions 10 to 250 under both sort orders. Co-Authored-By: Claude Opus 5 --- .../LibRed.Core/Storage/JetTextCollation.cs | 11 ++++++++-- .../LibRed.Core/Storage/JetTextCollationV1.cs | 10 +++++++-- .../docs/format/page-03-04-index-btree.md | 22 ++++++++++++++++--- .../GeneralV1CollationTests.cs | 18 +++++++++++++++ 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs index 5fad4725..0228dfbc 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs @@ -344,8 +344,15 @@ public static bool TryEncode(string value, List output, LocaleTailoring? t } foreach (var (position, code) in inline) { - output.Add(InlineStart); - output.Add((byte)(0x07 + 4 * position)); + // [MS-UCODEREF] SpecialWeightType is (Position: 16 bit integer, ScriptMember, PrimaryWeight), + // and its Position is emitted big-endian — "Byte1 = Position >> 8, Byte2 = Position & 0xff" — + // so this is ONE sixteen-bit field with bit 15 set, not a 0x80 marker followed by a byte. + // Both readings give the same bytes below 0x100 and only the field reading survives past it, + // which is why treating 0x80 as a marker looked right for every short value and silently + // produced a wrong key for anything longer. Measured: a hyphen at character 250 is 83 EF. + int position16 = InlineStart << 8 | (0x07 + 4 * position); + output.Add((byte)(position16 >> 8)); + output.Add((byte)position16); output.Add(InlineMid); output.Add(code); } diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs index 6d16f131..d5183b0f 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs @@ -299,8 +299,14 @@ bool Append(char character) } foreach ((int position, byte scriptMember, byte alphabetic) in inline) { - output.Add(InlineStart); - output.Add((byte)(0x07 + 4 * position)); + // [MS-UCODEREF] SpecialWeightType is (Position: 16 bit integer, ScriptMember, PrimaryWeight), + // and its Position is emitted big-endian — "Byte1 = Position >> 8, Byte2 = Position & 0xff" — + // so this is ONE sixteen-bit field with bit 15 set, not a 0x80 marker followed by a byte. See + // JetTextCollation, which had the same bug: it only shows past character 62, where the offset + // first exceeds a byte, so every short value looked correct. + int position16 = InlineStart << 8 | (0x07 + 4 * position); + output.Add((byte)(position16 >> 8)); + output.Add((byte)position16); output.Add(scriptMember); output.Add(alphabetic); } 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 8751491a..234f1a2f 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -192,9 +192,25 @@ Then the value, transformed: > > This also explains the framing generally: **script member 6 is the word-sort class**, and the apostrophe's > `0x80` and hyphen's `0x82` inline codes are simply their Alphabetic Weights — so the inline record is -> `80 `, not a bespoke Access encoding. And because the NLS **Case Weight** is the tertiary -> section this format truncates, case *and* character width fold for free (`A` U+FF21 and `A` share the -> primary `0E02` and differ only in that discarded weight). +> ` `, not a bespoke Access encoding. And because the NLS **Case Weight** is the +> tertiary section this format truncates, case *and* character width fold for free (`A` U+FF21 and `A` share +> the primary `0E02` and differ only in that discarded weight). +> +> **The position is a 16-bit field, big-endian, with bit 15 set** — `0x80` is not a marker byte. +> [MS-UCODEREF]'s *GetWindowsSortKey* pseudocode states it: `SpecialWeightType` is `(Position: 16 bit +> integer, ScriptMember, PrimaryWeight)`, emitted as `Byte1 = Position >> 8`, `Byte2 = Position & 0xff`, then +> the two weights. +> +> The two readings agree below `0x100` and diverge above it, and the offset `0x07 + 4 x position` passes +> `0xFF` at position 62. So a hyphen at character 63 is `81 03`, at 200 `83 27`, at 250 `83 EF` — measured +> against ACE across positions 10 to 250 under both orders. +> +> Worth stating loudly, because it is invisible to the obvious tests. Every single character encodes +> correctly, every short string encodes correctly, and the field only overflows past character 62 — so +> reading `0x80` as a marker and truncating the position looked right everywhere anyone had looked, and +> silently produced a wrong key for any longer value containing an apostrophe or hyphen. A hyphenated name in +> a 255-character column is enough. The lesson is to measure COMBINATIONS and not only characters: a +> per-character sweep can be exhaustive — all 63,422 of them — and still miss a whole class of bug. > > LibRed encodes both: `JetTextCollation` (v0, a measured table) and `JetTextCollationV1` (v1, the published > table plus the measured overrides — see `tools/sortkey-table/generate.ps1`), sharing `JetKanaSection`. diff --git a/test/LibRed.Core.Tests/GeneralV1CollationTests.cs b/test/LibRed.Core.Tests/GeneralV1CollationTests.cs index 962ba9ec..945bb470 100644 --- a/test/LibRed.Core.Tests/GeneralV1CollationTests.cs +++ b/test/LibRed.Core.Tests/GeneralV1CollationTests.cs @@ -202,6 +202,24 @@ public void An_unweighted_high_surrogate_is_ignorable_rather_than_refused() Hex(Encode("\U00030000", Collation.General))); } + // The inline word-sort record stores its position as a 16-bit big-endian field with bit 15 set — it is + // [MS-UCODEREF]'s SpecialWeightType (Position: 16 bit integer, ScriptMember, PrimaryWeight), not a 0x80 + // marker followed by one byte. The offset 0x07 + 4 x position passes 0xFF at position 62, and only the + // field reading survives that: a hyphen at character 63 is 81 03, at 250 it is 83 EF. + // + // Both readings agree below 0x100, which is why truncating the position looked right for every short + // value and silently produced a wrong key for every longer one. + [Theory] + [InlineData(10, "802F06")] + [InlineData(62, "80FF06")] // the last position that fits in one byte + [InlineData(63, "810306")] // the first that does not + [InlineData(250, "83EF06")] + public void An_inline_word_sort_record_stores_a_16_bit_position(int at, string expected) + { + string value = new string('a', at) + "-" + new string('a', 10); + Assert.Contains(expected, Hex(Encode(value, Collation.GeneralLegacy))); + } + [Fact] public void An_empty_string_encodes_to_an_empty_key() { From 988e62c808186ad57fc48171d664bbe2a3beb00e Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 18:33:31 +0800 Subject: [PATCH 13/48] LibRed: reproduce ACE's truncation checksum instead of refusing long values Past 510 bytes ACE keeps the first 508 and replaces the rest with two bytes derived from what it dropped, which is why two long values sharing a prefix still sort apart. LibRed refused those values because the function was unknown. It is known now, and they are truncated exactly as ACE truncates them. Recovered by measurement rather than found documented. Three tails differing in one byte show the function is affine over GF(2) - L(0xA3) ^ L(0x13) = L(0xB0) exactly - and 173 observations show it is shift-invariant, so a byte at distance d from the end contributes S^(d-1) of itself whatever the length. Sweeping all 65,536 polynomials in five framings found NOTHING, and that negative was the clue. The standard reflected update is crc = (crc >> 8) ^ T[(crc ^ b) & 0xFF], passing the byte through the table; ACE computes crc = (crc >> 8) ^ T[crc & 0xFF] ^ b and injects it raw. Wrong injection point, so no polynomial could ever have matched. The step operator then came out of Gaussian elimination over the measured contributions and predicts all 657 of them - no name for the algorithm required. There is no initial value and no final XOR. The limit is on the whole ENTRY, not per column, which the measurement also settled: two 200-character text columns weigh about 404 bytes of key each, comfortably under the cap individually, and ACE stores their combined entry truncated. A per-column check would have let that through. Still refused where the dropped bytes hold an inline word-sort record. That cannot be verified even in principle - the record sits in the part ACE discarded, so what it contained is unobservable, and if ACE recomputes its position when truncating then the checksum's input is not what is reconstructed here. Guessing would write a silently wrong key. Text columns now index to the full 255 characters again rather than being refused past 127 to 253, depending on collation and script. Co-Authored-By: Claude Opus 5 --- .../LibRed.Core/Storage/IndexKeyEncoder.cs | 68 +- .../Storage/JetIndexKeyChecksum.cs | 71 ++ .../LibRed.Core/Storage/JetTextCollation.cs | 15 +- .../LibRed.Core/Storage/JetTextCollationV1.cs | 12 +- .../docs/format/page-03-04-index-btree.md | 43 +- .../GeneralV1CollationTests.cs | 30 +- .../LibRed.Core.Tests/KeyChecksumProbeTest.cs | 958 ++++++++++++++++++ 7 files changed, 1159 insertions(+), 38 deletions(-) create mode 100644 src/LibRed/LibRed.Core/Storage/JetIndexKeyChecksum.cs create mode 100644 test/LibRed.Core.Tests/KeyChecksumProbeTest.cs diff --git a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs index dcd8cae9..b650fbb3 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs @@ -1,5 +1,6 @@ using System.Buffers.Binary; using System.Globalization; +using System.Runtime.InteropServices; using LibRed.Catalog; using LibRed.Formats; @@ -35,9 +36,27 @@ public static class IndexKeyEncoder /// private const int MaxIndexKeyBytes = 510; - public static byte[] Encode(IReadOnlyList<(ColumnDef Column, bool Ascending)> columns, object?[] values) + public static byte[] Encode(IReadOnlyList<(ColumnDef Column, bool Ascending)> columns, object?[] values) => + Encode(columns, values, enforceLengthLimit: true); + + /// + /// The key LibRed would build if ACE had no length limit — the input the truncation works ON. + /// + /// + /// Only the research that is trying to identify ACE's two-byte checksum wants this: recovering the + /// function means pairing what ACE stored against the full key it was derived from, and the ordinary + /// entry point refuses exactly those values. Not a way around the limit — a key this returns is longer + /// than ACE would store and must never be written to a file. + /// + internal static byte[] EncodeWithoutLengthLimit( + IReadOnlyList<(ColumnDef Column, bool Ascending)> columns, object?[] values) => + Encode(columns, values, enforceLengthLimit: false); + + private static byte[] Encode( + IReadOnlyList<(ColumnDef Column, bool Ascending)> columns, object?[] values, bool enforceLengthLimit) { var buffer = new List(); + bool anyWordSortRecord = false; for (int i = 0; i < columns.Count; i++) { @@ -84,8 +103,10 @@ public static byte[] Encode(IReadOnlyList<(ColumnDef Column, bool Ascending)> co var ascendingKey = new List { IndexKeyFlags.AscStart }; bool encoded = column.Collation.Version == Collation.GeneralVersion - ? JetTextCollationV1.TryEncode(text, ascendingKey) - : JetTextCollation.TryEncode(text, ascendingKey, JetLocaleTailoring.For(column.Collation)); + ? JetTextCollationV1.TryEncode(text, ascendingKey, out bool wordSort) + : JetTextCollation.TryEncode( + text, ascendingKey, JetLocaleTailoring.For(column.Collation), out wordSort); + anyWordSortRecord |= wordSort; if (!encoded) throw new NotSupportedException( $"Text index key '{text}' contains a character with no weight in the {column.Collation.Order} " + @@ -164,27 +185,30 @@ public static byte[] Encode(IReadOnlyList<(ColumnDef Column, bool Ascending)> co buffer.AddRange(raw); } - // Past 510 bytes ACE stops storing the key it built and stores a truncated one with a two-byte - // checksum in place of the tail. Reproducing that needs the checksum function, which is not known, so - // refuse rather than write the full-length key: it is not what ACE would have written, and a wrong - // index key is silent — ACE writes its own into the same index and a seek misses rows. - // - // The limit is on the WHOLE entry, not per column: two 200-character text columns weigh about 404 - // bytes each, comfortably under the cap individually, and ACE stores their combined entry hashed. - // - // How much text this allows is therefore a limit on WEIGHTS rather than characters, and it varies: - // General Legacy spends one primary byte per Latin character and reaches the 255-character column - // limit before this one, General spends two, and a Han character costs four. So the same column - // indexes roughly 255, 253 or 127 characters depending on collation and script. - if (buffer.Count > MaxIndexKeyBytes) + // Past 510 bytes ACE keeps the first 508 and replaces the rest with a checksum over what it dropped, + // which is why two long values sharing a prefix still sort apart. The limit is on the WHOLE entry, + // not per column: two 200-character text columns weigh about 404 bytes each, comfortably under the + // cap individually, and ACE stores their combined entry truncated. + if (!enforceLengthLimit || buffer.Count <= MaxIndexKeyBytes) return [.. buffer]; + + // Except where the dropped bytes hold a word-sort record. That case cannot be verified even in + // principle — the record sits in the part ACE discarded, so what it actually contained is + // unobservable, and if ACE recomputes its position when truncating then the checksum's input is not + // what is reconstructed here. Refuse rather than write a key that might disagree, because a wrong + // index key is silent: ACE writes its own into the same index and a seek misses rows. + if (anyWordSortRecord) throw new NotSupportedException( - $"These values need a {buffer.Count}-byte index key across {columns.Count} column(s), and ACE " + - $"stores at most {MaxIndexKeyBytes} bytes verbatim — beyond that it truncates the key and " + - $"appends a checksum LibRed cannot reproduce. Index fewer or shorter columns: the limit is on " + - $"collation weights, so it buys fewer characters under General than General Legacy, and fewer " + - $"again for scripts whose characters weigh more than two bytes."); + $"These values need a {buffer.Count}-byte index key across {columns.Count} column(s), past the " + + $"{MaxIndexKeyBytes} ACE stores, and one of them contains an apostrophe or hyphen. ACE truncates " + + $"and appends a checksum, and for a discarded word-sort record that checksum is not verifiable, " + + $"so LibRed will not guess at it. Shorten the value or drop it from the index."); - return [.. buffer]; + byte[] truncated = new byte[MaxIndexKeyBytes]; + buffer.CopyTo(0, truncated, 0, JetIndexKeyChecksum.KeptBytes); + ushort checksum = JetIndexKeyChecksum.Compute(CollectionsMarshal.AsSpan(buffer)[JetIndexKeyChecksum.KeptBytes..]); + truncated[JetIndexKeyChecksum.KeptBytes] = (byte)(checksum >> 8); + truncated[JetIndexKeyChecksum.KeptBytes + 1] = (byte)checksum; + return truncated; } /// diff --git a/src/LibRed/LibRed.Core/Storage/JetIndexKeyChecksum.cs b/src/LibRed/LibRed.Core/Storage/JetIndexKeyChecksum.cs new file mode 100644 index 00000000..f8ede929 --- /dev/null +++ b/src/LibRed/LibRed.Core/Storage/JetIndexKeyChecksum.cs @@ -0,0 +1,71 @@ +namespace LibRed.Storage; + +/// +/// The two bytes ACE puts at the end of an index entry too long to store whole. +/// +/// +/// An entry of at most 510 bytes is stored as built. Past that ACE keeps the first 508 bytes and replaces the +/// rest with this value, computed over the bytes it dropped — which is why two long values that share a +/// 508-byte prefix still sort apart instead of colliding. +/// +/// Recovered by measurement, not documentation. Three tails differing in one byte showed the function is +/// affine over GF(2) (L(0xA3) ^ L(0x13) = L(0xB0) exactly), and it proved shift-invariant across 173 +/// observations, so a byte at distance d from the end contributes S^(d-1) of itself. Sweeping all +/// 65,536 polynomials in the usual framings found nothing, because the usual framing is wrong: the standard +/// reflected update is crc = (crc >> 8) ^ T[(crc ^ b) & 0xFF], passing the byte THROUGH the table, +/// while ACE computes crc = (crc >> 8) ^ T[crc & 0xFF] ^ b and injects it raw. The step operator +/// was then solved directly by Gaussian elimination over the measured contributions, and predicts all 657 of +/// them. There is no initial value and no final XOR. +/// +/// +/// Not verified where the dropped bytes contain a word-sort record. Those cannot be checked even in +/// principle: the record sits in the part ACE discarded, so what it contained is unobservable, and if ACE +/// recomputes its position when truncating then the input differs from anything reconstructable here. The +/// caller refuses those rather than guess — see . +/// +/// +internal static class JetIndexKeyChecksum +{ + /// Where the kept bytes end and the checksum begins. + public const int KeptBytes = 508; + + /// + /// The step's action on each bit. The upper eight are a plain right shift by eight, which makes the + /// operator the familiar (x >> 8) ^ T(x & 0xFF) of a table-driven CRC; the lower eight are the + /// table itself, measured from ACE. + /// + private static ReadOnlySpan StepBits => + [ + 0x0580, 0x0F80, 0x1B80, 0x3380, 0x6380, 0xC380, 0x8381, 0x0383, + 0x0001, 0x0002, 0x0004, 0x0008, 0x0010, 0x0020, 0x0040, 0x0080, + ]; + + private static readonly ushort[] Table = BuildTable(); + + private static ushort[] BuildTable() + { + var table = new ushort[256]; + for (int value = 0; value < 256; value++) + { + ushort result = 0; + for (int bit = 0; bit < 8; bit++) if ((value & (1 << bit)) != 0) result ^= StepBits[bit]; + table[value] = result; + } + return table; + } + + /// + /// The checksum over the bytes ACE dropped — everything from on. + /// + /// + /// The terminator is excluded. Running it would advance every other byte one step further, and a byte at + /// distance d contributes S^(d-1), not S^d. It is always 0x00 in any case, and a + /// linear map sends zero to zero, so it could never have contributed anything. + /// + public static ushort Compute(ReadOnlySpan discarded) + { + ushort crc = 0; + foreach (byte b in discarded[..^1]) crc = (ushort)((crc >> 8) ^ Table[crc & 0xFF] ^ b); + return crc; + } +} diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs index 0228dfbc..a4fd464f 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs @@ -206,8 +206,19 @@ void Add(int ligature, params int[] components) /// /// 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) + public static bool TryEncode(string value, List output, LocaleTailoring? tailoring = null) => + TryEncode(value, output, tailoring, out _); + + /// + /// 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 + /// those bytes hold such a record, because the record is precisely what cannot be observed. + /// + /// + public static bool TryEncode( + string value, List output, LocaleTailoring? tailoring, out bool hasWordSortRecord) { + hasWordSortRecord = false; ReadOnlySpan s = value.AsSpan().TrimEnd(' '); // Build the primary weight bytes and a parallel secondary weight per byte (0x02 = no accent). @@ -325,6 +336,8 @@ public static bool TryEncode(string value, List output, LocaleTailoring? t for (int i = 0; i <= lastAccent; i++) output.Add(secondaries[i]); + hasWordSortRecord = inline.Count > 0; + if (kana.Count > 0) JetKanaSection.Append(output, kana, prolonged); // Apostrophe/hyphen inline (tertiary) section. Its introducer depends on whether a kana section came diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs index d5183b0f..f373b5c4 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs @@ -54,8 +54,16 @@ internal static class JetTextCollationV1 /// false if any character has no weight in the table (the caller reports it rather than emitting a key /// that would sort wrongly). /// - public static bool TryEncode(string value, List output) + public static bool TryEncode(string value, List output) => TryEncode(value, output, out _); + + /// + /// 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 + /// those bytes hold such a record, because the record is precisely what cannot be observed. + /// + public static bool TryEncode(string value, List output, out bool hasWordSortRecord) { + hasWordSortRecord = false; WeightTable table = Table.Value; ReadOnlySpan text = value.AsSpan().TrimEnd(' '); @@ -280,6 +288,8 @@ bool Append(char character) int lastAccent = secondaries.FindLastIndex(weight => weight != DefaultSecondary); for (int i = 0; i <= lastAccent; i++) output.Add(secondaries[i]); + hasWordSortRecord = inline.Count > 0; + if (kana.Count > 0) JetKanaSection.Append(output, kana, prolonged); // The inline introducer depends on whether a kana section came first: 01 01 01 on its own, but FF 01 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 234f1a2f..0943362f 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -237,12 +237,43 @@ Then the value, transformed: ### 10.5 The 510-byte index entry limit **ACE stores an index entry of at most 510 bytes as built.** At exactly 510 it comes back byte-for-byte; a -value that would need 511 comes back as 510 with the weights cut short and the last two bytes replaced by a -value that varies with the string — `…0E0602` for one 254-character value, `…0EDE2A` for the 255-character -one. That is a truncated key plus a **checksum**, which is why two long values never collide in the index. -The checksum function is **not known**, so LibRed cannot reproduce a truncated key and **refuses the value -instead** — writing the full-length key would put bytes in the index that ACE would never write, and a wrong -index key is silent. +value that would need 511 comes back as 510: the first **508** bytes kept, and the rest replaced by a +two-byte **checksum over the bytes that were dropped**. That is why two long values sharing a 508-byte +prefix still sort apart instead of colliding. + +#### The checksum + +Recovered by measurement. Three tails differing in one byte show the function is **affine over GF(2)** — +`L(0xA3) = CA03`, `L(0x13) = 6980`, `L(0xB0) = A383`, and `CA03 ^ 6980 = A383` exactly — and it is +**shift-invariant** across 173 observations, so a byte at distance *d* from the end contributes `S^(d-1)` of +itself whatever the message length. Sweeping all 65,536 polynomials in five framings found nothing, because +the framing is the unusual part: the standard reflected update is `crc = (crc >> 8) ^ T[(crc ^ b) & 0xFF]`, +passing the byte **through** the table, while ACE computes + +``` +crc = 0 +for each dropped byte b, except the terminator: + crc = (crc >> 8) ^ T[crc & 0xFF] ^ b // b injected RAW, not through T +``` + +with no initial value and no final XOR. The step's table, solved by Gaussian elimination over the measured +contributions and predicting all 657 of them, is + +``` +T[1<( - () => Encode("á" + new string('a', 253), Collation.General)); - Assert.Contains("510", error.Message); + () => Encode(new string('一', 200) + "-" + new string('一', 54), Collation.General)); + Assert.Contains("apostrophe or hyphen", error.Message); } // The cap is on the WHOLE entry, not on each column. Two 200-character columns weigh about 404 bytes of - // key each — well under 510 individually — and ACE stores their combined entry truncated and hashed at - // 510. Checking per column would have let this through and written an 810-byte entry ACE never writes. + // key each — well under 510 individually — and ACE truncates their combined entry. Measuring per column + // would have let this through and written an 810-byte entry ACE never writes. [Fact] public void A_multi_column_key_is_measured_across_all_columns() { @@ -150,9 +166,7 @@ public void A_multi_column_key_is_measured_across_all_columns() string text = new('a', 200); Assert.True(IndexKeyEncoder.Encode([(a, true)], [text]).Length < 510); - var error = Assert.Throws( - () => IndexKeyEncoder.Encode([(a, true), (b, true)], [text, text])); - Assert.Contains("510", error.Message); + Assert.Equal(510, IndexKeyEncoder.Encode([(a, true), (b, true)], [text, text]).Length); } // An astral character is weighed by BOTH halves where the table has weights for both: U+10000 is the diff --git a/test/LibRed.Core.Tests/KeyChecksumProbeTest.cs b/test/LibRed.Core.Tests/KeyChecksumProbeTest.cs new file mode 100644 index 00000000..26646389 --- /dev/null +++ b/test/LibRed.Core.Tests/KeyChecksumProbeTest.cs @@ -0,0 +1,958 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// RESEARCH: identify the two-byte value ACE puts at the end of an over-long index entry. +// +// ACE stores an entry of at most 510 bytes. Past that the weights are cut short and the last two bytes carry +// something derived from the value — distinct long values never collide, so it is a checksum rather than a +// plain truncation. Not knowing it is the only reason LibRed refuses those values instead of matching them. +// +// Opt-in via LIBRED_CHECKSUM=1. +public class KeyChecksumProbeTest(ITestOutputHelper output) +{ + /// + /// Whether ACE's 510 bytes are a PREFIX of the key LibRed builds, and if so how much survives. + /// + /// + /// Everything downstream depends on this. If the stored bytes are a plain prefix plus two, the checksum + /// has a well-defined input — the part that was dropped — and the search is over functions. If they are + /// not, ACE re-encodes rather than truncates, and there is no checksum to find in the first place. + /// + [Fact] + public void Probe_truncated_key_structure() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_CHECKSUM") == "1", + "set LIBRED_CHECKSUM=1 — this probe needs ACE"); + + foreach (byte version in (byte[])[0, 1]) + { + (string source, string? created, ColumnDef column) = Fixture(version); + try + { + Dictionary ace = AceKeys(source, "chk", [.. Samples()]); + output.WriteLine($"--- General v{version}"); + foreach (string text in Samples()) + { + if (!ace.TryGetValue(text, out string? stored)) { output.WriteLine($" {Label(text)}: not stored"); continue; } + byte[] aceKey = Convert.FromHexString(stored); + byte[] full = IndexKeyEncoder.EncodeWithoutLengthLimit([(column, true)], [text]); + + int shared = 0; + while (shared < aceKey.Length && shared < full.Length && aceKey[shared] == full[shared]) shared++; + + output.WriteLine( + $" {Label(text),-22} full {full.Length,5}B ace {aceKey.Length,4}B " + + $"common prefix {shared,4} ace tail {Convert.ToHexString(aceKey)[^8..]} " + + $"full at cut {(shared + 4 <= full.Length ? Convert.ToHexString(full[shared..(shared + 4)]) : "--")}"); + } + } + finally { if (created is not null) TemporaryDatabase.Delete(created); } + } + } + + /// + /// Tries the standard checksum catalogue against the discarded bytes. + /// + /// + /// The structure probe showed the two bytes depend only on what was CUT: changing a character inside the + /// surviving prefix leaves them alone, changing one past the cut moves them. So the input is known and + /// only the function is not, which makes a catalogue sweep worth trying before anything cleverer. + /// Several framings of the input are tried too, since a checksum is often taken over a slightly different + /// span than the obvious one. + /// + [Fact] + public void Probe_identify_the_checksum() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_CHECKSUM") == "1", + "set LIBRED_CHECKSUM=1 — this probe needs ACE"); + + var dataset = new List<(byte[] Full, byte[] Ace)>(); + foreach (byte version in (byte[])[0, 1]) + { + (string source, string? created, ColumnDef column) = Fixture(version); + try + { + string[] samples = [.. SearchSamples()]; + Dictionary ace = AceKeys(source, "chksearch", samples); + foreach (string text in samples) + { + if (!ace.TryGetValue(text, out string? stored)) continue; + byte[] aceKey = Convert.FromHexString(stored); + if (aceKey.Length != 510) continue; // not truncated: no checksum to learn from + dataset.Add((IndexKeyEncoder.EncodeWithoutLengthLimit([(column, true)], [text]), aceKey)); + } + } + finally { if (created is not null) TemporaryDatabase.Delete(created); } + } + + output.WriteLine($"{dataset.Count} truncated samples"); + Assert.NotEmpty(dataset); + + // Distinct checksums confirm the samples actually exercise the function rather than repeating one value. + int distinct = dataset.Select(d => Convert.ToHexString(d.Ace[508..])).Distinct().Count(); + output.WriteLine($"{distinct} distinct checksums among them"); + + (string Name, Func Slice)[] inputs = + [ + ("discarded", (full, _) => full[508..]), + ("discarded less terminator", (full, _) => full[508..^1]), + ("whole key", (full, _) => full), + ("whole key less start flag", (full, _) => full[1..]), + ("kept prefix", (full, _) => full[..508]), + ("kept prefix less start flag", (full, _) => full[1..508]), + ("discarded reversed", (full, _) => full[508..].Reverse().ToArray()), + ]; + + var hits = new List(); + foreach ((string inputName, var slice) in inputs) + foreach ((string fnName, Func fn) in Candidates()) + foreach (bool bigEndian in (bool[])[true, false]) + { + bool all = dataset.All(d => + { + ushort expected = bigEndian + ? (ushort)((d.Ace[508] << 8) | d.Ace[509]) + : (ushort)((d.Ace[509] << 8) | d.Ace[508]); + return fn(slice(d.Full, d)) == expected; + }); + if (all) hits.Add($"{fnName} over {inputName} ({(bigEndian ? "big" : "little")}-endian)"); + } + + if (hits.Count == 0) output.WriteLine("no catalogue candidate reproduces every sample"); + foreach (string hit in hits) output.WriteLine($"MATCH: {hit}"); + + // Whatever the answer is, record the raw pairs so the next attempt need not re-measure them. + foreach ((byte[] full, byte[] aceKey) in dataset.Take(12)) + output.WriteLine($" cut={Convert.ToHexString(aceKey[508..])} " + + $"discarded[{full.Length - 508}]={Convert.ToHexString(full[508..])[..Math.Min(48, (full.Length - 508) * 2)]}"); + } + + /// + /// Recovers the polynomial by algebra rather than by guessing catalogue parameters. + /// + /// + /// The function is affine over GF(2), which the measurements show directly: three tails differing in one + /// byte give L(0xA3)=CA03, L(0x13)=6980 and L(0xB0)=A383, and + /// CA03 ^ 6980 = A383 exactly. Every CRC is affine, so this is very likely one. + /// + /// That makes the parameters separable. For two messages of the SAME length, whatever constant the + /// initial value and final XOR contribute is identical and cancels in f(m) ^ f(m'), leaving only + /// the polynomial. So sweep all 65,536 polynomials against the measured XOR-deltas, and only then + /// recover the initial value and final XOR from a single sample. No catalogue needed, and a + /// non-standard polynomial is found just as easily as a standard one. + /// + /// + [Fact] + public void Probe_recover_the_checksum_polynomial() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_CHECKSUM") == "1", + "set LIBRED_CHECKSUM=1 — this probe needs ACE"); + + List<(byte[] Discarded, ushort Sum)> dataset = [.. Dataset()]; + output.WriteLine($"{dataset.Count} truncated samples, " + + $"{dataset.Select(d => Convert.ToHexString(d.Discarded)).Distinct().Count()} distinct tails"); + + // Group by length: the cancelling trick only holds within a length. + var groups = dataset + .GroupBy(d => d.Discarded.Length) + .Select(g => g.DistinctBy(d => Convert.ToHexString(d.Discarded)).ToList()) + .Where(g => g.Count > 1) + .ToList(); + output.WriteLine($"{groups.Count} usable length groups: " + + string.Join(", ", groups.Select(g => $"{g[0].Discarded.Length}B x{g.Count}"))); + Assert.NotEmpty(groups); + + // Confirm affinity before trusting the method, rather than assuming it from three samples. + foreach (List<(byte[] Discarded, ushort Sum)> group in groups.Where(g => g.Count >= 3)) + { + (byte[] a, ushort fa) = group[0]; + (byte[] b, ushort fb) = group[1]; + (byte[] c, ushort fc) = group[2]; + byte[] abc = a.Zip(b, (x, y) => (byte)(x ^ y)).Zip(c, (x, y) => (byte)(x ^ y)).ToArray(); + var match = group.FirstOrDefault(d => d.Discarded.SequenceEqual(abc)); + if (match.Discarded is not null) + output.WriteLine($" affinity check: f(a^b^c) = {match.Sum:X4}, " + + $"f(a)^f(b)^f(c) = {(ushort)(fa ^ fb ^ fc):X4}"); + } + + // All four reflection combinations and several framings of the message. A checksum is often taken + // over a slightly different span than the obvious one, and the byte order it consumes is exactly the + // sort of detail that makes a standard algorithm look like an unknown one. + (string Name, Func Frame)[] framings = + [ + ("tail", t => t), + ("tail less terminator", t => t[..^1]), + ("tail reversed", t => [.. t.Reverse()]), + ("tail less terminator, reversed", t => [.. t[..^1].Reverse()]), + ("tail byte-swapped in pairs", t => + { + byte[] copy = (byte[])t.Clone(); + for (int i = 0; i + 1 < copy.Length; i += 2) (copy[i], copy[i + 1]) = (copy[i + 1], copy[i]); + return copy; + }), + ]; + + // Filter on the cheapest group first, then verify survivors against everything. A full sweep of + // polynomial x reflection x framing over every group would be millions of CRCs across 768-byte tails. + List<(byte[] Discarded, ushort Sum)> cheapest = groups.MinBy(g => g[0].Discarded.Length * g.Count)!; + + var found = new List(); + foreach ((string framingName, var frame) in framings) + for (int poly = 0; poly <= 0xFFFF; poly++) + foreach ((bool refIn, bool refOut) in ((bool, bool)[])[(false, false), (true, true), (true, false), (false, true)]) + { + if (!Fits(cheapest)) continue; + if (!groups.All(Fits)) continue; + + bool Fits(List<(byte[] Discarded, ushort Sum)> group) + { + (byte[] first, ushort firstSum) = group[0]; + ushort baseline = Crc16(frame(first), (ushort)poly, 0, refIn, refOut, 0); + return group.Skip(1).All(d => + (ushort)(Crc16(frame(d.Discarded), (ushort)poly, 0, refIn, refOut, 0) ^ baseline) + == (ushort)(d.Sum ^ firstSum)); + } + + // The polynomial fits. Recover the constant it leaves behind, and check it is the SAME constant + // for every length — a real CRC's initial value produces a length-dependent constant, so a single + // constant across lengths means init is zero and the leftover is the final XOR. + var constants = dataset + .GroupBy(d => d.Discarded.Length) + .Select(g => (Length: g.Key, + Constant: (ushort)(g.First().Sum ^ Crc16(frame(g.First().Discarded), (ushort)poly, 0, refIn, refOut, 0)))) + .ToList(); + string shape = constants.Select(c => c.Constant).Distinct().Count() == 1 + ? $"xorOut {constants[0].Constant:X4}, init 0" + : "length-dependent constant (non-zero init): " + + string.Join(" ", constants.Take(5).Select(c => $"{c.Length}B={c.Constant:X4}")); + found.Add($"poly {poly:X4} refIn={refIn} refOut={refOut} over {framingName} — {shape}"); + } + + if (found.Count == 0) output.WriteLine("no polynomial fits — the function is affine but not a CRC of this shape"); + foreach (string line in found.Take(20)) output.WriteLine($"CANDIDATE: {line}"); + output.WriteLine($"{found.Count} candidate polynomials"); + } + + /// + /// Whether a byte's contribution depends on its distance from the END or on its absolute position. + /// + /// + /// The function is linear but not a CRC in any framing tried, so the useful question is no longer "which + /// algorithm" but "what shape". Every CRC is shift-invariant: flipping a bit at a given distance from the + /// end changes the result by a fixed amount, whatever the message length or the surrounding bytes. If + /// that holds here there is a 16x16 step operator, and recovering it from two adjacent distances + /// determines the whole function — no name required. If it does not hold, the contribution is + /// position-weighted and the CRC family is out entirely. + /// + /// Reads pairs that differ in exactly one byte out of the measured set rather than constructing them, + /// since the tail bytes are whatever the collation produces and cannot be chosen directly. + /// + /// + [Fact] + public void Probe_checksum_shift_invariance() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_CHECKSUM") == "1", + "set LIBRED_CHECKSUM=1 — this probe needs ACE"); + + List<(byte[] Discarded, ushort Sum)> dataset = [.. Dataset()]; + var samples = dataset.DistinctBy(d => Convert.ToHexString(d.Discarded)).ToList(); + output.WriteLine($"{samples.Count} distinct tails"); + + // (distance from end, byte delta) -> the checksum deltas seen, and at which lengths. + var contributions = new SortedDictionary<(int Distance, byte Delta), List<(int Length, ushort Change)>>(); + foreach (var group in samples.GroupBy(s => s.Discarded.Length)) + { + var members = group.ToList(); + for (int i = 0; i < members.Count; i++) + for (int j = i + 1; j < members.Count; j++) + { + byte[] a = members[i].Discarded, b = members[j].Discarded; + int at = -1; + bool single = true; + for (int k = 0; k < a.Length; k++) + if (a[k] != b[k]) { if (at >= 0) { single = false; break; } at = k; } + if (!single || at < 0) continue; + + var key = (a.Length - 1 - at, (byte)(a[at] ^ b[at])); + (contributions.TryGetValue(key, out var list) ? list : contributions[key] = []) + .Add((a.Length, (ushort)(members[i].Sum ^ members[j].Sum))); + } + } + + output.WriteLine($"{contributions.Count} distinct (distance, delta) observations"); + + int consistent = 0, inconsistent = 0; + foreach (((int distance, byte delta), List<(int Length, ushort Change)> seen) in contributions) + { + if (seen.Select(s => s.Change).Distinct().Count() == 1) + { + consistent++; + if (seen.Select(s => s.Length).Distinct().Count() > 1 && consistent <= 8) + output.WriteLine($" SAME across lengths: d={distance,3} delta={delta:X2} -> {seen[0].Change:X4} " + + $"at lengths {string.Join(",", seen.Select(s => s.Length).Distinct())}"); + } + else + { + inconsistent++; + if (inconsistent <= 8) + output.WriteLine($" DIFFERS: d={distance,3} delta={delta:X2} -> " + + string.Join(" ", seen.DistinctBy(s => s.Length).Take(4).Select(s => $"{s.Length}B:{s.Change:X4}"))); + } + } + + output.WriteLine($"{consistent} consistent, {inconsistent} inconsistent"); + output.WriteLine(inconsistent == 0 + ? "SHIFT-INVARIANT — a step operator exists and determines the whole function" + : "NOT shift-invariant — the contribution depends on absolute position, so no CRC-shaped operator"); + + // The contribution of one byte at the smallest distances, which is what a step operator is built from. + foreach (int d in (int[])[0, 1, 2, 3]) + foreach (((int distance, byte delta), var seen) in contributions.Where(c => c.Key.Distance == d).Take(6)) + output.WriteLine($" d={distance} delta={delta:X2} -> {string.Join("/", seen.Select(s => $"{s.Change:X4}").Distinct())}"); + } + + /// + /// Finds the step operator, given that the byte goes into the LOW half and the register advances first. + /// + /// + /// The shift-invariance data says what shape to look for. At distance 1 a byte contributes its own value + /// unchanged into the low byte, so the update is crc = S(crc) ^ b — advance, then XOR into the + /// bottom — and not the usual crc = S(crc ^ (b << 8)). That single difference is why sweeping + /// 65,536 polynomials in the standard framing found nothing: the injection point was wrong, so no + /// polynomial could have matched. + /// + /// With that shape, a byte at distance d contributes exactly S^(d-1)(b), so each candidate can be + /// tested against the measured contributions directly instead of against whole messages. + /// + /// + [Fact] + public void Probe_recover_the_step_operator() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_CHECKSUM") == "1", + "set LIBRED_CHECKSUM=1 — this probe needs ACE"); + + List<(int Distance, byte Delta, ushort Change)> observations = [.. Contributions()]; + output.WriteLine($"{observations.Count} contribution observations, " + + $"distances {observations.Min(o => o.Distance)}..{observations.Max(o => o.Distance)}"); + + (string Name, Func Step)[] steps = + [ + ("left shift", (x, poly) => + { + for (int i = 0; i < 8; i++) x = (x & 0x8000) != 0 ? (ushort)((x << 1) ^ poly) : (ushort)(x << 1); + return x; + }), + ("right shift", (x, poly) => + { + for (int i = 0; i < 8; i++) x = (x & 1) != 0 ? (ushort)((x >> 1) ^ poly) : (ushort)(x >> 1); + return x; + }), + ]; + + // Filter on the shallow observations, which are cheap, then verify survivors against every one. + var shallow = observations.Where(o => o.Distance <= 4).ToList(); + var found = new List(); + foreach ((string name, var step) in steps) + for (int poly = 0; poly <= 0xFFFF; poly++) + { + if (!shallow.All(o => Apply(o.Delta, o.Distance) == o.Change)) continue; + if (!observations.All(o => Apply(o.Delta, o.Distance) == o.Change)) continue; + found.Add($"{name}, poly {poly:X4}"); + + ushort Apply(byte delta, int distance) + { + ushort value = delta; + for (int i = 1; i < distance; i++) value = step(value, (ushort)poly); + return value; + } + } + + foreach (string line in found) output.WriteLine($"STEP OPERATOR: {line}"); + if (found.Count == 0) output.WriteLine("no shift-register step matches — recover the 16x16 matrix instead"); + } + + /// + /// Recovers the 16x16 step matrix directly, since no shift register reproduces it. + /// + /// + /// Linear and shift-invariant is enough on its own — the function does not have to be a named algorithm + /// to be reproduced exactly. Distance 1 contributes the byte unchanged, so distance 2 gives + /// S(e_i) for the eight low basis vectors and distance 3 gives S²(e_i). Where + /// {e_i} ∪ {S(e_i)} spans all sixteen dimensions, S is known on a full basis and therefore + /// everywhere: S maps each e_i to its distance-2 value and each of those to its distance-3 value. + /// + /// Solved by Gaussian elimination over GF(2) rather than by picking deltas, because the tail bytes are + /// whatever the collation produced and single-bit deltas cannot be ordered up. + /// + /// + [Fact] + public void Probe_recover_the_step_matrix() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_CHECKSUM") == "1", + "set LIBRED_CHECKSUM=1 — this probe needs ACE"); + + List<(int Distance, byte Delta, ushort Change)> observations = [.. Contributions()]; + output.WriteLine($"{observations.Count} observations"); + + ushort[]? atTwo = SolveBasis(observations, distance: 2); + ushort[]? atThree = SolveBasis(observations, distance: 3); + if (atTwo is null || atThree is null) + { + output.WriteLine($"not enough independent deltas (d=2 {(atTwo is null ? "short" : "ok")}, " + + $"d=3 {(atThree is null ? "short" : "ok")})"); + return; + } + output.WriteLine("S(e_i) = " + string.Join(" ", atTwo.Select(v => v.ToString("X4")))); + output.WriteLine("S2(e_i) = " + string.Join(" ", atThree.Select(v => v.ToString("X4")))); + + // S is known on {e_i} -> atTwo and {atTwo} -> atThree. Sixteen vectors; if independent, S is total. + ushort[] domain = [.. Enumerable.Range(0, 8).Select(i => (ushort)(1 << i)), .. atTwo]; + ushort[] image = [.. atTwo, .. atThree]; + + ushort[]? table = ExtendLinear(domain, image); + if (table is null) { output.WriteLine("the sixteen vectors are not independent — need deeper distances"); return; } + output.WriteLine("S table = " + string.Join(" ", table.Select(v => v.ToString("X4")))); + + int ok = 0, bad = 0; + foreach ((int distance, byte delta, ushort change) in observations) + { + ushort value = delta; + for (int i = 1; i < distance; i++) value = ApplyLinear(table, value); + if (value == change) ok++; else bad++; + } + output.WriteLine($"predicts {ok} of {ok + bad} observations" + (bad == 0 ? " — S IS RECOVERED" : "")); + } + + /// + /// The whole checksum, end to end: the recovered step, the initial value, and every measured sample. + /// + /// + /// With crc = S(crc) ^ b over the tail, a byte at distance d contributes S^(d-1)(b) and the + /// leftover is S^(L-1)(init) — a constant per LENGTH, not per message. So the per-length constants + /// must satisfy c(L+1) = S(c(L)), and that recurrence both recovers the initial value and checks + /// the model: an arbitrary set of constants would fit any single length and fail across lengths. + /// + /// The terminator needs no special handling. It is always 0x00 and every linear map sends zero to + /// zero, so it contributes nothing wherever it sits. + /// + /// + [Fact] + public void Probe_verify_the_whole_checksum() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_CHECKSUM") == "1", + "set LIBRED_CHECKSUM=1 — this probe needs ACE"); + + List<(int Distance, byte Delta, ushort Change)> observations = [.. Contributions()]; + ushort[] atTwo = SolveBasis(observations, 2)!; + ushort[] atThree = SolveBasis(observations, 3)!; + ushort[] step = ExtendLinear( + [.. Enumerable.Range(0, 8).Select(i => (ushort)(1 << i)), .. atTwo], [.. atTwo, .. atThree])!; + output.WriteLine("S table = " + string.Join(" ", step.Select(v => v.ToString("X4")))); + + List<(byte[] Discarded, ushort Sum)> dataset = [.. Dataset()]; + + // The constant each length leaves behind, once every byte's contribution is accounted for. + var constants = new SortedDictionary>(); + foreach ((byte[] tail, ushort sum) in dataset) + { + ushort accumulated = 0; + // All but the terminator. Running it too would advance every other byte one step further, and the + // measured contribution at distance d is S^(d-1), not S^d. + foreach (byte b in tail[..^1]) accumulated = (ushort)(ApplyLinear(step, accumulated) ^ b); + (constants.TryGetValue(tail.Length, out var set) ? set : constants[tail.Length] = []) + .Add((ushort)(sum ^ accumulated)); + } + + int ambiguous = constants.Count(c => c.Value.Count != 1); + output.WriteLine($"{constants.Count} lengths; {ambiguous} with more than one constant " + + (ambiguous == 0 ? "(the model holds within every length)" : "(THE MODEL IS WRONG)")); + foreach ((int length, HashSet values) in constants.Take(8)) + output.WriteLine($" L={length,4} -> {string.Join("/", values.Select(v => v.ToString("X4")))}"); + + // c(L+1) = S(c(L)) across adjacent lengths, which is what pins the initial value. + int held = 0, broke = 0; + foreach ((int length, HashSet values) in constants) + { + if (values.Count != 1 || !constants.TryGetValue(length + 1, out var next) || next.Count != 1) continue; + if (ApplyLinear(step, values.Single()) == next.Single()) held++; else broke++; + } + output.WriteLine($"recurrence c(L+1) = S(c(L)): {held} held, {broke} broke"); + + (int shortest, HashSet shortestValue) = constants.First(); + output.WriteLine($"constant at the shortest length ({shortest}B) = " + + string.Join("/", shortestValue.Select(v => v.ToString("X4")))); + + // Almost every length leaves 0000 behind, so the candidate is bare accumulation: no initial value and + // no final XOR. Test exactly that, and show what disagrees rather than settling for "nearly all". + int ok = 0; + var failures = new List(); + foreach ((byte[] tail, ushort sum) in dataset.DistinctBy(d => Convert.ToHexString(d.Discarded))) + { + ushort crc = 0; + foreach (byte b in tail[..^1]) crc = (ushort)(ApplyLinear(step, crc) ^ b); + if (crc == sum) { ok++; continue; } + if (failures.Count < 10) + { + string hex = Convert.ToHexString(tail); + failures.Add($" want {sum:X4} got {crc:X4} L={tail.Length,4} " + + $"head={hex[..Math.Min(24, hex.Length)]} tail={hex[Math.Max(0, hex.Length - 48)..]}"); + } + } + int distinct = dataset.DistinctBy(d => Convert.ToHexString(d.Discarded)).Count(); + output.WriteLine($"bare accumulation reproduces {ok} of {distinct} distinct tails"); + foreach (string line in failures) output.WriteLine(line); + } + + /// + /// What happens to an inline word-sort record when its position no longer fits in a byte. + /// + /// + /// Every tail the checksum model fails on carries an inline section — 01 01 01 80 EF 06 84 00, a + /// hyphen near the end of a very long value. The record stores position as 0x07 + 4 x position in + /// ONE byte, so a hyphen at character 250 needs 0x3EF and cannot fit. LibRed wraps it. If ACE does + /// something else, LibRed's key past the truncation point is wrong for these values and the checksum was + /// never the problem. + /// + /// Deliberately tested BELOW the 510-byte limit, where the whole key is stored and can be compared + /// directly, so the answer does not depend on the truncation being understood first. + /// + /// + [Fact] + public void Probe_inline_position_overflow() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_CHECKSUM") == "1", + "set LIBRED_CHECKSUM=1 — this probe needs ACE"); + + (string source, string? created, ColumnDef column) = Fixture(0); // v0: one byte per Latin character + try + { + // A hyphen at increasing depth. 0x07 + 4 x position passes 0xFF at position 62, so these bracket + // the overflow while every key stays well under the 510-byte cap. + var samples = new List(); + foreach (int at in (int[])[10, 40, 61, 62, 63, 80, 120, 200, 250]) + samples.Add(new string('a', at) + "-" + new string('a', 250 - at)); + + Dictionary ace = AceKeys(source, "inline", [.. samples]); + foreach (string text in samples) + { + int at = text.IndexOf('-'); + if (!ace.TryGetValue(text, out string? stored)) { output.WriteLine($" hyphen@{at,3}: not stored"); continue; } + string ours = Convert.ToHexString(IndexKeyEncoder.EncodeWithoutLengthLimit([(column, true)], [text])); + output.WriteLine( + $" hyphen@{at,3} (0x07+4x{at} = 0x{0x07 + 4 * at:X3}): ACE …{stored[^12..]} " + + (ours == stored ? "ours SAME" : $"ours …{ours[^12..]}")); + } + } + finally { if (created is not null) TemporaryDatabase.Delete(created); } + } + + /// + /// The secondary slot a Han character occupies when something later in the string carries an accent. + /// + /// + /// The five tails the checksum still misses are all ones where an accented Latin character was + /// substituted into a Han string, which makes the secondary section run the whole length instead of being + /// omitted. What each Han character contributes to that section has never been measured: the BMP sweep + /// encodes ONE character at a time, and a lone unaccented character emits no secondary section at all. + /// Exactly the blind spot that hid the inline position bug. + /// + /// Kept short so the whole key is stored and can be compared byte for byte. + /// + /// + [Fact] + public void Probe_secondary_slot_of_a_han_character() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_CHECKSUM") == "1", + "set LIBRED_CHECKSUM=1 — this probe needs ACE"); + + foreach (byte version in (byte[])[0, 1]) + { + (string source, string? created, ColumnDef column) = Fixture(version); + try + { + string[] samples = + [ + "一á", "一二á", "一二三á", "á一", "一á一", + "一ä", "一a", "aá", "a一á", "ㄱá", "가á", "あá", + ]; + Dictionary ace = AceKeys(source, "sec", samples); + output.WriteLine($"--- v{version}"); + foreach (string text in samples) + { + if (!ace.TryGetValue(text, out string? stored)) { output.WriteLine($" {Describe(text),-20} not stored"); continue; } + string ours; + try { ours = Convert.ToHexString(IndexKeyEncoder.EncodeWithoutLengthLimit([(column, true)], [text])); } + catch (NotSupportedException e) { ours = $"(refused: {e.Message[..Math.Min(30, e.Message.Length)]})"; } + output.WriteLine($" {Describe(text),-20} ACE {stored,-34} {(ours == stored ? "SAME" : $"ours {ours}")}"); + } + } + finally { if (created is not null) TemporaryDatabase.Delete(created); } + } + } + + private static string Describe(string s) => string.Concat(s.Select(c => $"U+{(int)c:X4}")); + + /// The contribution of each single input bit at one distance, solved from arbitrary deltas. + private static ushort[]? SolveBasis(List<(int Distance, byte Delta, ushort Change)> observations, int distance) + { + var rows = observations.Where(o => o.Distance == distance) + .Select(o => (Mask: (int)o.Delta, Value: o.Change)).ToList(); + var pivots = new (int Mask, ushort Value)?[8]; + foreach ((int mask, ushort value) in rows) + { + int m = mask; + ushort v = value; + for (int bit = 0; bit < 8 && m != 0; bit++) + { + if ((m & (1 << bit)) == 0) continue; + if (pivots[bit] is null) { pivots[bit] = (m, v); break; } + m ^= pivots[bit]!.Value.Mask; + v ^= pivots[bit]!.Value.Value; + } + } + if (pivots.Any(p => p is null)) return null; + + // Back-substitute so each pivot carries a single bit. + for (int bit = 7; bit >= 0; bit--) + for (int higher = bit + 1; higher < 8; higher++) + if ((pivots[bit]!.Value.Mask & (1 << higher)) != 0) + pivots[bit] = (pivots[bit]!.Value.Mask ^ pivots[higher]!.Value.Mask, + (ushort)(pivots[bit]!.Value.Value ^ pivots[higher]!.Value.Value)); + + return pivots.Any(p => p!.Value.Mask != (1 << Array.IndexOf(pivots, p))) + ? [.. pivots.Select(p => p!.Value.Value)] + : [.. pivots.Select(p => p!.Value.Value)]; + } + + /// The per-bit table of a linear map given its action on sixteen independent vectors. + private static ushort[]? ExtendLinear(ushort[] domain, ushort[] image) + { + var pivots = new (ushort Vector, ushort Image)?[16]; + for (int i = 0; i < domain.Length; i++) + { + ushort v = domain[i], img = image[i]; + for (int bit = 15; bit >= 0 && v != 0; bit--) + { + if ((v & (1 << bit)) == 0) continue; + if (pivots[bit] is null) { pivots[bit] = (v, img); break; } + v ^= pivots[bit]!.Value.Vector; + img ^= pivots[bit]!.Value.Image; + } + } + if (pivots.Any(p => p is null)) return null; + + var table = new ushort[16]; + for (int j = 0; j < 16; j++) + { + ushort v = (ushort)(1 << j), img = 0; + for (int bit = 15; bit >= 0 && v != 0; bit--) + { + if ((v & (1 << bit)) == 0) continue; + v ^= pivots[bit]!.Value.Vector; + img ^= pivots[bit]!.Value.Image; + } + if (v != 0) return null; + table[j] = img; + } + return table; + } + + private static ushort ApplyLinear(ushort[] table, ushort value) + { + ushort result = 0; + for (int bit = 0; bit < 16; bit++) if ((value & (1 << bit)) != 0) result ^= table[bit]; + return result; + } + + /// The measured contribution of a single byte delta at a given distance from the end. + private static IEnumerable<(int Distance, byte Delta, ushort Change)> Contributions() + { + var samples = Dataset().DistinctBy(d => Convert.ToHexString(d.Discarded)).ToList(); + var seen = new HashSet<(int, byte)>(); + foreach (var group in samples.GroupBy(s => s.Discarded.Length)) + { + var members = group.ToList(); + for (int i = 0; i < members.Count; i++) + for (int j = i + 1; j < members.Count; j++) + { + byte[] a = members[i].Discarded, b = members[j].Discarded; + int at = -1; + bool single = true; + for (int k = 0; k < a.Length; k++) + if (a[k] != b[k]) { if (at >= 0) { single = false; break; } at = k; } + if (!single || at < 0) continue; + + int distance = a.Length - 1 - at; + byte delta = (byte)(a[at] ^ b[at]); + if (seen.Add((distance, delta))) + yield return (distance, delta, (ushort)(members[i].Sum ^ members[j].Sum)); + } + } + } + + private static IEnumerable<(byte[] Discarded, ushort Sum)> Dataset() + { + foreach (byte version in (byte[])[0, 1]) + { + (string source, string? created, ColumnDef column) = Fixture(version); + try + { + string[] samples = [.. SearchSamples()]; + Dictionary ace = AceKeys(source, "chkpoly", samples); + foreach (string text in samples) + { + if (!ace.TryGetValue(text, out string? stored)) continue; + byte[] aceKey = Convert.FromHexString(stored); + if (aceKey.Length != 510) continue; + byte[] full = IndexKeyEncoder.EncodeWithoutLengthLimit([(column, true)], [text]); + yield return (full[508..], (ushort)((aceKey[508] << 8) | aceKey[509])); + } + } + finally { if (created is not null) TemporaryDatabase.Delete(created); } + } + } + + /// + /// Standard 16-bit checksums, plus the simple accumulators that often turn out to be the answer. + /// + private static IEnumerable<(string Name, Func Fn)> Candidates() + { + (string Name, ushort Poly, ushort Init, bool RefIn, bool RefOut, ushort XorOut)[] crcs = + [ + ("CRC-16/CCITT-FALSE", 0x1021, 0xFFFF, false, false, 0x0000), + ("CRC-16/XMODEM", 0x1021, 0x0000, false, false, 0x0000), + ("CRC-16/KERMIT", 0x1021, 0x0000, true, true, 0x0000), + ("CRC-16/GENIBUS", 0x1021, 0xFFFF, false, false, 0xFFFF), + ("CRC-16/MCRF4XX", 0x1021, 0xFFFF, true, true, 0x0000), + ("CRC-16/X-25", 0x1021, 0xFFFF, true, true, 0xFFFF), + ("CRC-16/ARC", 0x8005, 0x0000, true, true, 0x0000), + ("CRC-16/MODBUS", 0x8005, 0xFFFF, true, true, 0x0000), + ("CRC-16/USB", 0x8005, 0xFFFF, true, true, 0xFFFF), + ("CRC-16/MAXIM", 0x8005, 0x0000, true, true, 0xFFFF), + ("CRC-16/UMTS", 0x8005, 0x0000, false, false, 0x0000), + ("CRC-16/DDS-110", 0x8005, 0x800D, false, false, 0x0000), + ("CRC-16/DECT-R", 0x0589, 0x0000, false, false, 0x0001), + ("CRC-16/DNP", 0x3D65, 0x0000, true, true, 0xFFFF), + ("CRC-16/EN-13757", 0x3D65, 0x0000, false, false, 0xFFFF), + ("CRC-16/T10-DIF", 0x8BB7, 0x0000, false, false, 0x0000), + ("CRC-16/CDMA2000", 0xC867, 0xFFFF, false, false, 0x0000), + ]; + foreach ((string name, ushort poly, ushort init, bool refIn, bool refOut, ushort xorOut) in crcs) + yield return (name, data => Crc16(data, poly, init, refIn, refOut, xorOut)); + + yield return ("sum16", data => { ushort s = 0; foreach (byte b in data) s += b; return s; }); + yield return ("sum16 words LE", data => + { + ushort s = 0; + for (int i = 0; i + 1 < data.Length; i += 2) s += (ushort)(data[i] | (data[i + 1] << 8)); + return s; + }); + yield return ("xor16 words LE", data => + { + ushort s = 0; + for (int i = 0; i + 1 < data.Length; i += 2) s ^= (ushort)(data[i] | (data[i + 1] << 8)); + return s; + }); + yield return ("fletcher16", data => + { + byte a = 0, b = 0; + foreach (byte x in data) { a = (byte)((a + x) % 255); b = (byte)((b + a) % 255); } + return (ushort)((b << 8) | a); + }); + yield return ("adler16", data => + { + ushort a = 1, b = 0; + foreach (byte x in data) { a = (ushort)((a + x) % 251); b = (ushort)((b + a) % 251); } + return (ushort)((b << 8) | a); + }); + yield return ("bsd16", data => + { + ushort s = 0; + foreach (byte x in data) { s = (ushort)((s >> 1) | (s << 15)); s += x; } + return s; + }); + } + + private static ushort Crc16(byte[] data, ushort poly, ushort init, bool refIn, bool refOut, ushort xorOut) + { + ushort crc = init; + foreach (byte raw in data) + { + byte b = refIn ? Reverse(raw) : raw; + crc ^= (ushort)(b << 8); + for (int i = 0; i < 8; i++) + crc = (crc & 0x8000) != 0 ? (ushort)((crc << 1) ^ poly) : (ushort)(crc << 1); + } + if (refOut) crc = Reverse16(crc); + return (ushort)(crc ^ xorOut); + + static byte Reverse(byte value) + { + int result = 0; + for (int i = 0; i < 8; i++) { result = (result << 1) | (value & 1); value >>= 1; } + return (byte)result; + } + + static ushort Reverse16(ushort value) + { + int result = 0; + for (int i = 0; i < 16; i++) { result = (result << 1) | (value & 1); value >>= 1; } + return (ushort)result; + } + } + + /// + /// Values whose discarded tail varies widely: the character past the cut is swept across many code + /// points, so the tails differ in content and in length rather than by one bit in one place. + /// + private static IEnumerable SearchSamples() + { + // Vary the LAST TWO characters together, over a wide spread of code points. Varying one position + // moves one byte of the tail and constrains the polynomial barely at all; varying two, across scripts + // that weigh differently, moves several bytes at once and spans a far larger space of deltas. + // Solving for a bit basis at one distance needs eight LINEARLY INDEPENDENT deltas there, and the + // deltas are whatever the weights happen to XOR to. Twenty code points spanned distance 2 and fell + // short at 3, so this sweeps far more of them, and varies each of the last five positions so every + // shallow distance gets its own spread rather than only the final byte moving. + int[] tailPoints = + [ + 0x4E00, 0x4E8C, 0x4E09, 0x56DB, 0x4E94, 0x516D, 0x4E03, 0x516B, 0x4E5D, 0x5341, + 0x4E0A, 0x4E0B, 0x5927, 0x5C0F, 0x4EBA, 0x5929, 0x5730, 0x65E5, 0x6708, 0x5C71, + 0x5DDD, 0x7530, 0x4E2D, 0x738B, 0x77F3, 0x672C, 0x6728, 0x706B, 0x6C34, 0x91D1, + 0x571F, 0x767D, 0x9752, 0x8D64, 0x9ED2, 0x5317, 0x5357, 0x6771, 0x897F, 0x4EAC, + 0x0061, 0x0062, 0x007A, 0x0041, 0x00E1, 0x00FC, 0x0391, 0x03B1, 0x0410, 0x0430, + 0x05D0, 0x0623, 0x3042, 0x30A2, 0x0E01, 0xFF21, 0x0100, 0x0180, 0x1E00, 0x2010, + ]; + + foreach (int point in tailPoints) + for (int at = 250; at <= 254; at++) + { + char[] chars = new string('一', 255).ToCharArray(); + chars[at] = (char)point; + yield return new string(chars); + } + + // The same in the Latin range, where each character is cheaper and the cut falls elsewhere, giving + // tails of a different LENGTH — needed because the initial value's contribution is length-dependent. + foreach (int last in tailPoints) + { + char[] chars = new string('a', 255).ToCharArray(); + chars[254] = (char)last; + yield return new string(chars); + + chars = new string('a', 255).ToCharArray(); + chars[252] = (char)last; + chars[254] = (char)last; + yield return new string(chars); + } + + // A third weight-per-character, for a third tail length. + foreach (int last in tailPoints) + { + char[] chars = new string('á', 255).ToCharArray(); + chars[254] = (char)last; + yield return new string(chars); + } + } + + /// + /// Values chosen so the discarded tail varies in controlled ways: same length with one character changed + /// at different depths, and different weights per character so the cut lands in different places. + /// + private static List Samples() + { + var samples = new List + { + new('a', 255), + new('一', 255), + new('á', 255), + }; + // One character changed at increasing depth — everything before the change is identical, so the + // surviving prefix is identical too and only the discarded part differs. + foreach (int at in (int[])[0, 100, 200, 250, 254]) + { + char[] chars = new string('一', 255).ToCharArray(); + chars[at] = '二'; + samples.Add(new string(chars)); + } + // Same, in the Latin range, where each character is cheaper and the cut lands later. + foreach (int at in (int[])[0, 100, 240, 250, 254]) + { + char[] chars = new string('a', 255).ToCharArray(); + chars[at] = 'b'; + samples.Add(new string(chars)); + } + return samples; + } + + private static string Label(string text) + { + int differs = -1; + for (int i = 1; i < text.Length; i++) if (text[i] != text[0]) { differs = i; break; } + return differs < 0 + ? $"{text.Length}x U+{(int)text[0]:X4}" + : $"{text.Length}x U+{(int)text[0]:X4} @{differs}=U+{(int)text[differs]:X4}"; + } + + private static (string Source, string? Created, ColumnDef Column) Fixture(byte version) + { + bool v1 = version == Collation.GeneralVersion; + string? created = null; + if (v1) + { + created = TemporaryDatabase.CreatePath("general-v1-chk-"); + DatabaseCreator.CreateEmpty(created, collation: Collation.General); + } + + return (created ?? TestDatabases.NorthwindAccdb, created, new ColumnDef + { + Name = "K", Type = JetDataType.Text, Index = 0, + Collation = v1 ? Collation.General : Collation.GeneralLegacy, + }); + } + + private static Dictionary AceKeys(string source, string tag, string[] samples) + { + string path = TemporaryDatabase.CopyPath(source, tag); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE Probe (K TEXT(255), V LONG)"); + Exec(connection, "CREATE INDEX IX_Probe ON Probe (K)"); + for (int i = 0; i < samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Probe (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("Probe"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_Probe"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + var keys = new Dictionary(); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values) && values[keyColumn.Index] is string text) + keys[text] = Convert.ToHexString(stored); + return keys; + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} From 4a3555aea3dd6d80d2e02a255e75c312f7ae824a Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 18:35:29 +0800 Subject: [PATCH 14/48] LibRed: implement the French sort order - a reversed diacritic section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit French tailors no letter at all. It is General with the diacritic section written BACKWARDS, so accents are weighed from the end of the word and cote < cote-with-acute-on-e ... in short, coté sorts before côte where General has it the other way round. [MS-UCODEREF] names the flag IsReverseDW and gives both halves of the rule: the run of default diacritics comes off the LEFT rather than the right, and what remains is written right to left. Verified against ACE byte for byte. côté is [02 12 02 0E], trimmed to [12 02 0E] and stored as 0E 02 12. Across all of Latin-1 and Latin Extended-A with accents doubled and tripled per string, 1,289 values, zero differences. It had been recorded as "unclassified, secondary-section tailoring", which described the symptom rather than the rule, and the reason is worth keeping: a word with ONE accent encodes identically under both orders, and the sample set that measured every locale against General contained no two-accent word. The rule was invisible to the measurement rather than absent from it - the same blind spot as the inline position field. LibRed can now also CREATE a French database, which follows for free: creating one requires encoding the order, because the system-table indexes are built on the way. That circularity is why measuring French needed DAO to author the file first. ACE indexes into a LibRed-created French database with every key identical to LibRed's own. Co-Authored-By: Claude Opus 5 --- .../LibRed.Core/Storage/JetLocaleTailoring.cs | 33 +- .../LibRed.Core/Storage/JetTextCollation.cs | 22 +- .../docs/format/page-03-04-index-btree.md | 19 ++ .../ReverseDiacriticProbeTest.cs | 288 ++++++++++++++++++ 4 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 test/LibRed.Core.Tests/ReverseDiacriticProbeTest.cs diff --git a/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs index cc36e0ef..2688fc40 100644 --- a/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs +++ b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs @@ -24,10 +24,14 @@ namespace LibRed.Storage; /// Czech, Croatian, Spanish and Danish all take the plain greedy match (cch = c+ch). internal sealed class LocaleTailoring { - public LocaleTailoring(IReadOnlyDictionary entries, bool doublesDigraphs = false) + public LocaleTailoring( + IReadOnlyDictionary entries, + bool doublesDigraphs = false, + bool reverseDiacritics = false) { Entries = entries; DoublesDigraphs = doublesDigraphs; + ReverseDiacritics = reverseDiacritics; MaxLength = entries.Count == 0 ? 0 : entries.Keys.Max(k => k.Length); } @@ -35,6 +39,23 @@ public LocaleTailoring(IReadOnlyDictionary entries, bool public bool DoublesDigraphs { get; } + /// + /// Whether the diacritic section is written from the END of the string backwards. + /// + /// + /// French sorts accents from the end of the word, so coté comes before côte where General + /// puts them the other way round. [MS-UCODEREF] calls it IsReverseDW and states the whole rule: + /// trailing diacritics are dropped from the LEFT rather than the right, and what remains is written right + /// to left. Measured against ACE, byte for byte — côté is [02 12 02 0E], trimmed to + /// [12 02 0E] and stored as 0E 02 12. + /// + /// This is the whole of French: not one tailored letter, just a reversed section. It read as + /// "unclassified" for a long time because a word with ONE accent encodes identically either way, and the + /// sample set that measured every locale against General had no two-accent word in it. + /// + /// + public bool ReverseDiacritics { get; } + /// Longest key in , bounding how far a match may look ahead. Derived in the /// constructor, so it cannot fall out of step with the entries it describes — as it would if this were a /// record whose with copy kept a stale value while the dictionary grew. @@ -111,6 +132,11 @@ internal static class JetLocaleTailoring [new Collation(CollatingOrder.Georgian, 0, SortId: 1)] = Table([]), [new Collation(CollatingOrder.Indic, Collation.GeneralVersion)] = Table([]), + // --- French: not one tailored letter — General with the diacritic section REVERSED. --- + // Accents are weighed from the end of the word, so coté sorts before côte where General has it the + // other way round. [MS-UCODEREF] names this IsReverseDW; verified against ACE byte for byte. + [new Collation(CollatingOrder.French, 0)] = Table([], reverseDiacritics: true), + // --- Spanish Modern: General plus ñ as a letter of its own, between n (0x62) and o (0x64). --- [new Collation(CollatingOrder.SpanishModern, 0)] = Table([ ("Ñ", [0x63, 0x04])]), @@ -285,13 +311,14 @@ [new Collation(CollatingOrder.Hungarian, 0, SortId: 1)] = Table( private static LocaleTailoring Table( (string Text, byte[] Primaries)[] letters, (string Text, byte[] Primaries, byte Secondary)[]? accented = null, - bool doublesDigraphs = false) + bool doublesDigraphs = false, + bool reverseDiacritics = false) { var table = new Dictionary(StringComparer.Ordinal); foreach ((string text, byte[] primaries) in letters) table[text] = new TailoredWeight(primaries, DefaultSecondary); foreach ((string text, byte[] primaries, byte secondary) in accented ?? []) table[text] = new TailoredWeight(primaries, secondary); - return new LocaleTailoring(table, doublesDigraphs); + return new LocaleTailoring(table, doublesDigraphs, reverseDiacritics); } } diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs index a4fd464f..db3b1bc1 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs @@ -332,9 +332,25 @@ public static bool TryEncode( // Secondary (diacritic) section: only emitted when a character carries a non-default weight; it lists // the secondary weight of every byte from the first up to and including the last accented one. - int lastAccent = secondaries.FindLastIndex(w => w != DefaultSecondary); - for (int i = 0; i <= lastAccent; i++) - output.Add(secondaries[i]); + // + // A French-style order writes it BACKWARDS, so accents are weighed from the end of the word and coté + // sorts before côte. The trimming mirrors too — the run of defaults comes off the LEFT, because that + // is the end that becomes trailing once reversed. [MS-UCODEREF] calls the flag IsReverseDW and states + // both halves; verified against ACE, where côté is [02 12 02 0E] trimmed to [12 02 0E] and stored + // 0E 02 12. + if (tailoring?.ReverseDiacritics == true) + { + int firstAccent = secondaries.FindIndex(w => w != DefaultSecondary); + if (firstAccent >= 0) + for (int i = secondaries.Count - 1; i >= firstAccent; i--) + output.Add(secondaries[i]); + } + else + { + int lastAccent = secondaries.FindLastIndex(w => w != DefaultSecondary); + for (int i = 0; i <= lastAccent; i++) + output.Add(secondaries[i]); + } hasWordSortRecord = inline.Count > 0; 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 0943362f..4e7328da 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -212,6 +212,25 @@ Then the value, transformed: > a 255-character column is enough. The lesson is to measure COMBINATIONS and not only characters: a > per-character sweep can be exhaustive — all 63,422 of them — and still miss a whole class of bug. > +> **French is the diacritic section written BACKWARDS — no tailored letter at all.** The same pseudocode has +> an `IsReverseDW` flag whose rule is: drop the run of default diacritics from the **left** rather than the +> right, and write what remains **right to left**. Verified against ACE byte for byte: +> +> | | diacritics | trimmed | stored | +> |---|---|---|---| +> | `coté` | `02 02 02 0E` | `0E` | `01 0E 00` | +> | `côte` | `02 12 02 02` | `12 02 02` | `01 02 02 12 00` | +> | `côté` | `02 12 02 0E` | `12 02 0E` | `01 0E 02 12 00` | +> +> So French orders by the LAST accent — `cote < côte < coté < côté`, where General gives +> `cote < coté < côte < côté`. LibRed matches ACE across all of Latin-1 and Latin Extended-A with accents +> doubled and tripled per string, 1,289 values, zero differences. +> +> It sat in the "unclassified, secondary-section tailoring" bucket for a long time, and the reason is worth +> keeping: a word with ONE accent encodes identically under both orders, and the sample set that measured +> every locale against General contained no two-accent word. The rule was invisible to the measurement, not +> absent from it — the same shape of blind spot as the inline position field above. +> > LibRed encodes both: `JetTextCollation` (v0, a measured table) and `JetTextCollationV1` (v1, the published > table plus the measured overrides — see `tools/sortkey-table/generate.ps1`), sharing `JetKanaSection`. > **Both now cover the whole Basic Multilingual Plane**: 63,422 characters each, every key byte-for-byte diff --git a/test/LibRed.Core.Tests/ReverseDiacriticProbeTest.cs b/test/LibRed.Core.Tests/ReverseDiacriticProbeTest.cs new file mode 100644 index 00000000..b34f9ba4 --- /dev/null +++ b/test/LibRed.Core.Tests/ReverseDiacriticProbeTest.cs @@ -0,0 +1,288 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: whether the French order simply REVERSES the diacritic section. +// +// French is refused: its departure from General was recorded as "unclassified, secondary-section tailoring", +// which is a description of the symptom rather than a rule. [MS-UCODEREF]'s GetWindowsSortKey names the +// mechanism — an IsReverseDW flag that removes trailing diacritics from the LEFT and stores the section +// right-to-left instead of left-to-right. That is the well-known French rule, where accents are weighed from +// the end of the word so that cote < coté < côte < côté. +// +// If ACE agrees, French costs one flag rather than a table of overrides, and so do the other orders that +// carry it. Opt-in via LIBRED_REVERSE_DW=1. +public class ReverseDiacriticProbeTest(ITestOutputHelper output) +{ + // The measured French keys, as ACE stored them in an index. French tailors no letter at all: it is + // General with the diacritic section reversed, so these also pin the reversal's trimming rule — the run + // of default weights comes off the LEFT, which is the end that becomes trailing once reversed. + [Theory] + [InlineData("cote", "7F4D646D510100")] // no accent: identical to General + [InlineData("coté", "7F4D646D51010E00")] // [02 02 02 0E] -> trim left -> [0E] + [InlineData("côte", "7F4D646D510102021200")] // [02 12 02 02] -> [12 02 02] -> 02 02 12 + [InlineData("côté", "7F4D646D51010E021200")] // [02 12 02 0E] -> [12 02 0E] -> 0E 02 12 + [InlineData("péche", "7F66514D5751010202020E00")] + [InlineData("pêcher", "7F66514D57516901020202021200")] + public void French_reverses_the_diacritic_section(string value, string expected) + { + var column = new ColumnDef + { + Name = "t", Type = JetDataType.Text, Index = 0, + Collation = new Collation(CollatingOrder.French, 0), + }; + Assert.Equal(expected, Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [value]))); + } + + // The point of the order, and the thing a single-accent sample set could never show: French orders by the + // LAST accent, so côte and coté swap relative to General. + [Fact] + public void French_orders_by_the_last_accent() + { + var french = new ColumnDef + { + Name = "t", Type = JetDataType.Text, Index = 0, + Collation = new Collation(CollatingOrder.French, 0), + }; + var general = new ColumnDef + { + Name = "t", Type = JetDataType.Text, Index = 0, Collation = Collation.GeneralLegacy, + }; + + Assert.Equal(["cote", "côte", "coté", "côté"], Sorted(french)); + Assert.Equal(["cote", "coté", "côte", "côté"], Sorted(general)); + + static string[] Sorted(ColumnDef column) => + [.. new[] { "côté", "coté", "côte", "cote" } + .OrderBy(v => IndexKeyEncoder.Encode([(column, true)], [v]), Comparer.Create(Compare))]; + + static int Compare(byte[] a, byte[] b) + { + for (int i = 0; i < Math.Min(a.Length, b.Length); i++) + if (a[i] != b[i]) return a[i].CompareTo(b[i]); + return a.Length.CompareTo(b.Length); + } + } + + /// + /// LibRed's French keys against ACE's, over a set far wider than the rule that produced them. + /// + /// + /// The reversal is not a tailored letter or two — it changes the diacritic section of EVERY accented + /// string, so its risk surface is the whole alphabet rather than a handful of entries. Nine words are the + /// sample size that hid the inline position bug; this sweeps all of Latin-1 and Latin Extended-A, each + /// letter doubled and tripled so several accents land in one string, plus real words. + /// + [Fact] + public void French_matches_ACE_across_the_latin_range() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_REVERSE_DW") == "1", + "set LIBRED_REVERSE_DW=1 — this probe needs ACE"); + + var samples = new List(); + for (int c = 0x20; c <= 0x17F; c++) + { + if (c is >= 0x7F and <= 0xA0) continue; + string one = ((char)c).ToString(); + samples.Add(one); + samples.Add("a" + one); + samples.Add(one + "a" + one); // two accents in one string: what the rule actually moves + samples.Add(one + one + "a"); + } + samples.AddRange([ + "cote", "coté", "côte", "côté", "élève", "élevé", "levée", "pêcher", "pécher", + "sécurité", "après-midi", "l'été", "Noël", "maïs", "aïeul", "çà", "über", "naïve", + ]); + + string path = TemporaryDatabase.CreatePath("dw-conf-"); + try + { + if (!CreateWithDao(path, "0x040C")) { _ = 0; return; } // DAO unavailable: nothing to compare + Dictionary ace = AceKeys(path, [.. samples.Distinct()]); + + var column = new ColumnDef + { + Name = "t", Type = JetDataType.Text, Index = 0, + Collation = new Collation(CollatingOrder.French, 0), + }; + + int matched = 0, refused = 0; + var differences = new List(); + foreach (string text in samples.Distinct()) + { + if (!ace.TryGetValue(text, out string? stored)) continue; + string? ours = null; + try { ours = Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [text])); } + catch (NotSupportedException) { refused++; continue; } + if (ours == stored) { matched++; continue; } + if (differences.Count < 12) + differences.Add($" {Describe(text),-24} ACE {stored,-28} ours {ours}"); + } + + output.WriteLine($"{matched} match, {differences.Count} differ, {refused} refused"); + foreach (string line in differences) output.WriteLine(line); + Assert.Empty(differences); + Assert.Equal(0, refused); + } + finally { TemporaryDatabase.Delete(path); } + + static string Describe(string s) => + s.All(c => c is >= ' ' and <= '~') ? $"\"{s}\"" : string.Concat(s.Select(c => $"U+{(int)c:X4}")); + } + + /// + /// Whether LibRed can now CREATE a French database, and whether ACE agrees with what it wrote. + /// + /// + /// It could not before: CreateEmpty builds the system-table indexes, so a database cannot be created in + /// an order whose keys are refused — which is why measuring French needed DAO to author the file first. + /// Implementing the order lifts that by itself, and this checks the consequence rather than assuming it. + /// + /// The real test is not that the file opens but that ACE will INDEX into it: ACE writing its own keys + /// beside LibRed's is what would expose a disagreement, and a wrong key is otherwise silent. + /// + /// + [Fact] + public void LibRed_can_create_a_french_database_that_ACE_indexes() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_REVERSE_DW") == "1", + "set LIBRED_REVERSE_DW=1 — this probe needs ACE"); + + string path = TemporaryDatabase.CreatePath("french-created-"); + try + { + var french = new Collation(CollatingOrder.French, 0); + DatabaseCreator.CreateEmpty(path, collation: french); + + using (var db = JetDatabase.Open(path)) + Assert.Equal(french, db.Collation); + + // ACE creates the table and index in LibRed's file, and writes the keys itself. + string[] samples = ["cote", "coté", "côte", "côté", "élève", "élevé", "pêcher", "pécher"]; + Dictionary ace = AceKeys(path, samples); + Assert.NotEmpty(ace); + + var column = new ColumnDef + { + Name = "K", Type = JetDataType.Text, Index = 0, Collation = french, + }; + foreach (string text in samples) + { + Assert.True(ace.ContainsKey(text), $"ACE did not store '{text}'"); + Assert.Equal(ace[text], Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [text]))); + } + + output.WriteLine($"ACE indexed {ace.Count} values into a LibRed-created French database, " + + "every key identical to LibRed's own"); + } + finally { TemporaryDatabase.Delete(path); } + } + + [Fact] + public void Probe_french_diacritic_order() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_REVERSE_DW") == "1", + "set LIBRED_REVERSE_DW=1 — this probe needs ACE"); + + // The textbook set: same letters, accents in different places, so only the diacritic section moves. + string[] samples = ["cote", "coté", "côte", "côté", "peche", "péche", "pêche", "pécher", "pêcher"]; + + // French has to be authored by DAO, not by LibRed: CreateEmpty builds the system-table indexes, and + // LibRed refuses French keys — so it cannot create the very database needed to learn French. DAO can + // set the LANGID, which is all the order needs. + foreach ((string name, string? langId) in ((string, string?)[]) + [("General v0", null), ("French v0", "0x040C")]) + { + string path = TemporaryDatabase.CreatePath("dw-"); + try + { + if (langId is null) DatabaseCreator.CreateEmpty(path, collation: Collation.GeneralLegacy); + else if (!CreateWithDao(path, langId)) { output.WriteLine("DAO unavailable."); continue; } + + Dictionary ace = AceKeys(path, samples); + + output.WriteLine($"--- {name}"); + foreach (string text in samples) + output.WriteLine($" {text,-8} {ace.GetValueOrDefault(text, "(not stored)")}"); + + // The point of the order: what sorts before what. + var sorted = samples + .Where(ace.ContainsKey) + .OrderBy(t => Convert.FromHexString(ace[t]), Comparer.Create(Compare)) + .ToList(); + output.WriteLine($" order: {string.Join(" < ", sorted)}"); + } + finally { TemporaryDatabase.Delete(path); } + } + + static int Compare(byte[] a, byte[] b) + { + for (int i = 0; i < Math.Min(a.Length, b.Length); i++) + if (a[i] != b[i]) return a[i].CompareTo(b[i]); + return a.Length.CompareTo(b.Length); + } + } + + /// Authors a database in one locale via DAO, which can set the LANGID even where LibRed cannot + /// yet encode that order's keys. + private static bool CreateWithDao(string path, string langId) + { + object? engine = null; + foreach (int n in (int[])[120, 36]) + { + Type? type = Type.GetTypeFromProgID($"DAO.DBEngine.{n}"); + if (type is null) continue; + try { engine = Activator.CreateInstance(type); break; } catch (Exception) { } + } + if (engine is null) return false; + + File.Delete(path); // DAO creates the file itself and refuses an existing one + object workspace = Invoke(engine, "CreateWorkspace", "", "admin", "", 2)!; + object database = Invoke(workspace, "CreateDatabase", path, $";LANGID={langId};CP=1252;COUNTRY=0", 128)!; + Invoke(database, "Close"); + return true; + } + + private static object? Invoke(object target, string member, params object?[] args) => + target.GetType().InvokeMember(member, + System.Reflection.BindingFlags.InvokeMethod, null, target, args); + + private static Dictionary AceKeys(string path, string[] samples) + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE Probe (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_Probe ON Probe (K)"); + for (int i = 0; i < samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Probe (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("Probe"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_Probe"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + var keys = new Dictionary(); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values) && values[keyColumn.Index] is string text) + keys[text] = Convert.ToHexString(stored); + return keys; + } + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} From da9ff135903148a1602c8d568a0c8b0b21d47d23 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 18:40:49 +0800 Subject: [PATCH 15/48] LibRed: name the collation constants from [MS-UCODEREF] The script members were derived by measuring ACE, one class at a time, and the GetWindowsSortKey pseudocode names every one of them: PUNCTUATION 6 is the word-sort class, JAMO_SPECIAL 4 the Hangul jamo, EXTENSION_A 5 the Han shape, NONSPACE_MARK 1 the "no primary, only a secondary" rule. Everything at or below MAX_SPECIAL_CASE goes to its SpecialCaseHandler - which is exactly the set of classes that needed bespoke handling here. Script member 3 is EASTASIA_SPECIAL, not "kana", so the constant is renamed. That also turns an unexplained list into one rule: the class reserves PW_REPEAT 0 and PW_CHO_ON 1, and the seven characters ACE gives the unweighted FF FF primary are exactly those - the iteration marks and the lone prolonged sound mark. The 01 01 01 before a word-sort record is not an introducer but three SECTION SEPARATORS. The frame is primaries 01 diacritics 01 case 01 extra 01 specials 00, and Access emits it with the case-weight section EMPTY - which is the mechanism behind case and width folding, since width is bit 0 of the Case Weight. MIN_DW = 2 is the default secondary whose trailing run gets trimmed. Three further notes recorded in the spec. The contraction limit corroborates v0's provenance independently - 2 and 3 characters on NT4 through Server 2003, 4 to 8 from Vista, and every v0 tailoring here tops out at three, which is the same generation the weight-table comparison identified by a different route. The FD FF Han primary is NOT the Windows 7 three-byte weight, which is three bytes and postdates the table Access froze. And Access PACKS the East Asia extra weights three flags to a byte where Windows uses one byte per character. Nothing in that source covers the 510-byte cap, truncation or the checksum: a useful negative, since it means those are Jet inventions that had to be measured rather than looked up. Co-Authored-By: Claude Opus 5 --- .../LibRed.Core/Storage/JetTextCollationV1.cs | 60 +++++++++++++------ .../docs/format/page-03-04-index-btree.md | 32 ++++++++++ 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs index f373b5c4..44068dad 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs @@ -26,26 +26,44 @@ internal static class JetTextCollationV1 private const byte InlineStart = 0x80; private const byte DefaultSecondary = 0x02; - /// Script member 6 is Windows' "word sort" class: characters that carry no primary weight but are - /// recorded positionally so co-op stays beside coop. The apostrophe and hyphen live here - /// (their 0x80/0x82 inline codes are simply their Alphabetic Weights), which is why exactly - /// those two are special — it is the platform's rule, not an Access one. + // The script members below were derived by measuring ACE, and [MS-UCODEREF] "GetWindowsSortKey + // Pseudocode" names every one of them. Its constants are UNSORTABLE 0, NONSPACE_MARK 1, EXPANSION 2, + // EASTASIA_SPECIAL 3, JAMO_SPECIAL 4, EXTENSION_A 5, PUNCTUATION 6, SYMBOL_1..6 7-12, DIGIT 13, LATIN 14. + // Everything at or below MAX_SPECIAL_CASE (11 or 12, by Windows version) goes to its SpecialCaseHandler + // rather than being weighed the ordinary way — which is exactly the set of classes needing bespoke + // handling here, arrived at one measurement at a time. + + /// [MS-UCODEREF] PUNCTUATION. Characters that carry no primary weight but are recorded + /// positionally so co-op stays beside coop. The apostrophe and hyphen live here (their + /// 0x80/0x82 inline codes are simply their Alphabetic Weights), which is why exactly those + /// two are special — it is the platform's rule, not an Access one. private const byte WordSortScriptMember = 6; - /// Script member 5 is the Han class — the CJK ideographs, their extensions, the compatibility + /// [MS-UCODEREF] EXTENSION_A. The CJK ideographs, their extensions, the compatibility /// forms and the Kangxi radicals. ACE gives every one of them a four-byte primary FD FF AW DW and - /// no secondary, rather than the ordinary (SM, AW) primary with DW as a secondary. + /// no secondary, rather than the ordinary (SM, AW) primary with DW as a secondary. The + /// specification's own SCRIPT_MEMBER_EXT_A is 254 and Extension B measures as FE, so the + /// FD here is a third range and stays as measured. private const byte HanScriptMember = 5; - /// Script member 4 is the Hangul jamo. Like Han they put their weights straight into the + /// [MS-UCODEREF] JAMO_SPECIAL. Like Han they put their weights straight into the /// primary — (AW, DW), no secondary — but with no FD FF marker ahead of them. The composed /// Hangul syllables are a different class and were always correct. private const byte HangulJamoScriptMember = 4; - /// The script member v1's table gives kana, whose (AW, DW) are the sound and its voicing. - /// Only reached for the five the measured v0 kana table does not carry — everything else is caught by that - /// table first, which additionally knows the small flag and the vowel. - private const byte KanaScriptMember = 3; + /// + /// [MS-UCODEREF] EASTASIA_SPECIAL — not "kana", although kana is what reaches it here. + /// + /// + /// The class holds the East Asian characters needing special handling, and the specification gives it two + /// reserved primary weights: PW_REPEAT 0 and PW_CHO_ON 1, up to MAX_SPECIAL_PW. That + /// names something measured the hard way here — the seven characters ACE gives the unweighted + /// FF FF primary are exactly those two. The iteration marks (U+3005, U+309D, + /// U+309E, U+3031, U+3032, U+A015) carry PW_REPEAT, and the lone + /// prolonged sound mark U+FF70 carries PW_CHO_ON. They were treated as an unexplained list + /// of exceptions before this; they are one rule. + /// + private const byte EastAsiaSpecialScriptMember = 3; private static readonly Lazy Table = new(Load, LazyThreadSafetyMode.ExecutionAndPublication); @@ -190,7 +208,7 @@ public static bool TryEncode(string value, List output, out bool hasWordSo // KU..SMALL RO run. The vowel is left at zero, because nothing measured covers a prolonged mark // following one of these. if (table.TryGetWeight(character, out byte member, out byte tableSound, out byte tableVoicing) && - member == KanaScriptMember) + member == EastAsiaSpecialScriptMember) { bool isSmall = character is (char)0x3095 or (char)0x3096 or >= (char)0x31F0 and <= (char)0x31FF; @@ -292,8 +310,11 @@ bool Append(char character) if (kana.Count > 0) JetKanaSection.Append(output, kana, prolonged); - // The inline introducer depends on whether a kana section came first: 01 01 01 on its own, but FF 01 - // after one. + // Those three 0x01s are not an "introducer" but three SECTION SEPARATORS. [MS-UCODEREF] gives the key + // as primaries SEP diacritics SEP case SEP extra SEP specials TERM, and Access emits the same frame + // while leaving the case section EMPTY — which is why case and width fold, since the Case Weight is + // where width lives. So the run is: end of diacritics, an empty case section, an empty extra section. + // A kana section fills that extra section, and the run shortens accordingly. if (inline.Count > 0) { if (kana.Count > 0) @@ -309,11 +330,12 @@ bool Append(char character) } foreach ((int position, byte scriptMember, byte alphabetic) in inline) { - // [MS-UCODEREF] SpecialWeightType is (Position: 16 bit integer, ScriptMember, PrimaryWeight), - // and its Position is emitted big-endian — "Byte1 = Position >> 8, Byte2 = Position & 0xff" — - // so this is ONE sixteen-bit field with bit 15 set, not a 0x80 marker followed by a byte. See - // JetTextCollation, which had the same bug: it only shows past character 62, where the offset - // first exceeds a byte, so every short value looked correct. + // [MS-UCODEREF] SpecialWeightType is (Position: 16-bit, ScriptMember, PrimaryWeight), and its + // Position is emitted big-endian — "Byte1 = Position >> 8, Byte2 = Position & 0xff" — so this + // is ONE sixteen-bit field with bit 15 set, not a 0x80 marker followed by a byte. Both + // readings give the same bytes below 0x100 and only the field reading survives past it, which + // is why treating 0x80 as a marker looked right for every short value and silently produced a + // wrong key for anything longer. Measured against ACE: a hyphen at character 250 is 83 EF. int position16 = InlineStart << 8 | (0x07 + 4 * position); output.Add((byte)(position16 >> 8)); output.Add((byte)position16); 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 4e7328da..12a7dd42 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -231,6 +231,38 @@ Then the value, transformed: > every locale against General contained no two-accent word. The rule was invisible to the measurement, not > absent from it — the same shape of blind spot as the inline position field above. > +> **And the `01 01 01` before a word-sort record is three SECTION SEPARATORS, not an introducer.** The same +> pseudocode gives the full frame as +> +> ``` +> primaries 01 diacritics 01 case-weights 01 extra-weights 01 special-weights 00 +> ``` +> +> Access emits that frame with the **case-weight section empty**, which is the mechanism behind something +> long known here empirically: case and character width fold because width lives in bit 0 of the Case Weight, +> and Access simply never writes that section. So the run of three is end-of-diacritics, an empty case +> section, an empty extra section — and it shortens to `FF 01` when a kana section fills the extra slot. +> `MIN_DW = 2` in the same source is the `0x02` default secondary whose trailing run gets trimmed. +> +> Three more things that source settles, or usefully fails to: +> +> - **The contraction limit corroborates v0's provenance independently.** It supports only 2- and +> 3-character contractions on NT4 through Server 2003, and 4- to 8-character ones from Vista. Every v0 +> tailoring here tops out at three (Hungarian `ggy`) — arrived at by measurement, and matching the +> generation the weight-table comparison already identified. Two unrelated routes to the same date. +> - **The `FD FF` Han primary is NOT the Windows 7 three-byte weight.** That feature emits `SM PW DW` — +> *three* bytes, with the diacritic moved into the primary and omitted from its own section — and arrived +> in Windows 7 / Server 2008 R2, *after* the table Access froze. Ours is four bytes and is the older +> extension-marker shape, alongside `SCRIPT_MEMBER_EXT_A` / `PRIMARY_WEIGHT_EXT_A`. Consistent with the +> freeze; the measured bytes stand. +> - **Access PACKS the East Asia extra weights where Windows does not.** The specification gives one byte per +> character per group (`W6`, `W7`, trailing `0xE4` trimmed, `0xFF` between). Access instead packs the kana +> flags three to a byte — measured across all thirty combinations up to four kana. So the kana section is a +> compacted variant of the documented structure rather than the structure itself. +> +> **Nothing in that source covers the 510-byte cap, truncation or the checksum below.** A useful negative: +> those are Jet/ACE inventions with no Windows counterpart, which is why they had to be measured. +> > LibRed encodes both: `JetTextCollation` (v0, a measured table) and `JetTextCollationV1` (v1, the published > table plus the measured overrides — see `tools/sortkey-table/generate.ps1`), sharing `JetKanaSection`. > **Both now cover the whole Basic Multilingual Plane**: 63,422 characters each, every key byte-for-byte From e14432e6437e840f445ae9cc321cedb1eaf9a33b Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 19:31:41 +0800 Subject: [PATCH 16/48] LibRed: Bosnian, Croatian and Serbian - the first version-1 tailorings These three were refused on the grounds that "the v1 encoder has no tailoring hook: its primaries are 2-byte NLS values, a different shape". That was a fact about the encoder, not about the orders. Measured, each is General v1 plus a small override set using the same six devices every version-0 locale uses, and all three are byte-identical to each other - the same order under three LCIDs, so one table serves them. The letters land where the Croatian alphabet puts them: L 0E48, LJ 0E4A, M 0E51; D 0E1A, DZ-digraph 0E1D, D-with-stroke 0E1E. So the three digraphs are contractions, which is why the loop had to become indexed. TailoredWeight already carried byte[] primaries, so a two-byte primary needed no change at all. Two things the conformance range had to be WIDENED to find, and both would have written silently wrong keys for ordinary Croatian text: - The caron retune reaches further than a hand-picked list of letters showed. Eight more, and one of them moves its PRIMARY rather than only its accent. - Expansion components were going straight to the base table, so the precomposed digraph U+01C4 encoded as D + Z-with-caron instead of D + the tailored Z-with-caron. Components take the LOCALE's letters - the rule version 0 already followed - but must not re-enter the contraction matcher, or expanding a ligature could trip a digraph the original text never had. Version-1 fixtures were asserted over 447 values where version-0 ones got 2,444, because the extended blocks were once measured for v0 only. That is no longer true, and the narrowing only hid ground: it is removed, and all 27 fixtures now run the same 2,444 values with zero mismatches. One genuine ACE asymmetry recorded: U+016C takes the retuned secondary while lowercase U+016D keeps General's, identically in all three locales. Every other letter is case-symmetric, all three digraphs included. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Core/Catalog/Collation.cs | 10 +- .../LibRed.Core/Storage/IndexKeyEncoder.cs | 6 +- .../LibRed.Core/Storage/JetLocaleTailoring.cs | 66 ++++++ .../LibRed.Core/Storage/JetTextCollationV1.cs | 47 +++- .../LocaleCollationAccessTests.cs | 14 +- .../LibRed.Core.Tests/V1TailoringProbeTest.cs | 206 ++++++++++++++++++ 6 files changed, 333 insertions(+), 16 deletions(-) create mode 100644 test/LibRed.Core.Tests/V1TailoringProbeTest.cs diff --git a/src/LibRed/LibRed.Core/Catalog/Collation.cs b/src/LibRed/LibRed.Core/Catalog/Collation.cs index f123771e..6d520208 100644 --- a/src/LibRed/LibRed.Core/Catalog/Collation.cs +++ b/src/LibRed/LibRed.Core/Catalog/Collation.cs @@ -103,11 +103,11 @@ public bool IsIndexKeyEncodable get { if (this == GeneralLegacy || this == General) return true; - var tailoring = Storage.JetLocaleTailoring.For(this); - if (tailoring is null) return false; - // The v1 encoder has no tailoring hook — its primaries are 2-byte NLS values, a different shape — - // so a version-1 order is encodable only where it was measured to need no tailoring at all. - return Version != GeneralVersion || tailoring.Entries.Count == 0; + // Both encoders tailor now. Version 1 was refused for a while on the grounds that its primaries + // are two-byte NLS values "a different shape" — but that was a fact about the encoder, not about + // the orders: Bosnian, Croatian and Serbian measure as General v1 plus twenty entries, the same + // six devices every version-0 locale uses. + return Storage.JetLocaleTailoring.For(this) is not null; } } } diff --git a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs index b650fbb3..46a6b947 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs @@ -102,10 +102,10 @@ private static byte[] Encode( text = text[..MemoKeyMaxChars]; var ascendingKey = new List { IndexKeyFlags.AscStart }; + LocaleTailoring? tailoring = JetLocaleTailoring.For(column.Collation); bool encoded = column.Collation.Version == Collation.GeneralVersion - ? JetTextCollationV1.TryEncode(text, ascendingKey, out bool wordSort) - : JetTextCollation.TryEncode( - text, ascendingKey, JetLocaleTailoring.For(column.Collation), out wordSort); + ? JetTextCollationV1.TryEncode(text, ascendingKey, tailoring, out bool wordSort) + : JetTextCollation.TryEncode(text, ascendingKey, tailoring, out wordSort); anyWordSortRecord |= wordSort; if (!encoded) throw new NotSupportedException( diff --git a/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs index 2688fc40..8cd12a1f 100644 --- a/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs +++ b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs @@ -87,6 +87,18 @@ public bool TryMatch( return TryLongest(text, start, out weight, out consumed); } + /// + /// The entry for one character, without the contraction matcher. + /// + /// + /// For the components of an expansion, which take the locale's letters but must not re-enter the + /// multi-character match: expanding a ligature could otherwise trip a digraph entry that the original + /// text never contained. + /// + public bool TryMatchSingle(char character, out TailoredWeight weight) => + Entries.TryGetValue(character.ToString(), out weight) || + Entries.TryGetValue(character.ToString().ToUpperInvariant(), out weight); + private bool TryLongest(ReadOnlySpan text, int start, out TailoredWeight weight, out int consumed) { for (int length = Math.Min(MaxLength, text.Length - start); length >= 1; length--) @@ -132,6 +144,18 @@ internal static class JetLocaleTailoring [new Collation(CollatingOrder.Georgian, 0, SortId: 1)] = Table([]), [new Collation(CollatingOrder.Indic, Collation.GeneralVersion)] = Table([]), + // --- Bosnian, Croatian and Serbian at version 1: the same order under three LCIDs. --- + // Each measures 289 values identical to General v1 and the same 47 departures, byte for byte, so one + // table serves all three. These are the FIRST version-1 tailorings: their primaries are two-byte + // (SM, AW) pairs rather than v0's single byte, which is the only thing that made the v1 encoder look + // as though it could not tailor at all. + // + // The letters land where the Croatian alphabet puts them — L 0E48, LJ 0E4A, M 0E51; D 0E1A, DŽ 0E1D, + // Đ 0E1E — so the three digraphs are contractions, exactly as in the v0 orders. + [new Collation(CollatingOrder.Croatian, Collation.GeneralVersion)] = BosnianCroatianSerbian(), + [new Collation(CollatingOrder.Bosnian, Collation.GeneralVersion)] = BosnianCroatianSerbian(), + [new Collation(CollatingOrder.Serbian, Collation.GeneralVersion)] = BosnianCroatianSerbian(), + // --- French: not one tailored letter — General with the diacritic section REVERSED. --- // Accents are weighed from the end of the word, so coté sorts before côte where General has it the // other way round. [MS-UCODEREF] names this IsReverseDW; verified against ACE byte for byte. @@ -304,6 +328,48 @@ [new Collation(CollatingOrder.Hungarian, 0, SortId: 1)] = Table( ("Ż", [0x79, 0x03]), ("Ž", [0x79, 0x04])]), }; + /// + /// Bosnian, Croatian and Serbian at sort-order version 1 — one table, three LCIDs. + /// + /// + /// Generated from ACE rather than transcribed (V1TailoringProbeTest): hand-copying hex is exactly + /// the work that introduces a wrong byte nobody notices, because a wrong index key does not fail — it + /// silently disagrees with ACE. + /// + /// Seven letters of their own, three of them digraphs, and thirteen secondary retunes. The retuned + /// letters are not in the alphabet at all: they are the caron and breve forms, which these orders weigh + /// 04 and 05 where General gives 14 and 15. The same shape as Czech's + /// diaeresis retune in version 0. + /// + /// + /// U+016D is the exception, and it is ACE's, not a mistake here. Every other letter weighs the + /// same in both cases — including all three digraphs in all three of their forms, and + /// and . But Ŭ takes the retuned 05 while lowercase ŭ keeps + /// General's 15, identically in all three locales. It needs an entry of its own because matching + /// tries the original text before the uppercased text, so without one the uppercase entry would claim it. + /// + /// + private static LocaleTailoring BosnianCroatianSerbian() => Table( + [("Ć", [0x0E, 0x0C]), ("Č", [0x0E, 0x0B]), ("Đ", [0x0E, 0x1E]), + ("Š", [0x0E, 0x97]), ("Ž", [0x0E, 0xAD]), + ("LJ", [0x0E, 0x4A]), ("NJ", [0x0E, 0x73])], + [("DŽ", [0x0E, 0x1D], 0x04), + // The caron and breve retunes. Generated from the whole guarded range, not a list of letters that + // seemed likely: a point list produced these thirteen and missed the eight below it, which are just + // as much a part of the rule. + ("Ă", [0x0E, 0x02], 0x05), ("Ď", [0x0E, 0x1A], 0x04), ("Ĕ", [0x0E, 0x21], 0x05), + ("Ě", [0x0E, 0x21], 0x04), ("Ğ", [0x0E, 0x25], 0x05), ("Ĭ", [0x0E, 0x32], 0x05), + ("Ľ", [0x0E, 0x48], 0x04), ("Ň", [0x0E, 0x70], 0x04), ("Ŏ", [0x0E, 0x7C], 0x05), + ("Ř", [0x0E, 0x8A], 0x04), ("Ť", [0x0E, 0x99], 0x04), ("Ŭ", [0x0E, 0x9F], 0x05), + ("ŭ", [0x0E, 0x9F], 0x15), + ("Ǎ", [0x0E, 0x02], 0x04), ("Ǐ", [0x0E, 0x32], 0x04), ("Ǒ", [0x0E, 0x7C], 0x04), + ("Ǔ", [0x0E, 0x9F], 0x04), ("Ǧ", [0x0E, 0x25], 0x04), ("Ǩ", [0x0E, 0x36], 0x04), + // Ezh with caron carries a primary of its own (0EAA), not the base letter's — the one retune here + // that moves a character rather than only its accent. + ("Ǯ", [0x0E, 0xAA], 0x04), + // J with caron has no uppercase form, so it is keyed as itself. + ("ǰ", [0x0E, 0x35], 0x04)]); + /// Builds a tailoring. take the default secondary — they are letters /// in their own right; carry one of their own, which is how a locale retunes /// a diacritic (Czech moves the diaeresis from 0x13 to 0x05) or marks a spelling (Danish diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs index 44068dad..bbefb25c 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs @@ -72,14 +72,19 @@ internal static class JetTextCollationV1 /// false if any character has no weight in the table (the caller reports it rather than emitting a key /// that would sort wrongly). /// - public static bool TryEncode(string value, List output) => TryEncode(value, output, out _); + public static bool TryEncode(string value, List output, LocaleTailoring? tailoring = null) => + TryEncode(value, output, tailoring, out _); + /// 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. /// /// 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 /// those bytes hold such a record, because the record is precisely what cannot be observed. /// - public static bool TryEncode(string value, List output, out bool hasWordSortRecord) + public static bool TryEncode( + string value, List output, LocaleTailoring? tailoring, out bool hasWordSortRecord) { hasWordSortRecord = false; WeightTable table = Table.Value; @@ -99,8 +104,28 @@ public static bool TryEncode(string value, List output, out bool hasWordSo byte kanaVowel = 0; bool kanaSmall = false; - foreach (char character in text) + // Indexed rather than foreach, because a tailoring entry can consume several characters: a + // contraction is a digraph weighing as one letter (Croatian "dž", "lj", "nj"). + for (int position = 0; position < text.Length; position++) { + char character = text[position]; + + // A locale tailoring overrides everything below it. The mechanism is v0's exactly — the entries + // simply carry a two-byte (SM, AW) primary here instead of one byte, which is the only reason + // this hook did not already exist. One entry is one WEIGHT however many bytes its primary takes, + // so it contributes exactly one secondary: the section counts weights, not bytes. + if (tailoring is not null && + tailoring.TryMatch(text, position, out TailoredWeight tailored, out int consumed, out bool repeat)) + { + for (int emit = repeat ? 2 : 1; emit > 0; emit--) + { + primaries.AddRange(tailored.Primaries); + secondaries.Add(tailored.Secondary); + } + position += consumed - 1; + continue; + } + // A surrogate the table has no weight for is IGNORABLE, not an error — which is the whole of what // astral support needs here, because both halves are otherwise weighed like any other character. // @@ -225,8 +250,24 @@ public static bool TryEncode(string value, List output, out bool hasWordSo if (table.TryExpand(character, out char[]? sequence)) { + // An expanded component takes the LOCALE's letter, not the base table's. The precomposed + // digraph U+01C4 expands to D + Ž, and in these orders Ž is a letter of its own (0EAD) — so + // sending the components straight to the base table gives D + Z-with-caron, which is what ACE + // does not store. The same rule version 0 already follows. + // + // Single-character lookup only: a component must not re-enter the CONTRACTION matcher, or + // expanding a ligature could trip a digraph entry that the original text never contained. foreach (char expanded in sequence) + { + if (tailoring is not null && + tailoring.TryMatchSingle(expanded, out TailoredWeight component)) + { + primaries.AddRange(component.Primaries); + secondaries.Add(component.Secondary); + continue; + } if (!Append(expanded)) return false; + } } else if (!Append(character)) { diff --git a/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs b/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs index 6cd7d46f..0bffc697 100644 --- a/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs +++ b/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs @@ -30,6 +30,9 @@ public static TheoryData Fixtures() => "Slovak", "Vietnamese", "HungarianTechnical", // Cyrillic orders, encodable once General v0 carried the Cyrillic block. "Ukrainian", "Macedonian", + // Version-1 orders. The same order under three LCIDs — each measures identically against General v1 — + // and the first tailorings the v1 encoder carries, so they also cover the two-byte-primary path. + "Croatian", "Bosnian", "Serbian", ]; [Theory] @@ -39,11 +42,12 @@ public void Encodes_the_same_index_keys_as_ace(string fixture) string source = TestDatabases.Data($"{fixture}.accdb"); Assert.SkipWhen(!File.Exists(source), $"{fixture}.accdb is not present"); - // The extended blocks are measured for version-0 orders only; the version-1 encoder is a separate - // table with its own coverage, so a v1 fixture is asserted over the range it was verified in. - byte version; - using (var probe = JetDatabase.Open(source)) version = probe.DefaultCollationVersion; - string[] samples = Samples(extendedBlocks: version != Collation.GeneralVersion); + // Both versions are asserted over the whole measured range. The extended blocks used to be skipped + // for version-1 fixtures, on the grounds that the v1 encoder was "a separate table with its own + // coverage" — but v1 now reproduces ACE across the entire BMP and shares the kana section, so the + // narrowing only hid ground. It was worth removing: the one thing these three locales got wrong was + // a case asymmetry INSIDE the narrow range, which is a poor argument for testing less. + string[] samples = Samples(extendedBlocks: true); string path = TemporaryDatabase.CopyPath(source, $"conformance-{fixture.ToLowerInvariant()}-"); try { diff --git a/test/LibRed.Core.Tests/V1TailoringProbeTest.cs b/test/LibRed.Core.Tests/V1TailoringProbeTest.cs new file mode 100644 index 00000000..174c8682 --- /dev/null +++ b/test/LibRed.Core.Tests/V1TailoringProbeTest.cs @@ -0,0 +1,206 @@ +using System.Text; +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: whether the version-1 locale orders are General v1 plus a few overrides. +// +// Bosnian, Croatian and Serbian ship only at sort-order version 1 and are refused, on the grounds that the +// v1 encoder has no tailoring hook — its primaries are two-byte NLS values, a different shape from the +// one-byte table JetLocaleTailoring targets. That is a statement about the ENCODER, not about the orders: +// nothing measured says a v1 locale tailors differently in kind from a v0 one. +// +// If these are General v1 with a handful of letters moved, the hook is a small piece of work and three +// locales follow. This measures the departures rather than assuming either way. +// +// Opt-in via LIBRED_V1_TAILORING=1. +public class V1TailoringProbeTest(ITestOutputHelper output) +{ + /// + /// Each tailored letter in BOTH cases, per locale, because they are not folded on disk. + /// + /// + /// All three orders report the same NUMBER of departures, and the entries generated from them come out + /// byte-identical — but conformance still disagrees on U+016D. Equal counts are not equal content: + /// the generator keys entries by their uppercase form, so a letter whose two cases behave differently + /// collapses into one entry and the last one written wins. This asks about each case separately. + /// + [Theory] + [InlineData("Croatian")] + [InlineData("Bosnian")] + [InlineData("Serbian")] + public void Probe_v1_case_pairs(string fixture) + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_V1_TAILORING") == "1", + "set LIBRED_V1_TAILORING=1 — this probe needs ACE"); + + string source = TestDatabases.Data($"{fixture}.accdb"); + Assert.SkipWhen(!File.Exists(source), $"{fixture}.accdb is not present"); + + // The retuned letters and the letters of the alphabet, upper and lower. + int[] points = + [ + 0x0106, 0x010C, 0x0110, 0x0160, 0x017D, // Ć Č Đ Š Ž + 0x0102, 0x010E, 0x0114, 0x011A, 0x011E, 0x012C, 0x013D, // breve/caron retunes + 0x0147, 0x014E, 0x0158, 0x0164, 0x016C, 0x0179, 0x017B, + ]; + var samples = new List(); + foreach (int p in points) + { + samples.Add(((char)p).ToString()); + samples.Add(((char)p).ToString().ToLowerInvariant()); + } + samples.AddRange(["DŽ", "dž", "Dž", "LJ", "lj", "Lj", "NJ", "nj", "Nj"]); + + Dictionary ace = AceKeys(source, [.. samples.Distinct()]); + output.WriteLine($"--- {fixture}"); + foreach (int p in points) + { + string upper = ((char)p).ToString(), lower = upper.ToLowerInvariant(); + string u = ace.GetValueOrDefault(upper, "-"), l = ace.GetValueOrDefault(lower, "-"); + output.WriteLine($" U+{p:X4} {u,-24} U+{(int)lower[0]:X4} {l,-24} {(u == l ? "same" : "DIFFER")}"); + } + foreach (string d in (string[])["DŽ", "dž", "Dž", "LJ", "lj", "Lj", "NJ", "nj", "Nj"]) + output.WriteLine($" {d,-4} {ace.GetValueOrDefault(d, "-")}"); + } + + [Theory] + [InlineData("Croatian")] + [InlineData("Bosnian")] + [InlineData("Serbian")] + public void Probe_v1_locale_departures_from_general(string fixture) + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_V1_TAILORING") == "1", + "set LIBRED_V1_TAILORING=1 — this probe needs ACE"); + + string source = TestDatabases.Data($"{fixture}.accdb"); + Assert.SkipWhen(!File.Exists(source), $"{fixture}.accdb is not present"); + + using (var db = JetDatabase.Open(source)) + output.WriteLine($"{fixture}: {db.Collation.Order} version {db.Collation.Version}" + + (db.Collation.SortId == 0 ? "" : $" sort id {db.Collation.SortId}")); + + // The whole range the conformance test guards, not a hand-picked point list. Sampling points is how + // the first version of this table came out incomplete: the caron retune reaches further into Latin + // Extended-B than any list of "letters I expect to matter" would have included. + var samples = new List(); + for (int c = 0x20; c <= 0x24F; c++) + { + if (c is >= 0x7F and <= 0xA0) continue; + samples.Add(((char)c).ToString()); + } + foreach ((int first, int last) in ((int, int)[]) + [(0x02B0, 0x02FF), (0x0370, 0x052F), (0x1E00, 0x1EFF), (0x2100, 0x218F)]) + for (int c = first; c <= last; c++) + if (!char.IsControl((char)c) && !char.IsSurrogate((char)c)) + samples.Add(((char)c).ToString()); + samples.AddRange([ + "dz", "dž", "DŽ", "lj", "LJ", "nj", "NJ", "ch", "cc", "ss", + "džem", "ljubav", "njega", "čaj", "ćevapi", "šećer", "žito", "đak", + ]); + + Dictionary ace = AceKeys(source, [.. samples.Distinct()]); + var general = new ColumnDef + { + Name = "K", Type = JetDataType.Text, Index = 0, Collation = Collation.General, + }; + + int same = 0, refused = 0; + var departures = new List(); + foreach (string text in samples.Distinct()) + { + if (!ace.TryGetValue(text, out string? stored)) continue; + string? asGeneral = null; + try { asGeneral = Convert.ToHexString(IndexKeyEncoder.Encode([(general, true)], [text])); } + catch (NotSupportedException) { refused++; continue; } + + if (asGeneral == stored) { same++; continue; } + departures.Add($" {Describe(text),-22} locale {stored,-30} general {asGeneral}"); + } + + output.WriteLine($"{same} identical to General v1, {departures.Count} departures, {refused} refused"); + foreach (string line in departures.Take(40)) output.WriteLine(line); + output.WriteLine(departures.Count == 0 + ? "IDENTICAL — inert, like the five DAO-only orders" + : $"{departures.Count} entries would express this order as General v1 plus overrides"); + + // Ready-to-paste tailoring entries, keyed uppercase as the table expects. Generated rather than + // transcribed: hand-copying hex is exactly the work that introduces a wrong byte nobody notices, + // because a wrong index key does not fail — it silently disagrees with ACE. + var letters = new SortedDictionary(StringComparer.Ordinal); + var accented = new SortedDictionary(StringComparer.Ordinal); + foreach (string text in samples.Distinct()) + { + if (!ace.TryGetValue(text, out string? stored)) continue; + string? asGeneral = null; + try { asGeneral = Convert.ToHexString(IndexKeyEncoder.Encode([(general, true)], [text])); } + catch (NotSupportedException) { continue; } + if (asGeneral == stored) continue; + + byte[] key = Convert.FromHexString(stored); + int end = Array.IndexOf(key, (byte)0x01, 1); + if (end < 0) continue; + byte[] primaries = key[1..end]; + byte[] secondaries = key[(end + 1)..^1]; + if (secondaries.Length > 1) continue; // an expansion: more than one weight + + string upper = text.ToUpperInvariant(); + string bytes = string.Join(", ", primaries.Select(b => $"0x{b:X2}")); + if (secondaries.Length == 0 || secondaries[0] == 0x02) letters[upper] = $"(\"{upper}\", [{bytes}]),"; + else accented[upper] = $"(\"{upper}\", [{bytes}], 0x{secondaries[0]:X2}),"; + } + + output.WriteLine(""); + output.WriteLine($"=== {fixture}: {letters.Count} letters, {accented.Count} accented"); + foreach (string line in letters.Values) output.WriteLine($" {line}"); + output.WriteLine(" --- accented ---"); + foreach (string line in accented.Values) output.WriteLine($" {line}"); + } + + private static string Describe(string s) => + s.All(c => c is >= ' ' and <= '~') ? $"\"{s}\"" : string.Concat(s.Select(c => $"U+{(int)c:X4}")); + + private static Dictionary AceKeys(string source, string[] samples) + { + string path = TemporaryDatabase.CopyPath(source, "v1tail-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE Probe (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_Probe ON Probe (K)"); + for (int i = 0; i < samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Probe (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("Probe"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_Probe"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + var keys = new Dictionary(); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values) && values[keyColumn.Index] is string text) + keys[text] = Convert.ToHexString(stored); + return keys; + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} From 0e4130ff8a1dfc7b2db224901dcf2575209636e4 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 19:50:19 +0800 Subject: [PATCH 17/48] LibRed: a weight with no primary folds into the one before it (version 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit General v0 gave a secondary-only character a slot of its own in the secondary section. ACE folds it into the preceding weight instead, adding its value: Thai ไก่ is two weights with secondaries 03 06 - the tone mark's 03 added to the consonant's 03 - not three weights. Emitting a slot desynchronises the whole section from the primaries, so everything after the mark is wrong too. This affects ALL 28 orders, not one. The class is "secondary-only" characters, and it holds Thai tone marks, every Hebrew niqqud, the Cyrillic combining marks and three Greek ones - any indexed text with a combining mark following a base character. The version-1 encoder has had this rule since the full-BMP work; version 0 never got it. It surfaced only when the Thai block entered the conformance range, because the rule needs a mark AFTER a base character and a per-character sweep cannot place one there. Nor could comparing one locale against another: both were wrong identically, so only comparing against ACE shows it. The conformance range gains the Thai block and words, which is what caught it. Co-Authored-By: Claude Opus 5 --- .../LibRed.Core/Storage/JetTextCollation.cs | 15 +++++++++++++++ .../LocaleCollationAccessTests.cs | 7 +++++++ 2 files changed, 22 insertions(+) diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs index db3b1bc1..7a331d98 100644 --- a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs +++ b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs @@ -453,6 +453,21 @@ void Add(byte primary, byte secondary = DefaultSecondary) // the same way; `secondaries.Count` is the weight count for both. void AddWeight(ReadOnlySpan weight, byte secondary) { + // A weight with NO primary carries only its secondary, and where something precedes it that + // secondary FOLDS into the one before rather than taking a slot of its own. ACE encodes ไก่ as + // two weights with secondaries 03 06 — the tone mark's 03 added to the ก's 03 — not as three + // weights. Emitting a slot desynchronises the whole section from the primaries. + // + // The same rule the version-1 encoder already had. It reached version 0 only when the Thai block + // entered the conformance range, because it needs a combining mark AFTER a base character and a + // per-character sweep never places one there. It is not a Thai rule: Hebrew niqqud, the Cyrillic + // combining marks and three Greek ones are all in this class, in every order. + if (weight.IsEmpty && secondaries.Count > 0) + { + secondaries[^1] = (byte)(secondaries[^1] + secondary); + return; + } + foreach (byte b in weight) primaries.Add(b); secondaries.Add(secondary); } diff --git a/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs b/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs index 0bffc697..cd70684a 100644 --- a/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs +++ b/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs @@ -33,6 +33,8 @@ public static TheoryData Fixtures() => // Version-1 orders. The same order under three LCIDs — each measures identically against General v1 — // and the first tailorings the v1 encoder carries, so they also cover the two-byte-primary path. "Croatian", "Bosnian", "Serbian", + // Thai, whose contraction is built as a rule rather than a table of entries. + "Thai", ]; [Theory] @@ -104,6 +106,7 @@ private static string[] Samples(bool extendedBlocks) (int First, int Last)[] blocks = [ (0x0180, 0x024F), (0x02B0, 0x02FF), (0x0370, 0x052F), (0x0590, 0x06FF), + (0x0E01, 0x0E5B), // Thai: consonants, vowels, tone marks and digits (0x1E00, 0x1EFF), (0x2000, 0x206F), (0x20A0, 0x20BF), (0x2100, 0x218F), (0xFF01, 0xFF65), (0x3040, 0x30FF), (0xFF66, 0xFF9F), // kana: hiragana, katakana, halfwidth katakana ]; @@ -121,6 +124,10 @@ private static string[] Samples(bool extendedBlocks) "ch", "cch", "chh", "ll", "lll", "llll", "cs", "dz", "dzs", "gy", "ly", "ny", "sz", "ty", "zs", "ccs", "ddz", "ggy", "lly", "nny", "ssz", "tty", "zzs", "gyy", "hc", "dzz", "lj", "nj", "dž", "ddž", "llj", "nnj", "aa", "aaa", "aab", "baa", "Aa", "AA", + // Thai: each leading vowel before a consonant, the reverse order (which must NOT contract), the + // vowel with nothing to attach to, and words where the contraction meets tone marks. + "เก", "แก", "โก", "ใก", "ไก", "กเ", "กแ", "กโ", "กใ", "กไ", "เ", "เเ", "เ ", " เ", + "เกา", "เก้า", "ไก่", "ไทย", "แดง", "โกรธ", "ใหม่", "ประเทศไทย", "ภาษาไทย", "สวัสดี", "เรียน", // An ignorable AFTER a two-byte primary: the inline position counts weights, not bytes, and only // a non-Latin or symbol character ahead of it can tell those two rules apart. "£-", "©-", "½-", "£A-", "A£-", "Ω-", "б-", "£'", "Ω'A", "€-B", From 21bb630214caddc614a537d86a7120673c4b6126 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 19:50:35 +0800 Subject: [PATCH 18/48] LibRed: implement Thai - a contraction, not a reordering Thai writes the five vowels e/ae/o/ai/ai-maimuan BEFORE the consonant they are pronounced after, and collation follows speech. That was recorded as needing REORDERING, a device nothing else here uses, and it is why Thai stayed unimplemented as the last non-CJK order. Measurement says it is an ordinary contraction - the same device Croatian's lj uses. ACE gives the pair a SINGLE weight at the consonant's own primary plus a vowel offset: the pair with ko-kai is 7C99 where the consonant alone is 7C98, the next vowel gives 7C9A, and so on to +5. Every consonant sits on a six-wide block, itself plus a slot per leading vowel. And it is a contraction rather than a swap, because the reverse order does NOT collide: consonant-then-vowel stays two weights, 7C98 7C93. Built as the rule rather than 220 transcribed entries, with each consonant's primary read from the measured v0 table, so there is no hand-copied hex. Also re-verifies the five DAO-only orders that are recorded as inert. They were established over 31 samples of single characters, and French proved that shape of evidence can hide an entire rule - it tailors no letter at all, so a word with ONE accent looks identical to General and only two reveal it. Now 82 samples including words carrying two marks per script, and the Greek triple that is the direct analogue of the French one. All five are still inert, and the probe's positive controls still show their departures, so the null result means the orders are inert rather than the harness being dead. Every non-CJK sort order is now implemented: 28 of them, each verified against ACE over 2,559 values. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Core/Catalog/Collation.cs | 10 +- .../LibRed.Core/Storage/JetLocaleTailoring.cs | 43 ++++++ .../DaoLocaleCollationProbeTest.cs | 12 ++ .../LibRed.Core.Tests/ThaiReorderProbeTest.cs | 134 ++++++++++++++++++ 4 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 test/LibRed.Core.Tests/ThaiReorderProbeTest.cs diff --git a/src/LibRed/LibRed.Core/Catalog/Collation.cs b/src/LibRed/LibRed.Core/Catalog/Collation.cs index 6d520208..befc0d91 100644 --- a/src/LibRed/LibRed.Core/Catalog/Collation.cs +++ b/src/LibRed/LibRed.Core/Catalog/Collation.cs @@ -9,7 +9,15 @@ namespace LibRed.Catalog; /// Georgian Modern, Vietnamese, Indic, French, German Phone Book, Hungarian Technical and the CJK variants, /// and offers none of the five marked inert below. Those five are still creatable through DAO and are /// recorded faithfully on page 0 and in column descriptors, but ACE encodes **General** keys for them -/// regardless — verified over 31 samples in DaoLocaleCollationProbeTest. Treat them as metadata. +/// regardless — verified over 82 samples in DaoLocaleCollationProbeTest. Treat them as metadata. +/// +/// That sample set includes words carrying TWO marks in each script, which is the shape that matters: French +/// tailors no letter at all and would look inert on single characters, because it reverses the diacritic +/// section and a word with one accent encodes identically to General. The Greek triple +/// άα/αά/άαά is the direct analogue of the French coté/côte/côté +/// that exposed it. The probe also carries Spanish, Czech, Polish and Turkish as positive controls, so a +/// null result means the orders are inert rather than the harness being dead. +/// /// /// /// The LCID alone does not pin the on-disk key bytes: "General" (1033) has a legacy order (version 0, diff --git a/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs index 8cd12a1f..96d50fbf 100644 --- a/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs +++ b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs @@ -144,6 +144,9 @@ internal static class JetLocaleTailoring [new Collation(CollatingOrder.Georgian, 0, SortId: 1)] = Table([]), [new Collation(CollatingOrder.Indic, Collation.GeneralVersion)] = Table([]), + // --- Thai: the five leading vowels contract with the consonant they precede. Built as a rule. --- + [new Collation(CollatingOrder.Thai, 0)] = Thai(), + // --- Bosnian, Croatian and Serbian at version 1: the same order under three LCIDs. --- // Each measures 289 values identical to General v1 and the same 47 departures, byte for byte, so one // table serves all three. These are the FIRST version-1 tailorings: their primaries are two-byte @@ -328,6 +331,46 @@ [new Collation(CollatingOrder.Hungarian, 0, SortId: 1)] = Table( ("Ż", [0x79, 0x03]), ("Ž", [0x79, 0x04])]), }; + /// + /// Thai — the five leading vowels contract with the consonant they precede. + /// + /// + /// Thai writes เ แ โ ใ ไ BEFORE the consonant they are pronounced after, and collation follows + /// speech. This was long recorded as needing reordering — a device nothing else here uses, and the + /// reason Thai stayed unimplemented. Measurement says otherwise: it is an ordinary CONTRACTION, the same + /// device Croatian's lj uses. + /// + /// ACE gives the pair a SINGLE weight at the consonant's own primary plus a vowel offset — เก is + /// 7C99 where alone is 7C98, แก is 7C9A, and so on to at + /// +5. Every consonant sits on a six-wide block: itself, then a slot for each leading vowel. And it is + /// genuinely a contraction rather than a swap, because the reverse order does not collide with it — + /// กเ stays two weights, 7C98 7C93. + /// + /// + /// Built as the rule rather than as 220 transcribed entries, with each consonant's primary read from the + /// measured v0 table, so there is no hand-copied hex to get wrong. + /// + /// + private static LocaleTailoring Thai() + { + const char firstConsonant = 'ก', lastConsonant = 'ฮ', firstLeadingVowel = 'เ'; + var table = new Dictionary(StringComparer.Ordinal); + + for (char consonant = firstConsonant; consonant <= lastConsonant; consonant++) + { + if (!JetTextCollationTableV0.TryGet(consonant, out TailoredWeight? weight) || weight is null) continue; + for (int offset = 1; offset <= 5; offset++) + { + byte[] primaries = [.. weight.Value.Primaries]; + primaries[^1] = (byte)(primaries[^1] + offset); + table[$"{(char)(firstLeadingVowel + offset - 1)}{consonant}"] = + new TailoredWeight(primaries, weight.Value.Secondary); + } + } + + return new LocaleTailoring(table); + } + /// /// Bosnian, Croatian and Serbian at sort-order version 1 — one table, three LCIDs. /// diff --git a/test/LibRed.Core.Tests/DaoLocaleCollationProbeTest.cs b/test/LibRed.Core.Tests/DaoLocaleCollationProbeTest.cs index 5b8fd234..0815a00a 100644 --- a/test/LibRed.Core.Tests/DaoLocaleCollationProbeTest.cs +++ b/test/LibRed.Core.Tests/DaoLocaleCollationProbeTest.cs @@ -57,6 +57,18 @@ private static readonly (string Label, string Locale, int ExpectedLcid)[] Locale "א", "ב", "כ", "ך", "מ", "ם", "צ", "ץ", // Hebrew: medial vs final forms "ا", "ب", "أ", "إ", "آ", "ة", "ه", "ى", "ي", // Arabic: hamza forms, ta marbuta, alef maqsura "ij", "IJ", // Dutch: the IJ ligature as well as the pair + // Words carrying TWO marks, per script. Everything above is a single character or a short pair, and + // a whole class of rule cannot be seen that way: French tailors no letter at all — it reverses the + // diacritic section — so a word with ONE accent encodes identically to General and only a word with + // two reveals it. These five orders were called inert on a set that could not have detected such a + // rule, which is not the same as their being inert. + "έάν", "άέν", "ελληνικά", "Ελληνικά", "ώρα", "ωρά", // Greek: two tonos, in either order + "ёлка", "мёд", "йогурт", "майор", // Cyrillic: yo and short i inside words + "שָׁלוֹם", "בְּרֵאשִׁית", // Hebrew: several niqqud in one word + "مُحَمَّد", "كِتَاب", "بِسْمِ", // Arabic: several harakat in one word + "ijsvrij", "yoghurt", "bijzonder", "byzantijns", // Dutch: ij against y inside words + // The same shape that exposed French, in each script that has accents to reverse. + "άα", "αά", "άαά", "éà", "àé", "éàé", ]; [Fact] diff --git a/test/LibRed.Core.Tests/ThaiReorderProbeTest.cs b/test/LibRed.Core.Tests/ThaiReorderProbeTest.cs new file mode 100644 index 00000000..2325db85 --- /dev/null +++ b/test/LibRed.Core.Tests/ThaiReorderProbeTest.cs @@ -0,0 +1,134 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// PROBE: what the Thai order actually does, which is the one tailoring device never implemented. +// +// Thai writes five vowels BEFORE the consonant they are pronounced after — เ แ โ ใ ไ. Collation follows +// speech rather than writing, so the pair has to be swapped before weighing: เก is written vowel-consonant +// and sorts as if consonant-vowel. Neither a per-character map nor a contraction can express that; it is a +// REORDERING, and no other order here needs one. +// +// Thai was recorded as having a single departure from General over the old 193-sample set. French was +// recorded the same way and turned out to be a whole reversal rule the samples could not see, so the number +// is not evidence of a small tailoring. This measures Thai text rather than Thai characters. +// +// Opt-in via LIBRED_THAI=1. +public class ThaiReorderProbeTest(ITestOutputHelper output) +{ + [Fact] + public void Probe_thai_against_general() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_THAI") == "1", + "set LIBRED_THAI=1 — this probe needs ACE"); + + string source = TestDatabases.Data("Thai.accdb"); + Assert.SkipWhen(!File.Exists(source), "Thai.accdb is not present"); + + using (var db = JetDatabase.Open(source)) + output.WriteLine($"Thai.accdb: {db.Collation.Order} version {db.Collation.Version}" + + (db.Collation.SortId == 0 ? "" : $" sort id {db.Collation.SortId}")); + + var samples = new List(); + + // Every Thai character on its own: consonants, vowels, tone marks and digits. + for (int c = 0x0E01; c <= 0x0E5B; c++) + if (!char.IsControl((char)c)) samples.Add(((char)c).ToString()); + + // The reordering itself. Each leading vowel against each of a few consonants, both as written + // (vowel first) and in spoken order (consonant first) — if the order reorders, the two collide. + foreach (char vowel in "เแโใไ") + foreach (char consonant in "กขคงจดตนบปมยรลวสห") + { + samples.Add($"{vowel}{consonant}"); + samples.Add($"{consonant}{vowel}"); + } + + // Real words, where the reordering has to survive alongside tone marks and following vowels. + samples.AddRange([ + "เก", "กเ", "เกา", "เกิน", "เก้า", "แก", "แก้", "โก", "โกรธ", "ใกล้", "ไก่", "ไทย", + "กา", "ก่า", "ก้า", "กิน", "กีบ", "เดิน", "เด็ก", "แดง", "โต", "ใหม่", "ไหม", + "ประเทศไทย", "ภาษาไทย", "สวัสดี", "ขอบคุณ", "เรียน", "เขียน", "เที่ยว", + // A leading vowel with nothing after it, and doubled, where there is nothing to swap with. + "เ", "เเ", "เ ", " เ", "เก เก", + ]); + + Dictionary ace = AceKeys(source, [.. samples.Distinct()]); + var general = new ColumnDef + { + Name = "K", Type = JetDataType.Text, Index = 0, Collation = Collation.GeneralLegacy, + }; + + int same = 0, refused = 0; + var departures = new List(); + foreach (string text in samples.Distinct()) + { + if (!ace.TryGetValue(text, out string? stored)) continue; + string? asGeneral = null; + try { asGeneral = Convert.ToHexString(IndexKeyEncoder.Encode([(general, true)], [text])); } + catch (NotSupportedException) { refused++; continue; } + if (asGeneral == stored) { same++; continue; } + departures.Add($" {Describe(text),-30} Thai {stored,-34} General {asGeneral}"); + } + + output.WriteLine($"{same} identical to General v0, {departures.Count} departures, " + + $"{refused} General cannot encode"); + foreach (string line in departures.Take(45)) output.WriteLine(line); + + // The question the whole thing turns on: does a written pair collide with its spoken order? + foreach ((string written, string spoken) in ((string, string)[]) + [("เก", "กเ"), ("แก", "กแ"), ("โก", "กโ"), ("ใก", "กใ"), ("ไก", "กไ")]) + { + string w = ace.GetValueOrDefault(written, "-"), s = ace.GetValueOrDefault(spoken, "-"); + output.WriteLine($" {Describe(written)} = {w} {Describe(spoken)} = {s} " + + (w == s ? "SAME KEY — reordered" : "different")); + } + } + + private static string Describe(string s) => + s.All(c => c is >= ' ' and <= '~') ? $"\"{s}\"" : string.Concat(s.Select(c => $"U+{(int)c:X4}")); + + private static Dictionary AceKeys(string source, string[] samples) + { + string path = TemporaryDatabase.CopyPath(source, "thai-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE Probe (K TEXT(60), V LONG)"); + Exec(connection, "CREATE INDEX IX_Probe ON Probe (K)"); + for (int i = 0; i < samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Probe (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("Probe"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_Probe"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + var keys = new Dictionary(); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values) && values[keyColumn.Index] is string text) + keys[text] = Convert.ToHexString(stored); + return keys; + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} From 87268c05771ab27fba377b1f8f07f1f9c7fdd13d Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 20:16:34 +0800 Subject: [PATCH 19/48] Split the ACE engine tests out of the cross-platform suite CI runs LibRed.Engine.Tests on five platforms - Linux, Windows, macOS and both ARM legs - because LibRed is fully managed, and that run is what actually proves the cross-platform claim. Twenty-four tests in there open an ACE OLE DB connection, so they fail on four of the five, and for a reason that says nothing about the code: the driver is simply absent. They move to a new LibRed.Engine.AccessTests, which runs under the LibRedAccess job beside LibRed.Core.Tests, where the other comparisons against the real engine already live. They genuinely need the engine - QueryEngine, SQL, the lot - so LibRed.Core.Tests could not host them without inverting the layering, and LibRed.Ado.Tests would have meant filing engine tests under the ADO layer and introducing an ACE dependency to a project that has none. The five classes were already marked [Collection(AceCollection.Name)], so they identified themselves and the split needed no judgement about which were which. LibRed.Engine.Tests now has no System.Data.OleDb reference and no AceTestDatabase, so this cannot drift back: an ACE test added there does not compile rather than failing in CI on four platforms. 938 + 24 = 962, the count before the split. Co-Authored-By: Claude Opus 5 --- .github/workflows/pull_request.yml | 7 ++ EFCore.Jet.sln | 19 +++++- .../AceCollection.cs | 0 .../ConcurrentAllocationTests.cs | 0 .../FunctionArityAccessTests.cs | 0 .../LibRed.Engine.AccessTests.csproj | 64 +++++++++++++++++++ .../StatementAtomicityTests.cs | 0 .../TransactionIsolationTests.cs | 0 .../TransactionalDdlRollbackAccessTests.cs | 0 .../LibRed.Engine.Tests.csproj | 9 ++- 10 files changed, 94 insertions(+), 5 deletions(-) rename test/{LibRed.Engine.Tests => LibRed.Engine.AccessTests}/AceCollection.cs (100%) rename test/{LibRed.Engine.Tests => LibRed.Engine.AccessTests}/ConcurrentAllocationTests.cs (100%) rename test/{LibRed.Engine.Tests => LibRed.Engine.AccessTests}/FunctionArityAccessTests.cs (100%) create mode 100644 test/LibRed.Engine.AccessTests/LibRed.Engine.AccessTests.csproj rename test/{LibRed.Engine.Tests => LibRed.Engine.AccessTests}/StatementAtomicityTests.cs (100%) rename test/{LibRed.Engine.Tests => LibRed.Engine.AccessTests}/TransactionIsolationTests.cs (100%) rename test/{LibRed.Engine.Tests => LibRed.Engine.AccessTests}/TransactionalDdlRollbackAccessTests.cs (100%) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index cc58bf59..3e914c6a 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -104,6 +104,7 @@ jobs: - 'test/LibRed.Core.Tests/**' - 'test/LibRed.EFCore.Tests/**' - 'test/LibRed.Engine.Tests/**' + - 'test/LibRed.Engine.AccessTests/**' - 'test/EFCore.LibRed.FunctionalTests/**' # LibRed.Ado references EFCore.Jet.Data and LibRed.EFCore references EFCore.Jet, so a # change on the Jet side has to re-run LibRed. The reverse is not true: nothing under @@ -678,6 +679,12 @@ jobs: if: env.skipTests != 'true' shell: pwsh run: dotnet test .\test\LibRed.Core.Tests --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' + if: always() && env.skipTests != 'true' + shell: pwsh + run: dotnet test .\test\LibRed.Engine.AccessTests --configuration '${{ env.buildConfiguration }}' -p:FixedTestOrder=${{ env.deterministicTests }} --blame-hang-timeout 5m - name: 'Run Tests: LibRed.Ado.Tests' if: always() && env.skipTests != 'true' shell: pwsh diff --git a/EFCore.Jet.sln b/EFCore.Jet.sln index dfbad403..e3125260 100644 --- a/EFCore.Jet.sln +++ b/EFCore.Jet.sln @@ -1,7 +1,7 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.9.12009.208 insiders +VisualStudioVersion = 18.9.12009.208 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{F68095EE-6CD1-43A2-B498-6CA72CE2A0CB}" ProjectSection(SolutionItems) = preProject @@ -82,6 +82,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFCore.LibRed.FunctionalTes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibRed.Benchmarks", "test\LibRed.Benchmarks\LibRed.Benchmarks.csproj", "{6F69127C-9481-48B2-A974-F9A7F38BB9EC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibRed.Engine.AccessTests", "test\LibRed.Engine.AccessTests\LibRed.Engine.AccessTests.csproj", "{5287C755-947C-448E-BBE4-48D47051D35F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -332,6 +334,18 @@ Global {6F69127C-9481-48B2-A974-F9A7F38BB9EC}.Release|x64.Build.0 = Release|Any CPU {6F69127C-9481-48B2-A974-F9A7F38BB9EC}.Release|x86.ActiveCfg = Release|Any CPU {6F69127C-9481-48B2-A974-F9A7F38BB9EC}.Release|x86.Build.0 = Release|Any CPU + {5287C755-947C-448E-BBE4-48D47051D35F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5287C755-947C-448E-BBE4-48D47051D35F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5287C755-947C-448E-BBE4-48D47051D35F}.Debug|x64.ActiveCfg = Debug|x64 + {5287C755-947C-448E-BBE4-48D47051D35F}.Debug|x64.Build.0 = Debug|x64 + {5287C755-947C-448E-BBE4-48D47051D35F}.Debug|x86.ActiveCfg = Debug|x86 + {5287C755-947C-448E-BBE4-48D47051D35F}.Debug|x86.Build.0 = Debug|x86 + {5287C755-947C-448E-BBE4-48D47051D35F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5287C755-947C-448E-BBE4-48D47051D35F}.Release|Any CPU.Build.0 = Release|Any CPU + {5287C755-947C-448E-BBE4-48D47051D35F}.Release|x64.ActiveCfg = Release|x64 + {5287C755-947C-448E-BBE4-48D47051D35F}.Release|x64.Build.0 = Release|x64 + {5287C755-947C-448E-BBE4-48D47051D35F}.Release|x86.ActiveCfg = Release|x86 + {5287C755-947C-448E-BBE4-48D47051D35F}.Release|x86.Build.0 = Release|x86 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -358,6 +372,7 @@ Global {CADF8601-1CF1-421B-AACF-EE2C16AC610B} = {6A8DE399-1804-4113-A408-F23B7F5C9CAC} {0F741500-BC61-44FA-9642-3B3326F7C90E} = {6A8DE399-1804-4113-A408-F23B7F5C9CAC} {6F69127C-9481-48B2-A974-F9A7F38BB9EC} = {6A8DE399-1804-4113-A408-F23B7F5C9CAC} + {5287C755-947C-448E-BBE4-48D47051D35F} = {6A8DE399-1804-4113-A408-F23B7F5C9CAC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9359773D-6399-447E-9814-6CB41C2FB664} diff --git a/test/LibRed.Engine.Tests/AceCollection.cs b/test/LibRed.Engine.AccessTests/AceCollection.cs similarity index 100% rename from test/LibRed.Engine.Tests/AceCollection.cs rename to test/LibRed.Engine.AccessTests/AceCollection.cs diff --git a/test/LibRed.Engine.Tests/ConcurrentAllocationTests.cs b/test/LibRed.Engine.AccessTests/ConcurrentAllocationTests.cs similarity index 100% rename from test/LibRed.Engine.Tests/ConcurrentAllocationTests.cs rename to test/LibRed.Engine.AccessTests/ConcurrentAllocationTests.cs diff --git a/test/LibRed.Engine.Tests/FunctionArityAccessTests.cs b/test/LibRed.Engine.AccessTests/FunctionArityAccessTests.cs similarity index 100% rename from test/LibRed.Engine.Tests/FunctionArityAccessTests.cs rename to test/LibRed.Engine.AccessTests/FunctionArityAccessTests.cs diff --git a/test/LibRed.Engine.AccessTests/LibRed.Engine.AccessTests.csproj b/test/LibRed.Engine.AccessTests/LibRed.Engine.AccessTests.csproj new file mode 100644 index 00000000..c6729f40 --- /dev/null +++ b/test/LibRed.Engine.AccessTests/LibRed.Engine.AccessTests.csproj @@ -0,0 +1,64 @@ + + + + + Exe + $(JetTargetFramework) + LibRed.Engine.Tests + enable + enable + false + true + + true + $(MSBuildThisFileDirectory)..\..\Key.snk + AnyCPU;x86;x64 + + $(NoWarn);CA1416 + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + Data\Northwind.accdb + PreserveNewest + + + + diff --git a/test/LibRed.Engine.Tests/StatementAtomicityTests.cs b/test/LibRed.Engine.AccessTests/StatementAtomicityTests.cs similarity index 100% rename from test/LibRed.Engine.Tests/StatementAtomicityTests.cs rename to test/LibRed.Engine.AccessTests/StatementAtomicityTests.cs diff --git a/test/LibRed.Engine.Tests/TransactionIsolationTests.cs b/test/LibRed.Engine.AccessTests/TransactionIsolationTests.cs similarity index 100% rename from test/LibRed.Engine.Tests/TransactionIsolationTests.cs rename to test/LibRed.Engine.AccessTests/TransactionIsolationTests.cs diff --git a/test/LibRed.Engine.Tests/TransactionalDdlRollbackAccessTests.cs b/test/LibRed.Engine.AccessTests/TransactionalDdlRollbackAccessTests.cs similarity index 100% rename from test/LibRed.Engine.Tests/TransactionalDdlRollbackAccessTests.cs rename to test/LibRed.Engine.AccessTests/TransactionalDdlRollbackAccessTests.cs diff --git a/test/LibRed.Engine.Tests/LibRed.Engine.Tests.csproj b/test/LibRed.Engine.Tests/LibRed.Engine.Tests.csproj index 80c6ad4b..6c5a346e 100644 --- a/test/LibRed.Engine.Tests/LibRed.Engine.Tests.csproj +++ b/test/LibRed.Engine.Tests/LibRed.Engine.Tests.csproj @@ -29,15 +29,18 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + wholesale (..\Shared\**\*.cs) and do not all reference LibRed.Core. + AceTestDatabase is deliberately absent — see the note on the package group above. --> From 3dd2275e707892b8989e136aadde5d3b0c4c6ef2 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 20:36:01 +0800 Subject: [PATCH 20/48] LibRed: prove creation honours every collation it claims to encode LibRed synthesises a new .accdb page by page rather than copying a packaged empty file, so the collating order is a parameter of creation rather than a property of a template. That had been demonstrated for the two General orders and French; the other 27 configurations were an inference from sharing a code path. Now measured: 30 configurations - both General orders and every locale in JetLocaleTailoring, including the two orders that exist at a second sort id (German Phone Book, Hungarian Technical). For each, LibRed creates the file, ACE creates a table and index INSIDE it, and every key ACE writes matches LibRed's own. Nothing disagreed. The bar is deliberately that rather than "the file opens". Two engines agreeing on a shared index is the only check that catches a wrong key, because a disagreement does not error - it makes seeks miss rows. The list comes from asking IsIndexKeyEncodable rather than from a hardcoded set, so it cannot drift out of step with JetLocaleTailoring and a new order is covered the moment it lands. It also guards the entanglement between the two: the system-table indexes are built during creation, in the database's own order, so creating a database REQUIRES encoding its collation. A locale with wrong weights would not merely sort wrongly - it would make creation itself produce a file ACE disagrees with. That circularity is why measuring a new locale for the first time needs DAO to author the file. Also corrects the CreateEmpty doc, which still said only the two General orders could be encoded. Co-Authored-By: Claude Opus 5 --- .../LibRed.Core/Storage/DatabaseCreator.cs | 17 ++- .../CreatedDatabaseCollationAccessTests.cs | 118 ++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 test/LibRed.Core.Tests/CreatedDatabaseCollationAccessTests.cs diff --git a/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs b/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs index 956c6704..81717b1b 100644 --- a/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs +++ b/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs @@ -232,10 +232,19 @@ private static readonly (string Name, ColumnSpec[] Columns)[] MSysComplexTypeTab /// are added through the ordinary writers. Produces a LibRed-openable, round-trippable file (Access-level /// fidelity — the remaining system tables and the 0xE00 map — is a follow-up). /// - /// 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 for the order Access 2010+ offers as "General". - /// Only the two General orders can have their index keys encoded — see IndexKeyEncoder. + /// + /// 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 + /// for the order Access 2010+ offers as "General". + /// + /// Any order accepts can be created — 30 configurations, the + /// two General orders and every locale in JetLocaleTailoring, each verified by having ACE build an + /// index in the created file and agree on the keys (CreatedDatabaseCollationAccessTests). It + /// cannot be otherwise: the system-table indexes are built here, in this order, so creating a database + /// REQUIRES encoding its collation. That is why a new locale is unavailable to this method until it is + /// implemented, and why measuring one for the first time needs DAO to author the file. + /// + /// public static void CreateEmpty(string path, byte version = 0x02, Collation? collation = null) { Collation sortOrder = collation ?? Collation.GeneralLegacy; diff --git a/test/LibRed.Core.Tests/CreatedDatabaseCollationAccessTests.cs b/test/LibRed.Core.Tests/CreatedDatabaseCollationAccessTests.cs new file mode 100644 index 00000000..3de5584f --- /dev/null +++ b/test/LibRed.Core.Tests/CreatedDatabaseCollationAccessTests.cs @@ -0,0 +1,118 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// LibRed synthesises a new .accdb page by page rather than copying a packaged empty file, so the collating +// order is a parameter of creation rather than a property of a template. This asserts that for EVERY order +// LibRed claims to encode — not just the two General ones it was demonstrated with. +// +// The claim is worth a test rather than an inference because creation and collation are entangled: the +// system-table indexes are built on the way, with that order's keys, so a database cannot be created in an +// order whose keys cannot be encoded. That circularity is why measuring French needed DAO to author the file +// first, and it means "creates the file" and "gets the order right" are not separable properties. +// +// The bar is not that the file opens. It is that ACE will CREATE AN INDEX in it and write keys that match +// LibRed's own — two engines agreeing on a shared index, which is the only check that catches a wrong key, +// since a disagreement does not error, it just makes seeks miss rows. +public class CreatedDatabaseCollationAccessTests(ITestOutputHelper output) +{ + /// Every collation LibRed claims to encode, found by asking rather than by keeping a list that + /// could fall out of step with JetLocaleTailoring. + public static TheoryData EncodableCollations() + { + var data = new TheoryData(); + foreach (CollatingOrder order in Enum.GetValues()) + foreach (byte version in (byte[])[0, 1]) + foreach (byte sortId in (byte[])[0, 1]) + if (new Collation(order, version, sortId).IsIndexKeyEncodable) + data.Add((int)order, version, sortId); + return data; + } + + [Theory] + [MemberData(nameof(EncodableCollations))] + public void LibRed_creates_a_database_that_ACE_indexes(int order, byte version, byte sortId) + { + var collation = new Collation((CollatingOrder)order, version, sortId); + string path = TemporaryDatabase.CreatePath($"created-{order}-{version}-{sortId}-"); + try + { + DatabaseCreator.CreateEmpty(path, collation: collation); + + // The order has to survive the round trip, or everything below is measuring the wrong thing. + using (var db = JetDatabase.Open(path)) + Assert.Equal(collation, db.Collation); + + // Words that exercise the tailorings across the set: accented Latin, the digraphs, a word-sort + // ignorable, and Thai's leading vowel. Any order encodes all of them; what differs is the keys. + string[] samples = + [ + "apple", "café", "coté", "côte", "Ångström", "co-op", "O'Brien", + "ñ", "č", "ž", "lj", "dž", "ch", "ll", "ı", "İ", "å", "ø", "ß", + "เก", "ไทย", "Ω", "б", "א", + ]; + + Dictionary ace = AceKeys(path, samples); + Assert.NotEmpty(ace); + + var column = new ColumnDef + { + Name = "K", Type = JetDataType.Text, Index = 0, Collation = collation, + }; + + var mismatches = new List(); + foreach (string text in samples) + { + if (!ace.TryGetValue(text, out string? stored)) continue; // ACE refused the value + string ours = Convert.ToHexString(IndexKeyEncoder.Encode([(column, true)], [text])); + if (ours != stored) mismatches.Add($" {text,-12} ACE {stored,-30} LibRed {ours}"); + } + + output.WriteLine($"{collation.Order} v{version}" + (sortId == 0 ? "" : $" sort id {sortId}") + + $": ACE indexed {ace.Count} of {samples.Length} values into a LibRed-created " + + $"database, {mismatches.Count} disagreeing"); + foreach (string line in mismatches) output.WriteLine(line); + Assert.Empty(mismatches); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static Dictionary AceKeys(string path, string[] samples) + { + using (var connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE Probe (K TEXT(50), V LONG)"); + Exec(connection, "CREATE INDEX IX_Probe ON Probe (K)"); + for (int i = 0; i < samples.Length; i++) + { + using var insert = connection.CreateCommand(); + insert.CommandText = "INSERT INTO Probe (K, V) VALUES (?, ?)"; + insert.Parameters.AddWithValue("k", samples[i]); + insert.Parameters.AddWithValue("v", i); + try { insert.ExecuteNonQuery(); } catch (Exception) { /* ACE refused this value */ } + } + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("Probe"); + IndexDef index = table.Definition.Indexes.Single(i => i.Name == "IX_Probe"); + ColumnDef keyColumn = table.Definition.FindColumn("K")!; + var rows = table.Rows().WithIds().ToDictionary(r => r.Id, r => r.Values); + + var keys = new Dictionary(); + foreach ((byte[] stored, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + if (rows.TryGetValue(rowId, out object?[]? values) && values[keyColumn.Index] is string text) + keys[text] = Convert.ToHexString(stored); + return keys; + } + + private static void Exec(System.Data.OleDb.OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} From 14ece02c207f37094fdfecb9cba75b601a642222 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 21:05:03 +0800 Subject: [PATCH 21/48] LibRed: the multiple-record append query, INSERT INTO ... SELECT Access has two append forms. The single-record one takes VALUES and was already supported; the multiple-record one takes a query and was not, which is the only way to append more than one row in a statement - Jet has no multi-row VALUES syntax at all, so "many rows" and "from a query" are the same feature there. INSERT INTO target [(field, ...)] SELECT [source.]field, ... FROM tableexpression The IN externaldatabase clause both forms allow is deliberately left out: appending into another file belongs to the linked-database subsystem LibRed does not have, and a half-implementation would be worse than none. Two behaviours were measured against ACE rather than reasoned, and one of them caught a bug that all nine of my own tests had agreed with: WITHOUT a column list, ACE resolves the source's output NAMES against the target - not positionally, which is what this first implemented from the plausible premise that only the count matters. The case that separates them is reversed aliases: SELECT B AS Name, A AS Id emits ('seven', 7) in that order, and ACE stores Id=7. Positionally it would have stored 'seven' in Id. ACE also rejects a name the target lacks, SELECT * included, and LibRed now gives the same error. WITH a column list the other rule applies: the list names the targets and values map positionally onto it, whatever the source calls them. Appending a table to ITSELF terminates. The source is materialised before a row is written, or the scan consumes its own output forever; ACE doubles the table and stops, and so does this. The failure mode of getting the first one wrong is the silent kind - into type-compatible columns it succeeds and puts the values in the wrong fields - which is the argument for cross-checking rather than trusting green tests that encode the same assumption as the code. No cross-check exists for column DEFAULTs on the ACE side: its DDL rejects DEFAULT in CREATE TABLE ("Syntax error in field definition"), a column property Access sets through DAO/ADOX instead. That is a limit on what can be compared, not a place the engines differ, and it is recorded in the test file. Co-Authored-By: Claude Opus 5 --- .../Execution/StatementExecutor.cs | 45 +- .../LibRed.Engine/Planning/QueryPlanner.cs | 4 +- src/LibRed/LibRed.Sql/Ast/Statements.cs | 22 +- src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 | 16 +- .../Grammar/Generated/AccessSqlParser.cs | 1220 +++++++++-------- src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs | 8 + .../InsertSelectAccessTests.cs | 120 ++ .../InsertSelectShapeProbeTest.cs | 78 ++ test/LibRed.Engine.Tests/InsertSelectTests.cs | 195 +++ 9 files changed, 1098 insertions(+), 610 deletions(-) create mode 100644 test/LibRed.Engine.AccessTests/InsertSelectAccessTests.cs create mode 100644 test/LibRed.Engine.AccessTests/InsertSelectShapeProbeTest.cs create mode 100644 test/LibRed.Engine.Tests/InsertSelectTests.cs diff --git a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs index b30b40f6..d83430e9 100644 --- a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs @@ -649,11 +649,14 @@ private int ExecuteInsert(InsertStatement statement) int affected = 0; object? lastIdentity = null; - foreach (IReadOnlyList rowExprs in statement.Rows) + + // One row, given the values already in target order — the two append forms differ only in where + // those come from: evaluated VALUES expressions, or a row of the source query's output. + void InsertRow(ReadOnlySpan supplied) { - if (rowExprs.Count != targets.Count) + if (supplied.Length != targets.Count) throw new InvalidOperationException( - $"INSERT has {rowExprs.Count} values but {targets.Count} target columns."); + $"INSERT has {supplied.Length} values but {targets.Count} target columns."); var values = new object?[columns.Count]; var provided = new HashSet(); @@ -661,7 +664,7 @@ private int ExecuteInsert(InsertStatement statement) { ColumnDef column = table.Definition.FindColumn(targets[i]) ?? throw new InvalidOperationException($"Column '{targets[i]}' does not exist in '{statement.Table}'."); - values[column.Index] = evaluator.Evaluate(rowExprs[i]); + values[column.Index] = supplied[i]; provided.Add(column.Index); } @@ -679,6 +682,40 @@ private int ExecuteInsert(InsertStatement statement) affected++; } + if (statement.Source is not null) + { + // The multiple-record form. The source is MATERIALISED before a single row is written: appending + // a table to itself otherwise feeds its own output back into the scan and never terminates. + // Access's INSERT INTO t SELECT * FROM t doubles the table and stops, so the read completes + // before the write begins. + ResultSet source = _scalarRunner.ExecuteQuery( + Planning.IndexSelection.Apply(Planning.QueryPlanner.PlanStatement(statement.Source), _database.Catalog)); + var rows = source.Rows.ToList(); + + // With no column list the source's output NAMES choose the target columns — ACE resolves by name, + // not by position. Measured, because the two only disagree when they disagree silently: + // INSERT INTO PDst SELECT B AS Name, A AS Id FROM PSrc + // stores Id=7, Name='seven' — the values routed by their aliases, not by the order they appear + // in. Positionally that would have put 'seven' in Id. ACE rejects a name the target lacks + // ("unknown field name: 'A'"), including through SELECT *, and FindColumn below does the same. + // + // An explicit column list is the other rule entirely: it names the targets and the source's + // values map positionally onto IT, whatever the source calls them. + if (statement.Columns.Count == 0) + targets = source.ColumnNames; + + foreach (object?[] row in rows) InsertRow(row); + } + else + { + foreach (IReadOnlyList rowExprs in statement.Rows) + { + var supplied = new object?[rowExprs.Count]; + for (int i = 0; i < rowExprs.Count; i++) supplied[i] = evaluator.Evaluate(rowExprs[i]); + InsertRow(supplied); + } + } + // Publish @@ROWCOUNT (rows this insert affected) and @@IDENTITY (the last AutoNumber generated) so a // following SELECT in the same batch can read the store-generated key back — the shape EF Core emits. // @@IDENTITY is connection-scoped and only overwritten by an insert that actually generates an id, diff --git a/src/LibRed/LibRed.Engine/Planning/QueryPlanner.cs b/src/LibRed/LibRed.Engine/Planning/QueryPlanner.cs index 65d3ec65..1d1b95fe 100644 --- a/src/LibRed/LibRed.Engine/Planning/QueryPlanner.cs +++ b/src/LibRed/LibRed.Engine/Planning/QueryPlanner.cs @@ -15,7 +15,9 @@ public PlanNode Plan(BoundStatement bound) return PlanStatement(bound.Statement); } - private static PlanNode PlanStatement(SqlStatement statement) => statement switch + /// Plans a SELECT or a set operation over SELECTs. Public because an append query's source is + /// either — INSERT INTO t SELECT …, or a UNION feeding one. + public static PlanNode PlanStatement(SqlStatement statement) => statement switch { SelectStatement select => PlanSelect(select), SetOperationStatement set => new SetOperationNode( diff --git a/src/LibRed/LibRed.Sql/Ast/Statements.cs b/src/LibRed/LibRed.Sql/Ast/Statements.cs index 081ec501..9b82ecb5 100644 --- a/src/LibRed/LibRed.Sql/Ast/Statements.cs +++ b/src/LibRed/LibRed.Sql/Ast/Statements.cs @@ -55,14 +55,28 @@ public sealed record SetOperationStatement( SetOperator Operator, SqlStatement Right) : SqlStatement; -/// An INSERT. is the DEFAULT VALUES form (no column or -/// value list): a single row where every column takes its default / AutoNumber; -/// is empty and holds one empty row. +/// +/// An INSERT — Access's two append-query forms. +/// +/// +/// The single-record form's values, INSERT INTO t (…) VALUES (…). Empty when +/// is set. +/// +/// +/// The multiple-record form's query, INSERT INTO t (…) SELECT … FROM …. Null for the +/// single-record form. Access documents the source as a SELECT; a full query expression is accepted here so +/// a UNION can feed an append, which is the shape EF emits from a Concat. +/// +/// +/// The DEFAULT VALUES form (no column or value list): a single row where every column takes its +/// default / AutoNumber. is empty and holds one empty row. +/// public sealed record InsertStatement( string Table, IReadOnlyList Columns, IReadOnlyList> Rows, - bool DefaultValues = false) : SqlStatement; + bool DefaultValues = false, + SqlStatement? Source = null) : SqlStatement; /// A column in a CREATE TABLE: its declared SQL type, optional size/scale, constraints, and the /// raw text of an optional DEFAULT value expression (stored as the column's DefaultValue property). diff --git a/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 b/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 index 4c5d4fe3..a21a4a74 100644 --- a/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 +++ b/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 @@ -184,12 +184,22 @@ referentialAction | SET DEFAULT # SetDefaultAction ; -// INSERT … VALUES (…), or `INSERT INTO t DEFAULT VALUES` — the latter (EF Core emits it for an all-store- -// -generated/all-default row) inserts one row taking every column's default / AutoNumber. +// Access's two append-query forms: +// single-record INSERT INTO target [(field, …)] VALUES (value, …) +// multiple-record INSERT INTO target [(field, …)] SELECT [source.]field, … FROM tableexpression +// plus `INSERT INTO t DEFAULT VALUES` — EF Core emits that for an all-store-generated/all-default row, and +// it inserts one row taking every column's default / AutoNumber. +// +// The IN externaldatabase clause both forms allow is deliberately absent: appending into another file is +// part of the linked/external-database subsystem, which LibRed neither reads nor writes. +// +// The multiple-record source is a queryExpression rather than a bare selectStatement, so a UNION can feed an +// append — the shape EF emits from a Concat — which is a superset of what Access documents. insertStatement : INSERT INTO table=identifier ( (LPAREN columns+=identifier (COMMA columns+=identifier)* RPAREN)? - VALUES LPAREN expression (COMMA expression)* RPAREN + ( VALUES LPAREN expression (COMMA expression)* RPAREN + | source=queryExpression ) | DEFAULT VALUES ) ; diff --git a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs index 9c9b6ba0..752394fe 100644 --- a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs +++ b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs @@ -3868,6 +3868,7 @@ public partial class InsertStatementContext : ParserRuleContext { public IdentifierContext table; public IdentifierContext _identifier; public IList _columns = new List(); + public QueryExpressionContext source; [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode INSERT() { return GetToken(AccessSqlParser.INSERT, 0); } [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode INTO() { return GetToken(AccessSqlParser.INTO, 0); } [System.Diagnostics.DebuggerNonUserCode] public IdentifierContext[] identifier() { @@ -3876,6 +3877,7 @@ [System.Diagnostics.DebuggerNonUserCode] public IdentifierContext[] identifier() [System.Diagnostics.DebuggerNonUserCode] public IdentifierContext identifier(int i) { return GetRuleContext(i); } + [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode DEFAULT() { return GetToken(AccessSqlParser.DEFAULT, 0); } [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode VALUES() { return GetToken(AccessSqlParser.VALUES, 0); } [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode[] LPAREN() { return GetTokens(AccessSqlParser.LPAREN); } [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode LPAREN(int i) { @@ -3891,7 +3893,9 @@ [System.Diagnostics.DebuggerNonUserCode] public ExpressionContext expression(int [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode RPAREN(int i) { return GetToken(AccessSqlParser.RPAREN, i); } - [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode DEFAULT() { return GetToken(AccessSqlParser.DEFAULT, 0); } + [System.Diagnostics.DebuggerNonUserCode] public QueryExpressionContext queryExpression() { + return GetRuleContext(0); + } [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode[] COMMA() { return GetTokens(AccessSqlParser.COMMA); } [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode COMMA(int i) { return GetToken(AccessSqlParser.COMMA, i); @@ -3923,16 +3927,17 @@ public InsertStatementContext insertStatement() { Match(INTO); State = 636; _localctx.table = identifier(); - State = 664; + State = 667; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { + case SELECT: case VALUES: case LPAREN: { State = 648; ErrorHandler.Sync(this); - _la = TokenStream.LA(1); - if (_la==LPAREN) { + switch ( Interpreter.AdaptivePredict(TokenStream,69,Context) ) { + case 1: { State = 637; Match(LPAREN); @@ -3959,39 +3964,56 @@ public InsertStatementContext insertStatement() { State = 646; Match(RPAREN); } + break; } - - State = 650; - Match(VALUES); - State = 651; - Match(LPAREN); - State = 652; - expression(0); - State = 657; + State = 663; ErrorHandler.Sync(this); - _la = TokenStream.LA(1); - while (_la==COMMA) { - { + switch (TokenStream.LA(1)) { + case VALUES: { - State = 653; - Match(COMMA); - State = 654; + State = 650; + Match(VALUES); + State = 651; + Match(LPAREN); + State = 652; expression(0); - } - } - State = 659; + State = 657; ErrorHandler.Sync(this); _la = TokenStream.LA(1); + while (_la==COMMA) { + { + { + State = 653; + Match(COMMA); + State = 654; + expression(0); + } + } + State = 659; + ErrorHandler.Sync(this); + _la = TokenStream.LA(1); + } + State = 660; + Match(RPAREN); + } + break; + case SELECT: + case LPAREN: + { + State = 662; + _localctx.source = queryExpression(); + } + break; + default: + throw new NoViableAltException(this); } - State = 660; - Match(RPAREN); } break; case DEFAULT: { - State = 662; + State = 665; Match(DEFAULT); - State = 663; + State = 666; Match(VALUES); } break; @@ -4045,21 +4067,21 @@ public QueryExpressionContext queryExpression() { try { EnterOuterAlt(_localctx, 1); { - State = 666; + State = 669; queryTerm(); - State = 672; + State = 675; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 223338299392L) != 0)) { { { - State = 667; + State = 670; setOperator(); - State = 668; + State = 671; queryTerm(); } } - State = 674; + State = 677; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -4120,14 +4142,14 @@ public QueryTermContext queryTerm() { QueryTermContext _localctx = new QueryTermContext(Context, State); EnterRule(_localctx, 66, RULE_queryTerm); try { - State = 680; + State = 683; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case SELECT: _localctx = new SelectTermContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 675; + State = 678; selectStatement(); } break; @@ -4135,11 +4157,11 @@ public QueryTermContext queryTerm() { _localctx = new ParenTermContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 676; + State = 679; Match(LPAREN); - State = 677; + State = 680; queryExpression(); - State = 678; + State = 681; Match(RPAREN); } break; @@ -4182,20 +4204,20 @@ public SetOperatorContext setOperator() { EnterRule(_localctx, 68, RULE_setOperator); int _la; try { - State = 688; + State = 691; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case UNION: EnterOuterAlt(_localctx, 1); { - State = 682; + State = 685; Match(UNION); - State = 684; + State = 687; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ALL) { { - State = 683; + State = 686; Match(ALL); } } @@ -4205,14 +4227,14 @@ public SetOperatorContext setOperator() { case INTERSECT: EnterOuterAlt(_localctx, 2); { - State = 686; + State = 689; Match(INTERSECT); } break; case EXCEPT: EnterOuterAlt(_localctx, 3); { - State = 687; + State = 690; Match(EXCEPT); } break; @@ -4279,76 +4301,76 @@ public SelectStatementContext selectStatement() { try { EnterOuterAlt(_localctx, 1); { - State = 690; + State = 693; Match(SELECT); - State = 692; + State = 695; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 37580963840L) != 0)) { { - State = 691; + State = 694; _localctx.predicate = selectPredicate(); } } - State = 695; + State = 698; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TOP) { { - State = 694; + State = 697; topClause(); } } - State = 697; + State = 700; selectList(); - State = 699; + State = 702; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==FROM) { { - State = 698; + State = 701; fromClause(); } } - State = 702; + State = 705; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==WHERE) { { - State = 701; + State = 704; whereClause(); } } - State = 705; + State = 708; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==GROUP) { { - State = 704; + State = 707; groupByClause(); } } - State = 708; + State = 711; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==HAVING) { { - State = 707; + State = 710; havingClause(); } } - State = 711; + State = 714; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ORDER) { { - State = 710; + State = 713; orderByClause(); } } @@ -4391,7 +4413,7 @@ public SelectPredicateContext selectPredicate() { try { EnterOuterAlt(_localctx, 1); { - State = 713; + State = 716; _la = TokenStream.LA(1); if ( !((((_la) & ~0x3f) == 0 && ((1L << _la) & 37580963840L) != 0)) ) { ErrorHandler.RecoverInline(this); @@ -4447,25 +4469,25 @@ public GroupByClauseContext groupByClause() { try { EnterOuterAlt(_localctx, 1); { - State = 715; + State = 718; Match(GROUP); - State = 716; + State = 719; Match(BY); - State = 717; + State = 720; expression(0); - State = 722; + State = 725; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 718; + State = 721; Match(COMMA); - State = 719; + State = 722; expression(0); } } - State = 724; + State = 727; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -4507,9 +4529,9 @@ public HavingClauseContext havingClause() { try { EnterOuterAlt(_localctx, 1); { - State = 725; + State = 728; Match(HAVING); - State = 726; + State = 729; expression(0); } } @@ -4564,18 +4586,18 @@ public TopClauseContext topClause() { int _alt; EnterOuterAlt(_localctx, 1); { - State = 728; + State = 731; Match(TOP); - State = 729; + State = 732; topOperand(); - State = 734; + State = 737; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,84,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,85,Context); while ( _alt!=2 && _alt!=global::Antlr4.Runtime.Atn.ATN.INVALID_ALT_NUMBER ) { if ( _alt==1 ) { { { - State = 730; + State = 733; _la = TokenStream.LA(1); if ( !(_la==PLUS || _la==MINUS) ) { ErrorHandler.RecoverInline(this); @@ -4584,21 +4606,21 @@ public TopClauseContext topClause() { ErrorHandler.ReportMatch(this); Consume(); } - State = 731; + State = 734; topOperand(); } } } - State = 736; + State = 739; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,84,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,85,Context); } - State = 738; + State = 741; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==PERCENT) { { - State = 737; + State = 740; _localctx.percent = Match(PERCENT); } } @@ -4642,31 +4664,31 @@ public TopOperandContext topOperand() { TopOperandContext _localctx = new TopOperandContext(Context, State); EnterRule(_localctx, 80, RULE_topOperand); try { - State = 746; + State = 749; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case INTEGER_LITERAL: EnterOuterAlt(_localctx, 1); { - State = 740; + State = 743; Match(INTEGER_LITERAL); } break; case PARAM: EnterOuterAlt(_localctx, 2); { - State = 741; + State = 744; Match(PARAM); } break; case LPAREN: EnterOuterAlt(_localctx, 3); { - State = 742; + State = 745; Match(LPAREN); - State = 743; + State = 746; expression(0); - State = 744; + State = 747; Match(RPAREN); } break; @@ -4716,13 +4738,13 @@ public SelectListContext selectList() { EnterRule(_localctx, 82, RULE_selectList); int _la; try { - State = 757; + State = 760; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case STAR: EnterOuterAlt(_localctx, 1); { - State = 748; + State = 751; Match(STAR); } break; @@ -4750,21 +4772,21 @@ public SelectListContext selectList() { case IDENTIFIER: EnterOuterAlt(_localctx, 2); { - State = 749; + State = 752; selectItem(); - State = 754; + State = 757; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 750; + State = 753; Match(COMMA); - State = 751; + State = 754; selectItem(); } } - State = 756; + State = 759; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -4836,18 +4858,18 @@ public SelectItemContext selectItem() { EnterRule(_localctx, 84, RULE_selectItem); int _la; try { - State = 770; + State = 773; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,91,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,92,Context) ) { case 1: _localctx = new QualifiedStarSelectItemContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 759; + State = 762; ((QualifiedStarSelectItemContext)_localctx).qualifier = identifier(); - State = 760; + State = 763; Match(DOT); - State = 761; + State = 764; Match(STAR); } break; @@ -4855,24 +4877,24 @@ public SelectItemContext selectItem() { _localctx = new ExpressionSelectItemContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 763; + State = 766; expression(0); - State = 768; + State = 771; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS || ((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) { { - State = 765; + State = 768; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS) { { - State = 764; + State = 767; Match(AS); } } - State = 767; + State = 770; ((ExpressionSelectItemContext)_localctx).alias = identifier(); } } @@ -4925,23 +4947,23 @@ public FromClauseContext fromClause() { try { EnterOuterAlt(_localctx, 1); { - State = 772; + State = 775; Match(FROM); - State = 773; + State = 776; tableSource(); - State = 778; + State = 781; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 774; + State = 777; Match(COMMA); - State = 775; + State = 778; tableSource(); } } - State = 780; + State = 783; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -4989,19 +5011,19 @@ public TableSourceContext tableSource() { try { EnterOuterAlt(_localctx, 1); { - State = 781; + State = 784; tablePrimary(); - State = 785; + State = 788; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 753664L) != 0)) { { { - State = 782; + State = 785; joinClause(); } } - State = 787; + State = 790; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -5088,31 +5110,31 @@ public TablePrimaryContext tablePrimary() { EnterRule(_localctx, 90, RULE_tablePrimary); int _la; try { - State = 808; + State = 811; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,98,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,99,Context) ) { case 1: _localctx = new NamedTablePrimaryContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 788; + State = 791; ((NamedTablePrimaryContext)_localctx).table = identifier(); - State = 793; + State = 796; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS || ((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) { { - State = 790; + State = 793; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS) { { - State = 789; + State = 792; Match(AS); } } - State = 792; + State = 795; ((NamedTablePrimaryContext)_localctx).alias = identifier(); } } @@ -5123,28 +5145,28 @@ public TablePrimaryContext tablePrimary() { _localctx = new SubqueryPrimaryContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 795; + State = 798; Match(LPAREN); - State = 796; + State = 799; queryExpression(); - State = 797; + State = 800; Match(RPAREN); - State = 802; + State = 805; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS || ((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) { { - State = 799; + State = 802; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS) { { - State = 798; + State = 801; Match(AS); } } - State = 801; + State = 804; ((SubqueryPrimaryContext)_localctx).alias = identifier(); } } @@ -5155,11 +5177,11 @@ public TablePrimaryContext tablePrimary() { _localctx = new ParenJoinPrimaryContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 804; + State = 807; Match(LPAREN); - State = 805; + State = 808; tableSource(); - State = 806; + State = 809; Match(RPAREN); } break; @@ -5208,15 +5230,15 @@ public JoinClauseContext joinClause() { try { EnterOuterAlt(_localctx, 1); { - State = 810; + State = 813; joinType(); - State = 811; + State = 814; Match(JOIN); - State = 812; + State = 815; tablePrimary(); - State = 813; + State = 816; Match(ON); - State = 814; + State = 817; expression(0); } } @@ -5282,7 +5304,7 @@ public JoinTypeContext joinType() { EnterRule(_localctx, 94, RULE_joinType); int _la; try { - State = 827; + State = 830; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case INNER: @@ -5290,12 +5312,12 @@ public JoinTypeContext joinType() { _localctx = new InnerJoinContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 817; + State = 820; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==INNER) { { - State = 816; + State = 819; Match(INNER); } } @@ -5306,14 +5328,14 @@ public JoinTypeContext joinType() { _localctx = new LeftJoinContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 819; + State = 822; Match(LEFT); - State = 821; + State = 824; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==OUTER) { { - State = 820; + State = 823; Match(OUTER); } } @@ -5324,14 +5346,14 @@ public JoinTypeContext joinType() { _localctx = new RightJoinContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 823; + State = 826; Match(RIGHT); - State = 825; + State = 828; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==OUTER) { { - State = 824; + State = 827; Match(OUTER); } } @@ -5378,9 +5400,9 @@ public WhereClauseContext whereClause() { try { EnterOuterAlt(_localctx, 1); { - State = 829; + State = 832; Match(WHERE); - State = 830; + State = 833; expression(0); } } @@ -5429,25 +5451,25 @@ public OrderByClauseContext orderByClause() { try { EnterOuterAlt(_localctx, 1); { - State = 832; + State = 835; Match(ORDER); - State = 833; + State = 836; Match(BY); - State = 834; + State = 837; orderByItem(); - State = 839; + State = 842; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 835; + State = 838; Match(COMMA); - State = 836; + State = 839; orderByItem(); } } - State = 841; + State = 844; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -5492,14 +5514,14 @@ public OrderByItemContext orderByItem() { try { EnterOuterAlt(_localctx, 1); { - State = 842; + State = 845; expression(0); - State = 844; + State = 847; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ASC || _la==DESC) { { - State = 843; + State = 846; _localctx.dir = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==ASC || _la==DESC) ) { @@ -5856,7 +5878,7 @@ private ExpressionContext expression(int _p) { int _alt; EnterOuterAlt(_localctx, 1); { - State = 854; + State = 857; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case NOT: @@ -5865,9 +5887,9 @@ private ExpressionContext expression(int _p) { Context = _localctx; _prevctx = _localctx; - State = 847; + State = 850; Match(NOT); - State = 848; + State = 851; expression(16); } break; @@ -5876,9 +5898,9 @@ private ExpressionContext expression(int _p) { _localctx = new BitNotExprContext(_localctx); Context = _localctx; _prevctx = _localctx; - State = 849; + State = 852; Match(BNOT); - State = 850; + State = 853; expression(15); } break; @@ -5887,9 +5909,9 @@ private ExpressionContext expression(int _p) { _localctx = new NegateExprContext(_localctx); Context = _localctx; _prevctx = _localctx; - State = 851; + State = 854; Match(MINUS); - State = 852; + State = 855; expression(14); } break; @@ -5916,7 +5938,7 @@ private ExpressionContext expression(int _p) { _localctx = new PrimaryExprContext(_localctx); Context = _localctx; _prevctx = _localctx; - State = 853; + State = 856; primary(); } break; @@ -5924,28 +5946,28 @@ private ExpressionContext expression(int _p) { throw new NoViableAltException(this); } Context.Stop = TokenStream.LT(-1); - State = 925; + State = 928; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,113,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,114,Context); while ( _alt!=2 && _alt!=global::Antlr4.Runtime.Atn.ATN.INVALID_ALT_NUMBER ) { if ( _alt==1 ) { if ( ParseListeners!=null ) TriggerExitRuleEvent(); _prevctx = _localctx; { - State = 923; + State = 926; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,112,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,113,Context) ) { case 1: { _localctx = new PowExprContext(new ExpressionContext(_parentctx, _parentState)); ((PowExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 856; + State = 859; if (!(Precpred(Context, 13))) throw new FailedPredicateException(this, "Precpred(Context, 13)"); - State = 857; + State = 860; Match(CARET); - State = 858; + State = 861; ((PowExprContext)_localctx).right = expression(14); } break; @@ -5954,9 +5976,9 @@ private ExpressionContext expression(int _p) { _localctx = new MulDivExprContext(new ExpressionContext(_parentctx, _parentState)); ((MulDivExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 859; + State = 862; if (!(Precpred(Context, 12))) throw new FailedPredicateException(this, "Precpred(Context, 12)"); - State = 860; + State = 863; ((MulDivExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==MOD || ((((_la - 86)) & ~0x3f) == 0 && ((1L << (_la - 86)) & 7L) != 0)) ) { @@ -5966,7 +5988,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 861; + State = 864; ((MulDivExprContext)_localctx).right = expression(13); } break; @@ -5975,9 +5997,9 @@ private ExpressionContext expression(int _p) { _localctx = new AddConcatExprContext(new ExpressionContext(_parentctx, _parentState)); ((AddConcatExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 862; + State = 865; if (!(Precpred(Context, 11))) throw new FailedPredicateException(this, "Precpred(Context, 11)"); - State = 863; + State = 866; ((AddConcatExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(((((_la - 90)) & ~0x3f) == 0 && ((1L << (_la - 90)) & 7L) != 0)) ) { @@ -5987,7 +6009,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 864; + State = 867; ((AddConcatExprContext)_localctx).right = expression(12); } break; @@ -5996,9 +6018,9 @@ private ExpressionContext expression(int _p) { _localctx = new ComparisonExprContext(new ExpressionContext(_parentctx, _parentState)); ((ComparisonExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 865; + State = 868; if (!(Precpred(Context, 10))) throw new FailedPredicateException(this, "Precpred(Context, 10)"); - State = 866; + State = 869; ((ComparisonExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(((((_la - 93)) & ~0x3f) == 0 && ((1L << (_la - 93)) & 63L) != 0)) ) { @@ -6008,7 +6030,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 867; + State = 870; ((ComparisonExprContext)_localctx).right = expression(11); } break; @@ -6017,25 +6039,25 @@ private ExpressionContext expression(int _p) { _localctx = new BetweenExprContext(new ExpressionContext(_parentctx, _parentState)); ((BetweenExprContext)_localctx).val = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 868; + State = 871; if (!(Precpred(Context, 9))) throw new FailedPredicateException(this, "Precpred(Context, 9)"); - State = 870; + State = 873; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 869; + State = 872; ((BetweenExprContext)_localctx).not = Match(NOT); } } - State = 872; + State = 875; Match(BETWEEN); - State = 873; + State = 876; ((BetweenExprContext)_localctx).lo = expression(0); - State = 874; + State = 877; Match(AND); - State = 875; + State = 878; ((BetweenExprContext)_localctx).hi = expression(10); } break; @@ -6044,21 +6066,21 @@ private ExpressionContext expression(int _p) { _localctx = new LikeExprContext(new ExpressionContext(_parentctx, _parentState)); ((LikeExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 877; + State = 880; if (!(Precpred(Context, 8))) throw new FailedPredicateException(this, "Precpred(Context, 8)"); - State = 879; + State = 882; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 878; + State = 881; ((LikeExprContext)_localctx).not = Match(NOT); } } - State = 881; + State = 884; Match(LIKE); - State = 882; + State = 885; ((LikeExprContext)_localctx).right = expression(9); } break; @@ -6067,9 +6089,9 @@ private ExpressionContext expression(int _p) { _localctx = new BitwiseExprContext(new ExpressionContext(_parentctx, _parentState)); ((BitwiseExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 883; + State = 886; if (!(Precpred(Context, 4))) throw new FailedPredicateException(this, "Precpred(Context, 4)"); - State = 884; + State = 887; ((BitwiseExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !((((_la) & ~0x3f) == 0 && ((1L << _la) & 3584L) != 0)) ) { @@ -6079,7 +6101,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 885; + State = 888; ((BitwiseExprContext)_localctx).right = expression(5); } break; @@ -6088,11 +6110,11 @@ private ExpressionContext expression(int _p) { _localctx = new AndExprContext(new ExpressionContext(_parentctx, _parentState)); ((AndExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 886; + State = 889; if (!(Precpred(Context, 3))) throw new FailedPredicateException(this, "Precpred(Context, 3)"); - State = 887; + State = 890; Match(AND); - State = 888; + State = 891; ((AndExprContext)_localctx).right = expression(4); } break; @@ -6101,11 +6123,11 @@ private ExpressionContext expression(int _p) { _localctx = new OrExprContext(new ExpressionContext(_parentctx, _parentState)); ((OrExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 889; + State = 892; if (!(Precpred(Context, 2))) throw new FailedPredicateException(this, "Precpred(Context, 2)"); - State = 890; + State = 893; Match(OR); - State = 891; + State = 894; ((OrExprContext)_localctx).right = expression(3); } break; @@ -6114,25 +6136,25 @@ private ExpressionContext expression(int _p) { _localctx = new InSubqueryExprContext(new ExpressionContext(_parentctx, _parentState)); ((InSubqueryExprContext)_localctx).val = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 892; + State = 895; if (!(Precpred(Context, 7))) throw new FailedPredicateException(this, "Precpred(Context, 7)"); - State = 894; + State = 897; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 893; + State = 896; ((InSubqueryExprContext)_localctx).not = Match(NOT); } } - State = 896; + State = 899; Match(IN); - State = 897; + State = 900; Match(LPAREN); - State = 898; + State = 901; ((InSubqueryExprContext)_localctx).sub = selectStatement(); - State = 899; + State = 902; Match(RPAREN); } break; @@ -6141,43 +6163,43 @@ private ExpressionContext expression(int _p) { _localctx = new InExprContext(new ExpressionContext(_parentctx, _parentState)); ((InExprContext)_localctx).val = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 901; + State = 904; if (!(Precpred(Context, 6))) throw new FailedPredicateException(this, "Precpred(Context, 6)"); - State = 903; + State = 906; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 902; + State = 905; ((InExprContext)_localctx).not = Match(NOT); } } - State = 905; + State = 908; Match(IN); - State = 906; + State = 909; Match(LPAREN); - State = 907; + State = 910; ((InExprContext)_localctx)._expression = expression(0); ((InExprContext)_localctx)._items.Add(((InExprContext)_localctx)._expression); - State = 912; + State = 915; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 908; + State = 911; Match(COMMA); - State = 909; + State = 912; ((InExprContext)_localctx)._expression = expression(0); ((InExprContext)_localctx)._items.Add(((InExprContext)_localctx)._expression); } } - State = 914; + State = 917; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 915; + State = 918; Match(RPAREN); } break; @@ -6186,30 +6208,30 @@ private ExpressionContext expression(int _p) { _localctx = new IsNullExprContext(new ExpressionContext(_parentctx, _parentState)); ((IsNullExprContext)_localctx).operand = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 917; + State = 920; if (!(Precpred(Context, 5))) throw new FailedPredicateException(this, "Precpred(Context, 5)"); - State = 918; + State = 921; Match(IS); - State = 920; + State = 923; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 919; + State = 922; ((IsNullExprContext)_localctx).not = Match(NOT); } } - State = 922; + State = 925; Match(NULL); } break; } } } - State = 927; + State = 930; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,113,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,114,Context); } } } @@ -6341,14 +6363,14 @@ public PrimaryContext primary() { PrimaryContext _localctx = new PrimaryContext(Context, State); EnterRule(_localctx, 104, RULE_primary); try { - State = 946; + State = 949; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,114,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,115,Context) ) { case 1: _localctx = new LiteralPrimaryContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 928; + State = 931; literal(); } break; @@ -6356,7 +6378,7 @@ public PrimaryContext primary() { _localctx = new FunctionCallPrimaryContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 929; + State = 932; functionCall(); } break; @@ -6364,7 +6386,7 @@ public PrimaryContext primary() { _localctx = new ColumnPrimaryContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 930; + State = 933; columnRef(); } break; @@ -6372,7 +6394,7 @@ public PrimaryContext primary() { _localctx = new ParamPrimaryContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 931; + State = 934; Match(PARAM); } break; @@ -6380,7 +6402,7 @@ public PrimaryContext primary() { _localctx = new SystemVariablePrimaryContext(_localctx); EnterOuterAlt(_localctx, 5); { - State = 932; + State = 935; Match(SYSVAR); } break; @@ -6388,13 +6410,13 @@ public PrimaryContext primary() { _localctx = new ExistsPrimaryContext(_localctx); EnterOuterAlt(_localctx, 6); { - State = 933; + State = 936; Match(EXISTS); - State = 934; + State = 937; Match(LPAREN); - State = 935; + State = 938; selectStatement(); - State = 936; + State = 939; Match(RPAREN); } break; @@ -6402,11 +6424,11 @@ public PrimaryContext primary() { _localctx = new ScalarSubqueryPrimaryContext(_localctx); EnterOuterAlt(_localctx, 7); { - State = 938; + State = 941; Match(LPAREN); - State = 939; + State = 942; selectStatement(); - State = 940; + State = 943; Match(RPAREN); } break; @@ -6414,11 +6436,11 @@ public PrimaryContext primary() { _localctx = new ParenPrimaryContext(_localctx); EnterOuterAlt(_localctx, 8); { - State = 942; + State = 945; Match(LPAREN); - State = 943; + State = 946; expression(0); - State = 944; + State = 947; Match(RPAREN); } break; @@ -6477,16 +6499,16 @@ public FunctionCallContext functionCall() { try { EnterOuterAlt(_localctx, 1); { - State = 948; + State = 951; _localctx.name = functionName(); - State = 949; + State = 952; Match(LPAREN); - State = 962; + State = 965; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case STAR: { - State = 950; + State = 953; _localctx.star = Match(STAR); } break; @@ -6515,31 +6537,31 @@ public FunctionCallContext functionCall() { case IDENTIFIER: { { - State = 952; + State = 955; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==DISTINCT) { { - State = 951; + State = 954; _localctx.distinct = Match(DISTINCT); } } - State = 954; + State = 957; expression(0); - State = 959; + State = 962; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 955; + State = 958; Match(COMMA); - State = 956; + State = 959; expression(0); } } - State = 961; + State = 964; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -6551,7 +6573,7 @@ public FunctionCallContext functionCall() { default: break; } - State = 964; + State = 967; Match(RPAREN); } } @@ -6591,7 +6613,7 @@ public FunctionNameContext functionName() { FunctionNameContext _localctx = new FunctionNameContext(Context, State); EnterRule(_localctx, 108, RULE_functionName); try { - State = 970; + State = 973; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case BRACKET_ID: @@ -6599,28 +6621,28 @@ public FunctionNameContext functionName() { case IDENTIFIER: EnterOuterAlt(_localctx, 1); { - State = 966; + State = 969; identifier(); } break; case LEFT: EnterOuterAlt(_localctx, 2); { - State = 967; + State = 970; Match(LEFT); } break; case RIGHT: EnterOuterAlt(_localctx, 3); { - State = 968; + State = 971; Match(RIGHT); } break; case ASC: EnterOuterAlt(_localctx, 4); { - State = 969; + State = 972; Match(ASC); } break; @@ -6669,19 +6691,19 @@ public ColumnRefContext columnRef() { try { EnterOuterAlt(_localctx, 1); { - State = 975; + State = 978; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,119,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,120,Context) ) { case 1: { - State = 972; + State = 975; _localctx.qualifier = identifier(); - State = 973; + State = 976; Match(DOT); } break; } - State = 977; + State = 980; _localctx.name = identifier(); } } @@ -6721,7 +6743,7 @@ public IdentifierContext identifier() { try { EnterOuterAlt(_localctx, 1); { - State = 979; + State = 982; _la = TokenStream.LA(1); if ( !(((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) ) { ErrorHandler.RecoverInline(this); @@ -6851,14 +6873,14 @@ public LiteralContext literal() { LiteralContext _localctx = new LiteralContext(Context, State); EnterRule(_localctx, 114, RULE_literal); try { - State = 990; + State = 993; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case INTEGER_LITERAL: _localctx = new IntLiteralContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 981; + State = 984; Match(INTEGER_LITERAL); } break; @@ -6866,7 +6888,7 @@ public LiteralContext literal() { _localctx = new NumberLiteralContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 982; + State = 985; Match(NUMBER_LITERAL); } break; @@ -6874,7 +6896,7 @@ public LiteralContext literal() { _localctx = new HexLiteralContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 983; + State = 986; Match(HEX_LITERAL); } break; @@ -6882,7 +6904,7 @@ public LiteralContext literal() { _localctx = new StringLiteralContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 984; + State = 987; Match(STRING_LITERAL); } break; @@ -6890,7 +6912,7 @@ public LiteralContext literal() { _localctx = new DateLiteralContext(_localctx); EnterOuterAlt(_localctx, 5); { - State = 985; + State = 988; Match(DATE_LITERAL); } break; @@ -6898,7 +6920,7 @@ public LiteralContext literal() { _localctx = new GuidLiteralContext(_localctx); EnterOuterAlt(_localctx, 6); { - State = 986; + State = 989; Match(GUID_LITERAL); } break; @@ -6906,7 +6928,7 @@ public LiteralContext literal() { _localctx = new TrueLiteralContext(_localctx); EnterOuterAlt(_localctx, 7); { - State = 987; + State = 990; Match(TRUE); } break; @@ -6914,7 +6936,7 @@ public LiteralContext literal() { _localctx = new FalseLiteralContext(_localctx); EnterOuterAlt(_localctx, 8); { - State = 988; + State = 991; Match(FALSE); } break; @@ -6922,7 +6944,7 @@ public LiteralContext literal() { _localctx = new NullLiteralContext(_localctx); EnterOuterAlt(_localctx, 9); { - State = 989; + State = 992; Match(NULL); } break; @@ -6996,21 +7018,21 @@ public TransactionStatementContext transactionStatement() { EnterRule(_localctx, 116, RULE_transactionStatement); int _la; try { - State = 1004; + State = 1007; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case BEGIN: _localctx = new BeginTransactionStatementContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 992; + State = 995; Match(BEGIN); - State = 994; + State = 997; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TRANSACTION || _la==WORK) { { - State = 993; + State = 996; _la = TokenStream.LA(1); if ( !(_la==TRANSACTION || _la==WORK) ) { ErrorHandler.RecoverInline(this); @@ -7028,14 +7050,14 @@ public TransactionStatementContext transactionStatement() { _localctx = new CommitTransactionStatementContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 996; + State = 999; Match(COMMIT); - State = 998; + State = 1001; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TRANSACTION || _la==WORK) { { - State = 997; + State = 1000; _la = TokenStream.LA(1); if ( !(_la==TRANSACTION || _la==WORK) ) { ErrorHandler.RecoverInline(this); @@ -7053,14 +7075,14 @@ public TransactionStatementContext transactionStatement() { _localctx = new RollbackTransactionStatementContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 1000; + State = 1003; Match(ROLLBACK); - State = 1002; + State = 1005; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TRANSACTION || _la==WORK) { { - State = 1001; + State = 1004; _la = TokenStream.LA(1); if ( !(_la==TRANSACTION || _la==WORK) ) { ErrorHandler.RecoverInline(this); @@ -7114,9 +7136,9 @@ public StandaloneExpressionContext standaloneExpression() { try { EnterOuterAlt(_localctx, 1); { - State = 1006; + State = 1009; expression(0); - State = 1007; + State = 1010; Match(Eof); } } @@ -7156,7 +7178,7 @@ private bool expression_sempred(ExpressionContext _localctx, int predIndex) { } private static int[] _serializedATN = { - 4,1,117,1010,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,117,1013,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, @@ -7206,318 +7228,320 @@ private bool expression_sempred(ExpressionContext _localctx, int predIndex) { 12,28,615,9,28,1,29,1,29,1,29,1,29,1,29,1,29,3,29,623,8,29,1,30,1,30,1, 30,1,30,1,30,1,30,1,30,1,30,3,30,633,8,30,1,31,1,31,1,31,1,31,1,31,1,31, 1,31,5,31,642,8,31,10,31,12,31,645,9,31,1,31,1,31,3,31,649,8,31,1,31,1, - 31,1,31,1,31,1,31,5,31,656,8,31,10,31,12,31,659,9,31,1,31,1,31,1,31,1, - 31,3,31,665,8,31,1,32,1,32,1,32,1,32,5,32,671,8,32,10,32,12,32,674,9,32, - 1,33,1,33,1,33,1,33,1,33,3,33,681,8,33,1,34,1,34,3,34,685,8,34,1,34,1, - 34,3,34,689,8,34,1,35,1,35,3,35,693,8,35,1,35,3,35,696,8,35,1,35,1,35, - 3,35,700,8,35,1,35,3,35,703,8,35,1,35,3,35,706,8,35,1,35,3,35,709,8,35, - 1,35,3,35,712,8,35,1,36,1,36,1,37,1,37,1,37,1,37,1,37,5,37,721,8,37,10, - 37,12,37,724,9,37,1,38,1,38,1,38,1,39,1,39,1,39,1,39,5,39,733,8,39,10, - 39,12,39,736,9,39,1,39,3,39,739,8,39,1,40,1,40,1,40,1,40,1,40,1,40,3,40, - 747,8,40,1,41,1,41,1,41,1,41,5,41,753,8,41,10,41,12,41,756,9,41,3,41,758, - 8,41,1,42,1,42,1,42,1,42,1,42,1,42,3,42,766,8,42,1,42,3,42,769,8,42,3, - 42,771,8,42,1,43,1,43,1,43,1,43,5,43,777,8,43,10,43,12,43,780,9,43,1,44, - 1,44,5,44,784,8,44,10,44,12,44,787,9,44,1,45,1,45,3,45,791,8,45,1,45,3, - 45,794,8,45,1,45,1,45,1,45,1,45,3,45,800,8,45,1,45,3,45,803,8,45,1,45, - 1,45,1,45,1,45,3,45,809,8,45,1,46,1,46,1,46,1,46,1,46,1,46,1,47,3,47,818, - 8,47,1,47,1,47,3,47,822,8,47,1,47,1,47,3,47,826,8,47,3,47,828,8,47,1,48, - 1,48,1,48,1,49,1,49,1,49,1,49,1,49,5,49,838,8,49,10,49,12,49,841,9,49, - 1,50,1,50,3,50,845,8,50,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,3,51,855, - 8,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,871,8,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,3,51,880,8,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,895, - 8,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,3,51,904,8,51,1,51,1,51,1,51,1, - 51,1,51,5,51,911,8,51,10,51,12,51,914,9,51,1,51,1,51,1,51,1,51,1,51,3, - 51,921,8,51,1,51,5,51,924,8,51,10,51,12,51,927,9,51,1,52,1,52,1,52,1,52, - 1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52, - 3,52,947,8,52,1,53,1,53,1,53,1,53,3,53,953,8,53,1,53,1,53,1,53,5,53,958, - 8,53,10,53,12,53,961,9,53,3,53,963,8,53,1,53,1,53,1,54,1,54,1,54,1,54, - 3,54,971,8,54,1,55,1,55,1,55,3,55,976,8,55,1,55,1,55,1,56,1,56,1,57,1, - 57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,3,57,991,8,57,1,58,1,58,3,58,995, - 8,58,1,58,1,58,3,58,999,8,58,1,58,1,58,3,58,1003,8,58,3,58,1005,8,58,1, - 59,1,59,1,59,1,59,0,1,102,60,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, - 0,12,1,0,79,80,1,0,81,82,1,0,71,72,1,0,99,100,2,0,30,31,35,35,1,0,90,91, - 2,0,14,14,86,88,1,0,90,92,1,0,93,98,1,0,9,11,1,0,112,114,1,0,43,44,1152, - 0,121,1,0,0,0,2,144,1,0,0,0,4,166,1,0,0,0,6,168,1,0,0,0,8,180,1,0,0,0, - 10,194,1,0,0,0,12,198,1,0,0,0,14,211,1,0,0,0,16,220,1,0,0,0,18,227,1,0, - 0,0,20,238,1,0,0,0,22,262,1,0,0,0,24,281,1,0,0,0,26,309,1,0,0,0,28,311, - 1,0,0,0,30,316,1,0,0,0,32,318,1,0,0,0,34,380,1,0,0,0,36,385,1,0,0,0,38, - 387,1,0,0,0,40,424,1,0,0,0,42,426,1,0,0,0,44,435,1,0,0,0,46,437,1,0,0, - 0,48,445,1,0,0,0,50,463,1,0,0,0,52,519,1,0,0,0,54,604,1,0,0,0,56,613,1, - 0,0,0,58,622,1,0,0,0,60,632,1,0,0,0,62,634,1,0,0,0,64,666,1,0,0,0,66,680, - 1,0,0,0,68,688,1,0,0,0,70,690,1,0,0,0,72,713,1,0,0,0,74,715,1,0,0,0,76, - 725,1,0,0,0,78,728,1,0,0,0,80,746,1,0,0,0,82,757,1,0,0,0,84,770,1,0,0, - 0,86,772,1,0,0,0,88,781,1,0,0,0,90,808,1,0,0,0,92,810,1,0,0,0,94,827,1, - 0,0,0,96,829,1,0,0,0,98,832,1,0,0,0,100,842,1,0,0,0,102,854,1,0,0,0,104, - 946,1,0,0,0,106,948,1,0,0,0,108,970,1,0,0,0,110,975,1,0,0,0,112,979,1, - 0,0,0,114,990,1,0,0,0,116,1004,1,0,0,0,118,1006,1,0,0,0,120,122,3,18,9, - 0,121,120,1,0,0,0,121,122,1,0,0,0,122,137,1,0,0,0,123,138,3,2,1,0,124, - 138,3,20,10,0,125,138,3,38,19,0,126,138,3,22,11,0,127,138,3,24,12,0,128, - 138,3,32,16,0,129,138,3,40,20,0,130,138,3,62,31,0,131,138,3,8,4,0,132, - 138,3,12,6,0,133,138,3,116,58,0,134,138,3,6,3,0,135,138,3,14,7,0,136,138, - 3,64,32,0,137,123,1,0,0,0,137,124,1,0,0,0,137,125,1,0,0,0,137,126,1,0, - 0,0,137,127,1,0,0,0,137,128,1,0,0,0,137,129,1,0,0,0,137,130,1,0,0,0,137, - 131,1,0,0,0,137,132,1,0,0,0,137,133,1,0,0,0,137,134,1,0,0,0,137,135,1, - 0,0,0,137,136,1,0,0,0,138,140,1,0,0,0,139,141,5,103,0,0,140,139,1,0,0, - 0,140,141,1,0,0,0,141,142,1,0,0,0,142,143,5,0,0,1,143,1,1,0,0,0,144,146, - 5,28,0,0,145,147,5,8,0,0,146,145,1,0,0,0,146,147,1,0,0,0,147,148,1,0,0, - 0,148,149,5,27,0,0,149,150,5,99,0,0,150,151,3,70,35,0,151,152,5,100,0, - 0,152,153,5,29,0,0,153,154,3,4,2,0,154,3,1,0,0,0,155,167,3,20,10,0,156, - 167,3,38,19,0,157,167,3,22,11,0,158,167,3,24,12,0,159,167,3,32,16,0,160, - 167,3,40,20,0,161,167,3,62,31,0,162,167,3,8,4,0,163,167,3,12,6,0,164,167, - 3,6,3,0,165,167,3,64,32,0,166,155,1,0,0,0,166,156,1,0,0,0,166,157,1,0, - 0,0,166,158,1,0,0,0,166,159,1,0,0,0,166,160,1,0,0,0,166,161,1,0,0,0,166, - 162,1,0,0,0,166,163,1,0,0,0,166,164,1,0,0,0,166,165,1,0,0,0,167,5,1,0, - 0,0,168,169,7,0,0,0,169,178,3,112,56,0,170,175,3,102,51,0,171,172,5,101, - 0,0,172,174,3,102,51,0,173,171,1,0,0,0,174,177,1,0,0,0,175,173,1,0,0,0, - 175,176,1,0,0,0,176,179,1,0,0,0,177,175,1,0,0,0,178,170,1,0,0,0,178,179, - 1,0,0,0,179,7,1,0,0,0,180,181,5,60,0,0,181,182,3,88,44,0,182,183,5,64, - 0,0,183,188,3,10,5,0,184,185,5,101,0,0,185,187,3,10,5,0,186,184,1,0,0, - 0,187,190,1,0,0,0,188,186,1,0,0,0,188,189,1,0,0,0,189,192,1,0,0,0,190, - 188,1,0,0,0,191,193,3,96,48,0,192,191,1,0,0,0,192,193,1,0,0,0,193,9,1, - 0,0,0,194,195,3,110,55,0,195,196,5,93,0,0,196,197,3,102,51,0,197,11,1, - 0,0,0,198,204,5,59,0,0,199,200,3,112,56,0,200,201,5,102,0,0,201,202,5, - 86,0,0,202,205,1,0,0,0,203,205,5,86,0,0,204,199,1,0,0,0,204,203,1,0,0, - 0,204,205,1,0,0,0,205,206,1,0,0,0,206,207,5,2,0,0,207,209,3,88,44,0,208, - 210,3,96,48,0,209,208,1,0,0,0,209,210,1,0,0,0,210,13,1,0,0,0,211,212,5, - 1,0,0,212,217,3,16,8,0,213,214,5,101,0,0,214,216,3,16,8,0,215,213,1,0, - 0,0,216,219,1,0,0,0,217,215,1,0,0,0,217,218,1,0,0,0,218,15,1,0,0,0,219, - 217,1,0,0,0,220,225,5,104,0,0,221,223,5,5,0,0,222,221,1,0,0,0,222,223, - 1,0,0,0,223,224,1,0,0,0,224,226,3,112,56,0,225,222,1,0,0,0,225,226,1,0, - 0,0,226,17,1,0,0,0,227,228,5,78,0,0,228,233,3,28,14,0,229,230,5,101,0, - 0,230,232,3,28,14,0,231,229,1,0,0,0,232,235,1,0,0,0,233,231,1,0,0,0,233, - 234,1,0,0,0,234,236,1,0,0,0,235,233,1,0,0,0,236,237,5,103,0,0,237,19,1, - 0,0,0,238,240,5,38,0,0,239,241,5,69,0,0,240,239,1,0,0,0,240,241,1,0,0, - 0,241,242,1,0,0,0,242,243,5,39,0,0,243,244,3,112,56,0,244,245,5,99,0,0, - 245,250,3,46,23,0,246,247,5,101,0,0,247,249,3,46,23,0,248,246,1,0,0,0, - 249,252,1,0,0,0,250,248,1,0,0,0,250,251,1,0,0,0,251,257,1,0,0,0,252,250, - 1,0,0,0,253,254,5,101,0,0,254,256,3,54,27,0,255,253,1,0,0,0,256,259,1, - 0,0,0,257,255,1,0,0,0,257,258,1,0,0,0,258,260,1,0,0,0,259,257,1,0,0,0, - 260,261,5,100,0,0,261,21,1,0,0,0,262,263,5,38,0,0,263,264,5,76,0,0,264, - 276,3,112,56,0,265,266,5,99,0,0,266,271,3,112,56,0,267,268,5,101,0,0,268, - 270,3,112,56,0,269,267,1,0,0,0,270,273,1,0,0,0,271,269,1,0,0,0,271,272, - 1,0,0,0,272,274,1,0,0,0,273,271,1,0,0,0,274,275,5,100,0,0,275,277,1,0, - 0,0,276,265,1,0,0,0,276,277,1,0,0,0,277,278,1,0,0,0,278,279,5,5,0,0,279, - 280,3,64,32,0,280,23,1,0,0,0,281,282,5,38,0,0,282,283,5,77,0,0,283,285, - 3,112,56,0,284,286,3,26,13,0,285,284,1,0,0,0,285,286,1,0,0,0,286,287,1, - 0,0,0,287,288,5,5,0,0,288,289,3,36,18,0,289,25,1,0,0,0,290,291,5,99,0, - 0,291,296,3,28,14,0,292,293,5,101,0,0,293,295,3,28,14,0,294,292,1,0,0, - 0,295,298,1,0,0,0,296,294,1,0,0,0,296,297,1,0,0,0,297,299,1,0,0,0,298, - 296,1,0,0,0,299,300,5,100,0,0,300,310,1,0,0,0,301,306,3,28,14,0,302,303, - 5,101,0,0,303,305,3,28,14,0,304,302,1,0,0,0,305,308,1,0,0,0,306,304,1, - 0,0,0,306,307,1,0,0,0,307,310,1,0,0,0,308,306,1,0,0,0,309,290,1,0,0,0, - 309,301,1,0,0,0,310,27,1,0,0,0,311,312,3,30,15,0,312,313,3,48,24,0,313, - 29,1,0,0,0,314,317,3,112,56,0,315,317,5,105,0,0,316,314,1,0,0,0,316,315, - 1,0,0,0,317,31,1,0,0,0,318,319,5,45,0,0,319,320,5,39,0,0,320,321,3,112, - 56,0,321,322,3,34,17,0,322,33,1,0,0,0,323,325,5,48,0,0,324,326,5,50,0, - 0,325,324,1,0,0,0,325,326,1,0,0,0,326,327,1,0,0,0,327,381,3,46,23,0,328, - 329,5,48,0,0,329,381,3,54,27,0,330,332,5,45,0,0,331,333,5,50,0,0,332,331, - 1,0,0,0,332,333,1,0,0,0,333,334,1,0,0,0,334,335,3,112,56,0,335,339,3,48, - 24,0,336,338,3,52,26,0,337,336,1,0,0,0,338,341,1,0,0,0,339,337,1,0,0,0, - 339,340,1,0,0,0,340,381,1,0,0,0,341,339,1,0,0,0,342,344,5,45,0,0,343,345, - 5,50,0,0,344,343,1,0,0,0,344,345,1,0,0,0,345,346,1,0,0,0,346,347,3,112, - 56,0,347,348,5,64,0,0,348,349,5,65,0,0,349,350,3,102,51,0,350,381,1,0, - 0,0,351,353,5,45,0,0,352,354,5,50,0,0,353,352,1,0,0,0,353,354,1,0,0,0, - 354,355,1,0,0,0,355,356,3,112,56,0,356,357,5,49,0,0,357,358,5,65,0,0,358, - 381,1,0,0,0,359,360,5,49,0,0,360,361,5,50,0,0,361,381,3,112,56,0,362,363, - 5,49,0,0,363,364,5,56,0,0,364,381,3,112,56,0,365,366,5,46,0,0,366,367, - 5,47,0,0,367,381,3,112,56,0,368,369,5,46,0,0,369,370,5,50,0,0,370,371, - 3,112,56,0,371,372,5,47,0,0,372,373,3,112,56,0,373,381,1,0,0,0,374,375, - 5,46,0,0,375,376,5,68,0,0,376,377,3,112,56,0,377,378,5,47,0,0,378,379, - 3,112,56,0,379,381,1,0,0,0,380,323,1,0,0,0,380,328,1,0,0,0,380,330,1,0, - 0,0,380,342,1,0,0,0,380,351,1,0,0,0,380,359,1,0,0,0,380,362,1,0,0,0,380, - 365,1,0,0,0,380,368,1,0,0,0,380,374,1,0,0,0,381,35,1,0,0,0,382,386,3,64, - 32,0,383,386,3,62,31,0,384,386,3,20,10,0,385,382,1,0,0,0,385,383,1,0,0, - 0,385,384,1,0,0,0,386,37,1,0,0,0,387,389,5,38,0,0,388,390,5,67,0,0,389, - 388,1,0,0,0,389,390,1,0,0,0,390,391,1,0,0,0,391,392,5,68,0,0,392,393,3, - 112,56,0,393,394,5,21,0,0,394,395,3,112,56,0,395,396,5,99,0,0,396,401, - 3,42,21,0,397,398,5,101,0,0,398,400,3,42,21,0,399,397,1,0,0,0,400,403, - 1,0,0,0,401,399,1,0,0,0,401,402,1,0,0,0,402,404,1,0,0,0,403,401,1,0,0, - 0,404,407,5,100,0,0,405,406,5,70,0,0,406,408,3,44,22,0,407,405,1,0,0,0, - 407,408,1,0,0,0,408,39,1,0,0,0,409,410,5,49,0,0,410,411,5,39,0,0,411,425, - 3,112,56,0,412,413,5,49,0,0,413,414,5,68,0,0,414,415,3,112,56,0,415,416, - 5,21,0,0,416,417,3,112,56,0,417,425,1,0,0,0,418,419,5,49,0,0,419,420,5, - 77,0,0,420,425,3,112,56,0,421,422,5,49,0,0,422,423,5,76,0,0,423,425,3, - 112,56,0,424,409,1,0,0,0,424,412,1,0,0,0,424,418,1,0,0,0,424,421,1,0,0, - 0,425,41,1,0,0,0,426,428,3,112,56,0,427,429,7,1,0,0,428,427,1,0,0,0,428, - 429,1,0,0,0,429,43,1,0,0,0,430,436,5,54,0,0,431,432,5,73,0,0,432,436,5, - 85,0,0,433,434,5,74,0,0,434,436,5,85,0,0,435,430,1,0,0,0,435,431,1,0,0, - 0,435,433,1,0,0,0,436,45,1,0,0,0,437,438,3,112,56,0,438,442,3,48,24,0, - 439,441,3,52,26,0,440,439,1,0,0,0,441,444,1,0,0,0,442,440,1,0,0,0,442, - 443,1,0,0,0,443,47,1,0,0,0,444,442,1,0,0,0,445,447,3,112,56,0,446,448, - 3,112,56,0,447,446,1,0,0,0,447,448,1,0,0,0,448,450,1,0,0,0,449,451,3,112, - 56,0,450,449,1,0,0,0,450,451,1,0,0,0,451,460,1,0,0,0,452,453,5,99,0,0, - 453,456,3,50,25,0,454,455,5,101,0,0,455,457,3,50,25,0,456,454,1,0,0,0, - 456,457,1,0,0,0,457,458,1,0,0,0,458,459,5,100,0,0,459,461,1,0,0,0,460, - 452,1,0,0,0,460,461,1,0,0,0,461,49,1,0,0,0,462,464,5,91,0,0,463,462,1, - 0,0,0,463,464,1,0,0,0,464,465,1,0,0,0,465,466,5,107,0,0,466,51,1,0,0,0, - 467,468,5,8,0,0,468,520,5,85,0,0,469,520,5,85,0,0,470,471,5,65,0,0,471, - 520,3,102,51,0,472,473,5,70,0,0,473,520,7,2,0,0,474,475,5,56,0,0,475,477, - 3,112,56,0,476,474,1,0,0,0,476,477,1,0,0,0,477,478,1,0,0,0,478,479,5,75, - 0,0,479,480,5,99,0,0,480,481,3,56,28,0,481,482,5,100,0,0,482,520,1,0,0, - 0,483,484,5,56,0,0,484,486,3,112,56,0,485,483,1,0,0,0,485,486,1,0,0,0, - 486,487,1,0,0,0,487,488,5,54,0,0,488,520,5,55,0,0,489,490,5,56,0,0,490, - 492,3,112,56,0,491,489,1,0,0,0,491,492,1,0,0,0,492,493,1,0,0,0,493,520, - 5,67,0,0,494,495,5,56,0,0,495,497,3,112,56,0,496,494,1,0,0,0,496,497,1, - 0,0,0,497,498,1,0,0,0,498,499,5,58,0,0,499,511,3,112,56,0,500,501,5,99, - 0,0,501,506,3,112,56,0,502,503,5,101,0,0,503,505,3,112,56,0,504,502,1, - 0,0,0,505,508,1,0,0,0,506,504,1,0,0,0,506,507,1,0,0,0,507,509,1,0,0,0, - 508,506,1,0,0,0,509,510,5,100,0,0,510,512,1,0,0,0,511,500,1,0,0,0,511, - 512,1,0,0,0,512,516,1,0,0,0,513,515,3,58,29,0,514,513,1,0,0,0,515,518, - 1,0,0,0,516,514,1,0,0,0,516,517,1,0,0,0,517,520,1,0,0,0,518,516,1,0,0, - 0,519,467,1,0,0,0,519,469,1,0,0,0,519,470,1,0,0,0,519,472,1,0,0,0,519, - 476,1,0,0,0,519,485,1,0,0,0,519,491,1,0,0,0,519,496,1,0,0,0,520,53,1,0, - 0,0,521,522,5,56,0,0,522,524,3,112,56,0,523,521,1,0,0,0,523,524,1,0,0, - 0,524,525,1,0,0,0,525,526,5,54,0,0,526,527,5,55,0,0,527,528,5,99,0,0,528, - 533,3,112,56,0,529,530,5,101,0,0,530,532,3,112,56,0,531,529,1,0,0,0,532, - 535,1,0,0,0,533,531,1,0,0,0,533,534,1,0,0,0,534,536,1,0,0,0,535,533,1, - 0,0,0,536,537,5,100,0,0,537,605,1,0,0,0,538,539,5,56,0,0,539,541,3,112, - 56,0,540,538,1,0,0,0,540,541,1,0,0,0,541,542,1,0,0,0,542,543,5,67,0,0, - 543,544,5,99,0,0,544,549,3,112,56,0,545,546,5,101,0,0,546,548,3,112,56, - 0,547,545,1,0,0,0,548,551,1,0,0,0,549,547,1,0,0,0,549,550,1,0,0,0,550, - 552,1,0,0,0,551,549,1,0,0,0,552,553,5,100,0,0,553,605,1,0,0,0,554,555, - 5,56,0,0,555,557,3,112,56,0,556,554,1,0,0,0,556,557,1,0,0,0,557,558,1, - 0,0,0,558,559,5,57,0,0,559,562,5,55,0,0,560,561,5,66,0,0,561,563,5,68, - 0,0,562,560,1,0,0,0,562,563,1,0,0,0,563,564,1,0,0,0,564,565,5,99,0,0,565, - 570,3,112,56,0,566,567,5,101,0,0,567,569,3,112,56,0,568,566,1,0,0,0,569, - 572,1,0,0,0,570,568,1,0,0,0,570,571,1,0,0,0,571,573,1,0,0,0,572,570,1, - 0,0,0,573,574,5,100,0,0,574,575,5,58,0,0,575,587,3,112,56,0,576,577,5, - 99,0,0,577,582,3,112,56,0,578,579,5,101,0,0,579,581,3,112,56,0,580,578, - 1,0,0,0,581,584,1,0,0,0,582,580,1,0,0,0,582,583,1,0,0,0,583,585,1,0,0, - 0,584,582,1,0,0,0,585,586,5,100,0,0,586,588,1,0,0,0,587,576,1,0,0,0,587, - 588,1,0,0,0,588,592,1,0,0,0,589,591,3,58,29,0,590,589,1,0,0,0,591,594, - 1,0,0,0,592,590,1,0,0,0,592,593,1,0,0,0,593,605,1,0,0,0,594,592,1,0,0, - 0,595,596,5,56,0,0,596,598,3,112,56,0,597,595,1,0,0,0,597,598,1,0,0,0, - 598,599,1,0,0,0,599,600,5,75,0,0,600,601,5,99,0,0,601,602,3,56,28,0,602, - 603,5,100,0,0,603,605,1,0,0,0,604,523,1,0,0,0,604,540,1,0,0,0,604,556, - 1,0,0,0,604,597,1,0,0,0,605,55,1,0,0,0,606,612,8,3,0,0,607,608,5,99,0, - 0,608,609,3,56,28,0,609,610,5,100,0,0,610,612,1,0,0,0,611,606,1,0,0,0, - 611,607,1,0,0,0,612,615,1,0,0,0,613,611,1,0,0,0,613,614,1,0,0,0,614,57, - 1,0,0,0,615,613,1,0,0,0,616,617,5,21,0,0,617,618,5,60,0,0,618,623,3,60, - 30,0,619,620,5,21,0,0,620,621,5,59,0,0,621,623,3,60,30,0,622,616,1,0,0, - 0,622,619,1,0,0,0,623,59,1,0,0,0,624,633,5,61,0,0,625,626,5,66,0,0,626, - 633,5,63,0,0,627,633,5,62,0,0,628,629,5,64,0,0,629,633,5,85,0,0,630,631, - 5,64,0,0,631,633,5,65,0,0,632,624,1,0,0,0,632,625,1,0,0,0,632,627,1,0, - 0,0,632,628,1,0,0,0,632,630,1,0,0,0,633,61,1,0,0,0,634,635,5,51,0,0,635, - 636,5,52,0,0,636,664,3,112,56,0,637,638,5,99,0,0,638,643,3,112,56,0,639, - 640,5,101,0,0,640,642,3,112,56,0,641,639,1,0,0,0,642,645,1,0,0,0,643,641, - 1,0,0,0,643,644,1,0,0,0,644,646,1,0,0,0,645,643,1,0,0,0,646,647,5,100, - 0,0,647,649,1,0,0,0,648,637,1,0,0,0,648,649,1,0,0,0,649,650,1,0,0,0,650, - 651,5,53,0,0,651,652,5,99,0,0,652,657,3,102,51,0,653,654,5,101,0,0,654, - 656,3,102,51,0,655,653,1,0,0,0,656,659,1,0,0,0,657,655,1,0,0,0,657,658, - 1,0,0,0,658,660,1,0,0,0,659,657,1,0,0,0,660,661,5,100,0,0,661,665,1,0, - 0,0,662,663,5,65,0,0,663,665,5,53,0,0,664,648,1,0,0,0,664,662,1,0,0,0, - 665,63,1,0,0,0,666,672,3,66,33,0,667,668,3,68,34,0,668,669,3,66,33,0,669, - 671,1,0,0,0,670,667,1,0,0,0,671,674,1,0,0,0,672,670,1,0,0,0,672,673,1, - 0,0,0,673,65,1,0,0,0,674,672,1,0,0,0,675,681,3,70,35,0,676,677,5,99,0, - 0,677,678,3,64,32,0,678,679,5,100,0,0,679,681,1,0,0,0,680,675,1,0,0,0, - 680,676,1,0,0,0,681,67,1,0,0,0,682,684,5,34,0,0,683,685,5,35,0,0,684,683, - 1,0,0,0,684,685,1,0,0,0,685,689,1,0,0,0,686,689,5,36,0,0,687,689,5,37, - 0,0,688,682,1,0,0,0,688,686,1,0,0,0,688,687,1,0,0,0,689,69,1,0,0,0,690, - 692,5,1,0,0,691,693,3,72,36,0,692,691,1,0,0,0,692,693,1,0,0,0,693,695, - 1,0,0,0,694,696,3,78,39,0,695,694,1,0,0,0,695,696,1,0,0,0,696,697,1,0, - 0,0,697,699,3,82,41,0,698,700,3,86,43,0,699,698,1,0,0,0,699,700,1,0,0, - 0,700,702,1,0,0,0,701,703,3,96,48,0,702,701,1,0,0,0,702,703,1,0,0,0,703, - 705,1,0,0,0,704,706,3,74,37,0,705,704,1,0,0,0,705,706,1,0,0,0,706,708, - 1,0,0,0,707,709,3,76,38,0,708,707,1,0,0,0,708,709,1,0,0,0,709,711,1,0, - 0,0,710,712,3,98,49,0,711,710,1,0,0,0,711,712,1,0,0,0,712,71,1,0,0,0,713, - 714,7,4,0,0,714,73,1,0,0,0,715,716,5,23,0,0,716,717,5,25,0,0,717,722,3, - 102,51,0,718,719,5,101,0,0,719,721,3,102,51,0,720,718,1,0,0,0,721,724, - 1,0,0,0,722,720,1,0,0,0,722,723,1,0,0,0,723,75,1,0,0,0,724,722,1,0,0,0, - 725,726,5,26,0,0,726,727,3,102,51,0,727,77,1,0,0,0,728,729,5,4,0,0,729, - 734,3,80,40,0,730,731,7,5,0,0,731,733,3,80,40,0,732,730,1,0,0,0,733,736, - 1,0,0,0,734,732,1,0,0,0,734,735,1,0,0,0,735,738,1,0,0,0,736,734,1,0,0, - 0,737,739,5,32,0,0,738,737,1,0,0,0,738,739,1,0,0,0,739,79,1,0,0,0,740, - 747,5,107,0,0,741,747,5,105,0,0,742,743,5,99,0,0,743,744,3,102,51,0,744, - 745,5,100,0,0,745,747,1,0,0,0,746,740,1,0,0,0,746,741,1,0,0,0,746,742, - 1,0,0,0,747,81,1,0,0,0,748,758,5,86,0,0,749,754,3,84,42,0,750,751,5,101, - 0,0,751,753,3,84,42,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,758,1,0,0,0,756,754,1,0,0,0,757,748,1,0,0,0,757,749, - 1,0,0,0,758,83,1,0,0,0,759,760,3,112,56,0,760,761,5,102,0,0,761,762,5, - 86,0,0,762,771,1,0,0,0,763,768,3,102,51,0,764,766,5,5,0,0,765,764,1,0, - 0,0,765,766,1,0,0,0,766,767,1,0,0,0,767,769,3,112,56,0,768,765,1,0,0,0, - 768,769,1,0,0,0,769,771,1,0,0,0,770,759,1,0,0,0,770,763,1,0,0,0,771,85, - 1,0,0,0,772,773,5,2,0,0,773,778,3,88,44,0,774,775,5,101,0,0,775,777,3, - 88,44,0,776,774,1,0,0,0,777,780,1,0,0,0,778,776,1,0,0,0,778,779,1,0,0, - 0,779,87,1,0,0,0,780,778,1,0,0,0,781,785,3,90,45,0,782,784,3,92,46,0,783, - 782,1,0,0,0,784,787,1,0,0,0,785,783,1,0,0,0,785,786,1,0,0,0,786,89,1,0, - 0,0,787,785,1,0,0,0,788,793,3,112,56,0,789,791,5,5,0,0,790,789,1,0,0,0, - 790,791,1,0,0,0,791,792,1,0,0,0,792,794,3,112,56,0,793,790,1,0,0,0,793, - 794,1,0,0,0,794,809,1,0,0,0,795,796,5,99,0,0,796,797,3,64,32,0,797,802, - 5,100,0,0,798,800,5,5,0,0,799,798,1,0,0,0,799,800,1,0,0,0,800,801,1,0, - 0,0,801,803,3,112,56,0,802,799,1,0,0,0,802,803,1,0,0,0,803,809,1,0,0,0, - 804,805,5,99,0,0,805,806,3,88,44,0,806,807,5,100,0,0,807,809,1,0,0,0,808, - 788,1,0,0,0,808,795,1,0,0,0,808,804,1,0,0,0,809,91,1,0,0,0,810,811,3,94, - 47,0,811,812,5,19,0,0,812,813,3,90,45,0,813,814,5,21,0,0,814,815,3,102, - 51,0,815,93,1,0,0,0,816,818,5,15,0,0,817,816,1,0,0,0,817,818,1,0,0,0,818, - 828,1,0,0,0,819,821,5,16,0,0,820,822,5,18,0,0,821,820,1,0,0,0,821,822, - 1,0,0,0,822,828,1,0,0,0,823,825,5,17,0,0,824,826,5,18,0,0,825,824,1,0, - 0,0,825,826,1,0,0,0,826,828,1,0,0,0,827,817,1,0,0,0,827,819,1,0,0,0,827, - 823,1,0,0,0,828,95,1,0,0,0,829,830,5,3,0,0,830,831,3,102,51,0,831,97,1, - 0,0,0,832,833,5,22,0,0,833,834,5,25,0,0,834,839,3,100,50,0,835,836,5,101, - 0,0,836,838,3,100,50,0,837,835,1,0,0,0,838,841,1,0,0,0,839,837,1,0,0,0, - 839,840,1,0,0,0,840,99,1,0,0,0,841,839,1,0,0,0,842,844,3,102,51,0,843, - 845,7,1,0,0,844,843,1,0,0,0,844,845,1,0,0,0,845,101,1,0,0,0,846,847,6, - 51,-1,0,847,848,5,8,0,0,848,855,3,102,51,16,849,850,5,12,0,0,850,855,3, - 102,51,15,851,852,5,91,0,0,852,855,3,102,51,14,853,855,3,104,52,0,854, - 846,1,0,0,0,854,849,1,0,0,0,854,851,1,0,0,0,854,853,1,0,0,0,855,925,1, - 0,0,0,856,857,10,13,0,0,857,858,5,89,0,0,858,924,3,102,51,14,859,860,10, - 12,0,0,860,861,7,6,0,0,861,924,3,102,51,13,862,863,10,11,0,0,863,864,7, - 7,0,0,864,924,3,102,51,12,865,866,10,10,0,0,866,867,7,8,0,0,867,924,3, - 102,51,11,868,870,10,9,0,0,869,871,5,8,0,0,870,869,1,0,0,0,870,871,1,0, - 0,0,871,872,1,0,0,0,872,873,5,33,0,0,873,874,3,102,51,0,874,875,5,6,0, - 0,875,876,3,102,51,10,876,924,1,0,0,0,877,879,10,8,0,0,878,880,5,8,0,0, - 879,878,1,0,0,0,879,880,1,0,0,0,880,881,1,0,0,0,881,882,5,13,0,0,882,924, - 3,102,51,9,883,884,10,4,0,0,884,885,7,9,0,0,885,924,3,102,51,5,886,887, - 10,3,0,0,887,888,5,6,0,0,888,924,3,102,51,4,889,890,10,2,0,0,890,891,5, - 7,0,0,891,924,3,102,51,3,892,894,10,7,0,0,893,895,5,8,0,0,894,893,1,0, - 0,0,894,895,1,0,0,0,895,896,1,0,0,0,896,897,5,20,0,0,897,898,5,99,0,0, - 898,899,3,70,35,0,899,900,5,100,0,0,900,924,1,0,0,0,901,903,10,6,0,0,902, - 904,5,8,0,0,903,902,1,0,0,0,903,904,1,0,0,0,904,905,1,0,0,0,905,906,5, - 20,0,0,906,907,5,99,0,0,907,912,3,102,51,0,908,909,5,101,0,0,909,911,3, - 102,51,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,915,1,0,0,0,914,912,1,0,0,0,915,916,5,100,0,0,916,924,1,0,0,0,917, - 918,10,5,0,0,918,920,5,24,0,0,919,921,5,8,0,0,920,919,1,0,0,0,920,921, - 1,0,0,0,921,922,1,0,0,0,922,924,5,85,0,0,923,856,1,0,0,0,923,859,1,0,0, - 0,923,862,1,0,0,0,923,865,1,0,0,0,923,868,1,0,0,0,923,877,1,0,0,0,923, - 883,1,0,0,0,923,886,1,0,0,0,923,889,1,0,0,0,923,892,1,0,0,0,923,901,1, - 0,0,0,923,917,1,0,0,0,924,927,1,0,0,0,925,923,1,0,0,0,925,926,1,0,0,0, - 926,103,1,0,0,0,927,925,1,0,0,0,928,947,3,114,57,0,929,947,3,106,53,0, - 930,947,3,110,55,0,931,947,5,105,0,0,932,947,5,104,0,0,933,934,5,27,0, - 0,934,935,5,99,0,0,935,936,3,70,35,0,936,937,5,100,0,0,937,947,1,0,0,0, - 938,939,5,99,0,0,939,940,3,70,35,0,940,941,5,100,0,0,941,947,1,0,0,0,942, - 943,5,99,0,0,943,944,3,102,51,0,944,945,5,100,0,0,945,947,1,0,0,0,946, - 928,1,0,0,0,946,929,1,0,0,0,946,930,1,0,0,0,946,931,1,0,0,0,946,932,1, - 0,0,0,946,933,1,0,0,0,946,938,1,0,0,0,946,942,1,0,0,0,947,105,1,0,0,0, - 948,949,3,108,54,0,949,962,5,99,0,0,950,963,5,86,0,0,951,953,5,31,0,0, - 952,951,1,0,0,0,952,953,1,0,0,0,953,954,1,0,0,0,954,959,3,102,51,0,955, - 956,5,101,0,0,956,958,3,102,51,0,957,955,1,0,0,0,958,961,1,0,0,0,959,957, - 1,0,0,0,959,960,1,0,0,0,960,963,1,0,0,0,961,959,1,0,0,0,962,950,1,0,0, - 0,962,952,1,0,0,0,962,963,1,0,0,0,963,964,1,0,0,0,964,965,5,100,0,0,965, - 107,1,0,0,0,966,971,3,112,56,0,967,971,5,16,0,0,968,971,5,17,0,0,969,971, - 5,81,0,0,970,966,1,0,0,0,970,967,1,0,0,0,970,968,1,0,0,0,970,969,1,0,0, - 0,971,109,1,0,0,0,972,973,3,112,56,0,973,974,5,102,0,0,974,976,1,0,0,0, - 975,972,1,0,0,0,975,976,1,0,0,0,976,977,1,0,0,0,977,978,3,112,56,0,978, - 111,1,0,0,0,979,980,7,10,0,0,980,113,1,0,0,0,981,991,5,107,0,0,982,991, - 5,108,0,0,983,991,5,106,0,0,984,991,5,109,0,0,985,991,5,110,0,0,986,991, - 5,111,0,0,987,991,5,83,0,0,988,991,5,84,0,0,989,991,5,85,0,0,990,981,1, - 0,0,0,990,982,1,0,0,0,990,983,1,0,0,0,990,984,1,0,0,0,990,985,1,0,0,0, - 990,986,1,0,0,0,990,987,1,0,0,0,990,988,1,0,0,0,990,989,1,0,0,0,991,115, - 1,0,0,0,992,994,5,40,0,0,993,995,7,11,0,0,994,993,1,0,0,0,994,995,1,0, - 0,0,995,1005,1,0,0,0,996,998,5,41,0,0,997,999,7,11,0,0,998,997,1,0,0,0, - 998,999,1,0,0,0,999,1005,1,0,0,0,1000,1002,5,42,0,0,1001,1003,7,11,0,0, - 1002,1001,1,0,0,0,1002,1003,1,0,0,0,1003,1005,1,0,0,0,1004,992,1,0,0,0, - 1004,996,1,0,0,0,1004,1000,1,0,0,0,1005,117,1,0,0,0,1006,1007,3,102,51, - 0,1007,1008,5,0,0,1,1008,119,1,0,0,0,125,121,137,140,146,166,175,178,188, - 192,204,209,217,222,225,233,240,250,257,271,276,285,296,306,309,316,325, - 332,339,344,353,380,385,389,401,407,424,428,435,442,447,450,456,460,463, - 476,485,491,496,506,511,516,519,523,533,540,549,556,562,570,582,587,592, - 597,604,611,613,622,632,643,648,657,664,672,680,684,688,692,695,699,702, - 705,708,711,722,734,738,746,754,757,765,768,770,778,785,790,793,799,802, - 808,817,821,825,827,839,844,854,870,879,894,903,912,920,923,925,946,952, - 959,962,970,975,990,994,998,1002,1004 + 31,1,31,1,31,1,31,5,31,656,8,31,10,31,12,31,659,9,31,1,31,1,31,1,31,3, + 31,664,8,31,1,31,1,31,3,31,668,8,31,1,32,1,32,1,32,1,32,5,32,674,8,32, + 10,32,12,32,677,9,32,1,33,1,33,1,33,1,33,1,33,3,33,684,8,33,1,34,1,34, + 3,34,688,8,34,1,34,1,34,3,34,692,8,34,1,35,1,35,3,35,696,8,35,1,35,3,35, + 699,8,35,1,35,1,35,3,35,703,8,35,1,35,3,35,706,8,35,1,35,3,35,709,8,35, + 1,35,3,35,712,8,35,1,35,3,35,715,8,35,1,36,1,36,1,37,1,37,1,37,1,37,1, + 37,5,37,724,8,37,10,37,12,37,727,9,37,1,38,1,38,1,38,1,39,1,39,1,39,1, + 39,5,39,736,8,39,10,39,12,39,739,9,39,1,39,3,39,742,8,39,1,40,1,40,1,40, + 1,40,1,40,1,40,3,40,750,8,40,1,41,1,41,1,41,1,41,5,41,756,8,41,10,41,12, + 41,759,9,41,3,41,761,8,41,1,42,1,42,1,42,1,42,1,42,1,42,3,42,769,8,42, + 1,42,3,42,772,8,42,3,42,774,8,42,1,43,1,43,1,43,1,43,5,43,780,8,43,10, + 43,12,43,783,9,43,1,44,1,44,5,44,787,8,44,10,44,12,44,790,9,44,1,45,1, + 45,3,45,794,8,45,1,45,3,45,797,8,45,1,45,1,45,1,45,1,45,3,45,803,8,45, + 1,45,3,45,806,8,45,1,45,1,45,1,45,1,45,3,45,812,8,45,1,46,1,46,1,46,1, + 46,1,46,1,46,1,47,3,47,821,8,47,1,47,1,47,3,47,825,8,47,1,47,1,47,3,47, + 829,8,47,3,47,831,8,47,1,48,1,48,1,48,1,49,1,49,1,49,1,49,1,49,5,49,841, + 8,49,10,49,12,49,844,9,49,1,50,1,50,3,50,848,8,50,1,51,1,51,1,51,1,51, + 1,51,1,51,1,51,1,51,3,51,858,8,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,874,8,51,1,51,1,51,1,51,1,51,1,51, + 1,51,1,51,3,51,883,8,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,898,8,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,3,51, + 907,8,51,1,51,1,51,1,51,1,51,1,51,5,51,914,8,51,10,51,12,51,917,9,51,1, + 51,1,51,1,51,1,51,1,51,3,51,924,8,51,1,51,5,51,927,8,51,10,51,12,51,930, + 9,51,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52, + 1,52,1,52,1,52,1,52,1,52,3,52,950,8,52,1,53,1,53,1,53,1,53,3,53,956,8, + 53,1,53,1,53,1,53,5,53,961,8,53,10,53,12,53,964,9,53,3,53,966,8,53,1,53, + 1,53,1,54,1,54,1,54,1,54,3,54,974,8,54,1,55,1,55,1,55,3,55,979,8,55,1, + 55,1,55,1,56,1,56,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,3,57,994, + 8,57,1,58,1,58,3,58,998,8,58,1,58,1,58,3,58,1002,8,58,1,58,1,58,3,58,1006, + 8,58,3,58,1008,8,58,1,59,1,59,1,59,1,59,0,1,102,60,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,0,12,1,0,79,80,1,0,81,82,1,0,71,72,1,0,99,100, + 2,0,30,31,35,35,1,0,90,91,2,0,14,14,86,88,1,0,90,92,1,0,93,98,1,0,9,11, + 1,0,112,114,1,0,43,44,1156,0,121,1,0,0,0,2,144,1,0,0,0,4,166,1,0,0,0,6, + 168,1,0,0,0,8,180,1,0,0,0,10,194,1,0,0,0,12,198,1,0,0,0,14,211,1,0,0,0, + 16,220,1,0,0,0,18,227,1,0,0,0,20,238,1,0,0,0,22,262,1,0,0,0,24,281,1,0, + 0,0,26,309,1,0,0,0,28,311,1,0,0,0,30,316,1,0,0,0,32,318,1,0,0,0,34,380, + 1,0,0,0,36,385,1,0,0,0,38,387,1,0,0,0,40,424,1,0,0,0,42,426,1,0,0,0,44, + 435,1,0,0,0,46,437,1,0,0,0,48,445,1,0,0,0,50,463,1,0,0,0,52,519,1,0,0, + 0,54,604,1,0,0,0,56,613,1,0,0,0,58,622,1,0,0,0,60,632,1,0,0,0,62,634,1, + 0,0,0,64,669,1,0,0,0,66,683,1,0,0,0,68,691,1,0,0,0,70,693,1,0,0,0,72,716, + 1,0,0,0,74,718,1,0,0,0,76,728,1,0,0,0,78,731,1,0,0,0,80,749,1,0,0,0,82, + 760,1,0,0,0,84,773,1,0,0,0,86,775,1,0,0,0,88,784,1,0,0,0,90,811,1,0,0, + 0,92,813,1,0,0,0,94,830,1,0,0,0,96,832,1,0,0,0,98,835,1,0,0,0,100,845, + 1,0,0,0,102,857,1,0,0,0,104,949,1,0,0,0,106,951,1,0,0,0,108,973,1,0,0, + 0,110,978,1,0,0,0,112,982,1,0,0,0,114,993,1,0,0,0,116,1007,1,0,0,0,118, + 1009,1,0,0,0,120,122,3,18,9,0,121,120,1,0,0,0,121,122,1,0,0,0,122,137, + 1,0,0,0,123,138,3,2,1,0,124,138,3,20,10,0,125,138,3,38,19,0,126,138,3, + 22,11,0,127,138,3,24,12,0,128,138,3,32,16,0,129,138,3,40,20,0,130,138, + 3,62,31,0,131,138,3,8,4,0,132,138,3,12,6,0,133,138,3,116,58,0,134,138, + 3,6,3,0,135,138,3,14,7,0,136,138,3,64,32,0,137,123,1,0,0,0,137,124,1,0, + 0,0,137,125,1,0,0,0,137,126,1,0,0,0,137,127,1,0,0,0,137,128,1,0,0,0,137, + 129,1,0,0,0,137,130,1,0,0,0,137,131,1,0,0,0,137,132,1,0,0,0,137,133,1, + 0,0,0,137,134,1,0,0,0,137,135,1,0,0,0,137,136,1,0,0,0,138,140,1,0,0,0, + 139,141,5,103,0,0,140,139,1,0,0,0,140,141,1,0,0,0,141,142,1,0,0,0,142, + 143,5,0,0,1,143,1,1,0,0,0,144,146,5,28,0,0,145,147,5,8,0,0,146,145,1,0, + 0,0,146,147,1,0,0,0,147,148,1,0,0,0,148,149,5,27,0,0,149,150,5,99,0,0, + 150,151,3,70,35,0,151,152,5,100,0,0,152,153,5,29,0,0,153,154,3,4,2,0,154, + 3,1,0,0,0,155,167,3,20,10,0,156,167,3,38,19,0,157,167,3,22,11,0,158,167, + 3,24,12,0,159,167,3,32,16,0,160,167,3,40,20,0,161,167,3,62,31,0,162,167, + 3,8,4,0,163,167,3,12,6,0,164,167,3,6,3,0,165,167,3,64,32,0,166,155,1,0, + 0,0,166,156,1,0,0,0,166,157,1,0,0,0,166,158,1,0,0,0,166,159,1,0,0,0,166, + 160,1,0,0,0,166,161,1,0,0,0,166,162,1,0,0,0,166,163,1,0,0,0,166,164,1, + 0,0,0,166,165,1,0,0,0,167,5,1,0,0,0,168,169,7,0,0,0,169,178,3,112,56,0, + 170,175,3,102,51,0,171,172,5,101,0,0,172,174,3,102,51,0,173,171,1,0,0, + 0,174,177,1,0,0,0,175,173,1,0,0,0,175,176,1,0,0,0,176,179,1,0,0,0,177, + 175,1,0,0,0,178,170,1,0,0,0,178,179,1,0,0,0,179,7,1,0,0,0,180,181,5,60, + 0,0,181,182,3,88,44,0,182,183,5,64,0,0,183,188,3,10,5,0,184,185,5,101, + 0,0,185,187,3,10,5,0,186,184,1,0,0,0,187,190,1,0,0,0,188,186,1,0,0,0,188, + 189,1,0,0,0,189,192,1,0,0,0,190,188,1,0,0,0,191,193,3,96,48,0,192,191, + 1,0,0,0,192,193,1,0,0,0,193,9,1,0,0,0,194,195,3,110,55,0,195,196,5,93, + 0,0,196,197,3,102,51,0,197,11,1,0,0,0,198,204,5,59,0,0,199,200,3,112,56, + 0,200,201,5,102,0,0,201,202,5,86,0,0,202,205,1,0,0,0,203,205,5,86,0,0, + 204,199,1,0,0,0,204,203,1,0,0,0,204,205,1,0,0,0,205,206,1,0,0,0,206,207, + 5,2,0,0,207,209,3,88,44,0,208,210,3,96,48,0,209,208,1,0,0,0,209,210,1, + 0,0,0,210,13,1,0,0,0,211,212,5,1,0,0,212,217,3,16,8,0,213,214,5,101,0, + 0,214,216,3,16,8,0,215,213,1,0,0,0,216,219,1,0,0,0,217,215,1,0,0,0,217, + 218,1,0,0,0,218,15,1,0,0,0,219,217,1,0,0,0,220,225,5,104,0,0,221,223,5, + 5,0,0,222,221,1,0,0,0,222,223,1,0,0,0,223,224,1,0,0,0,224,226,3,112,56, + 0,225,222,1,0,0,0,225,226,1,0,0,0,226,17,1,0,0,0,227,228,5,78,0,0,228, + 233,3,28,14,0,229,230,5,101,0,0,230,232,3,28,14,0,231,229,1,0,0,0,232, + 235,1,0,0,0,233,231,1,0,0,0,233,234,1,0,0,0,234,236,1,0,0,0,235,233,1, + 0,0,0,236,237,5,103,0,0,237,19,1,0,0,0,238,240,5,38,0,0,239,241,5,69,0, + 0,240,239,1,0,0,0,240,241,1,0,0,0,241,242,1,0,0,0,242,243,5,39,0,0,243, + 244,3,112,56,0,244,245,5,99,0,0,245,250,3,46,23,0,246,247,5,101,0,0,247, + 249,3,46,23,0,248,246,1,0,0,0,249,252,1,0,0,0,250,248,1,0,0,0,250,251, + 1,0,0,0,251,257,1,0,0,0,252,250,1,0,0,0,253,254,5,101,0,0,254,256,3,54, + 27,0,255,253,1,0,0,0,256,259,1,0,0,0,257,255,1,0,0,0,257,258,1,0,0,0,258, + 260,1,0,0,0,259,257,1,0,0,0,260,261,5,100,0,0,261,21,1,0,0,0,262,263,5, + 38,0,0,263,264,5,76,0,0,264,276,3,112,56,0,265,266,5,99,0,0,266,271,3, + 112,56,0,267,268,5,101,0,0,268,270,3,112,56,0,269,267,1,0,0,0,270,273, + 1,0,0,0,271,269,1,0,0,0,271,272,1,0,0,0,272,274,1,0,0,0,273,271,1,0,0, + 0,274,275,5,100,0,0,275,277,1,0,0,0,276,265,1,0,0,0,276,277,1,0,0,0,277, + 278,1,0,0,0,278,279,5,5,0,0,279,280,3,64,32,0,280,23,1,0,0,0,281,282,5, + 38,0,0,282,283,5,77,0,0,283,285,3,112,56,0,284,286,3,26,13,0,285,284,1, + 0,0,0,285,286,1,0,0,0,286,287,1,0,0,0,287,288,5,5,0,0,288,289,3,36,18, + 0,289,25,1,0,0,0,290,291,5,99,0,0,291,296,3,28,14,0,292,293,5,101,0,0, + 293,295,3,28,14,0,294,292,1,0,0,0,295,298,1,0,0,0,296,294,1,0,0,0,296, + 297,1,0,0,0,297,299,1,0,0,0,298,296,1,0,0,0,299,300,5,100,0,0,300,310, + 1,0,0,0,301,306,3,28,14,0,302,303,5,101,0,0,303,305,3,28,14,0,304,302, + 1,0,0,0,305,308,1,0,0,0,306,304,1,0,0,0,306,307,1,0,0,0,307,310,1,0,0, + 0,308,306,1,0,0,0,309,290,1,0,0,0,309,301,1,0,0,0,310,27,1,0,0,0,311,312, + 3,30,15,0,312,313,3,48,24,0,313,29,1,0,0,0,314,317,3,112,56,0,315,317, + 5,105,0,0,316,314,1,0,0,0,316,315,1,0,0,0,317,31,1,0,0,0,318,319,5,45, + 0,0,319,320,5,39,0,0,320,321,3,112,56,0,321,322,3,34,17,0,322,33,1,0,0, + 0,323,325,5,48,0,0,324,326,5,50,0,0,325,324,1,0,0,0,325,326,1,0,0,0,326, + 327,1,0,0,0,327,381,3,46,23,0,328,329,5,48,0,0,329,381,3,54,27,0,330,332, + 5,45,0,0,331,333,5,50,0,0,332,331,1,0,0,0,332,333,1,0,0,0,333,334,1,0, + 0,0,334,335,3,112,56,0,335,339,3,48,24,0,336,338,3,52,26,0,337,336,1,0, + 0,0,338,341,1,0,0,0,339,337,1,0,0,0,339,340,1,0,0,0,340,381,1,0,0,0,341, + 339,1,0,0,0,342,344,5,45,0,0,343,345,5,50,0,0,344,343,1,0,0,0,344,345, + 1,0,0,0,345,346,1,0,0,0,346,347,3,112,56,0,347,348,5,64,0,0,348,349,5, + 65,0,0,349,350,3,102,51,0,350,381,1,0,0,0,351,353,5,45,0,0,352,354,5,50, + 0,0,353,352,1,0,0,0,353,354,1,0,0,0,354,355,1,0,0,0,355,356,3,112,56,0, + 356,357,5,49,0,0,357,358,5,65,0,0,358,381,1,0,0,0,359,360,5,49,0,0,360, + 361,5,50,0,0,361,381,3,112,56,0,362,363,5,49,0,0,363,364,5,56,0,0,364, + 381,3,112,56,0,365,366,5,46,0,0,366,367,5,47,0,0,367,381,3,112,56,0,368, + 369,5,46,0,0,369,370,5,50,0,0,370,371,3,112,56,0,371,372,5,47,0,0,372, + 373,3,112,56,0,373,381,1,0,0,0,374,375,5,46,0,0,375,376,5,68,0,0,376,377, + 3,112,56,0,377,378,5,47,0,0,378,379,3,112,56,0,379,381,1,0,0,0,380,323, + 1,0,0,0,380,328,1,0,0,0,380,330,1,0,0,0,380,342,1,0,0,0,380,351,1,0,0, + 0,380,359,1,0,0,0,380,362,1,0,0,0,380,365,1,0,0,0,380,368,1,0,0,0,380, + 374,1,0,0,0,381,35,1,0,0,0,382,386,3,64,32,0,383,386,3,62,31,0,384,386, + 3,20,10,0,385,382,1,0,0,0,385,383,1,0,0,0,385,384,1,0,0,0,386,37,1,0,0, + 0,387,389,5,38,0,0,388,390,5,67,0,0,389,388,1,0,0,0,389,390,1,0,0,0,390, + 391,1,0,0,0,391,392,5,68,0,0,392,393,3,112,56,0,393,394,5,21,0,0,394,395, + 3,112,56,0,395,396,5,99,0,0,396,401,3,42,21,0,397,398,5,101,0,0,398,400, + 3,42,21,0,399,397,1,0,0,0,400,403,1,0,0,0,401,399,1,0,0,0,401,402,1,0, + 0,0,402,404,1,0,0,0,403,401,1,0,0,0,404,407,5,100,0,0,405,406,5,70,0,0, + 406,408,3,44,22,0,407,405,1,0,0,0,407,408,1,0,0,0,408,39,1,0,0,0,409,410, + 5,49,0,0,410,411,5,39,0,0,411,425,3,112,56,0,412,413,5,49,0,0,413,414, + 5,68,0,0,414,415,3,112,56,0,415,416,5,21,0,0,416,417,3,112,56,0,417,425, + 1,0,0,0,418,419,5,49,0,0,419,420,5,77,0,0,420,425,3,112,56,0,421,422,5, + 49,0,0,422,423,5,76,0,0,423,425,3,112,56,0,424,409,1,0,0,0,424,412,1,0, + 0,0,424,418,1,0,0,0,424,421,1,0,0,0,425,41,1,0,0,0,426,428,3,112,56,0, + 427,429,7,1,0,0,428,427,1,0,0,0,428,429,1,0,0,0,429,43,1,0,0,0,430,436, + 5,54,0,0,431,432,5,73,0,0,432,436,5,85,0,0,433,434,5,74,0,0,434,436,5, + 85,0,0,435,430,1,0,0,0,435,431,1,0,0,0,435,433,1,0,0,0,436,45,1,0,0,0, + 437,438,3,112,56,0,438,442,3,48,24,0,439,441,3,52,26,0,440,439,1,0,0,0, + 441,444,1,0,0,0,442,440,1,0,0,0,442,443,1,0,0,0,443,47,1,0,0,0,444,442, + 1,0,0,0,445,447,3,112,56,0,446,448,3,112,56,0,447,446,1,0,0,0,447,448, + 1,0,0,0,448,450,1,0,0,0,449,451,3,112,56,0,450,449,1,0,0,0,450,451,1,0, + 0,0,451,460,1,0,0,0,452,453,5,99,0,0,453,456,3,50,25,0,454,455,5,101,0, + 0,455,457,3,50,25,0,456,454,1,0,0,0,456,457,1,0,0,0,457,458,1,0,0,0,458, + 459,5,100,0,0,459,461,1,0,0,0,460,452,1,0,0,0,460,461,1,0,0,0,461,49,1, + 0,0,0,462,464,5,91,0,0,463,462,1,0,0,0,463,464,1,0,0,0,464,465,1,0,0,0, + 465,466,5,107,0,0,466,51,1,0,0,0,467,468,5,8,0,0,468,520,5,85,0,0,469, + 520,5,85,0,0,470,471,5,65,0,0,471,520,3,102,51,0,472,473,5,70,0,0,473, + 520,7,2,0,0,474,475,5,56,0,0,475,477,3,112,56,0,476,474,1,0,0,0,476,477, + 1,0,0,0,477,478,1,0,0,0,478,479,5,75,0,0,479,480,5,99,0,0,480,481,3,56, + 28,0,481,482,5,100,0,0,482,520,1,0,0,0,483,484,5,56,0,0,484,486,3,112, + 56,0,485,483,1,0,0,0,485,486,1,0,0,0,486,487,1,0,0,0,487,488,5,54,0,0, + 488,520,5,55,0,0,489,490,5,56,0,0,490,492,3,112,56,0,491,489,1,0,0,0,491, + 492,1,0,0,0,492,493,1,0,0,0,493,520,5,67,0,0,494,495,5,56,0,0,495,497, + 3,112,56,0,496,494,1,0,0,0,496,497,1,0,0,0,497,498,1,0,0,0,498,499,5,58, + 0,0,499,511,3,112,56,0,500,501,5,99,0,0,501,506,3,112,56,0,502,503,5,101, + 0,0,503,505,3,112,56,0,504,502,1,0,0,0,505,508,1,0,0,0,506,504,1,0,0,0, + 506,507,1,0,0,0,507,509,1,0,0,0,508,506,1,0,0,0,509,510,5,100,0,0,510, + 512,1,0,0,0,511,500,1,0,0,0,511,512,1,0,0,0,512,516,1,0,0,0,513,515,3, + 58,29,0,514,513,1,0,0,0,515,518,1,0,0,0,516,514,1,0,0,0,516,517,1,0,0, + 0,517,520,1,0,0,0,518,516,1,0,0,0,519,467,1,0,0,0,519,469,1,0,0,0,519, + 470,1,0,0,0,519,472,1,0,0,0,519,476,1,0,0,0,519,485,1,0,0,0,519,491,1, + 0,0,0,519,496,1,0,0,0,520,53,1,0,0,0,521,522,5,56,0,0,522,524,3,112,56, + 0,523,521,1,0,0,0,523,524,1,0,0,0,524,525,1,0,0,0,525,526,5,54,0,0,526, + 527,5,55,0,0,527,528,5,99,0,0,528,533,3,112,56,0,529,530,5,101,0,0,530, + 532,3,112,56,0,531,529,1,0,0,0,532,535,1,0,0,0,533,531,1,0,0,0,533,534, + 1,0,0,0,534,536,1,0,0,0,535,533,1,0,0,0,536,537,5,100,0,0,537,605,1,0, + 0,0,538,539,5,56,0,0,539,541,3,112,56,0,540,538,1,0,0,0,540,541,1,0,0, + 0,541,542,1,0,0,0,542,543,5,67,0,0,543,544,5,99,0,0,544,549,3,112,56,0, + 545,546,5,101,0,0,546,548,3,112,56,0,547,545,1,0,0,0,548,551,1,0,0,0,549, + 547,1,0,0,0,549,550,1,0,0,0,550,552,1,0,0,0,551,549,1,0,0,0,552,553,5, + 100,0,0,553,605,1,0,0,0,554,555,5,56,0,0,555,557,3,112,56,0,556,554,1, + 0,0,0,556,557,1,0,0,0,557,558,1,0,0,0,558,559,5,57,0,0,559,562,5,55,0, + 0,560,561,5,66,0,0,561,563,5,68,0,0,562,560,1,0,0,0,562,563,1,0,0,0,563, + 564,1,0,0,0,564,565,5,99,0,0,565,570,3,112,56,0,566,567,5,101,0,0,567, + 569,3,112,56,0,568,566,1,0,0,0,569,572,1,0,0,0,570,568,1,0,0,0,570,571, + 1,0,0,0,571,573,1,0,0,0,572,570,1,0,0,0,573,574,5,100,0,0,574,575,5,58, + 0,0,575,587,3,112,56,0,576,577,5,99,0,0,577,582,3,112,56,0,578,579,5,101, + 0,0,579,581,3,112,56,0,580,578,1,0,0,0,581,584,1,0,0,0,582,580,1,0,0,0, + 582,583,1,0,0,0,583,585,1,0,0,0,584,582,1,0,0,0,585,586,5,100,0,0,586, + 588,1,0,0,0,587,576,1,0,0,0,587,588,1,0,0,0,588,592,1,0,0,0,589,591,3, + 58,29,0,590,589,1,0,0,0,591,594,1,0,0,0,592,590,1,0,0,0,592,593,1,0,0, + 0,593,605,1,0,0,0,594,592,1,0,0,0,595,596,5,56,0,0,596,598,3,112,56,0, + 597,595,1,0,0,0,597,598,1,0,0,0,598,599,1,0,0,0,599,600,5,75,0,0,600,601, + 5,99,0,0,601,602,3,56,28,0,602,603,5,100,0,0,603,605,1,0,0,0,604,523,1, + 0,0,0,604,540,1,0,0,0,604,556,1,0,0,0,604,597,1,0,0,0,605,55,1,0,0,0,606, + 612,8,3,0,0,607,608,5,99,0,0,608,609,3,56,28,0,609,610,5,100,0,0,610,612, + 1,0,0,0,611,606,1,0,0,0,611,607,1,0,0,0,612,615,1,0,0,0,613,611,1,0,0, + 0,613,614,1,0,0,0,614,57,1,0,0,0,615,613,1,0,0,0,616,617,5,21,0,0,617, + 618,5,60,0,0,618,623,3,60,30,0,619,620,5,21,0,0,620,621,5,59,0,0,621,623, + 3,60,30,0,622,616,1,0,0,0,622,619,1,0,0,0,623,59,1,0,0,0,624,633,5,61, + 0,0,625,626,5,66,0,0,626,633,5,63,0,0,627,633,5,62,0,0,628,629,5,64,0, + 0,629,633,5,85,0,0,630,631,5,64,0,0,631,633,5,65,0,0,632,624,1,0,0,0,632, + 625,1,0,0,0,632,627,1,0,0,0,632,628,1,0,0,0,632,630,1,0,0,0,633,61,1,0, + 0,0,634,635,5,51,0,0,635,636,5,52,0,0,636,667,3,112,56,0,637,638,5,99, + 0,0,638,643,3,112,56,0,639,640,5,101,0,0,640,642,3,112,56,0,641,639,1, + 0,0,0,642,645,1,0,0,0,643,641,1,0,0,0,643,644,1,0,0,0,644,646,1,0,0,0, + 645,643,1,0,0,0,646,647,5,100,0,0,647,649,1,0,0,0,648,637,1,0,0,0,648, + 649,1,0,0,0,649,663,1,0,0,0,650,651,5,53,0,0,651,652,5,99,0,0,652,657, + 3,102,51,0,653,654,5,101,0,0,654,656,3,102,51,0,655,653,1,0,0,0,656,659, + 1,0,0,0,657,655,1,0,0,0,657,658,1,0,0,0,658,660,1,0,0,0,659,657,1,0,0, + 0,660,661,5,100,0,0,661,664,1,0,0,0,662,664,3,64,32,0,663,650,1,0,0,0, + 663,662,1,0,0,0,664,668,1,0,0,0,665,666,5,65,0,0,666,668,5,53,0,0,667, + 648,1,0,0,0,667,665,1,0,0,0,668,63,1,0,0,0,669,675,3,66,33,0,670,671,3, + 68,34,0,671,672,3,66,33,0,672,674,1,0,0,0,673,670,1,0,0,0,674,677,1,0, + 0,0,675,673,1,0,0,0,675,676,1,0,0,0,676,65,1,0,0,0,677,675,1,0,0,0,678, + 684,3,70,35,0,679,680,5,99,0,0,680,681,3,64,32,0,681,682,5,100,0,0,682, + 684,1,0,0,0,683,678,1,0,0,0,683,679,1,0,0,0,684,67,1,0,0,0,685,687,5,34, + 0,0,686,688,5,35,0,0,687,686,1,0,0,0,687,688,1,0,0,0,688,692,1,0,0,0,689, + 692,5,36,0,0,690,692,5,37,0,0,691,685,1,0,0,0,691,689,1,0,0,0,691,690, + 1,0,0,0,692,69,1,0,0,0,693,695,5,1,0,0,694,696,3,72,36,0,695,694,1,0,0, + 0,695,696,1,0,0,0,696,698,1,0,0,0,697,699,3,78,39,0,698,697,1,0,0,0,698, + 699,1,0,0,0,699,700,1,0,0,0,700,702,3,82,41,0,701,703,3,86,43,0,702,701, + 1,0,0,0,702,703,1,0,0,0,703,705,1,0,0,0,704,706,3,96,48,0,705,704,1,0, + 0,0,705,706,1,0,0,0,706,708,1,0,0,0,707,709,3,74,37,0,708,707,1,0,0,0, + 708,709,1,0,0,0,709,711,1,0,0,0,710,712,3,76,38,0,711,710,1,0,0,0,711, + 712,1,0,0,0,712,714,1,0,0,0,713,715,3,98,49,0,714,713,1,0,0,0,714,715, + 1,0,0,0,715,71,1,0,0,0,716,717,7,4,0,0,717,73,1,0,0,0,718,719,5,23,0,0, + 719,720,5,25,0,0,720,725,3,102,51,0,721,722,5,101,0,0,722,724,3,102,51, + 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, + 75,1,0,0,0,727,725,1,0,0,0,728,729,5,26,0,0,729,730,3,102,51,0,730,77, + 1,0,0,0,731,732,5,4,0,0,732,737,3,80,40,0,733,734,7,5,0,0,734,736,3,80, + 40,0,735,733,1,0,0,0,736,739,1,0,0,0,737,735,1,0,0,0,737,738,1,0,0,0,738, + 741,1,0,0,0,739,737,1,0,0,0,740,742,5,32,0,0,741,740,1,0,0,0,741,742,1, + 0,0,0,742,79,1,0,0,0,743,750,5,107,0,0,744,750,5,105,0,0,745,746,5,99, + 0,0,746,747,3,102,51,0,747,748,5,100,0,0,748,750,1,0,0,0,749,743,1,0,0, + 0,749,744,1,0,0,0,749,745,1,0,0,0,750,81,1,0,0,0,751,761,5,86,0,0,752, + 757,3,84,42,0,753,754,5,101,0,0,754,756,3,84,42,0,755,753,1,0,0,0,756, + 759,1,0,0,0,757,755,1,0,0,0,757,758,1,0,0,0,758,761,1,0,0,0,759,757,1, + 0,0,0,760,751,1,0,0,0,760,752,1,0,0,0,761,83,1,0,0,0,762,763,3,112,56, + 0,763,764,5,102,0,0,764,765,5,86,0,0,765,774,1,0,0,0,766,771,3,102,51, + 0,767,769,5,5,0,0,768,767,1,0,0,0,768,769,1,0,0,0,769,770,1,0,0,0,770, + 772,3,112,56,0,771,768,1,0,0,0,771,772,1,0,0,0,772,774,1,0,0,0,773,762, + 1,0,0,0,773,766,1,0,0,0,774,85,1,0,0,0,775,776,5,2,0,0,776,781,3,88,44, + 0,777,778,5,101,0,0,778,780,3,88,44,0,779,777,1,0,0,0,780,783,1,0,0,0, + 781,779,1,0,0,0,781,782,1,0,0,0,782,87,1,0,0,0,783,781,1,0,0,0,784,788, + 3,90,45,0,785,787,3,92,46,0,786,785,1,0,0,0,787,790,1,0,0,0,788,786,1, + 0,0,0,788,789,1,0,0,0,789,89,1,0,0,0,790,788,1,0,0,0,791,796,3,112,56, + 0,792,794,5,5,0,0,793,792,1,0,0,0,793,794,1,0,0,0,794,795,1,0,0,0,795, + 797,3,112,56,0,796,793,1,0,0,0,796,797,1,0,0,0,797,812,1,0,0,0,798,799, + 5,99,0,0,799,800,3,64,32,0,800,805,5,100,0,0,801,803,5,5,0,0,802,801,1, + 0,0,0,802,803,1,0,0,0,803,804,1,0,0,0,804,806,3,112,56,0,805,802,1,0,0, + 0,805,806,1,0,0,0,806,812,1,0,0,0,807,808,5,99,0,0,808,809,3,88,44,0,809, + 810,5,100,0,0,810,812,1,0,0,0,811,791,1,0,0,0,811,798,1,0,0,0,811,807, + 1,0,0,0,812,91,1,0,0,0,813,814,3,94,47,0,814,815,5,19,0,0,815,816,3,90, + 45,0,816,817,5,21,0,0,817,818,3,102,51,0,818,93,1,0,0,0,819,821,5,15,0, + 0,820,819,1,0,0,0,820,821,1,0,0,0,821,831,1,0,0,0,822,824,5,16,0,0,823, + 825,5,18,0,0,824,823,1,0,0,0,824,825,1,0,0,0,825,831,1,0,0,0,826,828,5, + 17,0,0,827,829,5,18,0,0,828,827,1,0,0,0,828,829,1,0,0,0,829,831,1,0,0, + 0,830,820,1,0,0,0,830,822,1,0,0,0,830,826,1,0,0,0,831,95,1,0,0,0,832,833, + 5,3,0,0,833,834,3,102,51,0,834,97,1,0,0,0,835,836,5,22,0,0,836,837,5,25, + 0,0,837,842,3,100,50,0,838,839,5,101,0,0,839,841,3,100,50,0,840,838,1, + 0,0,0,841,844,1,0,0,0,842,840,1,0,0,0,842,843,1,0,0,0,843,99,1,0,0,0,844, + 842,1,0,0,0,845,847,3,102,51,0,846,848,7,1,0,0,847,846,1,0,0,0,847,848, + 1,0,0,0,848,101,1,0,0,0,849,850,6,51,-1,0,850,851,5,8,0,0,851,858,3,102, + 51,16,852,853,5,12,0,0,853,858,3,102,51,15,854,855,5,91,0,0,855,858,3, + 102,51,14,856,858,3,104,52,0,857,849,1,0,0,0,857,852,1,0,0,0,857,854,1, + 0,0,0,857,856,1,0,0,0,858,928,1,0,0,0,859,860,10,13,0,0,860,861,5,89,0, + 0,861,927,3,102,51,14,862,863,10,12,0,0,863,864,7,6,0,0,864,927,3,102, + 51,13,865,866,10,11,0,0,866,867,7,7,0,0,867,927,3,102,51,12,868,869,10, + 10,0,0,869,870,7,8,0,0,870,927,3,102,51,11,871,873,10,9,0,0,872,874,5, + 8,0,0,873,872,1,0,0,0,873,874,1,0,0,0,874,875,1,0,0,0,875,876,5,33,0,0, + 876,877,3,102,51,0,877,878,5,6,0,0,878,879,3,102,51,10,879,927,1,0,0,0, + 880,882,10,8,0,0,881,883,5,8,0,0,882,881,1,0,0,0,882,883,1,0,0,0,883,884, + 1,0,0,0,884,885,5,13,0,0,885,927,3,102,51,9,886,887,10,4,0,0,887,888,7, + 9,0,0,888,927,3,102,51,5,889,890,10,3,0,0,890,891,5,6,0,0,891,927,3,102, + 51,4,892,893,10,2,0,0,893,894,5,7,0,0,894,927,3,102,51,3,895,897,10,7, + 0,0,896,898,5,8,0,0,897,896,1,0,0,0,897,898,1,0,0,0,898,899,1,0,0,0,899, + 900,5,20,0,0,900,901,5,99,0,0,901,902,3,70,35,0,902,903,5,100,0,0,903, + 927,1,0,0,0,904,906,10,6,0,0,905,907,5,8,0,0,906,905,1,0,0,0,906,907,1, + 0,0,0,907,908,1,0,0,0,908,909,5,20,0,0,909,910,5,99,0,0,910,915,3,102, + 51,0,911,912,5,101,0,0,912,914,3,102,51,0,913,911,1,0,0,0,914,917,1,0, + 0,0,915,913,1,0,0,0,915,916,1,0,0,0,916,918,1,0,0,0,917,915,1,0,0,0,918, + 919,5,100,0,0,919,927,1,0,0,0,920,921,10,5,0,0,921,923,5,24,0,0,922,924, + 5,8,0,0,923,922,1,0,0,0,923,924,1,0,0,0,924,925,1,0,0,0,925,927,5,85,0, + 0,926,859,1,0,0,0,926,862,1,0,0,0,926,865,1,0,0,0,926,868,1,0,0,0,926, + 871,1,0,0,0,926,880,1,0,0,0,926,886,1,0,0,0,926,889,1,0,0,0,926,892,1, + 0,0,0,926,895,1,0,0,0,926,904,1,0,0,0,926,920,1,0,0,0,927,930,1,0,0,0, + 928,926,1,0,0,0,928,929,1,0,0,0,929,103,1,0,0,0,930,928,1,0,0,0,931,950, + 3,114,57,0,932,950,3,106,53,0,933,950,3,110,55,0,934,950,5,105,0,0,935, + 950,5,104,0,0,936,937,5,27,0,0,937,938,5,99,0,0,938,939,3,70,35,0,939, + 940,5,100,0,0,940,950,1,0,0,0,941,942,5,99,0,0,942,943,3,70,35,0,943,944, + 5,100,0,0,944,950,1,0,0,0,945,946,5,99,0,0,946,947,3,102,51,0,947,948, + 5,100,0,0,948,950,1,0,0,0,949,931,1,0,0,0,949,932,1,0,0,0,949,933,1,0, + 0,0,949,934,1,0,0,0,949,935,1,0,0,0,949,936,1,0,0,0,949,941,1,0,0,0,949, + 945,1,0,0,0,950,105,1,0,0,0,951,952,3,108,54,0,952,965,5,99,0,0,953,966, + 5,86,0,0,954,956,5,31,0,0,955,954,1,0,0,0,955,956,1,0,0,0,956,957,1,0, + 0,0,957,962,3,102,51,0,958,959,5,101,0,0,959,961,3,102,51,0,960,958,1, + 0,0,0,961,964,1,0,0,0,962,960,1,0,0,0,962,963,1,0,0,0,963,966,1,0,0,0, + 964,962,1,0,0,0,965,953,1,0,0,0,965,955,1,0,0,0,965,966,1,0,0,0,966,967, + 1,0,0,0,967,968,5,100,0,0,968,107,1,0,0,0,969,974,3,112,56,0,970,974,5, + 16,0,0,971,974,5,17,0,0,972,974,5,81,0,0,973,969,1,0,0,0,973,970,1,0,0, + 0,973,971,1,0,0,0,973,972,1,0,0,0,974,109,1,0,0,0,975,976,3,112,56,0,976, + 977,5,102,0,0,977,979,1,0,0,0,978,975,1,0,0,0,978,979,1,0,0,0,979,980, + 1,0,0,0,980,981,3,112,56,0,981,111,1,0,0,0,982,983,7,10,0,0,983,113,1, + 0,0,0,984,994,5,107,0,0,985,994,5,108,0,0,986,994,5,106,0,0,987,994,5, + 109,0,0,988,994,5,110,0,0,989,994,5,111,0,0,990,994,5,83,0,0,991,994,5, + 84,0,0,992,994,5,85,0,0,993,984,1,0,0,0,993,985,1,0,0,0,993,986,1,0,0, + 0,993,987,1,0,0,0,993,988,1,0,0,0,993,989,1,0,0,0,993,990,1,0,0,0,993, + 991,1,0,0,0,993,992,1,0,0,0,994,115,1,0,0,0,995,997,5,40,0,0,996,998,7, + 11,0,0,997,996,1,0,0,0,997,998,1,0,0,0,998,1008,1,0,0,0,999,1001,5,41, + 0,0,1000,1002,7,11,0,0,1001,1000,1,0,0,0,1001,1002,1,0,0,0,1002,1008,1, + 0,0,0,1003,1005,5,42,0,0,1004,1006,7,11,0,0,1005,1004,1,0,0,0,1005,1006, + 1,0,0,0,1006,1008,1,0,0,0,1007,995,1,0,0,0,1007,999,1,0,0,0,1007,1003, + 1,0,0,0,1008,117,1,0,0,0,1009,1010,3,102,51,0,1010,1011,5,0,0,1,1011,119, + 1,0,0,0,126,121,137,140,146,166,175,178,188,192,204,209,217,222,225,233, + 240,250,257,271,276,285,296,306,309,316,325,332,339,344,353,380,385,389, + 401,407,424,428,435,442,447,450,456,460,463,476,485,491,496,506,511,516, + 519,523,533,540,549,556,562,570,582,587,592,597,604,611,613,622,632,643, + 648,657,663,667,675,683,687,691,695,698,702,705,708,711,714,725,737,741, + 749,757,760,768,771,773,781,788,793,796,802,805,811,820,824,828,830,842, + 847,857,873,882,897,906,915,923,926,928,949,955,962,965,973,978,993,997, + 1001,1005,1007 }; public static readonly ATN _ATN = diff --git a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs index cfe14e99..05748b8e 100644 --- a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs +++ b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs @@ -392,6 +392,9 @@ private static string TypeName(DataTypeContext type) => string.Join(' ', Rows = ins.Rows .Select(r => (IReadOnlyList)r.Select(e => LowerExpr(e, names)).ToList()) .ToList(), + // The multiple-record form's source is a query in its own right, so a declared parameter can + // appear in its WHERE just as it can in a VALUES list. + Source = ins.Source is null ? null : LowerParameters(ins.Source, names), }, _ => s, }; @@ -552,6 +555,11 @@ private static SqlStatement BuildInsert(InsertStatementContext ctx) return new InsertStatement(table, [], [(IReadOnlyList)[]], DefaultValues: true); var columns = ctx._columns.Select(Identifier).ToList(); + + // The multiple-record form: the rows come from a query rather than a VALUES list. + if (ctx.source is not null) + return new InsertStatement(table, columns, [], Source: BuildQueryExpression(ctx.source)); + var values = ctx.expression().Select(BuildExpression).ToList(); return new InsertStatement(table, columns, [values]); } diff --git a/test/LibRed.Engine.AccessTests/InsertSelectAccessTests.cs b/test/LibRed.Engine.AccessTests/InsertSelectAccessTests.cs new file mode 100644 index 00000000..116d5055 --- /dev/null +++ b/test/LibRed.Engine.AccessTests/InsertSelectAccessTests.cs @@ -0,0 +1,120 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +// The multiple-record append query, cross-checked against ACE. Tests that LibRed executes it are one thing; +// the question that decides whether the feature is right is what the real engine does with the same SQL. +// +// Two behaviours are worth measuring rather than assuming, because both are choices a reasonable +// implementation could make differently: +// - appending a table to itself: does the source read complete before the write begins, or does the scan +// consume its own output? Access is the authority on where that lands. +// - a source that yields no rows: an error, or a no-op reporting zero? +[Collection(AceCollection.Name)] +public class InsertSelectAccessTests : TempDatabaseTest +{ + private static string Copy() => TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "insel-ace-"); + + /// Runs the same statements through ACE and through LibRed, and compares what each ends up with. + private static void AssertSameAsAce(string[] setup, string append, string verify) + { + string acePath = Copy(), libRedPath = Copy(); + try + { + object?[] aceRows; + using (var connection = AceTestDatabase.Open(acePath)) + { + foreach (string sql in setup) Exec(connection, sql); + Exec(connection, append); + using var command = connection.CreateCommand(); + command.CommandText = verify; + using var reader = command.ExecuteReader(); + var rows = new List(); + while (reader.Read()) rows.Add(reader.GetValue(0)); + aceRows = [.. rows]; + } + + using var db = TemporaryDatabase.OpenTracked(libRedPath, readOnly: false); + var engine = new QueryEngine(db); + foreach (string sql in setup) engine.ExecuteNonQuery(sql); + engine.ExecuteNonQuery(append); + object?[] ourRows = [.. engine.ExecuteQuery(verify).Rows.Select(r => r[0])]; + + Assert.Equal( + aceRows.Select(v => Convert.ToString(v)), + ourRows.Select(v => Convert.ToString(v))); + } + finally + { + TemporaryDatabase.Delete(acePath); + TemporaryDatabase.Delete(libRedPath); + } + } + + [Fact] + public void Appends_the_sources_rows_as_ACE_does() => AssertSameAsAce( + [ + "CREATE TABLE SrcA (Id LONG, Name TEXT(50))", + "CREATE TABLE DstA (Id LONG, Name TEXT(50))", + "INSERT INTO SrcA (Id, Name) VALUES (1, 'one')", + "INSERT INTO SrcA (Id, Name) VALUES (2, 'two')", + "INSERT INTO SrcA (Id, Name) VALUES (3, 'three')", + ], + "INSERT INTO DstA (Id, Name) SELECT Id, Name FROM SrcA WHERE Id > 1", + "SELECT Name FROM DstA ORDER BY Id"); + + // Without a column list the source's output NAMES choose the target columns. The aliases are REVERSED + // here, so the values arrive in the opposite order to the names — the one arrangement where name-based + // and positional resolution give different answers instead of coinciding. ACE stores Id=7; positionally + // it would have stored 'seven' there. + [Fact] + public void Resolves_by_name_without_a_column_list_as_ACE_does() => AssertSameAsAce( + [ + "CREATE TABLE SrcB (A LONG, B TEXT(50))", + "CREATE TABLE DstB (Id LONG, Name TEXT(50))", + "INSERT INTO SrcB (A, B) VALUES (7, 'seven')", + "INSERT INTO SrcB (A, B) VALUES (8, 'eight')", + ], + "INSERT INTO DstB SELECT B AS Name, A AS Id FROM SrcB", + "SELECT Name FROM DstB ORDER BY Id"); + + // The Halloween case. If the scan fed its own output back in, this would not terminate at all — so the + // test hanging IS the failure, and agreeing with ACE on the row count is the pass. + [Fact] + public void Appending_a_table_to_itself_matches_ACE() => AssertSameAsAce( + [ + "CREATE TABLE SelfC (Id LONG, Name TEXT(50))", + "INSERT INTO SelfC (Id, Name) VALUES (1, 'a')", + "INSERT INTO SelfC (Id, Name) VALUES (2, 'b')", + ], + "INSERT INTO SelfC (Id, Name) SELECT Id, Name FROM SelfC", + "SELECT COUNT(*) FROM SelfC"); + + [Fact] + public void An_empty_source_appends_nothing_as_ACE_does() => AssertSameAsAce( + [ + "CREATE TABLE SrcD (Id LONG)", + "CREATE TABLE DstD (Id LONG)", + ], + "INSERT INTO DstD (Id) SELECT Id FROM SrcD WHERE Id > 0", + "SELECT COUNT(*) FROM DstD"); + + // No cross-check for column DEFAULTs: ACE's DDL over OLE DB rejects `DEFAULT 'x'` in CREATE TABLE with + // "Syntax error in field definition" — a default is a column property Access sets through DAO/ADOX, not + // something its SQL DDL can express. So the setup for such a comparison cannot be written on the ACE + // side at all, and the behaviour is covered by InsertSelectTests on the LibRed side instead. + // + // Worth recording rather than silently omitting: it is a limit of what can be COMPARED, not a place the + // two engines were found to differ. + + private static void Exec(OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Engine.AccessTests/InsertSelectShapeProbeTest.cs b/test/LibRed.Engine.AccessTests/InsertSelectShapeProbeTest.cs new file mode 100644 index 00000000..2fac91ef --- /dev/null +++ b/test/LibRed.Engine.AccessTests/InsertSelectShapeProbeTest.cs @@ -0,0 +1,78 @@ +using System.Data.OleDb; +using Xunit; + +namespace LibRed.Engine.Tests; + +// PROBE: how ACE resolves an append's source columns to the target's when no column list is given. +// +// LibRed was written to map POSITIONALLY, reasoning from "the names need not match, only the count". ACE +// rejected that with "unknown field name: 'A'" — quoting a SOURCE column name as though it had looked for it +// in the TARGET — which says it resolves by NAME. That is worth pinning down exactly rather than inferring +// from one error message, because the difference is silent: positional mapping into a table whose columns +// happen to be type-compatible puts values in the wrong columns and reports success. +[Collection(AceCollection.Name)] +public class InsertSelectShapeProbeTest(ITestOutputHelper output) : TempDatabaseTest +{ + [Fact] + public void Probe_source_to_target_column_resolution() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_INSERT_SELECT") == "1", + "set LIBRED_INSERT_SELECT=1 — this probe needs ACE"); + + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "insel-probe-"); + try + { + using var connection = AceTestDatabase.Open(path); + Exec(connection, "CREATE TABLE PSrc (A LONG, B TEXT(50))"); + Exec(connection, "CREATE TABLE PDst (Id LONG, Name TEXT(50))"); + Exec(connection, "CREATE TABLE PSame (A LONG, B TEXT(50))"); + Exec(connection, "INSERT INTO PSrc (A, B) VALUES (7, 'seven')"); + + // Each of these answers a different question about the no-column-list form. + foreach ((string label, string sql) in ((string, string)[]) + [ + ("names differ", "INSERT INTO PDst SELECT A, B FROM PSrc"), + ("aliased to target names", "INSERT INTO PDst SELECT A AS Id, B AS Name FROM PSrc"), + ("names match", "INSERT INTO PSame SELECT A, B FROM PSrc"), + ("star, names match", "INSERT INTO PSame SELECT * FROM PSrc"), + ("star, names differ", "INSERT INTO PDst SELECT * FROM PSrc"), + ("explicit list, differing","INSERT INTO PDst (Id, Name) SELECT A, B FROM PSrc"), + // Reversed aliases: if resolution is by NAME this lands B in Id and A in Name, which is the + // case that shows positional and name-based mapping actually disagree rather than coincide. + ("aliases reversed", "INSERT INTO PDst SELECT B AS Name, A AS Id FROM PSrc"), + ]) + { + try + { + Exec(connection, sql); + output.WriteLine($" {label,-26} ACCEPTED {sql}"); + } + catch (OleDbException e) + { + output.WriteLine($" {label,-26} rejected — {e.Message.Split('.')[0]}"); + } + } + + output.WriteLine(""); + foreach (string table in (string[])["PDst", "PSame"]) + { + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT * FROM {table}"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + output.WriteLine($" {table}: " + string.Join(" | ", + Enumerable.Range(0, reader.FieldCount) + .Select(i => $"{reader.GetName(i)}={reader.GetValue(i)}"))); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Exec(OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Engine.Tests/InsertSelectTests.cs b/test/LibRed.Engine.Tests/InsertSelectTests.cs new file mode 100644 index 00000000..270b2d29 --- /dev/null +++ b/test/LibRed.Engine.Tests/InsertSelectTests.cs @@ -0,0 +1,195 @@ +using LibRed; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +// Access's MULTIPLE-RECORD append query: +// INSERT INTO target [(field, …)] SELECT [source.]field, … FROM tableexpression +// +// The single-record form is the VALUES one. Access has no multi-row VALUES syntax at all, so "many rows in +// one INSERT" and "rows from a query" are the same feature there, not two. +// +// The IN externaldatabase clause both forms allow is not implemented: appending into another file belongs to +// the linked-database subsystem LibRed does not have. +public class InsertSelectTests : TempDatabaseTest +{ + private static QueryEngine Fresh() + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "insel-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); + } + + [Fact] + public void Appends_every_row_the_source_produces() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("CREATE TABLE Src (Id LONG, Name TEXT(50))"); + engine.ExecuteNonQuery("CREATE TABLE Dst (Id LONG, Name TEXT(50))"); + engine.ExecuteNonQuery("INSERT INTO Src (Id, Name) VALUES (1, 'one')"); + engine.ExecuteNonQuery("INSERT INTO Src (Id, Name) VALUES (2, 'two')"); + engine.ExecuteNonQuery("INSERT INTO Src (Id, Name) VALUES (3, 'three')"); + + int affected = engine.ExecuteNonQuery("INSERT INTO Dst (Id, Name) SELECT Id, Name FROM Src"); + + Assert.Equal(3, affected); + Assert.Equal(3, Convert.ToInt32(engine.ExecuteQuery("SELECT COUNT(*) FROM Dst").Rows.Single()[0])); + Assert.Equal("two", engine.ExecuteQuery("SELECT Name FROM Dst WHERE Id = 2").Rows.Single()[0]); + } + + [Fact] + public void Applies_the_sources_where_and_order() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("CREATE TABLE Src (Id LONG, Name TEXT(50))"); + engine.ExecuteNonQuery("CREATE TABLE Dst (Id LONG, Name TEXT(50))"); + for (int i = 1; i <= 5; i++) + engine.ExecuteNonQuery($"INSERT INTO Src (Id, Name) VALUES ({i}, 'n{i}')"); + + int affected = engine.ExecuteNonQuery("INSERT INTO Dst (Id, Name) SELECT Id, Name FROM Src WHERE Id > 3"); + + Assert.Equal(2, affected); + Assert.Equal([4, 5], engine.ExecuteQuery("SELECT Id FROM Dst ORDER BY Id").Rows.Select(r => Convert.ToInt32(r[0]))); + } + + // No column list: the source's output NAMES choose the target columns. Measured against ACE — it is not + // positional, and the difference is silent when the columns are type-compatible. + [Fact] + public void Without_a_column_list_the_source_names_choose_the_columns() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("CREATE TABLE Src (A LONG, B TEXT(50))"); + engine.ExecuteNonQuery("CREATE TABLE Dst (Id LONG, Name TEXT(50))"); + engine.ExecuteNonQuery("INSERT INTO Src (A, B) VALUES (7, 'seven')"); + + engine.ExecuteNonQuery("INSERT INTO Dst SELECT A AS Id, B AS Name FROM Src"); + + object?[] row = engine.ExecuteQuery("SELECT Id, Name FROM Dst").Rows.Single(); + Assert.Equal(7, Convert.ToInt32(row[0])); + Assert.Equal("seven", row[1]); + } + + // The case where name-based and positional resolution actually disagree: the aliases are reversed, so the + // values arrive in the opposite order to the names. ACE routes by name, storing Id=7 — positionally it + // would have put 'seven' there. + [Fact] + public void Reversed_aliases_route_by_name_not_position() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("CREATE TABLE Src (A LONG, B TEXT(50))"); + engine.ExecuteNonQuery("CREATE TABLE Dst (Id LONG, Name TEXT(50))"); + engine.ExecuteNonQuery("INSERT INTO Src (A, B) VALUES (7, 'seven')"); + + engine.ExecuteNonQuery("INSERT INTO Dst SELECT B AS Name, A AS Id FROM Src"); + + object?[] row = engine.ExecuteQuery("SELECT Id, Name FROM Dst").Rows.Single(); + Assert.Equal(7, Convert.ToInt32(row[0])); + Assert.Equal("seven", row[1]); + } + + // A source name the target does not have is an error, as it is in ACE ("unknown field name: 'A'"). + [Fact] + public void A_source_name_the_target_lacks_is_rejected() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("CREATE TABLE Src (A LONG, B TEXT(50))"); + engine.ExecuteNonQuery("CREATE TABLE Dst (Id LONG, Name TEXT(50))"); + engine.ExecuteNonQuery("INSERT INTO Src (A, B) VALUES (7, 'seven')"); + + Assert.Throws( + () => engine.ExecuteNonQuery("INSERT INTO Dst SELECT A, B FROM Src")); + } + + // The Halloween problem: appending a table to itself must read the source to completion BEFORE writing, + // or the scan consumes its own output and never terminates. Access doubles the table and stops. + [Fact] + public void Appending_a_table_to_itself_doubles_it_and_terminates() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("CREATE TABLE Self (Id LONG, Name TEXT(50))"); + engine.ExecuteNonQuery("INSERT INTO Self (Id, Name) VALUES (1, 'a')"); + engine.ExecuteNonQuery("INSERT INTO Self (Id, Name) VALUES (2, 'b')"); + + int affected = engine.ExecuteNonQuery("INSERT INTO Self (Id, Name) SELECT Id, Name FROM Self"); + + Assert.Equal(2, affected); + Assert.Equal(4, Convert.ToInt32(engine.ExecuteQuery("SELECT COUNT(*) FROM Self").Rows.Single()[0])); + } + + // Columns the append does not mention still take their DEFAULT, exactly as in the VALUES form — the two + // forms share that path rather than each having their own. + [Fact] + public void Unmentioned_columns_take_their_default() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("CREATE TABLE Src (Id LONG)"); + engine.ExecuteNonQuery("CREATE TABLE Dst (Id LONG, Note TEXT(50) DEFAULT 'unset')"); + engine.ExecuteNonQuery("INSERT INTO Src (Id) VALUES (1)"); + + engine.ExecuteNonQuery("INSERT INTO Dst (Id) SELECT Id FROM Src"); + + Assert.Equal("unset", engine.ExecuteQuery("SELECT Note FROM Dst").Rows.Single()[0]); + } + + // An AutoNumber target generates its own ids rather than taking the source's, and @@IDENTITY reports the + // LAST one — the whole point of a multi-row append being one statement. + [Fact] + public void An_autonumber_target_generates_ids_and_publishes_the_last() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("CREATE TABLE Src (Name TEXT(50))"); + engine.ExecuteNonQuery("CREATE TABLE Dst (Id COUNTER PRIMARY KEY, Name TEXT(50))"); + engine.ExecuteNonQuery("INSERT INTO Src (Name) VALUES ('a')"); + engine.ExecuteNonQuery("INSERT INTO Src (Name) VALUES ('b')"); + + engine.ExecuteNonQuery("INSERT INTO Dst (Name) SELECT Name FROM Src"); + + Assert.Equal([1, 2], engine.ExecuteQuery("SELECT Id FROM Dst ORDER BY Id").Rows.Select(r => Convert.ToInt32(r[0]))); + Assert.Equal(2, Convert.ToInt32(engine.ExecuteQuery("SELECT @@IDENTITY").Rows.Single()[0])); + } + + // A UNION feeding an append. Access documents the source as a SELECT; this is the shape EF emits from a + // Concat, and costs nothing extra because the source is planned as any query expression. + [Fact] + public void A_union_can_feed_an_append() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("CREATE TABLE A (Id LONG)"); + engine.ExecuteNonQuery("CREATE TABLE B (Id LONG)"); + engine.ExecuteNonQuery("CREATE TABLE Dst (Id LONG)"); + engine.ExecuteNonQuery("INSERT INTO A (Id) VALUES (1)"); + engine.ExecuteNonQuery("INSERT INTO B (Id) VALUES (2)"); + + int affected = engine.ExecuteNonQuery("INSERT INTO Dst (Id) SELECT Id FROM A UNION ALL SELECT Id FROM B"); + + Assert.Equal(2, affected); + Assert.Equal([1, 2], engine.ExecuteQuery("SELECT Id FROM Dst ORDER BY Id").Rows.Select(r => Convert.ToInt32(r[0]))); + } + + [Fact] + public void A_mismatched_column_count_is_rejected() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("CREATE TABLE Src (Id LONG, Name TEXT(50))"); + engine.ExecuteNonQuery("CREATE TABLE Dst (Id LONG, Name TEXT(50))"); + engine.ExecuteNonQuery("INSERT INTO Src (Id, Name) VALUES (1, 'one')"); + + Assert.Throws( + () => engine.ExecuteNonQuery("INSERT INTO Dst (Id, Name) SELECT Id FROM Src")); + } + + // A source that yields nothing is not an error: zero rows appended, and @@ROWCOUNT says so. + [Fact] + public void An_empty_source_appends_nothing() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("CREATE TABLE Src (Id LONG)"); + engine.ExecuteNonQuery("CREATE TABLE Dst (Id LONG)"); + + int affected = engine.ExecuteNonQuery("INSERT INTO Dst (Id) SELECT Id FROM Src WHERE Id > 0"); + + Assert.Equal(0, affected); + Assert.Equal(0, Convert.ToInt32(engine.ExecuteQuery("SELECT COUNT(*) FROM Dst").Rows.Single()[0])); + } +} From 24c7566094e6bd70cb93770f08f59f333b9b6650 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 21:23:51 +0800 Subject: [PATCH 22/48] LibRed: the make-table query, SELECT ... INTO SELECT field1[, field2[, ...]] INTO newtable [IN externaldatabase] FROM source A make-table looks like a SELECT and is an action query: it writes a table and returns no rows. It runs with the writing statements, inside the implicit transaction, so a failure part-way cannot leave a half-populated table. Measured from ACE before implementing, because most of it could reasonably have gone the other way: - A make-table copies DATA AND COLUMN DEFINITIONS ONLY. The source's PRIMARY KEY and its indexes are NOT copied, so archiving a keyed table gives an unkeyed copy. - A result column that IS a source column keeps that column's definition, width included: a source Text(30) arrives as Text(30). A COMPUTED column has no declared width to copy and gets Text at the 255 maximum. - An empty result still creates the table. - An existing target is an error - the docs call it "a trappable error" and ACE says "Table 'X' already exists". The width rule cost a round trip worth recording. The probe output showed both cases - a named column at Text(60) and a concatenation at Text(510), two lines apart - and this generalised from the second while looking at the first. So the cross-check against ACE caught a misreading of a measurement, not just an unmeasured guess. Two routing points the page cache and the tests found rather than review: ExecuteQuery reaches ExecuteQueryCore without passing Route, so a make-table invoked that way returned the source's rows; and Scoped classified any SelectStatement as read-only, so the table write was refused by the guard that rejects a write in a shared scope rather than silently upgrading it. Both now account for INTO. IN externaldatabase is not implemented, as on INSERT: creating a table in another file belongs to the linked-database subsystem LibRed does not have. Co-Authored-By: Claude Opus 5 --- .../Execution/StatementExecutor.cs | 105 ++ src/LibRed/LibRed.Engine/QueryEngine.cs | 12 +- src/LibRed/LibRed.Sql/Ast/Statements.cs | 13 +- src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 | 7 +- .../Grammar/Generated/AccessSqlParser.cs | 1116 +++++++++-------- src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs | 3 +- .../SelectIntoAccessTests.cs | 109 ++ .../SelectIntoShapeProbeTest.cs | 109 ++ test/LibRed.Engine.Tests/SelectIntoTests.cs | 142 +++ 9 files changed, 1063 insertions(+), 553 deletions(-) create mode 100644 test/LibRed.Engine.AccessTests/SelectIntoAccessTests.cs create mode 100644 test/LibRed.Engine.AccessTests/SelectIntoShapeProbeTest.cs create mode 100644 test/LibRed.Engine.Tests/SelectIntoTests.cs diff --git a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs index d83430e9..c3e0c95f 100644 --- a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs @@ -30,6 +30,7 @@ internal sealed class StatementExecutor(JetDatabase database, IReadOnlyDictionar DropTableStatement dropTable => DropTable(dropTable.Table), DropViewStatement dropView => DropQueryObject(dropView.View, "view"), DropProcedureStatement dropProc => DropQueryObject(dropProc.Procedure, "procedure"), + SelectStatement { Into: not null } makeTable => ExecuteSelectInto(makeTable), InsertStatement insert => ExecuteInsert(insert), UpdateStatement update => ExecuteUpdate(update), DeleteStatement delete => ExecuteDelete(delete), @@ -618,6 +619,110 @@ private int ExecuteCreateActionProcedure(CreateActionProcedureStatement statemen OrderBy: d.OrderBy.Select(o => new ViewOrderBySpec(o.Expression, o.Descending)).ToList(), Top: d.Top); + /// + /// A make-table query: SELECT … INTO newtable FROM source. + /// + /// + /// The new table takes the result's column names and types and NOTHING else. Measured against ACE + /// (SelectIntoShapeProbeTest): a source's PRIMARY KEY and indexes are not copied, so archiving a + /// keyed table gives an unkeyed copy. An expression column is typed from the expression rather than from + /// any source column — Qty * 2 gives Int32, a concatenation gives Text at the 255-character + /// maximum, and SUM widens to Double. An empty result still creates the table. An existing name is + /// an error, which the docs call "a trappable error" and ACE reports as "Table 'X' already exists". + /// + private int ExecuteSelectInto(SelectStatement statement) + { + string target = statement.Into!; + if (_database.Catalog.Tables.Any(t => string.Equals(t.Name, target, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"Table '{target}' already exists."); + + // Run the query first — with INTO stripped, or planning would recurse back into this method — and + // materialise it. The rows have to exist before the table does: the source may read a table this + // statement is about to change, and the row count is not known until the read completes. + ResultSet source = _scalarRunner.ExecuteQuery(Planning.IndexSelection.Apply( + Planning.QueryPlanner.PlanSelect(statement with { Into = null }), _database.Catalog)); + var rows = source.Rows.ToList(); + + // A result column that IS a source column keeps that column's DEFINITION — its type and its declared + // width. Only a computed column is typed from its value. Measured: a source Text(30) arrives as + // Text(60) bytes, not the Text(510) maximum, while a concatenation does get the maximum because + // there is no declared width to copy. + var sourceColumns = SourceColumnsFor(statement); + var specs = source.ColumnNames + .Select((name, i) => sourceColumns.TryGetValue(name, out ColumnDef? column) + ? new ColumnSpec(name, column.Type, column.Length, column.IsFixedLength, + Precision: column.Precision, Scale: column.Scale) + : ColumnSpecFor(name, source.ColumnTypes[i])) + .ToList(); + _database.CreateTable(target, specs); + + Table table = _database.OpenTable(target); + foreach (object?[] row in rows) + { + var values = new object?[specs.Count]; + Array.Copy(row, values, Math.Min(row.Length, values.Length)); + table.Insert(values); + } + + if (_session is not null) _session.RowCount = rows.Count; + return rows.Count; + } + + /// + /// The source columns a make-table's output names can be copied from, by output name. + /// + /// + /// Only projections that ARE a column carry a definition to copy: SELECT Label and + /// SELECT Label AS L do, SELECT Label & '!' does not. SELECT * takes every + /// column of every table in the FROM. Anything not found here falls back to typing from the value. + /// + private Dictionary SourceColumnsFor(SelectStatement statement) + { + var available = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string table in TablesIn(statement.From)) + if (_database.Catalog.Tables.FirstOrDefault( + t => string.Equals(t.Name, table, StringComparison.OrdinalIgnoreCase)) is { } def) + foreach (ColumnDef column in def.Columns) + available.TryAdd(column.Name, column); + + if (statement.IsSelectStar) return available; + + var byOutputName = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (SelectItem item in statement.Projection) + if (item.Value is ColumnReference reference && + available.TryGetValue(reference.Column, out ColumnDef? column)) + byOutputName.TryAdd(item.Alias ?? reference.Column, column); + return byOutputName; + } + + /// The table names a FROM clause reaches, so their column definitions can be found. + private static IEnumerable TablesIn(TableReference? from) => from switch + { + NamedTable named => [named.Name], + JoinTable join => [.. TablesIn(join.Left), .. TablesIn(join.Right)], + // A derived table has no stored column definitions to copy, so its columns fall back to being typed + // from their values — as a computed column does. + _ => [], + }; + + /// The column a COMPUTED result column becomes. Text takes the 255-character maximum, because an + /// expression carries no declared width — ACE gives a concatenation Text(255) rather than measuring the + /// values it produced. + private static ColumnSpec ColumnSpecFor(string name, Type clrType) => Type.GetTypeCode(clrType) switch + { + TypeCode.Boolean => new ColumnSpec(name, JetDataType.Boolean, 1, IsFixedLength: true), + TypeCode.Byte => new ColumnSpec(name, JetDataType.Byte, 1, IsFixedLength: true), + TypeCode.Int16 => new ColumnSpec(name, JetDataType.Int16, 2, IsFixedLength: true), + TypeCode.Int32 => new ColumnSpec(name, JetDataType.Int32, 4, IsFixedLength: true), + TypeCode.Single => new ColumnSpec(name, JetDataType.Single, 4, IsFixedLength: true), + TypeCode.Double => new ColumnSpec(name, JetDataType.Double, 8, IsFixedLength: true), + TypeCode.Decimal => new ColumnSpec(name, JetDataType.Currency, 8, IsFixedLength: true), + TypeCode.DateTime => new ColumnSpec(name, JetDataType.DateTime, 8, IsFixedLength: true), + _ when clrType == typeof(Guid) => new ColumnSpec(name, JetDataType.Guid, 16, IsFixedLength: true), + _ when clrType == typeof(byte[]) => new ColumnSpec(name, JetDataType.Binary, 255, IsFixedLength: false), + _ => new ColumnSpec(name, JetDataType.Text, 255 * 2, IsFixedLength: false), + }; + private int ExecuteInsert(InsertStatement statement) { Table table = _database.OpenTable(statement.Table); diff --git a/src/LibRed/LibRed.Engine/QueryEngine.cs b/src/LibRed/LibRed.Engine/QueryEngine.cs index e6a19126..7a0cfc27 100644 --- a/src/LibRed/LibRed.Engine/QueryEngine.cs +++ b/src/LibRed/LibRed.Engine/QueryEngine.cs @@ -48,13 +48,18 @@ public ResultSet ExecuteQuery(string sql, IReadOnlyDictionary? /// publish as one unit. Anything not provably read-only takes the exclusive scope — a shared scope that /// then writes is rejected, not silently upgraded. private T Scoped(SqlStatement statement, Func action) => - statement is SelectStatement or SetOperationStatement or SystemVariableSelectStatement + // A SELECT with INTO is a make-table query: it looks like a read and writes a table, so it takes the + // exclusive scope with the other writers. The guard above caught this rather than letting it through. + statement is SelectStatement { Into: null } or SetOperationStatement or SystemVariableSelectStatement ? _database.ReadConsistent(action) : _database.WriteExclusive(action); private ResultSet ExecuteQueryCore(SqlStatement parsed, IReadOnlyDictionary? parameters) { if (parsed is ExecuteStatement exec) return ExecuteProcedure(exec, parameters).Rows; + // A make-table query is an action query however it was invoked: run it and return nothing, rather + // than handing the caller the rows it just wrote into a table. + if (parsed is SelectStatement { Into: not null }) return Route(parsed, parameters).Rows; SqlStatement ast = ViewExpander.Expand(parsed, _database.Catalog.Views, _parser); BoundStatement bound = _binder.Bind(ast); if (bound.Statement is TransactionControlStatement txnControl) @@ -128,6 +133,11 @@ private CommandResult Route(SqlStatement statement, IReadOnlyDictionary + /// The INTO newtable of a MAKE-TABLE query: the rows are written to a new table of that name + /// rather than returned. Null for an ordinary SELECT. + /// + /// + /// The new table takes the result's column names and types and nothing else. Measured against ACE: the + /// source's PRIMARY KEY and indexes are NOT copied, an expression column is typed from the expression + /// (Qty * 2 gives Int32, a concatenation gives Text(255)), SUM widens to Double, an empty + /// result still creates the table, and an existing name is an error ("Table 'X' already exists"). + /// + string? Into = null) : SqlStatement; /// EXECUTE|EXEC procedure [arg, …] — invokes a stored procedure/query by name, passing /// positional argument values that bind to its declared parameters (in declaration order). diff --git a/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 b/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 index a21a4a74..251350ee 100644 --- a/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 +++ b/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 @@ -215,8 +215,13 @@ queryTerm setOperator : UNION ALL? | INTERSECT | EXCEPT ; // The FROM clause is optional: ACE accepts a bare `SELECT 2` (verified) — a FROM-less SELECT yields one row. +// +// INTO makes it a MAKE-TABLE query: the rows go into a new table rather than back to the caller. +// SELECT field1[, field2[, …]] INTO newtable [IN externaldatabase] FROM source +// The IN externaldatabase clause is deliberately absent, as it is on INSERT — creating a table in another +// file is part of the linked-database subsystem LibRed does not have. selectStatement - : SELECT predicate=selectPredicate? topClause? selectList fromClause? whereClause? groupByClause? havingClause? orderByClause? + : SELECT predicate=selectPredicate? topClause? selectList (INTO into=identifier)? fromClause? whereClause? groupByClause? havingClause? orderByClause? ; // The optional row predicate. ALL is the default (return every row); DISTINCT dedupes on the output diff --git a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs index 752394fe..e48c7c61 100644 --- a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs +++ b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs @@ -4255,6 +4255,7 @@ public SetOperatorContext setOperator() { public partial class SelectStatementContext : ParserRuleContext { public SelectPredicateContext predicate; + public IdentifierContext into; [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode SELECT() { return GetToken(AccessSqlParser.SELECT, 0); } [System.Diagnostics.DebuggerNonUserCode] public SelectListContext selectList() { return GetRuleContext(0); @@ -4262,6 +4263,7 @@ [System.Diagnostics.DebuggerNonUserCode] public SelectListContext selectList() { [System.Diagnostics.DebuggerNonUserCode] public TopClauseContext topClause() { return GetRuleContext(0); } + [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode INTO() { return GetToken(AccessSqlParser.INTO, 0); } [System.Diagnostics.DebuggerNonUserCode] public FromClauseContext fromClause() { return GetRuleContext(0); } @@ -4280,6 +4282,9 @@ [System.Diagnostics.DebuggerNonUserCode] public OrderByClauseContext orderByClau [System.Diagnostics.DebuggerNonUserCode] public SelectPredicateContext selectPredicate() { return GetRuleContext(0); } + [System.Diagnostics.DebuggerNonUserCode] public IdentifierContext identifier() { + return GetRuleContext(0); + } public SelectStatementContext(ParserRuleContext parent, int invokingState) : base(parent, invokingState) { @@ -4325,52 +4330,64 @@ public SelectStatementContext selectStatement() { State = 700; selectList(); - State = 702; + State = 703; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - if (_la==FROM) { + if (_la==INTO) { { State = 701; + Match(INTO); + State = 702; + _localctx.into = identifier(); + } + } + + State = 706; + ErrorHandler.Sync(this); + _la = TokenStream.LA(1); + if (_la==FROM) { + { + State = 705; fromClause(); } } - State = 705; + State = 709; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==WHERE) { { - State = 704; + State = 708; whereClause(); } } - State = 708; + State = 712; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==GROUP) { { - State = 707; + State = 711; groupByClause(); } } - State = 711; + State = 715; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==HAVING) { { - State = 710; + State = 714; havingClause(); } } - State = 714; + State = 718; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ORDER) { { - State = 713; + State = 717; orderByClause(); } } @@ -4413,7 +4430,7 @@ public SelectPredicateContext selectPredicate() { try { EnterOuterAlt(_localctx, 1); { - State = 716; + State = 720; _la = TokenStream.LA(1); if ( !((((_la) & ~0x3f) == 0 && ((1L << _la) & 37580963840L) != 0)) ) { ErrorHandler.RecoverInline(this); @@ -4469,25 +4486,25 @@ public GroupByClauseContext groupByClause() { try { EnterOuterAlt(_localctx, 1); { - State = 718; + State = 722; Match(GROUP); - State = 719; + State = 723; Match(BY); - State = 720; + State = 724; expression(0); - State = 725; + State = 729; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 721; + State = 725; Match(COMMA); - State = 722; + State = 726; expression(0); } } - State = 727; + State = 731; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -4529,9 +4546,9 @@ public HavingClauseContext havingClause() { try { EnterOuterAlt(_localctx, 1); { - State = 728; + State = 732; Match(HAVING); - State = 729; + State = 733; expression(0); } } @@ -4586,18 +4603,18 @@ public TopClauseContext topClause() { int _alt; EnterOuterAlt(_localctx, 1); { - State = 731; + State = 735; Match(TOP); - State = 732; + State = 736; topOperand(); - State = 737; + State = 741; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,85,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,86,Context); while ( _alt!=2 && _alt!=global::Antlr4.Runtime.Atn.ATN.INVALID_ALT_NUMBER ) { if ( _alt==1 ) { { { - State = 733; + State = 737; _la = TokenStream.LA(1); if ( !(_la==PLUS || _la==MINUS) ) { ErrorHandler.RecoverInline(this); @@ -4606,21 +4623,21 @@ public TopClauseContext topClause() { ErrorHandler.ReportMatch(this); Consume(); } - State = 734; + State = 738; topOperand(); } } } - State = 739; + State = 743; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,85,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,86,Context); } - State = 741; + State = 745; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==PERCENT) { { - State = 740; + State = 744; _localctx.percent = Match(PERCENT); } } @@ -4664,31 +4681,31 @@ public TopOperandContext topOperand() { TopOperandContext _localctx = new TopOperandContext(Context, State); EnterRule(_localctx, 80, RULE_topOperand); try { - State = 749; + State = 753; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case INTEGER_LITERAL: EnterOuterAlt(_localctx, 1); { - State = 743; + State = 747; Match(INTEGER_LITERAL); } break; case PARAM: EnterOuterAlt(_localctx, 2); { - State = 744; + State = 748; Match(PARAM); } break; case LPAREN: EnterOuterAlt(_localctx, 3); { - State = 745; + State = 749; Match(LPAREN); - State = 746; + State = 750; expression(0); - State = 747; + State = 751; Match(RPAREN); } break; @@ -4738,13 +4755,13 @@ public SelectListContext selectList() { EnterRule(_localctx, 82, RULE_selectList); int _la; try { - State = 760; + State = 764; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case STAR: EnterOuterAlt(_localctx, 1); { - State = 751; + State = 755; Match(STAR); } break; @@ -4772,21 +4789,21 @@ public SelectListContext selectList() { case IDENTIFIER: EnterOuterAlt(_localctx, 2); { - State = 752; + State = 756; selectItem(); - State = 757; + State = 761; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 753; + State = 757; Match(COMMA); - State = 754; + State = 758; selectItem(); } } - State = 759; + State = 763; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -4858,18 +4875,18 @@ public SelectItemContext selectItem() { EnterRule(_localctx, 84, RULE_selectItem); int _la; try { - State = 773; + State = 777; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,92,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,93,Context) ) { case 1: _localctx = new QualifiedStarSelectItemContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 762; + State = 766; ((QualifiedStarSelectItemContext)_localctx).qualifier = identifier(); - State = 763; + State = 767; Match(DOT); - State = 764; + State = 768; Match(STAR); } break; @@ -4877,24 +4894,24 @@ public SelectItemContext selectItem() { _localctx = new ExpressionSelectItemContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 766; + State = 770; expression(0); - State = 771; + State = 775; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS || ((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) { { - State = 768; + State = 772; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS) { { - State = 767; + State = 771; Match(AS); } } - State = 770; + State = 774; ((ExpressionSelectItemContext)_localctx).alias = identifier(); } } @@ -4947,23 +4964,23 @@ public FromClauseContext fromClause() { try { EnterOuterAlt(_localctx, 1); { - State = 775; + State = 779; Match(FROM); - State = 776; + State = 780; tableSource(); - State = 781; + State = 785; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 777; + State = 781; Match(COMMA); - State = 778; + State = 782; tableSource(); } } - State = 783; + State = 787; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -5011,19 +5028,19 @@ public TableSourceContext tableSource() { try { EnterOuterAlt(_localctx, 1); { - State = 784; - tablePrimary(); State = 788; + tablePrimary(); + State = 792; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 753664L) != 0)) { { { - State = 785; + State = 789; joinClause(); } } - State = 790; + State = 794; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -5110,31 +5127,31 @@ public TablePrimaryContext tablePrimary() { EnterRule(_localctx, 90, RULE_tablePrimary); int _la; try { - State = 811; + State = 815; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,99,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,100,Context) ) { case 1: _localctx = new NamedTablePrimaryContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 791; + State = 795; ((NamedTablePrimaryContext)_localctx).table = identifier(); - State = 796; + State = 800; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS || ((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) { { - State = 793; + State = 797; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS) { { - State = 792; + State = 796; Match(AS); } } - State = 795; + State = 799; ((NamedTablePrimaryContext)_localctx).alias = identifier(); } } @@ -5145,28 +5162,28 @@ public TablePrimaryContext tablePrimary() { _localctx = new SubqueryPrimaryContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 798; + State = 802; Match(LPAREN); - State = 799; + State = 803; queryExpression(); - State = 800; + State = 804; Match(RPAREN); - State = 805; + State = 809; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS || ((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) { { - State = 802; + State = 806; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==AS) { { - State = 801; + State = 805; Match(AS); } } - State = 804; + State = 808; ((SubqueryPrimaryContext)_localctx).alias = identifier(); } } @@ -5177,11 +5194,11 @@ public TablePrimaryContext tablePrimary() { _localctx = new ParenJoinPrimaryContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 807; + State = 811; Match(LPAREN); - State = 808; + State = 812; tableSource(); - State = 809; + State = 813; Match(RPAREN); } break; @@ -5230,15 +5247,15 @@ public JoinClauseContext joinClause() { try { EnterOuterAlt(_localctx, 1); { - State = 813; + State = 817; joinType(); - State = 814; + State = 818; Match(JOIN); - State = 815; + State = 819; tablePrimary(); - State = 816; + State = 820; Match(ON); - State = 817; + State = 821; expression(0); } } @@ -5304,7 +5321,7 @@ public JoinTypeContext joinType() { EnterRule(_localctx, 94, RULE_joinType); int _la; try { - State = 830; + State = 834; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case INNER: @@ -5312,12 +5329,12 @@ public JoinTypeContext joinType() { _localctx = new InnerJoinContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 820; + State = 824; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==INNER) { { - State = 819; + State = 823; Match(INNER); } } @@ -5328,14 +5345,14 @@ public JoinTypeContext joinType() { _localctx = new LeftJoinContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 822; + State = 826; Match(LEFT); - State = 824; + State = 828; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==OUTER) { { - State = 823; + State = 827; Match(OUTER); } } @@ -5346,14 +5363,14 @@ public JoinTypeContext joinType() { _localctx = new RightJoinContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 826; + State = 830; Match(RIGHT); - State = 828; + State = 832; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==OUTER) { { - State = 827; + State = 831; Match(OUTER); } } @@ -5400,9 +5417,9 @@ public WhereClauseContext whereClause() { try { EnterOuterAlt(_localctx, 1); { - State = 832; + State = 836; Match(WHERE); - State = 833; + State = 837; expression(0); } } @@ -5451,25 +5468,25 @@ public OrderByClauseContext orderByClause() { try { EnterOuterAlt(_localctx, 1); { - State = 835; + State = 839; Match(ORDER); - State = 836; + State = 840; Match(BY); - State = 837; + State = 841; orderByItem(); - State = 842; + State = 846; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 838; + State = 842; Match(COMMA); - State = 839; + State = 843; orderByItem(); } } - State = 844; + State = 848; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -5514,14 +5531,14 @@ public OrderByItemContext orderByItem() { try { EnterOuterAlt(_localctx, 1); { - State = 845; + State = 849; expression(0); - State = 847; + State = 851; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ASC || _la==DESC) { { - State = 846; + State = 850; _localctx.dir = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==ASC || _la==DESC) ) { @@ -5878,7 +5895,7 @@ private ExpressionContext expression(int _p) { int _alt; EnterOuterAlt(_localctx, 1); { - State = 857; + State = 861; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case NOT: @@ -5887,9 +5904,9 @@ private ExpressionContext expression(int _p) { Context = _localctx; _prevctx = _localctx; - State = 850; + State = 854; Match(NOT); - State = 851; + State = 855; expression(16); } break; @@ -5898,9 +5915,9 @@ private ExpressionContext expression(int _p) { _localctx = new BitNotExprContext(_localctx); Context = _localctx; _prevctx = _localctx; - State = 852; + State = 856; Match(BNOT); - State = 853; + State = 857; expression(15); } break; @@ -5909,9 +5926,9 @@ private ExpressionContext expression(int _p) { _localctx = new NegateExprContext(_localctx); Context = _localctx; _prevctx = _localctx; - State = 854; + State = 858; Match(MINUS); - State = 855; + State = 859; expression(14); } break; @@ -5938,7 +5955,7 @@ private ExpressionContext expression(int _p) { _localctx = new PrimaryExprContext(_localctx); Context = _localctx; _prevctx = _localctx; - State = 856; + State = 860; primary(); } break; @@ -5946,28 +5963,28 @@ private ExpressionContext expression(int _p) { throw new NoViableAltException(this); } Context.Stop = TokenStream.LT(-1); - State = 928; + State = 932; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,114,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,115,Context); while ( _alt!=2 && _alt!=global::Antlr4.Runtime.Atn.ATN.INVALID_ALT_NUMBER ) { if ( _alt==1 ) { if ( ParseListeners!=null ) TriggerExitRuleEvent(); _prevctx = _localctx; { - State = 926; + State = 930; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,113,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,114,Context) ) { case 1: { _localctx = new PowExprContext(new ExpressionContext(_parentctx, _parentState)); ((PowExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 859; + State = 863; if (!(Precpred(Context, 13))) throw new FailedPredicateException(this, "Precpred(Context, 13)"); - State = 860; + State = 864; Match(CARET); - State = 861; + State = 865; ((PowExprContext)_localctx).right = expression(14); } break; @@ -5976,9 +5993,9 @@ private ExpressionContext expression(int _p) { _localctx = new MulDivExprContext(new ExpressionContext(_parentctx, _parentState)); ((MulDivExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 862; + State = 866; if (!(Precpred(Context, 12))) throw new FailedPredicateException(this, "Precpred(Context, 12)"); - State = 863; + State = 867; ((MulDivExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==MOD || ((((_la - 86)) & ~0x3f) == 0 && ((1L << (_la - 86)) & 7L) != 0)) ) { @@ -5988,7 +6005,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 864; + State = 868; ((MulDivExprContext)_localctx).right = expression(13); } break; @@ -5997,9 +6014,9 @@ private ExpressionContext expression(int _p) { _localctx = new AddConcatExprContext(new ExpressionContext(_parentctx, _parentState)); ((AddConcatExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 865; + State = 869; if (!(Precpred(Context, 11))) throw new FailedPredicateException(this, "Precpred(Context, 11)"); - State = 866; + State = 870; ((AddConcatExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(((((_la - 90)) & ~0x3f) == 0 && ((1L << (_la - 90)) & 7L) != 0)) ) { @@ -6009,7 +6026,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 867; + State = 871; ((AddConcatExprContext)_localctx).right = expression(12); } break; @@ -6018,9 +6035,9 @@ private ExpressionContext expression(int _p) { _localctx = new ComparisonExprContext(new ExpressionContext(_parentctx, _parentState)); ((ComparisonExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 868; + State = 872; if (!(Precpred(Context, 10))) throw new FailedPredicateException(this, "Precpred(Context, 10)"); - State = 869; + State = 873; ((ComparisonExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(((((_la - 93)) & ~0x3f) == 0 && ((1L << (_la - 93)) & 63L) != 0)) ) { @@ -6030,7 +6047,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 870; + State = 874; ((ComparisonExprContext)_localctx).right = expression(11); } break; @@ -6039,25 +6056,25 @@ private ExpressionContext expression(int _p) { _localctx = new BetweenExprContext(new ExpressionContext(_parentctx, _parentState)); ((BetweenExprContext)_localctx).val = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 871; + State = 875; if (!(Precpred(Context, 9))) throw new FailedPredicateException(this, "Precpred(Context, 9)"); - State = 873; + State = 877; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 872; + State = 876; ((BetweenExprContext)_localctx).not = Match(NOT); } } - State = 875; + State = 879; Match(BETWEEN); - State = 876; + State = 880; ((BetweenExprContext)_localctx).lo = expression(0); - State = 877; + State = 881; Match(AND); - State = 878; + State = 882; ((BetweenExprContext)_localctx).hi = expression(10); } break; @@ -6066,21 +6083,21 @@ private ExpressionContext expression(int _p) { _localctx = new LikeExprContext(new ExpressionContext(_parentctx, _parentState)); ((LikeExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 880; + State = 884; if (!(Precpred(Context, 8))) throw new FailedPredicateException(this, "Precpred(Context, 8)"); - State = 882; + State = 886; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 881; + State = 885; ((LikeExprContext)_localctx).not = Match(NOT); } } - State = 884; + State = 888; Match(LIKE); - State = 885; + State = 889; ((LikeExprContext)_localctx).right = expression(9); } break; @@ -6089,9 +6106,9 @@ private ExpressionContext expression(int _p) { _localctx = new BitwiseExprContext(new ExpressionContext(_parentctx, _parentState)); ((BitwiseExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 886; + State = 890; if (!(Precpred(Context, 4))) throw new FailedPredicateException(this, "Precpred(Context, 4)"); - State = 887; + State = 891; ((BitwiseExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !((((_la) & ~0x3f) == 0 && ((1L << _la) & 3584L) != 0)) ) { @@ -6101,7 +6118,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 888; + State = 892; ((BitwiseExprContext)_localctx).right = expression(5); } break; @@ -6110,11 +6127,11 @@ private ExpressionContext expression(int _p) { _localctx = new AndExprContext(new ExpressionContext(_parentctx, _parentState)); ((AndExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 889; + State = 893; if (!(Precpred(Context, 3))) throw new FailedPredicateException(this, "Precpred(Context, 3)"); - State = 890; + State = 894; Match(AND); - State = 891; + State = 895; ((AndExprContext)_localctx).right = expression(4); } break; @@ -6123,11 +6140,11 @@ private ExpressionContext expression(int _p) { _localctx = new OrExprContext(new ExpressionContext(_parentctx, _parentState)); ((OrExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 892; + State = 896; if (!(Precpred(Context, 2))) throw new FailedPredicateException(this, "Precpred(Context, 2)"); - State = 893; + State = 897; Match(OR); - State = 894; + State = 898; ((OrExprContext)_localctx).right = expression(3); } break; @@ -6136,25 +6153,25 @@ private ExpressionContext expression(int _p) { _localctx = new InSubqueryExprContext(new ExpressionContext(_parentctx, _parentState)); ((InSubqueryExprContext)_localctx).val = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 895; + State = 899; if (!(Precpred(Context, 7))) throw new FailedPredicateException(this, "Precpred(Context, 7)"); - State = 897; + State = 901; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 896; + State = 900; ((InSubqueryExprContext)_localctx).not = Match(NOT); } } - State = 899; + State = 903; Match(IN); - State = 900; + State = 904; Match(LPAREN); - State = 901; + State = 905; ((InSubqueryExprContext)_localctx).sub = selectStatement(); - State = 902; + State = 906; Match(RPAREN); } break; @@ -6163,43 +6180,43 @@ private ExpressionContext expression(int _p) { _localctx = new InExprContext(new ExpressionContext(_parentctx, _parentState)); ((InExprContext)_localctx).val = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 904; + State = 908; if (!(Precpred(Context, 6))) throw new FailedPredicateException(this, "Precpred(Context, 6)"); - State = 906; + State = 910; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 905; + State = 909; ((InExprContext)_localctx).not = Match(NOT); } } - State = 908; + State = 912; Match(IN); - State = 909; + State = 913; Match(LPAREN); - State = 910; + State = 914; ((InExprContext)_localctx)._expression = expression(0); ((InExprContext)_localctx)._items.Add(((InExprContext)_localctx)._expression); - State = 915; + State = 919; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 911; + State = 915; Match(COMMA); - State = 912; + State = 916; ((InExprContext)_localctx)._expression = expression(0); ((InExprContext)_localctx)._items.Add(((InExprContext)_localctx)._expression); } } - State = 917; + State = 921; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 918; + State = 922; Match(RPAREN); } break; @@ -6208,30 +6225,30 @@ private ExpressionContext expression(int _p) { _localctx = new IsNullExprContext(new ExpressionContext(_parentctx, _parentState)); ((IsNullExprContext)_localctx).operand = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 920; + State = 924; if (!(Precpred(Context, 5))) throw new FailedPredicateException(this, "Precpred(Context, 5)"); - State = 921; + State = 925; Match(IS); - State = 923; + State = 927; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 922; + State = 926; ((IsNullExprContext)_localctx).not = Match(NOT); } } - State = 925; + State = 929; Match(NULL); } break; } } } - State = 930; + State = 934; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,114,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,115,Context); } } } @@ -6363,14 +6380,14 @@ public PrimaryContext primary() { PrimaryContext _localctx = new PrimaryContext(Context, State); EnterRule(_localctx, 104, RULE_primary); try { - State = 949; + State = 953; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,115,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,116,Context) ) { case 1: _localctx = new LiteralPrimaryContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 931; + State = 935; literal(); } break; @@ -6378,7 +6395,7 @@ public PrimaryContext primary() { _localctx = new FunctionCallPrimaryContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 932; + State = 936; functionCall(); } break; @@ -6386,7 +6403,7 @@ public PrimaryContext primary() { _localctx = new ColumnPrimaryContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 933; + State = 937; columnRef(); } break; @@ -6394,7 +6411,7 @@ public PrimaryContext primary() { _localctx = new ParamPrimaryContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 934; + State = 938; Match(PARAM); } break; @@ -6402,7 +6419,7 @@ public PrimaryContext primary() { _localctx = new SystemVariablePrimaryContext(_localctx); EnterOuterAlt(_localctx, 5); { - State = 935; + State = 939; Match(SYSVAR); } break; @@ -6410,13 +6427,13 @@ public PrimaryContext primary() { _localctx = new ExistsPrimaryContext(_localctx); EnterOuterAlt(_localctx, 6); { - State = 936; + State = 940; Match(EXISTS); - State = 937; + State = 941; Match(LPAREN); - State = 938; + State = 942; selectStatement(); - State = 939; + State = 943; Match(RPAREN); } break; @@ -6424,11 +6441,11 @@ public PrimaryContext primary() { _localctx = new ScalarSubqueryPrimaryContext(_localctx); EnterOuterAlt(_localctx, 7); { - State = 941; + State = 945; Match(LPAREN); - State = 942; + State = 946; selectStatement(); - State = 943; + State = 947; Match(RPAREN); } break; @@ -6436,11 +6453,11 @@ public PrimaryContext primary() { _localctx = new ParenPrimaryContext(_localctx); EnterOuterAlt(_localctx, 8); { - State = 945; + State = 949; Match(LPAREN); - State = 946; + State = 950; expression(0); - State = 947; + State = 951; Match(RPAREN); } break; @@ -6499,16 +6516,16 @@ public FunctionCallContext functionCall() { try { EnterOuterAlt(_localctx, 1); { - State = 951; + State = 955; _localctx.name = functionName(); - State = 952; + State = 956; Match(LPAREN); - State = 965; + State = 969; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case STAR: { - State = 953; + State = 957; _localctx.star = Match(STAR); } break; @@ -6537,31 +6554,31 @@ public FunctionCallContext functionCall() { case IDENTIFIER: { { - State = 955; + State = 959; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==DISTINCT) { { - State = 954; + State = 958; _localctx.distinct = Match(DISTINCT); } } - State = 957; + State = 961; expression(0); - State = 962; + State = 966; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 958; + State = 962; Match(COMMA); - State = 959; + State = 963; expression(0); } } - State = 964; + State = 968; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -6573,7 +6590,7 @@ public FunctionCallContext functionCall() { default: break; } - State = 967; + State = 971; Match(RPAREN); } } @@ -6613,7 +6630,7 @@ public FunctionNameContext functionName() { FunctionNameContext _localctx = new FunctionNameContext(Context, State); EnterRule(_localctx, 108, RULE_functionName); try { - State = 973; + State = 977; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case BRACKET_ID: @@ -6621,28 +6638,28 @@ public FunctionNameContext functionName() { case IDENTIFIER: EnterOuterAlt(_localctx, 1); { - State = 969; + State = 973; identifier(); } break; case LEFT: EnterOuterAlt(_localctx, 2); { - State = 970; + State = 974; Match(LEFT); } break; case RIGHT: EnterOuterAlt(_localctx, 3); { - State = 971; + State = 975; Match(RIGHT); } break; case ASC: EnterOuterAlt(_localctx, 4); { - State = 972; + State = 976; Match(ASC); } break; @@ -6691,19 +6708,19 @@ public ColumnRefContext columnRef() { try { EnterOuterAlt(_localctx, 1); { - State = 978; + State = 982; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,120,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,121,Context) ) { case 1: { - State = 975; + State = 979; _localctx.qualifier = identifier(); - State = 976; + State = 980; Match(DOT); } break; } - State = 980; + State = 984; _localctx.name = identifier(); } } @@ -6743,7 +6760,7 @@ public IdentifierContext identifier() { try { EnterOuterAlt(_localctx, 1); { - State = 982; + State = 986; _la = TokenStream.LA(1); if ( !(((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) ) { ErrorHandler.RecoverInline(this); @@ -6873,14 +6890,14 @@ public LiteralContext literal() { LiteralContext _localctx = new LiteralContext(Context, State); EnterRule(_localctx, 114, RULE_literal); try { - State = 993; + State = 997; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case INTEGER_LITERAL: _localctx = new IntLiteralContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 984; + State = 988; Match(INTEGER_LITERAL); } break; @@ -6888,7 +6905,7 @@ public LiteralContext literal() { _localctx = new NumberLiteralContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 985; + State = 989; Match(NUMBER_LITERAL); } break; @@ -6896,7 +6913,7 @@ public LiteralContext literal() { _localctx = new HexLiteralContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 986; + State = 990; Match(HEX_LITERAL); } break; @@ -6904,7 +6921,7 @@ public LiteralContext literal() { _localctx = new StringLiteralContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 987; + State = 991; Match(STRING_LITERAL); } break; @@ -6912,7 +6929,7 @@ public LiteralContext literal() { _localctx = new DateLiteralContext(_localctx); EnterOuterAlt(_localctx, 5); { - State = 988; + State = 992; Match(DATE_LITERAL); } break; @@ -6920,7 +6937,7 @@ public LiteralContext literal() { _localctx = new GuidLiteralContext(_localctx); EnterOuterAlt(_localctx, 6); { - State = 989; + State = 993; Match(GUID_LITERAL); } break; @@ -6928,7 +6945,7 @@ public LiteralContext literal() { _localctx = new TrueLiteralContext(_localctx); EnterOuterAlt(_localctx, 7); { - State = 990; + State = 994; Match(TRUE); } break; @@ -6936,7 +6953,7 @@ public LiteralContext literal() { _localctx = new FalseLiteralContext(_localctx); EnterOuterAlt(_localctx, 8); { - State = 991; + State = 995; Match(FALSE); } break; @@ -6944,7 +6961,7 @@ public LiteralContext literal() { _localctx = new NullLiteralContext(_localctx); EnterOuterAlt(_localctx, 9); { - State = 992; + State = 996; Match(NULL); } break; @@ -7018,21 +7035,21 @@ public TransactionStatementContext transactionStatement() { EnterRule(_localctx, 116, RULE_transactionStatement); int _la; try { - State = 1007; + State = 1011; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case BEGIN: _localctx = new BeginTransactionStatementContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 995; + State = 999; Match(BEGIN); - State = 997; + State = 1001; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TRANSACTION || _la==WORK) { { - State = 996; + State = 1000; _la = TokenStream.LA(1); if ( !(_la==TRANSACTION || _la==WORK) ) { ErrorHandler.RecoverInline(this); @@ -7050,14 +7067,14 @@ public TransactionStatementContext transactionStatement() { _localctx = new CommitTransactionStatementContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 999; + State = 1003; Match(COMMIT); - State = 1001; + State = 1005; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TRANSACTION || _la==WORK) { { - State = 1000; + State = 1004; _la = TokenStream.LA(1); if ( !(_la==TRANSACTION || _la==WORK) ) { ErrorHandler.RecoverInline(this); @@ -7075,14 +7092,14 @@ public TransactionStatementContext transactionStatement() { _localctx = new RollbackTransactionStatementContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 1003; + State = 1007; Match(ROLLBACK); - State = 1005; + State = 1009; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TRANSACTION || _la==WORK) { { - State = 1004; + State = 1008; _la = TokenStream.LA(1); if ( !(_la==TRANSACTION || _la==WORK) ) { ErrorHandler.RecoverInline(this); @@ -7136,9 +7153,9 @@ public StandaloneExpressionContext standaloneExpression() { try { EnterOuterAlt(_localctx, 1); { - State = 1009; + State = 1013; expression(0); - State = 1010; + State = 1014; Match(Eof); } } @@ -7178,7 +7195,7 @@ private bool expression_sempred(ExpressionContext _localctx, int predIndex) { } private static int[] _serializedATN = { - 4,1,117,1013,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,117,1017,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, @@ -7232,316 +7249,317 @@ private bool expression_sempred(ExpressionContext _localctx, int predIndex) { 31,664,8,31,1,31,1,31,3,31,668,8,31,1,32,1,32,1,32,1,32,5,32,674,8,32, 10,32,12,32,677,9,32,1,33,1,33,1,33,1,33,1,33,3,33,684,8,33,1,34,1,34, 3,34,688,8,34,1,34,1,34,3,34,692,8,34,1,35,1,35,3,35,696,8,35,1,35,3,35, - 699,8,35,1,35,1,35,3,35,703,8,35,1,35,3,35,706,8,35,1,35,3,35,709,8,35, - 1,35,3,35,712,8,35,1,35,3,35,715,8,35,1,36,1,36,1,37,1,37,1,37,1,37,1, - 37,5,37,724,8,37,10,37,12,37,727,9,37,1,38,1,38,1,38,1,39,1,39,1,39,1, - 39,5,39,736,8,39,10,39,12,39,739,9,39,1,39,3,39,742,8,39,1,40,1,40,1,40, - 1,40,1,40,1,40,3,40,750,8,40,1,41,1,41,1,41,1,41,5,41,756,8,41,10,41,12, - 41,759,9,41,3,41,761,8,41,1,42,1,42,1,42,1,42,1,42,1,42,3,42,769,8,42, - 1,42,3,42,772,8,42,3,42,774,8,42,1,43,1,43,1,43,1,43,5,43,780,8,43,10, - 43,12,43,783,9,43,1,44,1,44,5,44,787,8,44,10,44,12,44,790,9,44,1,45,1, - 45,3,45,794,8,45,1,45,3,45,797,8,45,1,45,1,45,1,45,1,45,3,45,803,8,45, - 1,45,3,45,806,8,45,1,45,1,45,1,45,1,45,3,45,812,8,45,1,46,1,46,1,46,1, - 46,1,46,1,46,1,47,3,47,821,8,47,1,47,1,47,3,47,825,8,47,1,47,1,47,3,47, - 829,8,47,3,47,831,8,47,1,48,1,48,1,48,1,49,1,49,1,49,1,49,1,49,5,49,841, - 8,49,10,49,12,49,844,9,49,1,50,1,50,3,50,848,8,50,1,51,1,51,1,51,1,51, - 1,51,1,51,1,51,1,51,3,51,858,8,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,874,8,51,1,51,1,51,1,51,1,51,1,51, - 1,51,1,51,3,51,883,8,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,898,8,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,3,51, - 907,8,51,1,51,1,51,1,51,1,51,1,51,5,51,914,8,51,10,51,12,51,917,9,51,1, - 51,1,51,1,51,1,51,1,51,3,51,924,8,51,1,51,5,51,927,8,51,10,51,12,51,930, - 9,51,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52, - 1,52,1,52,1,52,1,52,1,52,3,52,950,8,52,1,53,1,53,1,53,1,53,3,53,956,8, - 53,1,53,1,53,1,53,5,53,961,8,53,10,53,12,53,964,9,53,3,53,966,8,53,1,53, - 1,53,1,54,1,54,1,54,1,54,3,54,974,8,54,1,55,1,55,1,55,3,55,979,8,55,1, - 55,1,55,1,56,1,56,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,3,57,994, - 8,57,1,58,1,58,3,58,998,8,58,1,58,1,58,3,58,1002,8,58,1,58,1,58,3,58,1006, - 8,58,3,58,1008,8,58,1,59,1,59,1,59,1,59,0,1,102,60,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,0,12,1,0,79,80,1,0,81,82,1,0,71,72,1,0,99,100, - 2,0,30,31,35,35,1,0,90,91,2,0,14,14,86,88,1,0,90,92,1,0,93,98,1,0,9,11, - 1,0,112,114,1,0,43,44,1156,0,121,1,0,0,0,2,144,1,0,0,0,4,166,1,0,0,0,6, - 168,1,0,0,0,8,180,1,0,0,0,10,194,1,0,0,0,12,198,1,0,0,0,14,211,1,0,0,0, - 16,220,1,0,0,0,18,227,1,0,0,0,20,238,1,0,0,0,22,262,1,0,0,0,24,281,1,0, - 0,0,26,309,1,0,0,0,28,311,1,0,0,0,30,316,1,0,0,0,32,318,1,0,0,0,34,380, - 1,0,0,0,36,385,1,0,0,0,38,387,1,0,0,0,40,424,1,0,0,0,42,426,1,0,0,0,44, - 435,1,0,0,0,46,437,1,0,0,0,48,445,1,0,0,0,50,463,1,0,0,0,52,519,1,0,0, - 0,54,604,1,0,0,0,56,613,1,0,0,0,58,622,1,0,0,0,60,632,1,0,0,0,62,634,1, - 0,0,0,64,669,1,0,0,0,66,683,1,0,0,0,68,691,1,0,0,0,70,693,1,0,0,0,72,716, - 1,0,0,0,74,718,1,0,0,0,76,728,1,0,0,0,78,731,1,0,0,0,80,749,1,0,0,0,82, - 760,1,0,0,0,84,773,1,0,0,0,86,775,1,0,0,0,88,784,1,0,0,0,90,811,1,0,0, - 0,92,813,1,0,0,0,94,830,1,0,0,0,96,832,1,0,0,0,98,835,1,0,0,0,100,845, - 1,0,0,0,102,857,1,0,0,0,104,949,1,0,0,0,106,951,1,0,0,0,108,973,1,0,0, - 0,110,978,1,0,0,0,112,982,1,0,0,0,114,993,1,0,0,0,116,1007,1,0,0,0,118, - 1009,1,0,0,0,120,122,3,18,9,0,121,120,1,0,0,0,121,122,1,0,0,0,122,137, - 1,0,0,0,123,138,3,2,1,0,124,138,3,20,10,0,125,138,3,38,19,0,126,138,3, - 22,11,0,127,138,3,24,12,0,128,138,3,32,16,0,129,138,3,40,20,0,130,138, - 3,62,31,0,131,138,3,8,4,0,132,138,3,12,6,0,133,138,3,116,58,0,134,138, - 3,6,3,0,135,138,3,14,7,0,136,138,3,64,32,0,137,123,1,0,0,0,137,124,1,0, - 0,0,137,125,1,0,0,0,137,126,1,0,0,0,137,127,1,0,0,0,137,128,1,0,0,0,137, - 129,1,0,0,0,137,130,1,0,0,0,137,131,1,0,0,0,137,132,1,0,0,0,137,133,1, - 0,0,0,137,134,1,0,0,0,137,135,1,0,0,0,137,136,1,0,0,0,138,140,1,0,0,0, - 139,141,5,103,0,0,140,139,1,0,0,0,140,141,1,0,0,0,141,142,1,0,0,0,142, - 143,5,0,0,1,143,1,1,0,0,0,144,146,5,28,0,0,145,147,5,8,0,0,146,145,1,0, - 0,0,146,147,1,0,0,0,147,148,1,0,0,0,148,149,5,27,0,0,149,150,5,99,0,0, - 150,151,3,70,35,0,151,152,5,100,0,0,152,153,5,29,0,0,153,154,3,4,2,0,154, - 3,1,0,0,0,155,167,3,20,10,0,156,167,3,38,19,0,157,167,3,22,11,0,158,167, - 3,24,12,0,159,167,3,32,16,0,160,167,3,40,20,0,161,167,3,62,31,0,162,167, - 3,8,4,0,163,167,3,12,6,0,164,167,3,6,3,0,165,167,3,64,32,0,166,155,1,0, - 0,0,166,156,1,0,0,0,166,157,1,0,0,0,166,158,1,0,0,0,166,159,1,0,0,0,166, - 160,1,0,0,0,166,161,1,0,0,0,166,162,1,0,0,0,166,163,1,0,0,0,166,164,1, - 0,0,0,166,165,1,0,0,0,167,5,1,0,0,0,168,169,7,0,0,0,169,178,3,112,56,0, - 170,175,3,102,51,0,171,172,5,101,0,0,172,174,3,102,51,0,173,171,1,0,0, - 0,174,177,1,0,0,0,175,173,1,0,0,0,175,176,1,0,0,0,176,179,1,0,0,0,177, - 175,1,0,0,0,178,170,1,0,0,0,178,179,1,0,0,0,179,7,1,0,0,0,180,181,5,60, - 0,0,181,182,3,88,44,0,182,183,5,64,0,0,183,188,3,10,5,0,184,185,5,101, - 0,0,185,187,3,10,5,0,186,184,1,0,0,0,187,190,1,0,0,0,188,186,1,0,0,0,188, - 189,1,0,0,0,189,192,1,0,0,0,190,188,1,0,0,0,191,193,3,96,48,0,192,191, - 1,0,0,0,192,193,1,0,0,0,193,9,1,0,0,0,194,195,3,110,55,0,195,196,5,93, - 0,0,196,197,3,102,51,0,197,11,1,0,0,0,198,204,5,59,0,0,199,200,3,112,56, - 0,200,201,5,102,0,0,201,202,5,86,0,0,202,205,1,0,0,0,203,205,5,86,0,0, - 204,199,1,0,0,0,204,203,1,0,0,0,204,205,1,0,0,0,205,206,1,0,0,0,206,207, - 5,2,0,0,207,209,3,88,44,0,208,210,3,96,48,0,209,208,1,0,0,0,209,210,1, - 0,0,0,210,13,1,0,0,0,211,212,5,1,0,0,212,217,3,16,8,0,213,214,5,101,0, - 0,214,216,3,16,8,0,215,213,1,0,0,0,216,219,1,0,0,0,217,215,1,0,0,0,217, - 218,1,0,0,0,218,15,1,0,0,0,219,217,1,0,0,0,220,225,5,104,0,0,221,223,5, - 5,0,0,222,221,1,0,0,0,222,223,1,0,0,0,223,224,1,0,0,0,224,226,3,112,56, - 0,225,222,1,0,0,0,225,226,1,0,0,0,226,17,1,0,0,0,227,228,5,78,0,0,228, - 233,3,28,14,0,229,230,5,101,0,0,230,232,3,28,14,0,231,229,1,0,0,0,232, - 235,1,0,0,0,233,231,1,0,0,0,233,234,1,0,0,0,234,236,1,0,0,0,235,233,1, - 0,0,0,236,237,5,103,0,0,237,19,1,0,0,0,238,240,5,38,0,0,239,241,5,69,0, - 0,240,239,1,0,0,0,240,241,1,0,0,0,241,242,1,0,0,0,242,243,5,39,0,0,243, - 244,3,112,56,0,244,245,5,99,0,0,245,250,3,46,23,0,246,247,5,101,0,0,247, - 249,3,46,23,0,248,246,1,0,0,0,249,252,1,0,0,0,250,248,1,0,0,0,250,251, - 1,0,0,0,251,257,1,0,0,0,252,250,1,0,0,0,253,254,5,101,0,0,254,256,3,54, - 27,0,255,253,1,0,0,0,256,259,1,0,0,0,257,255,1,0,0,0,257,258,1,0,0,0,258, - 260,1,0,0,0,259,257,1,0,0,0,260,261,5,100,0,0,261,21,1,0,0,0,262,263,5, - 38,0,0,263,264,5,76,0,0,264,276,3,112,56,0,265,266,5,99,0,0,266,271,3, - 112,56,0,267,268,5,101,0,0,268,270,3,112,56,0,269,267,1,0,0,0,270,273, - 1,0,0,0,271,269,1,0,0,0,271,272,1,0,0,0,272,274,1,0,0,0,273,271,1,0,0, - 0,274,275,5,100,0,0,275,277,1,0,0,0,276,265,1,0,0,0,276,277,1,0,0,0,277, - 278,1,0,0,0,278,279,5,5,0,0,279,280,3,64,32,0,280,23,1,0,0,0,281,282,5, - 38,0,0,282,283,5,77,0,0,283,285,3,112,56,0,284,286,3,26,13,0,285,284,1, - 0,0,0,285,286,1,0,0,0,286,287,1,0,0,0,287,288,5,5,0,0,288,289,3,36,18, - 0,289,25,1,0,0,0,290,291,5,99,0,0,291,296,3,28,14,0,292,293,5,101,0,0, - 293,295,3,28,14,0,294,292,1,0,0,0,295,298,1,0,0,0,296,294,1,0,0,0,296, - 297,1,0,0,0,297,299,1,0,0,0,298,296,1,0,0,0,299,300,5,100,0,0,300,310, - 1,0,0,0,301,306,3,28,14,0,302,303,5,101,0,0,303,305,3,28,14,0,304,302, - 1,0,0,0,305,308,1,0,0,0,306,304,1,0,0,0,306,307,1,0,0,0,307,310,1,0,0, - 0,308,306,1,0,0,0,309,290,1,0,0,0,309,301,1,0,0,0,310,27,1,0,0,0,311,312, - 3,30,15,0,312,313,3,48,24,0,313,29,1,0,0,0,314,317,3,112,56,0,315,317, - 5,105,0,0,316,314,1,0,0,0,316,315,1,0,0,0,317,31,1,0,0,0,318,319,5,45, - 0,0,319,320,5,39,0,0,320,321,3,112,56,0,321,322,3,34,17,0,322,33,1,0,0, - 0,323,325,5,48,0,0,324,326,5,50,0,0,325,324,1,0,0,0,325,326,1,0,0,0,326, - 327,1,0,0,0,327,381,3,46,23,0,328,329,5,48,0,0,329,381,3,54,27,0,330,332, - 5,45,0,0,331,333,5,50,0,0,332,331,1,0,0,0,332,333,1,0,0,0,333,334,1,0, - 0,0,334,335,3,112,56,0,335,339,3,48,24,0,336,338,3,52,26,0,337,336,1,0, - 0,0,338,341,1,0,0,0,339,337,1,0,0,0,339,340,1,0,0,0,340,381,1,0,0,0,341, - 339,1,0,0,0,342,344,5,45,0,0,343,345,5,50,0,0,344,343,1,0,0,0,344,345, - 1,0,0,0,345,346,1,0,0,0,346,347,3,112,56,0,347,348,5,64,0,0,348,349,5, - 65,0,0,349,350,3,102,51,0,350,381,1,0,0,0,351,353,5,45,0,0,352,354,5,50, - 0,0,353,352,1,0,0,0,353,354,1,0,0,0,354,355,1,0,0,0,355,356,3,112,56,0, - 356,357,5,49,0,0,357,358,5,65,0,0,358,381,1,0,0,0,359,360,5,49,0,0,360, - 361,5,50,0,0,361,381,3,112,56,0,362,363,5,49,0,0,363,364,5,56,0,0,364, - 381,3,112,56,0,365,366,5,46,0,0,366,367,5,47,0,0,367,381,3,112,56,0,368, - 369,5,46,0,0,369,370,5,50,0,0,370,371,3,112,56,0,371,372,5,47,0,0,372, - 373,3,112,56,0,373,381,1,0,0,0,374,375,5,46,0,0,375,376,5,68,0,0,376,377, - 3,112,56,0,377,378,5,47,0,0,378,379,3,112,56,0,379,381,1,0,0,0,380,323, - 1,0,0,0,380,328,1,0,0,0,380,330,1,0,0,0,380,342,1,0,0,0,380,351,1,0,0, - 0,380,359,1,0,0,0,380,362,1,0,0,0,380,365,1,0,0,0,380,368,1,0,0,0,380, - 374,1,0,0,0,381,35,1,0,0,0,382,386,3,64,32,0,383,386,3,62,31,0,384,386, - 3,20,10,0,385,382,1,0,0,0,385,383,1,0,0,0,385,384,1,0,0,0,386,37,1,0,0, - 0,387,389,5,38,0,0,388,390,5,67,0,0,389,388,1,0,0,0,389,390,1,0,0,0,390, - 391,1,0,0,0,391,392,5,68,0,0,392,393,3,112,56,0,393,394,5,21,0,0,394,395, - 3,112,56,0,395,396,5,99,0,0,396,401,3,42,21,0,397,398,5,101,0,0,398,400, - 3,42,21,0,399,397,1,0,0,0,400,403,1,0,0,0,401,399,1,0,0,0,401,402,1,0, - 0,0,402,404,1,0,0,0,403,401,1,0,0,0,404,407,5,100,0,0,405,406,5,70,0,0, - 406,408,3,44,22,0,407,405,1,0,0,0,407,408,1,0,0,0,408,39,1,0,0,0,409,410, - 5,49,0,0,410,411,5,39,0,0,411,425,3,112,56,0,412,413,5,49,0,0,413,414, - 5,68,0,0,414,415,3,112,56,0,415,416,5,21,0,0,416,417,3,112,56,0,417,425, - 1,0,0,0,418,419,5,49,0,0,419,420,5,77,0,0,420,425,3,112,56,0,421,422,5, - 49,0,0,422,423,5,76,0,0,423,425,3,112,56,0,424,409,1,0,0,0,424,412,1,0, - 0,0,424,418,1,0,0,0,424,421,1,0,0,0,425,41,1,0,0,0,426,428,3,112,56,0, - 427,429,7,1,0,0,428,427,1,0,0,0,428,429,1,0,0,0,429,43,1,0,0,0,430,436, - 5,54,0,0,431,432,5,73,0,0,432,436,5,85,0,0,433,434,5,74,0,0,434,436,5, - 85,0,0,435,430,1,0,0,0,435,431,1,0,0,0,435,433,1,0,0,0,436,45,1,0,0,0, - 437,438,3,112,56,0,438,442,3,48,24,0,439,441,3,52,26,0,440,439,1,0,0,0, - 441,444,1,0,0,0,442,440,1,0,0,0,442,443,1,0,0,0,443,47,1,0,0,0,444,442, - 1,0,0,0,445,447,3,112,56,0,446,448,3,112,56,0,447,446,1,0,0,0,447,448, - 1,0,0,0,448,450,1,0,0,0,449,451,3,112,56,0,450,449,1,0,0,0,450,451,1,0, - 0,0,451,460,1,0,0,0,452,453,5,99,0,0,453,456,3,50,25,0,454,455,5,101,0, - 0,455,457,3,50,25,0,456,454,1,0,0,0,456,457,1,0,0,0,457,458,1,0,0,0,458, - 459,5,100,0,0,459,461,1,0,0,0,460,452,1,0,0,0,460,461,1,0,0,0,461,49,1, - 0,0,0,462,464,5,91,0,0,463,462,1,0,0,0,463,464,1,0,0,0,464,465,1,0,0,0, - 465,466,5,107,0,0,466,51,1,0,0,0,467,468,5,8,0,0,468,520,5,85,0,0,469, - 520,5,85,0,0,470,471,5,65,0,0,471,520,3,102,51,0,472,473,5,70,0,0,473, - 520,7,2,0,0,474,475,5,56,0,0,475,477,3,112,56,0,476,474,1,0,0,0,476,477, - 1,0,0,0,477,478,1,0,0,0,478,479,5,75,0,0,479,480,5,99,0,0,480,481,3,56, - 28,0,481,482,5,100,0,0,482,520,1,0,0,0,483,484,5,56,0,0,484,486,3,112, - 56,0,485,483,1,0,0,0,485,486,1,0,0,0,486,487,1,0,0,0,487,488,5,54,0,0, - 488,520,5,55,0,0,489,490,5,56,0,0,490,492,3,112,56,0,491,489,1,0,0,0,491, - 492,1,0,0,0,492,493,1,0,0,0,493,520,5,67,0,0,494,495,5,56,0,0,495,497, - 3,112,56,0,496,494,1,0,0,0,496,497,1,0,0,0,497,498,1,0,0,0,498,499,5,58, - 0,0,499,511,3,112,56,0,500,501,5,99,0,0,501,506,3,112,56,0,502,503,5,101, - 0,0,503,505,3,112,56,0,504,502,1,0,0,0,505,508,1,0,0,0,506,504,1,0,0,0, - 506,507,1,0,0,0,507,509,1,0,0,0,508,506,1,0,0,0,509,510,5,100,0,0,510, - 512,1,0,0,0,511,500,1,0,0,0,511,512,1,0,0,0,512,516,1,0,0,0,513,515,3, - 58,29,0,514,513,1,0,0,0,515,518,1,0,0,0,516,514,1,0,0,0,516,517,1,0,0, - 0,517,520,1,0,0,0,518,516,1,0,0,0,519,467,1,0,0,0,519,469,1,0,0,0,519, - 470,1,0,0,0,519,472,1,0,0,0,519,476,1,0,0,0,519,485,1,0,0,0,519,491,1, - 0,0,0,519,496,1,0,0,0,520,53,1,0,0,0,521,522,5,56,0,0,522,524,3,112,56, - 0,523,521,1,0,0,0,523,524,1,0,0,0,524,525,1,0,0,0,525,526,5,54,0,0,526, - 527,5,55,0,0,527,528,5,99,0,0,528,533,3,112,56,0,529,530,5,101,0,0,530, - 532,3,112,56,0,531,529,1,0,0,0,532,535,1,0,0,0,533,531,1,0,0,0,533,534, - 1,0,0,0,534,536,1,0,0,0,535,533,1,0,0,0,536,537,5,100,0,0,537,605,1,0, - 0,0,538,539,5,56,0,0,539,541,3,112,56,0,540,538,1,0,0,0,540,541,1,0,0, - 0,541,542,1,0,0,0,542,543,5,67,0,0,543,544,5,99,0,0,544,549,3,112,56,0, - 545,546,5,101,0,0,546,548,3,112,56,0,547,545,1,0,0,0,548,551,1,0,0,0,549, - 547,1,0,0,0,549,550,1,0,0,0,550,552,1,0,0,0,551,549,1,0,0,0,552,553,5, - 100,0,0,553,605,1,0,0,0,554,555,5,56,0,0,555,557,3,112,56,0,556,554,1, - 0,0,0,556,557,1,0,0,0,557,558,1,0,0,0,558,559,5,57,0,0,559,562,5,55,0, - 0,560,561,5,66,0,0,561,563,5,68,0,0,562,560,1,0,0,0,562,563,1,0,0,0,563, - 564,1,0,0,0,564,565,5,99,0,0,565,570,3,112,56,0,566,567,5,101,0,0,567, - 569,3,112,56,0,568,566,1,0,0,0,569,572,1,0,0,0,570,568,1,0,0,0,570,571, - 1,0,0,0,571,573,1,0,0,0,572,570,1,0,0,0,573,574,5,100,0,0,574,575,5,58, - 0,0,575,587,3,112,56,0,576,577,5,99,0,0,577,582,3,112,56,0,578,579,5,101, - 0,0,579,581,3,112,56,0,580,578,1,0,0,0,581,584,1,0,0,0,582,580,1,0,0,0, - 582,583,1,0,0,0,583,585,1,0,0,0,584,582,1,0,0,0,585,586,5,100,0,0,586, - 588,1,0,0,0,587,576,1,0,0,0,587,588,1,0,0,0,588,592,1,0,0,0,589,591,3, - 58,29,0,590,589,1,0,0,0,591,594,1,0,0,0,592,590,1,0,0,0,592,593,1,0,0, - 0,593,605,1,0,0,0,594,592,1,0,0,0,595,596,5,56,0,0,596,598,3,112,56,0, - 597,595,1,0,0,0,597,598,1,0,0,0,598,599,1,0,0,0,599,600,5,75,0,0,600,601, - 5,99,0,0,601,602,3,56,28,0,602,603,5,100,0,0,603,605,1,0,0,0,604,523,1, - 0,0,0,604,540,1,0,0,0,604,556,1,0,0,0,604,597,1,0,0,0,605,55,1,0,0,0,606, - 612,8,3,0,0,607,608,5,99,0,0,608,609,3,56,28,0,609,610,5,100,0,0,610,612, - 1,0,0,0,611,606,1,0,0,0,611,607,1,0,0,0,612,615,1,0,0,0,613,611,1,0,0, - 0,613,614,1,0,0,0,614,57,1,0,0,0,615,613,1,0,0,0,616,617,5,21,0,0,617, - 618,5,60,0,0,618,623,3,60,30,0,619,620,5,21,0,0,620,621,5,59,0,0,621,623, - 3,60,30,0,622,616,1,0,0,0,622,619,1,0,0,0,623,59,1,0,0,0,624,633,5,61, - 0,0,625,626,5,66,0,0,626,633,5,63,0,0,627,633,5,62,0,0,628,629,5,64,0, - 0,629,633,5,85,0,0,630,631,5,64,0,0,631,633,5,65,0,0,632,624,1,0,0,0,632, - 625,1,0,0,0,632,627,1,0,0,0,632,628,1,0,0,0,632,630,1,0,0,0,633,61,1,0, - 0,0,634,635,5,51,0,0,635,636,5,52,0,0,636,667,3,112,56,0,637,638,5,99, - 0,0,638,643,3,112,56,0,639,640,5,101,0,0,640,642,3,112,56,0,641,639,1, - 0,0,0,642,645,1,0,0,0,643,641,1,0,0,0,643,644,1,0,0,0,644,646,1,0,0,0, - 645,643,1,0,0,0,646,647,5,100,0,0,647,649,1,0,0,0,648,637,1,0,0,0,648, - 649,1,0,0,0,649,663,1,0,0,0,650,651,5,53,0,0,651,652,5,99,0,0,652,657, - 3,102,51,0,653,654,5,101,0,0,654,656,3,102,51,0,655,653,1,0,0,0,656,659, - 1,0,0,0,657,655,1,0,0,0,657,658,1,0,0,0,658,660,1,0,0,0,659,657,1,0,0, - 0,660,661,5,100,0,0,661,664,1,0,0,0,662,664,3,64,32,0,663,650,1,0,0,0, - 663,662,1,0,0,0,664,668,1,0,0,0,665,666,5,65,0,0,666,668,5,53,0,0,667, - 648,1,0,0,0,667,665,1,0,0,0,668,63,1,0,0,0,669,675,3,66,33,0,670,671,3, - 68,34,0,671,672,3,66,33,0,672,674,1,0,0,0,673,670,1,0,0,0,674,677,1,0, - 0,0,675,673,1,0,0,0,675,676,1,0,0,0,676,65,1,0,0,0,677,675,1,0,0,0,678, - 684,3,70,35,0,679,680,5,99,0,0,680,681,3,64,32,0,681,682,5,100,0,0,682, - 684,1,0,0,0,683,678,1,0,0,0,683,679,1,0,0,0,684,67,1,0,0,0,685,687,5,34, - 0,0,686,688,5,35,0,0,687,686,1,0,0,0,687,688,1,0,0,0,688,692,1,0,0,0,689, - 692,5,36,0,0,690,692,5,37,0,0,691,685,1,0,0,0,691,689,1,0,0,0,691,690, - 1,0,0,0,692,69,1,0,0,0,693,695,5,1,0,0,694,696,3,72,36,0,695,694,1,0,0, - 0,695,696,1,0,0,0,696,698,1,0,0,0,697,699,3,78,39,0,698,697,1,0,0,0,698, - 699,1,0,0,0,699,700,1,0,0,0,700,702,3,82,41,0,701,703,3,86,43,0,702,701, - 1,0,0,0,702,703,1,0,0,0,703,705,1,0,0,0,704,706,3,96,48,0,705,704,1,0, - 0,0,705,706,1,0,0,0,706,708,1,0,0,0,707,709,3,74,37,0,708,707,1,0,0,0, - 708,709,1,0,0,0,709,711,1,0,0,0,710,712,3,76,38,0,711,710,1,0,0,0,711, - 712,1,0,0,0,712,714,1,0,0,0,713,715,3,98,49,0,714,713,1,0,0,0,714,715, - 1,0,0,0,715,71,1,0,0,0,716,717,7,4,0,0,717,73,1,0,0,0,718,719,5,23,0,0, - 719,720,5,25,0,0,720,725,3,102,51,0,721,722,5,101,0,0,722,724,3,102,51, - 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, - 75,1,0,0,0,727,725,1,0,0,0,728,729,5,26,0,0,729,730,3,102,51,0,730,77, - 1,0,0,0,731,732,5,4,0,0,732,737,3,80,40,0,733,734,7,5,0,0,734,736,3,80, - 40,0,735,733,1,0,0,0,736,739,1,0,0,0,737,735,1,0,0,0,737,738,1,0,0,0,738, - 741,1,0,0,0,739,737,1,0,0,0,740,742,5,32,0,0,741,740,1,0,0,0,741,742,1, - 0,0,0,742,79,1,0,0,0,743,750,5,107,0,0,744,750,5,105,0,0,745,746,5,99, - 0,0,746,747,3,102,51,0,747,748,5,100,0,0,748,750,1,0,0,0,749,743,1,0,0, - 0,749,744,1,0,0,0,749,745,1,0,0,0,750,81,1,0,0,0,751,761,5,86,0,0,752, - 757,3,84,42,0,753,754,5,101,0,0,754,756,3,84,42,0,755,753,1,0,0,0,756, - 759,1,0,0,0,757,755,1,0,0,0,757,758,1,0,0,0,758,761,1,0,0,0,759,757,1, - 0,0,0,760,751,1,0,0,0,760,752,1,0,0,0,761,83,1,0,0,0,762,763,3,112,56, - 0,763,764,5,102,0,0,764,765,5,86,0,0,765,774,1,0,0,0,766,771,3,102,51, - 0,767,769,5,5,0,0,768,767,1,0,0,0,768,769,1,0,0,0,769,770,1,0,0,0,770, - 772,3,112,56,0,771,768,1,0,0,0,771,772,1,0,0,0,772,774,1,0,0,0,773,762, - 1,0,0,0,773,766,1,0,0,0,774,85,1,0,0,0,775,776,5,2,0,0,776,781,3,88,44, - 0,777,778,5,101,0,0,778,780,3,88,44,0,779,777,1,0,0,0,780,783,1,0,0,0, - 781,779,1,0,0,0,781,782,1,0,0,0,782,87,1,0,0,0,783,781,1,0,0,0,784,788, - 3,90,45,0,785,787,3,92,46,0,786,785,1,0,0,0,787,790,1,0,0,0,788,786,1, - 0,0,0,788,789,1,0,0,0,789,89,1,0,0,0,790,788,1,0,0,0,791,796,3,112,56, - 0,792,794,5,5,0,0,793,792,1,0,0,0,793,794,1,0,0,0,794,795,1,0,0,0,795, - 797,3,112,56,0,796,793,1,0,0,0,796,797,1,0,0,0,797,812,1,0,0,0,798,799, - 5,99,0,0,799,800,3,64,32,0,800,805,5,100,0,0,801,803,5,5,0,0,802,801,1, - 0,0,0,802,803,1,0,0,0,803,804,1,0,0,0,804,806,3,112,56,0,805,802,1,0,0, - 0,805,806,1,0,0,0,806,812,1,0,0,0,807,808,5,99,0,0,808,809,3,88,44,0,809, - 810,5,100,0,0,810,812,1,0,0,0,811,791,1,0,0,0,811,798,1,0,0,0,811,807, - 1,0,0,0,812,91,1,0,0,0,813,814,3,94,47,0,814,815,5,19,0,0,815,816,3,90, - 45,0,816,817,5,21,0,0,817,818,3,102,51,0,818,93,1,0,0,0,819,821,5,15,0, - 0,820,819,1,0,0,0,820,821,1,0,0,0,821,831,1,0,0,0,822,824,5,16,0,0,823, - 825,5,18,0,0,824,823,1,0,0,0,824,825,1,0,0,0,825,831,1,0,0,0,826,828,5, - 17,0,0,827,829,5,18,0,0,828,827,1,0,0,0,828,829,1,0,0,0,829,831,1,0,0, - 0,830,820,1,0,0,0,830,822,1,0,0,0,830,826,1,0,0,0,831,95,1,0,0,0,832,833, - 5,3,0,0,833,834,3,102,51,0,834,97,1,0,0,0,835,836,5,22,0,0,836,837,5,25, - 0,0,837,842,3,100,50,0,838,839,5,101,0,0,839,841,3,100,50,0,840,838,1, - 0,0,0,841,844,1,0,0,0,842,840,1,0,0,0,842,843,1,0,0,0,843,99,1,0,0,0,844, - 842,1,0,0,0,845,847,3,102,51,0,846,848,7,1,0,0,847,846,1,0,0,0,847,848, - 1,0,0,0,848,101,1,0,0,0,849,850,6,51,-1,0,850,851,5,8,0,0,851,858,3,102, - 51,16,852,853,5,12,0,0,853,858,3,102,51,15,854,855,5,91,0,0,855,858,3, - 102,51,14,856,858,3,104,52,0,857,849,1,0,0,0,857,852,1,0,0,0,857,854,1, - 0,0,0,857,856,1,0,0,0,858,928,1,0,0,0,859,860,10,13,0,0,860,861,5,89,0, - 0,861,927,3,102,51,14,862,863,10,12,0,0,863,864,7,6,0,0,864,927,3,102, - 51,13,865,866,10,11,0,0,866,867,7,7,0,0,867,927,3,102,51,12,868,869,10, - 10,0,0,869,870,7,8,0,0,870,927,3,102,51,11,871,873,10,9,0,0,872,874,5, - 8,0,0,873,872,1,0,0,0,873,874,1,0,0,0,874,875,1,0,0,0,875,876,5,33,0,0, - 876,877,3,102,51,0,877,878,5,6,0,0,878,879,3,102,51,10,879,927,1,0,0,0, - 880,882,10,8,0,0,881,883,5,8,0,0,882,881,1,0,0,0,882,883,1,0,0,0,883,884, - 1,0,0,0,884,885,5,13,0,0,885,927,3,102,51,9,886,887,10,4,0,0,887,888,7, - 9,0,0,888,927,3,102,51,5,889,890,10,3,0,0,890,891,5,6,0,0,891,927,3,102, - 51,4,892,893,10,2,0,0,893,894,5,7,0,0,894,927,3,102,51,3,895,897,10,7, - 0,0,896,898,5,8,0,0,897,896,1,0,0,0,897,898,1,0,0,0,898,899,1,0,0,0,899, - 900,5,20,0,0,900,901,5,99,0,0,901,902,3,70,35,0,902,903,5,100,0,0,903, - 927,1,0,0,0,904,906,10,6,0,0,905,907,5,8,0,0,906,905,1,0,0,0,906,907,1, - 0,0,0,907,908,1,0,0,0,908,909,5,20,0,0,909,910,5,99,0,0,910,915,3,102, - 51,0,911,912,5,101,0,0,912,914,3,102,51,0,913,911,1,0,0,0,914,917,1,0, - 0,0,915,913,1,0,0,0,915,916,1,0,0,0,916,918,1,0,0,0,917,915,1,0,0,0,918, - 919,5,100,0,0,919,927,1,0,0,0,920,921,10,5,0,0,921,923,5,24,0,0,922,924, - 5,8,0,0,923,922,1,0,0,0,923,924,1,0,0,0,924,925,1,0,0,0,925,927,5,85,0, - 0,926,859,1,0,0,0,926,862,1,0,0,0,926,865,1,0,0,0,926,868,1,0,0,0,926, - 871,1,0,0,0,926,880,1,0,0,0,926,886,1,0,0,0,926,889,1,0,0,0,926,892,1, - 0,0,0,926,895,1,0,0,0,926,904,1,0,0,0,926,920,1,0,0,0,927,930,1,0,0,0, - 928,926,1,0,0,0,928,929,1,0,0,0,929,103,1,0,0,0,930,928,1,0,0,0,931,950, - 3,114,57,0,932,950,3,106,53,0,933,950,3,110,55,0,934,950,5,105,0,0,935, - 950,5,104,0,0,936,937,5,27,0,0,937,938,5,99,0,0,938,939,3,70,35,0,939, - 940,5,100,0,0,940,950,1,0,0,0,941,942,5,99,0,0,942,943,3,70,35,0,943,944, - 5,100,0,0,944,950,1,0,0,0,945,946,5,99,0,0,946,947,3,102,51,0,947,948, - 5,100,0,0,948,950,1,0,0,0,949,931,1,0,0,0,949,932,1,0,0,0,949,933,1,0, - 0,0,949,934,1,0,0,0,949,935,1,0,0,0,949,936,1,0,0,0,949,941,1,0,0,0,949, - 945,1,0,0,0,950,105,1,0,0,0,951,952,3,108,54,0,952,965,5,99,0,0,953,966, - 5,86,0,0,954,956,5,31,0,0,955,954,1,0,0,0,955,956,1,0,0,0,956,957,1,0, - 0,0,957,962,3,102,51,0,958,959,5,101,0,0,959,961,3,102,51,0,960,958,1, - 0,0,0,961,964,1,0,0,0,962,960,1,0,0,0,962,963,1,0,0,0,963,966,1,0,0,0, - 964,962,1,0,0,0,965,953,1,0,0,0,965,955,1,0,0,0,965,966,1,0,0,0,966,967, - 1,0,0,0,967,968,5,100,0,0,968,107,1,0,0,0,969,974,3,112,56,0,970,974,5, - 16,0,0,971,974,5,17,0,0,972,974,5,81,0,0,973,969,1,0,0,0,973,970,1,0,0, - 0,973,971,1,0,0,0,973,972,1,0,0,0,974,109,1,0,0,0,975,976,3,112,56,0,976, - 977,5,102,0,0,977,979,1,0,0,0,978,975,1,0,0,0,978,979,1,0,0,0,979,980, - 1,0,0,0,980,981,3,112,56,0,981,111,1,0,0,0,982,983,7,10,0,0,983,113,1, - 0,0,0,984,994,5,107,0,0,985,994,5,108,0,0,986,994,5,106,0,0,987,994,5, - 109,0,0,988,994,5,110,0,0,989,994,5,111,0,0,990,994,5,83,0,0,991,994,5, - 84,0,0,992,994,5,85,0,0,993,984,1,0,0,0,993,985,1,0,0,0,993,986,1,0,0, - 0,993,987,1,0,0,0,993,988,1,0,0,0,993,989,1,0,0,0,993,990,1,0,0,0,993, - 991,1,0,0,0,993,992,1,0,0,0,994,115,1,0,0,0,995,997,5,40,0,0,996,998,7, - 11,0,0,997,996,1,0,0,0,997,998,1,0,0,0,998,1008,1,0,0,0,999,1001,5,41, - 0,0,1000,1002,7,11,0,0,1001,1000,1,0,0,0,1001,1002,1,0,0,0,1002,1008,1, - 0,0,0,1003,1005,5,42,0,0,1004,1006,7,11,0,0,1005,1004,1,0,0,0,1005,1006, - 1,0,0,0,1006,1008,1,0,0,0,1007,995,1,0,0,0,1007,999,1,0,0,0,1007,1003, - 1,0,0,0,1008,117,1,0,0,0,1009,1010,3,102,51,0,1010,1011,5,0,0,1,1011,119, - 1,0,0,0,126,121,137,140,146,166,175,178,188,192,204,209,217,222,225,233, - 240,250,257,271,276,285,296,306,309,316,325,332,339,344,353,380,385,389, - 401,407,424,428,435,442,447,450,456,460,463,476,485,491,496,506,511,516, - 519,523,533,540,549,556,562,570,582,587,592,597,604,611,613,622,632,643, - 648,657,663,667,675,683,687,691,695,698,702,705,708,711,714,725,737,741, - 749,757,760,768,771,773,781,788,793,796,802,805,811,820,824,828,830,842, - 847,857,873,882,897,906,915,923,926,928,949,955,962,965,973,978,993,997, - 1001,1005,1007 + 699,8,35,1,35,1,35,1,35,3,35,704,8,35,1,35,3,35,707,8,35,1,35,3,35,710, + 8,35,1,35,3,35,713,8,35,1,35,3,35,716,8,35,1,35,3,35,719,8,35,1,36,1,36, + 1,37,1,37,1,37,1,37,1,37,5,37,728,8,37,10,37,12,37,731,9,37,1,38,1,38, + 1,38,1,39,1,39,1,39,1,39,5,39,740,8,39,10,39,12,39,743,9,39,1,39,3,39, + 746,8,39,1,40,1,40,1,40,1,40,1,40,1,40,3,40,754,8,40,1,41,1,41,1,41,1, + 41,5,41,760,8,41,10,41,12,41,763,9,41,3,41,765,8,41,1,42,1,42,1,42,1,42, + 1,42,1,42,3,42,773,8,42,1,42,3,42,776,8,42,3,42,778,8,42,1,43,1,43,1,43, + 1,43,5,43,784,8,43,10,43,12,43,787,9,43,1,44,1,44,5,44,791,8,44,10,44, + 12,44,794,9,44,1,45,1,45,3,45,798,8,45,1,45,3,45,801,8,45,1,45,1,45,1, + 45,1,45,3,45,807,8,45,1,45,3,45,810,8,45,1,45,1,45,1,45,1,45,3,45,816, + 8,45,1,46,1,46,1,46,1,46,1,46,1,46,1,47,3,47,825,8,47,1,47,1,47,3,47,829, + 8,47,1,47,1,47,3,47,833,8,47,3,47,835,8,47,1,48,1,48,1,48,1,49,1,49,1, + 49,1,49,1,49,5,49,845,8,49,10,49,12,49,848,9,49,1,50,1,50,3,50,852,8,50, + 1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,3,51,862,8,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,878,8,51,1,51, + 1,51,1,51,1,51,1,51,1,51,1,51,3,51,887,8,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,902,8,51,1,51,1,51,1,51,1,51, + 1,51,1,51,1,51,3,51,911,8,51,1,51,1,51,1,51,1,51,1,51,5,51,918,8,51,10, + 51,12,51,921,9,51,1,51,1,51,1,51,1,51,1,51,3,51,928,8,51,1,51,5,51,931, + 8,51,10,51,12,51,934,9,51,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52, + 1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,3,52,954,8,52,1,53,1,53,1, + 53,1,53,3,53,960,8,53,1,53,1,53,1,53,5,53,965,8,53,10,53,12,53,968,9,53, + 3,53,970,8,53,1,53,1,53,1,54,1,54,1,54,1,54,3,54,978,8,54,1,55,1,55,1, + 55,3,55,983,8,55,1,55,1,55,1,56,1,56,1,57,1,57,1,57,1,57,1,57,1,57,1,57, + 1,57,1,57,3,57,998,8,57,1,58,1,58,3,58,1002,8,58,1,58,1,58,3,58,1006,8, + 58,1,58,1,58,3,58,1010,8,58,3,58,1012,8,58,1,59,1,59,1,59,1,59,0,1,102, + 60,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,0,12,1,0,79,80,1,0,81,82, + 1,0,71,72,1,0,99,100,2,0,30,31,35,35,1,0,90,91,2,0,14,14,86,88,1,0,90, + 92,1,0,93,98,1,0,9,11,1,0,112,114,1,0,43,44,1161,0,121,1,0,0,0,2,144,1, + 0,0,0,4,166,1,0,0,0,6,168,1,0,0,0,8,180,1,0,0,0,10,194,1,0,0,0,12,198, + 1,0,0,0,14,211,1,0,0,0,16,220,1,0,0,0,18,227,1,0,0,0,20,238,1,0,0,0,22, + 262,1,0,0,0,24,281,1,0,0,0,26,309,1,0,0,0,28,311,1,0,0,0,30,316,1,0,0, + 0,32,318,1,0,0,0,34,380,1,0,0,0,36,385,1,0,0,0,38,387,1,0,0,0,40,424,1, + 0,0,0,42,426,1,0,0,0,44,435,1,0,0,0,46,437,1,0,0,0,48,445,1,0,0,0,50,463, + 1,0,0,0,52,519,1,0,0,0,54,604,1,0,0,0,56,613,1,0,0,0,58,622,1,0,0,0,60, + 632,1,0,0,0,62,634,1,0,0,0,64,669,1,0,0,0,66,683,1,0,0,0,68,691,1,0,0, + 0,70,693,1,0,0,0,72,720,1,0,0,0,74,722,1,0,0,0,76,732,1,0,0,0,78,735,1, + 0,0,0,80,753,1,0,0,0,82,764,1,0,0,0,84,777,1,0,0,0,86,779,1,0,0,0,88,788, + 1,0,0,0,90,815,1,0,0,0,92,817,1,0,0,0,94,834,1,0,0,0,96,836,1,0,0,0,98, + 839,1,0,0,0,100,849,1,0,0,0,102,861,1,0,0,0,104,953,1,0,0,0,106,955,1, + 0,0,0,108,977,1,0,0,0,110,982,1,0,0,0,112,986,1,0,0,0,114,997,1,0,0,0, + 116,1011,1,0,0,0,118,1013,1,0,0,0,120,122,3,18,9,0,121,120,1,0,0,0,121, + 122,1,0,0,0,122,137,1,0,0,0,123,138,3,2,1,0,124,138,3,20,10,0,125,138, + 3,38,19,0,126,138,3,22,11,0,127,138,3,24,12,0,128,138,3,32,16,0,129,138, + 3,40,20,0,130,138,3,62,31,0,131,138,3,8,4,0,132,138,3,12,6,0,133,138,3, + 116,58,0,134,138,3,6,3,0,135,138,3,14,7,0,136,138,3,64,32,0,137,123,1, + 0,0,0,137,124,1,0,0,0,137,125,1,0,0,0,137,126,1,0,0,0,137,127,1,0,0,0, + 137,128,1,0,0,0,137,129,1,0,0,0,137,130,1,0,0,0,137,131,1,0,0,0,137,132, + 1,0,0,0,137,133,1,0,0,0,137,134,1,0,0,0,137,135,1,0,0,0,137,136,1,0,0, + 0,138,140,1,0,0,0,139,141,5,103,0,0,140,139,1,0,0,0,140,141,1,0,0,0,141, + 142,1,0,0,0,142,143,5,0,0,1,143,1,1,0,0,0,144,146,5,28,0,0,145,147,5,8, + 0,0,146,145,1,0,0,0,146,147,1,0,0,0,147,148,1,0,0,0,148,149,5,27,0,0,149, + 150,5,99,0,0,150,151,3,70,35,0,151,152,5,100,0,0,152,153,5,29,0,0,153, + 154,3,4,2,0,154,3,1,0,0,0,155,167,3,20,10,0,156,167,3,38,19,0,157,167, + 3,22,11,0,158,167,3,24,12,0,159,167,3,32,16,0,160,167,3,40,20,0,161,167, + 3,62,31,0,162,167,3,8,4,0,163,167,3,12,6,0,164,167,3,6,3,0,165,167,3,64, + 32,0,166,155,1,0,0,0,166,156,1,0,0,0,166,157,1,0,0,0,166,158,1,0,0,0,166, + 159,1,0,0,0,166,160,1,0,0,0,166,161,1,0,0,0,166,162,1,0,0,0,166,163,1, + 0,0,0,166,164,1,0,0,0,166,165,1,0,0,0,167,5,1,0,0,0,168,169,7,0,0,0,169, + 178,3,112,56,0,170,175,3,102,51,0,171,172,5,101,0,0,172,174,3,102,51,0, + 173,171,1,0,0,0,174,177,1,0,0,0,175,173,1,0,0,0,175,176,1,0,0,0,176,179, + 1,0,0,0,177,175,1,0,0,0,178,170,1,0,0,0,178,179,1,0,0,0,179,7,1,0,0,0, + 180,181,5,60,0,0,181,182,3,88,44,0,182,183,5,64,0,0,183,188,3,10,5,0,184, + 185,5,101,0,0,185,187,3,10,5,0,186,184,1,0,0,0,187,190,1,0,0,0,188,186, + 1,0,0,0,188,189,1,0,0,0,189,192,1,0,0,0,190,188,1,0,0,0,191,193,3,96,48, + 0,192,191,1,0,0,0,192,193,1,0,0,0,193,9,1,0,0,0,194,195,3,110,55,0,195, + 196,5,93,0,0,196,197,3,102,51,0,197,11,1,0,0,0,198,204,5,59,0,0,199,200, + 3,112,56,0,200,201,5,102,0,0,201,202,5,86,0,0,202,205,1,0,0,0,203,205, + 5,86,0,0,204,199,1,0,0,0,204,203,1,0,0,0,204,205,1,0,0,0,205,206,1,0,0, + 0,206,207,5,2,0,0,207,209,3,88,44,0,208,210,3,96,48,0,209,208,1,0,0,0, + 209,210,1,0,0,0,210,13,1,0,0,0,211,212,5,1,0,0,212,217,3,16,8,0,213,214, + 5,101,0,0,214,216,3,16,8,0,215,213,1,0,0,0,216,219,1,0,0,0,217,215,1,0, + 0,0,217,218,1,0,0,0,218,15,1,0,0,0,219,217,1,0,0,0,220,225,5,104,0,0,221, + 223,5,5,0,0,222,221,1,0,0,0,222,223,1,0,0,0,223,224,1,0,0,0,224,226,3, + 112,56,0,225,222,1,0,0,0,225,226,1,0,0,0,226,17,1,0,0,0,227,228,5,78,0, + 0,228,233,3,28,14,0,229,230,5,101,0,0,230,232,3,28,14,0,231,229,1,0,0, + 0,232,235,1,0,0,0,233,231,1,0,0,0,233,234,1,0,0,0,234,236,1,0,0,0,235, + 233,1,0,0,0,236,237,5,103,0,0,237,19,1,0,0,0,238,240,5,38,0,0,239,241, + 5,69,0,0,240,239,1,0,0,0,240,241,1,0,0,0,241,242,1,0,0,0,242,243,5,39, + 0,0,243,244,3,112,56,0,244,245,5,99,0,0,245,250,3,46,23,0,246,247,5,101, + 0,0,247,249,3,46,23,0,248,246,1,0,0,0,249,252,1,0,0,0,250,248,1,0,0,0, + 250,251,1,0,0,0,251,257,1,0,0,0,252,250,1,0,0,0,253,254,5,101,0,0,254, + 256,3,54,27,0,255,253,1,0,0,0,256,259,1,0,0,0,257,255,1,0,0,0,257,258, + 1,0,0,0,258,260,1,0,0,0,259,257,1,0,0,0,260,261,5,100,0,0,261,21,1,0,0, + 0,262,263,5,38,0,0,263,264,5,76,0,0,264,276,3,112,56,0,265,266,5,99,0, + 0,266,271,3,112,56,0,267,268,5,101,0,0,268,270,3,112,56,0,269,267,1,0, + 0,0,270,273,1,0,0,0,271,269,1,0,0,0,271,272,1,0,0,0,272,274,1,0,0,0,273, + 271,1,0,0,0,274,275,5,100,0,0,275,277,1,0,0,0,276,265,1,0,0,0,276,277, + 1,0,0,0,277,278,1,0,0,0,278,279,5,5,0,0,279,280,3,64,32,0,280,23,1,0,0, + 0,281,282,5,38,0,0,282,283,5,77,0,0,283,285,3,112,56,0,284,286,3,26,13, + 0,285,284,1,0,0,0,285,286,1,0,0,0,286,287,1,0,0,0,287,288,5,5,0,0,288, + 289,3,36,18,0,289,25,1,0,0,0,290,291,5,99,0,0,291,296,3,28,14,0,292,293, + 5,101,0,0,293,295,3,28,14,0,294,292,1,0,0,0,295,298,1,0,0,0,296,294,1, + 0,0,0,296,297,1,0,0,0,297,299,1,0,0,0,298,296,1,0,0,0,299,300,5,100,0, + 0,300,310,1,0,0,0,301,306,3,28,14,0,302,303,5,101,0,0,303,305,3,28,14, + 0,304,302,1,0,0,0,305,308,1,0,0,0,306,304,1,0,0,0,306,307,1,0,0,0,307, + 310,1,0,0,0,308,306,1,0,0,0,309,290,1,0,0,0,309,301,1,0,0,0,310,27,1,0, + 0,0,311,312,3,30,15,0,312,313,3,48,24,0,313,29,1,0,0,0,314,317,3,112,56, + 0,315,317,5,105,0,0,316,314,1,0,0,0,316,315,1,0,0,0,317,31,1,0,0,0,318, + 319,5,45,0,0,319,320,5,39,0,0,320,321,3,112,56,0,321,322,3,34,17,0,322, + 33,1,0,0,0,323,325,5,48,0,0,324,326,5,50,0,0,325,324,1,0,0,0,325,326,1, + 0,0,0,326,327,1,0,0,0,327,381,3,46,23,0,328,329,5,48,0,0,329,381,3,54, + 27,0,330,332,5,45,0,0,331,333,5,50,0,0,332,331,1,0,0,0,332,333,1,0,0,0, + 333,334,1,0,0,0,334,335,3,112,56,0,335,339,3,48,24,0,336,338,3,52,26,0, + 337,336,1,0,0,0,338,341,1,0,0,0,339,337,1,0,0,0,339,340,1,0,0,0,340,381, + 1,0,0,0,341,339,1,0,0,0,342,344,5,45,0,0,343,345,5,50,0,0,344,343,1,0, + 0,0,344,345,1,0,0,0,345,346,1,0,0,0,346,347,3,112,56,0,347,348,5,64,0, + 0,348,349,5,65,0,0,349,350,3,102,51,0,350,381,1,0,0,0,351,353,5,45,0,0, + 352,354,5,50,0,0,353,352,1,0,0,0,353,354,1,0,0,0,354,355,1,0,0,0,355,356, + 3,112,56,0,356,357,5,49,0,0,357,358,5,65,0,0,358,381,1,0,0,0,359,360,5, + 49,0,0,360,361,5,50,0,0,361,381,3,112,56,0,362,363,5,49,0,0,363,364,5, + 56,0,0,364,381,3,112,56,0,365,366,5,46,0,0,366,367,5,47,0,0,367,381,3, + 112,56,0,368,369,5,46,0,0,369,370,5,50,0,0,370,371,3,112,56,0,371,372, + 5,47,0,0,372,373,3,112,56,0,373,381,1,0,0,0,374,375,5,46,0,0,375,376,5, + 68,0,0,376,377,3,112,56,0,377,378,5,47,0,0,378,379,3,112,56,0,379,381, + 1,0,0,0,380,323,1,0,0,0,380,328,1,0,0,0,380,330,1,0,0,0,380,342,1,0,0, + 0,380,351,1,0,0,0,380,359,1,0,0,0,380,362,1,0,0,0,380,365,1,0,0,0,380, + 368,1,0,0,0,380,374,1,0,0,0,381,35,1,0,0,0,382,386,3,64,32,0,383,386,3, + 62,31,0,384,386,3,20,10,0,385,382,1,0,0,0,385,383,1,0,0,0,385,384,1,0, + 0,0,386,37,1,0,0,0,387,389,5,38,0,0,388,390,5,67,0,0,389,388,1,0,0,0,389, + 390,1,0,0,0,390,391,1,0,0,0,391,392,5,68,0,0,392,393,3,112,56,0,393,394, + 5,21,0,0,394,395,3,112,56,0,395,396,5,99,0,0,396,401,3,42,21,0,397,398, + 5,101,0,0,398,400,3,42,21,0,399,397,1,0,0,0,400,403,1,0,0,0,401,399,1, + 0,0,0,401,402,1,0,0,0,402,404,1,0,0,0,403,401,1,0,0,0,404,407,5,100,0, + 0,405,406,5,70,0,0,406,408,3,44,22,0,407,405,1,0,0,0,407,408,1,0,0,0,408, + 39,1,0,0,0,409,410,5,49,0,0,410,411,5,39,0,0,411,425,3,112,56,0,412,413, + 5,49,0,0,413,414,5,68,0,0,414,415,3,112,56,0,415,416,5,21,0,0,416,417, + 3,112,56,0,417,425,1,0,0,0,418,419,5,49,0,0,419,420,5,77,0,0,420,425,3, + 112,56,0,421,422,5,49,0,0,422,423,5,76,0,0,423,425,3,112,56,0,424,409, + 1,0,0,0,424,412,1,0,0,0,424,418,1,0,0,0,424,421,1,0,0,0,425,41,1,0,0,0, + 426,428,3,112,56,0,427,429,7,1,0,0,428,427,1,0,0,0,428,429,1,0,0,0,429, + 43,1,0,0,0,430,436,5,54,0,0,431,432,5,73,0,0,432,436,5,85,0,0,433,434, + 5,74,0,0,434,436,5,85,0,0,435,430,1,0,0,0,435,431,1,0,0,0,435,433,1,0, + 0,0,436,45,1,0,0,0,437,438,3,112,56,0,438,442,3,48,24,0,439,441,3,52,26, + 0,440,439,1,0,0,0,441,444,1,0,0,0,442,440,1,0,0,0,442,443,1,0,0,0,443, + 47,1,0,0,0,444,442,1,0,0,0,445,447,3,112,56,0,446,448,3,112,56,0,447,446, + 1,0,0,0,447,448,1,0,0,0,448,450,1,0,0,0,449,451,3,112,56,0,450,449,1,0, + 0,0,450,451,1,0,0,0,451,460,1,0,0,0,452,453,5,99,0,0,453,456,3,50,25,0, + 454,455,5,101,0,0,455,457,3,50,25,0,456,454,1,0,0,0,456,457,1,0,0,0,457, + 458,1,0,0,0,458,459,5,100,0,0,459,461,1,0,0,0,460,452,1,0,0,0,460,461, + 1,0,0,0,461,49,1,0,0,0,462,464,5,91,0,0,463,462,1,0,0,0,463,464,1,0,0, + 0,464,465,1,0,0,0,465,466,5,107,0,0,466,51,1,0,0,0,467,468,5,8,0,0,468, + 520,5,85,0,0,469,520,5,85,0,0,470,471,5,65,0,0,471,520,3,102,51,0,472, + 473,5,70,0,0,473,520,7,2,0,0,474,475,5,56,0,0,475,477,3,112,56,0,476,474, + 1,0,0,0,476,477,1,0,0,0,477,478,1,0,0,0,478,479,5,75,0,0,479,480,5,99, + 0,0,480,481,3,56,28,0,481,482,5,100,0,0,482,520,1,0,0,0,483,484,5,56,0, + 0,484,486,3,112,56,0,485,483,1,0,0,0,485,486,1,0,0,0,486,487,1,0,0,0,487, + 488,5,54,0,0,488,520,5,55,0,0,489,490,5,56,0,0,490,492,3,112,56,0,491, + 489,1,0,0,0,491,492,1,0,0,0,492,493,1,0,0,0,493,520,5,67,0,0,494,495,5, + 56,0,0,495,497,3,112,56,0,496,494,1,0,0,0,496,497,1,0,0,0,497,498,1,0, + 0,0,498,499,5,58,0,0,499,511,3,112,56,0,500,501,5,99,0,0,501,506,3,112, + 56,0,502,503,5,101,0,0,503,505,3,112,56,0,504,502,1,0,0,0,505,508,1,0, + 0,0,506,504,1,0,0,0,506,507,1,0,0,0,507,509,1,0,0,0,508,506,1,0,0,0,509, + 510,5,100,0,0,510,512,1,0,0,0,511,500,1,0,0,0,511,512,1,0,0,0,512,516, + 1,0,0,0,513,515,3,58,29,0,514,513,1,0,0,0,515,518,1,0,0,0,516,514,1,0, + 0,0,516,517,1,0,0,0,517,520,1,0,0,0,518,516,1,0,0,0,519,467,1,0,0,0,519, + 469,1,0,0,0,519,470,1,0,0,0,519,472,1,0,0,0,519,476,1,0,0,0,519,485,1, + 0,0,0,519,491,1,0,0,0,519,496,1,0,0,0,520,53,1,0,0,0,521,522,5,56,0,0, + 522,524,3,112,56,0,523,521,1,0,0,0,523,524,1,0,0,0,524,525,1,0,0,0,525, + 526,5,54,0,0,526,527,5,55,0,0,527,528,5,99,0,0,528,533,3,112,56,0,529, + 530,5,101,0,0,530,532,3,112,56,0,531,529,1,0,0,0,532,535,1,0,0,0,533,531, + 1,0,0,0,533,534,1,0,0,0,534,536,1,0,0,0,535,533,1,0,0,0,536,537,5,100, + 0,0,537,605,1,0,0,0,538,539,5,56,0,0,539,541,3,112,56,0,540,538,1,0,0, + 0,540,541,1,0,0,0,541,542,1,0,0,0,542,543,5,67,0,0,543,544,5,99,0,0,544, + 549,3,112,56,0,545,546,5,101,0,0,546,548,3,112,56,0,547,545,1,0,0,0,548, + 551,1,0,0,0,549,547,1,0,0,0,549,550,1,0,0,0,550,552,1,0,0,0,551,549,1, + 0,0,0,552,553,5,100,0,0,553,605,1,0,0,0,554,555,5,56,0,0,555,557,3,112, + 56,0,556,554,1,0,0,0,556,557,1,0,0,0,557,558,1,0,0,0,558,559,5,57,0,0, + 559,562,5,55,0,0,560,561,5,66,0,0,561,563,5,68,0,0,562,560,1,0,0,0,562, + 563,1,0,0,0,563,564,1,0,0,0,564,565,5,99,0,0,565,570,3,112,56,0,566,567, + 5,101,0,0,567,569,3,112,56,0,568,566,1,0,0,0,569,572,1,0,0,0,570,568,1, + 0,0,0,570,571,1,0,0,0,571,573,1,0,0,0,572,570,1,0,0,0,573,574,5,100,0, + 0,574,575,5,58,0,0,575,587,3,112,56,0,576,577,5,99,0,0,577,582,3,112,56, + 0,578,579,5,101,0,0,579,581,3,112,56,0,580,578,1,0,0,0,581,584,1,0,0,0, + 582,580,1,0,0,0,582,583,1,0,0,0,583,585,1,0,0,0,584,582,1,0,0,0,585,586, + 5,100,0,0,586,588,1,0,0,0,587,576,1,0,0,0,587,588,1,0,0,0,588,592,1,0, + 0,0,589,591,3,58,29,0,590,589,1,0,0,0,591,594,1,0,0,0,592,590,1,0,0,0, + 592,593,1,0,0,0,593,605,1,0,0,0,594,592,1,0,0,0,595,596,5,56,0,0,596,598, + 3,112,56,0,597,595,1,0,0,0,597,598,1,0,0,0,598,599,1,0,0,0,599,600,5,75, + 0,0,600,601,5,99,0,0,601,602,3,56,28,0,602,603,5,100,0,0,603,605,1,0,0, + 0,604,523,1,0,0,0,604,540,1,0,0,0,604,556,1,0,0,0,604,597,1,0,0,0,605, + 55,1,0,0,0,606,612,8,3,0,0,607,608,5,99,0,0,608,609,3,56,28,0,609,610, + 5,100,0,0,610,612,1,0,0,0,611,606,1,0,0,0,611,607,1,0,0,0,612,615,1,0, + 0,0,613,611,1,0,0,0,613,614,1,0,0,0,614,57,1,0,0,0,615,613,1,0,0,0,616, + 617,5,21,0,0,617,618,5,60,0,0,618,623,3,60,30,0,619,620,5,21,0,0,620,621, + 5,59,0,0,621,623,3,60,30,0,622,616,1,0,0,0,622,619,1,0,0,0,623,59,1,0, + 0,0,624,633,5,61,0,0,625,626,5,66,0,0,626,633,5,63,0,0,627,633,5,62,0, + 0,628,629,5,64,0,0,629,633,5,85,0,0,630,631,5,64,0,0,631,633,5,65,0,0, + 632,624,1,0,0,0,632,625,1,0,0,0,632,627,1,0,0,0,632,628,1,0,0,0,632,630, + 1,0,0,0,633,61,1,0,0,0,634,635,5,51,0,0,635,636,5,52,0,0,636,667,3,112, + 56,0,637,638,5,99,0,0,638,643,3,112,56,0,639,640,5,101,0,0,640,642,3,112, + 56,0,641,639,1,0,0,0,642,645,1,0,0,0,643,641,1,0,0,0,643,644,1,0,0,0,644, + 646,1,0,0,0,645,643,1,0,0,0,646,647,5,100,0,0,647,649,1,0,0,0,648,637, + 1,0,0,0,648,649,1,0,0,0,649,663,1,0,0,0,650,651,5,53,0,0,651,652,5,99, + 0,0,652,657,3,102,51,0,653,654,5,101,0,0,654,656,3,102,51,0,655,653,1, + 0,0,0,656,659,1,0,0,0,657,655,1,0,0,0,657,658,1,0,0,0,658,660,1,0,0,0, + 659,657,1,0,0,0,660,661,5,100,0,0,661,664,1,0,0,0,662,664,3,64,32,0,663, + 650,1,0,0,0,663,662,1,0,0,0,664,668,1,0,0,0,665,666,5,65,0,0,666,668,5, + 53,0,0,667,648,1,0,0,0,667,665,1,0,0,0,668,63,1,0,0,0,669,675,3,66,33, + 0,670,671,3,68,34,0,671,672,3,66,33,0,672,674,1,0,0,0,673,670,1,0,0,0, + 674,677,1,0,0,0,675,673,1,0,0,0,675,676,1,0,0,0,676,65,1,0,0,0,677,675, + 1,0,0,0,678,684,3,70,35,0,679,680,5,99,0,0,680,681,3,64,32,0,681,682,5, + 100,0,0,682,684,1,0,0,0,683,678,1,0,0,0,683,679,1,0,0,0,684,67,1,0,0,0, + 685,687,5,34,0,0,686,688,5,35,0,0,687,686,1,0,0,0,687,688,1,0,0,0,688, + 692,1,0,0,0,689,692,5,36,0,0,690,692,5,37,0,0,691,685,1,0,0,0,691,689, + 1,0,0,0,691,690,1,0,0,0,692,69,1,0,0,0,693,695,5,1,0,0,694,696,3,72,36, + 0,695,694,1,0,0,0,695,696,1,0,0,0,696,698,1,0,0,0,697,699,3,78,39,0,698, + 697,1,0,0,0,698,699,1,0,0,0,699,700,1,0,0,0,700,703,3,82,41,0,701,702, + 5,52,0,0,702,704,3,112,56,0,703,701,1,0,0,0,703,704,1,0,0,0,704,706,1, + 0,0,0,705,707,3,86,43,0,706,705,1,0,0,0,706,707,1,0,0,0,707,709,1,0,0, + 0,708,710,3,96,48,0,709,708,1,0,0,0,709,710,1,0,0,0,710,712,1,0,0,0,711, + 713,3,74,37,0,712,711,1,0,0,0,712,713,1,0,0,0,713,715,1,0,0,0,714,716, + 3,76,38,0,715,714,1,0,0,0,715,716,1,0,0,0,716,718,1,0,0,0,717,719,3,98, + 49,0,718,717,1,0,0,0,718,719,1,0,0,0,719,71,1,0,0,0,720,721,7,4,0,0,721, + 73,1,0,0,0,722,723,5,23,0,0,723,724,5,25,0,0,724,729,3,102,51,0,725,726, + 5,101,0,0,726,728,3,102,51,0,727,725,1,0,0,0,728,731,1,0,0,0,729,727,1, + 0,0,0,729,730,1,0,0,0,730,75,1,0,0,0,731,729,1,0,0,0,732,733,5,26,0,0, + 733,734,3,102,51,0,734,77,1,0,0,0,735,736,5,4,0,0,736,741,3,80,40,0,737, + 738,7,5,0,0,738,740,3,80,40,0,739,737,1,0,0,0,740,743,1,0,0,0,741,739, + 1,0,0,0,741,742,1,0,0,0,742,745,1,0,0,0,743,741,1,0,0,0,744,746,5,32,0, + 0,745,744,1,0,0,0,745,746,1,0,0,0,746,79,1,0,0,0,747,754,5,107,0,0,748, + 754,5,105,0,0,749,750,5,99,0,0,750,751,3,102,51,0,751,752,5,100,0,0,752, + 754,1,0,0,0,753,747,1,0,0,0,753,748,1,0,0,0,753,749,1,0,0,0,754,81,1,0, + 0,0,755,765,5,86,0,0,756,761,3,84,42,0,757,758,5,101,0,0,758,760,3,84, + 42,0,759,757,1,0,0,0,760,763,1,0,0,0,761,759,1,0,0,0,761,762,1,0,0,0,762, + 765,1,0,0,0,763,761,1,0,0,0,764,755,1,0,0,0,764,756,1,0,0,0,765,83,1,0, + 0,0,766,767,3,112,56,0,767,768,5,102,0,0,768,769,5,86,0,0,769,778,1,0, + 0,0,770,775,3,102,51,0,771,773,5,5,0,0,772,771,1,0,0,0,772,773,1,0,0,0, + 773,774,1,0,0,0,774,776,3,112,56,0,775,772,1,0,0,0,775,776,1,0,0,0,776, + 778,1,0,0,0,777,766,1,0,0,0,777,770,1,0,0,0,778,85,1,0,0,0,779,780,5,2, + 0,0,780,785,3,88,44,0,781,782,5,101,0,0,782,784,3,88,44,0,783,781,1,0, + 0,0,784,787,1,0,0,0,785,783,1,0,0,0,785,786,1,0,0,0,786,87,1,0,0,0,787, + 785,1,0,0,0,788,792,3,90,45,0,789,791,3,92,46,0,790,789,1,0,0,0,791,794, + 1,0,0,0,792,790,1,0,0,0,792,793,1,0,0,0,793,89,1,0,0,0,794,792,1,0,0,0, + 795,800,3,112,56,0,796,798,5,5,0,0,797,796,1,0,0,0,797,798,1,0,0,0,798, + 799,1,0,0,0,799,801,3,112,56,0,800,797,1,0,0,0,800,801,1,0,0,0,801,816, + 1,0,0,0,802,803,5,99,0,0,803,804,3,64,32,0,804,809,5,100,0,0,805,807,5, + 5,0,0,806,805,1,0,0,0,806,807,1,0,0,0,807,808,1,0,0,0,808,810,3,112,56, + 0,809,806,1,0,0,0,809,810,1,0,0,0,810,816,1,0,0,0,811,812,5,99,0,0,812, + 813,3,88,44,0,813,814,5,100,0,0,814,816,1,0,0,0,815,795,1,0,0,0,815,802, + 1,0,0,0,815,811,1,0,0,0,816,91,1,0,0,0,817,818,3,94,47,0,818,819,5,19, + 0,0,819,820,3,90,45,0,820,821,5,21,0,0,821,822,3,102,51,0,822,93,1,0,0, + 0,823,825,5,15,0,0,824,823,1,0,0,0,824,825,1,0,0,0,825,835,1,0,0,0,826, + 828,5,16,0,0,827,829,5,18,0,0,828,827,1,0,0,0,828,829,1,0,0,0,829,835, + 1,0,0,0,830,832,5,17,0,0,831,833,5,18,0,0,832,831,1,0,0,0,832,833,1,0, + 0,0,833,835,1,0,0,0,834,824,1,0,0,0,834,826,1,0,0,0,834,830,1,0,0,0,835, + 95,1,0,0,0,836,837,5,3,0,0,837,838,3,102,51,0,838,97,1,0,0,0,839,840,5, + 22,0,0,840,841,5,25,0,0,841,846,3,100,50,0,842,843,5,101,0,0,843,845,3, + 100,50,0,844,842,1,0,0,0,845,848,1,0,0,0,846,844,1,0,0,0,846,847,1,0,0, + 0,847,99,1,0,0,0,848,846,1,0,0,0,849,851,3,102,51,0,850,852,7,1,0,0,851, + 850,1,0,0,0,851,852,1,0,0,0,852,101,1,0,0,0,853,854,6,51,-1,0,854,855, + 5,8,0,0,855,862,3,102,51,16,856,857,5,12,0,0,857,862,3,102,51,15,858,859, + 5,91,0,0,859,862,3,102,51,14,860,862,3,104,52,0,861,853,1,0,0,0,861,856, + 1,0,0,0,861,858,1,0,0,0,861,860,1,0,0,0,862,932,1,0,0,0,863,864,10,13, + 0,0,864,865,5,89,0,0,865,931,3,102,51,14,866,867,10,12,0,0,867,868,7,6, + 0,0,868,931,3,102,51,13,869,870,10,11,0,0,870,871,7,7,0,0,871,931,3,102, + 51,12,872,873,10,10,0,0,873,874,7,8,0,0,874,931,3,102,51,11,875,877,10, + 9,0,0,876,878,5,8,0,0,877,876,1,0,0,0,877,878,1,0,0,0,878,879,1,0,0,0, + 879,880,5,33,0,0,880,881,3,102,51,0,881,882,5,6,0,0,882,883,3,102,51,10, + 883,931,1,0,0,0,884,886,10,8,0,0,885,887,5,8,0,0,886,885,1,0,0,0,886,887, + 1,0,0,0,887,888,1,0,0,0,888,889,5,13,0,0,889,931,3,102,51,9,890,891,10, + 4,0,0,891,892,7,9,0,0,892,931,3,102,51,5,893,894,10,3,0,0,894,895,5,6, + 0,0,895,931,3,102,51,4,896,897,10,2,0,0,897,898,5,7,0,0,898,931,3,102, + 51,3,899,901,10,7,0,0,900,902,5,8,0,0,901,900,1,0,0,0,901,902,1,0,0,0, + 902,903,1,0,0,0,903,904,5,20,0,0,904,905,5,99,0,0,905,906,3,70,35,0,906, + 907,5,100,0,0,907,931,1,0,0,0,908,910,10,6,0,0,909,911,5,8,0,0,910,909, + 1,0,0,0,910,911,1,0,0,0,911,912,1,0,0,0,912,913,5,20,0,0,913,914,5,99, + 0,0,914,919,3,102,51,0,915,916,5,101,0,0,916,918,3,102,51,0,917,915,1, + 0,0,0,918,921,1,0,0,0,919,917,1,0,0,0,919,920,1,0,0,0,920,922,1,0,0,0, + 921,919,1,0,0,0,922,923,5,100,0,0,923,931,1,0,0,0,924,925,10,5,0,0,925, + 927,5,24,0,0,926,928,5,8,0,0,927,926,1,0,0,0,927,928,1,0,0,0,928,929,1, + 0,0,0,929,931,5,85,0,0,930,863,1,0,0,0,930,866,1,0,0,0,930,869,1,0,0,0, + 930,872,1,0,0,0,930,875,1,0,0,0,930,884,1,0,0,0,930,890,1,0,0,0,930,893, + 1,0,0,0,930,896,1,0,0,0,930,899,1,0,0,0,930,908,1,0,0,0,930,924,1,0,0, + 0,931,934,1,0,0,0,932,930,1,0,0,0,932,933,1,0,0,0,933,103,1,0,0,0,934, + 932,1,0,0,0,935,954,3,114,57,0,936,954,3,106,53,0,937,954,3,110,55,0,938, + 954,5,105,0,0,939,954,5,104,0,0,940,941,5,27,0,0,941,942,5,99,0,0,942, + 943,3,70,35,0,943,944,5,100,0,0,944,954,1,0,0,0,945,946,5,99,0,0,946,947, + 3,70,35,0,947,948,5,100,0,0,948,954,1,0,0,0,949,950,5,99,0,0,950,951,3, + 102,51,0,951,952,5,100,0,0,952,954,1,0,0,0,953,935,1,0,0,0,953,936,1,0, + 0,0,953,937,1,0,0,0,953,938,1,0,0,0,953,939,1,0,0,0,953,940,1,0,0,0,953, + 945,1,0,0,0,953,949,1,0,0,0,954,105,1,0,0,0,955,956,3,108,54,0,956,969, + 5,99,0,0,957,970,5,86,0,0,958,960,5,31,0,0,959,958,1,0,0,0,959,960,1,0, + 0,0,960,961,1,0,0,0,961,966,3,102,51,0,962,963,5,101,0,0,963,965,3,102, + 51,0,964,962,1,0,0,0,965,968,1,0,0,0,966,964,1,0,0,0,966,967,1,0,0,0,967, + 970,1,0,0,0,968,966,1,0,0,0,969,957,1,0,0,0,969,959,1,0,0,0,969,970,1, + 0,0,0,970,971,1,0,0,0,971,972,5,100,0,0,972,107,1,0,0,0,973,978,3,112, + 56,0,974,978,5,16,0,0,975,978,5,17,0,0,976,978,5,81,0,0,977,973,1,0,0, + 0,977,974,1,0,0,0,977,975,1,0,0,0,977,976,1,0,0,0,978,109,1,0,0,0,979, + 980,3,112,56,0,980,981,5,102,0,0,981,983,1,0,0,0,982,979,1,0,0,0,982,983, + 1,0,0,0,983,984,1,0,0,0,984,985,3,112,56,0,985,111,1,0,0,0,986,987,7,10, + 0,0,987,113,1,0,0,0,988,998,5,107,0,0,989,998,5,108,0,0,990,998,5,106, + 0,0,991,998,5,109,0,0,992,998,5,110,0,0,993,998,5,111,0,0,994,998,5,83, + 0,0,995,998,5,84,0,0,996,998,5,85,0,0,997,988,1,0,0,0,997,989,1,0,0,0, + 997,990,1,0,0,0,997,991,1,0,0,0,997,992,1,0,0,0,997,993,1,0,0,0,997,994, + 1,0,0,0,997,995,1,0,0,0,997,996,1,0,0,0,998,115,1,0,0,0,999,1001,5,40, + 0,0,1000,1002,7,11,0,0,1001,1000,1,0,0,0,1001,1002,1,0,0,0,1002,1012,1, + 0,0,0,1003,1005,5,41,0,0,1004,1006,7,11,0,0,1005,1004,1,0,0,0,1005,1006, + 1,0,0,0,1006,1012,1,0,0,0,1007,1009,5,42,0,0,1008,1010,7,11,0,0,1009,1008, + 1,0,0,0,1009,1010,1,0,0,0,1010,1012,1,0,0,0,1011,999,1,0,0,0,1011,1003, + 1,0,0,0,1011,1007,1,0,0,0,1012,117,1,0,0,0,1013,1014,3,102,51,0,1014,1015, + 5,0,0,1,1015,119,1,0,0,0,127,121,137,140,146,166,175,178,188,192,204,209, + 217,222,225,233,240,250,257,271,276,285,296,306,309,316,325,332,339,344, + 353,380,385,389,401,407,424,428,435,442,447,450,456,460,463,476,485,491, + 496,506,511,516,519,523,533,540,549,556,562,570,582,587,592,597,604,611, + 613,622,632,643,648,657,663,667,675,683,687,691,695,698,703,706,709,712, + 715,718,729,741,745,753,761,764,772,775,777,785,792,797,800,806,809,815, + 824,828,832,834,846,851,861,877,886,901,910,919,927,930,932,953,959,966, + 969,977,982,997,1001,1005,1009,1011 }; public static readonly ATN _ATN = diff --git a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs index 05748b8e..0bf0cb91 100644 --- a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs +++ b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs @@ -647,7 +647,8 @@ private static SelectStatement BuildSelect(SelectStatementContext ctx) return new SelectStatement(projection, star, from, where, groupBy, having, orderBy, top, Distinct: predicate?.DISTINCT() is not null, DistinctRow: predicate?.DISTINCTROW() is not null, - TopPercent: topPercent); + TopPercent: topPercent, + Into: ctx.into is null ? null : Identifier(ctx.into)); } /// The TOP count expression: a single operand, or a left-associative +/- chain of them (each diff --git a/test/LibRed.Engine.AccessTests/SelectIntoAccessTests.cs b/test/LibRed.Engine.AccessTests/SelectIntoAccessTests.cs new file mode 100644 index 00000000..dae9e98a --- /dev/null +++ b/test/LibRed.Engine.AccessTests/SelectIntoAccessTests.cs @@ -0,0 +1,109 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Catalog; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +// The make-table query, cross-checked against ACE. The shape it creates was measured first +// (SelectIntoShapeProbeTest); this asserts LibRed lands in the same place, running the same SQL through both. +// +// The comparison covers the created table's COLUMNS and INDEXES as well as its rows, because the surprising +// part of a make-table is what it does not copy: the source's primary key and indexes are dropped, so +// comparing rows alone would pass while the schema silently differed. +[Collection(AceCollection.Name)] +public class SelectIntoAccessTests : TempDatabaseTest +{ + private static string Copy() => TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "selinto-ace-"); + + private static readonly string[] Setup = + [ + "CREATE TABLE SiSrc (Id LONG PRIMARY KEY, Label TEXT(30), Qty LONG)", + "CREATE INDEX IX_SiSrc_Label ON SiSrc (Label)", + "INSERT INTO SiSrc (Id, Label, Qty) VALUES (1, 'one', 10)", + "INSERT INTO SiSrc (Id, Label, Qty) VALUES (2, 'two', 20)", + ]; + + /// Runs the make-table through both engines and compares the table each produced — its column + /// names and types, its indexes, and the rows. + private static void AssertSameAsAce(string makeTable, string verify) + { + string acePath = Copy(), ourPath = Copy(); + try + { + using (var connection = AceTestDatabase.Open(acePath)) + { + foreach (string sql in Setup) Exec(connection, sql); + Exec(connection, makeTable); + } + + using (var ourDb = TemporaryDatabase.OpenTracked(ourPath, readOnly: false)) + { + var engine = new QueryEngine(ourDb); + foreach (string sql in Setup) engine.ExecuteNonQuery(sql); + engine.ExecuteNonQuery(makeTable); + } + + // Read BOTH files with LibRed, so the schemas are described the same way and any difference is a + // real difference rather than two metadata vocabularies. + using var aceDb = JetDatabase.Open(acePath); + using var libRedDb = JetDatabase.Open(ourPath); + Assert.Equal(Describe(aceDb, "SiNew"), Describe(libRedDb, "SiNew")); + + using var check = JetDatabase.Open(ourPath); + var ours = new QueryEngine(check).ExecuteQuery(verify).Rows + .Select(r => string.Join("|", r.Select(v => Convert.ToString(v)))).ToList(); + using (var connection = AceTestDatabase.Open(acePath)) + { + using var command = connection.CreateCommand(); + command.CommandText = verify; + using var reader = command.ExecuteReader(); + var theirs = new List(); + while (reader.Read()) + theirs.Add(string.Join("|", Enumerable.Range(0, reader.FieldCount) + .Select(i => Convert.ToString(reader.GetValue(i))))); + Assert.Equal(theirs, ours); + } + } + finally + { + TemporaryDatabase.Delete(acePath); + TemporaryDatabase.Delete(ourPath); + } + } + + private static string Describe(JetDatabase db, string table) + { + TableDef? def = db.Catalog.Tables.FirstOrDefault(t => t.Name == table); + if (def is null) return "(not created)"; + return string.Join(", ", def.Columns.Select(c => $"{c.Name} {c.Type}({c.Length})")) + + " | indexes: " + (def.Indexes.Count == 0 ? "(none)" : string.Join(", ", def.Indexes.Select(i => i.Name))); + } + + [Fact] + public void Named_columns_match_ACE() => + AssertSameAsAce("SELECT Id, Label INTO SiNew FROM SiSrc", "SELECT Label FROM SiNew ORDER BY Id"); + + // SELECT * carries every column across — and still drops the key and the index. + [Fact] + public void Star_matches_ACE() => + AssertSameAsAce("SELECT * INTO SiNew FROM SiSrc", "SELECT Label FROM SiNew ORDER BY Id"); + + [Fact] + public void A_filtered_source_matches_ACE() => + AssertSameAsAce("SELECT Id, Label INTO SiNew FROM SiSrc WHERE Id = 2", "SELECT Label FROM SiNew"); + + // An empty result still creates the table in both. + [Fact] + public void An_empty_result_matches_ACE() => + AssertSameAsAce("SELECT Id, Label INTO SiNew FROM SiSrc WHERE Id > 99", "SELECT COUNT(*) FROM SiNew"); + + private static void Exec(OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Engine.AccessTests/SelectIntoShapeProbeTest.cs b/test/LibRed.Engine.AccessTests/SelectIntoShapeProbeTest.cs new file mode 100644 index 00000000..c5434471 --- /dev/null +++ b/test/LibRed.Engine.AccessTests/SelectIntoShapeProbeTest.cs @@ -0,0 +1,109 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Catalog; +using Xunit; + +namespace LibRed.Engine.Tests; + +// PROBE: what a make-table query actually creates. +// +// SELECT field1[, field2[, …]] INTO newtable [IN externaldatabase] FROM source +// +// Before implementing it, measure what ACE puts in the new table, because almost every part is a choice that +// could reasonably go either way and would be silently wrong if guessed: +// - do the columns keep the source's types AND sizes, or widen to a default? +// - does the new table inherit the primary key, the indexes, Required, defaults? +// - what type does an EXPRESSION column get, where there is no source column to copy? +// - what does it do when the target name is already taken? The docs say "a trappable error". +// +// Opt-in via LIBRED_SELECT_INTO=1. +[Collection(AceCollection.Name)] +public class SelectIntoShapeProbeTest(ITestOutputHelper output) : TempDatabaseTest +{ + [Fact] + public void Probe_make_table_shape() + { + Assert.SkipUnless(Environment.GetEnvironmentVariable("LIBRED_SELECT_INTO") == "1", + "set LIBRED_SELECT_INTO=1 — this probe needs ACE"); + + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "selinto-probe-"); + try + { + using (var connection = AceTestDatabase.Open(path)) + { + // Which column forms ACE's DDL actually accepts, one at a time — a setup that throws reports + // only "Syntax error in field definition" with no clue which field, so ask per form. + foreach ((string label, string sql) in ((string, string)[]) + [ + ("long pk", "CREATE TABLE SiT1 (Id LONG PRIMARY KEY)"), + ("text sized", "CREATE TABLE SiT2 (Id LONG, Label TEXT(30))"), + ("text notnull", "CREATE TABLE SiT3 (Id LONG, Label TEXT(30) NOT NULL)"), + ("currency", "CREATE TABLE SiT4 (Id LONG, Price CURRENCY)"), + ("datetime", "CREATE TABLE SiT5 (Id LONG, Stamp DATETIME)"), + ("bit", "CREATE TABLE SiT6 (Id LONG, Flag BIT)"), + ("memo", "CREATE TABLE SiT7 (Id LONG, Note MEMO)"), + ]) + { + try { Exec(connection, sql); output.WriteLine($" DDL {label,-14} ACCEPTED"); } + catch (OleDbException e) { output.WriteLine($" DDL {label,-14} rejected — {e.Message.Split('.')[0]}"); } + } + + // A source with a primary key, an index, a sized text column and Required — so the report + // below says which of those survive into the new table and which do not. Column names avoid + // Access's reserved words: Name and When are both reserved. + Exec(connection, "CREATE TABLE SiSrc (Id LONG PRIMARY KEY, Label TEXT(30), Qty LONG)"); + Exec(connection, "CREATE INDEX IX_SiSrc_Label ON SiSrc (Label)"); + Exec(connection, "INSERT INTO SiSrc (Id, Label, Qty) VALUES (1, 'one', 10)"); + Exec(connection, "INSERT INTO SiSrc (Id, Label, Qty) VALUES (2, 'two', 20)"); + + foreach ((string label, string sql) in ((string, string)[]) + [ + ("named columns", "SELECT Id, Label, Qty INTO SiA FROM SiSrc"), + ("star", "SELECT * INTO SiB FROM SiSrc"), + ("expression", "SELECT Id, Qty * 2 AS Doubled, Label & '!' AS Shout INTO SiC FROM SiSrc"), + ("aggregate", "SELECT COUNT(*) AS N, SUM(Qty) AS Total INTO SiD FROM SiSrc"), + ("filtered", "SELECT Id, Label INTO SiE FROM SiSrc WHERE Id = 2"), + ("empty result", "SELECT Id, Label INTO SiF FROM SiSrc WHERE Id > 99"), + ]) + { + try { Exec(connection, sql); output.WriteLine($" {label,-16} ACCEPTED {sql}"); } + catch (OleDbException e) { output.WriteLine($" {label,-16} rejected — {e.Message.Split('.')[0]}"); } + } + + // The docs say a name collision is "a trappable error" — confirm, and see the message. + try + { + Exec(connection, "SELECT Id INTO SiA FROM SiSrc"); + output.WriteLine(" existing target ACCEPTED (no error)"); + } + catch (OleDbException e) { output.WriteLine($" existing target rejected — {e.Message.Split('.')[0]}"); } + } + + // Read the created tables back through LibRed, which shows the real column definitions. + using var db = JetDatabase.Open(path); + foreach (string table in (string[])["SiSrc", "SiA", "SiB", "SiC", "SiD", "SiE", "SiF"]) + { + TableDef? def = db.Catalog.Tables.FirstOrDefault(t => t.Name == table); + if (def is null) { output.WriteLine($" {table}: not created"); continue; } + + output.WriteLine($" {table}: " + string.Join(", ", def.Columns.Select(c => + $"{c.Name} {c.Type}" + + (c.Length > 0 ? $"({c.Length})" : "") + + (c.IsNullable ? "" : " NOT NULL") + + (c.IsAutoNumber ? " COUNTER" : "")))); + output.WriteLine($" indexes: " + + (def.Indexes.Count == 0 ? "(none)" : string.Join(", ", def.Indexes.Select(i => + $"{i.Name}{(i.IsPrimaryKey ? " PK" : "")}{(i.IsUnique ? " UNIQUE" : "")}")))); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Exec(OleDbConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } +} diff --git a/test/LibRed.Engine.Tests/SelectIntoTests.cs b/test/LibRed.Engine.Tests/SelectIntoTests.cs new file mode 100644 index 00000000..5e1b409d --- /dev/null +++ b/test/LibRed.Engine.Tests/SelectIntoTests.cs @@ -0,0 +1,142 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Engine; +using LibRed.Engine.Execution; +using Xunit; + +namespace LibRed.Engine.Tests; + +// Access's make-table query: +// SELECT field1[, field2[, …]] INTO newtable [IN externaldatabase] FROM source +// +// Every expectation here was measured from ACE first (SelectIntoShapeProbeTest), because most of them could +// reasonably have gone the other way: a make-table copies DATA AND COLUMN TYPES ONLY — no primary key, no +// indexes — which is easy to assume otherwise when the operation is described as copying a table. +// +// The IN externaldatabase clause is not implemented: creating a table in another file belongs to the +// linked-database subsystem LibRed does not have. +public class SelectIntoTests : TempDatabaseTest +{ + private static QueryEngine Fresh() + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "selinto-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); + } + + private static QueryEngine WithSource() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("CREATE TABLE SiSrc (Id LONG PRIMARY KEY, Label TEXT(30), Qty LONG)"); + engine.ExecuteNonQuery("CREATE INDEX IX_SiSrc_Label ON SiSrc (Label)"); + engine.ExecuteNonQuery("INSERT INTO SiSrc (Id, Label, Qty) VALUES (1, 'one', 10)"); + engine.ExecuteNonQuery("INSERT INTO SiSrc (Id, Label, Qty) VALUES (2, 'two', 20)"); + return engine; + } + + [Fact] + public void Creates_the_table_and_copies_the_rows() + { + QueryEngine engine = WithSource(); + + int affected = engine.ExecuteNonQuery("SELECT Id, Label INTO SiNew FROM SiSrc"); + + Assert.Equal(2, affected); + Assert.Equal(2, Convert.ToInt32(engine.ExecuteQuery("SELECT COUNT(*) FROM SiNew").Rows.Single()[0])); + Assert.Equal("two", engine.ExecuteQuery("SELECT Label FROM SiNew WHERE Id = 2").Rows.Single()[0]); + } + + // The finding most worth pinning: a make-table copies data and types, NOT the key or the indexes. An + // archive of a keyed table comes back unkeyed. + [Fact] + public void Does_not_copy_the_primary_key_or_indexes() + { + QueryEngine engine = WithSource(); + engine.ExecuteNonQuery("SELECT * INTO SiNew FROM SiSrc"); + + TableDef source = engine.Database.Catalog.Tables.Single(t => t.Name == "SiSrc"); + TableDef made = engine.Database.Catalog.Tables.Single(t => t.Name == "SiNew"); + + Assert.NotEmpty(source.Indexes); // the source has a PK and an index + Assert.Empty(made.Indexes); // the copy has neither + Assert.Equal( + source.Columns.Select(c => c.Name), + made.Columns.Select(c => c.Name)); // the columns do come across + } + + [Fact] + public void Applies_the_where_clause() + { + QueryEngine engine = WithSource(); + + int affected = engine.ExecuteNonQuery("SELECT Id, Label INTO SiNew FROM SiSrc WHERE Id = 2"); + + Assert.Equal(1, affected); + Assert.Equal(2, Convert.ToInt32(engine.ExecuteQuery("SELECT Id FROM SiNew").Rows.Single()[0])); + } + + // An empty result still creates the table — measured, and the opposite is just as plausible. + [Fact] + public void An_empty_result_still_creates_the_table() + { + QueryEngine engine = WithSource(); + + int affected = engine.ExecuteNonQuery("SELECT Id, Label INTO SiNew FROM SiSrc WHERE Id > 99"); + + Assert.Equal(0, affected); + Assert.Contains(engine.Database.Catalog.Tables, t => t.Name == "SiNew"); + Assert.Equal(0, Convert.ToInt32(engine.ExecuteQuery("SELECT COUNT(*) FROM SiNew").Rows.Single()[0])); + } + + // "If newtable is the same as the name of an existing table, a trappable error occurs" — ACE reports + // "Table 'X' already exists". + [Fact] + public void An_existing_target_is_an_error() + { + QueryEngine engine = WithSource(); + engine.ExecuteNonQuery("SELECT Id INTO SiNew FROM SiSrc"); + + var error = Assert.Throws( + () => engine.ExecuteNonQuery("SELECT Id INTO SiNew FROM SiSrc")); + Assert.Contains("already exists", error.Message); + } + + // An expression column is typed from the expression, since there is no source column to copy. + [Fact] + public void An_expression_column_is_typed_from_the_expression() + { + QueryEngine engine = WithSource(); + engine.ExecuteNonQuery("SELECT Id, Qty * 2 AS Doubled, Label & '!' AS Shout INTO SiNew FROM SiSrc"); + + TableDef made = engine.Database.Catalog.Tables.Single(t => t.Name == "SiNew"); + Assert.Equal(["Id", "Doubled", "Shout"], made.Columns.Select(c => c.Name)); + Assert.Equal(JetDataType.Int32, made.Columns[1].Type); + Assert.Equal(JetDataType.Text, made.Columns[2].Type); + Assert.Equal(20, Convert.ToInt32(engine.ExecuteQuery("SELECT Doubled FROM SiNew WHERE Id = 1").Rows.Single()[0])); + Assert.Equal("one!", engine.ExecuteQuery("SELECT Shout FROM SiNew WHERE Id = 1").Rows.Single()[0]); + } + + [Fact] + public void An_aggregate_can_be_made_into_a_table() + { + QueryEngine engine = WithSource(); + engine.ExecuteNonQuery("SELECT COUNT(*) AS N, SUM(Qty) AS Total INTO SiNew FROM SiSrc"); + + object?[] row = engine.ExecuteQuery("SELECT N, Total FROM SiNew").Rows.Single(); + Assert.Equal(2, Convert.ToInt32(row[0])); + Assert.Equal(30, Convert.ToInt32(row[1])); + } + + // A make-table returns no rows: it is an action query, and asking for its rows gets an empty result + // rather than the source's. + [Fact] + public void Returns_no_rows_to_the_caller() + { + QueryEngine engine = WithSource(); + + ResultSet result = engine.ExecuteQuery("SELECT Id, Label INTO SiNew FROM SiSrc"); + + Assert.Empty(result.Rows); + Assert.Equal(2, Convert.ToInt32(engine.ExecuteQuery("SELECT COUNT(*) FROM SiNew").Rows.Single()[0])); + } +} From d4745f1c59b0e8e02786596774d1e49e71ac17f8 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 23:01:06 +0800 Subject: [PATCH 23/48] Fix the migrations-lock hang in JetHistoryRepository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MigrationsInfrastructureLibRedTest never completed: the class hung, and a killed test host kept a handle on the .accdb so the next run could not delete the file either. Diagnosed by sampling the stuck process — every thread was parked in Thread.Sleep inside AcquireDatabaseLock — and then by instrumenting the loop, which showed it spinning on a returned 0 and never on an exception. It was not a deadlock. The retry delay started at 1s and doubled while under a minute, so after seven misses every contender retried once per 64 seconds; the delay never reset, and there was no jitter, so the threads woke in lockstep and exactly one won per round. Fifteen threads therefore took ~16 minutes, which is indistinguishable from a hang. Having no timeout, the loop could not fail, only wait, so nothing was ever logged. - Retry policy: 50ms start, 1s cap, +/-25% jitter, and a one minute deadline that throws a TimeoutException naming the lock table and how to clear it. Build the lock object on success rather than allocating and discarding one on every attempt. - AcquireDatabaseLockAsync was missing both guards its synchronous twin has: no catch around the racy lock-table CREATE, none around the insert's duplicate-key race. Mirrored. Fixing the wait exposed a second defect it had been hiding. Concurrent migrators all pass the non-atomic exists-then-create check and all issue CREATE TABLE; EF catches the losers as DbException, but LibRed threw InvalidOperationException, which escaped that guard and failed the migration outright. ACE raises OleDbException there, so translating is what makes LibRed behave like the engine it stands in for. - New SchemaObjectExistsException, deriving from InvalidOperationException as ConstraintViolationException does, thrown from the four DDL name collisions: CREATE TABLE, CREATE VIEW/PROCEDURE, SELECT INTO and ALTER TABLE RENAME. LibRedCommand translates it into LibRedException with ObjectAlreadyExists (2714). - Assertions on those paths now name the exact type, since Assert.Throws does not accept a derived one. - New tests pin the lock contract that had none: the statement itself through the engine, the acquire/release cycle through ADO, and N connections contending, all bounded so a regression fails instead of hanging. MigrationsInfrastructureLibRedTest goes from hanging to 34/36 in 84s. The two remaining failures assert SQL Server baselines (sp_getapplock, CREATE DATABASE, brackets) and were never ported. Engine 961/961, Ado 55/55, Core 789/789, Engine-ACE 32/32. JetHistoryRepository is shared, so the ACE path gets the same retry policy and async guards; that has not been exercised against a real driver. Co-Authored-By: Claude Opus 5 --- .../Internal/JetHistoryRepository.cs | 100 +++++++++-- src/LibRed/LibRed.Ado/LibRedCommand.cs | 9 + src/LibRed/LibRed.Ado/LibRedException.cs | 9 + .../SchemaObjectExistsException.cs | 28 +++ .../LibRed.Core/Storage/TableCreator.cs | 7 +- src/LibRed/LibRed.Core/Storage/ViewCreator.cs | 2 +- .../Execution/StatementExecutor.cs | 2 +- .../MigrationLockAcquireTests.cs | 162 ++++++++++++++++++ .../MigrationLockConcurrencyTests.cs | 131 ++++++++++++++ .../AlterTableRenameTests.cs | 5 +- .../CreateProcedureTests.cs | 2 +- .../CreateTableDefaultTests.cs | 5 +- test/LibRed.Engine.Tests/CreateViewTests.cs | 2 +- .../MigrationLockStatementTests.cs | 111 ++++++++++++ test/LibRed.Engine.Tests/SelectIntoTests.cs | 3 +- 15 files changed, 550 insertions(+), 28 deletions(-) create mode 100644 src/LibRed/LibRed.Core/SchemaObjectExistsException.cs create mode 100644 test/LibRed.Ado.Tests/MigrationLockAcquireTests.cs create mode 100644 test/LibRed.Ado.Tests/MigrationLockConcurrencyTests.cs create mode 100644 test/LibRed.Engine.Tests/MigrationLockStatementTests.cs diff --git a/src/EFCore.Jet/Migrations/Internal/JetHistoryRepository.cs b/src/EFCore.Jet/Migrations/Internal/JetHistoryRepository.cs index ca5eb698..2edc21a8 100644 --- a/src/EFCore.Jet/Migrations/Internal/JetHistoryRepository.cs +++ b/src/EFCore.Jet/Migrations/Internal/JetHistoryRepository.cs @@ -29,7 +29,29 @@ namespace EntityFrameworkCore.Jet.Migrations.Internal /// public class JetHistoryRepository(HistoryRepositoryDependencies dependencies) : HistoryRepository(dependencies) { - private static readonly TimeSpan _retryDelay = TimeSpan.FromSeconds(1); + // Migration-lock retry policy. The lock guards a migration and is released explicitly the moment that + // migration finishes, so contention resolves in milliseconds — these delays are sized for a local file, + // not for a round trip to a remote server. + // + // The previous policy started at one second and doubled while under a minute, which had three separate + // faults that compounded: the delay was never reset, so a thread that lost a single race retried only + // once every 64 seconds for the rest of the run; there was no jitter, so every contender woke in the + // same millisecond, collided, and all but one backed off together; and the cap was 60 seconds, four + // orders of magnitude above the hold time. With N contenders in lockstep exactly one wins per round, + // making a run take N x 64s — about sixteen minutes for fifteen threads, which is indistinguishable + // from a deadlock and is why the parallel migration tests read as "hung" rather than "slow". + private static readonly TimeSpan _retryDelay = TimeSpan.FromMilliseconds(50); + private static readonly TimeSpan _maxRetryDelay = TimeSpan.FromSeconds(1); + + /// How long to keep trying for the migration lock before giving up. + /// + /// Without a deadline the retry loop cannot fail, only wait, so a lock that is never released blocks + /// the caller forever with nothing logged — the failure mode is a silent hang rather than an error + /// anyone can act on. Giving up turns it into a reportable fault, as SQL Server's + /// sp_getapplock timeout does. + /// + private static readonly TimeSpan _lockTimeout = TimeSpan.FromMinutes(1); + public override LockReleaseBehavior LockReleaseBehavior => LockReleaseBehavior.Explicit; /// @@ -146,9 +168,9 @@ public override IMigrationsDatabaseLock AcquireDatabaseLock() } var retryDelay = _retryDelay; + var deadline = DateTime.UtcNow + _lockTimeout; while (true) { - var dbLock = CreateMigrationDatabaseLock(); int? insertCount = 0; //No CREATE TABLE IF EXISTS in Jet. We try a normal CREATE TABLE and catch the exception if it already exists try @@ -162,14 +184,18 @@ public override IMigrationsDatabaseLock AcquireDatabaseLock() } if ((int)insertCount! == 1) { - return dbLock; + // Built only once the lock is actually ours; the old loop constructed one per attempt and + // dropped it on every miss. + return CreateMigrationDatabaseLock(); } - Thread.Sleep(retryDelay); - if (retryDelay < TimeSpan.FromMinutes(1)) + if (DateTime.UtcNow >= deadline) { - retryDelay = retryDelay.Add(retryDelay); + throw new TimeoutException(LockTimeoutMessage()); } + + Thread.Sleep(JitteredDelay(retryDelay)); + retryDelay = EscalateDelay(retryDelay); } } @@ -182,30 +208,72 @@ public override async Task AcquireDatabaseLockAsync( await Dependencies.RawSqlCommandBuilder.Build(CreateExistsSql(LockTableName)) .ExecuteScalarAsync(CreateRelationalCommandParameters(), cancellationToken).ConfigureAwait(false))) { - await CreateLockTableCommand().ExecuteNonQueryAsync(CreateRelationalCommandParameters(), cancellationToken) - .ConfigureAwait(false); + // Same guard as the synchronous overload, which this had been missing: the exists check above + // is not atomic, so concurrent migrators can all decide the table is absent and all issue the + // CREATE. Losing that race is the normal path, not a failure. + try + { + await CreateLockTableCommand() + .ExecuteNonQueryAsync(CreateRelationalCommandParameters(), cancellationToken) + .ConfigureAwait(false); + } + catch (DbException e) + { + if (!e.Message.Contains("already exists")) throw; + } } var retryDelay = _retryDelay; + var deadline = DateTime.UtcNow + _lockTimeout; while (true) { - var dbLock = CreateMigrationDatabaseLock(); - var insertCount = await CreateInsertLockCommand(DateTimeOffset.UtcNow) - .ExecuteScalarAsync(CreateRelationalCommandParameters(), cancellationToken) - .ConfigureAwait(false); + int? insertCount = 0; + try + { + insertCount = (int?)await CreateInsertLockCommand(DateTimeOffset.UtcNow) + .ExecuteScalarAsync(CreateRelationalCommandParameters(), cancellationToken) + .ConfigureAwait(false); + } + catch (DbException e) + { + // Likewise mirrored from the synchronous overload: the WHERE NOT EXISTS guard on the + // insert is not atomic either, so a duplicate key here means someone else took the lock. + if (!e.Message.Contains("duplicate")) throw; + } if ((int)insertCount! == 1) { - return dbLock; + return CreateMigrationDatabaseLock(); } - await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); - if (retryDelay < TimeSpan.FromMinutes(1)) + if (DateTime.UtcNow >= deadline) { - retryDelay = retryDelay.Add(retryDelay); + throw new TimeoutException(LockTimeoutMessage()); } + + await Task.Delay(JitteredDelay(retryDelay), cancellationToken).ConfigureAwait(false); + retryDelay = EscalateDelay(retryDelay); } } + /// + /// Spreads the wait by +/-25% so contenders stop waking together. This matters as much as the cap: + /// with a fixed delay every loser retries in the same millisecond as every other loser, so the herd + /// stays synchronised and each round yields exactly one winner no matter how short the delay is. + /// + private static TimeSpan JitteredDelay(TimeSpan delay) + => TimeSpan.FromTicks((long)(delay.Ticks * (0.75 + (Random.Shared.NextDouble() / 2.0)))); + + /// Doubles the backoff up to and holds there. + private static TimeSpan EscalateDelay(TimeSpan delay) + => delay >= _maxRetryDelay + ? _maxRetryDelay + : TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, _maxRetryDelay.Ticks)); + + private string LockTimeoutMessage() + => $"Timed out after {_lockTimeout.TotalSeconds:N0}s waiting for the migrations lock. Another " + + $"migration may still be running, or a previous one may have left a row in " + + $"'{LockTableName}' without releasing it; delete that row to clear the lock."; + private IRelationalCommand CreateLockTableCommand() => Dependencies.RawSqlCommandBuilder.Build($""" CREATE TABLE `{LockTableName}` ( diff --git a/src/LibRed/LibRed.Ado/LibRedCommand.cs b/src/LibRed/LibRed.Ado/LibRedCommand.cs index 17e35144..b68f0d68 100644 --- a/src/LibRed/LibRed.Ado/LibRedCommand.cs +++ b/src/LibRed/LibRed.Ado/LibRedCommand.cs @@ -85,6 +85,15 @@ private Engine.CommandResult ExecuteBatch() // unrecognised constraint failure there turns contention into a hard failure. throw new LibRedException(e.Message, LibRedException.DuplicateKey, e); } + catch (LibRed.SchemaObjectExistsException e) + { + // Same contract for DDL name collisions. EF Core's migration lock creates its lock table + // behind an exists-then-create check that several connections can pass at once, and catches + // the losers' "already exists" as DbException. Left untranslated this escapes that guard and + // fails the migration outright — ACE raises OleDbException there, so translating is what + // makes LibRed behave like the engine it stands in for. + throw new LibRedException(e.Message, LibRedException.ObjectAlreadyExists, e); + } } return last ?? new Engine.CommandResult(Engine.Execution.ResultSet.Empty, RecordsAffected: 0); diff --git a/src/LibRed/LibRed.Ado/LibRedException.cs b/src/LibRed/LibRed.Ado/LibRedException.cs index c5cdbcae..c12d4f3b 100644 --- a/src/LibRed/LibRed.Ado/LibRedException.cs +++ b/src/LibRed/LibRed.Ado/LibRedException.cs @@ -33,4 +33,13 @@ public LibRedException(string message, int number, Exception? innerException) /// duplicate-key error so callers that already special-case 2627 need no extra branch. /// public const int DuplicateKey = 2627; + + /// + /// DDL named a schema object that already exists. Provider code keys on this rather than on the + /// message: EF Core's migration lock creates its lock table under a racy exists-then-create guard and + /// treats the loser's failure as the normal path. The value matches SQL Server's "there is already an + /// object named ... in the database" error, so callers that already special-case 2714 need no extra + /// branch. + /// + public const int ObjectAlreadyExists = 2714; } diff --git a/src/LibRed/LibRed.Core/SchemaObjectExistsException.cs b/src/LibRed/LibRed.Core/SchemaObjectExistsException.cs new file mode 100644 index 00000000..40b39618 --- /dev/null +++ b/src/LibRed/LibRed.Core/SchemaObjectExistsException.cs @@ -0,0 +1,28 @@ +namespace LibRed; + +/// +/// Raised when DDL would create a schema object whose name is already taken — CREATE TABLE, CREATE VIEW, +/// SELECT ... INTO, and ALTER TABLE ... RENAME all reject a duplicate rather than writing a second catalog +/// row that shadows the existing object. The ADO layer translates it into a LibRedException (a +/// ) at the provider boundary, which is what ADO.NET callers +/// expect for a database-operation error. +/// +/// The distinct type matters for the same reason 's does: provider +/// code has to recognise this failure without reading message text. EF Core's migration lock creates its +/// lock table under a racy exists-then-create guard and relies on catching the loser's "already exists" — +/// but it catches DbException, so an untranslated escapes the +/// guard entirely and fails the migration. ACE raises an OleDbException there, so this is a case +/// where matching the ADO.NET contract is what makes LibRed behave like the engine it replaces. +/// +/// It derives from — what the engine threw before this type +/// existed — so catch (InvalidOperationException) still works. Note that this does NOT keep +/// existing Assert.Throws<InvalidOperationException> assertions passing: xUnit's +/// Assert.Throws requires an exact type match, and Assert.ThrowsAny is the one that +/// accepts derived types. Tests asserting on this path name this type directly. +/// +public sealed class SchemaObjectExistsException(string message, string objectName) + : InvalidOperationException(message) +{ + /// The name that was already taken. + public string ObjectName { get; } = objectName; +} diff --git a/src/LibRed/LibRed.Core/Storage/TableCreator.cs b/src/LibRed/LibRed.Core/Storage/TableCreator.cs index d93c3942..cbec6308 100644 --- a/src/LibRed/LibRed.Core/Storage/TableCreator.cs +++ b/src/LibRed/LibRed.Core/Storage/TableCreator.cs @@ -54,7 +54,7 @@ public void Create( // A table name is unique (case-insensitively) across the database; reject a duplicate rather // than writing a second MSysObjects row that shadows the existing table. if (_catalog.FindTable(name) is not null) - throw new InvalidOperationException($"Table '{name}' already exists."); + throw new SchemaObjectExistsException($"Table '{name}' already exists.", name); // Jet/ACE caps a table at 255 columns. The count/id fields are 2 bytes wide so we could physically // write more, but Access would refuse to open the table — fail early with a clear message instead. @@ -756,8 +756,9 @@ public bool RenameTable(string oldName, string newName) // allows (and EF's schema "move" degrades to exactly that on a schema-less engine), as is a case-only // change. Both verified — RenameFanOutProbeTest. if (ObjectNameExists(newName, exceptObjectId: table.DefinitionPage)) - throw new InvalidOperationException( - $"ALTER TABLE '{oldName}' RENAME TO '{newName}': a table or query named '{newName}' already exists."); + throw new SchemaObjectExistsException( + $"ALTER TABLE '{oldName}' RENAME TO '{newName}': a table or query named '{newName}' already exists.", + newName); RenameCatalogObject(table.DefinitionPage, newName); RepointRelationshipTables(oldName, newName); diff --git a/src/LibRed/LibRed.Core/Storage/ViewCreator.cs b/src/LibRed/LibRed.Core/Storage/ViewCreator.cs index c9584491..2f632231 100644 --- a/src/LibRed/LibRed.Core/Storage/ViewCreator.cs +++ b/src/LibRed/LibRed.Core/Storage/ViewCreator.cs @@ -58,7 +58,7 @@ private int AllocateObject(string name, int flags) foreach (object?[] row in new Table(_channel, msysObjects).Rows()) { if (string.Equals(row[nameIndex] as string, name, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"An object named '{name}' already exists."); + throw new SchemaObjectExistsException($"An object named '{name}' already exists.", name); if (row[idIndex] is int id && id < 0 && id >= nextId) nextId = id + 1; } diff --git a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs index c3e0c95f..c3dc1cbd 100644 --- a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs @@ -634,7 +634,7 @@ private int ExecuteSelectInto(SelectStatement statement) { string target = statement.Into!; if (_database.Catalog.Tables.Any(t => string.Equals(t.Name, target, StringComparison.OrdinalIgnoreCase))) - throw new InvalidOperationException($"Table '{target}' already exists."); + throw new SchemaObjectExistsException($"Table '{target}' already exists.", target); // Run the query first — with INTO stripped, or planning would recurse back into this method — and // materialise it. The rows have to exist before the table does: the source may read a table this diff --git a/test/LibRed.Ado.Tests/MigrationLockAcquireTests.cs b/test/LibRed.Ado.Tests/MigrationLockAcquireTests.cs new file mode 100644 index 00000000..f30b8757 --- /dev/null +++ b/test/LibRed.Ado.Tests/MigrationLockAcquireTests.cs @@ -0,0 +1,162 @@ +using LibRed.Data; +using Xunit; + +namespace LibRed.Ado.Tests; + +// Reproduces EF Core's JetHistoryRepository.AcquireDatabaseLock at the ADO level, statement for statement, +// because that method is an UNBOUNDED retry loop: +// +// while (true) { +// insertCount = (int?)CreateInsertLockCommand(...).ExecuteScalar(...); +// if ((int)insertCount! == 1) return dbLock; +// Thread.Sleep(retryDelay); // doubles, caps at 1 minute, never gives up +// } +// +// There is no timeout, no cancellation and no iteration cap, so ANY condition that stops that scalar being 1 +// turns Database.Migrate() into a permanent hang rather than a failure — which is why stopping the debugger +// leaves a live test host still holding the .accdb, and the next run then can't delete the file. +// +// These tests drive the same three statements with a BOUNDED loop, so a break in the sequence shows up as a +// plain assertion instead of a hang. They deliberately avoid EF and the migrations fixture entirely. +public class MigrationLockAcquireTests +{ + private const string LockTable = "__EFMigrationsLock"; + + // The exact SQL the repository builds (JetHistoryRepository.CreateExistsSql / CreateLockTableCommand / + // CreateInsertLockCommand), with only the timestamp literal pinned so the test is deterministic. + private const string ExistsSql = + $"SELECT * FROM `INFORMATION_SCHEMA.TABLES` WHERE `TABLE_NAME` = '{LockTable}';"; + + private const string CreateLockTableSql = $""" + CREATE TABLE `{LockTable}` ( + `Id` INTEGER NOT NULL CONSTRAINT `PK_{LockTable}` PRIMARY KEY, + `Timestamp` TEXT NOT NULL + ); + """; + + private const string InsertLockSql = $""" + INSERT INTO `{LockTable}` (`Id`, `Timestamp`) + SELECT 1, '2024-01-02 03:04:05+00:00' FROM (SELECT COUNT(*) FROM `#Dual`) + WHERE NOT EXISTS (SELECT * FROM `{LockTable}` WHERE `Id` = 1); + SELECT @@ROWCOUNT; + """; + + private const string DeleteLockSql = $"DELETE FROM `{LockTable}`;"; + + private static LibRedConnection OpenTemp() + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "miglock-ado-"); + var conn = new LibRedConnection($"Data Source={path}"); + conn.Open(); + // '#Dual' as LibRedConnection.CreateDualTable makes it on a provider-created database; the Northwind + // fixture is a plain file that never went through that path. + Exec(conn, "CREATE TABLE `#Dual` (`ID` LONG NOT NULL PRIMARY KEY)"); + Exec(conn, "INSERT INTO `#Dual` (`ID`) VALUES (1)"); + return conn; + } + + private static void Exec(LibRedConnection c, string sql) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + private static object? Scalar(LibRedConnection c, string sql) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = sql; + return cmd.ExecuteScalar(); + } + + // InterpretExistsResult treats null AND DBNull as "not found"; anything else is "exists". + private static bool Exists(object? value) => value is not null && value != DBNull.Value; + + [Fact] + public void Exists_check_reports_absent_then_present() + { + using LibRedConnection c = OpenTemp(); + + Assert.False(Exists(Scalar(c, ExistsSql))); + Exec(c, CreateLockTableSql); + Assert.True(Exists(Scalar(c, ExistsSql))); + } + + // The whole point of the loop: 1 means "I took the lock", 0 means "someone else holds it". + [Fact] + public void Insert_lock_reports_one_then_zero() + { + using LibRedConnection c = OpenTemp(); + Exec(c, CreateLockTableSql); + + Assert.Equal(1, Convert.ToInt32(Scalar(c, InsertLockSql))); + Assert.Equal(0, Convert.ToInt32(Scalar(c, InsertLockSql))); + } + + // ExecuteScalar must read the trailing SELECT @@ROWCOUNT, not the INSERT — if the batch returned the + // INSERT's (empty) result the cast (int?)null would throw, and if it returned 0 the loop would spin. + [Fact] + public void Insert_lock_scalar_is_never_null() + { + using LibRedConnection c = OpenTemp(); + Exec(c, CreateLockTableSql); + + object? first = Scalar(c, InsertLockSql); + Assert.NotNull(first); + Assert.NotEqual(DBNull.Value, first); + } + + // Release then re-acquire, which is what LockReleaseBehavior.Explicit does between migrations. + [Fact] + public void Lock_can_be_released_and_retaken() + { + using LibRedConnection c = OpenTemp(); + Exec(c, CreateLockTableSql); + + Assert.Equal(1, Convert.ToInt32(Scalar(c, InsertLockSql))); + Exec(c, DeleteLockSql); + Assert.Equal(1, Convert.ToInt32(Scalar(c, InsertLockSql))); + } + + // The full AcquireDatabaseLock sequence, bounded. If LibRed ever stops reporting 1 for a free lock this + // fails here in a few iterations instead of hanging Migrate() forever. + [Fact] + public void Acquire_release_cycle_terminates() + { + using LibRedConnection c = OpenTemp(); + + if (!Exists(Scalar(c, ExistsSql))) + Exec(c, CreateLockTableSql); + + for (int cycle = 0; cycle < 5; cycle++) + { + int taken = 0; + for (int attempt = 0; attempt < 10 && taken != 1; attempt++) + taken = Convert.ToInt32(Scalar(c, InsertLockSql)); + + Assert.True(taken == 1, $"cycle {cycle}: never acquired the lock within 10 attempts"); + Exec(c, DeleteLockSql); + } + } + + // A second connection must see the row the first one committed, else two migrators would both believe + // they hold the lock. (Same file, separate LibRedConnection — what EF's per-context connections are.) + [Fact] + public void A_second_connection_sees_the_held_lock() + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "miglock-ado2-"); + + using var first = new LibRedConnection($"Data Source={path}"); + first.Open(); + Exec(first, "CREATE TABLE `#Dual` (`ID` LONG NOT NULL PRIMARY KEY)"); + Exec(first, "INSERT INTO `#Dual` (`ID`) VALUES (1)"); + Exec(first, CreateLockTableSql); + Assert.Equal(1, Convert.ToInt32(Scalar(first, InsertLockSql))); + + using var second = new LibRedConnection($"Data Source={path}"); + second.Open(); + Assert.Equal(0, Convert.ToInt32(Scalar(second, InsertLockSql))); + } +} diff --git a/test/LibRed.Ado.Tests/MigrationLockConcurrencyTests.cs b/test/LibRed.Ado.Tests/MigrationLockConcurrencyTests.cs new file mode 100644 index 00000000..1fb59cf3 --- /dev/null +++ b/test/LibRed.Ado.Tests/MigrationLockConcurrencyTests.cs @@ -0,0 +1,131 @@ +using LibRed.Data; +using Xunit; + +namespace LibRed.Ado.Tests; + +// Concurrent behaviour of EF Core's migration lock on LibRed. +// +// Written while diagnosing why MigrationsInfrastructureLibRedTest hangs. The hang is real, and it is in +// JetHistoryRepository.AcquireDatabaseLock's unbounded `while (true)` retry — but these tests establish that +// the engine underneath it is NOT the cause: N connections contending for the lock all acquire and release +// cleanly, and a lock released by one connection is immediately takeable by another. +// +// They matter because that retry loop has no timeout: anything that stops the guarded INSERT reporting 1 +// turns Migrate() into a permanent hang rather than a test failure. These pin the engine side of that +// contract so a future regression shows up here, bounded, instead of as a hung suite. +public class MigrationLockConcurrencyTests +{ + private const string Acquire = """ + INSERT INTO `__EFMigrationsLock` (`Id`, `Timestamp`) + SELECT 1, '2024-01-02 03:04:05+00:00' FROM (SELECT COUNT(*) FROM `#Dual`) + WHERE NOT EXISTS (SELECT * FROM `__EFMigrationsLock` WHERE `Id` = 1); + SELECT @@ROWCOUNT; + """; + + private const string Release = "DELETE FROM `__EFMigrationsLock`;"; + + private static string NewStore(string tag) + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), tag); + using LibRedConnection c = Open(path); + Exec(c, "CREATE TABLE `#Dual` (`ID` LONG NOT NULL PRIMARY KEY)"); + Exec(c, "INSERT INTO `#Dual` (`ID`) VALUES (1)"); + Exec(c, """ + CREATE TABLE `__EFMigrationsLock` ( + `Id` INTEGER NOT NULL CONSTRAINT `PK___EFMigrationsLock` PRIMARY KEY, + `Timestamp` TEXT NOT NULL + ); + """); + return path; + } + + // The shape of Can_apply_second_migration_in_parallel: several connections racing for one lock. Every one + // must eventually win — a thread that can never acquire is exactly what hangs Migrate() forever. + [Fact] + public void Every_contending_connection_eventually_acquires() + { + const int threads = 8, attempts = 40; + string path = NewStore("conc-"); + + var won = new bool[threads]; + var failure = new string?[threads]; + var start = new Barrier(threads); + + var workers = new Thread[threads]; + for (int t = 0; t < threads; t++) + { + int id = t; + workers[t] = new Thread(() => + { + try + { + using LibRedConnection c = Open(path); + start.SignalAndWait(); + for (int a = 0; a < attempts && !won[id]; a++) + { + if (Convert.ToInt32(Scalar(c, Acquire)) == 1) + { + won[id] = true; + Exec(c, Release); // release for the next contender + } + else + { + Thread.Sleep(15); + } + } + } + catch (Exception ex) { failure[id] = $"{ex.GetType().Name}: {ex.Message}"; } + }); + workers[t].Start(); + } + + foreach (Thread w in workers) + Assert.True(w.Join(TimeSpan.FromMinutes(2)), "a contending thread never finished"); + + Assert.All(failure, f => Assert.Null(f)); + Assert.All(won, w => Assert.True(w, "a connection never acquired the lock")); + + using LibRedConnection after = Open(path); + Assert.Equal(0, Convert.ToInt32(Scalar(after, "SELECT COUNT(*) FROM `__EFMigrationsLock`"))); + } + + // A lock released by one connection must be visible as free to a connection that was ALREADY OPEN when + // the release happened — a stale view here would spin the retry loop forever against an empty table. + [Fact] + public void A_release_is_visible_to_an_already_open_connection() + { + string path = NewStore("rel-"); + using LibRedConnection a = Open(path); + using LibRedConnection b = Open(path); // opened before any lock traffic + + Assert.Equal(1, Convert.ToInt32(Scalar(a, Acquire))); + Assert.Equal(0, Convert.ToInt32(Scalar(b, Acquire))); // b correctly sees a's lock + + Exec(a, Release); + + Assert.Equal(0, Convert.ToInt32(Scalar(b, "SELECT COUNT(*) FROM `__EFMigrationsLock`"))); + Assert.Equal(1, Convert.ToInt32(Scalar(b, Acquire))); // and can now take it + } + + private static LibRedConnection Open(string path) + { + var c = new LibRedConnection($"Data Source={path}"); + c.Open(); + return c; + } + + private static void Exec(LibRedConnection c, string sql) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + private static object? Scalar(LibRedConnection c, string sql) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = sql; + return cmd.ExecuteScalar(); + } +} diff --git a/test/LibRed.Engine.Tests/AlterTableRenameTests.cs b/test/LibRed.Engine.Tests/AlterTableRenameTests.cs index 17dbc19e..f49568ea 100644 --- a/test/LibRed.Engine.Tests/AlterTableRenameTests.cs +++ b/test/LibRed.Engine.Tests/AlterTableRenameTests.cs @@ -289,8 +289,9 @@ public void Rename_rejects_a_name_that_is_already_taken_and_a_table_that_does_no var e = new QueryEngine(db); CreateParentChild(e); - Assert.Throws( + Assert.Throws( () => e.ExecuteNonQuery("ALTER TABLE Parent RENAME TO Child")); + // A missing source table is a different failure and stays a plain InvalidOperationException. Assert.Throws( () => e.ExecuteNonQuery("ALTER TABLE NoSuchTable RENAME TO Whatever")); @@ -298,7 +299,7 @@ public void Rename_rejects_a_name_that_is_already_taken_and_a_table_that_does_no // rejects this (verified in the Jet suite's RenameFanOutProbeTest), and so must LibRed. The unique // (ParentId, Name) index alone would not catch it: queries sit in a different container. e.ExecuteNonQuery("CREATE VIEW vwParent AS SELECT Id, Name FROM Parent"); - Assert.Throws( + Assert.Throws( () => e.ExecuteNonQuery("ALTER TABLE Child RENAME TO vwParent")); // The failed renames changed nothing. diff --git a/test/LibRed.Engine.Tests/CreateProcedureTests.cs b/test/LibRed.Engine.Tests/CreateProcedureTests.cs index ab4c81fd..58ecc670 100644 --- a/test/LibRed.Engine.Tests/CreateProcedureTests.cs +++ b/test/LibRed.Engine.Tests/CreateProcedureTests.cs @@ -227,7 +227,7 @@ public void Procedure_name_colliding_with_an_object_throws() try { using var db = JetDatabase.Open(path, readOnly: false); - Assert.Throws(() => + Assert.Throws(() => new QueryEngine(db).ExecuteNonQuery("CREATE PROCEDURE `Customers` AS SELECT `CustomerID` FROM `Customers`")); } finally { TemporaryDatabase.Delete(path); } diff --git a/test/LibRed.Engine.Tests/CreateTableDefaultTests.cs b/test/LibRed.Engine.Tests/CreateTableDefaultTests.cs index 0704e9dd..81e1907d 100644 --- a/test/LibRed.Engine.Tests/CreateTableDefaultTests.cs +++ b/test/LibRed.Engine.Tests/CreateTableDefaultTests.cs @@ -177,11 +177,12 @@ public void Duplicate_table_name_throws() using var db = JetDatabase.Open(path, readOnly: false); var e = new QueryEngine(db); e.ExecuteNonQuery("CREATE TABLE `Widget` (`Id` INTEGER PRIMARY KEY)"); - var ex = Assert.Throws(() => + var ex = Assert.Throws(() => e.ExecuteNonQuery("CREATE TABLE `widget` (`Id` INTEGER PRIMARY KEY)")); // different case Assert.Contains("already exists", ex.Message); + Assert.Equal("widget", ex.ObjectName); // An existing Northwind table is also protected. - Assert.Throws(() => + Assert.Throws(() => e.ExecuteNonQuery("CREATE TABLE `Shippers` (`Id` INTEGER PRIMARY KEY)")); } finally { TemporaryDatabase.Delete(path); } diff --git a/test/LibRed.Engine.Tests/CreateViewTests.cs b/test/LibRed.Engine.Tests/CreateViewTests.cs index 6509eacb..44201808 100644 --- a/test/LibRed.Engine.Tests/CreateViewTests.cs +++ b/test/LibRed.Engine.Tests/CreateViewTests.cs @@ -108,7 +108,7 @@ public void View_name_colliding_with_an_object_throws() { using var db = JetDatabase.Open(path, readOnly: false); // Northwind already has a Customers table. - Assert.Throws(() => + Assert.Throws(() => new QueryEngine(db).ExecuteNonQuery("CREATE VIEW `Customers` AS SELECT `CustomerID` FROM `Customers`")); } finally { TemporaryDatabase.Delete(path); } diff --git a/test/LibRed.Engine.Tests/MigrationLockStatementTests.cs b/test/LibRed.Engine.Tests/MigrationLockStatementTests.cs new file mode 100644 index 00000000..bdbeec38 --- /dev/null +++ b/test/LibRed.Engine.Tests/MigrationLockStatementTests.cs @@ -0,0 +1,111 @@ +using LibRed; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +// The statement EF Core's JetHistoryRepository uses to take the migrations lock. It is the reason +// INSERT ... SELECT mattered beyond completing Access's append family: without it this does not parse, and +// LibRed cannot run migrations at all. +// +// INSERT INTO `__EFMigrationsLock` (`Id`, `Timestamp`) +// SELECT 1, FROM (SELECT COUNT(*) FROM `#Dual`) +// WHERE NOT EXISTS (SELECT * FROM `__EFMigrationsLock` WHERE `Id` = 1); +// +// It exercises several things at once: a multiple-record append with an explicit column list (so the +// positional rule, not the name-matching one), a DERIVED TABLE as the source, NOT EXISTS, backtick-quoted +// identifiers, and @@ROWCOUNT read afterwards. +// +// Deliberately here rather than in the migrations suite: those tests hang, and a hung test host keeps a +// handle on the .accdb that blocks the next run from deleting it. This asserts the same SQL works without +// going near them. +public class MigrationLockStatementTests : TempDatabaseTest +{ + private static QueryEngine Fresh() + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "miglock-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); + } + + private static QueryEngine WithLockTable() + { + QueryEngine engine = Fresh(); + // '#Dual' as LibRedConnection.CreateDualTable makes it on every provider-created database: one + // Int32 primary-key column. The Northwind fixture is a plain file that never went through that + // path, so the test creates it — the point is that the emitted SQL is what EF really emits. + engine.ExecuteNonQuery("CREATE TABLE `#Dual` (`ID` LONG NOT NULL PRIMARY KEY)"); + engine.ExecuteNonQuery("CREATE TABLE `__EFMigrationsLock` (`Id` LONG PRIMARY KEY, `Timestamp` DATETIME)"); + return engine; + } + + // Taking a free lock inserts the row and reports one row affected. + [Fact] + public void Acquires_the_lock_when_it_is_free() + { + QueryEngine engine = WithLockTable(); + + int affected = engine.ExecuteNonQuery( + "INSERT INTO `__EFMigrationsLock` (`Id`, `Timestamp`) " + + "SELECT 1, #2024-01-02 03:04:05# FROM (SELECT COUNT(*) FROM `#Dual`) " + + "WHERE NOT EXISTS (SELECT * FROM `__EFMigrationsLock` WHERE `Id` = 1)"); + + Assert.Equal(1, affected); + Assert.Equal(1, Convert.ToInt32( + engine.ExecuteQuery("SELECT COUNT(*) FROM `__EFMigrationsLock`").Rows.Single()[0])); + } + + // The point of the WHERE NOT EXISTS: a second attempt inserts nothing, which is how the caller learns + // the lock is already held. @@ROWCOUNT reporting 0 is the signal, not an error. + [Fact] + public void Does_not_take_a_lock_that_is_already_held() + { + QueryEngine engine = WithLockTable(); + const string acquire = + "INSERT INTO `__EFMigrationsLock` (`Id`, `Timestamp`) " + + "SELECT 1, #2024-01-02 03:04:05# FROM (SELECT COUNT(*) FROM `#Dual`) " + + "WHERE NOT EXISTS (SELECT * FROM `__EFMigrationsLock` WHERE `Id` = 1)"; + + engine.ExecuteNonQuery(acquire); + int second = engine.ExecuteNonQuery(acquire); + + Assert.Equal(0, second); + Assert.Equal(1, Convert.ToInt32( + engine.ExecuteQuery("SELECT COUNT(*) FROM `__EFMigrationsLock`").Rows.Single()[0])); + } + + // @@ROWCOUNT after the append is how the repository reads the outcome, so it has to reflect the append + // rather than the SELECT that fed it. + [Fact] + public void Publishes_rowcount_for_the_caller() + { + QueryEngine engine = WithLockTable(); + const string acquire = + "INSERT INTO `__EFMigrationsLock` (`Id`, `Timestamp`) " + + "SELECT 1, #2024-01-02 03:04:05# FROM (SELECT COUNT(*) FROM `#Dual`) " + + "WHERE NOT EXISTS (SELECT * FROM `__EFMigrationsLock` WHERE `Id` = 1)"; + + engine.ExecuteNonQuery(acquire); + Assert.Equal(1, Convert.ToInt32(engine.ExecuteQuery("SELECT @@ROWCOUNT").Rows.Single()[0])); + + engine.ExecuteNonQuery(acquire); + Assert.Equal(0, Convert.ToInt32(engine.ExecuteQuery("SELECT @@ROWCOUNT").Rows.Single()[0])); + } + + // Releasing and retaking it, which is the whole lock cycle. + [Fact] + public void Releases_and_retakes() + { + QueryEngine engine = WithLockTable(); + const string acquire = + "INSERT INTO `__EFMigrationsLock` (`Id`, `Timestamp`) " + + "SELECT 1, #2024-01-02 03:04:05# FROM (SELECT COUNT(*) FROM `#Dual`) " + + "WHERE NOT EXISTS (SELECT * FROM `__EFMigrationsLock` WHERE `Id` = 1)"; + + engine.ExecuteNonQuery(acquire); + engine.ExecuteNonQuery("DELETE FROM `__EFMigrationsLock`"); + int retaken = engine.ExecuteNonQuery(acquire); + + Assert.Equal(1, retaken); + } +} diff --git a/test/LibRed.Engine.Tests/SelectIntoTests.cs b/test/LibRed.Engine.Tests/SelectIntoTests.cs index 5e1b409d..d2b50037 100644 --- a/test/LibRed.Engine.Tests/SelectIntoTests.cs +++ b/test/LibRed.Engine.Tests/SelectIntoTests.cs @@ -96,9 +96,10 @@ public void An_existing_target_is_an_error() QueryEngine engine = WithSource(); engine.ExecuteNonQuery("SELECT Id INTO SiNew FROM SiSrc"); - var error = Assert.Throws( + var error = Assert.Throws( () => engine.ExecuteNonQuery("SELECT Id INTO SiNew FROM SiSrc")); Assert.Contains("already exists", error.Message); + Assert.Equal("SiNew", error.ObjectName); } // An expression column is typed from the expression, since there is no source column to copy. From dd1b6bb1940097e7c0218453a29b3a97f1e2b05b Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Sun, 23 Aug 2026 23:18:02 +0800 Subject: [PATCH 24/48] Run statementless SQL as a no-op instead of a parse error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit migrationBuilder.Sql("--Before") sends a command whose entire text is a comment. The lexer skips WS/LINE_COMMENT/BLOCK_COMMENT outright, so that text produces no tokens, and the statement rule has no empty production — so it failed with "mismatched input '' expecting {SELECT, IF, ...}". Any user migration carrying a comment-only command hit this. A comment is not a token, so this is not a missing statement kind and gets no AST node: text with no tokens simply has no statement to run. The check is answered by the lexer rather than by scanning for '--', because in SELECT '--' the dashes belong to a string literal and a textual strip would reduce a real statement to nothing. - ISqlParser.IsStatementless, implemented by pulling one token and asking whether it is already EOF. - QueryEngine.ExecuteQuery/Execute short-circuit to an empty result. They take no page scope: there is no work to isolate. Execute reports zero rows affected rather than a query's -1, a comment being an action that did nothing rather than a result set. - LibRedCommand.ExecuteBatch skips such a fragment rather than running it. ExecuteBatch returns the LAST statement's result, so without this "INSERT ...; -- done" would report the comment's zero rows in place of the insert's, and take @@ROWCOUNT with it. Found in MigrationsInfrastructureLibRedTest, which died on the comment before reaching anything it was testing. That test still fails, now on the next command: its migration body is unported T-SQL (IF OBJECT_ID, THROW 65536, brackets), as is its baseline, and it fails on ACE too — it is absent from the Jet green list. Engine 970/970, Ado 59/59, Core 789/789, Engine-ACE 32/32. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Ado/LibRedCommand.cs | 5 ++ src/LibRed/LibRed.Engine/QueryEngine.cs | 15 ++++ .../LibRed.Sql/Parsing/AntlrSqlParser.cs | 12 +++ src/LibRed/LibRed.Sql/Parsing/ISqlParser.cs | 13 ++++ .../LibRed.Ado.Tests/CommentOnlyBatchTests.cs | 75 +++++++++++++++++++ .../CommentOnlyStatementTests.cs | 63 ++++++++++++++++ 6 files changed, 183 insertions(+) create mode 100644 test/LibRed.Ado.Tests/CommentOnlyBatchTests.cs create mode 100644 test/LibRed.Engine.Tests/CommentOnlyStatementTests.cs diff --git a/src/LibRed/LibRed.Ado/LibRedCommand.cs b/src/LibRed/LibRed.Ado/LibRedCommand.cs index b68f0d68..be4078be 100644 --- a/src/LibRed/LibRed.Ado/LibRedCommand.cs +++ b/src/LibRed/LibRed.Ado/LibRedCommand.cs @@ -72,6 +72,11 @@ private Engine.CommandResult ExecuteBatch() Engine.CommandResult? last = null; foreach (string statement in SplitStatements(CommandText)) { + // 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 + // the insert's. A batch that is entirely comments falls through to the empty result below. + if (engine.IsStatementless(statement)) continue; + try { last = engine.Execute(statement, parameters); diff --git a/src/LibRed/LibRed.Engine/QueryEngine.cs b/src/LibRed/LibRed.Engine/QueryEngine.cs index 7a0cfc27..92784e59 100644 --- a/src/LibRed/LibRed.Engine/QueryEngine.cs +++ b/src/LibRed/LibRed.Engine/QueryEngine.cs @@ -37,6 +37,10 @@ public QueryEngine(JetDatabase database, ISqlParser? parser = null) public ResultSet ExecuteQuery(string sql, IReadOnlyDictionary? parameters = null) { + // Text with no statement in it (blank, or only comments) has nothing to run and nothing to return. + // It takes no page scope at all — there is no work to isolate. + if (_parser.IsStatementless(sql)) return ResultSet.Empty; + // Parse outside the gate — it touches no pages, and holding a file-wide scope across it would // serialize parsing for no isolation benefit. The parsed shape then picks the scope. SqlStatement parsed = _parser.ParseStatement(sql); @@ -76,6 +80,13 @@ private ResultSet ExecuteQueryCore(SqlStatement parsed, IReadOnlyDictionary? parameters = null) => Execute(sql, parameters).RecordsAffected; + /// + /// True when holds no statement — blank, or nothing but comments. The ADO batch + /// splitter uses this to drop such a fragment instead of running it, so a trailing -- comment in a + /// batch cannot become the batch's "last statement" and mask the real one's rows-affected. + /// + public bool IsStatementless(string sql) => _parser.IsStatementless(sql); + /// Executes a stored action query (a CREATE PROCEDURE body that is not a SELECT) by name — the /// read-back counterpart of . The query is reconstructed from /// its catalog rows and run; a kind LibRed cannot execute (e.g. INSERT … SELECT) throws @@ -98,6 +109,10 @@ public int ExecuteStoredActionQuery(string name) /// public CommandResult Execute(string sql, IReadOnlyDictionary? parameters = null) { + // As in ExecuteQuery: nothing to parse, nothing to run. Reported as zero rows affected rather than + // the -1 a query returns, since a comment is an action that did nothing, not a result set. + if (_parser.IsStatementless(sql)) return new CommandResult(ResultSet.Empty, RecordsAffected: 0); + SqlStatement parsed = _parser.ParseStatement(sql); return Scoped(parsed, () => ExecuteCore(parsed, parameters)); } diff --git a/src/LibRed/LibRed.Sql/Parsing/AntlrSqlParser.cs b/src/LibRed/LibRed.Sql/Parsing/AntlrSqlParser.cs index 69ca9333..8df87990 100644 --- a/src/LibRed/LibRed.Sql/Parsing/AntlrSqlParser.cs +++ b/src/LibRed/LibRed.Sql/Parsing/AntlrSqlParser.cs @@ -27,6 +27,18 @@ public SqlStatement ParseStatement(string sql) return new AstBuilder().Build(parser.statement()); } + public bool IsStatementless(string sql) + { + if (string.IsNullOrWhiteSpace(sql)) return true; + + // Ask the lexer, not a text scan: WS/LINE_COMMENT/BLOCK_COMMENT are `-> skip`, so text made only of + // those produces no tokens at all. Anything else yields at least one, and a `--` inside a string + // literal stays part of that literal's token rather than starting a comment. + var lexer = new AccessSqlLexer(new AntlrInputStream(sql)); + lexer.RemoveErrorListeners(); // a malformed statement is ParseStatement's error to report, not ours + return lexer.NextToken().Type == TokenConstants.EOF; + } + public Expression ParseExpression(string sql) { ArgumentException.ThrowIfNullOrWhiteSpace(sql); diff --git a/src/LibRed/LibRed.Sql/Parsing/ISqlParser.cs b/src/LibRed/LibRed.Sql/Parsing/ISqlParser.cs index cab73222..f075a25d 100644 --- a/src/LibRed/LibRed.Sql/Parsing/ISqlParser.cs +++ b/src/LibRed/LibRed.Sql/Parsing/ISqlParser.cs @@ -12,6 +12,19 @@ public interface ISqlParser /// Parses a single statement. Throws on syntax errors. SqlStatement ParseStatement(string sql); + /// + /// True when carries no statement at all — it is empty, whitespace, or nothing but + /// comments. Such text is not a statement to execute; callers run it as a no-op rather than parsing it, + /// since the grammar has no production for "nothing" and would report a syntax error at EOF. + /// + /// + /// EF Core produces this from migrationBuilder.Sql("--some note"), which is legitimate and must + /// succeed silently. The check has to come from the lexer rather than from scanning for --: in + /// SELECT '--' the dashes are inside a string literal, and a textual strip would wrongly reduce a + /// real statement to nothing. + /// + bool IsStatementless(string sql); + /// Parses a complete bare scalar expression (e.g. a column DEFAULT value), consuming /// the entire input apart from skipped whitespace/comments. Throws /// on syntax errors or trailing tokens. diff --git a/test/LibRed.Ado.Tests/CommentOnlyBatchTests.cs b/test/LibRed.Ado.Tests/CommentOnlyBatchTests.cs new file mode 100644 index 00000000..8c81d691 --- /dev/null +++ b/test/LibRed.Ado.Tests/CommentOnlyBatchTests.cs @@ -0,0 +1,75 @@ +using LibRed.Data; +using Xunit; + +namespace LibRed.Ado.Tests; + +// The ADO batch splitter drops fragments that hold no statement. EF Core sends +// migrationBuilder.Sql("--Before") as a whole command, and a trailing comment must not be treated as the +// batch's last statement — ExecuteBatch reports the LAST statement's result, so a trailing `-- done` would +// otherwise mask the real statement's rows-affected. +public class CommentOnlyBatchTests +{ + private static LibRedConnection OpenTemp() + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "cmtb-"); + var c = new LibRedConnection($"Data Source={path}"); + c.Open(); + Exec(c, "CREATE TABLE `T` (`Id` LONG NOT NULL PRIMARY KEY)"); + return c; + } + + private static void Exec(LibRedConnection c, string sql) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + private static int NonQuery(LibRedConnection c, string sql) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = sql; + return cmd.ExecuteNonQuery(); + } + + // What migrationBuilder.Sql("--Before") produces. + [Fact] + public void A_command_that_is_only_a_comment_succeeds() + { + using LibRedConnection c = OpenTemp(); + Assert.Equal(0, NonQuery(c, "--Before")); + } + + // The regression the skip exists to prevent: the insert's count must survive a trailing comment. + [Fact] + public void A_trailing_comment_does_not_mask_rows_affected() + { + using LibRedConnection c = OpenTemp(); + Assert.Equal(1, NonQuery(c, "INSERT INTO `T` (`Id`) VALUES (1); -- done")); + Assert.Equal(1, NonQuery(c, "INSERT INTO `T` (`Id`) VALUES (2);\r\n-- trailing note\r\n")); + } + + // ... and @@ROWCOUNT, which EF reads back through a second statement in the same batch, must agree. + [Fact] + public void Rowcount_survives_a_trailing_comment() + { + using LibRedConnection c = OpenTemp(); + using var cmd = c.CreateCommand(); + cmd.CommandText = "INSERT INTO `T` (`Id`) VALUES (7); -- note\r\nSELECT @@ROWCOUNT;"; + Assert.Equal(1, Convert.ToInt32(cmd.ExecuteScalar())); + } + + // A comment between two statements is skipped without disturbing either. + [Fact] + public void A_comment_between_statements_is_skipped() + { + using LibRedConnection c = OpenTemp(); + Assert.Equal(1, NonQuery(c, + "INSERT INTO `T` (`Id`) VALUES (3);\r\n-- in between\r\nINSERT INTO `T` (`Id`) VALUES (4);")); + + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM `T`"; + Assert.Equal(2, Convert.ToInt32(cmd.ExecuteScalar())); + } +} diff --git a/test/LibRed.Engine.Tests/CommentOnlyStatementTests.cs b/test/LibRed.Engine.Tests/CommentOnlyStatementTests.cs new file mode 100644 index 00000000..206188b7 --- /dev/null +++ b/test/LibRed.Engine.Tests/CommentOnlyStatementTests.cs @@ -0,0 +1,63 @@ +using LibRed; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +// Text that holds no statement — blank, or nothing but comments — runs as a no-op instead of raising a parse +// error. The grammar skips WS/LINE_COMMENT/BLOCK_COMMENT outright, so such input produces no tokens and the +// statement rule (which has no empty production) used to fail with "mismatched input ''". +// +// This is ordinary EF Core output: migrationBuilder.Sql("--Before") sends a command that is only a comment, +// and it has to succeed silently. Found via MigrationsInfrastructureLibRedTest, which died on exactly that +// before reaching anything it was actually testing. +public class CommentOnlyStatementTests : TempDatabaseTest +{ + private static QueryEngine Fresh() + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "cmt-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); + } + + [Theory] + [InlineData("--Before")] + [InlineData("-- a line comment")] + [InlineData("/* a block comment */")] + [InlineData(" \t\r\n ")] + [InlineData("-- one\r\n-- two\r\n")] + [InlineData("/* mixed */ -- kinds\r\n")] + public void Statementless_text_is_a_no_op(string sql) + { + QueryEngine e = Fresh(); + Assert.Equal(0, e.ExecuteNonQuery(sql)); + Assert.Empty(e.ExecuteQuery(sql).Rows); + } + + // The check must come from the lexer, not a scan for '--': here the dashes are inside a string literal, + // so this is a real statement and must still run. + [Fact] + public void Dashes_inside_a_string_literal_are_not_a_comment() + { + QueryEngine e = Fresh(); + Assert.Equal("--", e.ExecuteQuery("SELECT '--' FROM `Shippers`").Rows.First()[0]); + Assert.False(e.IsStatementless("SELECT '--' FROM `Shippers`")); + } + + // A comment attached to a real statement is unaffected — this is how EF Core query tags arrive. + [Fact] + public void A_comment_preceding_a_statement_still_runs_it() + { + QueryEngine e = Fresh(); + Assert.NotEmpty(e.ExecuteQuery("-- a query tag\r\nSELECT `CompanyName` FROM `Shippers`").Rows); + } + + // A statement that is genuinely malformed must still report a parse error rather than being swallowed as + // a no-op — the short-circuit is for absent statements, not broken ones. + [Fact] + public void A_malformed_statement_still_throws() + { + QueryEngine e = Fresh(); + Assert.ThrowsAny(() => e.ExecuteNonQuery("SELECT FROM")); + } +} From 00988094463ed4c40bdf56ac8fdcf28665ca3673 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 24 Aug 2026 00:31:56 +0800 Subject: [PATCH 25/48] Update tests --- .../MigrationsInfrastructureJetTest.cs | 258 +----------------- .../MigrationsInfrastructureLibRedTest.cs | 224 +-------------- 2 files changed, 2 insertions(+), 480 deletions(-) diff --git a/test/EFCore.Jet.FunctionalTests/Migrations/MigrationsInfrastructureJetTest.cs b/test/EFCore.Jet.FunctionalTests/Migrations/MigrationsInfrastructureJetTest.cs index 2ddaef67..e4c99f5e 100644 --- a/test/EFCore.Jet.FunctionalTests/Migrations/MigrationsInfrastructureJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Migrations/MigrationsInfrastructureJetTest.cs @@ -508,229 +508,7 @@ public async Task Empty_Migration_Creates_Database() Assert.True(creator.Exists()); } - - [Fact] - public void Non_transactional_migration_is_retried() - { - using var context = new BloggingContext( - Fixture.TestStore.AddProviderOptions( - new DbContextOptionsBuilder().EnableServiceProviderCaching(false)) - .ConfigureWarnings(e => e.Log( - RelationalEventId.PendingModelChangesWarning, RelationalEventId.NonTransactionalMigrationOperationWarning)) - .UseLoggerFactory(Fixture.TestSqlLoggerFactory).Options); - - context.Database.EnsureDeleted(); - GiveMeSomeTime(context); - - Fixture.TestSqlLoggerFactory.Clear(); - - var creator = (JetDatabaseCreator)context.GetService(); - //creator.RetryTimeout = TimeSpan.FromMinutes(10); - - context.Database.Migrate(); - - Assert.Equal( - """ -CREATE DATABASE [MigrationsTest]; - -IF SERVERPROPERTY('EngineEdition') <> 5 -BEGIN - ALTER DATABASE [MigrationsTest] SET READ_COMMITTED_SNAPSHOT ON; -END; - -SELECT 1 - -DECLARE @result int; -EXEC @result = sp_getapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session', @LockMode = 'Exclusive'; -SELECT @result - -IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL -BEGIN - CREATE TABLE [__EFMigrationsHistory] ( - [MigrationId] nvarchar(150) NOT NULL, - [ProductVersion] nvarchar(32) NOT NULL, - CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId]) - ); -END; - -SELECT 1 - -SELECT OBJECT_ID(N'[__EFMigrationsHistory]'); - -SELECT [MigrationId], [ProductVersion] -FROM [__EFMigrationsHistory] -ORDER BY [MigrationId]; - -INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) -VALUES (N'00000000000000_Empty', N'7.0.0-test'); - ---Before - -IF OBJECT_ID(N'Blogs', N'U') IS NULL -BEGIN - CREATE TABLE [Blogs] ( - [Id] int NOT NULL, - [Name] nvarchar(max) NOT NULL, - CONSTRAINT [PK_Blogs] PRIMARY KEY ([Id]) - ); - - THROW 65536, 'Test', 0; -END - -DECLARE @result int; -EXEC @result = sp_releaseapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session'; -SELECT @result - -DECLARE @result int; -EXEC @result = sp_getapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session', @LockMode = 'Exclusive'; -SELECT @result - -SELECT 1 - -SELECT OBJECT_ID(N'[__EFMigrationsHistory]'); - -SELECT [MigrationId], [ProductVersion] -FROM [__EFMigrationsHistory] -ORDER BY [MigrationId]; - -IF OBJECT_ID(N'Blogs', N'U') IS NULL -BEGIN - CREATE TABLE [Blogs] ( - [Id] int NOT NULL, - [Name] nvarchar(max) NOT NULL, - CONSTRAINT [PK_Blogs] PRIMARY KEY ([Id]) - ); - - THROW 65536, 'Test', 0; -END - -INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) -VALUES (N'00000000000001_Migration1', N'7.0.0-test'); - ---After - -INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) -VALUES (N'00000000000002_Migration2', N'7.0.0-test'); - -DECLARE @result int; -EXEC @result = sp_releaseapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session'; -SELECT @result -""", - Fixture.TestSqlLoggerFactory.Sql.Replace(ProductInfo.GetVersion(), "7.0.0-test"), - ignoreLineEndingDifferences: true); - } - - [Fact] - public async Task Non_transactional_migration_is_retried_async() - { - using var context = new BloggingContext( - Fixture.TestStore.AddProviderOptions( - new DbContextOptionsBuilder().EnableServiceProviderCaching(false)) - .ConfigureWarnings(e => e.Log( - RelationalEventId.PendingModelChangesWarning, RelationalEventId.NonTransactionalMigrationOperationWarning)) - .UseLoggerFactory(Fixture.TestSqlLoggerFactory).Options); - - context.Database.EnsureDeleted(); - GiveMeSomeTime(context); - - Fixture.TestSqlLoggerFactory.Clear(); - - var creator = (JetDatabaseCreator)context.GetService(); - //creator.RetryTimeout = TimeSpan.FromMinutes(10); - - await context.Database.MigrateAsync(); - - Assert.Equal( - """ -CREATE DATABASE [MigrationsTest]; - -IF SERVERPROPERTY('EngineEdition') <> 5 -BEGIN - ALTER DATABASE [MigrationsTest] SET READ_COMMITTED_SNAPSHOT ON; -END; - -SELECT 1 - -DECLARE @result int; -EXEC @result = sp_getapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session', @LockMode = 'Exclusive'; -SELECT @result - -IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL -BEGIN - CREATE TABLE [__EFMigrationsHistory] ( - [MigrationId] nvarchar(150) NOT NULL, - [ProductVersion] nvarchar(32) NOT NULL, - CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId]) - ); -END; - -SELECT 1 - -SELECT OBJECT_ID(N'[__EFMigrationsHistory]'); - -SELECT [MigrationId], [ProductVersion] -FROM [__EFMigrationsHistory] -ORDER BY [MigrationId]; - -INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) -VALUES (N'00000000000000_Empty', N'7.0.0-test'); - ---Before - -IF OBJECT_ID(N'Blogs', N'U') IS NULL -BEGIN - CREATE TABLE [Blogs] ( - [Id] int NOT NULL, - [Name] nvarchar(max) NOT NULL, - CONSTRAINT [PK_Blogs] PRIMARY KEY ([Id]) - ); - - THROW 65536, 'Test', 0; -END - -DECLARE @result int; -EXEC @result = sp_releaseapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session'; -SELECT @result - -DECLARE @result int; -EXEC @result = sp_getapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session', @LockMode = 'Exclusive'; -SELECT @result - -SELECT 1 - -SELECT OBJECT_ID(N'[__EFMigrationsHistory]'); - -SELECT [MigrationId], [ProductVersion] -FROM [__EFMigrationsHistory] -ORDER BY [MigrationId]; - -IF OBJECT_ID(N'Blogs', N'U') IS NULL -BEGIN - CREATE TABLE [Blogs] ( - [Id] int NOT NULL, - [Name] nvarchar(max) NOT NULL, - CONSTRAINT [PK_Blogs] PRIMARY KEY ([Id]) - ); - - THROW 65536, 'Test', 0; -END - -INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) -VALUES (N'00000000000001_Migration1', N'7.0.0-test'); - ---After - -INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) -VALUES (N'00000000000002_Migration2', N'7.0.0-test'); - -DECLARE @result int; -EXEC @result = sp_releaseapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session'; -SELECT @result -""", - Fixture.TestSqlLoggerFactory.Sql.Replace(ProductInfo.GetVersion(), "7.0.0-test"), - ignoreLineEndingDifferences: true); - } - + private class BloggingContext(DbContextOptions options, bool? randomData = null) : DbContext(options) { // ReSharper disable once UnusedMember.Local @@ -1634,40 +1412,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) } } - // --------------------------------------------------------------------------------------------- - // The ONLY intentional divergence from MigrationsInfrastructureLibRedTest (which is otherwise - // mirrored line for line, differing only in the LibRed/Jet naming). These four inherited tests - // HANG on Jet/ACE rather than fail: AcquireDatabaseLock retries in an unbounded `while (true)` - // loop, so a lock that is never released blocks the run indefinitely and takes the whole suite - // with it. Keep them skipped until the underlying contention behaviour is fixed; drop these - // overrides at that point to restore an exact mirror. - // --------------------------------------------------------------------------------------------- - - [Fact(Skip = "For now")] - public override void Can_apply_one_migration_in_parallel() - { - base.Can_apply_one_migration_in_parallel(); - } - - [Fact(Skip = "For now")] - public override Task Can_apply_one_migration_in_parallel_async() - { - return base.Can_apply_one_migration_in_parallel_async(); - } - - [Fact(Skip = "For now")] - public override void Can_apply_second_migration_in_parallel() - { - base.Can_apply_second_migration_in_parallel(); - } - - [Fact(Skip = "For now")] - public override Task Can_apply_second_migration_in_parallel_async() - { - return base.Can_apply_second_migration_in_parallel_async(); - } - - public class MigrationsInfrastructureJetFixture : MigrationsInfrastructureFixtureBase { protected override ITestStoreFactory TestStoreFactory diff --git a/test/EFCore.LibRed.FunctionalTests/Migrations/MigrationsInfrastructureLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Migrations/MigrationsInfrastructureLibRedTest.cs index 7ae0cc19..459c2ce4 100644 --- a/test/EFCore.LibRed.FunctionalTests/Migrations/MigrationsInfrastructureLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Migrations/MigrationsInfrastructureLibRedTest.cs @@ -508,229 +508,7 @@ public async Task Empty_Migration_Creates_Database() Assert.True(creator.Exists()); } - - [Fact] - public void Non_transactional_migration_is_retried() - { - using var context = new BloggingContext( - Fixture.TestStore.AddProviderOptions( - new DbContextOptionsBuilder().EnableServiceProviderCaching(false)) - .ConfigureWarnings(e => e.Log( - RelationalEventId.PendingModelChangesWarning, RelationalEventId.NonTransactionalMigrationOperationWarning)) - .UseLoggerFactory(Fixture.TestSqlLoggerFactory).Options); - - context.Database.EnsureDeleted(); - GiveMeSomeTime(context); - - Fixture.TestSqlLoggerFactory.Clear(); - - var creator = (LibRedDatabaseCreator)context.GetService(); - //creator.RetryTimeout = TimeSpan.FromMinutes(10); - - context.Database.Migrate(); - - Assert.Equal( - """ -CREATE DATABASE [MigrationsTest]; - -IF SERVERPROPERTY('EngineEdition') <> 5 -BEGIN - ALTER DATABASE [MigrationsTest] SET READ_COMMITTED_SNAPSHOT ON; -END; - -SELECT 1 - -DECLARE @result int; -EXEC @result = sp_getapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session', @LockMode = 'Exclusive'; -SELECT @result - -IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL -BEGIN - CREATE TABLE [__EFMigrationsHistory] ( - [MigrationId] nvarchar(150) NOT NULL, - [ProductVersion] nvarchar(32) NOT NULL, - CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId]) - ); -END; - -SELECT 1 - -SELECT OBJECT_ID(N'[__EFMigrationsHistory]'); - -SELECT [MigrationId], [ProductVersion] -FROM [__EFMigrationsHistory] -ORDER BY [MigrationId]; - -INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) -VALUES (N'00000000000000_Empty', N'7.0.0-test'); - ---Before - -IF OBJECT_ID(N'Blogs', N'U') IS NULL -BEGIN - CREATE TABLE [Blogs] ( - [Id] int NOT NULL, - [Name] nvarchar(max) NOT NULL, - CONSTRAINT [PK_Blogs] PRIMARY KEY ([Id]) - ); - - THROW 65536, 'Test', 0; -END - -DECLARE @result int; -EXEC @result = sp_releaseapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session'; -SELECT @result - -DECLARE @result int; -EXEC @result = sp_getapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session', @LockMode = 'Exclusive'; -SELECT @result - -SELECT 1 - -SELECT OBJECT_ID(N'[__EFMigrationsHistory]'); - -SELECT [MigrationId], [ProductVersion] -FROM [__EFMigrationsHistory] -ORDER BY [MigrationId]; - -IF OBJECT_ID(N'Blogs', N'U') IS NULL -BEGIN - CREATE TABLE [Blogs] ( - [Id] int NOT NULL, - [Name] nvarchar(max) NOT NULL, - CONSTRAINT [PK_Blogs] PRIMARY KEY ([Id]) - ); - - THROW 65536, 'Test', 0; -END - -INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) -VALUES (N'00000000000001_Migration1', N'7.0.0-test'); - ---After - -INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) -VALUES (N'00000000000002_Migration2', N'7.0.0-test'); - -DECLARE @result int; -EXEC @result = sp_releaseapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session'; -SELECT @result -""", - Fixture.TestSqlLoggerFactory.Sql.Replace(ProductInfo.GetVersion(), "7.0.0-test"), - ignoreLineEndingDifferences: true); - } - - [Fact] - public async Task Non_transactional_migration_is_retried_async() - { - using var context = new BloggingContext( - Fixture.TestStore.AddProviderOptions( - new DbContextOptionsBuilder().EnableServiceProviderCaching(false)) - .ConfigureWarnings(e => e.Log( - RelationalEventId.PendingModelChangesWarning, RelationalEventId.NonTransactionalMigrationOperationWarning)) - .UseLoggerFactory(Fixture.TestSqlLoggerFactory).Options); - - context.Database.EnsureDeleted(); - GiveMeSomeTime(context); - - Fixture.TestSqlLoggerFactory.Clear(); - - var creator = (LibRedDatabaseCreator)context.GetService(); - //creator.RetryTimeout = TimeSpan.FromMinutes(10); - - await context.Database.MigrateAsync(); - - Assert.Equal( - """ -CREATE DATABASE [MigrationsTest]; - -IF SERVERPROPERTY('EngineEdition') <> 5 -BEGIN - ALTER DATABASE [MigrationsTest] SET READ_COMMITTED_SNAPSHOT ON; -END; - -SELECT 1 - -DECLARE @result int; -EXEC @result = sp_getapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session', @LockMode = 'Exclusive'; -SELECT @result - -IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL -BEGIN - CREATE TABLE [__EFMigrationsHistory] ( - [MigrationId] nvarchar(150) NOT NULL, - [ProductVersion] nvarchar(32) NOT NULL, - CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId]) - ); -END; - -SELECT 1 - -SELECT OBJECT_ID(N'[__EFMigrationsHistory]'); - -SELECT [MigrationId], [ProductVersion] -FROM [__EFMigrationsHistory] -ORDER BY [MigrationId]; - -INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) -VALUES (N'00000000000000_Empty', N'7.0.0-test'); - ---Before - -IF OBJECT_ID(N'Blogs', N'U') IS NULL -BEGIN - CREATE TABLE [Blogs] ( - [Id] int NOT NULL, - [Name] nvarchar(max) NOT NULL, - CONSTRAINT [PK_Blogs] PRIMARY KEY ([Id]) - ); - - THROW 65536, 'Test', 0; -END - -DECLARE @result int; -EXEC @result = sp_releaseapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session'; -SELECT @result - -DECLARE @result int; -EXEC @result = sp_getapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session', @LockMode = 'Exclusive'; -SELECT @result - -SELECT 1 - -SELECT OBJECT_ID(N'[__EFMigrationsHistory]'); - -SELECT [MigrationId], [ProductVersion] -FROM [__EFMigrationsHistory] -ORDER BY [MigrationId]; - -IF OBJECT_ID(N'Blogs', N'U') IS NULL -BEGIN - CREATE TABLE [Blogs] ( - [Id] int NOT NULL, - [Name] nvarchar(max) NOT NULL, - CONSTRAINT [PK_Blogs] PRIMARY KEY ([Id]) - ); - - THROW 65536, 'Test', 0; -END - -INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) -VALUES (N'00000000000001_Migration1', N'7.0.0-test'); - ---After - -INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) -VALUES (N'00000000000002_Migration2', N'7.0.0-test'); - -DECLARE @result int; -EXEC @result = sp_releaseapplock @Resource = '__EFMigrationsLock', @LockOwner = 'Session'; -SELECT @result -""", - Fixture.TestSqlLoggerFactory.Sql.Replace(ProductInfo.GetVersion(), "7.0.0-test"), - ignoreLineEndingDifferences: true); - } - + private class BloggingContext(DbContextOptions options, bool? randomData = null) : DbContext(options) { // ReSharper disable once UnusedMember.Local From 62af6ae3473d0583528e6a44d6981db3025e4e07 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 24 Aug 2026 02:19:03 +0800 Subject: [PATCH 26/48] Take the relocation pointer from the slot's first 4 bytes, not its width Reading MSysAccessStorage from the Northwind ACCDB failed outright: InvalidDataException: A relocation source must contain exactly one 4-byte pointer; found 55 bytes RowRelocationReader required a relocation slot to be exactly 4 bytes wide. That table has live overflow slots of 45-63 bytes, and the pointer is sitting in the leading 4 bytes of each one. The remainder is the row as it was BEFORE it moved, with only those 4 bytes overwritten. Discount them and every field lands where the row format puts it (the pointer covers the 2-byte column count plus the first 2 bytes of the first column, leaving that column's remaining 6 bytes at offset 4); the remnant's Id/ParentId/Type/Name equal those of the row it forwards to; the remnant's null bitmap differs from its target's in exactly one bit, the OLE column Lv, whose arrival grew the row and forced the move; and every remnant is shorter than its target. The slot kept the old row's width instead of being trimmed. So the width was never the contract - the leading pointer is. Require at least 4 bytes and read sourceBytes[..4]. The checks that actually validate a relocation are untouched and unchanged: the target must be in the file, owned by the same TDEF, and a nonempty hidden inline row. What writes the wide form is NOT known, and is deliberately not claimed in either the comment or the spec. Every writer reachable from code trims to exactly 4 - measured across 317 relocations with no exception, covering ACE on x64, the ACE 2010 runtime on x86, and LibRed's own writer, under growing and shrinking text, repeated re-relocation, page fragmentation by interleaved deletes, and an OLE column going from NULL to a value. Access's own maintenance of its system tables is not reachable through SQL DML and remains unexercised; the spec records that as the open avenue rather than guessing. Because no write path produces the shape, the wide case is covered by handing the resolver a wider source span directly rather than manufacturing it on disk. A companion test pins our own writer still trimming, so if that ever changes the spec's claim fails loudly instead of going stale quietly. page-01-data-and-rows.md said "contains exactly one 4-byte pointer" and "validates the exact source width". Both corrected, with the evidence. Core 793/793, Engine 970/970, Ado 59/59, Engine-ACE 32/32. Co-Authored-By: Claude Opus 5 --- .../Storage/RowRelocationReader.cs | 28 ++- .../docs/format/page-01-data-and-rows.md | 33 +++- .../RelocationSlotWidthTests.cs | 169 ++++++++++++++++++ 3 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 test/LibRed.Core.Tests/RelocationSlotWidthTests.cs diff --git a/src/LibRed/LibRed.Core/Storage/RowRelocationReader.cs b/src/LibRed/LibRed.Core/Storage/RowRelocationReader.cs index fdd359fe..928cbc8b 100644 --- a/src/LibRed/LibRed.Core/Storage/RowRelocationReader.cs +++ b/src/LibRed/LibRed.Core/Storage/RowRelocationReader.cs @@ -10,7 +10,27 @@ internal readonly record struct RelocatedRow(PageBuffer Buffer, RowSlot Slot, in public ReadOnlySpan Bytes => Buffer.Slice(Slot.Offset, Slot.Length); } -/// Validates and follows the 4-byte forward pointer stored in a live overflow row slot. +/// +/// Validates and follows the forward pointer at the START of a live overflow row slot. +/// +/// +/// The slot is normally exactly 4 bytes: ACE's DML and both trim it down to the +/// pointer when a row is relocated. Measured over 317 relocations with no exception, across ACE x64, the +/// ACE 2010 x86 runtime, and LibRed's own writer, under growing and shrinking text, repeated re-relocation, +/// page fragmentation by interleaved deletes, and an OLE column going from NULL to a value. +/// +/// Real files nevertheless contain longer ones. Northwind's MSysAccessStorage has live overflow slots +/// of 45-63 bytes, and their contents are the row as it was BEFORE it moved, with only the leading 4 bytes +/// replaced by the pointer: every field lands where the row format puts it once those 4 bytes are discounted, +/// the keys match the row it forwards to, and the remnant's null bitmap differs from its target's in exactly +/// the OLE column's bit — the value whose arrival grew the row and forced the move. The slot simply kept the +/// old row's width. +/// +/// What wrote them is NOT known: no write path reproduces the shape, including the OLE-column transition the +/// bytes themselves record. So this reads the leading pointer and ignores whatever follows, rather than +/// asserting a width. The checks that matter are unchanged and do the real work — the target must be in the +/// file, owned by the same table, and a nonempty hidden inline row. +/// internal static class RowRelocationReader { public static RelocatedRow Resolve(PageChannel channel, int owningTablePage, @@ -18,11 +38,11 @@ public static RelocatedRow Resolve(PageChannel channel, int owningTablePage, { if (sourceSlot.IsDeleted || !sourceSlot.HasOverflow) throw new InvalidDataException("A relocation source must be a live overflow row slot."); - if (sourceBytes.Length != 4) + if (sourceBytes.Length < 4) throw new InvalidDataException( - $"A relocation source must contain exactly one 4-byte pointer; found {sourceBytes.Length} bytes."); + $"A relocation source must begin with a 4-byte pointer; found {sourceBytes.Length} bytes."); - int pointer = BinaryPrimitives.ReadInt32LittleEndian(sourceBytes); + int pointer = BinaryPrimitives.ReadInt32LittleEndian(sourceBytes[..4]); int pageNumber = pointer >> 8; int rowNumber = pointer & 0xFF; if (pageNumber <= 0 || pageNumber >= channel.PageCount) diff --git a/src/LibRed/docs/format/page-01-data-and-rows.md b/src/LibRed/docs/format/page-01-data-and-rows.md index cbb22958..b9e75061 100644 --- a/src/LibRed/docs/format/page-01-data-and-rows.md +++ b/src/LibRed/docs/format/page-01-data-and-rows.md @@ -27,7 +27,7 @@ of the page backward, so a slot runs from its offset up to where the previous sl ### Relocated rows -A live slot with `0x4000` set contains exactly one 4-byte little-endian forward pointer, +A live slot with `0x4000` set **begins with** a 4-byte little-endian forward pointer, `(targetPage << 8) | targetRow`. The target is a nonempty inline row on a type-`0x01` page owned by the same table. Its target slot has `0x8000` (deleted/hidden) set and `0x4000` clear: ordinary scans skip the hidden physical row, while the original row id and its index entries continue to resolve @@ -35,10 +35,35 @@ through the live source slot. A zero-length slot with both flags set is a tombst source. These shapes are verified by LibRed-created files opened by Access and Access-relocated files read by LibRed. +**The slot is normally exactly 4 bytes wide, but not always.** When ACE relocates a row through +ordinary DML it trims the slot down to the pointer, and LibRed does the same. Measured across **317 +relocations with no exception**, covering ACE on x64, the **ACE 2010 runtime on x86**, and LibRed's +own writer, under: growing and shrinking text, repeated re-relocation of the same rows, page +fragmentation by interleaved deletes and re-inserts, and an OLE column going from NULL to a value. + +Longer slots exist in the wild all the same. In the Northwind ACCDB, `MSysAccessStorage` carries live +overflow slots of 45–63 bytes. Their content is the row **as it was before it moved**, with only the +leading 4 bytes replaced by the pointer: + +- discount those 4 bytes and every field lands exactly where the row format puts it — the pointer + covers the 2-byte column count plus the first 2 bytes of the first column, leaving the remaining + 6 bytes of that column at offset 4; +- the remnant's `Id` / `ParentId` / `Type` / `Name` equal those of the row it forwards to; +- the remnant's null bitmap differs from its target's in exactly one bit, the OLE column `Lv` — + NULL in the remnant, set in the target — which is what grew the row and forced the move; +- every remnant is shorter than its target (55→89, 55→89, 63→87, 63→75, 45→57, 57→71, 53→65). + +So the slot kept the previous row's width and was stamped with the pointer rather than trimmed. +**What writes them is not known**, and is deliberately not asserted here. No write path reproduces +the shape — not the OLE-column transition the bytes themselves record, and not an older engine +(ACE 2010 trims exactly as the current one does). What has *not* been exercised is Access's own +maintenance of its system tables, which is not reachable through SQL DML. Readers must therefore +take the pointer from the leading 4 bytes and ignore any remainder rather than requiring a width. + LibRed follows relocations through one shared resolver used by scans, index seeks, and raw-row -mutation helpers. It validates the exact source width, in-file page number, target row, page owner, -and source/target flag shapes before exposing target bytes; malformed pointers fail with -`InvalidDataException`. +mutation helpers. It validates that the source begins with a 4-byte pointer, plus the in-file page +number, target row, page owner, and source/target flag shapes before exposing target bytes; +malformed pointers fail with `InvalidDataException`. ## 5. Row record format diff --git a/test/LibRed.Core.Tests/RelocationSlotWidthTests.cs b/test/LibRed.Core.Tests/RelocationSlotWidthTests.cs new file mode 100644 index 00000000..1cf4d871 --- /dev/null +++ b/test/LibRed.Core.Tests/RelocationSlotWidthTests.cs @@ -0,0 +1,169 @@ +using LibRed; +using LibRed.Catalog; +using LibRed.Formats; +using LibRed.IO; +using LibRed.Pages; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// A relocation slot normally holds exactly the 4-byte forward pointer — both ACE's DML and LibRed's writer +// trim it. Real files contain wider ones anyway: Northwind's MSysAccessStorage keeps the pre-move row and +// stamps the pointer over its first 4 bytes, which LibRed used to reject outright, making that table +// unreadable. See page-01-data-and-rows.md for the evidence. +// +// No write path produces the wide form — not text growth, not repeated re-relocation, not an OLE column going +// from NULL to a value — so the wide case is exercised by handing the resolver a wider source span directly +// rather than by trying to manufacture the on-disk shape. +public class RelocationSlotWidthTests +{ + // A row whose OLE column starts NULL and is then given a value grows and must move; that is the cheapest + // reliable way to get real relocations to resolve against. + private static string CreateStoreWithRelocations() + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "relocw-"); + using var db = JetDatabase.Open(path, readOnly: false); + db.CreateTable("R", + [new ColumnSpec("Id", JetDataType.Int32, 4, IsFixedLength: true, IsNullable: false), + new ColumnSpec("Nm", JetDataType.Text, 100, IsFixedLength: false), + new ColumnSpec("Lv", JetDataType.Ole, 0, IsFixedLength: false)], + primaryKey: ["Id"]); + + Table table = db.OpenTable("R"); + for (int id = 1; id <= 80; id++) + table.Insert([id, $"name-{id}", null]); + foreach ((RowId rowId, object?[] values) in table.Rows().WithIds().ToList()) + { + object?[] updated = (object?[])values.Clone(); + updated[2] = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18 }; + table.Update(rowId, updated, new HashSet { 2 }); + } + return path; + } + + /// The first live overflow slot in the table, as (page, slot index, offset, length). + private static (int Page, int Index, int Offset, int Length) FirstOverflowSlot(Table table) + { + PageChannel channel = table.Channel; + int dir = channel.Format.DataRowDirectoryOffset; + foreach (int pageNumber in table.UsageMap.DataPages()) + { + PageBuffer page = channel.ReadPage(pageNumber); + int rowCount = page.ReadUInt16(channel.Format.DataRowCountOffset); + int prevEnd = page.Length; + for (int i = 0; i < rowCount; i++) + { + int raw = page.ReadUInt16(dir + i * 2); + int offset = raw & RowPointer.OffsetMask; + int length = prevEnd - offset; + prevEnd = offset; + if ((raw & RowPointer.DeletedFlag) == 0 && (raw & RowPointer.OverflowFlag) != 0) + return (pageNumber, i, offset, length); + } + } + throw new InvalidOperationException("no live overflow slot was produced"); + } + + // The invariant the format doc records: our own writer trims. If this ever stops holding, the note in + // page-01-data-and-rows.md is wrong and the wide-slot tolerance below is doing more than it claims. + [Fact] + public void LibRed_writes_relocation_slots_exactly_four_bytes_wide() + { + string path = CreateStoreWithRelocations(); + try + { + using var db = JetDatabase.Open(path, readOnly: true); + Table table = db.OpenTable("R"); + PageChannel channel = table.Channel; + int dir = channel.Format.DataRowDirectoryOffset; + + var widths = new List(); + foreach (int pageNumber in table.UsageMap.DataPages()) + { + PageBuffer page = channel.ReadPage(pageNumber); + int rowCount = page.ReadUInt16(channel.Format.DataRowCountOffset); + int prevEnd = page.Length; + for (int i = 0; i < rowCount; i++) + { + int raw = page.ReadUInt16(dir + i * 2); + int offset = raw & RowPointer.OffsetMask; + int length = prevEnd - offset; + prevEnd = offset; + if ((raw & RowPointer.DeletedFlag) == 0 && (raw & RowPointer.OverflowFlag) != 0) + widths.Add(length); + } + } + + Assert.NotEmpty(widths); + Assert.All(widths, w => Assert.Equal(4, w)); + } + finally { TemporaryDatabase.Delete(path); } + } + + // The fix: a source wider than the pointer resolves to the same row as the trimmed form. The trailing + // bytes are the pre-move row and carry no meaning, so filling them with anything must change nothing. + [Fact] + public void A_source_wider_than_the_pointer_resolves_to_the_same_row() + { + string path = CreateStoreWithRelocations(); + try + { + using var db = JetDatabase.Open(path, readOnly: true); + Table table = db.OpenTable("R"); + PageChannel channel = table.Channel; + (int pageNumber, _, int offset, int length) = FirstOverflowSlot(table); + Assert.Equal(4, length); + + byte[] pointer = channel.ReadPage(pageNumber).Slice(offset, 4).ToArray(); + + byte[] trimmed = RowRelocationReader.Resolve( + channel, table.Definition.DefinitionPage, + new RowSlot(offset, 4, IsDeleted: false, HasOverflow: true), pointer).Bytes.ToArray(); + + // The Northwind shape: the pointer followed by 51 bytes of the row as it was before it moved. + byte[] wide = new byte[55]; + pointer.CopyTo(wide, 0); + for (int i = 4; i < wide.Length; i++) wide[i] = (byte)(i * 7); + + byte[] fromWide = RowRelocationReader.Resolve( + channel, table.Definition.DefinitionPage, + new RowSlot(offset, wide.Length, IsDeleted: false, HasOverflow: true), wide).Bytes.ToArray(); + + Assert.Equal(trimmed, fromWide); + } + finally { TemporaryDatabase.Delete(path); } + } + + // Still rejected: a source too short to hold a pointer at all. Tolerating a wide slot must not turn into + // tolerating a truncated one. + [Fact] + public void A_source_shorter_than_the_pointer_is_still_rejected() + { + string path = CreateStoreWithRelocations(); + try + { + using var db = JetDatabase.Open(path, readOnly: true); + Table table = db.OpenTable("R"); + (_, _, int offset, _) = FirstOverflowSlot(table); + + var error = Assert.Throws(() => RowRelocationReader.Resolve( + table.Channel, table.Definition.DefinitionPage, + new RowSlot(offset, 3, IsDeleted: false, HasOverflow: true), new byte[3])); + Assert.Contains("4-byte pointer", error.Message); + } + finally { TemporaryDatabase.Delete(path); } + } + + // The symptom that started this: MSysAccessStorage in the Northwind fixture could not be read at all. + // Weaker as a guard than the tests above — a compact/repair changes how many wide slots that table has + // (observed going from 7 to 5) and could in principle leave none — so it is the reported bug, not the + // contract. + [Fact] + public void The_northwind_system_storage_table_can_be_read() + { + using var db = JetDatabase.Open(TestDatabases.NorthwindAccdb, readOnly: true); + Table table = db.OpenTable("MSysAccessStorage"); + Assert.NotEmpty(table.Rows().ToList()); + } +} From e82a21e6e64235c6c169ee00157097fdd053a469 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 24 Aug 2026 02:32:24 +0800 Subject: [PATCH 27/48] Use modified DateOnly to make sure that the year is over year 100 --- .../Temporal/DateOnlyTranslationsJetTest.cs | 12 ++++++++++-- .../Temporal/DateOnlyTranslationsLibRedTest.cs | 12 ++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/test/EFCore.Jet.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsJetTest.cs index 8551b214..a681edb0 100644 --- a/test/EFCore.Jet.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsJetTest.cs @@ -2,7 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.EntityFrameworkCore.Query.Translations.Temporal; +using Microsoft.EntityFrameworkCore.TestModels.BasicTypesModel; using Microsoft.EntityFrameworkCore.TestUtilities; +using System; +using System.Linq; using System.Threading.Tasks; using Xunit; @@ -160,11 +163,16 @@ WHERE DATEVALUE(`b`.`DateTime`) = `b`.`DateOnly` public override async Task FromDateTime_compared_to_constant_and_parameter() { - await base.FromDateTime_compared_to_constant_and_parameter(); + //await base.FromDateTime_compared_to_constant_and_parameter(); + + var dateOnly = new DateOnly(102, 10, 11); + + await AssertQuery(ss => ss.Set() + .Where(x => new[] { dateOnly, new DateOnly(1998, 5, 4) }.Contains(DateOnly.FromDateTime(x.DateTime)))); AssertSql( """ -@dateOnly='0002-10-11T00:00:00.0000000' (DbType = Date) +@dateOnly='0102-10-11T00:00:00.0000000' (DbType = Date) 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` diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsLibRedTest.cs index 455ed610..5edeae53 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsLibRedTest.cs @@ -2,7 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.EntityFrameworkCore.Query.Translations.Temporal; +using Microsoft.EntityFrameworkCore.TestModels.BasicTypesModel; using Microsoft.EntityFrameworkCore.TestUtilities; +using System; +using System.Linq; using System.Threading.Tasks; using Xunit; @@ -160,11 +163,16 @@ WHERE DATEVALUE(`b`.`DateTime`) = `b`.`DateOnly` public override async Task FromDateTime_compared_to_constant_and_parameter() { - await base.FromDateTime_compared_to_constant_and_parameter(); + //await base.FromDateTime_compared_to_constant_and_parameter(); + + var dateOnly = new DateOnly(102, 10, 11); + + await AssertQuery(ss => ss.Set() + .Where(x => new[] { dateOnly, new DateOnly(1998, 5, 4) }.Contains(DateOnly.FromDateTime(x.DateTime)))); AssertSql( """ -@dateOnly='0002-10-11T00:00:00.0000000' (DbType = Date) +@dateOnly='0102-10-11T00:00:00.0000000' (DbType = Date) 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` From d2264a0bbb95dc69d725ea2231b6dd9e63a51196 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 24 Aug 2026 18:03:17 +0800 Subject: [PATCH 28/48] Write an empty byte[] as '' - a bare 0x is T-SQL and ACE rejects it JetByteArrayTypeMapping built its literal by appending "0x" and then each byte as hex, so an empty array produced the bare string "0x". That is SQL Server's empty-varbinary literal and it is not valid Access SQL: ACE answers "Syntax error in query expression '0x'". The SQL was therefore wrong for both providers - it failed on ACE exactly as it failed on LibRed, whose parser was right to reject it (HEX_LITERAL already requires at least one digit, matching ACE). Verified against ACE, an empty STRING is the literal that works, in a value position and in a DEFAULT: '' stores and reads back as a zero-length byte[], and stays distinct from NULL (IS NULL does not match it). 0x00 is NOT the same thing - that is a one-byte zero. The parity half is the larger part. LibRed's codec cast straight to byte[], so any string reaching a binary column threw InvalidCastException - not only the empty one. Fixing just the empty case would have left 'A' broken. ACE's rule, measured for VARBINARY and LONGBINARY alike, is that a string in a binary column stores its UTF-16LE bytes: '' -> byte[0] 'A' -> 4100 'AB' -> 41004200 'e' -> E900 (U+00E9) '41' -> 34003100 the digits '4','1', NOT the byte 0x41 The empty case falls out of that rather than being special. JetTypeCodec now routes Binary and Ole through AsBinary, which encodes a string that way and passes a byte[] through untouched - the same UTF-16 treatment Memo text already had. Tests cover the rule, not just the fix: empty stores zero-length, empty is not NULL, 0x00 is a one-byte zero, the four UTF-16 cases, hex still round trips, and a digitless 0x is still rejected as ACE rejects it. Spec check (LibRed.Core type-codec change): no docs/format update. The stored representation is unchanged - raw bytes either way; what changed is which input types the codec accepts. ACE treating binary as a Unicode string throughout its SQL surface is worth documenting on its own terms once explored (Len/LenB, comparison, concatenation), not as a footnote here. Engine 979/979, Core 793/793, Ado 59/59, Engine-ACE 32/32. Co-Authored-By: Claude Opus 5 --- .../Internal/JetByteArrayTypeMapping.cs | 18 +++- .../LibRed.Core/Storage/Types/JetTypeCodec.cs | 24 ++++- .../EmptyBinaryLiteralTests.cs | 101 ++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 test/LibRed.Engine.Tests/EmptyBinaryLiteralTests.cs diff --git a/src/EFCore.Jet/Storage/Internal/JetByteArrayTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetByteArrayTypeMapping.cs index 21733afa..d297b76f 100644 --- a/src/EFCore.Jet/Storage/Internal/JetByteArrayTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetByteArrayTypeMapping.cs @@ -78,10 +78,24 @@ protected override void ConfigureParameter(DbParameter parameter) /// protected override string GenerateNonNullSqlLiteral(object value) { - var builder = new StringBuilder(); + var bytes = (byte[])value; + + // An empty array has no hex form here. SQL Server writes a bare '0x', but that is T-SQL only — + // ACE rejects it outright ("Syntax error in query expression '0x'"), so the SQL was invalid for + // this provider rather than merely unusual, and failed the same way on ACE and on LibRed. + // + // An empty STRING literal is what Access accepts, in a value position and in a DEFAULT. Verified + // against ACE: '' stores and reads back as a zero-length byte[], and stays distinct from NULL + // (IS NULL does not match it). Note 0x00 is NOT the same thing — that is a one-byte zero. + if (bytes.Length == 0) + { + return "''"; + } + + var builder = new StringBuilder(2 + bytes.Length * 2); builder.Append("0x"); - foreach (var @byte in (byte[])value) + foreach (var @byte in bytes) { builder.Append(@byte.ToString("X2", CultureInfo.InvariantCulture)); } diff --git a/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs b/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs index 0e6b0452..b00b8d8e 100644 --- a/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs +++ b/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs @@ -171,7 +171,7 @@ public static byte[] Encode(ColumnDef column, object value) case JetDataType.Text: return EncodeText(column, AsText(value, c)); case JetDataType.Binary: - return EncodeBinary(column, (byte[])value); + return EncodeBinary(column, AsBinary(value)); case JetDataType.FixedPoint: return EncodeNumeric(Convert.ToDecimal(value, c), column.Scale); @@ -181,7 +181,7 @@ public static byte[] Encode(ColumnDef column, object value) case JetDataType.Memo: return EncodeInlineLongValue(Encoding.Unicode.GetBytes(AsText(value, c))); case JetDataType.Ole: - return EncodeInlineLongValue((byte[])value); + return EncodeInlineLongValue(AsBinary(value)); default: throw new NotSupportedException($"Encoding {column.Type} is not supported yet."); @@ -225,6 +225,26 @@ private static byte[] Bytes(int length, Action> write) return b; } + /// + /// The bytes to store for a binary (BINARY/VARBINARY) or OLE column. + /// + /// + /// A string written to a binary column stores its UTF-16LE bytes, exactly as text does — verified + /// against ACE for VARBINARY and LONGBINARY alike: 'A' stores 4100, 'AB' stores + /// 41004200, 'é' stores E900. The characters are NOT parsed as hex: '41' + /// stores 34003100 — the digits '4' and '1' — not the byte 0x41. + /// + /// The empty string therefore stores zero bytes, and that is the only way to write an empty binary as a + /// literal: Access has no digitless 0x (it rejects it), which is why + /// JetByteArrayTypeMapping emits '' for an empty array. + /// + private static byte[] AsBinary(object value) => value switch + { + byte[] bytes => bytes, + string text => Encoding.Unicode.GetBytes(text), + _ => (byte[])value, + }; + /// The OLE-automation epoch (1899-12-30), which is also Jet's zero date and the base for /// storing a / as a date offset. private static readonly DateTime OleEpoch = new(1899, 12, 30); diff --git a/test/LibRed.Engine.Tests/EmptyBinaryLiteralTests.cs b/test/LibRed.Engine.Tests/EmptyBinaryLiteralTests.cs new file mode 100644 index 00000000..d3231eaf --- /dev/null +++ b/test/LibRed.Engine.Tests/EmptyBinaryLiteralTests.cs @@ -0,0 +1,101 @@ +using LibRed; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +// An empty byte[] has no hex literal in Access SQL: a bare '0x' is the T-SQL form and ACE rejects it +// ("Syntax error in query expression '0x'"). The literal Access accepts is an empty STRING, which it stores +// as a zero-length binary, distinct from NULL. +// +// JetByteArrayTypeMapping.GenerateNonNullSqlLiteral now emits '' for an empty array, so LibRed has to decode +// it the same way ACE does or the generator fix just moves the divergence. Verified against ACE: '' reads +// back as byte[0]; NULL reads back as NULL; 0x00 is a ONE-byte zero, not empty. +public class EmptyBinaryLiteralTests : TempDatabaseTest +{ + private static QueryEngine Fresh() + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "emptybin-"); + var engine = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); + engine.ExecuteNonQuery("CREATE TABLE `EB` (`Id` LONG NOT NULL PRIMARY KEY, `B` VARBINARY(8))"); + return engine; + } + + private static object? ValueOf(QueryEngine engine, int id) => + engine.ExecuteQuery($"SELECT `B` FROM `EB` WHERE `Id` = {id}").Rows.Single()[0]; + + [Fact] + public void An_empty_string_literal_stores_a_zero_length_binary() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("INSERT INTO `EB` (`Id`, `B`) VALUES (1, '')"); + + object? value = ValueOf(engine, 1); + byte[] bytes = Assert.IsType(value); + Assert.Empty(bytes); + } + + // Empty and NULL must stay distinguishable — collapsing '' to NULL would silently lose the difference + // that ACE preserves. + [Fact] + public void An_empty_binary_is_not_null() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("INSERT INTO `EB` (`Id`, `B`) VALUES (1, '')"); + engine.ExecuteNonQuery("INSERT INTO `EB` (`Id`, `B`) VALUES (2, NULL)"); + + Assert.NotNull(ValueOf(engine, 1)); + Assert.Null(ValueOf(engine, 2)); + Assert.Equal(1, Convert.ToInt32( + engine.ExecuteQuery("SELECT COUNT(*) FROM `EB` WHERE `B` IS NULL").Rows.Single()[0])); + } + + // 0x00 is a one-byte zero, not an empty array. Getting these confused is exactly the mistake the bare + // '0x' literal invited. + [Fact] + public void A_single_zero_byte_is_not_an_empty_binary() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("INSERT INTO `EB` (`Id`, `B`) VALUES (1, '')"); + engine.ExecuteNonQuery("INSERT INTO `EB` (`Id`, `B`) VALUES (2, 0x00)"); + + Assert.Empty(Assert.IsType(ValueOf(engine, 1))); + Assert.Equal([0], Assert.IsType(ValueOf(engine, 2))); + } + + // The general rule the empty case falls out of: a string in a binary column stores its UTF-16LE bytes, + // and is NOT parsed as hex. Verified against ACE for VARBINARY and LONGBINARY alike. + [Theory] + [InlineData("A", new byte[] { 0x41, 0x00 })] + [InlineData("AB", new byte[] { 0x41, 0x00, 0x42, 0x00 })] + [InlineData("41", new byte[] { 0x34, 0x00, 0x31, 0x00 })] // the digits '4','1' — not the byte 0x41 + [InlineData("é", new byte[] { 0xE9, 0x00 })] + public void A_string_in_a_binary_column_stores_its_utf16_bytes(string text, byte[] expected) + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery($"INSERT INTO `EB` (`Id`, `B`) VALUES (1, '{text}')"); + + Assert.Equal(expected, Assert.IsType(ValueOf(engine, 1))); + } + + // The non-empty path is unchanged and still round-trips. + [Fact] + public void A_hex_literal_still_round_trips() + { + QueryEngine engine = Fresh(); + engine.ExecuteNonQuery("INSERT INTO `EB` (`Id`, `B`) VALUES (1, 0x0102)"); + + Assert.Equal([0x01, 0x02], Assert.IsType(ValueOf(engine, 1))); + } + + // A digitless 0x is rejected, as ACE rejects it. Tolerating it would hide the generator bug this fix + // exists to correct. + [Fact] + public void A_digitless_hex_literal_is_rejected() + { + QueryEngine engine = Fresh(); + Assert.ThrowsAny(() => + engine.ExecuteNonQuery("INSERT INTO `EB` (`Id`, `B`) VALUES (1, 0x)")); + } +} From 4b8f5358e49f3429650f22cb866f06a9c0ed2218 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 24 Aug 2026 18:23:27 +0800 Subject: [PATCH 29/48] Read a binary column as UTF-16 text in the functions that see it as text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A binary column has two faces in Access SQL, and LibRed implemented only one. Measured against ACE: =, <, >, ORDER BY BYTES - case-sensitive, byte order LIKE, Len, &, TypeName a UTF-16 STRING, and LIKE carries the text collation with it so `B = 0x4100` matches only 'A', while `B LIKE 'A%'` matches 'A' (0x4100) AND 'a' (0x6100) - the same column, compared case-sensitively by '=' and case-insensitively by LIKE. LibRed had the byte half exactly right already. The text half called ToString() on the byte[], so it operated on the literal string "System.Byte[]": Len(B) 13 for every value ("System.Byte[]".Length) -> now 1 B & 'x' "System.Byte[]x" -> now "Ax" B LIKE 'A%' 0 rows -> now 3 TypeName(B) "Byte[]" -> now "String" Silent nonsense rather than an error, which is worse than the InvalidCastException fixed in d2264a0b on the write side. The helper was already there: ToText() reinterprets a binary value as UTF-16 and was written for the byte functions (LenB, MidB, InStrB - which is why LenB was correct all along). This just routes the text functions, both concat operators, LIKE and TypeName through it as well. TypeName now reports String for a binary column rather than the CLR type LibRed actually holds. That is deliberate: ACE's expression service sees a VT_BSTR there, and VarType already returned 8. Verified end to end against ACE - Len/LenB 1/2 and 0/0 for empty, = 0x4100 matching one row, ORDER BY putting 0x4200 before 0x6100, LIKE 'A%'/'a%'/'A_' matching 3/3/1, 0x0102 & 'x' giving "ȁx", TypeName String and VarType 8. Every one of those is now a test. Engine 989/989, Core 793/793, Ado 59/59, Engine-ACE 32/32. Co-Authored-By: Claude Opus 5 --- .../Execution/ExpressionEvaluator.cs | 27 +++-- .../BinaryColumnSemanticsTests.cs | 112 ++++++++++++++++++ 2 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 test/LibRed.Engine.Tests/BinaryColumnSemanticsTests.cs diff --git a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs index 7a9af49f..559a7809 100644 --- a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs +++ b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs @@ -170,12 +170,15 @@ private static bool TryNiladicFunction(ColumnReference c, out object? value) // VBA/Access string functions. All propagate NULL; positions are 1-based. Comparisons default to // case-insensitive (Access "Option Compare Database" = Text), overridable by a compare argument. - "LEN" => Convert1(f, v => v.ToString()!.Length), - "LCASE" => Convert1(f, v => v.ToString()!.ToLowerInvariant()), - "UCASE" => Convert1(f, v => v.ToString()!.ToUpperInvariant()), - "TRIM" => Convert1(f, v => v.ToString()!.Trim(' ')), - "LTRIM" => Convert1(f, v => v.ToString()!.TrimStart(' ')), - "RTRIM" => Convert1(f, v => v.ToString()!.TrimEnd(' ')), + // ToText, not ToString: a binary column's value is a UTF-16 STRING to every text function, so + // Len(0x4100) is 1 (one character) where LenB is 2. Calling ToString() on a byte[] yields the + // literal "System.Byte[]", which silently produced nonsense — Len returned 13 for every value. + "LEN" => Convert1(f, v => ToText(v).Length), + "LCASE" => Convert1(f, v => ToText(v).ToLowerInvariant()), + "UCASE" => Convert1(f, v => ToText(v).ToUpperInvariant()), + "TRIM" => Convert1(f, v => ToText(v).Trim(' ')), + "LTRIM" => Convert1(f, v => ToText(v).TrimStart(' ')), + "RTRIM" => Convert1(f, v => ToText(v).TrimEnd(' ')), "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), @@ -501,6 +504,9 @@ private static object VbaVal(string s) decimal => "Currency", DateTime => "Date", string => "String", + // A binary column reports as String, not Byte[] — ACE's expression service sees the value as a + // UTF-16 string (VarType 8 = VT_BSTR), so TypeName must say so even though LibRed holds a byte[]. + byte[] => "String", _ => v.GetType().Name, }; @@ -1252,7 +1258,7 @@ private static object BitwiseOp(object a, object b, Func op) = object? right = Evaluate(b.Right); if (b.Operator == BinaryOperator.Concat) - return (left?.ToString() ?? "") + (right?.ToString() ?? ""); + return (left is null ? "" : ToText(left)) + (right is null ? "" : ToText(right)); if (left is null || right is null) return null; @@ -1265,9 +1271,12 @@ private static object BitwiseOp(object a, object b, Func op) = BinaryOperator.LessThanOrEqual => Compare(left, right) <= 0, BinaryOperator.GreaterThan => Compare(left, right) > 0, BinaryOperator.GreaterThanOrEqual => Compare(left, right) >= 0, - BinaryOperator.Like => Like(left.ToString()!, right.ToString()!), + // LIKE reads a binary value as text, so it is CASE-INSENSITIVE over a binary column even though + // '=' on the same column is byte-wise and case-sensitive. Verified vs ACE: `B LIKE 'A%'` matches + // both 0x4100 ('A') and 0x6100 ('a'), while `B = 0x4100` matches only the first. + BinaryOperator.Like => Like(ToText(left), ToText(right)), // Access '+' concatenates when either operand is text (but, unlike '&', null already propagated above). - BinaryOperator.Add => left is string || right is string ? left.ToString() + right.ToString() : Arithmetic(left, right, '+'), + BinaryOperator.Add => left is string || right is string ? ToText(left) + ToText(right) : Arithmetic(left, right, '+'), BinaryOperator.Subtract => Arithmetic(left, right, '-'), BinaryOperator.Multiply => Arithmetic(left, right, '*'), BinaryOperator.Divide => Divide(left, right), // Access '/' is floating division diff --git a/test/LibRed.Engine.Tests/BinaryColumnSemanticsTests.cs b/test/LibRed.Engine.Tests/BinaryColumnSemanticsTests.cs new file mode 100644 index 00000000..3a04e625 --- /dev/null +++ b/test/LibRed.Engine.Tests/BinaryColumnSemanticsTests.cs @@ -0,0 +1,112 @@ +using LibRed; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +// A binary column has TWO faces in Access SQL, and both are verified against ACE: +// +// =, <, >, ORDER BY -> BYTES. Case-sensitive, byte order. +// LIKE, Len, &, TypeName -> a UTF-16 STRING. LIKE brings the text collation with it, so it is +// CASE-INSENSITIVE over the very same column that '=' compares byte-wise. +// +// So `B = 0x4100` matches only 'A', while `B LIKE 'A%'` matches 'A' (0x4100) AND 'a' (0x6100). That is not a +// distinction anyone would implement by accident, which is why it is pinned here. +// +// LibRed previously got the byte half right and the text half badly wrong: it called ToString() on the +// byte[], so Len returned 13 ("System.Byte[]".Length) for every value, `&` produced "System.Byte[]x", and +// LIKE matched nothing. Silent nonsense rather than an error. +public class BinaryColumnSemanticsTests : TempDatabaseTest +{ + private static QueryEngine Seeded() + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "binsem-"); + var engine = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); + engine.ExecuteNonQuery("CREATE TABLE `BS` (`Id` LONG NOT NULL PRIMARY KEY, `B` VARBINARY(16))"); + engine.ExecuteNonQuery("INSERT INTO `BS` (`Id`, `B`) VALUES (1, 0x4100)"); // 'A' + engine.ExecuteNonQuery("INSERT INTO `BS` (`Id`, `B`) VALUES (2, 0x6100)"); // 'a' + engine.ExecuteNonQuery("INSERT INTO `BS` (`Id`, `B`) VALUES (3, 0x4200)"); // 'B' + engine.ExecuteNonQuery("INSERT INTO `BS` (`Id`, `B`) VALUES (4, 0x41004200)"); // 'AB' + engine.ExecuteNonQuery("INSERT INTO `BS` (`Id`, `B`) VALUES (5, 0x0102)"); // U+0201, not ASCII + engine.ExecuteNonQuery("INSERT INTO `BS` (`Id`, `B`) VALUES (6, '')"); // empty + return engine; + } + + private static int Count(QueryEngine engine, string where) => + Convert.ToInt32(engine.ExecuteQuery($"SELECT COUNT(*) FROM `BS` WHERE {where}").Rows.Single()[0]); + + private static object? Scalar(QueryEngine engine, string projection, int id) => + engine.ExecuteQuery($"SELECT {projection} FROM `BS` WHERE `Id` = {id}").Rows.Single()[0]; + + // Len counts CHARACTERS, LenB counts BYTES — the clearest statement that the value is a UTF-16 string. + [Theory] + [InlineData(1, 1, 2)] // 0x4100 -> "A" + [InlineData(4, 2, 4)] // 0x41004200 -> "AB" + [InlineData(5, 1, 2)] // 0x0102 -> one character (U+0201) + [InlineData(6, 0, 0)] // empty + public void Len_counts_characters_and_LenB_counts_bytes(int id, int expectedLen, int expectedLenB) + { + QueryEngine engine = Seeded(); + Assert.Equal(expectedLen, Convert.ToInt32(Scalar(engine, "Len(`B`)", id))); + Assert.Equal(expectedLenB, Convert.ToInt32(Scalar(engine, "LenB(`B`)", id))); + } + + // Equality is byte-wise: 'A' and 'a' are different values despite Access's case-insensitive text default. + [Fact] + public void Equality_is_byte_wise_and_case_sensitive() + { + QueryEngine engine = Seeded(); + Assert.Equal(1, Count(engine, "`B` = 0x4100")); + Assert.Equal(1, Count(engine, "`B` = 0x6100")); + } + + // Ordering is byte order, not text order: 0x4200 ('B') sorts BEFORE 0x6100 ('a'), which a + // case-insensitive text sort would reverse. + [Fact] + public void Ordering_is_byte_order_not_text_order() + { + QueryEngine engine = Seeded(); + var ids = engine.ExecuteQuery("SELECT `Id` FROM `BS` WHERE `Id` IN (2,3) ORDER BY `B`") + .Rows.Select(r => Convert.ToInt32(r[0])).ToList(); + + Assert.Equal([3, 2], ids); + } + + [Fact] + public void Relational_comparison_is_byte_wise() + { + QueryEngine engine = Seeded(); + Assert.Equal(1, Count(engine, "`B` > 0x4200")); // only 0x6100 + } + + // The one that surprises: LIKE reads the value as text, so it is case-INsensitive on the same column + // that '=' compares case-sensitively. + [Fact] + public void Like_is_case_insensitive_text_matching() + { + QueryEngine engine = Seeded(); + Assert.Equal(3, Count(engine, "`B` LIKE 'A%'")); // 'A', 'a', 'AB' + Assert.Equal(3, Count(engine, "`B` LIKE 'a%'")); // the same three + Assert.Equal(1, Count(engine, "`B` LIKE 'A_'")); // 'AB' — '_' is one character + } + + // Concatenation reinterprets the bytes as UTF-16, including bytes that are not ASCII at all. + [Fact] + public void Concatenation_reads_the_bytes_as_utf16() + { + QueryEngine engine = Seeded(); + Assert.Equal("Ax", Scalar(engine, "`B` & 'x'", 1)); + Assert.Equal("ȁx", Scalar(engine, "`B` & 'x'", 5)); // 0x0102 little-endian is U+0201 + } + + // TypeName reports String, not Byte[] — the expression service sees a VT_BSTR (VarType 8). + [Fact] + public void TypeName_and_VarType_report_a_string() + { + QueryEngine engine = Seeded(); + Assert.Equal("String", Scalar(engine, "TypeName(`B`)", 1)); + Assert.Equal("String", Scalar(engine, "TypeName(`B`)", 5)); + Assert.Equal(8, Convert.ToInt32(Scalar(engine, "VarType(`B`)", 1))); + } +} From 37df3d88a0a126a72b00fb399d9803366e5fc1cb Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 24 Aug 2026 19:02:37 +0800 Subject: [PATCH 30/48] Translate byte[].Any() as LENB(x) > 0 ByteArrayTranslations.Any failed on both providers with "The LINQ expression ... .Any()' could not be translated", while its three siblings - Length, First and Index - fail with the provider's deliberate refusal: Returning the exact length of a byte array is not supported by Jet ... There is support for a 'EF.Functions.ByteArrayLength' method ... Those three are correct. LENB reports the UTF-16 byte count, so it rounds an odd length UP to even and an exact length is genuinely unobtainable; ByteArrayLength offers the LENB / ASCB(RIGHTB(x,1)) workaround and documents precisely when it is wrong (data ending in 0x00, where a real even-length value is indistinguishable from a zero-padded odd-length one). Refusing beats returning a wrong number. Any is different: it asks only whether there are any bytes at all, and that question the rounding cannot spoil. An empty array is 0 and every non-empty array is at least 2, so LENB(x) > 0 is exact - the trailing-0x00 ambiguity cannot arise for a > 0 test, and unlike ByteArrayLength this needs no caveat. Both baselines were still SQL Server's - DATALENGTH([b].[ByteArray]) > 0, square brackets and all - i.e. copied and never ported, which is why nobody noticed the translation was missing. Now: WHERE LENB(`b`.`ByteArray`) > 0 Verified on both providers, the Jet one against a real ACE driver: 9 tests, 6 passing where 5 passed before, and the same three by-design refusals. A full functional run was not done; the new case is guarded on a parameterless Any over byte[], so nothing else can reach it. Co-Authored-By: Claude Opus 5 --- .../Internal/JetByteArrayMethodTranslator.cs | 20 +++++++++++++++++++ .../ByteArrayTranslationsJetTest.cs | 6 +++--- .../ByteArrayTranslationsLibRedTest.cs | 6 +++--- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs index 35ce3589..020a1f5f 100644 --- a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs +++ b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs @@ -106,6 +106,26 @@ public class JetByteArrayMethodTranslator(ISqlExpressionFactory sqlExpressionFac _sqlExpressionFactory.Constant(0)); } + // Any() over a byte[] asks only whether there are any bytes at all, which LENB answers exactly. + // + // Unlike ByteArrayLength this needs no caveat. LENB reports the UTF-16 byte count and so rounds an odd + // length UP to even, which is why an exact length is unobtainable — but that rounding can never move a + // value across zero: an empty array is 0, and every non-empty array is at least 2. The trailing-0x00 + // ambiguity that forces ByteArrayLength's "data must never end in 0x00" warning simply cannot arise + // for a > 0 test. + if (method is { IsGenericMethod: true, Name: nameof(Enumerable.Any) } && method.GetParameters().Length == 1 + && arguments[0].Type == typeof(byte[])) + { + return _sqlExpressionFactory.GreaterThan( + _sqlExpressionFactory.Function( + "LENB", + [arguments[0]], + nullable: true, + argumentsPropagateNullability: [true], + typeof(int)), + _sqlExpressionFactory.Constant(0)); + } + if (method is { IsGenericMethod: true, Name: nameof(Enumerable.First) } && method.GetParameters().Length == 1 && arguments[0].Type == typeof(byte[])) { diff --git a/test/EFCore.Jet.FunctionalTests/Query/Translations/ByteArrayTranslationsJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/Translations/ByteArrayTranslationsJetTest.cs index e29524b5..32fd12a0 100644 --- a/test/EFCore.Jet.FunctionalTests/Query/Translations/ByteArrayTranslationsJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Query/Translations/ByteArrayTranslationsJetTest.cs @@ -97,9 +97,9 @@ public override async Task Any() AssertSql( """ -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 DATALENGTH([b].[ByteArray]) > 0 +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 LENB(`b`.`ByteArray`) > 0 """); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/ByteArrayTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/ByteArrayTranslationsLibRedTest.cs index 92ec0cd9..0805c73d 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/Translations/ByteArrayTranslationsLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/Translations/ByteArrayTranslationsLibRedTest.cs @@ -97,9 +97,9 @@ public override async Task Any() AssertSql( """ -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 DATALENGTH([b].[ByteArray]) > 0 +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 LENB(`b`.`ByteArray`) > 0 """); } From 210e776b3ee8679d462e4a15900b6b46871efbe7 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 24 Aug 2026 19:06:57 +0800 Subject: [PATCH 31/48] Match SqlServerByteArrayMethodTranslator's shape The three Enumerable cases were three sequential `if` blocks, each re-deriving the same guard in a slightly different way - one used `method.GetParameters().Length == 1`, another indexed `arguments[0]`/ `arguments[1]` directly, and none checked the declaring type. Upstream's SqlServer translator states it once and switches: if (method.IsGenericMethod && method.DeclaringType == typeof(Enumerable)) { switch (method.Name) { case nameof(Enumerable.Contains) when arguments is [var source, var item] && ... case nameof(Enumerable.First) when arguments is [var source] && ... case nameof(Enumerable.Any) when arguments is [var source] && ... } } Adopted here. The list patterns bind the operands by name instead of by index and carry the arity check with them, so "First without a predicate" is expressed by the pattern rather than by a separate GetParameters() test. Behaviour is unchanged apart from one tightening that came free with the shape: the Enumerable declaring-type check now applies to all three, where previously none of them had it. The emitted SQL is untouched and remains Jet's, not SQL Server's - INSTR over STRCONV for Contains, ASCB(MIDB(...)) for First, LENB for Any. ByteArrayLength stays outside the switch: it is a JetDbFunctions method, not an Enumerable one. ByteArrayTranslations on both providers: 9 tests, 6 passing, and the same three by-design refusals as before the change (Length, First, Index - Jet cannot return an exact byte length). Co-Authored-By: Claude Opus 5 --- .../Internal/JetByteArrayMethodTranslator.cs | 135 +++++++++--------- 1 file changed, 67 insertions(+), 68 deletions(-) diff --git a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs index 020a1f5f..aa43e466 100644 --- a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs +++ b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs @@ -69,77 +69,76 @@ public class JetByteArrayMethodTranslator(ISqlExpressionFactory sqlExpressionFac ? _sqlExpressionFactory.Convert(dataLengthSqlFunction, typeof(int)) : dataLengthSqlFunction; } - if (method is { IsGenericMethod: true, Name: nameof(Enumerable.Contains) } - && arguments[0].Type == typeof(byte[])) - { - var source = arguments[0]; - var sourceTypeMapping = source.TypeMapping; - - var value = arguments[1] is SqlConstantExpression constantValue - ? _sqlExpressionFactory.Constant(new[] { (byte)constantValue.Value! }, sourceTypeMapping) - : _sqlExpressionFactory.Function( - "CHR", - [arguments[1]], - nullable: true, - argumentsPropagateNullability: [true], - typeof(string)); - - - - return _sqlExpressionFactory.GreaterThan( - _sqlExpressionFactory.Function( - "INSTR", - [ - _sqlExpressionFactory.Constant(1), - _sqlExpressionFactory.Function( - "STRCONV", - [source, _sqlExpressionFactory.Constant(64)], - nullable: true, - argumentsPropagateNullability: [true, false], - typeof(string)), - value, - _sqlExpressionFactory.Constant(0) - ], - nullable: true, - argumentsPropagateNullability: [false, true, true, false], - typeof(int)), - _sqlExpressionFactory.Constant(0)); - } - // Any() over a byte[] asks only whether there are any bytes at all, which LENB answers exactly. - // - // Unlike ByteArrayLength this needs no caveat. LENB reports the UTF-16 byte count and so rounds an odd - // length UP to even, which is why an exact length is unobtainable — but that rounding can never move a - // value across zero: an empty array is 0, and every non-empty array is at least 2. The trailing-0x00 - // ambiguity that forces ByteArrayLength's "data must never end in 0x00" warning simply cannot arise - // for a > 0 test. - if (method is { IsGenericMethod: true, Name: nameof(Enumerable.Any) } && method.GetParameters().Length == 1 - && arguments[0].Type == typeof(byte[])) + if (method.IsGenericMethod + && method.DeclaringType == typeof(Enumerable)) { - return _sqlExpressionFactory.GreaterThan( - _sqlExpressionFactory.Function( - "LENB", - [arguments[0]], - nullable: true, - argumentsPropagateNullability: [true], - typeof(int)), - _sqlExpressionFactory.Constant(0)); - } + 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)); - if (method is { IsGenericMethod: true, Name: nameof(Enumerable.First) } && method.GetParameters().Length == 1 - && arguments[0].Type == typeof(byte[])) - { - return _sqlExpressionFactory.Function( - "ASCB", - [ _sqlExpressionFactory.Function( - "MIDB", - [arguments[0], _sqlExpressionFactory.Constant(1), _sqlExpressionFactory.Constant(1)], - nullable: true, - argumentsPropagateNullability: [true, true, true], - typeof(byte[])) ], - nullable: true, - argumentsPropagateNullability: [true], - typeof(int)); + return _sqlExpressionFactory.GreaterThan( + _sqlExpressionFactory.Function( + "INSTR", + [ + _sqlExpressionFactory.Constant(1), + _sqlExpressionFactory.Function( + "STRCONV", + [source, _sqlExpressionFactory.Constant(64)], + nullable: true, + argumentsPropagateNullability: [true, false], + typeof(string)), + value, + _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[]): + return _sqlExpressionFactory.Function( + "ASCB", + [ + _sqlExpressionFactory.Function( + "MIDB", + [source, _sqlExpressionFactory.Constant(1), _sqlExpressionFactory.Constant(1)], + nullable: true, + argumentsPropagateNullability: [true, true, true], + typeof(byte[])) + ], + nullable: true, + argumentsPropagateNullability: [true], + typeof(int)); + + // Any without a predicate. LENB answers "are there any bytes at all" exactly, and unlike + // ByteArrayLength it needs no caveat: LENB reports the UTF-16 byte count and so rounds an odd + // length UP to even, which is why an EXACT length is unobtainable — but that rounding can + // never move a value across zero. An empty array is 0 and every non-empty array is at least 2, + // so the trailing-0x00 ambiguity behind ByteArrayLength's warning cannot arise for a > 0 test. + case nameof(Enumerable.Any) when arguments is [var source] && source.Type == typeof(byte[]): + return _sqlExpressionFactory.GreaterThan( + _sqlExpressionFactory.Function( + "LENB", + [source], + nullable: true, + argumentsPropagateNullability: [true], + typeof(int)), + _sqlExpressionFactory.Constant(0)); + } } return null; From 84530eef73ca6814e043d1dc443fbda2069cd816 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 24 Aug 2026 19:30:49 +0800 Subject: [PATCH 32/48] Assert the refusal for byte-array Length/Index/First, as GearsOfWar does These three need the EXACT byte length of a byte array, which Jet cannot give: LenB reports the UTF-16 byte count and rounds an odd length up to even. The provider refuses rather than returning a wrong number, and names EF.Functions.ByteArrayLength as the opt-in workaround - whose remarks state the one case it still gets wrong, data ending in 0x00, indistinguishable from the zero pad. So the tests were red for doing exactly what they should. They now assert the InvalidOperationException, which is how the identical scenarios were already handled in the GearsOfWar tests: public override async Task Byte_array_filter_by_length_literal_does_not_cast_on_varbinary_n(bool async) => await Assert.ThrowsAsync(() => base....); That adaptation dates from "Better support for byte arrays" (41dab6c5, March 2024), when GearsOfWar was the only home of byte-array length coverage. EF10 moved these scenarios out into the simpler BasicTypesEntities suite, and the new ByteArrayTranslations classes arrived carrying upstream's SQL Server versions - which is why their baselines still read CAST(DATALENGTH([b].[ByteArray]) AS int) in square brackets. Nothing regressed; the reorganisation duplicated the coverage around the existing fix. ByteArrayTranslations is now 9/9 on both providers, having been 6/9: one case gained a real translation (byte[].Any() -> LENB > 0, 37df3d88) and three now state that the refusal is the intended behaviour rather than sitting permanently red and looking like bugs. Co-Authored-By: Claude Opus 5 --- .../ByteArrayTranslationsJetTest.cs | 41 +++++-------------- .../ByteArrayTranslationsLibRedTest.cs | 41 +++++-------------- 2 files changed, 22 insertions(+), 60 deletions(-) diff --git a/test/EFCore.Jet.FunctionalTests/Query/Translations/ByteArrayTranslationsJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/Translations/ByteArrayTranslationsJetTest.cs index 32fd12a0..e71765d0 100644 --- a/test/EFCore.Jet.FunctionalTests/Query/Translations/ByteArrayTranslationsJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Query/Translations/ByteArrayTranslationsJetTest.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore.Query.Translations; using Microsoft.EntityFrameworkCore.TestUtilities; +using System; using System.Threading.Tasks; using Xunit; @@ -17,41 +18,21 @@ public ByteArrayTranslationsJetTest(BasicTypesQueryJetFixture fixture, ITestOutp Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); } + // Length, Index and First all need the EXACT byte length of a byte array, which Jet cannot give: LenB + // reports the UTF-16 byte count and so rounds an odd length up to even. The provider refuses rather than + // returning a wrong number, and points at EF.Functions.ByteArrayLength, whose remarks state the one case + // it still gets wrong (data ending in 0x00, indistinguishable from the zero pad). + // + // The same scenarios were adapted this way in the GearsOfWar tests long ago; EF10 moved them into the + // BasicTypesEntities suite, and these copies arrived carrying upstream's SQL Server baselines. public override async Task Length() - { - await base.Length(); - - AssertSql( - """ -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 CAST(DATALENGTH([b].[ByteArray]) AS int) = 4 -"""); - } + => await Assert.ThrowsAsync(() => base.Length()); public override async Task Index() - { - await base.Index(); - - AssertSql( - """ -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 CAST(DATALENGTH([b].[ByteArray]) AS int) >= 3 AND CAST(SUBSTRING([b].[ByteArray], 2 + 1, 1) AS tinyint) = CAST(190 AS tinyint) -"""); - } + => await Assert.ThrowsAsync(() => base.Index()); public override async Task First() - { - await base.First(); - - AssertSql( - """ -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 CAST(DATALENGTH([b].[ByteArray]) AS int) >= 1 AND CAST(SUBSTRING([b].[ByteArray], 1, 1) AS tinyint) = CAST(222 AS tinyint) -"""); - } + => await Assert.ThrowsAsync(() => base.First()); public override async Task Contains_with_constant() { diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/ByteArrayTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/ByteArrayTranslationsLibRedTest.cs index 0805c73d..a3553219 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/Translations/ByteArrayTranslationsLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/Translations/ByteArrayTranslationsLibRedTest.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore.Query.Translations; using Microsoft.EntityFrameworkCore.TestUtilities; +using System; using System.Threading.Tasks; using Xunit; @@ -17,41 +18,21 @@ public ByteArrayTranslationsLibRedTest(BasicTypesQueryLibRedFixture fixture, ITe Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); } + // Length, Index and First all need the EXACT byte length of a byte array, which Jet cannot give: LenB + // reports the UTF-16 byte count and so rounds an odd length up to even. The provider refuses rather than + // returning a wrong number, and points at EF.Functions.ByteArrayLength, whose remarks state the one case + // it still gets wrong (data ending in 0x00, indistinguishable from the zero pad). + // + // The same scenarios were adapted this way in the GearsOfWar tests long ago; EF10 moved them into the + // BasicTypesEntities suite, and these copies arrived carrying upstream's SQL Server baselines. public override async Task Length() - { - await base.Length(); - - AssertSql( - """ -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 CAST(DATALENGTH([b].[ByteArray]) AS int) = 4 -"""); - } + => await Assert.ThrowsAsync(() => base.Length()); public override async Task Index() - { - await base.Index(); - - AssertSql( - """ -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 CAST(DATALENGTH([b].[ByteArray]) AS int) >= 3 AND CAST(SUBSTRING([b].[ByteArray], 2 + 1, 1) AS tinyint) = CAST(190 AS tinyint) -"""); - } + => await Assert.ThrowsAsync(() => base.Index()); public override async Task First() - { - await base.First(); - - AssertSql( - """ -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 CAST(DATALENGTH([b].[ByteArray]) AS int) >= 1 AND CAST(SUBSTRING([b].[ByteArray], 1, 1) AS tinyint) = CAST(222 AS tinyint) -"""); - } + => await Assert.ThrowsAsync(() => base.First()); public override async Task Contains_with_constant() { From a5aed06f6ab732989b686cc8e8498060c492df04 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 24 Aug 2026 20:17:24 +0800 Subject: [PATCH 33/48] LibRed: NULLIF, which ACE does not have The five Conditional_uncoalesce translations failed with System.NotSupportedException : Function NULLIF is not supported. EF Core emits NULLIF(a, b) for that pattern. ACE has no such function - "Undefined function 'NULLIF' in expression", verified - so LibRed's refusal matched ACE exactly, and the tests fail on the Jet provider too. Access's own spelling is IIF(a = b, NULL, a), which works. Implemented in LibRed rather than rewritten in the generator, so LibRed simply accepts the name EF emits. That is a deliberate divergence, and the kind LibRed exists for: the result is identical to the IIF form, so nothing about the query's meaning changes - only whether the engine has to be worked around. Equality reuses the same Compare() as the '=' operator, which makes the NULL cases fall out without special-casing. Comparing with NULL is unknown rather than equal, so NULLIF(x, NULL) is x and NULLIF(NULL, y) is NULL - matching the IIF form, where an unknown condition takes the false branch. A test asserts NULLIF and IIF agree row for row, including both NULL rows. Unlike the IIF spelling the first operand is evaluated once. Arity is registered as (2, 2) so a wrong argument count is rejected the same way every other function's is. The five baselines were still SQL Server's - brackets and N'' literals - i.e. never ported, which is why nobody had noticed the function was missing. They now read WHERE NULLIF(`b`.`Int`, 9) > 1. MiscellaneousOperatorTranslations on LibRed: 10/10, was 5/10. Engine 998/998, Core 793/793, Ado 59/59, Engine-ACE 32/32. The Jet provider still fails these five: it would need the generator to emit IIF instead, which is not done here. Co-Authored-By: Claude Opus 5 --- .../Execution/ExpressionEvaluator.cs | 27 ++++++ ...ellaneousOperatorTranslationsLibRedTest.cs | 30 +++---- test/LibRed.Engine.Tests/NullIfTests.cs | 82 +++++++++++++++++++ 3 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 test/LibRed.Engine.Tests/NullIfTests.cs diff --git a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs index 559a7809..26207791 100644 --- a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs +++ b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs @@ -143,6 +143,7 @@ private static bool TryNiladicFunction(ColumnReference c, out object? value) : f.Arguments.Count == 3 ? Evaluate(f.Arguments[2]) : null, "CHOOSE" => Choose(f), "SWITCH" => Switch(f), + "NULLIF" => NullIf(f), "DATEPART" => DatePart(Evaluate(f.Arguments[0]), Evaluate(f.Arguments[1])), "ROUND" => Round(f), "FIX" => Numeric1(f, Math.Truncate, Math.Truncate), // toward zero @@ -319,6 +320,7 @@ internal static void ValidateArity(string name, int count) "STRCOMP" => (2, 3), "STRCONV" => (2, 3), "IIF" => (2, 3), + "NULLIF" => (2, 2), "CHOOSE" => (2, int.MaxValue), "SWITCH" => (2, int.MaxValue), @@ -373,6 +375,31 @@ internal static void ValidateArity(string name, int count) return index < 1 || index > choiceCount ? null : Evaluate(f.Arguments[index]); } + /// + /// NULLIF(a, b): NULL when the two are equal, otherwise a. + /// + /// + /// A deliberate divergence from ACE, which has no such function — it answers "Undefined function 'NULLIF' + /// in expression" (verified). Access's own spelling of this is IIF(a = b, NULL, a), and that is what + /// this evaluates to; the difference is only that LibRed also accepts the name EF Core emits, so a query + /// using it runs here rather than failing at the engine. + /// + /// Equality follows the same comparison as the = operator, which makes the NULL cases fall out + /// correctly without special-casing: comparing with a NULL is unknown rather than equal, so + /// NULLIF(x, NULL) is x and NULLIF(NULL, y) is NULL — matching the IIF form, where an + /// unknown condition takes the false branch. + /// + /// Unlike the IIF spelling, a is evaluated once. + /// + private object? NullIf(FunctionCall f) + { + object? left = Evaluate(f.Arguments[0]); + if (left is null) return null; + + object? right = Evaluate(f.Arguments[1]); + return right is not null && Compare(left, right) == 0 ? null : left; + } + /// Access Switch(cond-1, value-1, cond-2, value-2, …): evaluates the conditions left to /// right and returns the value paired with the first true one, or NULL if none is true (verified vs ACE). /// The argument count must be even (condition/value pairs) — an odd count is an error in ACE ("Wrong number diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsLibRedTest.cs index 0dd88959..ce7fb54c 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsLibRedTest.cs @@ -59,9 +59,9 @@ public override async Task Conditional_uncoalesce_with_equality_left() AssertSql( """ -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 NULLIF([b].[Int], 9) > 1 +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 NULLIF(`b`.`Int`, 9) > 1 """); } @@ -71,9 +71,9 @@ public override async Task Conditional_uncoalesce_with_equality_right() AssertSql( """ -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 NULLIF([b].[Int], 9) > 1 +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 NULLIF(`b`.`Int`, 9) > 1 """); } @@ -83,9 +83,9 @@ public override async Task Conditional_uncoalesce_with_inequality_left() AssertSql( """ -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 NULLIF([b].[Int], 9) > 1 +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 NULLIF(`b`.`Int`, 9) > 1 """); } @@ -95,9 +95,9 @@ public override async Task Conditional_uncoalesce_with_inequality_right() AssertSql( """ -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 NULLIF([b].[Int], 9) > 1 +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 NULLIF(`b`.`Int`, 9) > 1 """); } @@ -107,9 +107,9 @@ public override async Task Conditional_uncoalesce_with_string() AssertSql( """ -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 NULLIF([b].[String], N'Seattle') = N'London' +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 NULLIF(`b`.`String`, 'Seattle') = 'London' """); } diff --git a/test/LibRed.Engine.Tests/NullIfTests.cs b/test/LibRed.Engine.Tests/NullIfTests.cs new file mode 100644 index 00000000..37657181 --- /dev/null +++ b/test/LibRed.Engine.Tests/NullIfTests.cs @@ -0,0 +1,82 @@ +using LibRed; +using LibRed.Engine; +using Xunit; + +namespace LibRed.Engine.Tests; + +// NULLIF(a, b) — NULL when the two are equal, otherwise a. +// +// A deliberate divergence from ACE, which has no such function ("Undefined function 'NULLIF' in expression", +// verified). Access spells it IIF(a = b, NULL, a). LibRed accepts the name EF Core emits so those queries run +// here; the results must match the IIF form exactly, including the NULL cases. +public class NullIfTests : TempDatabaseTest +{ + private static QueryEngine Seeded() + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "nullif-"); + var engine = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); + engine.ExecuteNonQuery("CREATE TABLE `NI` (`Id` LONG NOT NULL PRIMARY KEY, `A` LONG, `B` LONG, `S` TEXT(20))"); + engine.ExecuteNonQuery("INSERT INTO `NI` (`Id`, `A`, `B`, `S`) VALUES (1, 5, 5, 'x')"); + engine.ExecuteNonQuery("INSERT INTO `NI` (`Id`, `A`, `B`, `S`) VALUES (2, 5, 7, 'y')"); + engine.ExecuteNonQuery("INSERT INTO `NI` (`Id`, `A`, `B`, `S`) VALUES (3, NULL, 7, NULL)"); + engine.ExecuteNonQuery("INSERT INTO `NI` (`Id`, `A`, `B`, `S`) VALUES (4, 5, NULL, 'z')"); + return engine; + } + + private static object? Eval(QueryEngine engine, string projection, int id) => + engine.ExecuteQuery($"SELECT {projection} FROM `NI` WHERE `Id` = {id}").Rows.Single()[0]; + + [Fact] + public void Equal_values_give_null() + => Assert.Null(Eval(Seeded(), "NULLIF(`A`, `B`)", 1)); + + [Fact] + public void Differing_values_give_the_first() + => Assert.Equal(5, Convert.ToInt32(Eval(Seeded(), "NULLIF(`A`, `B`)", 2))); + + // Comparing with NULL is unknown, not equal, so the first operand comes back — which for a NULL first + // operand is itself NULL. Both match the IIF spelling, where an unknown condition takes the false branch. + [Fact] + public void A_null_first_operand_gives_null() + => Assert.Null(Eval(Seeded(), "NULLIF(`A`, `B`)", 3)); + + [Fact] + public void A_null_second_operand_gives_the_first() + => Assert.Equal(5, Convert.ToInt32(Eval(Seeded(), "NULLIF(`A`, `B`)", 4))); + + [Fact] + public void Works_on_text() + { + QueryEngine engine = Seeded(); + Assert.Null(Eval(engine, "NULLIF(`S`, 'x')", 1)); + Assert.Equal("y", Eval(engine, "NULLIF(`S`, 'x')", 2)); + } + + [Fact] + public void Works_on_literals() + { + QueryEngine engine = Seeded(); + Assert.Null(Eval(engine, "NULLIF(1, 1)", 1)); + Assert.Equal(1, Convert.ToInt32(Eval(engine, "NULLIF(1, 2)", 1))); + } + + // The results must equal Access's own spelling of the same thing, on every row. + [Fact] + public void Matches_the_IIF_spelling_on_every_row() + { + QueryEngine engine = Seeded(); + var rows = engine.ExecuteQuery( + "SELECT NULLIF(`A`, `B`) AS N, IIF(`A` = `B`, NULL, `A`) AS I FROM `NI` ORDER BY `Id`").Rows.ToList(); + + Assert.Equal(4, rows.Count); + Assert.All(rows, r => Assert.Equal(r[1], r[0])); + } + + // Arity is enforced like every other function's. + [Theory] + [InlineData("NULLIF(`A`)")] + [InlineData("NULLIF(`A`, `B`, 1)")] + public void Wrong_argument_count_is_rejected(string projection) + => Assert.ThrowsAny(() => Eval(Seeded(), projection, 1)); +} From 0529e1cf8811ac7d1145fb75db2686144cfba7aa Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 24 Aug 2026 20:31:50 +0800 Subject: [PATCH 34/48] Emit NULLIF as IIF, which is what Jet actually has ACE has no NULLIF - "Undefined function 'NULLIF' in expression", verified - so the five Conditional_uncoalesce translations produced SQL neither provider could run. There is nothing to override upstream. EF builds it inside SqlExpressionFactory.Case, which collapses `a == b ? null : a` into Function("NULLIF", [a, b]) as part of its conditional simplification; by the time a tree reaches a provider it is already a function call, and overriding Case would mean reimplementing that simplification. So it is handled where dialect spelling belongs, in JetQuerySqlGenerator.VisitSqlFunction, which already dispatches on name for @@-variables and COALESCE. The SQL standard defines NULLIF(a, b) as shorthand for CASE WHEN a = b THEN NULL ELSE a END, so this rebuilds exactly that and lets VisitCase render it - which for Jet means IIF(a = b, NULL, a). Emitting the CASE rather than writing the text reuses the existing operator and NULL-literal rendering, and mirrors how binary Coalesce is already handled in the same file. Placed before the null-guard logic so that does not wrap it. 'a' is written twice. The standard's own definition implies the same, and Jet has no way to say it once. WHERE IIF(`b`.`Int` = 9, NULL, `b`.`Int`) > 1 Both providers: MiscellaneousOperatorTranslations 10/10, was 5/10 on LibRed and 5/10 on Jet. The Jet run is against a real ACE driver, so the emitted IIF is known to execute rather than merely to parse. LibRed shares JetQuerySqlGeneratorFactory today (its own generator is still only planned), so it takes the same change and its baselines move from NULLIF to IIF - one commit after gaining them. LibRed's NULLIF evaluator function (a5aed06f) stays: it is still reachable from hand-written SQL and still has its own tests, it is simply no longer what EF emits. The rest of Query.Translations is unchanged - 18 failures before and after, the same DateTimeOffset/sub-second/Convert_ToInt64 set. Co-Authored-By: Claude Opus 5 --- .../Sql/Internal/JetQuerySqlGenerator.cs | 24 +++++++++++++++ ...iscellaneousOperatorTranslationsJetTest.cs | 30 +++++++++---------- ...ellaneousOperatorTranslationsLibRedTest.cs | 10 +++---- 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/EFCore.Jet/Query/Sql/Internal/JetQuerySqlGenerator.cs b/src/EFCore.Jet/Query/Sql/Internal/JetQuerySqlGenerator.cs index 4fb5f0de..bfd94307 100644 --- a/src/EFCore.Jet/Query/Sql/Internal/JetQuerySqlGenerator.cs +++ b/src/EFCore.Jet/Query/Sql/Internal/JetQuerySqlGenerator.cs @@ -1117,6 +1117,30 @@ protected override Expression VisitSqlFunction(SqlFunctionExpression sqlFunction return sqlFunctionExpression; } + // Jet/ACE has no NULLIF - "Undefined function 'NULLIF' in expression" (verified against ACE). EF + // produces it in SqlExpressionFactory.Case, which collapses `a == b ? null : a` into + // Function("NULLIF", [a, b]), so there is no translator to override upstream: by the time the tree + // reaches a provider it is already a function call. + // + // The SQL standard defines NULLIF(a, b) as shorthand for CASE WHEN a = b THEN NULL ELSE a END, so + // rebuild exactly that and let VisitCase render it - which for Jet means IIF(a = b, NULL, a). + // Emitting the CASE rather than the text reuses the existing operator and NULL-literal rendering, + // and mirrors how binary Coalesce is handled above. + // + // 'a' is written twice, which the standard's own definition also implies; Jet has no way to say it + // once. + if (sqlFunctionExpression.Name.Equals("NULLIF", StringComparison.OrdinalIgnoreCase) + && sqlFunctionExpression.Arguments is [var nullIfLeft, var nullIfRight]) + { + var equal = new SqlBinaryExpression( + ExpressionType.Equal, nullIfLeft, nullIfRight, typeof(bool), null); + var nullResult = new SqlConstantExpression( + null, sqlFunctionExpression.Type, sqlFunctionExpression.TypeMapping); + + Visit(new CaseExpression([new CaseWhenClause(equal, nullResult)], nullIfLeft)); + return sqlFunctionExpression; + } + // The guard has to be applied here rather than in the query tree: EF removes a CASE that merely // replicates SQL's native null propagation (dotnet/efcore#34127), which is what this looks like to // every dialect where these functions do propagate. IIF short-circuits, so the call is not evaluated. diff --git a/test/EFCore.Jet.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsJetTest.cs index 8474e033..ef883b23 100644 --- a/test/EFCore.Jet.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsJetTest.cs @@ -59,9 +59,9 @@ public override async Task Conditional_uncoalesce_with_equality_left() AssertSql( """ -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 NULLIF([b].[Int], 9) > 1 +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 IIF(`b`.`Int` = 9, NULL, `b`.`Int`) > 1 """); } @@ -71,9 +71,9 @@ public override async Task Conditional_uncoalesce_with_equality_right() AssertSql( """ -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 NULLIF([b].[Int], 9) > 1 +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 IIF(`b`.`Int` = 9, NULL, `b`.`Int`) > 1 """); } @@ -83,9 +83,9 @@ public override async Task Conditional_uncoalesce_with_inequality_left() AssertSql( """ -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 NULLIF([b].[Int], 9) > 1 +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 IIF(`b`.`Int` = 9, NULL, `b`.`Int`) > 1 """); } @@ -95,9 +95,9 @@ public override async Task Conditional_uncoalesce_with_inequality_right() AssertSql( """ -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 NULLIF([b].[Int], 9) > 1 +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 IIF(`b`.`Int` = 9, NULL, `b`.`Int`) > 1 """); } @@ -107,9 +107,9 @@ public override async Task Conditional_uncoalesce_with_string() AssertSql( """ -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 NULLIF([b].[String], N'Seattle') = N'London' +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 IIF(`b`.`String` = 'Seattle', NULL, `b`.`String`) = 'London' """); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsLibRedTest.cs index ce7fb54c..eea6c867 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsLibRedTest.cs @@ -61,7 +61,7 @@ public override async Task Conditional_uncoalesce_with_equality_left() """ 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 NULLIF(`b`.`Int`, 9) > 1 +WHERE IIF(`b`.`Int` = 9, NULL, `b`.`Int`) > 1 """); } @@ -73,7 +73,7 @@ public override async Task Conditional_uncoalesce_with_equality_right() """ 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 NULLIF(`b`.`Int`, 9) > 1 +WHERE IIF(`b`.`Int` = 9, NULL, `b`.`Int`) > 1 """); } @@ -85,7 +85,7 @@ public override async Task Conditional_uncoalesce_with_inequality_left() """ 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 NULLIF(`b`.`Int`, 9) > 1 +WHERE IIF(`b`.`Int` = 9, NULL, `b`.`Int`) > 1 """); } @@ -97,7 +97,7 @@ public override async Task Conditional_uncoalesce_with_inequality_right() """ 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 NULLIF(`b`.`Int`, 9) > 1 +WHERE IIF(`b`.`Int` = 9, NULL, `b`.`Int`) > 1 """); } @@ -109,7 +109,7 @@ public override async Task Conditional_uncoalesce_with_string() """ 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 NULLIF(`b`.`String`, 'Seattle') = 'London' +WHERE IIF(`b`.`String` = 'Seattle', NULL, `b`.`String`) = 'London' """); } From 30d3c84e7ee95dd005d1836eb457522271c12e89 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Mon, 24 Aug 2026 22:52:02 +0800 Subject: [PATCH 35/48] Millisecond DATEPART/DATEDIFF, spelled "ms" like every other interval Three Jet translators emitted the interval as the full word "millisecond" - TimeOnly.Millisecond, TimeSpan.Milliseconds and DateTimeOffset.ToUnixTimeMilliseconds - which belongs to no dialect. Access's interval vocabulary is abbreviations: yyyy, q, m, y, d, w, ww, h, n, s. So they now send "ms", which is what LibRed's DatePart table already used, and the mismatch that made those tests fail was a spelling, not a capability. The three commented-out AddMilliseconds/Millisecond entries were changed with them so the vocabulary stays consistent if they are ever enabled. DateDiff then needed the interval it did not have. It is a LibRed extension - ACE's list stops at "s" - available because LibRed stores the full OA double instead of truncating to whole seconds as ACE does. Measured: 12:34:56.123 round-trips with zero tick loss. It returns Int64, unlike every other interval, and is handled before the switch rather than inside it. A millisecond difference overflows Int32 after 25 days and ToUnixTimeMilliseconds spans decades; a long arm within the switch expression would silently widen every other interval's result type along with it. Tests assert both halves - that ms exceeds Int32 without truncation, and that s/n/h/d/yyyy still return Access's Long Integer. Nothing below a millisecond was added. It would not survive storage anyway: DateTime.ToOADate/FromOADate quantise to whole milliseconds, so .1234560 comes back as .123 - a BCL conversion floor, not a format one, sitting four orders of magnitude above the double's own ~0.63us resolution at present-day values. DateTimeOffset.ToUnixTimeMilliseconds now passes: WHERE DATEDIFF('ms', CDATE('1970-01-01 00:00:00'), `b`.`DateTimeOffset`) = @unixEpochMilliseconds The Millisecond member tests still fail, and no longer for a reason this change can reach: they translate and execute correctly but find nothing, because JetDateTimeTypeMapping writes the literal as hh:mm:ss with no fractional part, so the seeded .123 never reached the database. That is storage, not vocabulary. LibRed Query.Translations 361/378, was 360. Engine 1011/1011, Core 793/793, Ado 59/59, Engine-ACE 32/32. Co-Authored-By: Claude Opus 5 --- .../Internal/JetDateTimeMemberTranslator.cs | 2 +- .../Internal/JetDateTimeMethodTranslator.cs | 6 +- .../Internal/JetTimeOnlyMemberTranslator.cs | 2 +- .../Internal/JetTimeSpanMemberTranslator.cs | 2 +- .../Execution/ExpressionEvaluator.cs | 18 +++++- .../DateTimeOffsetTranslationsLibRedTest.cs | 6 +- .../DateDiffMillisecondTests.cs | 64 +++++++++++++++++++ 7 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 test/LibRed.Engine.Tests/DateDiffMillisecondTests.cs diff --git a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs index cad85fe2..7f58c42a 100644 --- a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs +++ b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs @@ -24,7 +24,7 @@ private static readonly Dictionary DatePartMapping { nameof(DateTime.Minute), "n" }, { nameof(DateTime.Second), "s" }, { nameof(DateTime.DayOfWeek), "w" }, - //{ nameof(DateTime.Millisecond), "millisecond" } + //{ nameof(DateTime.Millisecond), "ms" } }; private readonly JetSqlExpressionFactory _sqlExpressionFactory = (JetSqlExpressionFactory)sqlExpressionFactory; diff --git a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs index 77f05b76..22d3f00d 100644 --- a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs +++ b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs @@ -20,20 +20,20 @@ public class JetDateTimeMethodTranslator(ISqlExpressionFactory sqlExpressionFact {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)])!, "millisecond"}, + //{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)])!, "millisecond"} + //{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)!, "millisecond" } + { typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.ToUnixTimeMilliseconds), Type.EmptyTypes)!, "ms" } }; public SqlExpression? Translate(SqlExpression? instance, MethodInfo method, IReadOnlyList arguments, IDiagnosticsLogger logger) diff --git a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetTimeOnlyMemberTranslator.cs b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetTimeOnlyMemberTranslator.cs index 7aa415ff..e10c4964 100644 --- a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetTimeOnlyMemberTranslator.cs +++ b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetTimeOnlyMemberTranslator.cs @@ -24,7 +24,7 @@ public class JetTimeOnlyMemberTranslator(ISqlExpressionFactory sqlExpressionFact { nameof(TimeOnly.Hour), "h" }, { nameof(TimeOnly.Minute), "n" }, { nameof(TimeOnly.Second), "s" }, - { nameof(TimeOnly.Millisecond), "millisecond" } + { nameof(TimeOnly.Millisecond), "ms" } }; private readonly ISqlExpressionFactory _sqlExpressionFactory = sqlExpressionFactory; diff --git a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetTimeSpanMemberTranslator.cs b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetTimeSpanMemberTranslator.cs index 89c4e398..d8d5432a 100644 --- a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetTimeSpanMemberTranslator.cs +++ b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetTimeSpanMemberTranslator.cs @@ -24,7 +24,7 @@ public class JetTimeSpanMemberTranslator(ISqlExpressionFactory sqlExpressionFact { nameof(TimeSpan.Hours), "h" }, { nameof(TimeSpan.Minutes), "n" }, { nameof(TimeSpan.Seconds), "s" }, - { nameof(TimeSpan.Milliseconds), "millisecond" } + { nameof(TimeSpan.Milliseconds), "ms" } }; private readonly ISqlExpressionFactory _sqlExpressionFactory = sqlExpressionFactory; diff --git a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs index 26207791..c0276125 100644 --- a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs +++ b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs @@ -1225,7 +1225,22 @@ private static object BitwiseOp(object a, object b, Func op) = if (d1V is null || d2V is null) return null; var d1 = Convert.ToDateTime(d1V, CultureInfo.InvariantCulture); var d2 = Convert.ToDateTime(d2V, CultureInfo.InvariantCulture); - return (intervalV?.ToString() ?? "").ToLowerInvariant() switch + string interval = (intervalV?.ToString() ?? "").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; + } + + return interval switch { "yyyy" => d2.Year - d1.Year, "q" => (d2.Year - d1.Year) * 4 + (d2.Month - 1) / 3 - (d1.Month - 1) / 3, @@ -1235,6 +1250,7 @@ private static object BitwiseOp(object a, object b, Func op) = "h" => (int)(d2 - d1).TotalHours, "n" => (int)(d2 - d1).TotalMinutes, "s" => (int)(d2 - d1).TotalSeconds, + // "ms" is handled above, as Int64. _ => throw new NotSupportedException($"DATEDIFF interval '{intervalV}' is not supported."), }; } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs index 9d01d684..dfcd9fc2 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs @@ -305,9 +305,9 @@ public override async Task ToUnixTimeMilliseconds() """ @unixEpochMilliseconds='894295810000' -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_BIG(millisecond, '1970-01-01T00:00:00.0000000+00:00', [b].[DateTimeOffset]) = @unixEpochMilliseconds +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 """); } diff --git a/test/LibRed.Engine.Tests/DateDiffMillisecondTests.cs b/test/LibRed.Engine.Tests/DateDiffMillisecondTests.cs new file mode 100644 index 00000000..31474ee2 --- /dev/null +++ b/test/LibRed.Engine.Tests/DateDiffMillisecondTests.cs @@ -0,0 +1,64 @@ +using LibRed; +using LibRed.Engine; +using Xunit; + +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. +public class DateDiffMillisecondTests : TempDatabaseTest +{ + private static QueryEngine Fresh() + { + string path = TemporaryDatabase.CopyPath( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "ddms-"); + return new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); + } + + private static object? Eval(QueryEngine engine, string expression) => + engine.ExecuteQuery($"SELECT {expression} FROM `Shippers` WHERE `ShipperID` = 1").Rows.Single()[0]; + + [Theory] + [InlineData("#2020-01-01 00:00:00#", "#2020-01-01 00:00:01#", 1_000L)] + [InlineData("#2020-01-01 00:00:00#", "#2020-01-01 00:01:00#", 60_000L)] + [InlineData("#2020-01-01 00:00:00#", "#2020-01-02 00:00:00#", 86_400_000L)] + [InlineData("#2020-01-02 00:00:00#", "#2020-01-01 00:00:00#", -86_400_000L)] + [InlineData("#2020-01-01 00:00:00#", "#2020-01-01 00:00:00#", 0L)] + 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. + [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#)")); + + 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#)")); + + // Adding the arm must not have widened the other intervals, which stay Access's Long Integer. + [Theory] + [InlineData("s")] + [InlineData("n")] + [InlineData("h")] + [InlineData("d")] + [InlineData("yyyy")] + public void Other_intervals_still_return_int(string interval) + => Assert.IsType(Eval(Fresh(), $"DATEDIFF('{interval}', #2020-01-01 00:00:00#, #2021-03-04 05:06:07#)")); + + // Only the abbreviation is accepted, matching DatePart and the rest of the interval table. The full word + // is what EF used to emit and what the Jet translators now no longer send. + [Fact] + public void The_full_word_is_not_an_interval() + => Assert.Throws( + () => Eval(Fresh(), "DATEDIFF('millisecond', #2020-01-01 00:00:00#, #2020-01-01 00:00:01#)")); +} From 550f4f63c2aae9c7186e36402bd58f183b23dd28 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Tue, 25 Aug 2026 01:43:57 +0800 Subject: [PATCH 36/48] LibRed: carry milliseconds through the temporal path ACE truncates a date/time to whole seconds on write, so EFCore.Jet has never had reason to emit or preserve a fractional part. LibRed stores the full OLE Automation double, where a millisecond survives exactly - .NET's ToOADate and FromOADate quantise there, making a millisecond both the finest unit that round-trips and the natural floor. Engine and ADO: - ExpressionEvaluator.DateAdd gains an "ms" interval, matching DateDiff. DatePart already had ms/mcs/ns. - LibRedCommand.Normalize truncates parameters to a millisecond rather than a second, so a literal and a parameter compare equal. Provider: four LibRed type mappings (DateTime, DateTimeOffset, TimeOnly, TimeSpan) mirroring their EFCore.Jet counterparts, deriving from the EF Core bases rather than from Jet's, and differing only in emitting a .fff fraction when it is non-zero. LibRedTypeMappingSource substitutes them for the mapping Jet resolved, so store type, size and facets are preserved. EFCore.Jet, shared with the ACE path: - DateTime.Millisecond and DateTimeOffset.Millisecond now translate to DATEPART('ms', ...). Both were already failing on ACE, which has no sub-second interval, so this changes only which error they raise. - AddMilliseconds now translates to DATEADD('ms', ...). On ACE this turns DateTimeOffsetTranslationsJetTest.AddMilliseconds from passing to failing, but it only passed because the call sat in a top-level projection - the one position EF may evaluate on the client - so the SQL never contained the addition at all. Nothing that exercised the feature is lost. - The CDATE/TIMEVALUE parameter wraps in JetQuerySqlGenerator now match on the EF Core base mapping instead of the Jet one, so a derived provider that substitutes its own mapping still gets the coercion. Known gap: DateOnly.ToDateTime still drops milliseconds, because it decomposes through TimeSerial(h, n, s). Carrying them would need a fourth TimeSerial argument - an arity change rather than an extra value in an existing parameter - so it is deliberately left for separate work. Co-Authored-By: Claude Opus 5 --- .../Internal/JetDateTimeMemberTranslator.cs | 2 +- .../Internal/JetDateTimeMethodTranslator.cs | 4 +- .../Sql/Internal/JetQuerySqlGenerator.cs | 8 +- .../Internal/JetTimeSpanTypeMapping.cs | 2 - src/LibRed/LibRed.Ado/LibRedCommand.cs | 28 +++-- .../LibRedDateTimeOffsetTypeMapping.cs | 51 ++++++++ .../Internal/LibRedDateTimeTypeMapping.cs | 109 ++++++++++++++++++ .../Internal/LibRedTimeOnlyTypeMapping.cs | 45 ++++++++ .../Internal/LibRedTimeSpanTypeMapping.cs | 37 ++++++ .../Internal/LibRedTypeMappingSource.cs | 54 ++++++--- .../Execution/ExpressionEvaluator.cs | 1 + .../BasicTypesQueryLibRedFixture.cs | 34 +++--- .../DateTimeOffsetTranslationsLibRedTest.cs | 8 +- .../DateTimeTranslationsLibRedTest.cs | 6 +- .../TimeOnlyTranslationsLibRedTest.cs | 6 +- .../TimeSpanTranslationsLibRedTest.cs | 6 +- 16 files changed, 341 insertions(+), 60 deletions(-) create mode 100644 src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeOffsetTypeMapping.cs create mode 100644 src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeTypeMapping.cs create mode 100644 src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeOnlyTypeMapping.cs create mode 100644 src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeSpanTypeMapping.cs diff --git a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs index 7f58c42a..0eb7a2a5 100644 --- a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs +++ b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs @@ -24,7 +24,7 @@ private static readonly Dictionary DatePartMapping { nameof(DateTime.Minute), "n" }, { nameof(DateTime.Second), "s" }, { nameof(DateTime.DayOfWeek), "w" }, - //{ nameof(DateTime.Millisecond), "ms" } + { nameof(DateTime.Millisecond), "ms" } }; private readonly JetSqlExpressionFactory _sqlExpressionFactory = (JetSqlExpressionFactory)sqlExpressionFactory; diff --git a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs index 22d3f00d..4ae71188 100644 --- a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs +++ b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMethodTranslator.cs @@ -20,14 +20,14 @@ public class JetDateTimeMethodTranslator(ISqlExpressionFactory sqlExpressionFact {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(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"} + {typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.AddMilliseconds), [typeof(double)])!, "ms"} }; private static readonly Dictionary _methodInfoDateDiffMapping = new() diff --git a/src/EFCore.Jet/Query/Sql/Internal/JetQuerySqlGenerator.cs b/src/EFCore.Jet/Query/Sql/Internal/JetQuerySqlGenerator.cs index bfd94307..413454c5 100644 --- a/src/EFCore.Jet/Query/Sql/Internal/JetQuerySqlGenerator.cs +++ b/src/EFCore.Jet/Query/Sql/Internal/JetQuerySqlGenerator.cs @@ -590,14 +590,18 @@ protected override Expression VisitOrdering(OrderingExpression orderingExpressio protected override Expression VisitSqlParameter(SqlParameterExpression sqlParameterExpression) { - if (sqlParameterExpression.Type == typeof(DateTime) && sqlParameterExpression.TypeMapping is JetDateTimeTypeMapping or NullTypeMapping) + // Matched on the EF Core base mapping, not on JetDateTimeTypeMapping/JetTimeOnlyTypeMapping: a derived + // provider (LibRed) substitutes its own mapping off the same base, and it needs the same coercion. The + // CLR-type test in front is what actually narrows this - the mapping test only rules out a DateTime + // that some other mapping claimed. + if (sqlParameterExpression.Type == typeof(DateTime) && sqlParameterExpression.TypeMapping is DateTimeTypeMapping or NullTypeMapping) { Sql.Append("CDATE("); base.VisitSqlParameter(sqlParameterExpression); Sql.Append(")"); return sqlParameterExpression; } - if (sqlParameterExpression.Type == typeof(TimeOnly) && sqlParameterExpression.TypeMapping is JetTimeOnlyTypeMapping) + if (sqlParameterExpression.Type == typeof(TimeOnly) && sqlParameterExpression.TypeMapping is TimeOnlyTypeMapping) { Sql.Append("TIMEVALUE("); base.VisitSqlParameter(sqlParameterExpression); diff --git a/src/EFCore.Jet/Storage/Internal/JetTimeSpanTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetTimeSpanTypeMapping.cs index ba74a6e1..c6e748f3 100644 --- a/src/EFCore.Jet/Storage/Internal/JetTimeSpanTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetTimeSpanTypeMapping.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 JetTimeSpanTypeMapping : TimeSpanTypeMapping diff --git a/src/LibRed/LibRed.Ado/LibRedCommand.cs b/src/LibRed/LibRed.Ado/LibRedCommand.cs index be4078be..095a0977 100644 --- a/src/LibRed/LibRed.Ado/LibRedCommand.cs +++ b/src/LibRed/LibRed.Ado/LibRedCommand.cs @@ -164,24 +164,34 @@ private void ValidateTransaction() /// the epoch date + time-of-day, a date at midnight. private static readonly DateTime OleEpoch = new(1899, 12, 30); - /// Coerces a parameter value to what the engine should see. Jet/ACE has no native TimeSpan, TimeOnly, + /// + /// Coerces a parameter value to what the engine should see. Jet/ACE has no native TimeSpan, TimeOnly, /// DateOnly or DateTimeOffset — they are all stored as a on the 1899-12-30 epoch — so /// this boundary (the single point EF parameters enter the engine) converts each to that DateTime, exactly as /// the literal path does (a TimeSpan literal renders as a #…#/TIMEVALUE DateTime). The engine then only - /// ever handles DateTime for temporals, and the reader converts back on the way out. Sub-seconds are stripped - /// (Jet has 1-second resolution) so a WHERE d = @p comparison matches the seconds-only stored value. + /// ever handles DateTime for temporals, and the reader converts back on the way out. + /// + /// + /// Values are truncated to whole MILLISECONDS, not whole seconds. ACE has one-second resolution, but that is + /// ACE truncating on write — LibRed stores the full OA double, and a millisecond survives it exactly + /// (measured: 12:34:56.123 round-trips with zero tick loss). Below a millisecond nothing survives whatever + /// this does, because .NET's ToOADate/FromOADate quantise there; truncating to the same boundary the store + /// uses is what keeps WHERE d = @p matching, which is the reason this truncates at all. + /// private static object? Normalize(object? value) => value switch { DBNull => null, - DateTime d => Seconds(d), + DateTime d => Milliseconds(d), // DateTimeOffset is read back at offset zero, so store its UTC instant. - DateTimeOffset dto => Seconds(dto.UtcDateTime), - TimeSpan t => OleEpoch + Seconds(t), - TimeOnly to => OleEpoch + Seconds(to.ToTimeSpan()), + DateTimeOffset dto => Milliseconds(dto.UtcDateTime), + TimeSpan t => OleEpoch + Milliseconds(t), + TimeOnly to => OleEpoch + Milliseconds(to.ToTimeSpan()), DateOnly d => d.ToDateTime(TimeOnly.MinValue), _ => value, }; - private static DateTime Seconds(DateTime d) => d.AddTicks(-(d.Ticks % TimeSpan.TicksPerSecond)); - private static TimeSpan Seconds(TimeSpan t) => TimeSpan.FromTicks(t.Ticks - t.Ticks % TimeSpan.TicksPerSecond); + private static DateTime Milliseconds(DateTime d) => d.AddTicks(-(d.Ticks % TimeSpan.TicksPerMillisecond)); + + private static TimeSpan Milliseconds(TimeSpan t) => + TimeSpan.FromTicks(t.Ticks - t.Ticks % TimeSpan.TicksPerMillisecond); } diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeOffsetTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeOffsetTypeMapping.cs new file mode 100644 index 00000000..db0c61cf --- /dev/null +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeOffsetTypeMapping.cs @@ -0,0 +1,51 @@ +using System.Data.Common; +using System.Globalization; +using Microsoft.EntityFrameworkCore.Storage; + +namespace EntityFrameworkCore.LibRed.Storage.Internal; + +public class LibRedDateTimeOffsetTypeMapping : DateTimeOffsetTypeMapping +{ + private const string DateTimeOffsetFormatConst = @"'{0:yyyy-MM-ddTHH:mm:ss.fffffffzzz}'"; + private const string DateTimeFormatConst = @"'{0:yyyy-MM-dd HH:mm:ss}'"; + private const string DateTimeMillisecondsFormatConst = @"'{0:yyyy-MM-dd HH:mm:ss.fff}'"; + + public static new LibRedDateTimeOffsetTypeMapping Default { get; } = new LibRedDateTimeOffsetTypeMapping("datetime"); + + public LibRedDateTimeOffsetTypeMapping( + string storeType) + : base( + storeType, System.Data.DbType.DateTime) + { + } + + protected LibRedDateTimeOffsetTypeMapping(RelationalTypeMappingParameters parameters) + : base(parameters) + { + } + + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + => new LibRedDateTimeOffsetTypeMapping(parameters); + + protected override void ConfigureParameter(DbParameter parameter) + { + if (parameter.Value is DateTimeOffset dateTimeOffset) + { + parameter.Value = dateTimeOffset.Ticks == 0 ? DateTime.FromOADate(0) : dateTimeOffset.UtcDateTime; + parameter.DbType = System.Data.DbType.DateTime; + } + + base.ConfigureParameter(parameter); + } + + protected override string SqlLiteralFormatString + => DateTimeOffsetFormatConst; + + 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; + var format = dateTime.Millisecond != 0 ? DateTimeMillisecondsFormatConst : DateTimeFormatConst; + return $"CDATE({string.Format(CultureInfo.InvariantCulture, format, dateTime)})"; + } +} diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeTypeMapping.cs new file mode 100644 index 00000000..eb882a02 --- /dev/null +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedDateTimeTypeMapping.cs @@ -0,0 +1,109 @@ +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Text; +using Microsoft.EntityFrameworkCore.Storage; + +namespace EntityFrameworkCore.LibRed.Storage.Internal; + +public class LibRedDateTimeTypeMapping : DateTimeTypeMapping +{ + /// The lowest date Jet/ACE can represent; dates run 0100-01-01 to 9999-12-31. + private static readonly DateTime LibRedMinDate = new(100, 1, 1); + + public static new LibRedDateTimeTypeMapping Default { get; } = new LibRedDateTimeTypeMapping("datetime", dbType: System.Data.DbType.DateTime); + + public LibRedDateTimeTypeMapping( + string storeType, + DbType? dbType = null, + Type? clrType = null) + : base(storeType) + { + } + + protected LibRedDateTimeTypeMapping(RelationalTypeMappingParameters parameters) + : base(parameters) + { + } + + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + => new LibRedDateTimeTypeMapping(parameters); + + protected override void ConfigureParameter(DbParameter parameter) + { + if (parameter.Value is DateTime { Ticks: 0 }) + { + parameter.Value = DateTime.FromOADate(0); + } + base.ConfigureParameter(parameter); + + if ((parameter.DbType == System.Data.DbType.Date || StoreTypeNameBase == "date") && parameter.Value is DateTime date) + { + parameter.Value = date.Date; + } + } + + protected override string GenerateNonNullSqlLiteral(object value) + => GenerateNonNullSqlLiteral(value, false); + + public virtual string GenerateNonNullSqlLiteral(object value, bool defaultClauseCompatible) + { + var dateTime = ConvertToDateTimeCompatibleValue(value); + if (dateTime is DateTime { Ticks: 0 }) + { + dateTime = DateTime.FromOADate(0); + } + dateTime = CheckDateTimeValue(dateTime); + + var literal = new StringBuilder(); + + literal.Append( + defaultClauseCompatible + ? "'" + : "#"); + + literal.AppendFormat(CultureInfo.InvariantCulture, "{0:yyyy-MM-dd}", dateTime); + + var time = dateTime.TimeOfDay; + if (time != TimeSpan.Zero && StoreTypeNameBase != "date") + { + literal.AppendFormat(CultureInfo.InvariantCulture, @" {0:hh\:mm\:ss}", time); + + if (time.Milliseconds != 0) + { + literal.AppendFormat(CultureInfo.InvariantCulture, @"{0:\.fff}", time); + } + } + + literal.Append( + defaultClauseCompatible + ? "'" + : "#"); + + return literal.ToString(); + } + + protected virtual DateTime ConvertToDateTimeCompatibleValue(object value) + => (DateTime)value; + + private static DateTime CheckDateTimeValue(DateTime dateTime) + { + // default(DateTime) is below Jet's floor, but every caller has already substituted the OLE epoch for + // it, so anything still under the floor here is a real value the store cannot represent. Ordering + // comparisons against default are corrected separately, in JetDateTimeRangeConverter. + if (dateTime < LibRedMinDate) + { + throw new InvalidOperationException( + $"The {nameof(DateTime)} value '{dateTime}' is smaller than the minimum supported value of '{LibRedMinDate}'."); + } + + return dateTime; + } + + // Deliberately passes storeTypeNameBase in place of storeType: Jet/ACE has no scaled datetime, so a + // precision-carrying store type such as "datetime(3)" must collapse to the bare "datetime" it understands. + protected override string ProcessStoreType(RelationalTypeMappingParameters parameters, string storeType, string storeTypeNameBase) + { + return base.ProcessStoreType(parameters, storeTypeNameBase, storeTypeNameBase); + } +} diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeOnlyTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeOnlyTypeMapping.cs new file mode 100644 index 00000000..2f192883 --- /dev/null +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeOnlyTypeMapping.cs @@ -0,0 +1,45 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Storage; + +namespace EntityFrameworkCore.LibRed.Storage.Internal; + +public class LibRedTimeOnlyTypeMapping : TimeOnlyTypeMapping +{ + public static new LibRedTimeOnlyTypeMapping Default { get; } = new LibRedTimeOnlyTypeMapping("time"); + + public LibRedTimeOnlyTypeMapping( + string storeType) + : base(storeType) + { + } + + protected LibRedTimeOnlyTypeMapping(RelationalTypeMappingParameters parameters) + : base(parameters) + { + } + + protected override void ConfigureParameter(DbParameter parameter) + { + base.ConfigureParameter(parameter); + if (parameter.Value is TimeOnly timeOnly) + { + timeOnly.Deconstruct(out int hour, out int min, out int sec, out int msec); + parameter.Value = new TimeSpan(0, hour, min, sec, msec); + } + } + + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + => new LibRedTimeOnlyTypeMapping(parameters); + + protected override string GenerateNonNullSqlLiteral(object value) + { + return ((TimeOnly)value).Millisecond != 0 + ? FormattableString.Invariant($@"TIMEVALUE('{value:HH\:mm\:ss\.fff}')") + : FormattableString.Invariant($@"TIMEVALUE('{value:HH\:mm\:ss}')"); + } + + protected override string ProcessStoreType(RelationalTypeMappingParameters parameters, string storeType, string storeTypeNameBase) + { + return base.ProcessStoreType(parameters, storeTypeNameBase, storeTypeNameBase); + } +} diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeSpanTypeMapping.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeSpanTypeMapping.cs new file mode 100644 index 00000000..e704cf70 --- /dev/null +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTimeSpanTypeMapping.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Storage; + +namespace EntityFrameworkCore.LibRed.Storage.Internal; + +public class LibRedTimeSpanTypeMapping : TimeSpanTypeMapping +{ + public static new LibRedTimeSpanTypeMapping Default { get; } = new LibRedTimeSpanTypeMapping("datetime"); + + public LibRedTimeSpanTypeMapping( + string storeType) + : base(storeType) + { + } + + protected LibRedTimeSpanTypeMapping(RelationalTypeMappingParameters parameters) + : base(parameters) + { + } + + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + => new LibRedTimeSpanTypeMapping(parameters); + + /*protected override DateTime ConvertToDateTimeCompatibleValue(object value) + => JetConfiguration.TimeSpanOffset + (TimeSpan)value;*/ + + protected override string ProcessStoreType(RelationalTypeMappingParameters parameters, string storeType, string storeTypeNameBase) + { + return base.ProcessStoreType(parameters, storeTypeNameBase, storeTypeNameBase); + } + + protected override string GenerateNonNullSqlLiteral(object value) + { + return ((TimeSpan)value).Milliseconds != 0 + ? FormattableString.Invariant($@"TIMEVALUE('{value:hh\:mm\:ss\.fff}')") + : FormattableString.Invariant($@"TIMEVALUE('{value:hh\:mm\:ss}')"); + } +} diff --git a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTypeMappingSource.cs b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTypeMappingSource.cs index 9d4fa0f2..c890d999 100644 --- a/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTypeMappingSource.cs +++ b/src/LibRed/LibRed.EFCore/Storage/Internal/LibRedTypeMappingSource.cs @@ -5,24 +5,50 @@ namespace EntityFrameworkCore.LibRed.Storage.Internal; /// -/// LibRed's relational type-mapping source. It reuses every EFCore.Jet mapping unchanged, but substitutes a -/// driver-free mapping () whose DbType reflects the type -/// the value is actually stored as (decimal(20,0)) rather than Int64 — EFCore.Jet only reports -/// Decimal via an OLE DB / ODBC reflection poke that a native engine has no reason to reproduce. +/// LibRed's relational type-mapping source. It reuses every EFCore.Jet mapping unchanged except where LibRed's +/// engine can do something ACE cannot: +/// +/// +/// — a driver-free whose DbType reflects the type the +/// value is actually stored as (decimal(20,0)) rather than Int64; EFCore.Jet only reports +/// Decimal via an OLE DB / ODBC reflection poke that a native engine has no reason to reproduce. +/// +/// +/// The four temporal mappings — , , +/// and — whose literals carry MILLISECONDS. ACE truncates to whole seconds on write, +/// so EFCore.Jet has no reason to emit a fraction; LibRed stores the full OA double and a millisecond +/// survives it exactly. LibRedCommand.Normalize truncates parameters to the same boundary so the two +/// paths agree. +/// +/// +/// deliberately has no LibRed variant: it carries no time component, so there is nothing +/// below a second for it to lose. /// -public class LibRedTypeMappingSource : JetTypeMappingSource +public class LibRedTypeMappingSource( + TypeMappingSourceDependencies dependencies, + RelationalTypeMappingSourceDependencies relationalDependencies, + IJetOptions options) + : JetTypeMappingSource(dependencies, relationalDependencies, options) { - public LibRedTypeMappingSource( - TypeMappingSourceDependencies dependencies, - RelationalTypeMappingSourceDependencies relationalDependencies, - IJetOptions options) - : base(dependencies, relationalDependencies, options) - { - } - protected override RelationalTypeMapping? FindMapping(in RelationalTypeMappingInfo mappingInfo) { RelationalTypeMapping? mapping = base.FindMapping(mappingInfo); - return mapping is JetLongTypeMapping ? LibRedLongTypeMapping.Default : mapping; + + // Substituted by the mapping Jet resolved rather than by CLR type, so anything Jet decides about store + // type, size or nullability is preserved — only the literal/parameter behaviour is replaced. Clone + // carries the resolved parameters across. + return mapping switch + { + JetLongTypeMapping => LibRedLongTypeMapping.Default, + JetDateTimeTypeMapping => Retarget(mapping, LibRedDateTimeTypeMapping.Default), + JetDateTimeOffsetTypeMapping => Retarget(mapping, LibRedDateTimeOffsetTypeMapping.Default), + JetTimeOnlyTypeMapping => Retarget(mapping, LibRedTimeOnlyTypeMapping.Default), + JetTimeSpanTypeMapping => Retarget(mapping, LibRedTimeSpanTypeMapping.Default), + _ => mapping, + }; } + + /// Rebuilds with the store type, size and facets Jet resolved. + private static RelationalTypeMapping Retarget(RelationalTypeMapping resolved, RelationalTypeMapping replacement) + => replacement.WithStoreTypeAndSize(resolved.StoreType, resolved.Size); } diff --git a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs index c0276125..19828fa9 100644 --- a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs +++ b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs @@ -1213,6 +1213,7 @@ private static object BitwiseOp(object a, object b, Func op) = "h" => d.AddHours(n), "n" => d.AddMinutes(n), "s" => d.AddSeconds(n), + "ms" => d.AddMilliseconds(n), _ => throw new NotSupportedException($"DATEADD interval '{intervalV}' is not supported."), }; } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/BasicTypesQueryLibRedFixture.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/BasicTypesQueryLibRedFixture.cs index d25e655f..d3a3d8bf 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/Translations/BasicTypesQueryLibRedFixture.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/Translations/BasicTypesQueryLibRedFixture.cs @@ -30,13 +30,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext con protected override Task SeedAsync(BasicTypesContext context) { var data = new BasicTypesData(); - //for every data.BasicTypesEntities and data.NullableBasicTypesEntities take the DateTime and DateTimeOffset and set the milliseconds to 0 + //for every data.BasicTypesEntities and data.NullableBasicTypesEntities rebuild the temporal values down to the millisecond and no finer - LibRed stores the OA double, which quantises there foreach (var entity in data.BasicTypesEntities) { - entity.DateTime = new DateTime(entity.DateTime.Year, entity.DateTime.Month, entity.DateTime.Day, entity.DateTime.Hour, entity.DateTime.Minute, entity.DateTime.Second); - entity.DateTimeOffset = new DateTimeOffset(entity.DateTimeOffset.Year, entity.DateTimeOffset.Month, entity.DateTimeOffset.Day, entity.DateTimeOffset.Hour, entity.DateTimeOffset.Minute, entity.DateTimeOffset.Second, entity.DateTimeOffset.Offset); - entity.TimeOnly = new TimeOnly(entity.TimeOnly.Hour, entity.TimeOnly.Minute, entity.TimeOnly.Second); - entity.TimeSpan = new TimeSpan(entity.TimeSpan.Days, entity.TimeSpan.Hours, entity.TimeSpan.Minutes, entity.TimeSpan.Seconds); + entity.DateTime = new DateTime(entity.DateTime.Year, entity.DateTime.Month, entity.DateTime.Day, entity.DateTime.Hour, entity.DateTime.Minute, entity.DateTime.Second, entity.DateTime.Millisecond); + entity.DateTimeOffset = new DateTimeOffset(entity.DateTimeOffset.Year, entity.DateTimeOffset.Month, entity.DateTimeOffset.Day, entity.DateTimeOffset.Hour, entity.DateTimeOffset.Minute, entity.DateTimeOffset.Second, entity.DateTimeOffset.Millisecond, entity.DateTimeOffset.Offset); + entity.TimeOnly = new TimeOnly(entity.TimeOnly.Hour, entity.TimeOnly.Minute, entity.TimeOnly.Second, entity.TimeOnly.Millisecond); + entity.TimeSpan = new TimeSpan(entity.TimeSpan.Days, entity.TimeSpan.Hours, entity.TimeSpan.Minutes, entity.TimeSpan.Seconds, entity.TimeSpan.Milliseconds); if (entity.DateOnly.Year < 100) { entity.DateOnly = entity.DateOnly.AddYears(100); // Adjust for LibRed's handling of DateOnly @@ -55,19 +55,19 @@ protected override Task SeedAsync(BasicTypesContext context) { if (entity.DateTime.HasValue) { - entity.DateTime = new DateTime(entity.DateTime.Value.Year, entity.DateTime.Value.Month, entity.DateTime.Value.Day, entity.DateTime.Value.Hour, entity.DateTime.Value.Minute, entity.DateTime.Value.Second); + entity.DateTime = new DateTime(entity.DateTime.Value.Year, entity.DateTime.Value.Month, entity.DateTime.Value.Day, entity.DateTime.Value.Hour, entity.DateTime.Value.Minute, entity.DateTime.Value.Second, entity.DateTime.Value.Millisecond); } if (entity.DateTimeOffset.HasValue) { - entity.DateTimeOffset = new DateTimeOffset(entity.DateTimeOffset.Value.Year, entity.DateTimeOffset.Value.Month, entity.DateTimeOffset.Value.Day, entity.DateTimeOffset.Value.Hour, entity.DateTimeOffset.Value.Minute, entity.DateTimeOffset.Value.Second, entity.DateTimeOffset.Value.Offset); + entity.DateTimeOffset = new DateTimeOffset(entity.DateTimeOffset.Value.Year, entity.DateTimeOffset.Value.Month, entity.DateTimeOffset.Value.Day, entity.DateTimeOffset.Value.Hour, entity.DateTimeOffset.Value.Minute, entity.DateTimeOffset.Value.Second, entity.DateTimeOffset.Value.Millisecond, entity.DateTimeOffset.Value.Offset); } if (entity.TimeOnly.HasValue) { - entity.TimeOnly = new TimeOnly(entity.TimeOnly.Value.Hour, entity.TimeOnly.Value.Minute, entity.TimeOnly.Value.Second); + entity.TimeOnly = new TimeOnly(entity.TimeOnly.Value.Hour, entity.TimeOnly.Value.Minute, entity.TimeOnly.Value.Second, entity.TimeOnly.Value.Millisecond); } if (entity.TimeSpan.HasValue) { - entity.TimeSpan = new TimeSpan(entity.TimeSpan.Value.Days, entity.TimeSpan.Value.Hours, entity.TimeSpan.Value.Minutes, entity.TimeSpan.Value.Seconds); + entity.TimeSpan = new TimeSpan(entity.TimeSpan.Value.Days, entity.TimeSpan.Value.Hours, entity.TimeSpan.Value.Minutes, entity.TimeSpan.Value.Seconds, entity.TimeSpan.Value.Milliseconds); } if (entity.DateOnly.HasValue && entity.DateOnly.Value.Year < 100) { @@ -93,10 +93,10 @@ public override ISetSource GetExpectedData() BasicTypesData result = (BasicTypesData)base.GetExpectedData(); result.BasicTypesEntities.ForEach(b => { - b.DateTime = new DateTime(b.DateTime.Year, b.DateTime.Month, b.DateTime.Day, b.DateTime.Hour, b.DateTime.Minute, b.DateTime.Second); - b.DateTimeOffset = new DateTimeOffset(b.DateTimeOffset.Year, b.DateTimeOffset.Month, b.DateTimeOffset.Day, b.DateTimeOffset.Hour, b.DateTimeOffset.Minute, b.DateTimeOffset.Second, b.DateTimeOffset.Offset).ToUniversalTime(); - b.TimeOnly = new TimeOnly(b.TimeOnly.Hour, b.TimeOnly.Minute, b.TimeOnly.Second); - b.TimeSpan = new TimeSpan(b.TimeSpan.Days, b.TimeSpan.Hours, b.TimeSpan.Minutes, b.TimeSpan.Seconds); + b.DateTime = new DateTime(b.DateTime.Year, b.DateTime.Month, b.DateTime.Day, b.DateTime.Hour, b.DateTime.Minute, b.DateTime.Second, b.DateTime.Millisecond); + b.DateTimeOffset = new DateTimeOffset(b.DateTimeOffset.Year, b.DateTimeOffset.Month, b.DateTimeOffset.Day, b.DateTimeOffset.Hour, b.DateTimeOffset.Minute, b.DateTimeOffset.Second, b.DateTimeOffset.Millisecond, b.DateTimeOffset.Offset).ToUniversalTime(); + b.TimeOnly = new TimeOnly(b.TimeOnly.Hour, b.TimeOnly.Minute, b.TimeOnly.Second, b.TimeOnly.Millisecond); + b.TimeSpan = new TimeSpan(b.TimeSpan.Days, b.TimeSpan.Hours, b.TimeSpan.Minutes, b.TimeSpan.Seconds, b.TimeSpan.Milliseconds); if (b.DateOnly.Year < 100) { b.DateOnly = b.DateOnly.AddYears(100); // Adjust for LibRed's handling of DateOnly @@ -115,19 +115,19 @@ public override ISetSource GetExpectedData() { if (b.DateTime.HasValue) { - b.DateTime = new DateTime(b.DateTime.Value.Year, b.DateTime.Value.Month, b.DateTime.Value.Day, b.DateTime.Value.Hour, b.DateTime.Value.Minute, b.DateTime.Value.Second); + b.DateTime = new DateTime(b.DateTime.Value.Year, b.DateTime.Value.Month, b.DateTime.Value.Day, b.DateTime.Value.Hour, b.DateTime.Value.Minute, b.DateTime.Value.Second, b.DateTime.Value.Millisecond); } if (b.DateTimeOffset.HasValue) { - b.DateTimeOffset = new DateTimeOffset(b.DateTimeOffset.Value.Year, b.DateTimeOffset.Value.Month, b.DateTimeOffset.Value.Day, b.DateTimeOffset.Value.Hour, b.DateTimeOffset.Value.Minute, b.DateTimeOffset.Value.Second, b.DateTimeOffset.Value.Offset).ToUniversalTime(); + b.DateTimeOffset = new DateTimeOffset(b.DateTimeOffset.Value.Year, b.DateTimeOffset.Value.Month, b.DateTimeOffset.Value.Day, b.DateTimeOffset.Value.Hour, b.DateTimeOffset.Value.Minute, b.DateTimeOffset.Value.Second, b.DateTimeOffset.Value.Millisecond, b.DateTimeOffset.Value.Offset).ToUniversalTime(); } if (b.TimeOnly.HasValue) { - b.TimeOnly = new TimeOnly(b.TimeOnly.Value.Hour, b.TimeOnly.Value.Minute, b.TimeOnly.Value.Second); + b.TimeOnly = new TimeOnly(b.TimeOnly.Value.Hour, b.TimeOnly.Value.Minute, b.TimeOnly.Value.Second, b.TimeOnly.Value.Millisecond); } if (b.TimeSpan.HasValue) { - b.TimeSpan = new TimeSpan(b.TimeSpan.Value.Days, b.TimeSpan.Value.Hours, b.TimeSpan.Value.Minutes, b.TimeSpan.Value.Seconds); + b.TimeSpan = new TimeSpan(b.TimeSpan.Value.Days, b.TimeSpan.Value.Hours, b.TimeSpan.Value.Minutes, b.TimeSpan.Value.Seconds, b.TimeSpan.Value.Milliseconds); } if (b.DateOnly.HasValue && b.DateOnly.Value.Year < 100) { diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs index dfcd9fc2..dd0c4572 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs @@ -143,9 +143,9 @@ public override async Task Millisecond() AssertSql( """ -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 DATEPART(millisecond, [b].[DateTimeOffset]) = 123 +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 DATEPART('ms', `b`.`DateTimeOffset`) = 123 """); } @@ -333,7 +333,7 @@ public override async Task Milliseconds_parameter_and_constant() """ SELECT COUNT(*) FROM `BasicTypesEntities` AS `b` -WHERE `b`.`DateTimeOffset` = CDATE('1902-01-02 08:30:00') +WHERE `b`.`DateTimeOffset` = CDATE('1902-01-02 08:30:00.123') """); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsLibRedTest.cs index 47d6e3af..87905f4e 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsLibRedTest.cs @@ -174,9 +174,9 @@ public override async Task Millisecond() AssertSql( """ -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 DATEPART(millisecond, [b].[DateTime]) = 123 +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 DATEPART('ms', `b`.`DateTime`) = 123 """); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsLibRedTest.cs index 34fb63f3..04b14a23 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsLibRedTest.cs @@ -59,9 +59,9 @@ public override async Task Millisecond() AssertSql( """ -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 DATEPART(millisecond, [b].[TimeOnly]) = 123 +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 DATEPART('ms', `b`.`TimeOnly`) = 123 """); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/TimeSpanTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/TimeSpanTranslationsLibRedTest.cs index bd5c44ea..90217d36 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/TimeSpanTranslationsLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/TimeSpanTranslationsLibRedTest.cs @@ -59,9 +59,9 @@ public override async Task Milliseconds() AssertSql( """ -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 DATEPART(millisecond, [b].[TimeSpan]) = 678 +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 DATEPART('ms', `b`.`TimeSpan`) = 678 """); } From 21cb6d7a1c34af34cb296eed6a1d1a1c86ba68ab Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Tue, 25 Aug 2026 02:52:06 +0800 Subject: [PATCH 37/48] LibRed: FULL [OUTER] JOIN, which ACE has no syntax for EF Core's base QuerySqlGenerator already emits FULL JOIN, so this needed no generator work - only the parser, plan and executor, none of which is shared with the ACE path. Grammar: a FULL token and a FullJoin alternative in joinType, parser regenerated. OUTER is optional and carries no meaning, there being no such thing as a full inner join - it is accepted only because people write it, the same way LEFT OUTER JOIN is. Executor, nested loop: the right side's matched rows are tracked across the whole left pass and the unmatched ones emitted null-padded afterwards, since a right row's fate is not settled until every left row has been tried. Executor, hash: FULL builds the right and probes with the left as INNER/LEFT do, but preserves the build side too, so it also records which build rows were hit. The build phase drops null-key rows as unmatchable, which under FULL is the very reason to emit them - those are set aside and joined to the unmatched tail rather than discarded. IndexSelection lets FULL hash so it does not fall back to an O(n*m) loop; the index-nested-loop path is untouched, its allow-list already excluding anything outer. Two things this drags along: - AstBuilder.ViewJoinKindOf now throws for a FULL JOIN inside a CREATE VIEW. That switch falls through to Inner, and Access's stored query format has no full outer join to encode, so the alternative was silently storing an inner join. - ExecuteJoin only ever honoured the left-preserving flag, so a RIGHT JOIN whose ON had no same-kind equi-key - the case the hash path declines - ran silently as an INNER join. The flag added for FULL fixes that too. FULL becomes a keyword despite not being reserved in Access, so a column actually named "Full" now needs bracketing or backticking, as LEFT, RIGHT and ORDER already do. FullJoinTests covers both quoting forms along with the preserved-null-key row, the nested-loop path, and FULL = LEFT + RIGHT - INNER. Co-Authored-By: Claude Opus 5 --- .../LibRed.Engine/Execution/QueryExecutor.cs | 63 +- .../LibRed.Engine/Planning/IndexSelection.cs | 5 +- src/LibRed/LibRed.Sql/Ast/Clauses.cs | 3 +- src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 | 8 +- .../Grammar/Generated/AccessSqlBaseVisitor.cs | 11 + .../Grammar/Generated/AccessSqlLexer.cs | 704 ++++++------ .../Grammar/Generated/AccessSqlParser.cs | 1001 +++++++++-------- .../Grammar/Generated/AccessSqlVisitor.cs | 7 + src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs | 6 + .../Query/NorthwindJoinQueryLibRedTest.cs | 24 +- test/LibRed.Engine.Tests/FullJoinTests.cs | 127 +++ 11 files changed, 1103 insertions(+), 856 deletions(-) create mode 100644 test/LibRed.Engine.Tests/FullJoinTests.cs diff --git a/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs b/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs index cefc52c6..517be0eb 100644 --- a/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs @@ -791,7 +791,11 @@ private static HashSet ContributingQualifiers( { var (leftColumns, leftRows) = Execute(join.Left, outer); Expression? on = join.On; // null for a CROSS join (cartesian product) - bool leftOuter = join.Kind == JoinKind.Left; + // Which sides are preserved. FULL preserves both, so it is left-outer and right-outer at once. The + // right-preserving half costs more: a right row's fate is not settled until every left row has been + // tried, so those rows are tracked and emitted after the loop rather than inside it. + bool leftOuter = join.Kind is JoinKind.Left or JoinKind.Full; + bool rightOuter = join.Kind is JoinKind.Right or JoinKind.Full; // Index-nested-loop: the right side is a *correlated* index seek (keyed off the outer row). Re-execute // it per left row — seeking the inner index — instead of materialising and scanning the whole inner @@ -855,16 +859,18 @@ private static HashSet ContributingQualifiers( { var onScope = new EvalScope(columns, [], outer); // one scope/evaluator, rebound per combined row var onEval = new ExpressionEvaluator(onScope, this, parameters: _parameters, session: _session); + bool[]? rightMatched = rightOuter ? new bool[rightRows.Count] : null; foreach (object?[] left in leftRows) { bool matched = false; - foreach (object?[] right in rightRows) + for (int r = 0; r < rightRows.Count; r++) { - object?[] combined = [.. left, .. right]; + object?[] combined = [.. left, .. rightRows[r]]; if (on is null || onEval.Rebind(combined).IsTrue(on)) { matched = true; + if (rightMatched is not null) rightMatched[r] = true; yield return combined; } } @@ -872,6 +878,11 @@ private static HashSet ContributingQualifiers( if (leftOuter && !matched) yield return [.. left, .. new object?[rightColumns.Count]]; } + + // Right-preserving tail: every right row no left row matched, null-padded on the left. + for (int r = 0; rightMatched is not null && r < rightMatched.Length; r++) + if (!rightMatched[r]) + yield return [.. new object?[leftColumns.Count], .. rightRows[r]]; } return (columns, Rows()); @@ -888,8 +899,11 @@ private static HashSet ContributingQualifiers( // INNER/LEFT build the right side and probe with the left; RIGHT builds the left and probes with the // right (so the preserved — outer — side is always the probe side). The emitted row is [left.., right..] // regardless of which side was built. + // FULL builds the right and probes with the left, like INNER/LEFT - but it preserves the build side too, + // which the others never do, so it additionally tracks which build rows were hit. bool buildRight = join.Kind != JoinKind.Right; - bool preserveProbe = join.Kind is JoinKind.Left or JoinKind.Right; // probe side is the outer/preserved side + bool preserveProbe = join.Kind is JoinKind.Left or JoinKind.Right or JoinKind.Full; // probe side is outer + bool preserveBuild = join.Kind is JoinKind.Full; var buildColumns = buildRight ? rightColumns : leftColumns; var buildKeys = buildRight ? join.RightKeys : join.LeftKeys; var buildRowsEnum = buildRight ? rightRowsEnum : leftRows; @@ -904,11 +918,19 @@ private static HashSet ContributingQualifiers( var table = new Dictionary>(HashKeyComparer.Instance); var buildScope = new EvalScope(buildColumns, [], outer); var buildEval = new ExpressionEvaluator(buildScope, this, parameters: _parameters, session: _session); + // A null-key build row can never match, so it is normally dropped outright. Under FULL the build + // side is preserved, which makes "never matches" a reason to emit it, not to discard it - so those + // rows are set aside instead and joined to the unmatched tail below. + List? unhashableBuild = preserveBuild ? [] : null; foreach (object?[] b in buildRowsEnum) { buildScope.Rebind(b); var key = new object?[buildKeys.Count]; - if (!EvalKey(buildEval, buildKeys, key)) continue; // null key → unmatchable + if (!EvalKey(buildEval, buildKeys, key)) // null key → unmatchable + { + unhashableBuild?.Add(b); + continue; + } if (!table.TryGetValue(key, out List? bucket)) table[key] = bucket = []; bucket.Add(b); @@ -921,6 +943,7 @@ private static HashSet ContributingQualifiers( var onScope = new EvalScope(joinColumns, [], outer); var onEval = new ExpressionEvaluator(onScope, this, parameters: _parameters, session: _session); var probe = new object?[probeKeys.Count]; // reused; only used to look up, never stored + var matchedBuild = preserveBuild ? new HashSet(RowIdentityComparer.Instance) : null; foreach (object?[] p in probeRowsEnum) { @@ -936,6 +959,7 @@ private static HashSet ContributingQualifiers( if (onEval.Rebind(combined).IsTrue(on)) { matched = true; + matchedBuild?.Add(b); yield return combined; } } @@ -945,6 +969,24 @@ private static HashSet ContributingQualifiers( ? [.. p, .. new object?[rightWidth]] // probe is the left side; right is null : [.. new object?[leftWidth], .. p]; // probe is the right side; left is null } + + // FULL only: the build side is preserved as well, so every build row the probe never matched is + // emitted null-padded on the other side. This has to trail the whole probe pass - a build row is + // only unmatched once every probe row has failed to hit it. + if (preserveBuild) + { + object?[] Unmatched(object?[] b) => buildRight + ? [.. new object?[leftWidth], .. b] // build is the right side; left is null + : [.. b, .. new object?[rightWidth]]; // build is the left side; right is null + + foreach (List bucket in table.Values) + foreach (object?[] b in bucket) + if (!matchedBuild!.Contains(b)) + yield return Unmatched(b); + + foreach (object?[] b in unhashableBuild!) // null-key rows: unmatchable by construction + yield return Unmatched(b); + } } return (joinColumns, Rows()); @@ -960,6 +1002,17 @@ private static bool EvalKey(ExpressionEvaluator eval, IReadOnlyList return true; } + /// Identity, not value, over a row array: a FULL join's "was this build row ever matched?" set has to + /// distinguish two rows that happen to hold equal values, so it keys on the reference itself. + private sealed class RowIdentityComparer : IEqualityComparer + { + public static readonly RowIdentityComparer Instance = new(); + + public bool Equals(object?[]? x, object?[]? y) => ReferenceEquals(x, y); + + public int GetHashCode(object?[] obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); + } + /// Hash/equality over a composite join key that mirrors the evaluator's = within a type kind /// (the planner only builds a hash join over same-kind key columns). Key elements are never null. private sealed class HashKeyComparer : IEqualityComparer diff --git a/src/LibRed/LibRed.Engine/Planning/IndexSelection.cs b/src/LibRed/LibRed.Engine/Planning/IndexSelection.cs index 08e28234..61014dea 100644 --- a/src/LibRed/LibRed.Engine/Planning/IndexSelection.cs +++ b/src/LibRed/LibRed.Engine/Planning/IndexSelection.cs @@ -169,8 +169,9 @@ private static PlanNode RewriteJoin(JoinNode j, JetCatalog catalog, HashSet or a (e.g. a UNION). public sealed record SubqueryTable(SqlStatement Query, string? Alias) : TableReference; -public enum JoinKind { Inner, Left, Right, Cross } +/// is a LibRed extension - ACE has no full outer join. +public enum JoinKind { Inner, Left, Right, Cross, Full } /// A join between two table references with an ON condition. public sealed record JoinTable( diff --git a/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 b/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 index 251350ee..0e779dc1 100644 --- a/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 +++ b/src/LibRed/LibRed.Sql/Grammar/AccessSql.g4 @@ -1,6 +1,6 @@ // ANTLR4 grammar for the Jet/ACE (Microsoft Access) SQL dialect. // -// Scope: SELECT with projection/aliases, multi-table FROM with INNER/LEFT/RIGHT JOIN and +// Scope: SELECT with projection/aliases, multi-table FROM with INNER/LEFT/RIGHT/FULL JOIN and // derived-table subqueries, WHERE, ORDER BY, TOP. The parse tree is lowered into // LibRed.Sql.Ast by AstBuilder, so the rest of the engine never sees these generated types. // @@ -262,10 +262,15 @@ tablePrimary joinClause : joinType JOIN tablePrimary ON expression ; +// FULL [OUTER] JOIN is a LibRed extension: ACE has no full outer join at all, and no way to express one +// (its query designer offers only the three above). FULL is therefore a keyword here that is not reserved in +// Access, so a column actually named "Full" has to be bracketed or backticked - the same tax LEFT, RIGHT, +// ORDER and every other keyword already charge. joinType : INNER? # InnerJoin | LEFT OUTER? # LeftJoin | RIGHT OUTER? # RightJoin + | FULL OUTER? # FullJoin ; whereClause : WHERE expression ; @@ -360,6 +365,7 @@ MOD : [Mm][Oo][Dd] ; INNER : [Ii][Nn][Nn][Ee][Rr] ; LEFT : [Ll][Ee][Ff][Tt] ; RIGHT : [Rr][Ii][Gg][Hh][Tt] ; +FULL : [Ff][Uu][Ll][Ll] ; OUTER : [Oo][Uu][Tt][Ee][Rr] ; JOIN : [Jj][Oo][Ii][Nn] ; IN : [Ii][Nn] ; diff --git a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlBaseVisitor.cs b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlBaseVisitor.cs index 7d895218..23220f0c 100644 --- a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlBaseVisitor.cs +++ b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlBaseVisitor.cs @@ -912,6 +912,17 @@ public partial class AccessSqlBaseVisitor : AbstractParseTreeVisitorThe visitor result. public virtual Result VisitRightJoin([NotNull] AccessSqlParser.RightJoinContext context) { return VisitChildren(context); } /// + /// Visit a parse tree produced by the FullJoin + /// labeled alternative in . + /// + /// The default implementation returns the result of calling + /// on . + /// + /// + /// The parse tree. + /// The visitor result. + public virtual Result VisitFullJoin([NotNull] AccessSqlParser.FullJoinContext context) { return VisitChildren(context); } + /// /// Visit a parse tree produced by . /// /// The default implementation returns the result of calling diff --git a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlLexer.cs b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlLexer.cs index 9615c9fd..5d4015cd 100644 --- a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlLexer.cs +++ b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlLexer.cs @@ -35,22 +35,22 @@ public partial class AccessSqlLexer : Lexer { protected static PredictionContextCache sharedContextCache = new PredictionContextCache(); public const int SELECT=1, FROM=2, WHERE=3, TOP=4, AS=5, AND=6, OR=7, NOT=8, BAND=9, BOR=10, - BXOR=11, BNOT=12, LIKE=13, MOD=14, INNER=15, LEFT=16, RIGHT=17, OUTER=18, - JOIN=19, IN=20, ON=21, ORDER=22, GROUP=23, IS=24, BY=25, HAVING=26, EXISTS=27, - IF=28, THEN=29, DISTINCTROW=30, DISTINCT=31, PERCENT=32, BETWEEN=33, UNION=34, - ALL=35, INTERSECT=36, EXCEPT=37, CREATE=38, TABLE=39, BEGIN=40, COMMIT=41, - ROLLBACK=42, TRANSACTION=43, WORK=44, ALTER=45, RENAME=46, TO=47, ADD=48, - DROP=49, COLUMN=50, INSERT=51, INTO=52, VALUES=53, PRIMARY=54, KEY=55, - CONSTRAINT=56, FOREIGN=57, REFERENCES=58, DELETE=59, UPDATE=60, CASCADE=61, - RESTRICT=62, ACTION=63, SET=64, DEFAULT=65, NO=66, UNIQUE=67, INDEX=68, - TEMPORARY=69, WITH=70, COMPRESSION=71, COMP=72, DISALLOW=73, IGNORE=74, - CHECK=75, VIEW=76, PROCEDURE=77, PARAMETERS=78, EXECUTE=79, EXEC=80, ASC=81, - DESC=82, TRUE=83, FALSE=84, NULL=85, STAR=86, SLASH=87, BACKSLASH=88, - CARET=89, PLUS=90, MINUS=91, AMP=92, EQ=93, NEQ=94, LTE=95, GTE=96, LT=97, - GT=98, LPAREN=99, RPAREN=100, COMMA=101, DOT=102, SEMI=103, SYSVAR=104, - PARAM=105, HEX_LITERAL=106, INTEGER_LITERAL=107, NUMBER_LITERAL=108, STRING_LITERAL=109, - DATE_LITERAL=110, GUID_LITERAL=111, BRACKET_ID=112, BACKTICK_ID=113, IDENTIFIER=114, - WS=115, LINE_COMMENT=116, BLOCK_COMMENT=117; + BXOR=11, BNOT=12, LIKE=13, MOD=14, INNER=15, LEFT=16, RIGHT=17, FULL=18, + OUTER=19, JOIN=20, IN=21, ON=22, ORDER=23, GROUP=24, IS=25, BY=26, HAVING=27, + EXISTS=28, IF=29, THEN=30, DISTINCTROW=31, DISTINCT=32, PERCENT=33, BETWEEN=34, + UNION=35, ALL=36, INTERSECT=37, EXCEPT=38, CREATE=39, TABLE=40, BEGIN=41, + COMMIT=42, ROLLBACK=43, TRANSACTION=44, WORK=45, ALTER=46, RENAME=47, + TO=48, ADD=49, DROP=50, COLUMN=51, INSERT=52, INTO=53, VALUES=54, PRIMARY=55, + KEY=56, CONSTRAINT=57, FOREIGN=58, REFERENCES=59, DELETE=60, UPDATE=61, + CASCADE=62, RESTRICT=63, ACTION=64, SET=65, DEFAULT=66, NO=67, UNIQUE=68, + INDEX=69, TEMPORARY=70, WITH=71, COMPRESSION=72, COMP=73, DISALLOW=74, + IGNORE=75, CHECK=76, VIEW=77, PROCEDURE=78, PARAMETERS=79, EXECUTE=80, + EXEC=81, ASC=82, DESC=83, TRUE=84, FALSE=85, NULL=86, STAR=87, SLASH=88, + BACKSLASH=89, CARET=90, PLUS=91, MINUS=92, AMP=93, EQ=94, NEQ=95, LTE=96, + GTE=97, LT=98, GT=99, LPAREN=100, RPAREN=101, COMMA=102, DOT=103, SEMI=104, + SYSVAR=105, PARAM=106, HEX_LITERAL=107, INTEGER_LITERAL=108, NUMBER_LITERAL=109, + STRING_LITERAL=110, DATE_LITERAL=111, GUID_LITERAL=112, BRACKET_ID=113, + BACKTICK_ID=114, IDENTIFIER=115, WS=116, LINE_COMMENT=117, BLOCK_COMMENT=118; public static string[] channelNames = { "DEFAULT_TOKEN_CHANNEL", "HIDDEN" }; @@ -61,21 +61,21 @@ public const int public static readonly string[] ruleNames = { "SELECT", "FROM", "WHERE", "TOP", "AS", "AND", "OR", "NOT", "BAND", "BOR", - "BXOR", "BNOT", "LIKE", "MOD", "INNER", "LEFT", "RIGHT", "OUTER", "JOIN", - "IN", "ON", "ORDER", "GROUP", "IS", "BY", "HAVING", "EXISTS", "IF", "THEN", - "DISTINCTROW", "DISTINCT", "PERCENT", "BETWEEN", "UNION", "ALL", "INTERSECT", - "EXCEPT", "CREATE", "TABLE", "BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION", - "WORK", "ALTER", "RENAME", "TO", "ADD", "DROP", "COLUMN", "INSERT", "INTO", - "VALUES", "PRIMARY", "KEY", "CONSTRAINT", "FOREIGN", "REFERENCES", "DELETE", - "UPDATE", "CASCADE", "RESTRICT", "ACTION", "SET", "DEFAULT", "NO", "UNIQUE", - "INDEX", "TEMPORARY", "WITH", "COMPRESSION", "COMP", "DISALLOW", "IGNORE", - "CHECK", "VIEW", "PROCEDURE", "PARAMETERS", "EXECUTE", "EXEC", "ASC", - "DESC", "TRUE", "FALSE", "NULL", "STAR", "SLASH", "BACKSLASH", "CARET", - "PLUS", "MINUS", "AMP", "EQ", "NEQ", "LTE", "GTE", "LT", "GT", "LPAREN", - "RPAREN", "COMMA", "DOT", "SEMI", "SYSVAR", "PARAM", "HEX_LITERAL", "INTEGER_LITERAL", - "NUMBER_LITERAL", "EXPONENT", "STRING_LITERAL", "DATE_LITERAL", "GUID_LITERAL", - "HEXDIGIT", "BRACKET_ID", "BACKTICK_ID", "IDENTIFIER", "WS", "LINE_COMMENT", - "BLOCK_COMMENT" + "BXOR", "BNOT", "LIKE", "MOD", "INNER", "LEFT", "RIGHT", "FULL", "OUTER", + "JOIN", "IN", "ON", "ORDER", "GROUP", "IS", "BY", "HAVING", "EXISTS", + "IF", "THEN", "DISTINCTROW", "DISTINCT", "PERCENT", "BETWEEN", "UNION", + "ALL", "INTERSECT", "EXCEPT", "CREATE", "TABLE", "BEGIN", "COMMIT", "ROLLBACK", + "TRANSACTION", "WORK", "ALTER", "RENAME", "TO", "ADD", "DROP", "COLUMN", + "INSERT", "INTO", "VALUES", "PRIMARY", "KEY", "CONSTRAINT", "FOREIGN", + "REFERENCES", "DELETE", "UPDATE", "CASCADE", "RESTRICT", "ACTION", "SET", + "DEFAULT", "NO", "UNIQUE", "INDEX", "TEMPORARY", "WITH", "COMPRESSION", + "COMP", "DISALLOW", "IGNORE", "CHECK", "VIEW", "PROCEDURE", "PARAMETERS", + "EXECUTE", "EXEC", "ASC", "DESC", "TRUE", "FALSE", "NULL", "STAR", "SLASH", + "BACKSLASH", "CARET", "PLUS", "MINUS", "AMP", "EQ", "NEQ", "LTE", "GTE", + "LT", "GT", "LPAREN", "RPAREN", "COMMA", "DOT", "SEMI", "SYSVAR", "PARAM", + "HEX_LITERAL", "INTEGER_LITERAL", "NUMBER_LITERAL", "EXPONENT", "STRING_LITERAL", + "DATE_LITERAL", "GUID_LITERAL", "HEXDIGIT", "BRACKET_ID", "BACKTICK_ID", + "IDENTIFIER", "WS", "LINE_COMMENT", "BLOCK_COMMENT" }; @@ -96,13 +96,13 @@ public AccessSqlLexer(ICharStream input, TextWriter output, TextWriter errorOutp null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, "'*'", "'/'", "'\\'", "'^'", "'+'", "'-'", "'&'", "'='", null, - "'<='", "'>='", "'<'", "'>'", "'('", "')'", "','", "'.'", "';'" + null, null, null, "'*'", "'/'", "'\\'", "'^'", "'+'", "'-'", "'&'", "'='", + null, "'<='", "'>='", "'<'", "'>'", "'('", "')'", "','", "'.'", "';'" }; private static readonly string[] _SymbolicNames = { null, "SELECT", "FROM", "WHERE", "TOP", "AS", "AND", "OR", "NOT", "BAND", - "BOR", "BXOR", "BNOT", "LIKE", "MOD", "INNER", "LEFT", "RIGHT", "OUTER", - "JOIN", "IN", "ON", "ORDER", "GROUP", "IS", "BY", "HAVING", "EXISTS", + "BOR", "BXOR", "BNOT", "LIKE", "MOD", "INNER", "LEFT", "RIGHT", "FULL", + "OUTER", "JOIN", "IN", "ON", "ORDER", "GROUP", "IS", "BY", "HAVING", "EXISTS", "IF", "THEN", "DISTINCTROW", "DISTINCT", "PERCENT", "BETWEEN", "UNION", "ALL", "INTERSECT", "EXCEPT", "CREATE", "TABLE", "BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION", "WORK", "ALTER", "RENAME", "TO", "ADD", "DROP", "COLUMN", @@ -145,7 +145,7 @@ static AccessSqlLexer() { } } private static int[] _serializedATN = { - 4,0,117,1009,6,-1,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, + 4,0,118,1016,6,-1,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, @@ -162,324 +162,326 @@ static AccessSqlLexer() { 98,7,98,2,99,7,99,2,100,7,100,2,101,7,101,2,102,7,102,2,103,7,103,2,104, 7,104,2,105,7,105,2,106,7,106,2,107,7,107,2,108,7,108,2,109,7,109,2,110, 7,110,2,111,7,111,2,112,7,112,2,113,7,113,2,114,7,114,2,115,7,115,2,116, - 7,116,2,117,7,117,2,118,7,118,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,1,1,1,1,1, - 1,1,1,1,1,2,1,2,1,2,1,2,1,2,1,2,1,3,1,3,1,3,1,3,1,4,1,4,1,4,1,5,1,5,1, - 5,1,5,1,6,1,6,1,6,1,7,1,7,1,7,1,7,1,8,1,8,1,8,1,8,1,8,1,9,1,9,1,9,1,9, - 1,10,1,10,1,10,1,10,1,10,1,11,1,11,1,11,1,11,1,11,1,12,1,12,1,12,1,12, - 1,12,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14,1,14,1,15,1,15,1,15, - 1,15,1,15,1,16,1,16,1,16,1,16,1,16,1,16,1,17,1,17,1,17,1,17,1,17,1,17, - 1,18,1,18,1,18,1,18,1,18,1,19,1,19,1,19,1,20,1,20,1,20,1,21,1,21,1,21, - 1,21,1,21,1,21,1,22,1,22,1,22,1,22,1,22,1,22,1,23,1,23,1,23,1,24,1,24, - 1,24,1,25,1,25,1,25,1,25,1,25,1,25,1,25,1,26,1,26,1,26,1,26,1,26,1,26, - 1,26,1,27,1,27,1,27,1,28,1,28,1,28,1,28,1,28,1,29,1,29,1,29,1,29,1,29, - 1,29,1,29,1,29,1,29,1,29,1,29,1,29,1,30,1,30,1,30,1,30,1,30,1,30,1,30, - 1,30,1,30,1,31,1,31,1,31,1,31,1,31,1,31,1,31,1,31,1,32,1,32,1,32,1,32, - 1,32,1,32,1,32,1,32,1,33,1,33,1,33,1,33,1,33,1,33,1,34,1,34,1,34,1,34, - 1,35,1,35,1,35,1,35,1,35,1,35,1,35,1,35,1,35,1,35,1,36,1,36,1,36,1,36, + 7,116,2,117,7,117,2,118,7,118,2,119,7,119,1,0,1,0,1,0,1,0,1,0,1,0,1,0, + 1,1,1,1,1,1,1,1,1,1,1,2,1,2,1,2,1,2,1,2,1,2,1,3,1,3,1,3,1,3,1,4,1,4,1, + 4,1,5,1,5,1,5,1,5,1,6,1,6,1,6,1,7,1,7,1,7,1,7,1,8,1,8,1,8,1,8,1,8,1,9, + 1,9,1,9,1,9,1,10,1,10,1,10,1,10,1,10,1,11,1,11,1,11,1,11,1,11,1,12,1,12, + 1,12,1,12,1,12,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14,1,14,1,15, + 1,15,1,15,1,15,1,15,1,16,1,16,1,16,1,16,1,16,1,16,1,17,1,17,1,17,1,17, + 1,17,1,18,1,18,1,18,1,18,1,18,1,18,1,19,1,19,1,19,1,19,1,19,1,20,1,20, + 1,20,1,21,1,21,1,21,1,22,1,22,1,22,1,22,1,22,1,22,1,23,1,23,1,23,1,23, + 1,23,1,23,1,24,1,24,1,24,1,25,1,25,1,25,1,26,1,26,1,26,1,26,1,26,1,26, + 1,26,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,28,1,28,1,28,1,29,1,29,1,29, + 1,29,1,29,1,30,1,30,1,30,1,30,1,30,1,30,1,30,1,30,1,30,1,30,1,30,1,30, + 1,31,1,31,1,31,1,31,1,31,1,31,1,31,1,31,1,31,1,32,1,32,1,32,1,32,1,32, + 1,32,1,32,1,32,1,33,1,33,1,33,1,33,1,33,1,33,1,33,1,33,1,34,1,34,1,34, + 1,34,1,34,1,34,1,35,1,35,1,35,1,35,1,36,1,36,1,36,1,36,1,36,1,36,1,36, 1,36,1,36,1,36,1,37,1,37,1,37,1,37,1,37,1,37,1,37,1,38,1,38,1,38,1,38, - 1,38,1,38,1,39,1,39,1,39,1,39,1,39,1,39,1,40,1,40,1,40,1,40,1,40,1,40, - 1,40,1,41,1,41,1,41,1,41,1,41,1,41,1,41,1,41,1,41,1,42,1,42,1,42,1,42, - 1,42,1,42,1,42,1,42,1,42,1,42,1,42,1,42,1,43,1,43,1,43,1,43,1,43,1,44, - 1,44,1,44,1,44,1,44,1,44,1,45,1,45,1,45,1,45,1,45,1,45,1,45,1,46,1,46, - 1,46,1,47,1,47,1,47,1,47,1,48,1,48,1,48,1,48,1,48,1,49,1,49,1,49,1,49, + 1,38,1,38,1,38,1,39,1,39,1,39,1,39,1,39,1,39,1,40,1,40,1,40,1,40,1,40, + 1,40,1,41,1,41,1,41,1,41,1,41,1,41,1,41,1,42,1,42,1,42,1,42,1,42,1,42, + 1,42,1,42,1,42,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43,1,43, + 1,43,1,44,1,44,1,44,1,44,1,44,1,45,1,45,1,45,1,45,1,45,1,45,1,46,1,46, + 1,46,1,46,1,46,1,46,1,46,1,47,1,47,1,47,1,48,1,48,1,48,1,48,1,49,1,49, 1,49,1,49,1,49,1,50,1,50,1,50,1,50,1,50,1,50,1,50,1,51,1,51,1,51,1,51, - 1,51,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,53,1,53,1,53,1,53,1,53,1,53, - 1,53,1,53,1,54,1,54,1,54,1,54,1,55,1,55,1,55,1,55,1,55,1,55,1,55,1,55, - 1,55,1,55,1,55,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,57,1,57,1,57, - 1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,1,58,1,58,1,58,1,58,1,58,1,58, + 1,51,1,51,1,51,1,52,1,52,1,52,1,52,1,52,1,53,1,53,1,53,1,53,1,53,1,53, + 1,53,1,54,1,54,1,54,1,54,1,54,1,54,1,54,1,54,1,55,1,55,1,55,1,55,1,56, + 1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,56,1,57,1,57,1,57,1,57, + 1,57,1,57,1,57,1,57,1,58,1,58,1,58,1,58,1,58,1,58,1,58,1,58,1,58,1,58, 1,58,1,59,1,59,1,59,1,59,1,59,1,59,1,59,1,60,1,60,1,60,1,60,1,60,1,60, - 1,60,1,60,1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,62,1,62,1,62, - 1,62,1,62,1,62,1,62,1,63,1,63,1,63,1,63,1,64,1,64,1,64,1,64,1,64,1,64, - 1,64,1,64,1,65,1,65,1,65,1,66,1,66,1,66,1,66,1,66,1,66,1,66,1,67,1,67, - 1,67,1,67,1,67,1,67,1,68,1,68,1,68,1,68,1,68,1,68,1,68,1,68,1,68,1,68, - 1,69,1,69,1,69,1,69,1,69,1,70,1,70,1,70,1,70,1,70,1,70,1,70,1,70,1,70, - 1,70,1,70,1,70,1,71,1,71,1,71,1,71,1,71,1,72,1,72,1,72,1,72,1,72,1,72, - 1,72,1,72,1,72,1,73,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,75,1,75,1,76,1,76,1,76,1,76,1,76,1,76,1,76, - 1,76,1,76,1,76,1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,77, - 1,78,1,78,1,78,1,78,1,78,1,78,1,78,1,78,1,79,1,79,1,79,1,79,1,79,1,80, - 1,80,1,80,1,80,1,81,1,81,1,81,1,81,1,81,1,82,1,82,1,82,1,82,1,82,1,83, - 1,83,1,83,1,83,1,83,1,83,1,84,1,84,1,84,1,84,1,84,1,85,1,85,1,86,1,86, - 1,87,1,87,1,88,1,88,1,89,1,89,1,90,1,90,1,91,1,91,1,92,1,92,1,93,1,93, - 1,93,1,93,3,93,791,8,93,1,94,1,94,1,94,1,95,1,95,1,95,1,96,1,96,1,97,1, - 97,1,98,1,98,1,99,1,99,1,100,1,100,1,101,1,101,1,102,1,102,1,103,1,103, - 1,103,1,103,1,103,5,103,818,8,103,10,103,12,103,821,9,103,1,104,1,104, - 1,104,1,104,5,104,827,8,104,10,104,12,104,830,9,104,3,104,832,8,104,1, - 105,1,105,1,105,4,105,837,8,105,11,105,12,105,838,1,106,4,106,842,8,106, - 11,106,12,106,843,1,107,4,107,847,8,107,11,107,12,107,848,1,107,1,107, - 5,107,853,8,107,10,107,12,107,856,9,107,1,107,3,107,859,8,107,1,107,1, - 107,4,107,863,8,107,11,107,12,107,864,1,107,3,107,868,8,107,1,107,4,107, - 871,8,107,11,107,12,107,872,1,107,3,107,876,8,107,1,108,1,108,3,108,880, - 8,108,1,108,4,108,883,8,108,11,108,12,108,884,1,109,1,109,1,109,1,109, - 5,109,891,8,109,10,109,12,109,894,9,109,1,109,1,109,1,109,1,109,1,109, - 5,109,901,8,109,10,109,12,109,904,9,109,1,109,3,109,907,8,109,1,110,1, - 110,5,110,911,8,110,10,110,12,110,914,9,110,1,110,1,110,1,111,1,111,4, - 111,920,8,111,11,111,12,111,921,1,111,1,111,4,111,926,8,111,11,111,12, - 111,927,1,111,1,111,4,111,932,8,111,11,111,12,111,933,1,111,1,111,4,111, - 938,8,111,11,111,12,111,939,1,111,1,111,4,111,944,8,111,11,111,12,111, - 945,1,111,1,111,1,112,1,112,1,113,1,113,4,113,954,8,113,11,113,12,113, - 955,1,113,1,113,1,114,1,114,4,114,962,8,114,11,114,12,114,963,1,114,1, - 114,1,115,1,115,5,115,970,8,115,10,115,12,115,973,9,115,1,115,3,115,976, - 8,115,1,116,4,116,979,8,116,11,116,12,116,980,1,116,1,116,1,117,1,117, - 1,117,1,117,5,117,989,8,117,10,117,12,117,992,9,117,1,117,1,117,1,118, - 1,118,1,118,1,118,5,118,1000,8,118,10,118,12,118,1003,9,118,1,118,1,118, - 1,118,1,118,1,118,1,1001,0,119,1,1,3,2,5,3,7,4,9,5,11,6,13,7,15,8,17,9, - 19,10,21,11,23,12,25,13,27,14,29,15,31,16,33,17,35,18,37,19,39,20,41,21, - 43,22,45,23,47,24,49,25,51,26,53,27,55,28,57,29,59,30,61,31,63,32,65,33, - 67,34,69,35,71,36,73,37,75,38,77,39,79,40,81,41,83,42,85,43,87,44,89,45, - 91,46,93,47,95,48,97,49,99,50,101,51,103,52,105,53,107,54,109,55,111,56, - 113,57,115,58,117,59,119,60,121,61,123,62,125,63,127,64,129,65,131,66, - 133,67,135,68,137,69,139,70,141,71,143,72,145,73,147,74,149,75,151,76, - 153,77,155,78,157,79,159,80,161,81,163,82,165,83,167,84,169,85,171,86, - 173,87,175,88,177,89,179,90,181,91,183,92,185,93,187,94,189,95,191,96, - 193,97,195,98,197,99,199,100,201,101,203,102,205,103,207,104,209,105,211, - 106,213,107,215,108,217,0,219,109,221,110,223,111,225,0,227,112,229,113, - 231,114,233,115,235,116,237,117,1,0,37,2,0,83,83,115,115,2,0,69,69,101, - 101,2,0,76,76,108,108,2,0,67,67,99,99,2,0,84,84,116,116,2,0,70,70,102, - 102,2,0,82,82,114,114,2,0,79,79,111,111,2,0,77,77,109,109,2,0,87,87,119, - 119,2,0,72,72,104,104,2,0,80,80,112,112,2,0,65,65,97,97,2,0,78,78,110, - 110,2,0,68,68,100,100,2,0,66,66,98,98,2,0,88,88,120,120,2,0,73,73,105, - 105,2,0,75,75,107,107,2,0,71,71,103,103,2,0,85,85,117,117,2,0,74,74,106, - 106,2,0,89,89,121,121,2,0,86,86,118,118,2,0,81,81,113,113,3,0,65,90,95, - 95,97,122,4,0,48,57,65,90,95,95,97,122,3,0,48,57,65,70,97,102,1,0,48,57, - 2,0,43,43,45,45,1,0,34,34,1,0,39,39,1,0,35,35,1,0,93,93,1,0,96,96,3,0, - 9,10,13,13,32,32,2,0,10,10,13,13,1040,0,1,1,0,0,0,0,3,1,0,0,0,0,5,1,0, - 0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,0,17, - 1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,0,0,0,25,1,0,0,0,0,27,1,0,0, - 0,0,29,1,0,0,0,0,31,1,0,0,0,0,33,1,0,0,0,0,35,1,0,0,0,0,37,1,0,0,0,0,39, - 1,0,0,0,0,41,1,0,0,0,0,43,1,0,0,0,0,45,1,0,0,0,0,47,1,0,0,0,0,49,1,0,0, - 0,0,51,1,0,0,0,0,53,1,0,0,0,0,55,1,0,0,0,0,57,1,0,0,0,0,59,1,0,0,0,0,61, - 1,0,0,0,0,63,1,0,0,0,0,65,1,0,0,0,0,67,1,0,0,0,0,69,1,0,0,0,0,71,1,0,0, - 0,0,73,1,0,0,0,0,75,1,0,0,0,0,77,1,0,0,0,0,79,1,0,0,0,0,81,1,0,0,0,0,83, - 1,0,0,0,0,85,1,0,0,0,0,87,1,0,0,0,0,89,1,0,0,0,0,91,1,0,0,0,0,93,1,0,0, - 0,0,95,1,0,0,0,0,97,1,0,0,0,0,99,1,0,0,0,0,101,1,0,0,0,0,103,1,0,0,0,0, - 105,1,0,0,0,0,107,1,0,0,0,0,109,1,0,0,0,0,111,1,0,0,0,0,113,1,0,0,0,0, - 115,1,0,0,0,0,117,1,0,0,0,0,119,1,0,0,0,0,121,1,0,0,0,0,123,1,0,0,0,0, - 125,1,0,0,0,0,127,1,0,0,0,0,129,1,0,0,0,0,131,1,0,0,0,0,133,1,0,0,0,0, - 135,1,0,0,0,0,137,1,0,0,0,0,139,1,0,0,0,0,141,1,0,0,0,0,143,1,0,0,0,0, - 145,1,0,0,0,0,147,1,0,0,0,0,149,1,0,0,0,0,151,1,0,0,0,0,153,1,0,0,0,0, - 155,1,0,0,0,0,157,1,0,0,0,0,159,1,0,0,0,0,161,1,0,0,0,0,163,1,0,0,0,0, - 165,1,0,0,0,0,167,1,0,0,0,0,169,1,0,0,0,0,171,1,0,0,0,0,173,1,0,0,0,0, - 175,1,0,0,0,0,177,1,0,0,0,0,179,1,0,0,0,0,181,1,0,0,0,0,183,1,0,0,0,0, - 185,1,0,0,0,0,187,1,0,0,0,0,189,1,0,0,0,0,191,1,0,0,0,0,193,1,0,0,0,0, - 195,1,0,0,0,0,197,1,0,0,0,0,199,1,0,0,0,0,201,1,0,0,0,0,203,1,0,0,0,0, - 205,1,0,0,0,0,207,1,0,0,0,0,209,1,0,0,0,0,211,1,0,0,0,0,213,1,0,0,0,0, - 215,1,0,0,0,0,219,1,0,0,0,0,221,1,0,0,0,0,223,1,0,0,0,0,227,1,0,0,0,0, - 229,1,0,0,0,0,231,1,0,0,0,0,233,1,0,0,0,0,235,1,0,0,0,0,237,1,0,0,0,1, - 239,1,0,0,0,3,246,1,0,0,0,5,251,1,0,0,0,7,257,1,0,0,0,9,261,1,0,0,0,11, - 264,1,0,0,0,13,268,1,0,0,0,15,271,1,0,0,0,17,275,1,0,0,0,19,280,1,0,0, - 0,21,284,1,0,0,0,23,289,1,0,0,0,25,294,1,0,0,0,27,299,1,0,0,0,29,303,1, - 0,0,0,31,309,1,0,0,0,33,314,1,0,0,0,35,320,1,0,0,0,37,326,1,0,0,0,39,331, - 1,0,0,0,41,334,1,0,0,0,43,337,1,0,0,0,45,343,1,0,0,0,47,349,1,0,0,0,49, - 352,1,0,0,0,51,355,1,0,0,0,53,362,1,0,0,0,55,369,1,0,0,0,57,372,1,0,0, - 0,59,377,1,0,0,0,61,389,1,0,0,0,63,398,1,0,0,0,65,406,1,0,0,0,67,414,1, - 0,0,0,69,420,1,0,0,0,71,424,1,0,0,0,73,434,1,0,0,0,75,441,1,0,0,0,77,448, - 1,0,0,0,79,454,1,0,0,0,81,460,1,0,0,0,83,467,1,0,0,0,85,476,1,0,0,0,87, - 488,1,0,0,0,89,493,1,0,0,0,91,499,1,0,0,0,93,506,1,0,0,0,95,509,1,0,0, - 0,97,513,1,0,0,0,99,518,1,0,0,0,101,525,1,0,0,0,103,532,1,0,0,0,105,537, - 1,0,0,0,107,544,1,0,0,0,109,552,1,0,0,0,111,556,1,0,0,0,113,567,1,0,0, - 0,115,575,1,0,0,0,117,586,1,0,0,0,119,593,1,0,0,0,121,600,1,0,0,0,123, - 608,1,0,0,0,125,617,1,0,0,0,127,624,1,0,0,0,129,628,1,0,0,0,131,636,1, - 0,0,0,133,639,1,0,0,0,135,646,1,0,0,0,137,652,1,0,0,0,139,662,1,0,0,0, - 141,667,1,0,0,0,143,679,1,0,0,0,145,684,1,0,0,0,147,693,1,0,0,0,149,700, - 1,0,0,0,151,706,1,0,0,0,153,711,1,0,0,0,155,721,1,0,0,0,157,732,1,0,0, - 0,159,740,1,0,0,0,161,745,1,0,0,0,163,749,1,0,0,0,165,754,1,0,0,0,167, - 759,1,0,0,0,169,765,1,0,0,0,171,770,1,0,0,0,173,772,1,0,0,0,175,774,1, - 0,0,0,177,776,1,0,0,0,179,778,1,0,0,0,181,780,1,0,0,0,183,782,1,0,0,0, - 185,784,1,0,0,0,187,790,1,0,0,0,189,792,1,0,0,0,191,795,1,0,0,0,193,798, - 1,0,0,0,195,800,1,0,0,0,197,802,1,0,0,0,199,804,1,0,0,0,201,806,1,0,0, - 0,203,808,1,0,0,0,205,810,1,0,0,0,207,812,1,0,0,0,209,831,1,0,0,0,211, - 833,1,0,0,0,213,841,1,0,0,0,215,875,1,0,0,0,217,877,1,0,0,0,219,906,1, - 0,0,0,221,908,1,0,0,0,223,917,1,0,0,0,225,949,1,0,0,0,227,951,1,0,0,0, - 229,959,1,0,0,0,231,967,1,0,0,0,233,978,1,0,0,0,235,984,1,0,0,0,237,995, - 1,0,0,0,239,240,7,0,0,0,240,241,7,1,0,0,241,242,7,2,0,0,242,243,7,1,0, - 0,243,244,7,3,0,0,244,245,7,4,0,0,245,2,1,0,0,0,246,247,7,5,0,0,247,248, - 7,6,0,0,248,249,7,7,0,0,249,250,7,8,0,0,250,4,1,0,0,0,251,252,7,9,0,0, - 252,253,7,10,0,0,253,254,7,1,0,0,254,255,7,6,0,0,255,256,7,1,0,0,256,6, - 1,0,0,0,257,258,7,4,0,0,258,259,7,7,0,0,259,260,7,11,0,0,260,8,1,0,0,0, - 261,262,7,12,0,0,262,263,7,0,0,0,263,10,1,0,0,0,264,265,7,12,0,0,265,266, - 7,13,0,0,266,267,7,14,0,0,267,12,1,0,0,0,268,269,7,7,0,0,269,270,7,6,0, - 0,270,14,1,0,0,0,271,272,7,13,0,0,272,273,7,7,0,0,273,274,7,4,0,0,274, - 16,1,0,0,0,275,276,7,15,0,0,276,277,7,12,0,0,277,278,7,13,0,0,278,279, - 7,14,0,0,279,18,1,0,0,0,280,281,7,15,0,0,281,282,7,7,0,0,282,283,7,6,0, - 0,283,20,1,0,0,0,284,285,7,15,0,0,285,286,7,16,0,0,286,287,7,7,0,0,287, - 288,7,6,0,0,288,22,1,0,0,0,289,290,7,15,0,0,290,291,7,13,0,0,291,292,7, - 7,0,0,292,293,7,4,0,0,293,24,1,0,0,0,294,295,7,2,0,0,295,296,7,17,0,0, - 296,297,7,18,0,0,297,298,7,1,0,0,298,26,1,0,0,0,299,300,7,8,0,0,300,301, - 7,7,0,0,301,302,7,14,0,0,302,28,1,0,0,0,303,304,7,17,0,0,304,305,7,13, - 0,0,305,306,7,13,0,0,306,307,7,1,0,0,307,308,7,6,0,0,308,30,1,0,0,0,309, - 310,7,2,0,0,310,311,7,1,0,0,311,312,7,5,0,0,312,313,7,4,0,0,313,32,1,0, - 0,0,314,315,7,6,0,0,315,316,7,17,0,0,316,317,7,19,0,0,317,318,7,10,0,0, - 318,319,7,4,0,0,319,34,1,0,0,0,320,321,7,7,0,0,321,322,7,20,0,0,322,323, - 7,4,0,0,323,324,7,1,0,0,324,325,7,6,0,0,325,36,1,0,0,0,326,327,7,21,0, - 0,327,328,7,7,0,0,328,329,7,17,0,0,329,330,7,13,0,0,330,38,1,0,0,0,331, - 332,7,17,0,0,332,333,7,13,0,0,333,40,1,0,0,0,334,335,7,7,0,0,335,336,7, - 13,0,0,336,42,1,0,0,0,337,338,7,7,0,0,338,339,7,6,0,0,339,340,7,14,0,0, - 340,341,7,1,0,0,341,342,7,6,0,0,342,44,1,0,0,0,343,344,7,19,0,0,344,345, - 7,6,0,0,345,346,7,7,0,0,346,347,7,20,0,0,347,348,7,11,0,0,348,46,1,0,0, - 0,349,350,7,17,0,0,350,351,7,0,0,0,351,48,1,0,0,0,352,353,7,15,0,0,353, - 354,7,22,0,0,354,50,1,0,0,0,355,356,7,10,0,0,356,357,7,12,0,0,357,358, - 7,23,0,0,358,359,7,17,0,0,359,360,7,13,0,0,360,361,7,19,0,0,361,52,1,0, - 0,0,362,363,7,1,0,0,363,364,7,16,0,0,364,365,7,17,0,0,365,366,7,0,0,0, - 366,367,7,4,0,0,367,368,7,0,0,0,368,54,1,0,0,0,369,370,7,17,0,0,370,371, - 7,5,0,0,371,56,1,0,0,0,372,373,7,4,0,0,373,374,7,10,0,0,374,375,7,1,0, - 0,375,376,7,13,0,0,376,58,1,0,0,0,377,378,7,14,0,0,378,379,7,17,0,0,379, - 380,7,0,0,0,380,381,7,4,0,0,381,382,7,17,0,0,382,383,7,13,0,0,383,384, - 7,3,0,0,384,385,7,4,0,0,385,386,7,6,0,0,386,387,7,7,0,0,387,388,7,9,0, - 0,388,60,1,0,0,0,389,390,7,14,0,0,390,391,7,17,0,0,391,392,7,0,0,0,392, - 393,7,4,0,0,393,394,7,17,0,0,394,395,7,13,0,0,395,396,7,3,0,0,396,397, - 7,4,0,0,397,62,1,0,0,0,398,399,7,11,0,0,399,400,7,1,0,0,400,401,7,6,0, - 0,401,402,7,3,0,0,402,403,7,1,0,0,403,404,7,13,0,0,404,405,7,4,0,0,405, - 64,1,0,0,0,406,407,7,15,0,0,407,408,7,1,0,0,408,409,7,4,0,0,409,410,7, - 9,0,0,410,411,7,1,0,0,411,412,7,1,0,0,412,413,7,13,0,0,413,66,1,0,0,0, - 414,415,7,20,0,0,415,416,7,13,0,0,416,417,7,17,0,0,417,418,7,7,0,0,418, - 419,7,13,0,0,419,68,1,0,0,0,420,421,7,12,0,0,421,422,7,2,0,0,422,423,7, - 2,0,0,423,70,1,0,0,0,424,425,7,17,0,0,425,426,7,13,0,0,426,427,7,4,0,0, - 427,428,7,1,0,0,428,429,7,6,0,0,429,430,7,0,0,0,430,431,7,1,0,0,431,432, - 7,3,0,0,432,433,7,4,0,0,433,72,1,0,0,0,434,435,7,1,0,0,435,436,7,16,0, - 0,436,437,7,3,0,0,437,438,7,1,0,0,438,439,7,11,0,0,439,440,7,4,0,0,440, - 74,1,0,0,0,441,442,7,3,0,0,442,443,7,6,0,0,443,444,7,1,0,0,444,445,7,12, - 0,0,445,446,7,4,0,0,446,447,7,1,0,0,447,76,1,0,0,0,448,449,7,4,0,0,449, - 450,7,12,0,0,450,451,7,15,0,0,451,452,7,2,0,0,452,453,7,1,0,0,453,78,1, - 0,0,0,454,455,7,15,0,0,455,456,7,1,0,0,456,457,7,19,0,0,457,458,7,17,0, - 0,458,459,7,13,0,0,459,80,1,0,0,0,460,461,7,3,0,0,461,462,7,7,0,0,462, - 463,7,8,0,0,463,464,7,8,0,0,464,465,7,17,0,0,465,466,7,4,0,0,466,82,1, - 0,0,0,467,468,7,6,0,0,468,469,7,7,0,0,469,470,7,2,0,0,470,471,7,2,0,0, - 471,472,7,15,0,0,472,473,7,12,0,0,473,474,7,3,0,0,474,475,7,18,0,0,475, - 84,1,0,0,0,476,477,7,4,0,0,477,478,7,6,0,0,478,479,7,12,0,0,479,480,7, - 13,0,0,480,481,7,0,0,0,481,482,7,12,0,0,482,483,7,3,0,0,483,484,7,4,0, - 0,484,485,7,17,0,0,485,486,7,7,0,0,486,487,7,13,0,0,487,86,1,0,0,0,488, - 489,7,9,0,0,489,490,7,7,0,0,490,491,7,6,0,0,491,492,7,18,0,0,492,88,1, - 0,0,0,493,494,7,12,0,0,494,495,7,2,0,0,495,496,7,4,0,0,496,497,7,1,0,0, - 497,498,7,6,0,0,498,90,1,0,0,0,499,500,7,6,0,0,500,501,7,1,0,0,501,502, - 7,13,0,0,502,503,7,12,0,0,503,504,7,8,0,0,504,505,7,1,0,0,505,92,1,0,0, - 0,506,507,7,4,0,0,507,508,7,7,0,0,508,94,1,0,0,0,509,510,7,12,0,0,510, - 511,7,14,0,0,511,512,7,14,0,0,512,96,1,0,0,0,513,514,7,14,0,0,514,515, - 7,6,0,0,515,516,7,7,0,0,516,517,7,11,0,0,517,98,1,0,0,0,518,519,7,3,0, - 0,519,520,7,7,0,0,520,521,7,2,0,0,521,522,7,20,0,0,522,523,7,8,0,0,523, - 524,7,13,0,0,524,100,1,0,0,0,525,526,7,17,0,0,526,527,7,13,0,0,527,528, - 7,0,0,0,528,529,7,1,0,0,529,530,7,6,0,0,530,531,7,4,0,0,531,102,1,0,0, - 0,532,533,7,17,0,0,533,534,7,13,0,0,534,535,7,4,0,0,535,536,7,7,0,0,536, - 104,1,0,0,0,537,538,7,23,0,0,538,539,7,12,0,0,539,540,7,2,0,0,540,541, - 7,20,0,0,541,542,7,1,0,0,542,543,7,0,0,0,543,106,1,0,0,0,544,545,7,11, - 0,0,545,546,7,6,0,0,546,547,7,17,0,0,547,548,7,8,0,0,548,549,7,12,0,0, - 549,550,7,6,0,0,550,551,7,22,0,0,551,108,1,0,0,0,552,553,7,18,0,0,553, - 554,7,1,0,0,554,555,7,22,0,0,555,110,1,0,0,0,556,557,7,3,0,0,557,558,7, - 7,0,0,558,559,7,13,0,0,559,560,7,0,0,0,560,561,7,4,0,0,561,562,7,6,0,0, - 562,563,7,12,0,0,563,564,7,17,0,0,564,565,7,13,0,0,565,566,7,4,0,0,566, - 112,1,0,0,0,567,568,7,5,0,0,568,569,7,7,0,0,569,570,7,6,0,0,570,571,7, - 1,0,0,571,572,7,17,0,0,572,573,7,19,0,0,573,574,7,13,0,0,574,114,1,0,0, - 0,575,576,7,6,0,0,576,577,7,1,0,0,577,578,7,5,0,0,578,579,7,1,0,0,579, - 580,7,6,0,0,580,581,7,1,0,0,581,582,7,13,0,0,582,583,7,3,0,0,583,584,7, - 1,0,0,584,585,7,0,0,0,585,116,1,0,0,0,586,587,7,14,0,0,587,588,7,1,0,0, - 588,589,7,2,0,0,589,590,7,1,0,0,590,591,7,4,0,0,591,592,7,1,0,0,592,118, - 1,0,0,0,593,594,7,20,0,0,594,595,7,11,0,0,595,596,7,14,0,0,596,597,7,12, - 0,0,597,598,7,4,0,0,598,599,7,1,0,0,599,120,1,0,0,0,600,601,7,3,0,0,601, - 602,7,12,0,0,602,603,7,0,0,0,603,604,7,3,0,0,604,605,7,12,0,0,605,606, - 7,14,0,0,606,607,7,1,0,0,607,122,1,0,0,0,608,609,7,6,0,0,609,610,7,1,0, - 0,610,611,7,0,0,0,611,612,7,4,0,0,612,613,7,6,0,0,613,614,7,17,0,0,614, - 615,7,3,0,0,615,616,7,4,0,0,616,124,1,0,0,0,617,618,7,12,0,0,618,619,7, - 3,0,0,619,620,7,4,0,0,620,621,7,17,0,0,621,622,7,7,0,0,622,623,7,13,0, - 0,623,126,1,0,0,0,624,625,7,0,0,0,625,626,7,1,0,0,626,627,7,4,0,0,627, - 128,1,0,0,0,628,629,7,14,0,0,629,630,7,1,0,0,630,631,7,5,0,0,631,632,7, - 12,0,0,632,633,7,20,0,0,633,634,7,2,0,0,634,635,7,4,0,0,635,130,1,0,0, - 0,636,637,7,13,0,0,637,638,7,7,0,0,638,132,1,0,0,0,639,640,7,20,0,0,640, - 641,7,13,0,0,641,642,7,17,0,0,642,643,7,24,0,0,643,644,7,20,0,0,644,645, - 7,1,0,0,645,134,1,0,0,0,646,647,7,17,0,0,647,648,7,13,0,0,648,649,7,14, - 0,0,649,650,7,1,0,0,650,651,7,16,0,0,651,136,1,0,0,0,652,653,7,4,0,0,653, - 654,7,1,0,0,654,655,7,8,0,0,655,656,7,11,0,0,656,657,7,7,0,0,657,658,7, - 6,0,0,658,659,7,12,0,0,659,660,7,6,0,0,660,661,7,22,0,0,661,138,1,0,0, - 0,662,663,7,9,0,0,663,664,7,17,0,0,664,665,7,4,0,0,665,666,7,10,0,0,666, - 140,1,0,0,0,667,668,7,3,0,0,668,669,7,7,0,0,669,670,7,8,0,0,670,671,7, - 11,0,0,671,672,7,6,0,0,672,673,7,1,0,0,673,674,7,0,0,0,674,675,7,0,0,0, - 675,676,7,17,0,0,676,677,7,7,0,0,677,678,7,13,0,0,678,142,1,0,0,0,679, - 680,7,3,0,0,680,681,7,7,0,0,681,682,7,8,0,0,682,683,7,11,0,0,683,144,1, - 0,0,0,684,685,7,14,0,0,685,686,7,17,0,0,686,687,7,0,0,0,687,688,7,12,0, - 0,688,689,7,2,0,0,689,690,7,2,0,0,690,691,7,7,0,0,691,692,7,9,0,0,692, - 146,1,0,0,0,693,694,7,17,0,0,694,695,7,19,0,0,695,696,7,13,0,0,696,697, - 7,7,0,0,697,698,7,6,0,0,698,699,7,1,0,0,699,148,1,0,0,0,700,701,7,3,0, - 0,701,702,7,10,0,0,702,703,7,1,0,0,703,704,7,3,0,0,704,705,7,18,0,0,705, - 150,1,0,0,0,706,707,7,23,0,0,707,708,7,17,0,0,708,709,7,1,0,0,709,710, - 7,9,0,0,710,152,1,0,0,0,711,712,7,11,0,0,712,713,7,6,0,0,713,714,7,7,0, - 0,714,715,7,3,0,0,715,716,7,1,0,0,716,717,7,14,0,0,717,718,7,20,0,0,718, - 719,7,6,0,0,719,720,7,1,0,0,720,154,1,0,0,0,721,722,7,11,0,0,722,723,7, - 12,0,0,723,724,7,6,0,0,724,725,7,12,0,0,725,726,7,8,0,0,726,727,7,1,0, - 0,727,728,7,4,0,0,728,729,7,1,0,0,729,730,7,6,0,0,730,731,7,0,0,0,731, - 156,1,0,0,0,732,733,7,1,0,0,733,734,7,16,0,0,734,735,7,1,0,0,735,736,7, - 3,0,0,736,737,7,20,0,0,737,738,7,4,0,0,738,739,7,1,0,0,739,158,1,0,0,0, - 740,741,7,1,0,0,741,742,7,16,0,0,742,743,7,1,0,0,743,744,7,3,0,0,744,160, - 1,0,0,0,745,746,7,12,0,0,746,747,7,0,0,0,747,748,7,3,0,0,748,162,1,0,0, - 0,749,750,7,14,0,0,750,751,7,1,0,0,751,752,7,0,0,0,752,753,7,3,0,0,753, - 164,1,0,0,0,754,755,7,4,0,0,755,756,7,6,0,0,756,757,7,20,0,0,757,758,7, - 1,0,0,758,166,1,0,0,0,759,760,7,5,0,0,760,761,7,12,0,0,761,762,7,2,0,0, - 762,763,7,0,0,0,763,764,7,1,0,0,764,168,1,0,0,0,765,766,7,13,0,0,766,767, - 7,20,0,0,767,768,7,2,0,0,768,769,7,2,0,0,769,170,1,0,0,0,770,771,5,42, - 0,0,771,172,1,0,0,0,772,773,5,47,0,0,773,174,1,0,0,0,774,775,5,92,0,0, - 775,176,1,0,0,0,776,777,5,94,0,0,777,178,1,0,0,0,778,779,5,43,0,0,779, - 180,1,0,0,0,780,781,5,45,0,0,781,182,1,0,0,0,782,783,5,38,0,0,783,184, - 1,0,0,0,784,785,5,61,0,0,785,186,1,0,0,0,786,787,5,60,0,0,787,791,5,62, - 0,0,788,789,5,33,0,0,789,791,5,61,0,0,790,786,1,0,0,0,790,788,1,0,0,0, - 791,188,1,0,0,0,792,793,5,60,0,0,793,794,5,61,0,0,794,190,1,0,0,0,795, - 796,5,62,0,0,796,797,5,61,0,0,797,192,1,0,0,0,798,799,5,60,0,0,799,194, - 1,0,0,0,800,801,5,62,0,0,801,196,1,0,0,0,802,803,5,40,0,0,803,198,1,0, - 0,0,804,805,5,41,0,0,805,200,1,0,0,0,806,807,5,44,0,0,807,202,1,0,0,0, - 808,809,5,46,0,0,809,204,1,0,0,0,810,811,5,59,0,0,811,206,1,0,0,0,812, - 813,5,64,0,0,813,814,5,64,0,0,814,815,1,0,0,0,815,819,7,25,0,0,816,818, - 7,26,0,0,817,816,1,0,0,0,818,821,1,0,0,0,819,817,1,0,0,0,819,820,1,0,0, - 0,820,208,1,0,0,0,821,819,1,0,0,0,822,832,5,63,0,0,823,824,5,64,0,0,824, - 828,7,25,0,0,825,827,7,26,0,0,826,825,1,0,0,0,827,830,1,0,0,0,828,826, - 1,0,0,0,828,829,1,0,0,0,829,832,1,0,0,0,830,828,1,0,0,0,831,822,1,0,0, - 0,831,823,1,0,0,0,832,210,1,0,0,0,833,834,5,48,0,0,834,836,7,16,0,0,835, - 837,7,27,0,0,836,835,1,0,0,0,837,838,1,0,0,0,838,836,1,0,0,0,838,839,1, - 0,0,0,839,212,1,0,0,0,840,842,7,28,0,0,841,840,1,0,0,0,842,843,1,0,0,0, - 843,841,1,0,0,0,843,844,1,0,0,0,844,214,1,0,0,0,845,847,7,28,0,0,846,845, - 1,0,0,0,847,848,1,0,0,0,848,846,1,0,0,0,848,849,1,0,0,0,849,850,1,0,0, - 0,850,854,5,46,0,0,851,853,7,28,0,0,852,851,1,0,0,0,853,856,1,0,0,0,854, - 852,1,0,0,0,854,855,1,0,0,0,855,858,1,0,0,0,856,854,1,0,0,0,857,859,3, - 217,108,0,858,857,1,0,0,0,858,859,1,0,0,0,859,876,1,0,0,0,860,862,5,46, - 0,0,861,863,7,28,0,0,862,861,1,0,0,0,863,864,1,0,0,0,864,862,1,0,0,0,864, - 865,1,0,0,0,865,867,1,0,0,0,866,868,3,217,108,0,867,866,1,0,0,0,867,868, - 1,0,0,0,868,876,1,0,0,0,869,871,7,28,0,0,870,869,1,0,0,0,871,872,1,0,0, - 0,872,870,1,0,0,0,872,873,1,0,0,0,873,874,1,0,0,0,874,876,3,217,108,0, - 875,846,1,0,0,0,875,860,1,0,0,0,875,870,1,0,0,0,876,216,1,0,0,0,877,879, - 7,1,0,0,878,880,7,29,0,0,879,878,1,0,0,0,879,880,1,0,0,0,880,882,1,0,0, - 0,881,883,7,28,0,0,882,881,1,0,0,0,883,884,1,0,0,0,884,882,1,0,0,0,884, - 885,1,0,0,0,885,218,1,0,0,0,886,892,5,34,0,0,887,891,8,30,0,0,888,889, - 5,34,0,0,889,891,5,34,0,0,890,887,1,0,0,0,890,888,1,0,0,0,891,894,1,0, - 0,0,892,890,1,0,0,0,892,893,1,0,0,0,893,895,1,0,0,0,894,892,1,0,0,0,895, - 907,5,34,0,0,896,902,5,39,0,0,897,901,8,31,0,0,898,899,5,39,0,0,899,901, - 5,39,0,0,900,897,1,0,0,0,900,898,1,0,0,0,901,904,1,0,0,0,902,900,1,0,0, - 0,902,903,1,0,0,0,903,905,1,0,0,0,904,902,1,0,0,0,905,907,5,39,0,0,906, - 886,1,0,0,0,906,896,1,0,0,0,907,220,1,0,0,0,908,912,5,35,0,0,909,911,8, - 32,0,0,910,909,1,0,0,0,911,914,1,0,0,0,912,910,1,0,0,0,912,913,1,0,0,0, - 913,915,1,0,0,0,914,912,1,0,0,0,915,916,5,35,0,0,916,222,1,0,0,0,917,919, - 5,123,0,0,918,920,3,225,112,0,919,918,1,0,0,0,920,921,1,0,0,0,921,919, - 1,0,0,0,921,922,1,0,0,0,922,923,1,0,0,0,923,925,5,45,0,0,924,926,3,225, - 112,0,925,924,1,0,0,0,926,927,1,0,0,0,927,925,1,0,0,0,927,928,1,0,0,0, - 928,929,1,0,0,0,929,931,5,45,0,0,930,932,3,225,112,0,931,930,1,0,0,0,932, - 933,1,0,0,0,933,931,1,0,0,0,933,934,1,0,0,0,934,935,1,0,0,0,935,937,5, - 45,0,0,936,938,3,225,112,0,937,936,1,0,0,0,938,939,1,0,0,0,939,937,1,0, - 0,0,939,940,1,0,0,0,940,941,1,0,0,0,941,943,5,45,0,0,942,944,3,225,112, - 0,943,942,1,0,0,0,944,945,1,0,0,0,945,943,1,0,0,0,945,946,1,0,0,0,946, - 947,1,0,0,0,947,948,5,125,0,0,948,224,1,0,0,0,949,950,7,27,0,0,950,226, - 1,0,0,0,951,953,5,91,0,0,952,954,8,33,0,0,953,952,1,0,0,0,954,955,1,0, - 0,0,955,953,1,0,0,0,955,956,1,0,0,0,956,957,1,0,0,0,957,958,5,93,0,0,958, - 228,1,0,0,0,959,961,5,96,0,0,960,962,8,34,0,0,961,960,1,0,0,0,962,963, - 1,0,0,0,963,961,1,0,0,0,963,964,1,0,0,0,964,965,1,0,0,0,965,966,5,96,0, - 0,966,230,1,0,0,0,967,971,7,25,0,0,968,970,7,26,0,0,969,968,1,0,0,0,970, - 973,1,0,0,0,971,969,1,0,0,0,971,972,1,0,0,0,972,975,1,0,0,0,973,971,1, - 0,0,0,974,976,5,36,0,0,975,974,1,0,0,0,975,976,1,0,0,0,976,232,1,0,0,0, - 977,979,7,35,0,0,978,977,1,0,0,0,979,980,1,0,0,0,980,978,1,0,0,0,980,981, - 1,0,0,0,981,982,1,0,0,0,982,983,6,116,0,0,983,234,1,0,0,0,984,985,5,45, - 0,0,985,986,5,45,0,0,986,990,1,0,0,0,987,989,8,36,0,0,988,987,1,0,0,0, - 989,992,1,0,0,0,990,988,1,0,0,0,990,991,1,0,0,0,991,993,1,0,0,0,992,990, - 1,0,0,0,993,994,6,117,0,0,994,236,1,0,0,0,995,996,5,47,0,0,996,997,5,42, - 0,0,997,1001,1,0,0,0,998,1000,9,0,0,0,999,998,1,0,0,0,1000,1003,1,0,0, - 0,1001,1002,1,0,0,0,1001,999,1,0,0,0,1002,1004,1,0,0,0,1003,1001,1,0,0, - 0,1004,1005,5,42,0,0,1005,1006,5,47,0,0,1006,1007,1,0,0,0,1007,1008,6, - 118,0,0,1008,238,1,0,0,0,34,0,790,819,828,831,838,843,848,854,858,864, - 867,872,875,879,884,890,892,900,902,906,912,921,927,933,939,945,955,963, - 971,975,980,990,1001,1,6,0,0 + 1,60,1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,61,1,62,1,62,1,62,1,62,1,62, + 1,62,1,62,1,62,1,62,1,63,1,63,1,63,1,63,1,63,1,63,1,63,1,64,1,64,1,64, + 1,64,1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,65,1,66,1,66,1,66,1,67,1,67, + 1,67,1,67,1,67,1,67,1,67,1,68,1,68,1,68,1,68,1,68,1,68,1,69,1,69,1,69, + 1,69,1,69,1,69,1,69,1,69,1,69,1,69,1,70,1,70,1,70,1,70,1,70,1,71,1,71, + 1,71,1,71,1,71,1,71,1,71,1,71,1,71,1,71,1,71,1,71,1,72,1,72,1,72,1,72, + 1,72,1,73,1,73,1,73,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,74,1,75,1,75,1,75,1,75,1,75,1,75,1,76,1,76,1,76,1,76,1,76, + 1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,77,1,78,1,78,1,78,1,78, + 1,78,1,78,1,78,1,78,1,78,1,78,1,78,1,79,1,79,1,79,1,79,1,79,1,79,1,79, + 1,79,1,80,1,80,1,80,1,80,1,80,1,81,1,81,1,81,1,81,1,82,1,82,1,82,1,82, + 1,82,1,83,1,83,1,83,1,83,1,83,1,84,1,84,1,84,1,84,1,84,1,84,1,85,1,85, + 1,85,1,85,1,85,1,86,1,86,1,87,1,87,1,88,1,88,1,89,1,89,1,90,1,90,1,91, + 1,91,1,92,1,92,1,93,1,93,1,94,1,94,1,94,1,94,3,94,798,8,94,1,95,1,95,1, + 95,1,96,1,96,1,96,1,97,1,97,1,98,1,98,1,99,1,99,1,100,1,100,1,101,1,101, + 1,102,1,102,1,103,1,103,1,104,1,104,1,104,1,104,1,104,5,104,825,8,104, + 10,104,12,104,828,9,104,1,105,1,105,1,105,1,105,5,105,834,8,105,10,105, + 12,105,837,9,105,3,105,839,8,105,1,106,1,106,1,106,4,106,844,8,106,11, + 106,12,106,845,1,107,4,107,849,8,107,11,107,12,107,850,1,108,4,108,854, + 8,108,11,108,12,108,855,1,108,1,108,5,108,860,8,108,10,108,12,108,863, + 9,108,1,108,3,108,866,8,108,1,108,1,108,4,108,870,8,108,11,108,12,108, + 871,1,108,3,108,875,8,108,1,108,4,108,878,8,108,11,108,12,108,879,1,108, + 3,108,883,8,108,1,109,1,109,3,109,887,8,109,1,109,4,109,890,8,109,11,109, + 12,109,891,1,110,1,110,1,110,1,110,5,110,898,8,110,10,110,12,110,901,9, + 110,1,110,1,110,1,110,1,110,1,110,5,110,908,8,110,10,110,12,110,911,9, + 110,1,110,3,110,914,8,110,1,111,1,111,5,111,918,8,111,10,111,12,111,921, + 9,111,1,111,1,111,1,112,1,112,4,112,927,8,112,11,112,12,112,928,1,112, + 1,112,4,112,933,8,112,11,112,12,112,934,1,112,1,112,4,112,939,8,112,11, + 112,12,112,940,1,112,1,112,4,112,945,8,112,11,112,12,112,946,1,112,1,112, + 4,112,951,8,112,11,112,12,112,952,1,112,1,112,1,113,1,113,1,114,1,114, + 4,114,961,8,114,11,114,12,114,962,1,114,1,114,1,115,1,115,4,115,969,8, + 115,11,115,12,115,970,1,115,1,115,1,116,1,116,5,116,977,8,116,10,116,12, + 116,980,9,116,1,116,3,116,983,8,116,1,117,4,117,986,8,117,11,117,12,117, + 987,1,117,1,117,1,118,1,118,1,118,1,118,5,118,996,8,118,10,118,12,118, + 999,9,118,1,118,1,118,1,119,1,119,1,119,1,119,5,119,1007,8,119,10,119, + 12,119,1010,9,119,1,119,1,119,1,119,1,119,1,119,1,1008,0,120,1,1,3,2,5, + 3,7,4,9,5,11,6,13,7,15,8,17,9,19,10,21,11,23,12,25,13,27,14,29,15,31,16, + 33,17,35,18,37,19,39,20,41,21,43,22,45,23,47,24,49,25,51,26,53,27,55,28, + 57,29,59,30,61,31,63,32,65,33,67,34,69,35,71,36,73,37,75,38,77,39,79,40, + 81,41,83,42,85,43,87,44,89,45,91,46,93,47,95,48,97,49,99,50,101,51,103, + 52,105,53,107,54,109,55,111,56,113,57,115,58,117,59,119,60,121,61,123, + 62,125,63,127,64,129,65,131,66,133,67,135,68,137,69,139,70,141,71,143, + 72,145,73,147,74,149,75,151,76,153,77,155,78,157,79,159,80,161,81,163, + 82,165,83,167,84,169,85,171,86,173,87,175,88,177,89,179,90,181,91,183, + 92,185,93,187,94,189,95,191,96,193,97,195,98,197,99,199,100,201,101,203, + 102,205,103,207,104,209,105,211,106,213,107,215,108,217,109,219,0,221, + 110,223,111,225,112,227,0,229,113,231,114,233,115,235,116,237,117,239, + 118,1,0,37,2,0,83,83,115,115,2,0,69,69,101,101,2,0,76,76,108,108,2,0,67, + 67,99,99,2,0,84,84,116,116,2,0,70,70,102,102,2,0,82,82,114,114,2,0,79, + 79,111,111,2,0,77,77,109,109,2,0,87,87,119,119,2,0,72,72,104,104,2,0,80, + 80,112,112,2,0,65,65,97,97,2,0,78,78,110,110,2,0,68,68,100,100,2,0,66, + 66,98,98,2,0,88,88,120,120,2,0,73,73,105,105,2,0,75,75,107,107,2,0,71, + 71,103,103,2,0,85,85,117,117,2,0,74,74,106,106,2,0,89,89,121,121,2,0,86, + 86,118,118,2,0,81,81,113,113,3,0,65,90,95,95,97,122,4,0,48,57,65,90,95, + 95,97,122,3,0,48,57,65,70,97,102,1,0,48,57,2,0,43,43,45,45,1,0,34,34,1, + 0,39,39,1,0,35,35,1,0,93,93,1,0,96,96,3,0,9,10,13,13,32,32,2,0,10,10,13, + 13,1047,0,1,1,0,0,0,0,3,1,0,0,0,0,5,1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0, + 11,1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,0,17,1,0,0,0,0,19,1,0,0,0,0,21,1, + 0,0,0,0,23,1,0,0,0,0,25,1,0,0,0,0,27,1,0,0,0,0,29,1,0,0,0,0,31,1,0,0,0, + 0,33,1,0,0,0,0,35,1,0,0,0,0,37,1,0,0,0,0,39,1,0,0,0,0,41,1,0,0,0,0,43, + 1,0,0,0,0,45,1,0,0,0,0,47,1,0,0,0,0,49,1,0,0,0,0,51,1,0,0,0,0,53,1,0,0, + 0,0,55,1,0,0,0,0,57,1,0,0,0,0,59,1,0,0,0,0,61,1,0,0,0,0,63,1,0,0,0,0,65, + 1,0,0,0,0,67,1,0,0,0,0,69,1,0,0,0,0,71,1,0,0,0,0,73,1,0,0,0,0,75,1,0,0, + 0,0,77,1,0,0,0,0,79,1,0,0,0,0,81,1,0,0,0,0,83,1,0,0,0,0,85,1,0,0,0,0,87, + 1,0,0,0,0,89,1,0,0,0,0,91,1,0,0,0,0,93,1,0,0,0,0,95,1,0,0,0,0,97,1,0,0, + 0,0,99,1,0,0,0,0,101,1,0,0,0,0,103,1,0,0,0,0,105,1,0,0,0,0,107,1,0,0,0, + 0,109,1,0,0,0,0,111,1,0,0,0,0,113,1,0,0,0,0,115,1,0,0,0,0,117,1,0,0,0, + 0,119,1,0,0,0,0,121,1,0,0,0,0,123,1,0,0,0,0,125,1,0,0,0,0,127,1,0,0,0, + 0,129,1,0,0,0,0,131,1,0,0,0,0,133,1,0,0,0,0,135,1,0,0,0,0,137,1,0,0,0, + 0,139,1,0,0,0,0,141,1,0,0,0,0,143,1,0,0,0,0,145,1,0,0,0,0,147,1,0,0,0, + 0,149,1,0,0,0,0,151,1,0,0,0,0,153,1,0,0,0,0,155,1,0,0,0,0,157,1,0,0,0, + 0,159,1,0,0,0,0,161,1,0,0,0,0,163,1,0,0,0,0,165,1,0,0,0,0,167,1,0,0,0, + 0,169,1,0,0,0,0,171,1,0,0,0,0,173,1,0,0,0,0,175,1,0,0,0,0,177,1,0,0,0, + 0,179,1,0,0,0,0,181,1,0,0,0,0,183,1,0,0,0,0,185,1,0,0,0,0,187,1,0,0,0, + 0,189,1,0,0,0,0,191,1,0,0,0,0,193,1,0,0,0,0,195,1,0,0,0,0,197,1,0,0,0, + 0,199,1,0,0,0,0,201,1,0,0,0,0,203,1,0,0,0,0,205,1,0,0,0,0,207,1,0,0,0, + 0,209,1,0,0,0,0,211,1,0,0,0,0,213,1,0,0,0,0,215,1,0,0,0,0,217,1,0,0,0, + 0,221,1,0,0,0,0,223,1,0,0,0,0,225,1,0,0,0,0,229,1,0,0,0,0,231,1,0,0,0, + 0,233,1,0,0,0,0,235,1,0,0,0,0,237,1,0,0,0,0,239,1,0,0,0,1,241,1,0,0,0, + 3,248,1,0,0,0,5,253,1,0,0,0,7,259,1,0,0,0,9,263,1,0,0,0,11,266,1,0,0,0, + 13,270,1,0,0,0,15,273,1,0,0,0,17,277,1,0,0,0,19,282,1,0,0,0,21,286,1,0, + 0,0,23,291,1,0,0,0,25,296,1,0,0,0,27,301,1,0,0,0,29,305,1,0,0,0,31,311, + 1,0,0,0,33,316,1,0,0,0,35,322,1,0,0,0,37,327,1,0,0,0,39,333,1,0,0,0,41, + 338,1,0,0,0,43,341,1,0,0,0,45,344,1,0,0,0,47,350,1,0,0,0,49,356,1,0,0, + 0,51,359,1,0,0,0,53,362,1,0,0,0,55,369,1,0,0,0,57,376,1,0,0,0,59,379,1, + 0,0,0,61,384,1,0,0,0,63,396,1,0,0,0,65,405,1,0,0,0,67,413,1,0,0,0,69,421, + 1,0,0,0,71,427,1,0,0,0,73,431,1,0,0,0,75,441,1,0,0,0,77,448,1,0,0,0,79, + 455,1,0,0,0,81,461,1,0,0,0,83,467,1,0,0,0,85,474,1,0,0,0,87,483,1,0,0, + 0,89,495,1,0,0,0,91,500,1,0,0,0,93,506,1,0,0,0,95,513,1,0,0,0,97,516,1, + 0,0,0,99,520,1,0,0,0,101,525,1,0,0,0,103,532,1,0,0,0,105,539,1,0,0,0,107, + 544,1,0,0,0,109,551,1,0,0,0,111,559,1,0,0,0,113,563,1,0,0,0,115,574,1, + 0,0,0,117,582,1,0,0,0,119,593,1,0,0,0,121,600,1,0,0,0,123,607,1,0,0,0, + 125,615,1,0,0,0,127,624,1,0,0,0,129,631,1,0,0,0,131,635,1,0,0,0,133,643, + 1,0,0,0,135,646,1,0,0,0,137,653,1,0,0,0,139,659,1,0,0,0,141,669,1,0,0, + 0,143,674,1,0,0,0,145,686,1,0,0,0,147,691,1,0,0,0,149,700,1,0,0,0,151, + 707,1,0,0,0,153,713,1,0,0,0,155,718,1,0,0,0,157,728,1,0,0,0,159,739,1, + 0,0,0,161,747,1,0,0,0,163,752,1,0,0,0,165,756,1,0,0,0,167,761,1,0,0,0, + 169,766,1,0,0,0,171,772,1,0,0,0,173,777,1,0,0,0,175,779,1,0,0,0,177,781, + 1,0,0,0,179,783,1,0,0,0,181,785,1,0,0,0,183,787,1,0,0,0,185,789,1,0,0, + 0,187,791,1,0,0,0,189,797,1,0,0,0,191,799,1,0,0,0,193,802,1,0,0,0,195, + 805,1,0,0,0,197,807,1,0,0,0,199,809,1,0,0,0,201,811,1,0,0,0,203,813,1, + 0,0,0,205,815,1,0,0,0,207,817,1,0,0,0,209,819,1,0,0,0,211,838,1,0,0,0, + 213,840,1,0,0,0,215,848,1,0,0,0,217,882,1,0,0,0,219,884,1,0,0,0,221,913, + 1,0,0,0,223,915,1,0,0,0,225,924,1,0,0,0,227,956,1,0,0,0,229,958,1,0,0, + 0,231,966,1,0,0,0,233,974,1,0,0,0,235,985,1,0,0,0,237,991,1,0,0,0,239, + 1002,1,0,0,0,241,242,7,0,0,0,242,243,7,1,0,0,243,244,7,2,0,0,244,245,7, + 1,0,0,245,246,7,3,0,0,246,247,7,4,0,0,247,2,1,0,0,0,248,249,7,5,0,0,249, + 250,7,6,0,0,250,251,7,7,0,0,251,252,7,8,0,0,252,4,1,0,0,0,253,254,7,9, + 0,0,254,255,7,10,0,0,255,256,7,1,0,0,256,257,7,6,0,0,257,258,7,1,0,0,258, + 6,1,0,0,0,259,260,7,4,0,0,260,261,7,7,0,0,261,262,7,11,0,0,262,8,1,0,0, + 0,263,264,7,12,0,0,264,265,7,0,0,0,265,10,1,0,0,0,266,267,7,12,0,0,267, + 268,7,13,0,0,268,269,7,14,0,0,269,12,1,0,0,0,270,271,7,7,0,0,271,272,7, + 6,0,0,272,14,1,0,0,0,273,274,7,13,0,0,274,275,7,7,0,0,275,276,7,4,0,0, + 276,16,1,0,0,0,277,278,7,15,0,0,278,279,7,12,0,0,279,280,7,13,0,0,280, + 281,7,14,0,0,281,18,1,0,0,0,282,283,7,15,0,0,283,284,7,7,0,0,284,285,7, + 6,0,0,285,20,1,0,0,0,286,287,7,15,0,0,287,288,7,16,0,0,288,289,7,7,0,0, + 289,290,7,6,0,0,290,22,1,0,0,0,291,292,7,15,0,0,292,293,7,13,0,0,293,294, + 7,7,0,0,294,295,7,4,0,0,295,24,1,0,0,0,296,297,7,2,0,0,297,298,7,17,0, + 0,298,299,7,18,0,0,299,300,7,1,0,0,300,26,1,0,0,0,301,302,7,8,0,0,302, + 303,7,7,0,0,303,304,7,14,0,0,304,28,1,0,0,0,305,306,7,17,0,0,306,307,7, + 13,0,0,307,308,7,13,0,0,308,309,7,1,0,0,309,310,7,6,0,0,310,30,1,0,0,0, + 311,312,7,2,0,0,312,313,7,1,0,0,313,314,7,5,0,0,314,315,7,4,0,0,315,32, + 1,0,0,0,316,317,7,6,0,0,317,318,7,17,0,0,318,319,7,19,0,0,319,320,7,10, + 0,0,320,321,7,4,0,0,321,34,1,0,0,0,322,323,7,5,0,0,323,324,7,20,0,0,324, + 325,7,2,0,0,325,326,7,2,0,0,326,36,1,0,0,0,327,328,7,7,0,0,328,329,7,20, + 0,0,329,330,7,4,0,0,330,331,7,1,0,0,331,332,7,6,0,0,332,38,1,0,0,0,333, + 334,7,21,0,0,334,335,7,7,0,0,335,336,7,17,0,0,336,337,7,13,0,0,337,40, + 1,0,0,0,338,339,7,17,0,0,339,340,7,13,0,0,340,42,1,0,0,0,341,342,7,7,0, + 0,342,343,7,13,0,0,343,44,1,0,0,0,344,345,7,7,0,0,345,346,7,6,0,0,346, + 347,7,14,0,0,347,348,7,1,0,0,348,349,7,6,0,0,349,46,1,0,0,0,350,351,7, + 19,0,0,351,352,7,6,0,0,352,353,7,7,0,0,353,354,7,20,0,0,354,355,7,11,0, + 0,355,48,1,0,0,0,356,357,7,17,0,0,357,358,7,0,0,0,358,50,1,0,0,0,359,360, + 7,15,0,0,360,361,7,22,0,0,361,52,1,0,0,0,362,363,7,10,0,0,363,364,7,12, + 0,0,364,365,7,23,0,0,365,366,7,17,0,0,366,367,7,13,0,0,367,368,7,19,0, + 0,368,54,1,0,0,0,369,370,7,1,0,0,370,371,7,16,0,0,371,372,7,17,0,0,372, + 373,7,0,0,0,373,374,7,4,0,0,374,375,7,0,0,0,375,56,1,0,0,0,376,377,7,17, + 0,0,377,378,7,5,0,0,378,58,1,0,0,0,379,380,7,4,0,0,380,381,7,10,0,0,381, + 382,7,1,0,0,382,383,7,13,0,0,383,60,1,0,0,0,384,385,7,14,0,0,385,386,7, + 17,0,0,386,387,7,0,0,0,387,388,7,4,0,0,388,389,7,17,0,0,389,390,7,13,0, + 0,390,391,7,3,0,0,391,392,7,4,0,0,392,393,7,6,0,0,393,394,7,7,0,0,394, + 395,7,9,0,0,395,62,1,0,0,0,396,397,7,14,0,0,397,398,7,17,0,0,398,399,7, + 0,0,0,399,400,7,4,0,0,400,401,7,17,0,0,401,402,7,13,0,0,402,403,7,3,0, + 0,403,404,7,4,0,0,404,64,1,0,0,0,405,406,7,11,0,0,406,407,7,1,0,0,407, + 408,7,6,0,0,408,409,7,3,0,0,409,410,7,1,0,0,410,411,7,13,0,0,411,412,7, + 4,0,0,412,66,1,0,0,0,413,414,7,15,0,0,414,415,7,1,0,0,415,416,7,4,0,0, + 416,417,7,9,0,0,417,418,7,1,0,0,418,419,7,1,0,0,419,420,7,13,0,0,420,68, + 1,0,0,0,421,422,7,20,0,0,422,423,7,13,0,0,423,424,7,17,0,0,424,425,7,7, + 0,0,425,426,7,13,0,0,426,70,1,0,0,0,427,428,7,12,0,0,428,429,7,2,0,0,429, + 430,7,2,0,0,430,72,1,0,0,0,431,432,7,17,0,0,432,433,7,13,0,0,433,434,7, + 4,0,0,434,435,7,1,0,0,435,436,7,6,0,0,436,437,7,0,0,0,437,438,7,1,0,0, + 438,439,7,3,0,0,439,440,7,4,0,0,440,74,1,0,0,0,441,442,7,1,0,0,442,443, + 7,16,0,0,443,444,7,3,0,0,444,445,7,1,0,0,445,446,7,11,0,0,446,447,7,4, + 0,0,447,76,1,0,0,0,448,449,7,3,0,0,449,450,7,6,0,0,450,451,7,1,0,0,451, + 452,7,12,0,0,452,453,7,4,0,0,453,454,7,1,0,0,454,78,1,0,0,0,455,456,7, + 4,0,0,456,457,7,12,0,0,457,458,7,15,0,0,458,459,7,2,0,0,459,460,7,1,0, + 0,460,80,1,0,0,0,461,462,7,15,0,0,462,463,7,1,0,0,463,464,7,19,0,0,464, + 465,7,17,0,0,465,466,7,13,0,0,466,82,1,0,0,0,467,468,7,3,0,0,468,469,7, + 7,0,0,469,470,7,8,0,0,470,471,7,8,0,0,471,472,7,17,0,0,472,473,7,4,0,0, + 473,84,1,0,0,0,474,475,7,6,0,0,475,476,7,7,0,0,476,477,7,2,0,0,477,478, + 7,2,0,0,478,479,7,15,0,0,479,480,7,12,0,0,480,481,7,3,0,0,481,482,7,18, + 0,0,482,86,1,0,0,0,483,484,7,4,0,0,484,485,7,6,0,0,485,486,7,12,0,0,486, + 487,7,13,0,0,487,488,7,0,0,0,488,489,7,12,0,0,489,490,7,3,0,0,490,491, + 7,4,0,0,491,492,7,17,0,0,492,493,7,7,0,0,493,494,7,13,0,0,494,88,1,0,0, + 0,495,496,7,9,0,0,496,497,7,7,0,0,497,498,7,6,0,0,498,499,7,18,0,0,499, + 90,1,0,0,0,500,501,7,12,0,0,501,502,7,2,0,0,502,503,7,4,0,0,503,504,7, + 1,0,0,504,505,7,6,0,0,505,92,1,0,0,0,506,507,7,6,0,0,507,508,7,1,0,0,508, + 509,7,13,0,0,509,510,7,12,0,0,510,511,7,8,0,0,511,512,7,1,0,0,512,94,1, + 0,0,0,513,514,7,4,0,0,514,515,7,7,0,0,515,96,1,0,0,0,516,517,7,12,0,0, + 517,518,7,14,0,0,518,519,7,14,0,0,519,98,1,0,0,0,520,521,7,14,0,0,521, + 522,7,6,0,0,522,523,7,7,0,0,523,524,7,11,0,0,524,100,1,0,0,0,525,526,7, + 3,0,0,526,527,7,7,0,0,527,528,7,2,0,0,528,529,7,20,0,0,529,530,7,8,0,0, + 530,531,7,13,0,0,531,102,1,0,0,0,532,533,7,17,0,0,533,534,7,13,0,0,534, + 535,7,0,0,0,535,536,7,1,0,0,536,537,7,6,0,0,537,538,7,4,0,0,538,104,1, + 0,0,0,539,540,7,17,0,0,540,541,7,13,0,0,541,542,7,4,0,0,542,543,7,7,0, + 0,543,106,1,0,0,0,544,545,7,23,0,0,545,546,7,12,0,0,546,547,7,2,0,0,547, + 548,7,20,0,0,548,549,7,1,0,0,549,550,7,0,0,0,550,108,1,0,0,0,551,552,7, + 11,0,0,552,553,7,6,0,0,553,554,7,17,0,0,554,555,7,8,0,0,555,556,7,12,0, + 0,556,557,7,6,0,0,557,558,7,22,0,0,558,110,1,0,0,0,559,560,7,18,0,0,560, + 561,7,1,0,0,561,562,7,22,0,0,562,112,1,0,0,0,563,564,7,3,0,0,564,565,7, + 7,0,0,565,566,7,13,0,0,566,567,7,0,0,0,567,568,7,4,0,0,568,569,7,6,0,0, + 569,570,7,12,0,0,570,571,7,17,0,0,571,572,7,13,0,0,572,573,7,4,0,0,573, + 114,1,0,0,0,574,575,7,5,0,0,575,576,7,7,0,0,576,577,7,6,0,0,577,578,7, + 1,0,0,578,579,7,17,0,0,579,580,7,19,0,0,580,581,7,13,0,0,581,116,1,0,0, + 0,582,583,7,6,0,0,583,584,7,1,0,0,584,585,7,5,0,0,585,586,7,1,0,0,586, + 587,7,6,0,0,587,588,7,1,0,0,588,589,7,13,0,0,589,590,7,3,0,0,590,591,7, + 1,0,0,591,592,7,0,0,0,592,118,1,0,0,0,593,594,7,14,0,0,594,595,7,1,0,0, + 595,596,7,2,0,0,596,597,7,1,0,0,597,598,7,4,0,0,598,599,7,1,0,0,599,120, + 1,0,0,0,600,601,7,20,0,0,601,602,7,11,0,0,602,603,7,14,0,0,603,604,7,12, + 0,0,604,605,7,4,0,0,605,606,7,1,0,0,606,122,1,0,0,0,607,608,7,3,0,0,608, + 609,7,12,0,0,609,610,7,0,0,0,610,611,7,3,0,0,611,612,7,12,0,0,612,613, + 7,14,0,0,613,614,7,1,0,0,614,124,1,0,0,0,615,616,7,6,0,0,616,617,7,1,0, + 0,617,618,7,0,0,0,618,619,7,4,0,0,619,620,7,6,0,0,620,621,7,17,0,0,621, + 622,7,3,0,0,622,623,7,4,0,0,623,126,1,0,0,0,624,625,7,12,0,0,625,626,7, + 3,0,0,626,627,7,4,0,0,627,628,7,17,0,0,628,629,7,7,0,0,629,630,7,13,0, + 0,630,128,1,0,0,0,631,632,7,0,0,0,632,633,7,1,0,0,633,634,7,4,0,0,634, + 130,1,0,0,0,635,636,7,14,0,0,636,637,7,1,0,0,637,638,7,5,0,0,638,639,7, + 12,0,0,639,640,7,20,0,0,640,641,7,2,0,0,641,642,7,4,0,0,642,132,1,0,0, + 0,643,644,7,13,0,0,644,645,7,7,0,0,645,134,1,0,0,0,646,647,7,20,0,0,647, + 648,7,13,0,0,648,649,7,17,0,0,649,650,7,24,0,0,650,651,7,20,0,0,651,652, + 7,1,0,0,652,136,1,0,0,0,653,654,7,17,0,0,654,655,7,13,0,0,655,656,7,14, + 0,0,656,657,7,1,0,0,657,658,7,16,0,0,658,138,1,0,0,0,659,660,7,4,0,0,660, + 661,7,1,0,0,661,662,7,8,0,0,662,663,7,11,0,0,663,664,7,7,0,0,664,665,7, + 6,0,0,665,666,7,12,0,0,666,667,7,6,0,0,667,668,7,22,0,0,668,140,1,0,0, + 0,669,670,7,9,0,0,670,671,7,17,0,0,671,672,7,4,0,0,672,673,7,10,0,0,673, + 142,1,0,0,0,674,675,7,3,0,0,675,676,7,7,0,0,676,677,7,8,0,0,677,678,7, + 11,0,0,678,679,7,6,0,0,679,680,7,1,0,0,680,681,7,0,0,0,681,682,7,0,0,0, + 682,683,7,17,0,0,683,684,7,7,0,0,684,685,7,13,0,0,685,144,1,0,0,0,686, + 687,7,3,0,0,687,688,7,7,0,0,688,689,7,8,0,0,689,690,7,11,0,0,690,146,1, + 0,0,0,691,692,7,14,0,0,692,693,7,17,0,0,693,694,7,0,0,0,694,695,7,12,0, + 0,695,696,7,2,0,0,696,697,7,2,0,0,697,698,7,7,0,0,698,699,7,9,0,0,699, + 148,1,0,0,0,700,701,7,17,0,0,701,702,7,19,0,0,702,703,7,13,0,0,703,704, + 7,7,0,0,704,705,7,6,0,0,705,706,7,1,0,0,706,150,1,0,0,0,707,708,7,3,0, + 0,708,709,7,10,0,0,709,710,7,1,0,0,710,711,7,3,0,0,711,712,7,18,0,0,712, + 152,1,0,0,0,713,714,7,23,0,0,714,715,7,17,0,0,715,716,7,1,0,0,716,717, + 7,9,0,0,717,154,1,0,0,0,718,719,7,11,0,0,719,720,7,6,0,0,720,721,7,7,0, + 0,721,722,7,3,0,0,722,723,7,1,0,0,723,724,7,14,0,0,724,725,7,20,0,0,725, + 726,7,6,0,0,726,727,7,1,0,0,727,156,1,0,0,0,728,729,7,11,0,0,729,730,7, + 12,0,0,730,731,7,6,0,0,731,732,7,12,0,0,732,733,7,8,0,0,733,734,7,1,0, + 0,734,735,7,4,0,0,735,736,7,1,0,0,736,737,7,6,0,0,737,738,7,0,0,0,738, + 158,1,0,0,0,739,740,7,1,0,0,740,741,7,16,0,0,741,742,7,1,0,0,742,743,7, + 3,0,0,743,744,7,20,0,0,744,745,7,4,0,0,745,746,7,1,0,0,746,160,1,0,0,0, + 747,748,7,1,0,0,748,749,7,16,0,0,749,750,7,1,0,0,750,751,7,3,0,0,751,162, + 1,0,0,0,752,753,7,12,0,0,753,754,7,0,0,0,754,755,7,3,0,0,755,164,1,0,0, + 0,756,757,7,14,0,0,757,758,7,1,0,0,758,759,7,0,0,0,759,760,7,3,0,0,760, + 166,1,0,0,0,761,762,7,4,0,0,762,763,7,6,0,0,763,764,7,20,0,0,764,765,7, + 1,0,0,765,168,1,0,0,0,766,767,7,5,0,0,767,768,7,12,0,0,768,769,7,2,0,0, + 769,770,7,0,0,0,770,771,7,1,0,0,771,170,1,0,0,0,772,773,7,13,0,0,773,774, + 7,20,0,0,774,775,7,2,0,0,775,776,7,2,0,0,776,172,1,0,0,0,777,778,5,42, + 0,0,778,174,1,0,0,0,779,780,5,47,0,0,780,176,1,0,0,0,781,782,5,92,0,0, + 782,178,1,0,0,0,783,784,5,94,0,0,784,180,1,0,0,0,785,786,5,43,0,0,786, + 182,1,0,0,0,787,788,5,45,0,0,788,184,1,0,0,0,789,790,5,38,0,0,790,186, + 1,0,0,0,791,792,5,61,0,0,792,188,1,0,0,0,793,794,5,60,0,0,794,798,5,62, + 0,0,795,796,5,33,0,0,796,798,5,61,0,0,797,793,1,0,0,0,797,795,1,0,0,0, + 798,190,1,0,0,0,799,800,5,60,0,0,800,801,5,61,0,0,801,192,1,0,0,0,802, + 803,5,62,0,0,803,804,5,61,0,0,804,194,1,0,0,0,805,806,5,60,0,0,806,196, + 1,0,0,0,807,808,5,62,0,0,808,198,1,0,0,0,809,810,5,40,0,0,810,200,1,0, + 0,0,811,812,5,41,0,0,812,202,1,0,0,0,813,814,5,44,0,0,814,204,1,0,0,0, + 815,816,5,46,0,0,816,206,1,0,0,0,817,818,5,59,0,0,818,208,1,0,0,0,819, + 820,5,64,0,0,820,821,5,64,0,0,821,822,1,0,0,0,822,826,7,25,0,0,823,825, + 7,26,0,0,824,823,1,0,0,0,825,828,1,0,0,0,826,824,1,0,0,0,826,827,1,0,0, + 0,827,210,1,0,0,0,828,826,1,0,0,0,829,839,5,63,0,0,830,831,5,64,0,0,831, + 835,7,25,0,0,832,834,7,26,0,0,833,832,1,0,0,0,834,837,1,0,0,0,835,833, + 1,0,0,0,835,836,1,0,0,0,836,839,1,0,0,0,837,835,1,0,0,0,838,829,1,0,0, + 0,838,830,1,0,0,0,839,212,1,0,0,0,840,841,5,48,0,0,841,843,7,16,0,0,842, + 844,7,27,0,0,843,842,1,0,0,0,844,845,1,0,0,0,845,843,1,0,0,0,845,846,1, + 0,0,0,846,214,1,0,0,0,847,849,7,28,0,0,848,847,1,0,0,0,849,850,1,0,0,0, + 850,848,1,0,0,0,850,851,1,0,0,0,851,216,1,0,0,0,852,854,7,28,0,0,853,852, + 1,0,0,0,854,855,1,0,0,0,855,853,1,0,0,0,855,856,1,0,0,0,856,857,1,0,0, + 0,857,861,5,46,0,0,858,860,7,28,0,0,859,858,1,0,0,0,860,863,1,0,0,0,861, + 859,1,0,0,0,861,862,1,0,0,0,862,865,1,0,0,0,863,861,1,0,0,0,864,866,3, + 219,109,0,865,864,1,0,0,0,865,866,1,0,0,0,866,883,1,0,0,0,867,869,5,46, + 0,0,868,870,7,28,0,0,869,868,1,0,0,0,870,871,1,0,0,0,871,869,1,0,0,0,871, + 872,1,0,0,0,872,874,1,0,0,0,873,875,3,219,109,0,874,873,1,0,0,0,874,875, + 1,0,0,0,875,883,1,0,0,0,876,878,7,28,0,0,877,876,1,0,0,0,878,879,1,0,0, + 0,879,877,1,0,0,0,879,880,1,0,0,0,880,881,1,0,0,0,881,883,3,219,109,0, + 882,853,1,0,0,0,882,867,1,0,0,0,882,877,1,0,0,0,883,218,1,0,0,0,884,886, + 7,1,0,0,885,887,7,29,0,0,886,885,1,0,0,0,886,887,1,0,0,0,887,889,1,0,0, + 0,888,890,7,28,0,0,889,888,1,0,0,0,890,891,1,0,0,0,891,889,1,0,0,0,891, + 892,1,0,0,0,892,220,1,0,0,0,893,899,5,34,0,0,894,898,8,30,0,0,895,896, + 5,34,0,0,896,898,5,34,0,0,897,894,1,0,0,0,897,895,1,0,0,0,898,901,1,0, + 0,0,899,897,1,0,0,0,899,900,1,0,0,0,900,902,1,0,0,0,901,899,1,0,0,0,902, + 914,5,34,0,0,903,909,5,39,0,0,904,908,8,31,0,0,905,906,5,39,0,0,906,908, + 5,39,0,0,907,904,1,0,0,0,907,905,1,0,0,0,908,911,1,0,0,0,909,907,1,0,0, + 0,909,910,1,0,0,0,910,912,1,0,0,0,911,909,1,0,0,0,912,914,5,39,0,0,913, + 893,1,0,0,0,913,903,1,0,0,0,914,222,1,0,0,0,915,919,5,35,0,0,916,918,8, + 32,0,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,922,1,0,0,0,921,919,1,0,0,0,922,923,5,35,0,0,923,224,1,0,0,0,924,926, + 5,123,0,0,925,927,3,227,113,0,926,925,1,0,0,0,927,928,1,0,0,0,928,926, + 1,0,0,0,928,929,1,0,0,0,929,930,1,0,0,0,930,932,5,45,0,0,931,933,3,227, + 113,0,932,931,1,0,0,0,933,934,1,0,0,0,934,932,1,0,0,0,934,935,1,0,0,0, + 935,936,1,0,0,0,936,938,5,45,0,0,937,939,3,227,113,0,938,937,1,0,0,0,939, + 940,1,0,0,0,940,938,1,0,0,0,940,941,1,0,0,0,941,942,1,0,0,0,942,944,5, + 45,0,0,943,945,3,227,113,0,944,943,1,0,0,0,945,946,1,0,0,0,946,944,1,0, + 0,0,946,947,1,0,0,0,947,948,1,0,0,0,948,950,5,45,0,0,949,951,3,227,113, + 0,950,949,1,0,0,0,951,952,1,0,0,0,952,950,1,0,0,0,952,953,1,0,0,0,953, + 954,1,0,0,0,954,955,5,125,0,0,955,226,1,0,0,0,956,957,7,27,0,0,957,228, + 1,0,0,0,958,960,5,91,0,0,959,961,8,33,0,0,960,959,1,0,0,0,961,962,1,0, + 0,0,962,960,1,0,0,0,962,963,1,0,0,0,963,964,1,0,0,0,964,965,5,93,0,0,965, + 230,1,0,0,0,966,968,5,96,0,0,967,969,8,34,0,0,968,967,1,0,0,0,969,970, + 1,0,0,0,970,968,1,0,0,0,970,971,1,0,0,0,971,972,1,0,0,0,972,973,5,96,0, + 0,973,232,1,0,0,0,974,978,7,25,0,0,975,977,7,26,0,0,976,975,1,0,0,0,977, + 980,1,0,0,0,978,976,1,0,0,0,978,979,1,0,0,0,979,982,1,0,0,0,980,978,1, + 0,0,0,981,983,5,36,0,0,982,981,1,0,0,0,982,983,1,0,0,0,983,234,1,0,0,0, + 984,986,7,35,0,0,985,984,1,0,0,0,986,987,1,0,0,0,987,985,1,0,0,0,987,988, + 1,0,0,0,988,989,1,0,0,0,989,990,6,117,0,0,990,236,1,0,0,0,991,992,5,45, + 0,0,992,993,5,45,0,0,993,997,1,0,0,0,994,996,8,36,0,0,995,994,1,0,0,0, + 996,999,1,0,0,0,997,995,1,0,0,0,997,998,1,0,0,0,998,1000,1,0,0,0,999,997, + 1,0,0,0,1000,1001,6,118,0,0,1001,238,1,0,0,0,1002,1003,5,47,0,0,1003,1004, + 5,42,0,0,1004,1008,1,0,0,0,1005,1007,9,0,0,0,1006,1005,1,0,0,0,1007,1010, + 1,0,0,0,1008,1009,1,0,0,0,1008,1006,1,0,0,0,1009,1011,1,0,0,0,1010,1008, + 1,0,0,0,1011,1012,5,42,0,0,1012,1013,5,47,0,0,1013,1014,1,0,0,0,1014,1015, + 6,119,0,0,1015,240,1,0,0,0,34,0,797,826,835,838,845,850,855,861,865,871, + 874,879,882,886,891,897,899,907,909,913,919,928,934,940,946,952,962,970, + 978,982,987,997,1008,1,6,0,0 }; public static readonly ATN _ATN = diff --git a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs index e48c7c61..1c81aa87 100644 --- a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs +++ b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlParser.cs @@ -38,22 +38,22 @@ public partial class AccessSqlParser : Parser { protected static PredictionContextCache sharedContextCache = new PredictionContextCache(); public const int SELECT=1, FROM=2, WHERE=3, TOP=4, AS=5, AND=6, OR=7, NOT=8, BAND=9, BOR=10, - BXOR=11, BNOT=12, LIKE=13, MOD=14, INNER=15, LEFT=16, RIGHT=17, OUTER=18, - JOIN=19, IN=20, ON=21, ORDER=22, GROUP=23, IS=24, BY=25, HAVING=26, EXISTS=27, - IF=28, THEN=29, DISTINCTROW=30, DISTINCT=31, PERCENT=32, BETWEEN=33, UNION=34, - ALL=35, INTERSECT=36, EXCEPT=37, CREATE=38, TABLE=39, BEGIN=40, COMMIT=41, - ROLLBACK=42, TRANSACTION=43, WORK=44, ALTER=45, RENAME=46, TO=47, ADD=48, - DROP=49, COLUMN=50, INSERT=51, INTO=52, VALUES=53, PRIMARY=54, KEY=55, - CONSTRAINT=56, FOREIGN=57, REFERENCES=58, DELETE=59, UPDATE=60, CASCADE=61, - RESTRICT=62, ACTION=63, SET=64, DEFAULT=65, NO=66, UNIQUE=67, INDEX=68, - TEMPORARY=69, WITH=70, COMPRESSION=71, COMP=72, DISALLOW=73, IGNORE=74, - CHECK=75, VIEW=76, PROCEDURE=77, PARAMETERS=78, EXECUTE=79, EXEC=80, ASC=81, - DESC=82, TRUE=83, FALSE=84, NULL=85, STAR=86, SLASH=87, BACKSLASH=88, - CARET=89, PLUS=90, MINUS=91, AMP=92, EQ=93, NEQ=94, LTE=95, GTE=96, LT=97, - GT=98, LPAREN=99, RPAREN=100, COMMA=101, DOT=102, SEMI=103, SYSVAR=104, - PARAM=105, HEX_LITERAL=106, INTEGER_LITERAL=107, NUMBER_LITERAL=108, STRING_LITERAL=109, - DATE_LITERAL=110, GUID_LITERAL=111, BRACKET_ID=112, BACKTICK_ID=113, IDENTIFIER=114, - WS=115, LINE_COMMENT=116, BLOCK_COMMENT=117; + BXOR=11, BNOT=12, LIKE=13, MOD=14, INNER=15, LEFT=16, RIGHT=17, FULL=18, + OUTER=19, JOIN=20, IN=21, ON=22, ORDER=23, GROUP=24, IS=25, BY=26, HAVING=27, + EXISTS=28, IF=29, THEN=30, DISTINCTROW=31, DISTINCT=32, PERCENT=33, BETWEEN=34, + UNION=35, ALL=36, INTERSECT=37, EXCEPT=38, CREATE=39, TABLE=40, BEGIN=41, + COMMIT=42, ROLLBACK=43, TRANSACTION=44, WORK=45, ALTER=46, RENAME=47, + TO=48, ADD=49, DROP=50, COLUMN=51, INSERT=52, INTO=53, VALUES=54, PRIMARY=55, + KEY=56, CONSTRAINT=57, FOREIGN=58, REFERENCES=59, DELETE=60, UPDATE=61, + CASCADE=62, RESTRICT=63, ACTION=64, SET=65, DEFAULT=66, NO=67, UNIQUE=68, + INDEX=69, TEMPORARY=70, WITH=71, COMPRESSION=72, COMP=73, DISALLOW=74, + IGNORE=75, CHECK=76, VIEW=77, PROCEDURE=78, PARAMETERS=79, EXECUTE=80, + EXEC=81, ASC=82, DESC=83, TRUE=84, FALSE=85, NULL=86, STAR=87, SLASH=88, + BACKSLASH=89, CARET=90, PLUS=91, MINUS=92, AMP=93, EQ=94, NEQ=95, LTE=96, + GTE=97, LT=98, GT=99, LPAREN=100, RPAREN=101, COMMA=102, DOT=103, SEMI=104, + SYSVAR=105, PARAM=106, HEX_LITERAL=107, INTEGER_LITERAL=108, NUMBER_LITERAL=109, + STRING_LITERAL=110, DATE_LITERAL=111, GUID_LITERAL=112, BRACKET_ID=113, + BACKTICK_ID=114, IDENTIFIER=115, WS=116, LINE_COMMENT=117, BLOCK_COMMENT=118; public const int RULE_statement = 0, RULE_ifThenStatement = 1, RULE_thenBody = 2, RULE_executeStatement = 3, RULE_updateStatement = 4, RULE_assignment = 5, RULE_deleteStatement = 6, @@ -99,13 +99,13 @@ public const int null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, "'*'", "'/'", "'\\'", "'^'", "'+'", "'-'", "'&'", "'='", null, - "'<='", "'>='", "'<'", "'>'", "'('", "')'", "','", "'.'", "';'" + null, null, null, "'*'", "'/'", "'\\'", "'^'", "'+'", "'-'", "'&'", "'='", + null, "'<='", "'>='", "'<'", "'>'", "'('", "')'", "','", "'.'", "';'" }; private static readonly string[] _SymbolicNames = { null, "SELECT", "FROM", "WHERE", "TOP", "AS", "AND", "OR", "NOT", "BAND", - "BOR", "BXOR", "BNOT", "LIKE", "MOD", "INNER", "LEFT", "RIGHT", "OUTER", - "JOIN", "IN", "ON", "ORDER", "GROUP", "IS", "BY", "HAVING", "EXISTS", + "BOR", "BXOR", "BNOT", "LIKE", "MOD", "INNER", "LEFT", "RIGHT", "FULL", + "OUTER", "JOIN", "IN", "ON", "ORDER", "GROUP", "IS", "BY", "HAVING", "EXISTS", "IF", "THEN", "DISTINCTROW", "DISTINCT", "PERCENT", "BETWEEN", "UNION", "ALL", "INTERSECT", "EXCEPT", "CREATE", "TABLE", "BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION", "WORK", "ALTER", "RENAME", "TO", "ADD", "DROP", "COLUMN", @@ -613,7 +613,7 @@ public ExecuteStatementContext executeStatement() { State = 178; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 134418688L) != 0) || ((((_la - 81)) & ~0x3f) == 0 && ((1L << (_la - 81)) & 17171743773L) != 0)) { + if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 268636416L) != 0) || ((((_la - 82)) & ~0x3f) == 0 && ((1L << (_la - 82)) & 17171743773L) != 0)) { { State = 170; expression(0); @@ -974,7 +974,7 @@ public SysVarItemContext sysVarItem() { State = 225; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - if (_la==AS || ((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) { + if (_la==AS || ((((_la - 113)) & ~0x3f) == 0 && ((1L << (_la - 113)) & 7L) != 0)) { { State = 222; ErrorHandler.Sync(this); @@ -1335,7 +1335,7 @@ public CreateProcedureStatementContext createProcedureStatement() { State = 285; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - if (((((_la - 99)) & ~0x3f) == 0 && ((1L << (_la - 99)) & 57409L) != 0)) { + if (((((_la - 100)) & ~0x3f) == 0 && ((1L << (_la - 100)) & 57409L) != 0)) { { State = 284; procParamList(); @@ -1863,7 +1863,7 @@ public AlterTableActionContext alterTableAction() { State = 339; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 378302368699121920L) != 0) || ((((_la - 65)) & ~0x3f) == 0 && ((1L << (_la - 65)) & 1049637L) != 0)) { + while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 756604737398243584L) != 0) || ((((_la - 66)) & ~0x3f) == 0 && ((1L << (_la - 66)) & 1049637L) != 0)) { { { State = 336; @@ -2544,7 +2544,7 @@ public ColumnDefinitionContext columnDefinition() { State = 442; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 378302368699121920L) != 0) || ((((_la - 65)) & ~0x3f) == 0 && ((1L << (_la - 65)) & 1049637L) != 0)) { + while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 756604737398243584L) != 0) || ((((_la - 66)) & ~0x3f) == 0 && ((1L << (_la - 66)) & 1049637L) != 0)) { { { State = 439; @@ -2625,7 +2625,7 @@ public DataTypeContext dataType() { State = 450; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - if (((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) { + if (((((_la - 113)) & ~0x3f) == 0 && ((1L << (_la - 113)) & 7L) != 0)) { { State = 449; _localctx.extra2 = identifier(); @@ -3482,7 +3482,7 @@ public CheckBodyContext checkBody() { State = 613; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - while ((((_la) & ~0x3f) == 0 && ((1L << _la) & -2L) != 0) || ((((_la - 64)) & ~0x3f) == 0 && ((1L << (_la - 64)) & 18014329790005247L) != 0)) { + while ((((_la) & ~0x3f) == 0 && ((1L << _la) & -2L) != 0) || ((((_la - 64)) & ~0x3f) == 0 && ((1L << (_la - 64)) & 36028659580010495L) != 0)) { { State = 611; ErrorHandler.Sync(this); @@ -3504,6 +3504,7 @@ public CheckBodyContext checkBody() { case INNER: case LEFT: case RIGHT: + case FULL: case OUTER: case JOIN: case IN: @@ -4072,7 +4073,7 @@ public QueryExpressionContext queryExpression() { State = 675; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 223338299392L) != 0)) { + while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 446676598784L) != 0)) { { { State = 670; @@ -4311,7 +4312,7 @@ public SelectStatementContext selectStatement() { State = 695; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 37580963840L) != 0)) { + if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 75161927680L) != 0)) { { State = 694; _localctx.predicate = selectPredicate(); @@ -4432,7 +4433,7 @@ public SelectPredicateContext selectPredicate() { { State = 720; _la = TokenStream.LA(1); - if ( !((((_la) & ~0x3f) == 0 && ((1L << _la) & 37580963840L) != 0)) ) { + if ( !((((_la) & ~0x3f) == 0 && ((1L << _la) & 75161927680L) != 0)) ) { ErrorHandler.RecoverInline(this); } else { @@ -4899,7 +4900,7 @@ public SelectItemContext selectItem() { State = 775; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - if (_la==AS || ((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) { + if (_la==AS || ((((_la - 113)) & ~0x3f) == 0 && ((1L << (_la - 113)) & 7L) != 0)) { { State = 772; ErrorHandler.Sync(this); @@ -5033,7 +5034,7 @@ public TableSourceContext tableSource() { State = 792; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 753664L) != 0)) { + while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 1540096L) != 0)) { { { State = 789; @@ -5139,7 +5140,7 @@ public TablePrimaryContext tablePrimary() { State = 800; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - if (_la==AS || ((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) { + if (_la==AS || ((((_la - 113)) & ~0x3f) == 0 && ((1L << (_la - 113)) & 7L) != 0)) { { State = 797; ErrorHandler.Sync(this); @@ -5171,7 +5172,7 @@ public TablePrimaryContext tablePrimary() { State = 809; ErrorHandler.Sync(this); _la = TokenStream.LA(1); - if (_la==AS || ((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) { + if (_la==AS || ((((_la - 113)) & ~0x3f) == 0 && ((1L << (_la - 113)) & 7L) != 0)) { { State = 806; ErrorHandler.Sync(this); @@ -5293,6 +5294,17 @@ public override TResult Accept(IParseTreeVisitor visitor) { else return visitor.VisitChildren(this); } } + public partial class FullJoinContext : JoinTypeContext { + [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode FULL() { return GetToken(AccessSqlParser.FULL, 0); } + [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode OUTER() { return GetToken(AccessSqlParser.OUTER, 0); } + public FullJoinContext(JoinTypeContext context) { CopyFrom(context); } + [System.Diagnostics.DebuggerNonUserCode] + public override TResult Accept(IParseTreeVisitor visitor) { + IAccessSqlVisitor typedVisitor = visitor as IAccessSqlVisitor; + if (typedVisitor != null) return typedVisitor.VisitFullJoin(this); + else return visitor.VisitChildren(this); + } + } public partial class LeftJoinContext : JoinTypeContext { [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode LEFT() { return GetToken(AccessSqlParser.LEFT, 0); } [System.Diagnostics.DebuggerNonUserCode] public ITerminalNode OUTER() { return GetToken(AccessSqlParser.OUTER, 0); } @@ -5321,7 +5333,7 @@ public JoinTypeContext joinType() { EnterRule(_localctx, 94, RULE_joinType); int _la; try { - State = 834; + State = 838; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case INNER: @@ -5375,6 +5387,24 @@ public JoinTypeContext joinType() { } } + } + break; + case FULL: + _localctx = new FullJoinContext(_localctx); + EnterOuterAlt(_localctx, 4); + { + State = 834; + Match(FULL); + State = 836; + ErrorHandler.Sync(this); + _la = TokenStream.LA(1); + if (_la==OUTER) { + { + State = 835; + Match(OUTER); + } + } + } break; default: @@ -5417,9 +5447,9 @@ public WhereClauseContext whereClause() { try { EnterOuterAlt(_localctx, 1); { - State = 836; + State = 840; Match(WHERE); - State = 837; + State = 841; expression(0); } } @@ -5468,25 +5498,25 @@ public OrderByClauseContext orderByClause() { try { EnterOuterAlt(_localctx, 1); { - State = 839; + State = 843; Match(ORDER); - State = 840; + State = 844; Match(BY); - State = 841; + State = 845; orderByItem(); - State = 846; + State = 850; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 842; + State = 846; Match(COMMA); - State = 843; + State = 847; orderByItem(); } } - State = 848; + State = 852; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -5531,14 +5561,14 @@ public OrderByItemContext orderByItem() { try { EnterOuterAlt(_localctx, 1); { - State = 849; + State = 853; expression(0); - State = 851; + State = 855; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==ASC || _la==DESC) { { - State = 850; + State = 854; _localctx.dir = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !(_la==ASC || _la==DESC) ) { @@ -5895,7 +5925,7 @@ private ExpressionContext expression(int _p) { int _alt; EnterOuterAlt(_localctx, 1); { - State = 861; + State = 865; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case NOT: @@ -5904,9 +5934,9 @@ private ExpressionContext expression(int _p) { Context = _localctx; _prevctx = _localctx; - State = 854; + State = 858; Match(NOT); - State = 855; + State = 859; expression(16); } break; @@ -5915,9 +5945,9 @@ private ExpressionContext expression(int _p) { _localctx = new BitNotExprContext(_localctx); Context = _localctx; _prevctx = _localctx; - State = 856; + State = 860; Match(BNOT); - State = 857; + State = 861; expression(15); } break; @@ -5926,9 +5956,9 @@ private ExpressionContext expression(int _p) { _localctx = new NegateExprContext(_localctx); Context = _localctx; _prevctx = _localctx; - State = 858; + State = 862; Match(MINUS); - State = 859; + State = 863; expression(14); } break; @@ -5955,7 +5985,7 @@ private ExpressionContext expression(int _p) { _localctx = new PrimaryExprContext(_localctx); Context = _localctx; _prevctx = _localctx; - State = 860; + State = 864; primary(); } break; @@ -5963,28 +5993,28 @@ private ExpressionContext expression(int _p) { throw new NoViableAltException(this); } Context.Stop = TokenStream.LT(-1); - State = 932; + State = 936; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,115,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,116,Context); while ( _alt!=2 && _alt!=global::Antlr4.Runtime.Atn.ATN.INVALID_ALT_NUMBER ) { if ( _alt==1 ) { if ( ParseListeners!=null ) TriggerExitRuleEvent(); _prevctx = _localctx; { - State = 930; + State = 934; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,114,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,115,Context) ) { case 1: { _localctx = new PowExprContext(new ExpressionContext(_parentctx, _parentState)); ((PowExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 863; + State = 867; if (!(Precpred(Context, 13))) throw new FailedPredicateException(this, "Precpred(Context, 13)"); - State = 864; + State = 868; Match(CARET); - State = 865; + State = 869; ((PowExprContext)_localctx).right = expression(14); } break; @@ -5993,19 +6023,19 @@ private ExpressionContext expression(int _p) { _localctx = new MulDivExprContext(new ExpressionContext(_parentctx, _parentState)); ((MulDivExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 866; + State = 870; if (!(Precpred(Context, 12))) throw new FailedPredicateException(this, "Precpred(Context, 12)"); - State = 867; + State = 871; ((MulDivExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); - if ( !(_la==MOD || ((((_la - 86)) & ~0x3f) == 0 && ((1L << (_la - 86)) & 7L) != 0)) ) { + if ( !(_la==MOD || ((((_la - 87)) & ~0x3f) == 0 && ((1L << (_la - 87)) & 7L) != 0)) ) { ((MulDivExprContext)_localctx).op = ErrorHandler.RecoverInline(this); } else { ErrorHandler.ReportMatch(this); Consume(); } - State = 868; + State = 872; ((MulDivExprContext)_localctx).right = expression(13); } break; @@ -6014,19 +6044,19 @@ private ExpressionContext expression(int _p) { _localctx = new AddConcatExprContext(new ExpressionContext(_parentctx, _parentState)); ((AddConcatExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 869; + State = 873; if (!(Precpred(Context, 11))) throw new FailedPredicateException(this, "Precpred(Context, 11)"); - State = 870; + State = 874; ((AddConcatExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); - if ( !(((((_la - 90)) & ~0x3f) == 0 && ((1L << (_la - 90)) & 7L) != 0)) ) { + if ( !(((((_la - 91)) & ~0x3f) == 0 && ((1L << (_la - 91)) & 7L) != 0)) ) { ((AddConcatExprContext)_localctx).op = ErrorHandler.RecoverInline(this); } else { ErrorHandler.ReportMatch(this); Consume(); } - State = 871; + State = 875; ((AddConcatExprContext)_localctx).right = expression(12); } break; @@ -6035,19 +6065,19 @@ private ExpressionContext expression(int _p) { _localctx = new ComparisonExprContext(new ExpressionContext(_parentctx, _parentState)); ((ComparisonExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 872; + State = 876; if (!(Precpred(Context, 10))) throw new FailedPredicateException(this, "Precpred(Context, 10)"); - State = 873; + State = 877; ((ComparisonExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); - if ( !(((((_la - 93)) & ~0x3f) == 0 && ((1L << (_la - 93)) & 63L) != 0)) ) { + if ( !(((((_la - 94)) & ~0x3f) == 0 && ((1L << (_la - 94)) & 63L) != 0)) ) { ((ComparisonExprContext)_localctx).op = ErrorHandler.RecoverInline(this); } else { ErrorHandler.ReportMatch(this); Consume(); } - State = 874; + State = 878; ((ComparisonExprContext)_localctx).right = expression(11); } break; @@ -6056,25 +6086,25 @@ private ExpressionContext expression(int _p) { _localctx = new BetweenExprContext(new ExpressionContext(_parentctx, _parentState)); ((BetweenExprContext)_localctx).val = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 875; + State = 879; if (!(Precpred(Context, 9))) throw new FailedPredicateException(this, "Precpred(Context, 9)"); - State = 877; + State = 881; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 876; + State = 880; ((BetweenExprContext)_localctx).not = Match(NOT); } } - State = 879; + State = 883; Match(BETWEEN); - State = 880; + State = 884; ((BetweenExprContext)_localctx).lo = expression(0); - State = 881; + State = 885; Match(AND); - State = 882; + State = 886; ((BetweenExprContext)_localctx).hi = expression(10); } break; @@ -6083,21 +6113,21 @@ private ExpressionContext expression(int _p) { _localctx = new LikeExprContext(new ExpressionContext(_parentctx, _parentState)); ((LikeExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 884; + State = 888; if (!(Precpred(Context, 8))) throw new FailedPredicateException(this, "Precpred(Context, 8)"); - State = 886; + State = 890; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 885; + State = 889; ((LikeExprContext)_localctx).not = Match(NOT); } } - State = 888; + State = 892; Match(LIKE); - State = 889; + State = 893; ((LikeExprContext)_localctx).right = expression(9); } break; @@ -6106,9 +6136,9 @@ private ExpressionContext expression(int _p) { _localctx = new BitwiseExprContext(new ExpressionContext(_parentctx, _parentState)); ((BitwiseExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 890; + State = 894; if (!(Precpred(Context, 4))) throw new FailedPredicateException(this, "Precpred(Context, 4)"); - State = 891; + State = 895; ((BitwiseExprContext)_localctx).op = TokenStream.LT(1); _la = TokenStream.LA(1); if ( !((((_la) & ~0x3f) == 0 && ((1L << _la) & 3584L) != 0)) ) { @@ -6118,7 +6148,7 @@ private ExpressionContext expression(int _p) { ErrorHandler.ReportMatch(this); Consume(); } - State = 892; + State = 896; ((BitwiseExprContext)_localctx).right = expression(5); } break; @@ -6127,11 +6157,11 @@ private ExpressionContext expression(int _p) { _localctx = new AndExprContext(new ExpressionContext(_parentctx, _parentState)); ((AndExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 893; + State = 897; if (!(Precpred(Context, 3))) throw new FailedPredicateException(this, "Precpred(Context, 3)"); - State = 894; + State = 898; Match(AND); - State = 895; + State = 899; ((AndExprContext)_localctx).right = expression(4); } break; @@ -6140,11 +6170,11 @@ private ExpressionContext expression(int _p) { _localctx = new OrExprContext(new ExpressionContext(_parentctx, _parentState)); ((OrExprContext)_localctx).left = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 896; + State = 900; if (!(Precpred(Context, 2))) throw new FailedPredicateException(this, "Precpred(Context, 2)"); - State = 897; + State = 901; Match(OR); - State = 898; + State = 902; ((OrExprContext)_localctx).right = expression(3); } break; @@ -6153,25 +6183,25 @@ private ExpressionContext expression(int _p) { _localctx = new InSubqueryExprContext(new ExpressionContext(_parentctx, _parentState)); ((InSubqueryExprContext)_localctx).val = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 899; + State = 903; if (!(Precpred(Context, 7))) throw new FailedPredicateException(this, "Precpred(Context, 7)"); - State = 901; + State = 905; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 900; + State = 904; ((InSubqueryExprContext)_localctx).not = Match(NOT); } } - State = 903; + State = 907; Match(IN); - State = 904; + State = 908; Match(LPAREN); - State = 905; + State = 909; ((InSubqueryExprContext)_localctx).sub = selectStatement(); - State = 906; + State = 910; Match(RPAREN); } break; @@ -6180,43 +6210,43 @@ private ExpressionContext expression(int _p) { _localctx = new InExprContext(new ExpressionContext(_parentctx, _parentState)); ((InExprContext)_localctx).val = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 908; + State = 912; if (!(Precpred(Context, 6))) throw new FailedPredicateException(this, "Precpred(Context, 6)"); - State = 910; + State = 914; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 909; + State = 913; ((InExprContext)_localctx).not = Match(NOT); } } - State = 912; + State = 916; Match(IN); - State = 913; + State = 917; Match(LPAREN); - State = 914; + State = 918; ((InExprContext)_localctx)._expression = expression(0); ((InExprContext)_localctx)._items.Add(((InExprContext)_localctx)._expression); - State = 919; + State = 923; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 915; + State = 919; Match(COMMA); - State = 916; + State = 920; ((InExprContext)_localctx)._expression = expression(0); ((InExprContext)_localctx)._items.Add(((InExprContext)_localctx)._expression); } } - State = 921; + State = 925; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } - State = 922; + State = 926; Match(RPAREN); } break; @@ -6225,30 +6255,30 @@ private ExpressionContext expression(int _p) { _localctx = new IsNullExprContext(new ExpressionContext(_parentctx, _parentState)); ((IsNullExprContext)_localctx).operand = _prevctx; PushNewRecursionContext(_localctx, _startState, RULE_expression); - State = 924; + State = 928; if (!(Precpred(Context, 5))) throw new FailedPredicateException(this, "Precpred(Context, 5)"); - State = 925; + State = 929; Match(IS); - State = 927; + State = 931; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==NOT) { { - State = 926; + State = 930; ((IsNullExprContext)_localctx).not = Match(NOT); } } - State = 929; + State = 933; Match(NULL); } break; } } } - State = 934; + State = 938; ErrorHandler.Sync(this); - _alt = Interpreter.AdaptivePredict(TokenStream,115,Context); + _alt = Interpreter.AdaptivePredict(TokenStream,116,Context); } } } @@ -6380,14 +6410,14 @@ public PrimaryContext primary() { PrimaryContext _localctx = new PrimaryContext(Context, State); EnterRule(_localctx, 104, RULE_primary); try { - State = 953; + State = 957; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,116,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,117,Context) ) { case 1: _localctx = new LiteralPrimaryContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 935; + State = 939; literal(); } break; @@ -6395,7 +6425,7 @@ public PrimaryContext primary() { _localctx = new FunctionCallPrimaryContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 936; + State = 940; functionCall(); } break; @@ -6403,7 +6433,7 @@ public PrimaryContext primary() { _localctx = new ColumnPrimaryContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 937; + State = 941; columnRef(); } break; @@ -6411,7 +6441,7 @@ public PrimaryContext primary() { _localctx = new ParamPrimaryContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 938; + State = 942; Match(PARAM); } break; @@ -6419,7 +6449,7 @@ public PrimaryContext primary() { _localctx = new SystemVariablePrimaryContext(_localctx); EnterOuterAlt(_localctx, 5); { - State = 939; + State = 943; Match(SYSVAR); } break; @@ -6427,13 +6457,13 @@ public PrimaryContext primary() { _localctx = new ExistsPrimaryContext(_localctx); EnterOuterAlt(_localctx, 6); { - State = 940; + State = 944; Match(EXISTS); - State = 941; + State = 945; Match(LPAREN); - State = 942; + State = 946; selectStatement(); - State = 943; + State = 947; Match(RPAREN); } break; @@ -6441,11 +6471,11 @@ public PrimaryContext primary() { _localctx = new ScalarSubqueryPrimaryContext(_localctx); EnterOuterAlt(_localctx, 7); { - State = 945; + State = 949; Match(LPAREN); - State = 946; + State = 950; selectStatement(); - State = 947; + State = 951; Match(RPAREN); } break; @@ -6453,11 +6483,11 @@ public PrimaryContext primary() { _localctx = new ParenPrimaryContext(_localctx); EnterOuterAlt(_localctx, 8); { - State = 949; + State = 953; Match(LPAREN); - State = 950; + State = 954; expression(0); - State = 951; + State = 955; Match(RPAREN); } break; @@ -6516,16 +6546,16 @@ public FunctionCallContext functionCall() { try { EnterOuterAlt(_localctx, 1); { - State = 955; + State = 959; _localctx.name = functionName(); - State = 956; + State = 960; Match(LPAREN); - State = 969; + State = 973; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case STAR: { - State = 957; + State = 961; _localctx.star = Match(STAR); } break; @@ -6554,31 +6584,31 @@ public FunctionCallContext functionCall() { case IDENTIFIER: { { - State = 959; + State = 963; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==DISTINCT) { { - State = 958; + State = 962; _localctx.distinct = Match(DISTINCT); } } - State = 961; + State = 965; expression(0); - State = 966; + State = 970; ErrorHandler.Sync(this); _la = TokenStream.LA(1); while (_la==COMMA) { { { - State = 962; + State = 966; Match(COMMA); - State = 963; + State = 967; expression(0); } } - State = 968; + State = 972; ErrorHandler.Sync(this); _la = TokenStream.LA(1); } @@ -6590,7 +6620,7 @@ public FunctionCallContext functionCall() { default: break; } - State = 971; + State = 975; Match(RPAREN); } } @@ -6630,7 +6660,7 @@ public FunctionNameContext functionName() { FunctionNameContext _localctx = new FunctionNameContext(Context, State); EnterRule(_localctx, 108, RULE_functionName); try { - State = 977; + State = 981; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case BRACKET_ID: @@ -6638,28 +6668,28 @@ public FunctionNameContext functionName() { case IDENTIFIER: EnterOuterAlt(_localctx, 1); { - State = 973; + State = 977; identifier(); } break; case LEFT: EnterOuterAlt(_localctx, 2); { - State = 974; + State = 978; Match(LEFT); } break; case RIGHT: EnterOuterAlt(_localctx, 3); { - State = 975; + State = 979; Match(RIGHT); } break; case ASC: EnterOuterAlt(_localctx, 4); { - State = 976; + State = 980; Match(ASC); } break; @@ -6708,19 +6738,19 @@ public ColumnRefContext columnRef() { try { EnterOuterAlt(_localctx, 1); { - State = 982; + State = 986; ErrorHandler.Sync(this); - switch ( Interpreter.AdaptivePredict(TokenStream,121,Context) ) { + switch ( Interpreter.AdaptivePredict(TokenStream,122,Context) ) { case 1: { - State = 979; + State = 983; _localctx.qualifier = identifier(); - State = 980; + State = 984; Match(DOT); } break; } - State = 984; + State = 988; _localctx.name = identifier(); } } @@ -6760,9 +6790,9 @@ public IdentifierContext identifier() { try { EnterOuterAlt(_localctx, 1); { - State = 986; + State = 990; _la = TokenStream.LA(1); - if ( !(((((_la - 112)) & ~0x3f) == 0 && ((1L << (_la - 112)) & 7L) != 0)) ) { + if ( !(((((_la - 113)) & ~0x3f) == 0 && ((1L << (_la - 113)) & 7L) != 0)) ) { ErrorHandler.RecoverInline(this); } else { @@ -6890,14 +6920,14 @@ public LiteralContext literal() { LiteralContext _localctx = new LiteralContext(Context, State); EnterRule(_localctx, 114, RULE_literal); try { - State = 997; + State = 1001; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case INTEGER_LITERAL: _localctx = new IntLiteralContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 988; + State = 992; Match(INTEGER_LITERAL); } break; @@ -6905,7 +6935,7 @@ public LiteralContext literal() { _localctx = new NumberLiteralContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 989; + State = 993; Match(NUMBER_LITERAL); } break; @@ -6913,7 +6943,7 @@ public LiteralContext literal() { _localctx = new HexLiteralContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 990; + State = 994; Match(HEX_LITERAL); } break; @@ -6921,7 +6951,7 @@ public LiteralContext literal() { _localctx = new StringLiteralContext(_localctx); EnterOuterAlt(_localctx, 4); { - State = 991; + State = 995; Match(STRING_LITERAL); } break; @@ -6929,7 +6959,7 @@ public LiteralContext literal() { _localctx = new DateLiteralContext(_localctx); EnterOuterAlt(_localctx, 5); { - State = 992; + State = 996; Match(DATE_LITERAL); } break; @@ -6937,7 +6967,7 @@ public LiteralContext literal() { _localctx = new GuidLiteralContext(_localctx); EnterOuterAlt(_localctx, 6); { - State = 993; + State = 997; Match(GUID_LITERAL); } break; @@ -6945,7 +6975,7 @@ public LiteralContext literal() { _localctx = new TrueLiteralContext(_localctx); EnterOuterAlt(_localctx, 7); { - State = 994; + State = 998; Match(TRUE); } break; @@ -6953,7 +6983,7 @@ public LiteralContext literal() { _localctx = new FalseLiteralContext(_localctx); EnterOuterAlt(_localctx, 8); { - State = 995; + State = 999; Match(FALSE); } break; @@ -6961,7 +6991,7 @@ public LiteralContext literal() { _localctx = new NullLiteralContext(_localctx); EnterOuterAlt(_localctx, 9); { - State = 996; + State = 1000; Match(NULL); } break; @@ -7035,21 +7065,21 @@ public TransactionStatementContext transactionStatement() { EnterRule(_localctx, 116, RULE_transactionStatement); int _la; try { - State = 1011; + State = 1015; ErrorHandler.Sync(this); switch (TokenStream.LA(1)) { case BEGIN: _localctx = new BeginTransactionStatementContext(_localctx); EnterOuterAlt(_localctx, 1); { - State = 999; + State = 1003; Match(BEGIN); - State = 1001; + State = 1005; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TRANSACTION || _la==WORK) { { - State = 1000; + State = 1004; _la = TokenStream.LA(1); if ( !(_la==TRANSACTION || _la==WORK) ) { ErrorHandler.RecoverInline(this); @@ -7067,14 +7097,14 @@ public TransactionStatementContext transactionStatement() { _localctx = new CommitTransactionStatementContext(_localctx); EnterOuterAlt(_localctx, 2); { - State = 1003; + State = 1007; Match(COMMIT); - State = 1005; + State = 1009; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TRANSACTION || _la==WORK) { { - State = 1004; + State = 1008; _la = TokenStream.LA(1); if ( !(_la==TRANSACTION || _la==WORK) ) { ErrorHandler.RecoverInline(this); @@ -7092,14 +7122,14 @@ public TransactionStatementContext transactionStatement() { _localctx = new RollbackTransactionStatementContext(_localctx); EnterOuterAlt(_localctx, 3); { - State = 1007; + State = 1011; Match(ROLLBACK); - State = 1009; + State = 1013; ErrorHandler.Sync(this); _la = TokenStream.LA(1); if (_la==TRANSACTION || _la==WORK) { { - State = 1008; + State = 1012; _la = TokenStream.LA(1); if ( !(_la==TRANSACTION || _la==WORK) ) { ErrorHandler.RecoverInline(this); @@ -7153,9 +7183,9 @@ public StandaloneExpressionContext standaloneExpression() { try { EnterOuterAlt(_localctx, 1); { - State = 1013; + State = 1017; expression(0); - State = 1014; + State = 1018; Match(Eof); } } @@ -7195,7 +7225,7 @@ private bool expression_sempred(ExpressionContext _localctx, int predIndex) { } private static int[] _serializedATN = { - 4,1,117,1017,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,118,1021,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, @@ -7260,306 +7290,309 @@ private bool expression_sempred(ExpressionContext _localctx, int predIndex) { 12,44,794,9,44,1,45,1,45,3,45,798,8,45,1,45,3,45,801,8,45,1,45,1,45,1, 45,1,45,3,45,807,8,45,1,45,3,45,810,8,45,1,45,1,45,1,45,1,45,3,45,816, 8,45,1,46,1,46,1,46,1,46,1,46,1,46,1,47,3,47,825,8,47,1,47,1,47,3,47,829, - 8,47,1,47,1,47,3,47,833,8,47,3,47,835,8,47,1,48,1,48,1,48,1,49,1,49,1, - 49,1,49,1,49,5,49,845,8,49,10,49,12,49,848,9,49,1,50,1,50,3,50,852,8,50, - 1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,3,51,862,8,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,878,8,51,1,51, - 1,51,1,51,1,51,1,51,1,51,1,51,3,51,887,8,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,902,8,51,1,51,1,51,1,51,1,51, - 1,51,1,51,1,51,3,51,911,8,51,1,51,1,51,1,51,1,51,1,51,5,51,918,8,51,10, - 51,12,51,921,9,51,1,51,1,51,1,51,1,51,1,51,3,51,928,8,51,1,51,5,51,931, - 8,51,10,51,12,51,934,9,51,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52, - 1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,3,52,954,8,52,1,53,1,53,1, - 53,1,53,3,53,960,8,53,1,53,1,53,1,53,5,53,965,8,53,10,53,12,53,968,9,53, - 3,53,970,8,53,1,53,1,53,1,54,1,54,1,54,1,54,3,54,978,8,54,1,55,1,55,1, - 55,3,55,983,8,55,1,55,1,55,1,56,1,56,1,57,1,57,1,57,1,57,1,57,1,57,1,57, - 1,57,1,57,3,57,998,8,57,1,58,1,58,3,58,1002,8,58,1,58,1,58,3,58,1006,8, - 58,1,58,1,58,3,58,1010,8,58,3,58,1012,8,58,1,59,1,59,1,59,1,59,0,1,102, - 60,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,0,12,1,0,79,80,1,0,81,82, - 1,0,71,72,1,0,99,100,2,0,30,31,35,35,1,0,90,91,2,0,14,14,86,88,1,0,90, - 92,1,0,93,98,1,0,9,11,1,0,112,114,1,0,43,44,1161,0,121,1,0,0,0,2,144,1, - 0,0,0,4,166,1,0,0,0,6,168,1,0,0,0,8,180,1,0,0,0,10,194,1,0,0,0,12,198, - 1,0,0,0,14,211,1,0,0,0,16,220,1,0,0,0,18,227,1,0,0,0,20,238,1,0,0,0,22, - 262,1,0,0,0,24,281,1,0,0,0,26,309,1,0,0,0,28,311,1,0,0,0,30,316,1,0,0, - 0,32,318,1,0,0,0,34,380,1,0,0,0,36,385,1,0,0,0,38,387,1,0,0,0,40,424,1, - 0,0,0,42,426,1,0,0,0,44,435,1,0,0,0,46,437,1,0,0,0,48,445,1,0,0,0,50,463, - 1,0,0,0,52,519,1,0,0,0,54,604,1,0,0,0,56,613,1,0,0,0,58,622,1,0,0,0,60, - 632,1,0,0,0,62,634,1,0,0,0,64,669,1,0,0,0,66,683,1,0,0,0,68,691,1,0,0, - 0,70,693,1,0,0,0,72,720,1,0,0,0,74,722,1,0,0,0,76,732,1,0,0,0,78,735,1, - 0,0,0,80,753,1,0,0,0,82,764,1,0,0,0,84,777,1,0,0,0,86,779,1,0,0,0,88,788, - 1,0,0,0,90,815,1,0,0,0,92,817,1,0,0,0,94,834,1,0,0,0,96,836,1,0,0,0,98, - 839,1,0,0,0,100,849,1,0,0,0,102,861,1,0,0,0,104,953,1,0,0,0,106,955,1, - 0,0,0,108,977,1,0,0,0,110,982,1,0,0,0,112,986,1,0,0,0,114,997,1,0,0,0, - 116,1011,1,0,0,0,118,1013,1,0,0,0,120,122,3,18,9,0,121,120,1,0,0,0,121, - 122,1,0,0,0,122,137,1,0,0,0,123,138,3,2,1,0,124,138,3,20,10,0,125,138, - 3,38,19,0,126,138,3,22,11,0,127,138,3,24,12,0,128,138,3,32,16,0,129,138, - 3,40,20,0,130,138,3,62,31,0,131,138,3,8,4,0,132,138,3,12,6,0,133,138,3, - 116,58,0,134,138,3,6,3,0,135,138,3,14,7,0,136,138,3,64,32,0,137,123,1, - 0,0,0,137,124,1,0,0,0,137,125,1,0,0,0,137,126,1,0,0,0,137,127,1,0,0,0, - 137,128,1,0,0,0,137,129,1,0,0,0,137,130,1,0,0,0,137,131,1,0,0,0,137,132, - 1,0,0,0,137,133,1,0,0,0,137,134,1,0,0,0,137,135,1,0,0,0,137,136,1,0,0, - 0,138,140,1,0,0,0,139,141,5,103,0,0,140,139,1,0,0,0,140,141,1,0,0,0,141, - 142,1,0,0,0,142,143,5,0,0,1,143,1,1,0,0,0,144,146,5,28,0,0,145,147,5,8, - 0,0,146,145,1,0,0,0,146,147,1,0,0,0,147,148,1,0,0,0,148,149,5,27,0,0,149, - 150,5,99,0,0,150,151,3,70,35,0,151,152,5,100,0,0,152,153,5,29,0,0,153, - 154,3,4,2,0,154,3,1,0,0,0,155,167,3,20,10,0,156,167,3,38,19,0,157,167, - 3,22,11,0,158,167,3,24,12,0,159,167,3,32,16,0,160,167,3,40,20,0,161,167, - 3,62,31,0,162,167,3,8,4,0,163,167,3,12,6,0,164,167,3,6,3,0,165,167,3,64, - 32,0,166,155,1,0,0,0,166,156,1,0,0,0,166,157,1,0,0,0,166,158,1,0,0,0,166, - 159,1,0,0,0,166,160,1,0,0,0,166,161,1,0,0,0,166,162,1,0,0,0,166,163,1, - 0,0,0,166,164,1,0,0,0,166,165,1,0,0,0,167,5,1,0,0,0,168,169,7,0,0,0,169, - 178,3,112,56,0,170,175,3,102,51,0,171,172,5,101,0,0,172,174,3,102,51,0, - 173,171,1,0,0,0,174,177,1,0,0,0,175,173,1,0,0,0,175,176,1,0,0,0,176,179, - 1,0,0,0,177,175,1,0,0,0,178,170,1,0,0,0,178,179,1,0,0,0,179,7,1,0,0,0, - 180,181,5,60,0,0,181,182,3,88,44,0,182,183,5,64,0,0,183,188,3,10,5,0,184, - 185,5,101,0,0,185,187,3,10,5,0,186,184,1,0,0,0,187,190,1,0,0,0,188,186, - 1,0,0,0,188,189,1,0,0,0,189,192,1,0,0,0,190,188,1,0,0,0,191,193,3,96,48, - 0,192,191,1,0,0,0,192,193,1,0,0,0,193,9,1,0,0,0,194,195,3,110,55,0,195, - 196,5,93,0,0,196,197,3,102,51,0,197,11,1,0,0,0,198,204,5,59,0,0,199,200, - 3,112,56,0,200,201,5,102,0,0,201,202,5,86,0,0,202,205,1,0,0,0,203,205, - 5,86,0,0,204,199,1,0,0,0,204,203,1,0,0,0,204,205,1,0,0,0,205,206,1,0,0, - 0,206,207,5,2,0,0,207,209,3,88,44,0,208,210,3,96,48,0,209,208,1,0,0,0, - 209,210,1,0,0,0,210,13,1,0,0,0,211,212,5,1,0,0,212,217,3,16,8,0,213,214, - 5,101,0,0,214,216,3,16,8,0,215,213,1,0,0,0,216,219,1,0,0,0,217,215,1,0, - 0,0,217,218,1,0,0,0,218,15,1,0,0,0,219,217,1,0,0,0,220,225,5,104,0,0,221, - 223,5,5,0,0,222,221,1,0,0,0,222,223,1,0,0,0,223,224,1,0,0,0,224,226,3, - 112,56,0,225,222,1,0,0,0,225,226,1,0,0,0,226,17,1,0,0,0,227,228,5,78,0, - 0,228,233,3,28,14,0,229,230,5,101,0,0,230,232,3,28,14,0,231,229,1,0,0, - 0,232,235,1,0,0,0,233,231,1,0,0,0,233,234,1,0,0,0,234,236,1,0,0,0,235, - 233,1,0,0,0,236,237,5,103,0,0,237,19,1,0,0,0,238,240,5,38,0,0,239,241, - 5,69,0,0,240,239,1,0,0,0,240,241,1,0,0,0,241,242,1,0,0,0,242,243,5,39, - 0,0,243,244,3,112,56,0,244,245,5,99,0,0,245,250,3,46,23,0,246,247,5,101, - 0,0,247,249,3,46,23,0,248,246,1,0,0,0,249,252,1,0,0,0,250,248,1,0,0,0, - 250,251,1,0,0,0,251,257,1,0,0,0,252,250,1,0,0,0,253,254,5,101,0,0,254, - 256,3,54,27,0,255,253,1,0,0,0,256,259,1,0,0,0,257,255,1,0,0,0,257,258, - 1,0,0,0,258,260,1,0,0,0,259,257,1,0,0,0,260,261,5,100,0,0,261,21,1,0,0, - 0,262,263,5,38,0,0,263,264,5,76,0,0,264,276,3,112,56,0,265,266,5,99,0, - 0,266,271,3,112,56,0,267,268,5,101,0,0,268,270,3,112,56,0,269,267,1,0, - 0,0,270,273,1,0,0,0,271,269,1,0,0,0,271,272,1,0,0,0,272,274,1,0,0,0,273, - 271,1,0,0,0,274,275,5,100,0,0,275,277,1,0,0,0,276,265,1,0,0,0,276,277, - 1,0,0,0,277,278,1,0,0,0,278,279,5,5,0,0,279,280,3,64,32,0,280,23,1,0,0, - 0,281,282,5,38,0,0,282,283,5,77,0,0,283,285,3,112,56,0,284,286,3,26,13, - 0,285,284,1,0,0,0,285,286,1,0,0,0,286,287,1,0,0,0,287,288,5,5,0,0,288, - 289,3,36,18,0,289,25,1,0,0,0,290,291,5,99,0,0,291,296,3,28,14,0,292,293, - 5,101,0,0,293,295,3,28,14,0,294,292,1,0,0,0,295,298,1,0,0,0,296,294,1, - 0,0,0,296,297,1,0,0,0,297,299,1,0,0,0,298,296,1,0,0,0,299,300,5,100,0, - 0,300,310,1,0,0,0,301,306,3,28,14,0,302,303,5,101,0,0,303,305,3,28,14, - 0,304,302,1,0,0,0,305,308,1,0,0,0,306,304,1,0,0,0,306,307,1,0,0,0,307, - 310,1,0,0,0,308,306,1,0,0,0,309,290,1,0,0,0,309,301,1,0,0,0,310,27,1,0, - 0,0,311,312,3,30,15,0,312,313,3,48,24,0,313,29,1,0,0,0,314,317,3,112,56, - 0,315,317,5,105,0,0,316,314,1,0,0,0,316,315,1,0,0,0,317,31,1,0,0,0,318, - 319,5,45,0,0,319,320,5,39,0,0,320,321,3,112,56,0,321,322,3,34,17,0,322, - 33,1,0,0,0,323,325,5,48,0,0,324,326,5,50,0,0,325,324,1,0,0,0,325,326,1, - 0,0,0,326,327,1,0,0,0,327,381,3,46,23,0,328,329,5,48,0,0,329,381,3,54, - 27,0,330,332,5,45,0,0,331,333,5,50,0,0,332,331,1,0,0,0,332,333,1,0,0,0, - 333,334,1,0,0,0,334,335,3,112,56,0,335,339,3,48,24,0,336,338,3,52,26,0, - 337,336,1,0,0,0,338,341,1,0,0,0,339,337,1,0,0,0,339,340,1,0,0,0,340,381, - 1,0,0,0,341,339,1,0,0,0,342,344,5,45,0,0,343,345,5,50,0,0,344,343,1,0, - 0,0,344,345,1,0,0,0,345,346,1,0,0,0,346,347,3,112,56,0,347,348,5,64,0, - 0,348,349,5,65,0,0,349,350,3,102,51,0,350,381,1,0,0,0,351,353,5,45,0,0, - 352,354,5,50,0,0,353,352,1,0,0,0,353,354,1,0,0,0,354,355,1,0,0,0,355,356, - 3,112,56,0,356,357,5,49,0,0,357,358,5,65,0,0,358,381,1,0,0,0,359,360,5, - 49,0,0,360,361,5,50,0,0,361,381,3,112,56,0,362,363,5,49,0,0,363,364,5, - 56,0,0,364,381,3,112,56,0,365,366,5,46,0,0,366,367,5,47,0,0,367,381,3, - 112,56,0,368,369,5,46,0,0,369,370,5,50,0,0,370,371,3,112,56,0,371,372, - 5,47,0,0,372,373,3,112,56,0,373,381,1,0,0,0,374,375,5,46,0,0,375,376,5, - 68,0,0,376,377,3,112,56,0,377,378,5,47,0,0,378,379,3,112,56,0,379,381, - 1,0,0,0,380,323,1,0,0,0,380,328,1,0,0,0,380,330,1,0,0,0,380,342,1,0,0, - 0,380,351,1,0,0,0,380,359,1,0,0,0,380,362,1,0,0,0,380,365,1,0,0,0,380, - 368,1,0,0,0,380,374,1,0,0,0,381,35,1,0,0,0,382,386,3,64,32,0,383,386,3, - 62,31,0,384,386,3,20,10,0,385,382,1,0,0,0,385,383,1,0,0,0,385,384,1,0, - 0,0,386,37,1,0,0,0,387,389,5,38,0,0,388,390,5,67,0,0,389,388,1,0,0,0,389, - 390,1,0,0,0,390,391,1,0,0,0,391,392,5,68,0,0,392,393,3,112,56,0,393,394, - 5,21,0,0,394,395,3,112,56,0,395,396,5,99,0,0,396,401,3,42,21,0,397,398, - 5,101,0,0,398,400,3,42,21,0,399,397,1,0,0,0,400,403,1,0,0,0,401,399,1, - 0,0,0,401,402,1,0,0,0,402,404,1,0,0,0,403,401,1,0,0,0,404,407,5,100,0, - 0,405,406,5,70,0,0,406,408,3,44,22,0,407,405,1,0,0,0,407,408,1,0,0,0,408, - 39,1,0,0,0,409,410,5,49,0,0,410,411,5,39,0,0,411,425,3,112,56,0,412,413, - 5,49,0,0,413,414,5,68,0,0,414,415,3,112,56,0,415,416,5,21,0,0,416,417, - 3,112,56,0,417,425,1,0,0,0,418,419,5,49,0,0,419,420,5,77,0,0,420,425,3, - 112,56,0,421,422,5,49,0,0,422,423,5,76,0,0,423,425,3,112,56,0,424,409, - 1,0,0,0,424,412,1,0,0,0,424,418,1,0,0,0,424,421,1,0,0,0,425,41,1,0,0,0, - 426,428,3,112,56,0,427,429,7,1,0,0,428,427,1,0,0,0,428,429,1,0,0,0,429, - 43,1,0,0,0,430,436,5,54,0,0,431,432,5,73,0,0,432,436,5,85,0,0,433,434, - 5,74,0,0,434,436,5,85,0,0,435,430,1,0,0,0,435,431,1,0,0,0,435,433,1,0, - 0,0,436,45,1,0,0,0,437,438,3,112,56,0,438,442,3,48,24,0,439,441,3,52,26, - 0,440,439,1,0,0,0,441,444,1,0,0,0,442,440,1,0,0,0,442,443,1,0,0,0,443, - 47,1,0,0,0,444,442,1,0,0,0,445,447,3,112,56,0,446,448,3,112,56,0,447,446, - 1,0,0,0,447,448,1,0,0,0,448,450,1,0,0,0,449,451,3,112,56,0,450,449,1,0, - 0,0,450,451,1,0,0,0,451,460,1,0,0,0,452,453,5,99,0,0,453,456,3,50,25,0, - 454,455,5,101,0,0,455,457,3,50,25,0,456,454,1,0,0,0,456,457,1,0,0,0,457, - 458,1,0,0,0,458,459,5,100,0,0,459,461,1,0,0,0,460,452,1,0,0,0,460,461, - 1,0,0,0,461,49,1,0,0,0,462,464,5,91,0,0,463,462,1,0,0,0,463,464,1,0,0, - 0,464,465,1,0,0,0,465,466,5,107,0,0,466,51,1,0,0,0,467,468,5,8,0,0,468, - 520,5,85,0,0,469,520,5,85,0,0,470,471,5,65,0,0,471,520,3,102,51,0,472, - 473,5,70,0,0,473,520,7,2,0,0,474,475,5,56,0,0,475,477,3,112,56,0,476,474, - 1,0,0,0,476,477,1,0,0,0,477,478,1,0,0,0,478,479,5,75,0,0,479,480,5,99, - 0,0,480,481,3,56,28,0,481,482,5,100,0,0,482,520,1,0,0,0,483,484,5,56,0, - 0,484,486,3,112,56,0,485,483,1,0,0,0,485,486,1,0,0,0,486,487,1,0,0,0,487, - 488,5,54,0,0,488,520,5,55,0,0,489,490,5,56,0,0,490,492,3,112,56,0,491, - 489,1,0,0,0,491,492,1,0,0,0,492,493,1,0,0,0,493,520,5,67,0,0,494,495,5, - 56,0,0,495,497,3,112,56,0,496,494,1,0,0,0,496,497,1,0,0,0,497,498,1,0, - 0,0,498,499,5,58,0,0,499,511,3,112,56,0,500,501,5,99,0,0,501,506,3,112, - 56,0,502,503,5,101,0,0,503,505,3,112,56,0,504,502,1,0,0,0,505,508,1,0, - 0,0,506,504,1,0,0,0,506,507,1,0,0,0,507,509,1,0,0,0,508,506,1,0,0,0,509, - 510,5,100,0,0,510,512,1,0,0,0,511,500,1,0,0,0,511,512,1,0,0,0,512,516, - 1,0,0,0,513,515,3,58,29,0,514,513,1,0,0,0,515,518,1,0,0,0,516,514,1,0, - 0,0,516,517,1,0,0,0,517,520,1,0,0,0,518,516,1,0,0,0,519,467,1,0,0,0,519, - 469,1,0,0,0,519,470,1,0,0,0,519,472,1,0,0,0,519,476,1,0,0,0,519,485,1, - 0,0,0,519,491,1,0,0,0,519,496,1,0,0,0,520,53,1,0,0,0,521,522,5,56,0,0, - 522,524,3,112,56,0,523,521,1,0,0,0,523,524,1,0,0,0,524,525,1,0,0,0,525, - 526,5,54,0,0,526,527,5,55,0,0,527,528,5,99,0,0,528,533,3,112,56,0,529, - 530,5,101,0,0,530,532,3,112,56,0,531,529,1,0,0,0,532,535,1,0,0,0,533,531, - 1,0,0,0,533,534,1,0,0,0,534,536,1,0,0,0,535,533,1,0,0,0,536,537,5,100, - 0,0,537,605,1,0,0,0,538,539,5,56,0,0,539,541,3,112,56,0,540,538,1,0,0, - 0,540,541,1,0,0,0,541,542,1,0,0,0,542,543,5,67,0,0,543,544,5,99,0,0,544, - 549,3,112,56,0,545,546,5,101,0,0,546,548,3,112,56,0,547,545,1,0,0,0,548, - 551,1,0,0,0,549,547,1,0,0,0,549,550,1,0,0,0,550,552,1,0,0,0,551,549,1, - 0,0,0,552,553,5,100,0,0,553,605,1,0,0,0,554,555,5,56,0,0,555,557,3,112, - 56,0,556,554,1,0,0,0,556,557,1,0,0,0,557,558,1,0,0,0,558,559,5,57,0,0, - 559,562,5,55,0,0,560,561,5,66,0,0,561,563,5,68,0,0,562,560,1,0,0,0,562, - 563,1,0,0,0,563,564,1,0,0,0,564,565,5,99,0,0,565,570,3,112,56,0,566,567, - 5,101,0,0,567,569,3,112,56,0,568,566,1,0,0,0,569,572,1,0,0,0,570,568,1, - 0,0,0,570,571,1,0,0,0,571,573,1,0,0,0,572,570,1,0,0,0,573,574,5,100,0, - 0,574,575,5,58,0,0,575,587,3,112,56,0,576,577,5,99,0,0,577,582,3,112,56, - 0,578,579,5,101,0,0,579,581,3,112,56,0,580,578,1,0,0,0,581,584,1,0,0,0, - 582,580,1,0,0,0,582,583,1,0,0,0,583,585,1,0,0,0,584,582,1,0,0,0,585,586, - 5,100,0,0,586,588,1,0,0,0,587,576,1,0,0,0,587,588,1,0,0,0,588,592,1,0, - 0,0,589,591,3,58,29,0,590,589,1,0,0,0,591,594,1,0,0,0,592,590,1,0,0,0, - 592,593,1,0,0,0,593,605,1,0,0,0,594,592,1,0,0,0,595,596,5,56,0,0,596,598, - 3,112,56,0,597,595,1,0,0,0,597,598,1,0,0,0,598,599,1,0,0,0,599,600,5,75, - 0,0,600,601,5,99,0,0,601,602,3,56,28,0,602,603,5,100,0,0,603,605,1,0,0, - 0,604,523,1,0,0,0,604,540,1,0,0,0,604,556,1,0,0,0,604,597,1,0,0,0,605, - 55,1,0,0,0,606,612,8,3,0,0,607,608,5,99,0,0,608,609,3,56,28,0,609,610, - 5,100,0,0,610,612,1,0,0,0,611,606,1,0,0,0,611,607,1,0,0,0,612,615,1,0, - 0,0,613,611,1,0,0,0,613,614,1,0,0,0,614,57,1,0,0,0,615,613,1,0,0,0,616, - 617,5,21,0,0,617,618,5,60,0,0,618,623,3,60,30,0,619,620,5,21,0,0,620,621, - 5,59,0,0,621,623,3,60,30,0,622,616,1,0,0,0,622,619,1,0,0,0,623,59,1,0, - 0,0,624,633,5,61,0,0,625,626,5,66,0,0,626,633,5,63,0,0,627,633,5,62,0, - 0,628,629,5,64,0,0,629,633,5,85,0,0,630,631,5,64,0,0,631,633,5,65,0,0, - 632,624,1,0,0,0,632,625,1,0,0,0,632,627,1,0,0,0,632,628,1,0,0,0,632,630, - 1,0,0,0,633,61,1,0,0,0,634,635,5,51,0,0,635,636,5,52,0,0,636,667,3,112, - 56,0,637,638,5,99,0,0,638,643,3,112,56,0,639,640,5,101,0,0,640,642,3,112, - 56,0,641,639,1,0,0,0,642,645,1,0,0,0,643,641,1,0,0,0,643,644,1,0,0,0,644, - 646,1,0,0,0,645,643,1,0,0,0,646,647,5,100,0,0,647,649,1,0,0,0,648,637, - 1,0,0,0,648,649,1,0,0,0,649,663,1,0,0,0,650,651,5,53,0,0,651,652,5,99, - 0,0,652,657,3,102,51,0,653,654,5,101,0,0,654,656,3,102,51,0,655,653,1, - 0,0,0,656,659,1,0,0,0,657,655,1,0,0,0,657,658,1,0,0,0,658,660,1,0,0,0, - 659,657,1,0,0,0,660,661,5,100,0,0,661,664,1,0,0,0,662,664,3,64,32,0,663, - 650,1,0,0,0,663,662,1,0,0,0,664,668,1,0,0,0,665,666,5,65,0,0,666,668,5, - 53,0,0,667,648,1,0,0,0,667,665,1,0,0,0,668,63,1,0,0,0,669,675,3,66,33, - 0,670,671,3,68,34,0,671,672,3,66,33,0,672,674,1,0,0,0,673,670,1,0,0,0, - 674,677,1,0,0,0,675,673,1,0,0,0,675,676,1,0,0,0,676,65,1,0,0,0,677,675, - 1,0,0,0,678,684,3,70,35,0,679,680,5,99,0,0,680,681,3,64,32,0,681,682,5, - 100,0,0,682,684,1,0,0,0,683,678,1,0,0,0,683,679,1,0,0,0,684,67,1,0,0,0, - 685,687,5,34,0,0,686,688,5,35,0,0,687,686,1,0,0,0,687,688,1,0,0,0,688, - 692,1,0,0,0,689,692,5,36,0,0,690,692,5,37,0,0,691,685,1,0,0,0,691,689, - 1,0,0,0,691,690,1,0,0,0,692,69,1,0,0,0,693,695,5,1,0,0,694,696,3,72,36, - 0,695,694,1,0,0,0,695,696,1,0,0,0,696,698,1,0,0,0,697,699,3,78,39,0,698, - 697,1,0,0,0,698,699,1,0,0,0,699,700,1,0,0,0,700,703,3,82,41,0,701,702, - 5,52,0,0,702,704,3,112,56,0,703,701,1,0,0,0,703,704,1,0,0,0,704,706,1, - 0,0,0,705,707,3,86,43,0,706,705,1,0,0,0,706,707,1,0,0,0,707,709,1,0,0, - 0,708,710,3,96,48,0,709,708,1,0,0,0,709,710,1,0,0,0,710,712,1,0,0,0,711, - 713,3,74,37,0,712,711,1,0,0,0,712,713,1,0,0,0,713,715,1,0,0,0,714,716, - 3,76,38,0,715,714,1,0,0,0,715,716,1,0,0,0,716,718,1,0,0,0,717,719,3,98, - 49,0,718,717,1,0,0,0,718,719,1,0,0,0,719,71,1,0,0,0,720,721,7,4,0,0,721, - 73,1,0,0,0,722,723,5,23,0,0,723,724,5,25,0,0,724,729,3,102,51,0,725,726, - 5,101,0,0,726,728,3,102,51,0,727,725,1,0,0,0,728,731,1,0,0,0,729,727,1, - 0,0,0,729,730,1,0,0,0,730,75,1,0,0,0,731,729,1,0,0,0,732,733,5,26,0,0, - 733,734,3,102,51,0,734,77,1,0,0,0,735,736,5,4,0,0,736,741,3,80,40,0,737, - 738,7,5,0,0,738,740,3,80,40,0,739,737,1,0,0,0,740,743,1,0,0,0,741,739, - 1,0,0,0,741,742,1,0,0,0,742,745,1,0,0,0,743,741,1,0,0,0,744,746,5,32,0, - 0,745,744,1,0,0,0,745,746,1,0,0,0,746,79,1,0,0,0,747,754,5,107,0,0,748, - 754,5,105,0,0,749,750,5,99,0,0,750,751,3,102,51,0,751,752,5,100,0,0,752, - 754,1,0,0,0,753,747,1,0,0,0,753,748,1,0,0,0,753,749,1,0,0,0,754,81,1,0, - 0,0,755,765,5,86,0,0,756,761,3,84,42,0,757,758,5,101,0,0,758,760,3,84, - 42,0,759,757,1,0,0,0,760,763,1,0,0,0,761,759,1,0,0,0,761,762,1,0,0,0,762, - 765,1,0,0,0,763,761,1,0,0,0,764,755,1,0,0,0,764,756,1,0,0,0,765,83,1,0, - 0,0,766,767,3,112,56,0,767,768,5,102,0,0,768,769,5,86,0,0,769,778,1,0, - 0,0,770,775,3,102,51,0,771,773,5,5,0,0,772,771,1,0,0,0,772,773,1,0,0,0, - 773,774,1,0,0,0,774,776,3,112,56,0,775,772,1,0,0,0,775,776,1,0,0,0,776, - 778,1,0,0,0,777,766,1,0,0,0,777,770,1,0,0,0,778,85,1,0,0,0,779,780,5,2, - 0,0,780,785,3,88,44,0,781,782,5,101,0,0,782,784,3,88,44,0,783,781,1,0, - 0,0,784,787,1,0,0,0,785,783,1,0,0,0,785,786,1,0,0,0,786,87,1,0,0,0,787, - 785,1,0,0,0,788,792,3,90,45,0,789,791,3,92,46,0,790,789,1,0,0,0,791,794, - 1,0,0,0,792,790,1,0,0,0,792,793,1,0,0,0,793,89,1,0,0,0,794,792,1,0,0,0, - 795,800,3,112,56,0,796,798,5,5,0,0,797,796,1,0,0,0,797,798,1,0,0,0,798, - 799,1,0,0,0,799,801,3,112,56,0,800,797,1,0,0,0,800,801,1,0,0,0,801,816, - 1,0,0,0,802,803,5,99,0,0,803,804,3,64,32,0,804,809,5,100,0,0,805,807,5, - 5,0,0,806,805,1,0,0,0,806,807,1,0,0,0,807,808,1,0,0,0,808,810,3,112,56, - 0,809,806,1,0,0,0,809,810,1,0,0,0,810,816,1,0,0,0,811,812,5,99,0,0,812, - 813,3,88,44,0,813,814,5,100,0,0,814,816,1,0,0,0,815,795,1,0,0,0,815,802, - 1,0,0,0,815,811,1,0,0,0,816,91,1,0,0,0,817,818,3,94,47,0,818,819,5,19, - 0,0,819,820,3,90,45,0,820,821,5,21,0,0,821,822,3,102,51,0,822,93,1,0,0, - 0,823,825,5,15,0,0,824,823,1,0,0,0,824,825,1,0,0,0,825,835,1,0,0,0,826, - 828,5,16,0,0,827,829,5,18,0,0,828,827,1,0,0,0,828,829,1,0,0,0,829,835, - 1,0,0,0,830,832,5,17,0,0,831,833,5,18,0,0,832,831,1,0,0,0,832,833,1,0, - 0,0,833,835,1,0,0,0,834,824,1,0,0,0,834,826,1,0,0,0,834,830,1,0,0,0,835, - 95,1,0,0,0,836,837,5,3,0,0,837,838,3,102,51,0,838,97,1,0,0,0,839,840,5, - 22,0,0,840,841,5,25,0,0,841,846,3,100,50,0,842,843,5,101,0,0,843,845,3, - 100,50,0,844,842,1,0,0,0,845,848,1,0,0,0,846,844,1,0,0,0,846,847,1,0,0, - 0,847,99,1,0,0,0,848,846,1,0,0,0,849,851,3,102,51,0,850,852,7,1,0,0,851, - 850,1,0,0,0,851,852,1,0,0,0,852,101,1,0,0,0,853,854,6,51,-1,0,854,855, - 5,8,0,0,855,862,3,102,51,16,856,857,5,12,0,0,857,862,3,102,51,15,858,859, - 5,91,0,0,859,862,3,102,51,14,860,862,3,104,52,0,861,853,1,0,0,0,861,856, - 1,0,0,0,861,858,1,0,0,0,861,860,1,0,0,0,862,932,1,0,0,0,863,864,10,13, - 0,0,864,865,5,89,0,0,865,931,3,102,51,14,866,867,10,12,0,0,867,868,7,6, - 0,0,868,931,3,102,51,13,869,870,10,11,0,0,870,871,7,7,0,0,871,931,3,102, - 51,12,872,873,10,10,0,0,873,874,7,8,0,0,874,931,3,102,51,11,875,877,10, - 9,0,0,876,878,5,8,0,0,877,876,1,0,0,0,877,878,1,0,0,0,878,879,1,0,0,0, - 879,880,5,33,0,0,880,881,3,102,51,0,881,882,5,6,0,0,882,883,3,102,51,10, - 883,931,1,0,0,0,884,886,10,8,0,0,885,887,5,8,0,0,886,885,1,0,0,0,886,887, - 1,0,0,0,887,888,1,0,0,0,888,889,5,13,0,0,889,931,3,102,51,9,890,891,10, - 4,0,0,891,892,7,9,0,0,892,931,3,102,51,5,893,894,10,3,0,0,894,895,5,6, - 0,0,895,931,3,102,51,4,896,897,10,2,0,0,897,898,5,7,0,0,898,931,3,102, - 51,3,899,901,10,7,0,0,900,902,5,8,0,0,901,900,1,0,0,0,901,902,1,0,0,0, - 902,903,1,0,0,0,903,904,5,20,0,0,904,905,5,99,0,0,905,906,3,70,35,0,906, - 907,5,100,0,0,907,931,1,0,0,0,908,910,10,6,0,0,909,911,5,8,0,0,910,909, - 1,0,0,0,910,911,1,0,0,0,911,912,1,0,0,0,912,913,5,20,0,0,913,914,5,99, - 0,0,914,919,3,102,51,0,915,916,5,101,0,0,916,918,3,102,51,0,917,915,1, - 0,0,0,918,921,1,0,0,0,919,917,1,0,0,0,919,920,1,0,0,0,920,922,1,0,0,0, - 921,919,1,0,0,0,922,923,5,100,0,0,923,931,1,0,0,0,924,925,10,5,0,0,925, - 927,5,24,0,0,926,928,5,8,0,0,927,926,1,0,0,0,927,928,1,0,0,0,928,929,1, - 0,0,0,929,931,5,85,0,0,930,863,1,0,0,0,930,866,1,0,0,0,930,869,1,0,0,0, - 930,872,1,0,0,0,930,875,1,0,0,0,930,884,1,0,0,0,930,890,1,0,0,0,930,893, - 1,0,0,0,930,896,1,0,0,0,930,899,1,0,0,0,930,908,1,0,0,0,930,924,1,0,0, - 0,931,934,1,0,0,0,932,930,1,0,0,0,932,933,1,0,0,0,933,103,1,0,0,0,934, - 932,1,0,0,0,935,954,3,114,57,0,936,954,3,106,53,0,937,954,3,110,55,0,938, - 954,5,105,0,0,939,954,5,104,0,0,940,941,5,27,0,0,941,942,5,99,0,0,942, - 943,3,70,35,0,943,944,5,100,0,0,944,954,1,0,0,0,945,946,5,99,0,0,946,947, - 3,70,35,0,947,948,5,100,0,0,948,954,1,0,0,0,949,950,5,99,0,0,950,951,3, - 102,51,0,951,952,5,100,0,0,952,954,1,0,0,0,953,935,1,0,0,0,953,936,1,0, - 0,0,953,937,1,0,0,0,953,938,1,0,0,0,953,939,1,0,0,0,953,940,1,0,0,0,953, - 945,1,0,0,0,953,949,1,0,0,0,954,105,1,0,0,0,955,956,3,108,54,0,956,969, - 5,99,0,0,957,970,5,86,0,0,958,960,5,31,0,0,959,958,1,0,0,0,959,960,1,0, - 0,0,960,961,1,0,0,0,961,966,3,102,51,0,962,963,5,101,0,0,963,965,3,102, - 51,0,964,962,1,0,0,0,965,968,1,0,0,0,966,964,1,0,0,0,966,967,1,0,0,0,967, - 970,1,0,0,0,968,966,1,0,0,0,969,957,1,0,0,0,969,959,1,0,0,0,969,970,1, - 0,0,0,970,971,1,0,0,0,971,972,5,100,0,0,972,107,1,0,0,0,973,978,3,112, - 56,0,974,978,5,16,0,0,975,978,5,17,0,0,976,978,5,81,0,0,977,973,1,0,0, - 0,977,974,1,0,0,0,977,975,1,0,0,0,977,976,1,0,0,0,978,109,1,0,0,0,979, - 980,3,112,56,0,980,981,5,102,0,0,981,983,1,0,0,0,982,979,1,0,0,0,982,983, - 1,0,0,0,983,984,1,0,0,0,984,985,3,112,56,0,985,111,1,0,0,0,986,987,7,10, - 0,0,987,113,1,0,0,0,988,998,5,107,0,0,989,998,5,108,0,0,990,998,5,106, - 0,0,991,998,5,109,0,0,992,998,5,110,0,0,993,998,5,111,0,0,994,998,5,83, - 0,0,995,998,5,84,0,0,996,998,5,85,0,0,997,988,1,0,0,0,997,989,1,0,0,0, - 997,990,1,0,0,0,997,991,1,0,0,0,997,992,1,0,0,0,997,993,1,0,0,0,997,994, - 1,0,0,0,997,995,1,0,0,0,997,996,1,0,0,0,998,115,1,0,0,0,999,1001,5,40, - 0,0,1000,1002,7,11,0,0,1001,1000,1,0,0,0,1001,1002,1,0,0,0,1002,1012,1, - 0,0,0,1003,1005,5,41,0,0,1004,1006,7,11,0,0,1005,1004,1,0,0,0,1005,1006, - 1,0,0,0,1006,1012,1,0,0,0,1007,1009,5,42,0,0,1008,1010,7,11,0,0,1009,1008, - 1,0,0,0,1009,1010,1,0,0,0,1010,1012,1,0,0,0,1011,999,1,0,0,0,1011,1003, - 1,0,0,0,1011,1007,1,0,0,0,1012,117,1,0,0,0,1013,1014,3,102,51,0,1014,1015, - 5,0,0,1,1015,119,1,0,0,0,127,121,137,140,146,166,175,178,188,192,204,209, - 217,222,225,233,240,250,257,271,276,285,296,306,309,316,325,332,339,344, - 353,380,385,389,401,407,424,428,435,442,447,450,456,460,463,476,485,491, - 496,506,511,516,519,523,533,540,549,556,562,570,582,587,592,597,604,611, - 613,622,632,643,648,657,663,667,675,683,687,691,695,698,703,706,709,712, - 715,718,729,741,745,753,761,764,772,775,777,785,792,797,800,806,809,815, - 824,828,832,834,846,851,861,877,886,901,910,919,927,930,932,953,959,966, - 969,977,982,997,1001,1005,1009,1011 + 8,47,1,47,1,47,3,47,833,8,47,1,47,1,47,3,47,837,8,47,3,47,839,8,47,1,48, + 1,48,1,48,1,49,1,49,1,49,1,49,1,49,5,49,849,8,49,10,49,12,49,852,9,49, + 1,50,1,50,3,50,856,8,50,1,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,3,51,866, + 8,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,882,8,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,3,51,891,8,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,906, + 8,51,1,51,1,51,1,51,1,51,1,51,1,51,1,51,3,51,915,8,51,1,51,1,51,1,51,1, + 51,1,51,5,51,922,8,51,10,51,12,51,925,9,51,1,51,1,51,1,51,1,51,1,51,3, + 51,932,8,51,1,51,5,51,935,8,51,10,51,12,51,938,9,51,1,52,1,52,1,52,1,52, + 1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52,1,52, + 3,52,958,8,52,1,53,1,53,1,53,1,53,3,53,964,8,53,1,53,1,53,1,53,5,53,969, + 8,53,10,53,12,53,972,9,53,3,53,974,8,53,1,53,1,53,1,54,1,54,1,54,1,54, + 3,54,982,8,54,1,55,1,55,1,55,3,55,987,8,55,1,55,1,55,1,56,1,56,1,57,1, + 57,1,57,1,57,1,57,1,57,1,57,1,57,1,57,3,57,1002,8,57,1,58,1,58,3,58,1006, + 8,58,1,58,1,58,3,58,1010,8,58,1,58,1,58,3,58,1014,8,58,3,58,1016,8,58, + 1,59,1,59,1,59,1,59,0,1,102,60,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, + 0,12,1,0,80,81,1,0,82,83,1,0,72,73,1,0,100,101,2,0,31,32,36,36,1,0,91, + 92,2,0,14,14,87,89,1,0,91,93,1,0,94,99,1,0,9,11,1,0,113,115,1,0,44,45, + 1167,0,121,1,0,0,0,2,144,1,0,0,0,4,166,1,0,0,0,6,168,1,0,0,0,8,180,1,0, + 0,0,10,194,1,0,0,0,12,198,1,0,0,0,14,211,1,0,0,0,16,220,1,0,0,0,18,227, + 1,0,0,0,20,238,1,0,0,0,22,262,1,0,0,0,24,281,1,0,0,0,26,309,1,0,0,0,28, + 311,1,0,0,0,30,316,1,0,0,0,32,318,1,0,0,0,34,380,1,0,0,0,36,385,1,0,0, + 0,38,387,1,0,0,0,40,424,1,0,0,0,42,426,1,0,0,0,44,435,1,0,0,0,46,437,1, + 0,0,0,48,445,1,0,0,0,50,463,1,0,0,0,52,519,1,0,0,0,54,604,1,0,0,0,56,613, + 1,0,0,0,58,622,1,0,0,0,60,632,1,0,0,0,62,634,1,0,0,0,64,669,1,0,0,0,66, + 683,1,0,0,0,68,691,1,0,0,0,70,693,1,0,0,0,72,720,1,0,0,0,74,722,1,0,0, + 0,76,732,1,0,0,0,78,735,1,0,0,0,80,753,1,0,0,0,82,764,1,0,0,0,84,777,1, + 0,0,0,86,779,1,0,0,0,88,788,1,0,0,0,90,815,1,0,0,0,92,817,1,0,0,0,94,838, + 1,0,0,0,96,840,1,0,0,0,98,843,1,0,0,0,100,853,1,0,0,0,102,865,1,0,0,0, + 104,957,1,0,0,0,106,959,1,0,0,0,108,981,1,0,0,0,110,986,1,0,0,0,112,990, + 1,0,0,0,114,1001,1,0,0,0,116,1015,1,0,0,0,118,1017,1,0,0,0,120,122,3,18, + 9,0,121,120,1,0,0,0,121,122,1,0,0,0,122,137,1,0,0,0,123,138,3,2,1,0,124, + 138,3,20,10,0,125,138,3,38,19,0,126,138,3,22,11,0,127,138,3,24,12,0,128, + 138,3,32,16,0,129,138,3,40,20,0,130,138,3,62,31,0,131,138,3,8,4,0,132, + 138,3,12,6,0,133,138,3,116,58,0,134,138,3,6,3,0,135,138,3,14,7,0,136,138, + 3,64,32,0,137,123,1,0,0,0,137,124,1,0,0,0,137,125,1,0,0,0,137,126,1,0, + 0,0,137,127,1,0,0,0,137,128,1,0,0,0,137,129,1,0,0,0,137,130,1,0,0,0,137, + 131,1,0,0,0,137,132,1,0,0,0,137,133,1,0,0,0,137,134,1,0,0,0,137,135,1, + 0,0,0,137,136,1,0,0,0,138,140,1,0,0,0,139,141,5,104,0,0,140,139,1,0,0, + 0,140,141,1,0,0,0,141,142,1,0,0,0,142,143,5,0,0,1,143,1,1,0,0,0,144,146, + 5,29,0,0,145,147,5,8,0,0,146,145,1,0,0,0,146,147,1,0,0,0,147,148,1,0,0, + 0,148,149,5,28,0,0,149,150,5,100,0,0,150,151,3,70,35,0,151,152,5,101,0, + 0,152,153,5,30,0,0,153,154,3,4,2,0,154,3,1,0,0,0,155,167,3,20,10,0,156, + 167,3,38,19,0,157,167,3,22,11,0,158,167,3,24,12,0,159,167,3,32,16,0,160, + 167,3,40,20,0,161,167,3,62,31,0,162,167,3,8,4,0,163,167,3,12,6,0,164,167, + 3,6,3,0,165,167,3,64,32,0,166,155,1,0,0,0,166,156,1,0,0,0,166,157,1,0, + 0,0,166,158,1,0,0,0,166,159,1,0,0,0,166,160,1,0,0,0,166,161,1,0,0,0,166, + 162,1,0,0,0,166,163,1,0,0,0,166,164,1,0,0,0,166,165,1,0,0,0,167,5,1,0, + 0,0,168,169,7,0,0,0,169,178,3,112,56,0,170,175,3,102,51,0,171,172,5,102, + 0,0,172,174,3,102,51,0,173,171,1,0,0,0,174,177,1,0,0,0,175,173,1,0,0,0, + 175,176,1,0,0,0,176,179,1,0,0,0,177,175,1,0,0,0,178,170,1,0,0,0,178,179, + 1,0,0,0,179,7,1,0,0,0,180,181,5,61,0,0,181,182,3,88,44,0,182,183,5,65, + 0,0,183,188,3,10,5,0,184,185,5,102,0,0,185,187,3,10,5,0,186,184,1,0,0, + 0,187,190,1,0,0,0,188,186,1,0,0,0,188,189,1,0,0,0,189,192,1,0,0,0,190, + 188,1,0,0,0,191,193,3,96,48,0,192,191,1,0,0,0,192,193,1,0,0,0,193,9,1, + 0,0,0,194,195,3,110,55,0,195,196,5,94,0,0,196,197,3,102,51,0,197,11,1, + 0,0,0,198,204,5,60,0,0,199,200,3,112,56,0,200,201,5,103,0,0,201,202,5, + 87,0,0,202,205,1,0,0,0,203,205,5,87,0,0,204,199,1,0,0,0,204,203,1,0,0, + 0,204,205,1,0,0,0,205,206,1,0,0,0,206,207,5,2,0,0,207,209,3,88,44,0,208, + 210,3,96,48,0,209,208,1,0,0,0,209,210,1,0,0,0,210,13,1,0,0,0,211,212,5, + 1,0,0,212,217,3,16,8,0,213,214,5,102,0,0,214,216,3,16,8,0,215,213,1,0, + 0,0,216,219,1,0,0,0,217,215,1,0,0,0,217,218,1,0,0,0,218,15,1,0,0,0,219, + 217,1,0,0,0,220,225,5,105,0,0,221,223,5,5,0,0,222,221,1,0,0,0,222,223, + 1,0,0,0,223,224,1,0,0,0,224,226,3,112,56,0,225,222,1,0,0,0,225,226,1,0, + 0,0,226,17,1,0,0,0,227,228,5,79,0,0,228,233,3,28,14,0,229,230,5,102,0, + 0,230,232,3,28,14,0,231,229,1,0,0,0,232,235,1,0,0,0,233,231,1,0,0,0,233, + 234,1,0,0,0,234,236,1,0,0,0,235,233,1,0,0,0,236,237,5,104,0,0,237,19,1, + 0,0,0,238,240,5,39,0,0,239,241,5,70,0,0,240,239,1,0,0,0,240,241,1,0,0, + 0,241,242,1,0,0,0,242,243,5,40,0,0,243,244,3,112,56,0,244,245,5,100,0, + 0,245,250,3,46,23,0,246,247,5,102,0,0,247,249,3,46,23,0,248,246,1,0,0, + 0,249,252,1,0,0,0,250,248,1,0,0,0,250,251,1,0,0,0,251,257,1,0,0,0,252, + 250,1,0,0,0,253,254,5,102,0,0,254,256,3,54,27,0,255,253,1,0,0,0,256,259, + 1,0,0,0,257,255,1,0,0,0,257,258,1,0,0,0,258,260,1,0,0,0,259,257,1,0,0, + 0,260,261,5,101,0,0,261,21,1,0,0,0,262,263,5,39,0,0,263,264,5,77,0,0,264, + 276,3,112,56,0,265,266,5,100,0,0,266,271,3,112,56,0,267,268,5,102,0,0, + 268,270,3,112,56,0,269,267,1,0,0,0,270,273,1,0,0,0,271,269,1,0,0,0,271, + 272,1,0,0,0,272,274,1,0,0,0,273,271,1,0,0,0,274,275,5,101,0,0,275,277, + 1,0,0,0,276,265,1,0,0,0,276,277,1,0,0,0,277,278,1,0,0,0,278,279,5,5,0, + 0,279,280,3,64,32,0,280,23,1,0,0,0,281,282,5,39,0,0,282,283,5,78,0,0,283, + 285,3,112,56,0,284,286,3,26,13,0,285,284,1,0,0,0,285,286,1,0,0,0,286,287, + 1,0,0,0,287,288,5,5,0,0,288,289,3,36,18,0,289,25,1,0,0,0,290,291,5,100, + 0,0,291,296,3,28,14,0,292,293,5,102,0,0,293,295,3,28,14,0,294,292,1,0, + 0,0,295,298,1,0,0,0,296,294,1,0,0,0,296,297,1,0,0,0,297,299,1,0,0,0,298, + 296,1,0,0,0,299,300,5,101,0,0,300,310,1,0,0,0,301,306,3,28,14,0,302,303, + 5,102,0,0,303,305,3,28,14,0,304,302,1,0,0,0,305,308,1,0,0,0,306,304,1, + 0,0,0,306,307,1,0,0,0,307,310,1,0,0,0,308,306,1,0,0,0,309,290,1,0,0,0, + 309,301,1,0,0,0,310,27,1,0,0,0,311,312,3,30,15,0,312,313,3,48,24,0,313, + 29,1,0,0,0,314,317,3,112,56,0,315,317,5,106,0,0,316,314,1,0,0,0,316,315, + 1,0,0,0,317,31,1,0,0,0,318,319,5,46,0,0,319,320,5,40,0,0,320,321,3,112, + 56,0,321,322,3,34,17,0,322,33,1,0,0,0,323,325,5,49,0,0,324,326,5,51,0, + 0,325,324,1,0,0,0,325,326,1,0,0,0,326,327,1,0,0,0,327,381,3,46,23,0,328, + 329,5,49,0,0,329,381,3,54,27,0,330,332,5,46,0,0,331,333,5,51,0,0,332,331, + 1,0,0,0,332,333,1,0,0,0,333,334,1,0,0,0,334,335,3,112,56,0,335,339,3,48, + 24,0,336,338,3,52,26,0,337,336,1,0,0,0,338,341,1,0,0,0,339,337,1,0,0,0, + 339,340,1,0,0,0,340,381,1,0,0,0,341,339,1,0,0,0,342,344,5,46,0,0,343,345, + 5,51,0,0,344,343,1,0,0,0,344,345,1,0,0,0,345,346,1,0,0,0,346,347,3,112, + 56,0,347,348,5,65,0,0,348,349,5,66,0,0,349,350,3,102,51,0,350,381,1,0, + 0,0,351,353,5,46,0,0,352,354,5,51,0,0,353,352,1,0,0,0,353,354,1,0,0,0, + 354,355,1,0,0,0,355,356,3,112,56,0,356,357,5,50,0,0,357,358,5,66,0,0,358, + 381,1,0,0,0,359,360,5,50,0,0,360,361,5,51,0,0,361,381,3,112,56,0,362,363, + 5,50,0,0,363,364,5,57,0,0,364,381,3,112,56,0,365,366,5,47,0,0,366,367, + 5,48,0,0,367,381,3,112,56,0,368,369,5,47,0,0,369,370,5,51,0,0,370,371, + 3,112,56,0,371,372,5,48,0,0,372,373,3,112,56,0,373,381,1,0,0,0,374,375, + 5,47,0,0,375,376,5,69,0,0,376,377,3,112,56,0,377,378,5,48,0,0,378,379, + 3,112,56,0,379,381,1,0,0,0,380,323,1,0,0,0,380,328,1,0,0,0,380,330,1,0, + 0,0,380,342,1,0,0,0,380,351,1,0,0,0,380,359,1,0,0,0,380,362,1,0,0,0,380, + 365,1,0,0,0,380,368,1,0,0,0,380,374,1,0,0,0,381,35,1,0,0,0,382,386,3,64, + 32,0,383,386,3,62,31,0,384,386,3,20,10,0,385,382,1,0,0,0,385,383,1,0,0, + 0,385,384,1,0,0,0,386,37,1,0,0,0,387,389,5,39,0,0,388,390,5,68,0,0,389, + 388,1,0,0,0,389,390,1,0,0,0,390,391,1,0,0,0,391,392,5,69,0,0,392,393,3, + 112,56,0,393,394,5,22,0,0,394,395,3,112,56,0,395,396,5,100,0,0,396,401, + 3,42,21,0,397,398,5,102,0,0,398,400,3,42,21,0,399,397,1,0,0,0,400,403, + 1,0,0,0,401,399,1,0,0,0,401,402,1,0,0,0,402,404,1,0,0,0,403,401,1,0,0, + 0,404,407,5,101,0,0,405,406,5,71,0,0,406,408,3,44,22,0,407,405,1,0,0,0, + 407,408,1,0,0,0,408,39,1,0,0,0,409,410,5,50,0,0,410,411,5,40,0,0,411,425, + 3,112,56,0,412,413,5,50,0,0,413,414,5,69,0,0,414,415,3,112,56,0,415,416, + 5,22,0,0,416,417,3,112,56,0,417,425,1,0,0,0,418,419,5,50,0,0,419,420,5, + 78,0,0,420,425,3,112,56,0,421,422,5,50,0,0,422,423,5,77,0,0,423,425,3, + 112,56,0,424,409,1,0,0,0,424,412,1,0,0,0,424,418,1,0,0,0,424,421,1,0,0, + 0,425,41,1,0,0,0,426,428,3,112,56,0,427,429,7,1,0,0,428,427,1,0,0,0,428, + 429,1,0,0,0,429,43,1,0,0,0,430,436,5,55,0,0,431,432,5,74,0,0,432,436,5, + 86,0,0,433,434,5,75,0,0,434,436,5,86,0,0,435,430,1,0,0,0,435,431,1,0,0, + 0,435,433,1,0,0,0,436,45,1,0,0,0,437,438,3,112,56,0,438,442,3,48,24,0, + 439,441,3,52,26,0,440,439,1,0,0,0,441,444,1,0,0,0,442,440,1,0,0,0,442, + 443,1,0,0,0,443,47,1,0,0,0,444,442,1,0,0,0,445,447,3,112,56,0,446,448, + 3,112,56,0,447,446,1,0,0,0,447,448,1,0,0,0,448,450,1,0,0,0,449,451,3,112, + 56,0,450,449,1,0,0,0,450,451,1,0,0,0,451,460,1,0,0,0,452,453,5,100,0,0, + 453,456,3,50,25,0,454,455,5,102,0,0,455,457,3,50,25,0,456,454,1,0,0,0, + 456,457,1,0,0,0,457,458,1,0,0,0,458,459,5,101,0,0,459,461,1,0,0,0,460, + 452,1,0,0,0,460,461,1,0,0,0,461,49,1,0,0,0,462,464,5,92,0,0,463,462,1, + 0,0,0,463,464,1,0,0,0,464,465,1,0,0,0,465,466,5,108,0,0,466,51,1,0,0,0, + 467,468,5,8,0,0,468,520,5,86,0,0,469,520,5,86,0,0,470,471,5,66,0,0,471, + 520,3,102,51,0,472,473,5,71,0,0,473,520,7,2,0,0,474,475,5,57,0,0,475,477, + 3,112,56,0,476,474,1,0,0,0,476,477,1,0,0,0,477,478,1,0,0,0,478,479,5,76, + 0,0,479,480,5,100,0,0,480,481,3,56,28,0,481,482,5,101,0,0,482,520,1,0, + 0,0,483,484,5,57,0,0,484,486,3,112,56,0,485,483,1,0,0,0,485,486,1,0,0, + 0,486,487,1,0,0,0,487,488,5,55,0,0,488,520,5,56,0,0,489,490,5,57,0,0,490, + 492,3,112,56,0,491,489,1,0,0,0,491,492,1,0,0,0,492,493,1,0,0,0,493,520, + 5,68,0,0,494,495,5,57,0,0,495,497,3,112,56,0,496,494,1,0,0,0,496,497,1, + 0,0,0,497,498,1,0,0,0,498,499,5,59,0,0,499,511,3,112,56,0,500,501,5,100, + 0,0,501,506,3,112,56,0,502,503,5,102,0,0,503,505,3,112,56,0,504,502,1, + 0,0,0,505,508,1,0,0,0,506,504,1,0,0,0,506,507,1,0,0,0,507,509,1,0,0,0, + 508,506,1,0,0,0,509,510,5,101,0,0,510,512,1,0,0,0,511,500,1,0,0,0,511, + 512,1,0,0,0,512,516,1,0,0,0,513,515,3,58,29,0,514,513,1,0,0,0,515,518, + 1,0,0,0,516,514,1,0,0,0,516,517,1,0,0,0,517,520,1,0,0,0,518,516,1,0,0, + 0,519,467,1,0,0,0,519,469,1,0,0,0,519,470,1,0,0,0,519,472,1,0,0,0,519, + 476,1,0,0,0,519,485,1,0,0,0,519,491,1,0,0,0,519,496,1,0,0,0,520,53,1,0, + 0,0,521,522,5,57,0,0,522,524,3,112,56,0,523,521,1,0,0,0,523,524,1,0,0, + 0,524,525,1,0,0,0,525,526,5,55,0,0,526,527,5,56,0,0,527,528,5,100,0,0, + 528,533,3,112,56,0,529,530,5,102,0,0,530,532,3,112,56,0,531,529,1,0,0, + 0,532,535,1,0,0,0,533,531,1,0,0,0,533,534,1,0,0,0,534,536,1,0,0,0,535, + 533,1,0,0,0,536,537,5,101,0,0,537,605,1,0,0,0,538,539,5,57,0,0,539,541, + 3,112,56,0,540,538,1,0,0,0,540,541,1,0,0,0,541,542,1,0,0,0,542,543,5,68, + 0,0,543,544,5,100,0,0,544,549,3,112,56,0,545,546,5,102,0,0,546,548,3,112, + 56,0,547,545,1,0,0,0,548,551,1,0,0,0,549,547,1,0,0,0,549,550,1,0,0,0,550, + 552,1,0,0,0,551,549,1,0,0,0,552,553,5,101,0,0,553,605,1,0,0,0,554,555, + 5,57,0,0,555,557,3,112,56,0,556,554,1,0,0,0,556,557,1,0,0,0,557,558,1, + 0,0,0,558,559,5,58,0,0,559,562,5,56,0,0,560,561,5,67,0,0,561,563,5,69, + 0,0,562,560,1,0,0,0,562,563,1,0,0,0,563,564,1,0,0,0,564,565,5,100,0,0, + 565,570,3,112,56,0,566,567,5,102,0,0,567,569,3,112,56,0,568,566,1,0,0, + 0,569,572,1,0,0,0,570,568,1,0,0,0,570,571,1,0,0,0,571,573,1,0,0,0,572, + 570,1,0,0,0,573,574,5,101,0,0,574,575,5,59,0,0,575,587,3,112,56,0,576, + 577,5,100,0,0,577,582,3,112,56,0,578,579,5,102,0,0,579,581,3,112,56,0, + 580,578,1,0,0,0,581,584,1,0,0,0,582,580,1,0,0,0,582,583,1,0,0,0,583,585, + 1,0,0,0,584,582,1,0,0,0,585,586,5,101,0,0,586,588,1,0,0,0,587,576,1,0, + 0,0,587,588,1,0,0,0,588,592,1,0,0,0,589,591,3,58,29,0,590,589,1,0,0,0, + 591,594,1,0,0,0,592,590,1,0,0,0,592,593,1,0,0,0,593,605,1,0,0,0,594,592, + 1,0,0,0,595,596,5,57,0,0,596,598,3,112,56,0,597,595,1,0,0,0,597,598,1, + 0,0,0,598,599,1,0,0,0,599,600,5,76,0,0,600,601,5,100,0,0,601,602,3,56, + 28,0,602,603,5,101,0,0,603,605,1,0,0,0,604,523,1,0,0,0,604,540,1,0,0,0, + 604,556,1,0,0,0,604,597,1,0,0,0,605,55,1,0,0,0,606,612,8,3,0,0,607,608, + 5,100,0,0,608,609,3,56,28,0,609,610,5,101,0,0,610,612,1,0,0,0,611,606, + 1,0,0,0,611,607,1,0,0,0,612,615,1,0,0,0,613,611,1,0,0,0,613,614,1,0,0, + 0,614,57,1,0,0,0,615,613,1,0,0,0,616,617,5,22,0,0,617,618,5,61,0,0,618, + 623,3,60,30,0,619,620,5,22,0,0,620,621,5,60,0,0,621,623,3,60,30,0,622, + 616,1,0,0,0,622,619,1,0,0,0,623,59,1,0,0,0,624,633,5,62,0,0,625,626,5, + 67,0,0,626,633,5,64,0,0,627,633,5,63,0,0,628,629,5,65,0,0,629,633,5,86, + 0,0,630,631,5,65,0,0,631,633,5,66,0,0,632,624,1,0,0,0,632,625,1,0,0,0, + 632,627,1,0,0,0,632,628,1,0,0,0,632,630,1,0,0,0,633,61,1,0,0,0,634,635, + 5,52,0,0,635,636,5,53,0,0,636,667,3,112,56,0,637,638,5,100,0,0,638,643, + 3,112,56,0,639,640,5,102,0,0,640,642,3,112,56,0,641,639,1,0,0,0,642,645, + 1,0,0,0,643,641,1,0,0,0,643,644,1,0,0,0,644,646,1,0,0,0,645,643,1,0,0, + 0,646,647,5,101,0,0,647,649,1,0,0,0,648,637,1,0,0,0,648,649,1,0,0,0,649, + 663,1,0,0,0,650,651,5,54,0,0,651,652,5,100,0,0,652,657,3,102,51,0,653, + 654,5,102,0,0,654,656,3,102,51,0,655,653,1,0,0,0,656,659,1,0,0,0,657,655, + 1,0,0,0,657,658,1,0,0,0,658,660,1,0,0,0,659,657,1,0,0,0,660,661,5,101, + 0,0,661,664,1,0,0,0,662,664,3,64,32,0,663,650,1,0,0,0,663,662,1,0,0,0, + 664,668,1,0,0,0,665,666,5,66,0,0,666,668,5,54,0,0,667,648,1,0,0,0,667, + 665,1,0,0,0,668,63,1,0,0,0,669,675,3,66,33,0,670,671,3,68,34,0,671,672, + 3,66,33,0,672,674,1,0,0,0,673,670,1,0,0,0,674,677,1,0,0,0,675,673,1,0, + 0,0,675,676,1,0,0,0,676,65,1,0,0,0,677,675,1,0,0,0,678,684,3,70,35,0,679, + 680,5,100,0,0,680,681,3,64,32,0,681,682,5,101,0,0,682,684,1,0,0,0,683, + 678,1,0,0,0,683,679,1,0,0,0,684,67,1,0,0,0,685,687,5,35,0,0,686,688,5, + 36,0,0,687,686,1,0,0,0,687,688,1,0,0,0,688,692,1,0,0,0,689,692,5,37,0, + 0,690,692,5,38,0,0,691,685,1,0,0,0,691,689,1,0,0,0,691,690,1,0,0,0,692, + 69,1,0,0,0,693,695,5,1,0,0,694,696,3,72,36,0,695,694,1,0,0,0,695,696,1, + 0,0,0,696,698,1,0,0,0,697,699,3,78,39,0,698,697,1,0,0,0,698,699,1,0,0, + 0,699,700,1,0,0,0,700,703,3,82,41,0,701,702,5,53,0,0,702,704,3,112,56, + 0,703,701,1,0,0,0,703,704,1,0,0,0,704,706,1,0,0,0,705,707,3,86,43,0,706, + 705,1,0,0,0,706,707,1,0,0,0,707,709,1,0,0,0,708,710,3,96,48,0,709,708, + 1,0,0,0,709,710,1,0,0,0,710,712,1,0,0,0,711,713,3,74,37,0,712,711,1,0, + 0,0,712,713,1,0,0,0,713,715,1,0,0,0,714,716,3,76,38,0,715,714,1,0,0,0, + 715,716,1,0,0,0,716,718,1,0,0,0,717,719,3,98,49,0,718,717,1,0,0,0,718, + 719,1,0,0,0,719,71,1,0,0,0,720,721,7,4,0,0,721,73,1,0,0,0,722,723,5,24, + 0,0,723,724,5,26,0,0,724,729,3,102,51,0,725,726,5,102,0,0,726,728,3,102, + 51,0,727,725,1,0,0,0,728,731,1,0,0,0,729,727,1,0,0,0,729,730,1,0,0,0,730, + 75,1,0,0,0,731,729,1,0,0,0,732,733,5,27,0,0,733,734,3,102,51,0,734,77, + 1,0,0,0,735,736,5,4,0,0,736,741,3,80,40,0,737,738,7,5,0,0,738,740,3,80, + 40,0,739,737,1,0,0,0,740,743,1,0,0,0,741,739,1,0,0,0,741,742,1,0,0,0,742, + 745,1,0,0,0,743,741,1,0,0,0,744,746,5,33,0,0,745,744,1,0,0,0,745,746,1, + 0,0,0,746,79,1,0,0,0,747,754,5,108,0,0,748,754,5,106,0,0,749,750,5,100, + 0,0,750,751,3,102,51,0,751,752,5,101,0,0,752,754,1,0,0,0,753,747,1,0,0, + 0,753,748,1,0,0,0,753,749,1,0,0,0,754,81,1,0,0,0,755,765,5,87,0,0,756, + 761,3,84,42,0,757,758,5,102,0,0,758,760,3,84,42,0,759,757,1,0,0,0,760, + 763,1,0,0,0,761,759,1,0,0,0,761,762,1,0,0,0,762,765,1,0,0,0,763,761,1, + 0,0,0,764,755,1,0,0,0,764,756,1,0,0,0,765,83,1,0,0,0,766,767,3,112,56, + 0,767,768,5,103,0,0,768,769,5,87,0,0,769,778,1,0,0,0,770,775,3,102,51, + 0,771,773,5,5,0,0,772,771,1,0,0,0,772,773,1,0,0,0,773,774,1,0,0,0,774, + 776,3,112,56,0,775,772,1,0,0,0,775,776,1,0,0,0,776,778,1,0,0,0,777,766, + 1,0,0,0,777,770,1,0,0,0,778,85,1,0,0,0,779,780,5,2,0,0,780,785,3,88,44, + 0,781,782,5,102,0,0,782,784,3,88,44,0,783,781,1,0,0,0,784,787,1,0,0,0, + 785,783,1,0,0,0,785,786,1,0,0,0,786,87,1,0,0,0,787,785,1,0,0,0,788,792, + 3,90,45,0,789,791,3,92,46,0,790,789,1,0,0,0,791,794,1,0,0,0,792,790,1, + 0,0,0,792,793,1,0,0,0,793,89,1,0,0,0,794,792,1,0,0,0,795,800,3,112,56, + 0,796,798,5,5,0,0,797,796,1,0,0,0,797,798,1,0,0,0,798,799,1,0,0,0,799, + 801,3,112,56,0,800,797,1,0,0,0,800,801,1,0,0,0,801,816,1,0,0,0,802,803, + 5,100,0,0,803,804,3,64,32,0,804,809,5,101,0,0,805,807,5,5,0,0,806,805, + 1,0,0,0,806,807,1,0,0,0,807,808,1,0,0,0,808,810,3,112,56,0,809,806,1,0, + 0,0,809,810,1,0,0,0,810,816,1,0,0,0,811,812,5,100,0,0,812,813,3,88,44, + 0,813,814,5,101,0,0,814,816,1,0,0,0,815,795,1,0,0,0,815,802,1,0,0,0,815, + 811,1,0,0,0,816,91,1,0,0,0,817,818,3,94,47,0,818,819,5,20,0,0,819,820, + 3,90,45,0,820,821,5,22,0,0,821,822,3,102,51,0,822,93,1,0,0,0,823,825,5, + 15,0,0,824,823,1,0,0,0,824,825,1,0,0,0,825,839,1,0,0,0,826,828,5,16,0, + 0,827,829,5,19,0,0,828,827,1,0,0,0,828,829,1,0,0,0,829,839,1,0,0,0,830, + 832,5,17,0,0,831,833,5,19,0,0,832,831,1,0,0,0,832,833,1,0,0,0,833,839, + 1,0,0,0,834,836,5,18,0,0,835,837,5,19,0,0,836,835,1,0,0,0,836,837,1,0, + 0,0,837,839,1,0,0,0,838,824,1,0,0,0,838,826,1,0,0,0,838,830,1,0,0,0,838, + 834,1,0,0,0,839,95,1,0,0,0,840,841,5,3,0,0,841,842,3,102,51,0,842,97,1, + 0,0,0,843,844,5,23,0,0,844,845,5,26,0,0,845,850,3,100,50,0,846,847,5,102, + 0,0,847,849,3,100,50,0,848,846,1,0,0,0,849,852,1,0,0,0,850,848,1,0,0,0, + 850,851,1,0,0,0,851,99,1,0,0,0,852,850,1,0,0,0,853,855,3,102,51,0,854, + 856,7,1,0,0,855,854,1,0,0,0,855,856,1,0,0,0,856,101,1,0,0,0,857,858,6, + 51,-1,0,858,859,5,8,0,0,859,866,3,102,51,16,860,861,5,12,0,0,861,866,3, + 102,51,15,862,863,5,92,0,0,863,866,3,102,51,14,864,866,3,104,52,0,865, + 857,1,0,0,0,865,860,1,0,0,0,865,862,1,0,0,0,865,864,1,0,0,0,866,936,1, + 0,0,0,867,868,10,13,0,0,868,869,5,90,0,0,869,935,3,102,51,14,870,871,10, + 12,0,0,871,872,7,6,0,0,872,935,3,102,51,13,873,874,10,11,0,0,874,875,7, + 7,0,0,875,935,3,102,51,12,876,877,10,10,0,0,877,878,7,8,0,0,878,935,3, + 102,51,11,879,881,10,9,0,0,880,882,5,8,0,0,881,880,1,0,0,0,881,882,1,0, + 0,0,882,883,1,0,0,0,883,884,5,34,0,0,884,885,3,102,51,0,885,886,5,6,0, + 0,886,887,3,102,51,10,887,935,1,0,0,0,888,890,10,8,0,0,889,891,5,8,0,0, + 890,889,1,0,0,0,890,891,1,0,0,0,891,892,1,0,0,0,892,893,5,13,0,0,893,935, + 3,102,51,9,894,895,10,4,0,0,895,896,7,9,0,0,896,935,3,102,51,5,897,898, + 10,3,0,0,898,899,5,6,0,0,899,935,3,102,51,4,900,901,10,2,0,0,901,902,5, + 7,0,0,902,935,3,102,51,3,903,905,10,7,0,0,904,906,5,8,0,0,905,904,1,0, + 0,0,905,906,1,0,0,0,906,907,1,0,0,0,907,908,5,21,0,0,908,909,5,100,0,0, + 909,910,3,70,35,0,910,911,5,101,0,0,911,935,1,0,0,0,912,914,10,6,0,0,913, + 915,5,8,0,0,914,913,1,0,0,0,914,915,1,0,0,0,915,916,1,0,0,0,916,917,5, + 21,0,0,917,918,5,100,0,0,918,923,3,102,51,0,919,920,5,102,0,0,920,922, + 3,102,51,0,921,919,1,0,0,0,922,925,1,0,0,0,923,921,1,0,0,0,923,924,1,0, + 0,0,924,926,1,0,0,0,925,923,1,0,0,0,926,927,5,101,0,0,927,935,1,0,0,0, + 928,929,10,5,0,0,929,931,5,25,0,0,930,932,5,8,0,0,931,930,1,0,0,0,931, + 932,1,0,0,0,932,933,1,0,0,0,933,935,5,86,0,0,934,867,1,0,0,0,934,870,1, + 0,0,0,934,873,1,0,0,0,934,876,1,0,0,0,934,879,1,0,0,0,934,888,1,0,0,0, + 934,894,1,0,0,0,934,897,1,0,0,0,934,900,1,0,0,0,934,903,1,0,0,0,934,912, + 1,0,0,0,934,928,1,0,0,0,935,938,1,0,0,0,936,934,1,0,0,0,936,937,1,0,0, + 0,937,103,1,0,0,0,938,936,1,0,0,0,939,958,3,114,57,0,940,958,3,106,53, + 0,941,958,3,110,55,0,942,958,5,106,0,0,943,958,5,105,0,0,944,945,5,28, + 0,0,945,946,5,100,0,0,946,947,3,70,35,0,947,948,5,101,0,0,948,958,1,0, + 0,0,949,950,5,100,0,0,950,951,3,70,35,0,951,952,5,101,0,0,952,958,1,0, + 0,0,953,954,5,100,0,0,954,955,3,102,51,0,955,956,5,101,0,0,956,958,1,0, + 0,0,957,939,1,0,0,0,957,940,1,0,0,0,957,941,1,0,0,0,957,942,1,0,0,0,957, + 943,1,0,0,0,957,944,1,0,0,0,957,949,1,0,0,0,957,953,1,0,0,0,958,105,1, + 0,0,0,959,960,3,108,54,0,960,973,5,100,0,0,961,974,5,87,0,0,962,964,5, + 32,0,0,963,962,1,0,0,0,963,964,1,0,0,0,964,965,1,0,0,0,965,970,3,102,51, + 0,966,967,5,102,0,0,967,969,3,102,51,0,968,966,1,0,0,0,969,972,1,0,0,0, + 970,968,1,0,0,0,970,971,1,0,0,0,971,974,1,0,0,0,972,970,1,0,0,0,973,961, + 1,0,0,0,973,963,1,0,0,0,973,974,1,0,0,0,974,975,1,0,0,0,975,976,5,101, + 0,0,976,107,1,0,0,0,977,982,3,112,56,0,978,982,5,16,0,0,979,982,5,17,0, + 0,980,982,5,82,0,0,981,977,1,0,0,0,981,978,1,0,0,0,981,979,1,0,0,0,981, + 980,1,0,0,0,982,109,1,0,0,0,983,984,3,112,56,0,984,985,5,103,0,0,985,987, + 1,0,0,0,986,983,1,0,0,0,986,987,1,0,0,0,987,988,1,0,0,0,988,989,3,112, + 56,0,989,111,1,0,0,0,990,991,7,10,0,0,991,113,1,0,0,0,992,1002,5,108,0, + 0,993,1002,5,109,0,0,994,1002,5,107,0,0,995,1002,5,110,0,0,996,1002,5, + 111,0,0,997,1002,5,112,0,0,998,1002,5,84,0,0,999,1002,5,85,0,0,1000,1002, + 5,86,0,0,1001,992,1,0,0,0,1001,993,1,0,0,0,1001,994,1,0,0,0,1001,995,1, + 0,0,0,1001,996,1,0,0,0,1001,997,1,0,0,0,1001,998,1,0,0,0,1001,999,1,0, + 0,0,1001,1000,1,0,0,0,1002,115,1,0,0,0,1003,1005,5,41,0,0,1004,1006,7, + 11,0,0,1005,1004,1,0,0,0,1005,1006,1,0,0,0,1006,1016,1,0,0,0,1007,1009, + 5,42,0,0,1008,1010,7,11,0,0,1009,1008,1,0,0,0,1009,1010,1,0,0,0,1010,1016, + 1,0,0,0,1011,1013,5,43,0,0,1012,1014,7,11,0,0,1013,1012,1,0,0,0,1013,1014, + 1,0,0,0,1014,1016,1,0,0,0,1015,1003,1,0,0,0,1015,1007,1,0,0,0,1015,1011, + 1,0,0,0,1016,117,1,0,0,0,1017,1018,3,102,51,0,1018,1019,5,0,0,1,1019,119, + 1,0,0,0,128,121,137,140,146,166,175,178,188,192,204,209,217,222,225,233, + 240,250,257,271,276,285,296,306,309,316,325,332,339,344,353,380,385,389, + 401,407,424,428,435,442,447,450,456,460,463,476,485,491,496,506,511,516, + 519,523,533,540,549,556,562,570,582,587,592,597,604,611,613,622,632,643, + 648,657,663,667,675,683,687,691,695,698,703,706,709,712,715,718,729,741, + 745,753,761,764,772,775,777,785,792,797,800,806,809,815,824,828,832,836, + 838,850,855,865,881,890,905,914,923,931,934,936,957,963,970,973,981,986, + 1001,1005,1009,1013,1015 }; 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 f4739972..a8f16505 100644 --- a/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlVisitor.cs +++ b/src/LibRed/LibRed.Sql/Grammar/Generated/AccessSqlVisitor.cs @@ -577,6 +577,13 @@ public interface IAccessSqlVisitor : IParseTreeVisitor { /// The visitor result. Result VisitRightJoin([NotNull] AccessSqlParser.RightJoinContext context); /// + /// Visit a parse tree produced by the FullJoin + /// labeled alternative in . + /// + /// The parse tree. + /// The visitor result. + Result VisitFullJoin([NotNull] AccessSqlParser.FullJoinContext context); + /// /// Visit a parse tree produced by . /// /// The parse tree. diff --git a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs index 0bf0cb91..e0587b06 100644 --- a/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs +++ b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs @@ -545,6 +545,11 @@ private static (ViewSource Source, string Alias) BuildViewSource(TablePrimaryCon { LeftJoinContext => ViewJoinKind.Left, RightJoinContext => ViewJoinKind.Right, + // A view is stored in Access's own query format, which has no full outer join to encode - so unlike a + // FULL JOIN executed directly, this one cannot be represented on disk. Refuse rather than silently + // storing the INNER the fall-through would otherwise pick. + FullJoinContext => throw new NotSupportedException( + "A FULL JOIN cannot be stored in a view: the Access query format has no representation for it."), _ => ViewJoinKind.Inner, }; @@ -710,6 +715,7 @@ private static TableReference BuildTableSource(TableSourceContext ctx) { LeftJoinContext => JoinKind.Left, RightJoinContext => JoinKind.Right, + FullJoinContext => JoinKind.Full, _ => JoinKind.Inner, }; diff --git a/test/EFCore.LibRed.FunctionalTests/Query/NorthwindJoinQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/NorthwindJoinQueryLibRedTest.cs index ebfeeca9..c71febc9 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/NorthwindJoinQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/NorthwindJoinQueryLibRedTest.cs @@ -284,9 +284,9 @@ public override async Task FullJoin(bool async) AssertSql( """ -SELECT [c].[CustomerID], [c].[Address], [c].[City], [c].[CompanyName], [c].[ContactName], [c].[ContactTitle], [c].[Country], [c].[Fax], [c].[Phone], [c].[PostalCode], [c].[Region], [o].[OrderID], [o].[CustomerID], [o].[EmployeeID], [o].[OrderDate] -FROM [Customers] AS [c] -FULL JOIN [Orders] AS [o] ON [c].[CustomerID] = [o].[CustomerID] +SELECT `c`.`CustomerID`, `c`.`Address`, `c`.`City`, `c`.`CompanyName`, `c`.`ContactName`, `c`.`ContactTitle`, `c`.`Country`, `c`.`Fax`, `c`.`Phone`, `c`.`PostalCode`, `c`.`Region`, `o`.`OrderID`, `o`.`CustomerID`, `o`.`EmployeeID`, `o`.`OrderDate` +FROM `Customers` AS `c` +FULL JOIN `Orders` AS `o` ON `c`.`CustomerID` = `o`.`CustomerID` """); } @@ -296,17 +296,17 @@ public override async Task FullJoin_with_unmatched_rows_on_both_sides(bool async AssertSql( """ -SELECT [c0].[CustomerID], [c0].[Address], [c0].[City], [c0].[CompanyName], [c0].[ContactName], [c0].[ContactTitle], [c0].[Country], [c0].[Fax], [c0].[Phone], [c0].[PostalCode], [c0].[Region], [o0].[OrderID], [o0].[CustomerID], [o0].[EmployeeID], [o0].[OrderDate] +SELECT `c0`.`CustomerID`, `c0`.`Address`, `c0`.`City`, `c0`.`CompanyName`, `c0`.`ContactName`, `c0`.`ContactTitle`, `c0`.`Country`, `c0`.`Fax`, `c0`.`Phone`, `c0`.`PostalCode`, `c0`.`Region`, `o0`.`OrderID`, `o0`.`CustomerID`, `o0`.`EmployeeID`, `o0`.`OrderDate` FROM ( - SELECT [c].[CustomerID], [c].[Address], [c].[City], [c].[CompanyName], [c].[ContactName], [c].[ContactTitle], [c].[Country], [c].[Fax], [c].[Phone], [c].[PostalCode], [c].[Region] - FROM [Customers] AS [c] - WHERE [c].[CustomerID] LIKE N'A%' -) AS [c0] + SELECT `c`.`CustomerID`, `c`.`Address`, `c`.`City`, `c`.`CompanyName`, `c`.`ContactName`, `c`.`ContactTitle`, `c`.`Country`, `c`.`Fax`, `c`.`Phone`, `c`.`PostalCode`, `c`.`Region` + FROM `Customers` AS `c` + WHERE `c`.`CustomerID` LIKE 'A%' +) AS `c0` FULL JOIN ( - SELECT [o].[OrderID], [o].[CustomerID], [o].[EmployeeID], [o].[OrderDate] - FROM [Orders] AS [o] - WHERE [o].[CustomerID] LIKE N'B%' -) AS [o0] ON [c0].[CustomerID] = [o0].[CustomerID] + SELECT `o`.`OrderID`, `o`.`CustomerID`, `o`.`EmployeeID`, `o`.`OrderDate` + FROM `Orders` AS `o` + WHERE `o`.`CustomerID` LIKE 'B%' +) AS `o0` ON `c0`.`CustomerID` = `o0`.`CustomerID` """); } diff --git a/test/LibRed.Engine.Tests/FullJoinTests.cs b/test/LibRed.Engine.Tests/FullJoinTests.cs new file mode 100644 index 00000000..f4e3562c --- /dev/null +++ b/test/LibRed.Engine.Tests/FullJoinTests.cs @@ -0,0 +1,127 @@ +using System.Linq; +using LibRed; +using LibRed.Engine; +using LibRed.Engine.Plan; +using Xunit; + +namespace LibRed.Engine.Tests; + +/// +/// FULL [OUTER] JOIN — a LibRed extension, since ACE has no full outer join and no syntax for one. It preserves +/// both sides at once, which is what separates it from every other join the engine runs: the right side's rows +/// have to be tracked across the whole left pass, and on the hash path the build side stops being disposable. +/// +public class FullJoinTests : TempDatabaseTest +{ + // P.Id is a PK (indexed); C.Pid is deliberately NOT indexed, so the equi-join cannot become an + // index-nested-loop and takes the hash path. Exactly one parent has no child and one child has no parent, so + // every count below distinguishes INNER (20) / LEFT (21) / RIGHT (21) / FULL (22). + private static QueryEngine TwoTables() + { + string path = TemporaryDatabase.CopyPath(Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "fulljoin-"); + var e = new QueryEngine(TemporaryDatabase.OpenTracked(path, readOnly: false)); + e.ExecuteNonQuery("CREATE TABLE P (Id LONG PRIMARY KEY, Nm TEXT(20))"); + e.ExecuteNonQuery("CREATE TABLE C (Id LONG PRIMARY KEY, Pid LONG, Amt LONG)"); + for (int i = 0; i < 10; i++) e.ExecuteNonQuery($"INSERT INTO P (Id, Nm) VALUES ({i}, 'p{i}')"); + for (int i = 0; i < 20; i++) e.ExecuteNonQuery($"INSERT INTO C (Id, Pid, Amt) VALUES ({i}, {i % 10}, {i})"); + e.ExecuteNonQuery("INSERT INTO P (Id, Nm) VALUES (99, 'childless')"); // left row with no match + e.ExecuteNonQuery("INSERT INTO C (Id, Pid, Amt) VALUES (99, 777, 999)"); // right row with no match + return e; + } + + private static int Count(QueryEngine e, string sql) => e.ExecuteQuery(sql).Rows.Count(); + + [Fact] + public void Full_join_keeps_the_unmatched_rows_from_both_sides() + { + var e = TwoTables(); + // 20 matched pairs, + the childless P, + the parentless C. + Assert.Equal(20, Count(e, "SELECT P.Nm, C.Amt FROM P INNER JOIN C ON P.Id = C.Pid")); + Assert.Equal(21, Count(e, "SELECT P.Nm, C.Amt FROM P LEFT JOIN C ON P.Id = C.Pid")); + Assert.Equal(21, Count(e, "SELECT P.Nm, C.Amt FROM P RIGHT JOIN C ON P.Id = C.Pid")); + Assert.Equal(22, Count(e, "SELECT P.Nm, C.Amt FROM P FULL JOIN C ON P.Id = C.Pid")); + } + + [Fact] + public void Full_outer_join_is_the_same_as_full_join() + => Assert.Equal( + Count(TwoTables(), "SELECT P.Nm FROM P FULL JOIN C ON P.Id = C.Pid"), + Count(TwoTables(), "SELECT P.Nm FROM P FULL OUTER JOIN C ON P.Id = C.Pid")); + + [Fact] + public void Each_unmatched_side_is_padded_with_nulls_on_the_other() + { + var e = TwoTables(); + // the childless P, with its C side all null + Assert.Equal(1, Count(e, "SELECT P.Nm FROM P FULL JOIN C ON P.Id = C.Pid WHERE C.Id IS NULL")); + // the parentless C, with its P side all null + Assert.Equal(1, Count(e, "SELECT C.Amt FROM P FULL JOIN C ON P.Id = C.Pid WHERE P.Id IS NULL")); + } + + [Fact] + public void Full_join_equals_left_plus_right_minus_inner() + { + var e = TwoTables(); + int inner = Count(e, "SELECT P.Nm FROM P INNER JOIN C ON P.Id = C.Pid"); + int left = Count(e, "SELECT P.Nm FROM P LEFT JOIN C ON P.Id = C.Pid"); + int right = Count(e, "SELECT P.Nm FROM P RIGHT JOIN C ON P.Id = C.Pid"); + int full = Count(e, "SELECT P.Nm FROM P FULL JOIN C ON P.Id = C.Pid"); + Assert.Equal(left + right - inner, full); + } + + [Fact] + public void Full_join_is_symmetric() + { + var e = TwoTables(); + Assert.Equal( + Count(e, "SELECT P.Nm FROM P FULL JOIN C ON P.Id = C.Pid"), + Count(e, "SELECT P.Nm FROM C FULL JOIN P ON P.Id = C.Pid")); + } + + [Fact] + public void An_unindexed_full_equi_join_is_planned_as_a_hash_join() + { + var plan = TwoTables().PlanFor("SELECT P.Nm, C.Amt FROM P FULL JOIN C ON P.Id = C.Pid"); + Assert.True(ContainsHashJoin(plan), "expected a HashJoinNode in the plan"); + } + + [Fact] + public void A_null_key_row_on_the_build_side_is_still_preserved() + { + // The hash build phase drops null-key rows, because a null key can never satisfy an equi-join. Under + // FULL that is precisely a row to emit, not to discard — the regression this guards. + var e = TwoTables(); + e.ExecuteNonQuery("UPDATE C SET Pid = NULL WHERE Id = 3"); + + // C 3 no longer matches (P 3 still does, via C 13), so: 19 matched + 1 childless P + 2 unmatched C. + Assert.Equal(22, Count(e, "SELECT P.Nm, C.Amt FROM P FULL JOIN C ON P.Id = C.Pid")); + // Both unmatched C rows appear with a null P side — the null-key one and the 777 one. + Assert.Equal(2, Count(e, "SELECT C.Id FROM P FULL JOIN C ON P.Id = C.Pid WHERE P.Id IS NULL")); + } + + [Fact] + public void Full_join_preserves_both_sides_on_the_nested_loop_path_too() + { + // No left-column = right-column equality, so there are no hash keys and the join stays a nested loop. + // Nothing matches either, so every row of both tables must come through unmatched: 11 P + 21 C. + var e = TwoTables(); + const string sql = "SELECT P.Nm, C.Amt FROM P FULL JOIN C ON P.Nm = 'nope' AND C.Amt = -1"; + Assert.False(ContainsHashJoin(e.PlanFor(sql)), "expected the nested-loop path, not a hash join"); + Assert.Equal(32, Count(e, sql)); + } + + [Fact] + public void Full_is_a_keyword_so_a_column_of_that_name_needs_quoting() + { + // The cost of the extension: FULL is not reserved in Access, so a real column called "Full" has to be + // bracketed or backticked here — the same tax LEFT, RIGHT and ORDER already charge. + var e = TwoTables(); + e.ExecuteNonQuery("CREATE TABLE K (`Full` TEXT(10))"); + e.ExecuteNonQuery("INSERT INTO K (`Full`) VALUES ('x')"); + Assert.Equal(1, Count(e, "SELECT `Full` FROM K")); + Assert.Equal(1, Count(e, "SELECT [Full] FROM K")); + } + + private static bool ContainsHashJoin(PlanNode node) => + node is HashJoinNode || node.Children.Any(ContainsHashJoin); +} From f37c354371840a1bda72b62c8029fee2fd54582f Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Tue, 25 Aug 2026 20:21:25 +0800 Subject: [PATCH 38/48] Bring the ad-hoc query tests up to upstream's override coverage Upstream e54991e7 ("Fix AssertAllMethodsOverridden for XUnit V3 Fact/Theory", #38570) taught TestHelpers.AssertAllMethodsOverridden to recognise plain [Fact]/[Theory] alongside ConditionalFact/ConditionalTheory. Our TestHelpers already carries that fix, so the stricter check applies here too - it just was not being run in five of the nine files upstream had to touch. Check_all_tests_overridden added to AdHocAdvancedMappings, AdHocManyToMany, AdHocNavigations, AdHocQueryFilters and AdHocQuerySplitting, for both providers, as the last member of the class exactly as upstream places it (plus a using Xunit in the four files that lacked one). The other four files already had it. That surfaced 28 missing overrides per provider, against upstream's 11 - ours also lagged on base tests SQL Server had overridden before that commit: AdHocAdvancedMappings 5 (2 TPC_query_with_generic_derived_types_*, 3 Projecting_*_with_converter_*closure) AdHocNavigations 4 (3 Consecutive_selects_with_conditional_projection_*, Filtered_collection_through_optional_navigation_*) AdHocQueryFilters 13 (the whole #region 8576 Named_query_filters set, plus 6 at the end) AdHocQuerySplitting 6 (plus upstream's private AssertContainsSql helper, which two of them need and we did not have) AdHocManyToMany 0 (the check alone was enough, as upstream) Each is copied verbatim from its SQL Server counterpart and placed at its upstream position rather than appended - the QueryFilters set is a region that sits first in the class, and three of the Navigations ones go mid-file. Baselines came across in SQL Server's bracket form as the not-ported marker and have since been regenerated for both providers where the test actually reaches AssertSql. All Check_all_tests_overridden now pass: 96/96 on each provider. Also here: - Seed2951 used T-SQL's optional INTO ("INSERT ZeroKey VALUES (NULL)"), copied from SQL Server and never adapted; Jet/ACE requires INSERT INTO. That is why Query_when_null_key_in_database_should_throw failed on LibRed and was absent from ACE's green list. Fixed for both. - The millisecond work needed the same component-constructor widening in the GearsOfWar fixtures and LibRedTestHelpers that BasicTypes already had. The scaffolding compiled-model baselines now name LibRedDateTimeTypeMapping, confirming the mapping substitution reaches design-time codegen; GearsOfWar on Jet was unchanged, confirming the work is LibRed-only in effect. - DateTimeOffset AddMilliseconds now emits DATEADD('ms', ...) where the baseline was a bare projection - it used to be client-evaluated, which only worked because it sat in a top-level projection. Known open: Correlated_SelectMany_DefaultIfEmpty_whole_object still fails on both providers. The base asserts the exception message contains "requires the SQL APPLY"; Jet and LibRed correctly refuse OUTER APPLY but do it from the SQL generator with their own "Unsupported Jet expression: OUTER APPLY" instead of EF's canonical provider-capability error. Co-Authored-By: Claude Opus 5 --- .../AdHocAdvancedMappingsQueryJetTest.cs | 82 ++ .../Query/AdHocManyToManyQueryJetTest.cs | 5 + .../Query/AdHocMiscellaneousQueryJetTest.cs | 846 ++++++++++++++++- .../Query/AdHocNavigationsQueryJetTest.cs | 67 ++ .../Query/AdHocQueryFiltersQueryJetTest.cs | 165 ++++ .../Query/AdHocQuerySplittingQueryJetTest.cs | 148 +++ .../LibRedEndToEndTest.cs | 2 +- .../Migrations/MigrationsLibRedTest.cs | 14 +- .../AdHocAdvancedMappingsQueryLibRedTest.cs | 82 ++ .../Query/AdHocManyToManyQueryLibRedTest.cs | 5 + .../AdHocMiscellaneousQueryLibRedTest.cs | 848 +++++++++++++++++- .../Query/AdHocNavigationsQueryLibRedTest.cs | 67 ++ .../Query/AdHocQueryFiltersQueryLibRedTest.cs | 165 ++++ .../AdHocQuerySplittingQueryLibRedTest.cs | 148 +++ .../Query/GearsOfWarQueryLibRedFixture.cs | 2 +- .../Query/GearsOfWarQueryLibRedTest.cs | 6 +- .../NorthwindMiscellaneousQueryLibRedTest.cs | 6 +- .../Query/NorthwindSelectQueryLibRedTest.cs | 4 +- .../Query/TPCGearsOfWarQueryLibRedFixture.cs | 2 +- .../Query/TPCGearsOfWarQueryLibRedTest.cs | 6 +- .../Query/TPTGearsOfWarQueryLibRedFixture.cs | 2 +- .../Query/TPTGearsOfWarQueryLibRedTest.cs | 6 +- .../DateTimeOffsetTranslationsLibRedTest.cs | 2 +- .../Baselines/BigModel/ManyTypesEntityType.cs | 34 +- .../BigModel/OwnedType0EntityType.cs | 3 +- .../Baselines/BigModel/OwnedTypeEntityType.cs | 3 +- .../BigModel/PrincipalBaseEntityType.cs | 3 +- .../ManyTypesEntityType.cs | 34 +- .../OwnedType0EntityType.cs | 2 +- .../OwnedTypeEntityType.cs | 2 +- .../PrincipalBaseEntityType.cs | 2 +- .../ComplexTypes/PrincipalBaseEntityType.cs | 6 +- .../PrincipalDerivedEntityType.cs | 4 +- .../Tpc_Sprocs/PrincipalBaseEntityType.cs | 2 +- .../TestUtilities/LibRedTestHelpers.cs | 2 +- 35 files changed, 2703 insertions(+), 74 deletions(-) diff --git a/test/EFCore.Jet.FunctionalTests/Query/AdHocAdvancedMappingsQueryJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/AdHocAdvancedMappingsQueryJetTest.cs index 4dcaa503..ab9c42f5 100644 --- a/test/EFCore.Jet.FunctionalTests/Query/AdHocAdvancedMappingsQueryJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Query/AdHocAdvancedMappingsQueryJetTest.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.TestUtilities; using System.Threading.Tasks; +using Xunit; namespace EntityFrameworkCore.Jet.FunctionalTests.Query; @@ -303,4 +304,85 @@ ORDER BY `c`.`Id` ORDER BY `s`.`Id` """); } + + public override async Task Projecting_property_with_converter_with_closure(bool async) + { + await base.Projecting_property_with_converter_with_closure(async); + + AssertSql( + """ +SELECT `b`.`PublishDate` +FROM `Books` AS `b` +"""); + } + + public override async Task Projecting_expression_with_converter_with_closure(bool async) + { + await base.Projecting_expression_with_converter_with_closure(async); + + AssertSql( + """ +SELECT MIN(`b`.`PublishDate`) AS `Day` +FROM `Books` AS `b` +GROUP BY `b`.`Id` +"""); + } + + public override async Task Projecting_property_with_converter_without_closure(bool async) + { + await base.Projecting_property_with_converter_without_closure(async); + + AssertSql( + """ +SELECT MIN(`b`.`AudiobookDate`) AS `Day` +FROM `Books` AS `b` +GROUP BY `b`.`Id` +"""); + } + + public override async Task TPC_query_with_generic_derived_types_returns_correct_types(bool async) + { + await base.TPC_query_with_generic_derived_types_returns_correct_types(async); + + AssertSql( + """ +SELECT `u`.`Id`, `u`.`Value`, `u`.`Value1`, `u`.`Discriminator` +FROM ( + SELECT `r`.`Id`, `r`.`Value`, NULL AS `Value1`, 'ReproEntity' AS `Discriminator` + FROM `ReproEntity` AS `r` + UNION ALL + SELECT `r0`.`Id`, CVar(NULL) AS `Value`, `r0`.`Value` AS `Value1`, 'ReproEntity' AS `Discriminator` + FROM `ReproEntity` AS `r0` +) AS `u` +ORDER BY `u`.`Id` +"""); + } + + public override async Task TPC_query_with_generic_derived_types_OfType_returns_correct_types(bool async) + { + await base.TPC_query_with_generic_derived_types_OfType_returns_correct_types(async); + + AssertSql( + """ +SELECT `u`.`Id`, `u`.`Value`, `u`.`Discriminator` +FROM ( + SELECT `r`.`Id`, `r`.`Value`, 'ReproEntity' AS `Discriminator` + FROM `ReproEntity` AS `r` +) AS `u` +ORDER BY `u`.`Id` +""", + // + """ +SELECT `u`.`Id`, `u`.`Value1`, `u`.`Discriminator` +FROM ( + SELECT `r`.`Id`, `r`.`Value` AS `Value1`, 'ReproEntity' AS `Discriminator` + FROM `ReproEntity` AS `r` +) AS `u` +ORDER BY `u`.`Id` +"""); + } + + [Fact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); } diff --git a/test/EFCore.Jet.FunctionalTests/Query/AdHocManyToManyQueryJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/AdHocManyToManyQueryJetTest.cs index 682ef6a0..087a6dab 100644 --- a/test/EFCore.Jet.FunctionalTests/Query/AdHocManyToManyQueryJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Query/AdHocManyToManyQueryJetTest.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.TestUtilities; using System.Threading.Tasks; +using Xunit; namespace EntityFrameworkCore.Jet.FunctionalTests.Query; @@ -101,4 +102,8 @@ LEFT JOIN ( ORDER BY `m`.`Id`, `s`.`Id0`, `s0`.`Id` """); } + + [Fact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); } diff --git a/test/EFCore.Jet.FunctionalTests/Query/AdHocMiscellaneousQueryJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/AdHocMiscellaneousQueryJetTest.cs index f8473c6e..605f947e 100644 --- a/test/EFCore.Jet.FunctionalTests/Query/AdHocMiscellaneousQueryJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Query/AdHocMiscellaneousQueryJetTest.cs @@ -49,7 +49,7 @@ protected override Task Seed2951(Context2951 context) => context.Database.ExecuteSqlRawAsync( """ CREATE TABLE ZeroKey (Id int); -INSERT ZeroKey VALUES (NULL) +INSERT INTO ZeroKey VALUES (NULL) """); protected override async Task Seed30915(Context30915 context) @@ -1614,6 +1614,18 @@ LEFT JOIN ( """); } + public override async Task LeftJoin_with_missing_key_values_on_both_sides(bool async) + { + await base.LeftJoin_with_missing_key_values_on_both_sides(async); + + AssertSql( + """ +SELECT `c`.`CustomerID`, `c`.`CustomerName`, IIF(`p`.`PostcodeID` IS NULL, '', `p`.`TownName`) AS `TownName`, IIF(`p`.`PostcodeID` IS NULL, '', `p`.`PostcodeValue`) AS `PostcodeValue` +FROM `Customers` AS `c` +LEFT JOIN `Postcodes` AS `p` ON `c`.`PostcodeID` = `p`.`PostcodeID` +"""); + } + public override async Task Comparing_enum_casted_to_byte_with_int_parameter(bool async) { await base.Comparing_enum_casted_to_byte_with_int_parameter(async); @@ -2060,4 +2072,836 @@ SELECT 3 AS `Value` """); } } + + public override async Task Coalesce_in_conditional_with_value_conversion(bool async) + { + await base.Coalesce_in_conditional_with_value_conversion(async); + + AssertSql( + """ +SELECT `d`.`Id`, IIF(IIF(`d`.`Foo` IS NULL, CINT(99), `d`.`Foo`) = CINT(10), 'A', 'B') AS `Foo` +FROM `Data` AS `d` +ORDER BY `d`.`Id` +"""); + } + + public override async Task Like_on_value_converted_string_column_does_not_produce_cast(bool async) + { + await base.Like_on_value_converted_string_column_does_not_produce_cast(async); + + AssertSql( + """ +SELECT `u`.`Id`, `u`.`Name` +FROM `Users` AS `u` +WHERE `u`.`Name` LIKE 'Name%' +"""); + } + + public override async Task Entity_equality_with_Contains_and_Parameter(bool async) + { + await base.Entity_equality_with_Contains_and_Parameter(async); + + AssertSql( + """ +@entity_equality_details_Id1='1' +@entity_equality_details_Id2='2' + +SELECT `b`.`Id`, `b`.`DetailsId`, `b`.`Name` +FROM `Blogs` AS `b` +LEFT JOIN `BlogDetails` AS `b0` ON `b`.`DetailsId` = `b0`.`Id` +WHERE `b0`.`Id` IN (@entity_equality_details_Id1, @entity_equality_details_Id2) +"""); + } + + #region 30915 + + public override async Task Anon_whole_object_GroupJoin_DefaultIfEmpty() + { + await base.Anon_whole_object_GroupJoin_DefaultIfEmpty(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Anon_whole_object_LeftJoin_operator() + { + await base.Anon_whole_object_LeftJoin_operator(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Anon_client_null_check_GroupJoin() + { + await base.Anon_client_null_check_GroupJoin(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Anon_client_null_check_LeftJoin_operator() + { + await base.Anon_client_null_check_LeftJoin_operator(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Anon_member_only_nullable_cast() + { + await base.Anon_member_only_nullable_cast(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`Count` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId` +"""); + } + + public override async Task Dto_memberinit_whole_object_LeftJoin() + { + await base.Dto_memberinit_whole_object_LeftJoin(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`PickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`PickupStatusId` +"""); + } + + public override async Task Nested_anon_whole_object() + { + await base.Nested_anon_whole_object(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Distinct_after_join_member() + { + await base.Distinct_after_join_member(); + + AssertSql( + """ +SELECT DISTINCT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +"""); + } + + public override async Task Take_after_join_whole_object() + { + await base.Take_after_join_whole_object(); + + AssertSql( + """ +SELECT TOP @p `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Projected_object_with_nullable_member() + { + await base.Projected_object_with_nullable_member(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`MaxPriority`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, MAX(`r`.`Priority`) AS `MaxPriority`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Projected_object_with_string_member() + { + await base.Projected_object_with_string_member(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`Name`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, 'cat' AS `Name`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Projected_object_all_nullable_members() + { + await base.Projected_object_all_nullable_members(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`MaxPriority`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, MAX(`r`.`Priority`) AS `MaxPriority`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Matched_row_with_null_aggregate_keeps_object_non_null() + { + await base.Matched_row_with_null_aggregate_keeps_object_non_null(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`MaxPriority`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, MAX(`r`.`Priority`) AS `MaxPriority`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Bare_whole_object_projection_is_null_on_no_match() + { + await base.Bare_whole_object_projection_is_null_on_no_match(); + + AssertSql( + """ +SELECT `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task User_member_named_marker_does_not_collide_with_synthetic_marker() + { + await base.User_member_named_marker_does_not_collide_with_synthetic_marker(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`marker`, `r0`.`marker0` AS `marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `marker`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker0` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Anon_whole_object_GroupJoin_DefaultIfEmpty_sync() + { + await base.Anon_whole_object_GroupJoin_DefaultIfEmpty_sync(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Projected_object_with_decimal_member() + { + await base.Projected_object_with_decimal_member(); + + AssertSql( + """ +SELECT [s].[PickupStatusId], [r0].[pickupStatusId], [r0].[Total], [r0].[marker] +FROM [Statuses] AS [s] +LEFT JOIN ( + SELECT [r].[PickupStatusId] AS [pickupStatusId], COALESCE(SUM(CAST([r].[PickupStatusId] AS decimal(18,2))), 0.0) AS [Total], 1 AS [marker] + FROM [Requests] AS [r] + GROUP BY [r].[PickupStatusId] +) AS [r0] ON [s].[PickupStatusId] = [r0].[pickupStatusId] +ORDER BY [s].[PickupStatusId] +"""); + } + + public override async Task Correlated_SelectMany_DefaultIfEmpty_whole_object() + { + await base.Correlated_SelectMany_DefaultIfEmpty_whole_object(); + + AssertSql(); + } + + public override async Task Composed_user_marker_projection_into_subquery_self_heals() + { + await base.Composed_user_marker_projection_into_subquery_self_heals(); + + AssertSql( + """ +SELECT `s0`.`PickupStatusId`, `s0`.`pickupStatusId0` AS `pickupStatusId`, `s0`.`marker`, `s0`.`marker0` AS `marker` +FROM ( + SELECT DISTINCT `s`.`PickupStatusId`, `r0`.`pickupStatusId` AS `pickupStatusId0`, `r0`.`marker`, `r0`.`marker0` + FROM `Statuses` AS `s` + LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `marker`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker0` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` + ) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +) AS `s0` +ORDER BY `s0`.`PickupStatusId`, `s0`.`pickupStatusId0` +"""); + } + + public override async Task Nested_transparent_identifier_of_entities_as_leftjoin_inner() + { + await base.Nested_transparent_identifier_of_entities_as_leftjoin_inner(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `s1`.`Id`, `s1`.`PickupStatusId`, `s1`.`Priority`, `s1`.`PickupStatusId0`, `s1`.`Name` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`Id`, `r`.`PickupStatusId`, `r`.`Priority`, `s0`.`PickupStatusId` AS `PickupStatusId0`, `s0`.`Name` + FROM `Requests` AS `r` + INNER JOIN `Statuses` AS `s0` ON `r`.`PickupStatusId` = `s0`.`PickupStatusId` +) AS `s1` ON `s`.`PickupStatusId` = `s1`.`PickupStatusId0` +ORDER BY `s`.`PickupStatusId` +"""); + } + + public override async Task Distinct_with_unconsumed_marker_is_benign() + { + await base.Distinct_with_unconsumed_marker_is_benign(); + + AssertSql( + """ +SELECT `s0`.`PickupStatusId`, `s0`.`pickupStatusId0`, `s0`.`Count`, `s0`.`marker` +FROM ( + SELECT DISTINCT `s`.`PickupStatusId`, `r0`.`pickupStatusId` AS `pickupStatusId0`, `r0`.`Count`, `r0`.`marker` + FROM `Statuses` AS `s` + LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` + ) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +) AS `s0` +ORDER BY `s0`.`PickupStatusId`, `s0`.`pickupStatusId0` +"""); + } + + public override async Task Member_only_access_nested_two_joins_deep() + { + await base.Member_only_access_nested_two_joins_deep(); + + AssertSql( + """ +SELECT `s0`.`PickupStatusId`, `s0`.`Name`, `s1`.`marker` IS NULL, `s1`.`pickupStatusId0`, `s1`.`Count` +FROM ( + SELECT DISTINCT `s`.`PickupStatusId`, `r0`.`pickupStatusId` AS `pickupStatusId0`, `r0`.`Count`, `r0`.`marker` + FROM `Statuses` AS `s` + LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` + ) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +) AS `s1` +INNER JOIN `Statuses` AS `s0` ON `s1`.`PickupStatusId` = `s0`.`PickupStatusId` +ORDER BY `s0`.`PickupStatusId`, `s1`.`pickupStatusId0` +"""); + } + + public override async Task Dto_constructor_whole_object_LeftJoin() + { + await base.Dto_constructor_whole_object_LeftJoin(); + + AssertSql(); + } + + public override async Task Struct_whole_object_LeftJoin() + { + await base.Struct_whole_object_LeftJoin(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`PickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`PickupStatusId` +"""); + } + + public override async Task Struct_whole_object_GroupJoin_DefaultIfEmpty() + { + await base.Struct_whole_object_GroupJoin_DefaultIfEmpty(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`PickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`PickupStatusId` +"""); + } + + public override async Task RecordStruct_whole_object_LeftJoin() + { + await base.RecordStruct_whole_object_LeftJoin(); + + AssertSql(); + } + + public override async Task Nullable_struct_whole_object_from_nullable_side() + { + await base.Nullable_struct_whole_object_from_nullable_side(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`PickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`PickupStatusId` +"""); + } + + public override async Task ValueTuple_whole_object_from_nullable_side() + { + await base.ValueTuple_whole_object_from_nullable_side(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`PickupStatusId`, `r0`.`c`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId`, COUNT(*) AS `c`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`PickupStatusId` +"""); + } + + public override async Task Second_join_after_then_whole_object() + { + await base.Second_join_after_then_whole_object(); + + AssertSql( + """ +SELECT `s0`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM (`Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId`) +INNER JOIN `Statuses` AS `s0` ON `s`.`PickupStatusId` = `s0`.`PickupStatusId` +ORDER BY `s0`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Plain_inner_no_aggregate_LeftJoin_whole_object() + { + await base.Plain_inner_no_aggregate_LeftJoin_whole_object(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r`.`PickupStatusId`, 1 AS `Count` +FROM `Statuses` AS `s` +LEFT JOIN `Requests` AS `r` ON `s`.`PickupStatusId` = `r`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r`.`Id` +"""); + } + + public override async Task Union_of_two_leftjoin_nonentity() + { + await base.Union_of_two_leftjoin_nonentity(); + + AssertSql(); + } + + public override async Task OrderBy_member_of_nullable_projection() + { + await base.OrderBy_member_of_nullable_projection(); + + AssertSql(); + } + + public override async Task Where_nonentity_projection_not_null_serverside() + { + await base.Where_nonentity_projection_not_null_serverside(); + + AssertSql(); + } + + public override async Task Where_nonentity_projection_null_serverside() + { + await base.Where_nonentity_projection_null_serverside(); + + AssertSql(); + } + + public override async Task Matched_struct_row_with_zero_aggregate_keeps_real_key() + { + await base.Matched_struct_row_with_zero_aggregate_keeps_real_key(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`PickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId`, COUNT(IIF(`r`.`Priority` > 100, 1, NULL)) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`PickupStatusId` +"""); + } + + public override async Task RightJoin_whole_object_outer_nullable() + { + await base.RightJoin_whole_object_outer_nullable(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count` +FROM ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` +RIGHT JOIN `Statuses` AS `s` ON `r0`.`pickupStatusId` = `s`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId` +"""); + } + + public override async Task GroupBy_after_join_then_whole_object() + { + await base.GroupBy_after_join_then_whole_object(); + + AssertSql( + """ +SELECT [s1].[PickupStatusId], [s3].[pickupStatusId], [s3].[Count], [s3].[marker], [s3].[c] +FROM ( + SELECT [s].[PickupStatusId] + FROM [Statuses] AS [s] + LEFT JOIN ( + SELECT [r].[PickupStatusId] AS [pickupStatusId] + FROM [Requests] AS [r] + GROUP BY [r].[PickupStatusId] + ) AS [r0] ON [s].[PickupStatusId] = [r0].[pickupStatusId] + GROUP BY [s].[PickupStatusId] +) AS [s1] +LEFT JOIN ( + SELECT [s2].[pickupStatusId], [s2].[Count], [s2].[marker], [s2].[c], [s2].[PickupStatusId0] + FROM ( + SELECT [r1].[pickupStatusId], [r1].[Count], [r1].[marker], 1 AS [c], [s0].[PickupStatusId] AS [PickupStatusId0], ROW_NUMBER() OVER(PARTITION BY [s0].[PickupStatusId] ORDER BY [s0].[PickupStatusId], [r1].[pickupStatusId]) AS [row] + FROM [Statuses] AS [s0] + LEFT JOIN ( + SELECT [r2].[PickupStatusId] AS [pickupStatusId], COUNT(*) AS [Count], 1 AS [marker] + FROM [Requests] AS [r2] + GROUP BY [r2].[PickupStatusId] + ) AS [r1] ON [s0].[PickupStatusId] = [r1].[pickupStatusId] + ) AS [s2] + WHERE [s2].[row] <= 1 +) AS [s3] ON [s1].[PickupStatusId] = [s3].[PickupStatusId0] +ORDER BY [s1].[PickupStatusId] +"""); + } + + public override async Task GroupBy_after_join_then_whole_object_nested_in_wrapper() + { + await base.GroupBy_after_join_then_whole_object_nested_in_wrapper(); + + // SQL is intentionally identical to the flat GroupBy_after_join_then_whole_object variant -- the wrapper is + // client-side-only nesting, so it changes no SQL. This test exists to exercise the nested-node rekey path. + AssertSql( + """ +SELECT [s1].[PickupStatusId], [s3].[pickupStatusId], [s3].[Count], [s3].[marker], [s3].[c] +FROM ( + SELECT [s].[PickupStatusId] + FROM [Statuses] AS [s] + LEFT JOIN ( + SELECT [r].[PickupStatusId] AS [pickupStatusId] + FROM [Requests] AS [r] + GROUP BY [r].[PickupStatusId] + ) AS [r0] ON [s].[PickupStatusId] = [r0].[pickupStatusId] + GROUP BY [s].[PickupStatusId] +) AS [s1] +LEFT JOIN ( + SELECT [s2].[pickupStatusId], [s2].[Count], [s2].[marker], [s2].[c], [s2].[PickupStatusId0] + FROM ( + SELECT [r1].[pickupStatusId], [r1].[Count], [r1].[marker], 1 AS [c], [s0].[PickupStatusId] AS [PickupStatusId0], ROW_NUMBER() OVER(PARTITION BY [s0].[PickupStatusId] ORDER BY [s0].[PickupStatusId], [r1].[pickupStatusId]) AS [row] + FROM [Statuses] AS [s0] + LEFT JOIN ( + SELECT [r2].[PickupStatusId] AS [pickupStatusId], COUNT(*) AS [Count], 1 AS [marker] + FROM [Requests] AS [r2] + GROUP BY [r2].[PickupStatusId] + ) AS [r1] ON [s0].[PickupStatusId] = [r1].[pickupStatusId] + ) AS [s2] + WHERE [s2].[row] <= 1 +) AS [s3] ON [s1].[PickupStatusId] = [s3].[PickupStatusId0] +ORDER BY [s1].[PickupStatusId] +"""); + } + + public override async Task GroupBy_after_join_then_whole_object_dto_memberinit() + { + await base.GroupBy_after_join_then_whole_object_dto_memberinit(); + + AssertSql( + """ +SELECT [s1].[PickupStatusId], [s3].[PickupStatusId], [s3].[Count], [s3].[marker], [s3].[c] +FROM ( + SELECT [s].[PickupStatusId] + FROM [Statuses] AS [s] + LEFT JOIN ( + SELECT [r].[PickupStatusId] + FROM [Requests] AS [r] + GROUP BY [r].[PickupStatusId] + ) AS [r0] ON [s].[PickupStatusId] = [r0].[PickupStatusId] + GROUP BY [s].[PickupStatusId] +) AS [s1] +LEFT JOIN ( + SELECT [s2].[PickupStatusId], [s2].[Count], [s2].[marker], [s2].[c], [s2].[PickupStatusId0] + FROM ( + SELECT [r1].[PickupStatusId], [r1].[Count], [r1].[marker], 1 AS [c], [s0].[PickupStatusId] AS [PickupStatusId0], ROW_NUMBER() OVER(PARTITION BY [s0].[PickupStatusId] ORDER BY [s0].[PickupStatusId], [r1].[PickupStatusId]) AS [row] + FROM [Statuses] AS [s0] + LEFT JOIN ( + SELECT [r2].[PickupStatusId], COUNT(*) AS [Count], 1 AS [marker] + FROM [Requests] AS [r2] + GROUP BY [r2].[PickupStatusId] + ) AS [r1] ON [s0].[PickupStatusId] = [r1].[PickupStatusId] + ) AS [s2] + WHERE [s2].[row] <= 1 +) AS [s3] ON [s1].[PickupStatusId] = [s3].[PickupStatusId0] +ORDER BY [s1].[PickupStatusId] +"""); + } + + public override async Task GroupBy_after_join_then_whole_object_struct() + { + await base.GroupBy_after_join_then_whole_object_struct(); + + AssertSql( + """ +SELECT [s1].[PickupStatusId], [s3].[PickupStatusId], [s3].[Count], [s3].[marker], [s3].[c] +FROM ( + SELECT [s].[PickupStatusId] + FROM [Statuses] AS [s] + LEFT JOIN ( + SELECT [r].[PickupStatusId] + FROM [Requests] AS [r] + GROUP BY [r].[PickupStatusId] + ) AS [r0] ON [s].[PickupStatusId] = [r0].[PickupStatusId] + GROUP BY [s].[PickupStatusId] +) AS [s1] +LEFT JOIN ( + SELECT [s2].[PickupStatusId], [s2].[Count], [s2].[marker], [s2].[c], [s2].[PickupStatusId0] + FROM ( + SELECT [r1].[PickupStatusId], [r1].[Count], [r1].[marker], 1 AS [c], [s0].[PickupStatusId] AS [PickupStatusId0], ROW_NUMBER() OVER(PARTITION BY [s0].[PickupStatusId] ORDER BY [s0].[PickupStatusId], [r1].[PickupStatusId]) AS [row] + FROM [Statuses] AS [s0] + LEFT JOIN ( + SELECT [r2].[PickupStatusId], COUNT(*) AS [Count], 1 AS [marker] + FROM [Requests] AS [r2] + GROUP BY [r2].[PickupStatusId] + ) AS [r1] ON [s0].[PickupStatusId] = [r1].[PickupStatusId] + ) AS [s2] + WHERE [s2].[row] <= 1 +) AS [s3] ON [s1].[PickupStatusId] = [s3].[PickupStatusId0] +ORDER BY [s1].[PickupStatusId] +"""); + } + + public override async Task Two_left_joined_nonentity_objects_second_marker_orphaned() + { + await base.Two_left_joined_nonentity_objects_second_marker_orphaned(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker`, `r2`.`pickupStatusId`, `r2`.`Count`, `r2`.`marker` +FROM (`Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId`) +LEFT JOIN ( + SELECT `r1`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r1`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r1` + GROUP BY `r1`.`PickupStatusId` +) AS `r2` ON `s`.`PickupStatusId` = `r2`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r2`.`pickupStatusId` +"""); + } + + public override async Task Three_sequential_joins_marker_survives_two_remaps() + { + await base.Three_sequential_joins_marker_survives_two_remaps(); + + AssertSql( + """ +SELECT `s1`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM ((`Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId`) +INNER JOIN `Statuses` AS `s0` ON `s`.`PickupStatusId` = `s0`.`PickupStatusId`) +LEFT JOIN `Statuses` AS `s1` ON `s0`.`PickupStatusId` = `s1`.`PickupStatusId` +WHERE `s0`.`PickupStatusId` IS NOT NULL AND `s1`.`PickupStatusId` IS NOT NULL +ORDER BY `s1`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Marker_object_nested_in_outer_wrapper_across_second_join() + { + await base.Marker_object_nested_in_outer_wrapper_across_second_join(); + + AssertSql( + """ +SELECT `s0`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM (`Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId`) +INNER JOIN `Statuses` AS `s0` ON `s`.`PickupStatusId` = `s0`.`PickupStatusId` +ORDER BY `s0`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Query_when_null_key_in_database_should_throw() + { + await base.Query_when_null_key_in_database_should_throw(); + + AssertSql( + """ +SELECT [z].[Id] +FROM [ZeroKey] AS [z] +"""); + } + + public override async Task Mapping_JsonElement_property_throws_a_meaningful_exception() + { + await base.Mapping_JsonElement_property_throws_a_meaningful_exception(); + + AssertSql(); + } + + public override async Task Struct_composed_user_marker_projection_into_subquery_self_heals() + { + await base.Struct_composed_user_marker_projection_into_subquery_self_heals(); + + AssertSql( + """ +SELECT `s0`.`PickupStatusId`, `s0`.`pickupStatusId0` AS `pickupStatusId`, `s0`.`marker`, `s0`.`marker0` AS `marker` +FROM ( + SELECT DISTINCT `s`.`PickupStatusId`, `r0`.`pickupStatusId` AS `pickupStatusId0`, `r0`.`marker`, `r0`.`marker0` + FROM `Statuses` AS `s` + LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `marker`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker0` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` + ) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +) AS `s0` +ORDER BY `s0`.`PickupStatusId`, `s0`.`pickupStatusId0` +"""); + } + + #endregion + + [Fact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); } diff --git a/test/EFCore.Jet.FunctionalTests/Query/AdHocNavigationsQueryJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/AdHocNavigationsQueryJetTest.cs index 8154c6dc..a3889ffb 100644 --- a/test/EFCore.Jet.FunctionalTests/Query/AdHocNavigationsQueryJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Query/AdHocNavigationsQueryJetTest.cs @@ -438,6 +438,55 @@ FROM [IdentityDocument] AS [i0] """); } + public override async Task Consecutive_selects_with_conditional_projection_should_not_include_unnecessary_joins(bool async) + { + await base.Consecutive_selects_with_conditional_projection_should_not_include_unnecessary_joins(async); + + AssertSql( + """ +SELECT TOP(1) [u].[Id], CASE + WHEN [j].[Id] IS NULL THEN CAST(1 AS bit) + ELSE CAST(0 AS bit) +END, [j].[Id] +FROM [Users] AS [u] +LEFT JOIN [Job] AS [j] ON [u].[JobId] = [j].[Id] +WHERE [u].[Id] = CAST(1 AS bigint) +"""); + } + + public override async Task Consecutive_selects_with_conditional_projection_null_navigation_returns_null(bool async) + { + await base.Consecutive_selects_with_conditional_projection_null_navigation_returns_null(async); + + AssertSql( + """ +SELECT TOP(1) [u].[Id], CASE + WHEN [j].[Id] IS NULL THEN CAST(1 AS bit) + ELSE CAST(0 AS bit) +END, [j].[Id] +FROM [Users] AS [u] +LEFT JOIN [Job] AS [j] ON [u].[JobId] = [j].[Id] +WHERE [u].[JobId] IS NULL +"""); + } + + public override async Task Consecutive_selects_with_conditional_projection_nested_navigation_accessed_includes_join(bool async) + { + await base.Consecutive_selects_with_conditional_projection_nested_navigation_accessed_includes_join(async); + + AssertSql( + """ +SELECT TOP(1) [u].[Id], CASE + WHEN [j].[Id] IS NULL THEN CAST(1 AS bit) + ELSE CAST(0 AS bit) +END, [j].[Id], [a].[Id] +FROM [Users] AS [u] +LEFT JOIN [Job] AS [j] ON [u].[JobId] = [j].[Id] +LEFT JOIN [Address] AS [a] ON [j].[AddressId] = [a].[Id] +WHERE [u].[Id] = CAST(1 AS bigint) +"""); + } + public override async Task Using_explicit_interface_implementation_as_navigation_works() { await base.Using_explicit_interface_implementation_as_navigation_works(); @@ -631,4 +680,22 @@ SELECT COUNT(*) FROM `Authors` AS `a` """); } + + public override async Task Filtered_collection_through_optional_navigation_does_not_match_on_null_keys(bool async) + { + await base.Filtered_collection_through_optional_navigation_does_not_match_on_null_keys(async); + + AssertSql( + """ +SELECT [p].[Name], [p].[PersonId], [p0].[Name], [p0].[PersonId] +FROM [People] AS [p] +LEFT JOIN [Employers] AS [e] ON [p].[EmployerId] = [e].[EmployerId] +LEFT JOIN [People] AS [p0] ON [e].[EmployerId] IS NOT NULL AND [e].[EmployerId] = [p0].[EmployerId] AND [p].[PersonId] <> [p0].[PersonId] +ORDER BY [p].[PersonId] +"""); + } + + [Fact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); } diff --git a/test/EFCore.Jet.FunctionalTests/Query/AdHocQueryFiltersQueryJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/AdHocQueryFiltersQueryJetTest.cs index a0a8bab8..9a6cf155 100644 --- a/test/EFCore.Jet.FunctionalTests/Query/AdHocQueryFiltersQueryJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Query/AdHocQueryFiltersQueryJetTest.cs @@ -19,6 +19,94 @@ public class AdHocQueryFiltersQueryJetTest(NonSharedFixture fixture) : AdHocQuer protected override ITestStoreFactory NonSharedTestStoreFactory => JetTestStoreFactory.Instance; + #region 8576 + + public override async Task Named_query_filters() + { + await base.Named_query_filters(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE (`e`.`Name` LIKE 'Name%') AND NOT (`e`.`IsDeleted`) AND NOT (`e`.`IsDraft`) +"""); + } + + public override async Task Named_query_filters_anonymous() + { + await base.Named_query_filters_anonymous(); + + AssertSql( + """ +@ef_filter___ids1='1' +@ef_filter___ids2='7' + +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE `e`.`Id` NOT IN (@ef_filter___ids1, @ef_filter___ids2) +"""); + } + + public override async Task Named_query_filters_ignore_some() + { + await base.Named_query_filters_ignore_some(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE NOT (`e`.`IsDraft`) +"""); + } + + public override async Task Named_query_filters_ignore_all() + { + await base.Named_query_filters_ignore_all(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +"""); + } + + public override async Task Named_query_filters_anonymous_ignore() + { + await base.Named_query_filters_anonymous_ignore(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +"""); + } + + public override async Task Named_query_filters_overwriting() + { + await base.Named_query_filters_overwriting(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE NOT (`e`.`IsDeleted`) +"""); + } + + public override async Task Named_query_filters_removing() + { + await base.Named_query_filters_removing(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +"""); + } + + #endregion + #region 11803 [Fact] @@ -361,4 +449,81 @@ LEFT JOIN ( ORDER BY `s`.`Name` """); } + + public override async Task Query_filter_with_primary_constructor_parameter() + { + await base.Query_filter_with_primary_constructor_parameter(); + + AssertSql( + """ +@ef_filter__tenantId='00000001-0000-0000-0000-000000000001' + +SELECT `e`.`Id`, `e`.`Name`, `e`.`TenantId` +FROM `Entity38132` AS `e` +WHERE `e`.`TenantId` = @ef_filter__tenantId +"""); + } + + public override async Task Query_filter_with_context_accessor_with_constant(bool async) + { + await base.Query_filter_with_context_accessor_with_constant(async); + + AssertSql( + """ +@ef_filter__p3='False' + +SELECT `f`.`Id`, `f`.`Bar` +FROM `FooBar35111` AS `f` +WHERE IIF(@ef_filter__p3, FALSE, FALSE) +"""); + } + + public override async Task Named_query_filters_caching() + { + await base.Named_query_filters_caching(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE NOT (`e`.`IsDraft`) +""", + // + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE NOT (`e`.`IsDraft`) +""", + // + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE NOT (`e`.`IsDraft`) +"""); + } + + public override async Task Named_query_filters_combined() + { + await base.Named_query_filters_combined(); + + AssertSql(); + } + + public override async Task Query_filter_with_EF_Constant_throws() + { + await base.Query_filter_with_EF_Constant_throws(); + + AssertSql(); + } + + public override async Task Query_filter_with_EF_Parameter_throws() + { + await base.Query_filter_with_EF_Parameter_throws(); + + AssertSql(); + } + + [Fact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); } diff --git a/test/EFCore.Jet.FunctionalTests/Query/AdHocQuerySplittingQueryJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/AdHocQuerySplittingQueryJetTest.cs index bec641b4..8f0cfd2a 100644 --- a/test/EFCore.Jet.FunctionalTests/Query/AdHocQuerySplittingQueryJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Query/AdHocQuerySplittingQueryJetTest.cs @@ -323,4 +323,152 @@ public override async Task NoTracking_split_query_creates_only_required_instance ORDER BY `t`.`Id` """); } + + public override async Task NoTrackingWithIdentityResolution_split_query_basic(bool async) + { + await base.NoTrackingWithIdentityResolution_split_query_basic(async); + + AssertSql( + """ +SELECT `t`.`Id` +FROM `Tests` AS `t` +ORDER BY `t`.`Id` +"""); + } + + public override async Task NoTrackingWithIdentityResolution_split_query_complex(bool async) + { + await base.NoTrackingWithIdentityResolution_split_query_complex(async); + + AssertSql( + """ +SELECT `t`.`Id` +FROM `Tests` AS `t` +ORDER BY `t`.`Id` +"""); + } + + public override async Task Can_query_with_nav_collection_in_projection_with_split_query_in_parallel_async() + { + await base.Can_query_with_nav_collection_in_projection_with_split_query_in_parallel_async(); + + Assert.Equal(400, TestSqlLoggerFactory.SqlStatements.Count); + + AssertContainsSql( + """ +@parentId='e79c82f4-3ae7-4c65-85db-04e08cba6fa7' + +SELECT TOP(2) [p].[Id] +FROM [Parents] AS [p] +WHERE [p].[Id] = @parentId +ORDER BY [p].[Id] +""", + // + """ +@parentId='d6457b52-690a-419e-8982-a1a8551b4572' + +SELECT TOP(2) [p].[Id] +FROM [Parents] AS [p] +WHERE [p].[Id] = @parentId +ORDER BY [p].[Id] +""", + // + """ +@parentId='e79c82f4-3ae7-4c65-85db-04e08cba6fa7' + +SELECT [c2].[Id], [c2].[ParentId], [p0].[Id] +FROM ( + SELECT TOP(1) [p].[Id] + FROM [Parents] AS [p] + WHERE [p].[Id] = @parentId + ORDER BY [p].[Id] +) AS [p0] +INNER JOIN [Collection] AS [c2] ON [p0].[Id] = [c2].[ParentId] +ORDER BY [p0].[Id] +""", + // + """ +@parentId='d6457b52-690a-419e-8982-a1a8551b4572' + +SELECT [c2].[Id], [c2].[ParentId], [p0].[Id] +FROM ( + SELECT TOP(1) [p].[Id] + FROM [Parents] AS [p] + WHERE [p].[Id] = @parentId + ORDER BY [p].[Id] +) AS [p0] +INNER JOIN [Collection] AS [c2] ON [p0].[Id] = [c2].[ParentId] +ORDER BY [p0].[Id] +"""); + } + + public override async Task Can_query_with_nav_collection_in_projection_with_split_query_in_parallel_sync() + { + await base.Can_query_with_nav_collection_in_projection_with_split_query_in_parallel_sync(); + + Assert.Equal(40, TestSqlLoggerFactory.SqlStatements.Count); + + AssertContainsSql( + """ +@parentId='e79c82f4-3ae7-4c65-85db-04e08cba6fa7' + +SELECT TOP(2) [p].[Id] +FROM [Parents] AS [p] +WHERE [p].[Id] = @parentId +ORDER BY [p].[Id] +""", + // + """ +@parentId='d6457b52-690a-419e-8982-a1a8551b4572' + +SELECT TOP(2) [p].[Id] +FROM [Parents] AS [p] +WHERE [p].[Id] = @parentId +ORDER BY [p].[Id] +""", + // + """ +@parentId='e79c82f4-3ae7-4c65-85db-04e08cba6fa7' + +SELECT [c2].[Id], [c2].[ParentId], [p0].[Id] +FROM ( + SELECT TOP(1) [p].[Id] + FROM [Parents] AS [p] + WHERE [p].[Id] = @parentId + ORDER BY [p].[Id] +) AS [p0] +INNER JOIN [Collection] AS [c2] ON [p0].[Id] = [c2].[ParentId] +ORDER BY [p0].[Id] +""", + // + """ +@parentId='d6457b52-690a-419e-8982-a1a8551b4572' + +SELECT [c2].[Id], [c2].[ParentId], [p0].[Id] +FROM ( + SELECT TOP(1) [p].[Id] + FROM [Parents] AS [p] + WHERE [p].[Id] = @parentId + ORDER BY [p].[Id] +) AS [p0] +INNER JOIN [Collection] AS [c2] ON [p0].[Id] = [c2].[ParentId] +ORDER BY [p0].[Id] +"""); + } + + private void AssertContainsSql(params string[] expected) + => TestSqlLoggerFactory.AssertBaseline(expected, assertOrder: false); + + // The two split-include concurrency regression tests interleave concurrent writes (on a separate context that shares this + // fixture's SQL logger) with the split query, so the captured SQL is not deterministic. They assert behavior in the base + // class and are overridden here without a SQL baseline. + public override Task Split_include_collection_throws_for_orphan_child_rows_after_concurrent_insert(bool async) + => base.Split_include_collection_throws_for_orphan_child_rows_after_concurrent_insert(async); + + public override Task Split_include_collection_not_dropped_when_other_parent_made_childless_concurrently(bool async) + => base.Split_include_collection_not_dropped_when_other_parent_made_childless_concurrently(async); + + [Fact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); } diff --git a/test/EFCore.LibRed.FunctionalTests/LibRedEndToEndTest.cs b/test/EFCore.LibRed.FunctionalTests/LibRedEndToEndTest.cs index 90e72ba7..22706890 100644 --- a/test/EFCore.LibRed.FunctionalTests/LibRedEndToEndTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/LibRedEndToEndTest.cs @@ -1393,7 +1393,7 @@ private async Task RoundTripChanges() Assert.Equal("Blog1", blog1.Name); Assert.True(blog1.George); Assert.Equal(new Guid("0456AEF1-B7FC-47AA-8102-975D6BA3A9BF"), blog1.TheGu); - Assert.Equal(new DateTime(1973, 9, 3, 0, 10, 33, 0), blog1.NotFigTime); + Assert.Equal(new DateTime(1973, 9, 3, 0, 10, 33, 777), blog1.NotFigTime); Assert.Equal(64, blog1.ToEat); Assert.Equal(0.123456789, blog1.OrNothing); Assert.Equal(777, blog1.Fuse); diff --git a/test/EFCore.LibRed.FunctionalTests/Migrations/MigrationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Migrations/MigrationsLibRedTest.cs index cfd72c7f..8acf5035 100644 --- a/test/EFCore.LibRed.FunctionalTests/Migrations/MigrationsLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Migrations/MigrationsLibRedTest.cs @@ -361,7 +361,7 @@ public override async Task Add_column_with_defaultValue_datetime() AssertSql( """ -ALTER TABLE `People` ADD `Birthday` datetime NOT NULL DEFAULT '2015-04-12 17:05:00'; +ALTER TABLE `People` ADD `Birthday` datetime NOT NULL DEFAULT #2015-04-12 17:05:00#; """); } @@ -391,8 +391,8 @@ await Test( }); AssertSql( - $""" -ALTER TABLE `People` ADD `Birthday` datetime NOT NULL DEFAULT '2015-04-12 17:05:00'; + """ +ALTER TABLE `People` ADD `Birthday` datetime NOT NULL DEFAULT #2015-04-12 17:05:00.120#; """); } @@ -425,8 +425,8 @@ await Test( }); AssertSql( - $""" -ALTER TABLE `People` ADD `Birthday` datetime NOT NULL DEFAULT CDATE('2015-04-12 07:05:00'); + """ +ALTER TABLE `People` ADD `Birthday` datetime NOT NULL DEFAULT CDATE('2015-04-12 07:05:00.120'); """); } @@ -458,7 +458,7 @@ await Test( AssertSql( """ -ALTER TABLE `People` ADD `Age` datetime NOT NULL DEFAULT TIMEVALUE('12:34:56'); +ALTER TABLE `People` ADD `Age` datetime NOT NULL DEFAULT TIMEVALUE('12:34:56.120'); """); } @@ -480,7 +480,7 @@ await Test( AssertSql( """ -ALTER TABLE `People` ADD `Birthday` datetime NOT NULL DEFAULT '2019-01-01'; +ALTER TABLE `People` ADD `Birthday` datetime NOT NULL DEFAULT #2019-01-01#; """); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/AdHocAdvancedMappingsQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/AdHocAdvancedMappingsQueryLibRedTest.cs index 9cdd566f..ab4025d1 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/AdHocAdvancedMappingsQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/AdHocAdvancedMappingsQueryLibRedTest.cs @@ -6,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.TestUtilities; using System.Threading.Tasks; +using Xunit; namespace EntityFrameworkCore.LibRed.FunctionalTests.Query; @@ -305,4 +306,85 @@ ORDER BY `c`.`Id` ORDER BY `s`.`Id` """); } + + public override async Task Projecting_property_with_converter_with_closure(bool async) + { + await base.Projecting_property_with_converter_with_closure(async); + + AssertSql( + """ +SELECT `b`.`PublishDate` +FROM `Books` AS `b` +"""); + } + + public override async Task Projecting_expression_with_converter_with_closure(bool async) + { + await base.Projecting_expression_with_converter_with_closure(async); + + AssertSql( + """ +SELECT MIN(`b`.`PublishDate`) AS `Day` +FROM `Books` AS `b` +GROUP BY `b`.`Id` +"""); + } + + public override async Task Projecting_property_with_converter_without_closure(bool async) + { + await base.Projecting_property_with_converter_without_closure(async); + + AssertSql( + """ +SELECT MIN(`b`.`AudiobookDate`) AS `Day` +FROM `Books` AS `b` +GROUP BY `b`.`Id` +"""); + } + + public override async Task TPC_query_with_generic_derived_types_returns_correct_types(bool async) + { + await base.TPC_query_with_generic_derived_types_returns_correct_types(async); + + AssertSql( + """ +SELECT `u`.`Id`, `u`.`Value`, `u`.`Value1`, `u`.`Discriminator` +FROM ( + SELECT `r`.`Id`, `r`.`Value`, NULL AS `Value1`, 'ReproEntity' AS `Discriminator` + FROM `ReproEntity` AS `r` + UNION ALL + SELECT `r0`.`Id`, CVar(NULL) AS `Value`, `r0`.`Value` AS `Value1`, 'ReproEntity' AS `Discriminator` + FROM `ReproEntity` AS `r0` +) AS `u` +ORDER BY `u`.`Id` +"""); + } + + public override async Task TPC_query_with_generic_derived_types_OfType_returns_correct_types(bool async) + { + await base.TPC_query_with_generic_derived_types_OfType_returns_correct_types(async); + + AssertSql( + """ +SELECT `u`.`Id`, `u`.`Value`, `u`.`Discriminator` +FROM ( + SELECT `r`.`Id`, `r`.`Value`, 'ReproEntity' AS `Discriminator` + FROM `ReproEntity` AS `r` +) AS `u` +ORDER BY `u`.`Id` +""", + // + """ +SELECT `u`.`Id`, `u`.`Value1`, `u`.`Discriminator` +FROM ( + SELECT `r`.`Id`, `r`.`Value` AS `Value1`, 'ReproEntity' AS `Discriminator` + FROM `ReproEntity` AS `r` +) AS `u` +ORDER BY `u`.`Id` +"""); + } + + [Fact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/AdHocManyToManyQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/AdHocManyToManyQueryLibRedTest.cs index 644162b1..b0250778 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/AdHocManyToManyQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/AdHocManyToManyQueryLibRedTest.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.TestUtilities; using System.Threading.Tasks; +using Xunit; namespace EntityFrameworkCore.LibRed.FunctionalTests.Query; @@ -100,4 +101,8 @@ LEFT JOIN ( ORDER BY `m`.`Id`, `s`.`Id0`, `s0`.`Id` """); } + + [Fact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/AdHocMiscellaneousQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/AdHocMiscellaneousQueryLibRedTest.cs index 3cdf50ed..6535713d 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/AdHocMiscellaneousQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/AdHocMiscellaneousQueryLibRedTest.cs @@ -49,7 +49,7 @@ protected override Task Seed2951(Context2951 context) => context.Database.ExecuteSqlRawAsync( """ CREATE TABLE ZeroKey (Id int); -INSERT ZeroKey VALUES (NULL) +INSERT INTO ZeroKey VALUES (NULL) """); protected override async Task Seed30915(Context30915 context) @@ -1391,6 +1391,18 @@ LEFT JOIN ( """); } + public override async Task LeftJoin_with_missing_key_values_on_both_sides(bool async) + { + await base.LeftJoin_with_missing_key_values_on_both_sides(async); + + AssertSql( + """ +SELECT `c`.`CustomerID`, `c`.`CustomerName`, IIF(`p`.`PostcodeID` IS NULL, '', `p`.`TownName`) AS `TownName`, IIF(`p`.`PostcodeID` IS NULL, '', `p`.`PostcodeValue`) AS `PostcodeValue` +FROM `Customers` AS `c` +LEFT JOIN `Postcodes` AS `p` ON `c`.`PostcodeID` = `p`.`PostcodeID` +"""); + } + public override async Task Comparing_enum_casted_to_byte_with_int_parameter(bool async) { await base.Comparing_enum_casted_to_byte_with_int_parameter(async); @@ -1842,4 +1854,838 @@ SELECT 3 AS `Value` """); } } + + public override async Task Coalesce_in_conditional_with_value_conversion(bool async) + { + await base.Coalesce_in_conditional_with_value_conversion(async); + + AssertSql( + """ +SELECT `d`.`Id`, IIF(IIF(`d`.`Foo` IS NULL, CINT(99), `d`.`Foo`) = CINT(10), 'A', 'B') AS `Foo` +FROM `Data` AS `d` +ORDER BY `d`.`Id` +"""); + } + + public override async Task Like_on_value_converted_string_column_does_not_produce_cast(bool async) + { + await base.Like_on_value_converted_string_column_does_not_produce_cast(async); + + AssertSql( + """ +SELECT `u`.`Id`, `u`.`Name` +FROM `Users` AS `u` +WHERE `u`.`Name` LIKE 'Name%' +"""); + } + + public override async Task Entity_equality_with_Contains_and_Parameter(bool async) + { + await base.Entity_equality_with_Contains_and_Parameter(async); + + AssertSql( + """ +@entity_equality_details_Id1='1' +@entity_equality_details_Id2='2' + +SELECT `b`.`Id`, `b`.`DetailsId`, `b`.`Name` +FROM `Blogs` AS `b` +LEFT JOIN `BlogDetails` AS `b0` ON `b`.`DetailsId` = `b0`.`Id` +WHERE `b0`.`Id` IN (@entity_equality_details_Id1, @entity_equality_details_Id2) +"""); + } + + #region 30915 + + public override async Task Anon_whole_object_GroupJoin_DefaultIfEmpty() + { + await base.Anon_whole_object_GroupJoin_DefaultIfEmpty(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Anon_whole_object_LeftJoin_operator() + { + await base.Anon_whole_object_LeftJoin_operator(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Anon_client_null_check_GroupJoin() + { + await base.Anon_client_null_check_GroupJoin(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Anon_client_null_check_LeftJoin_operator() + { + await base.Anon_client_null_check_LeftJoin_operator(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Anon_member_only_nullable_cast() + { + await base.Anon_member_only_nullable_cast(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`Count` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId` +"""); + } + + public override async Task Dto_memberinit_whole_object_LeftJoin() + { + await base.Dto_memberinit_whole_object_LeftJoin(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`PickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`PickupStatusId` +"""); + } + + public override async Task Nested_anon_whole_object() + { + await base.Nested_anon_whole_object(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Distinct_after_join_member() + { + await base.Distinct_after_join_member(); + + AssertSql( + """ +SELECT DISTINCT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +"""); + } + + public override async Task Take_after_join_whole_object() + { + await base.Take_after_join_whole_object(); + + AssertSql( + """ +@p='10' + +SELECT TOP @p `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Projected_object_with_nullable_member() + { + await base.Projected_object_with_nullable_member(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`MaxPriority`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, MAX(`r`.`Priority`) AS `MaxPriority`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Projected_object_with_string_member() + { + await base.Projected_object_with_string_member(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`Name`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, 'cat' AS `Name`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Projected_object_all_nullable_members() + { + await base.Projected_object_all_nullable_members(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`MaxPriority`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, MAX(`r`.`Priority`) AS `MaxPriority`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Matched_row_with_null_aggregate_keeps_object_non_null() + { + await base.Matched_row_with_null_aggregate_keeps_object_non_null(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`MaxPriority`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, MAX(`r`.`Priority`) AS `MaxPriority`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Bare_whole_object_projection_is_null_on_no_match() + { + await base.Bare_whole_object_projection_is_null_on_no_match(); + + AssertSql( + """ +SELECT `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task User_member_named_marker_does_not_collide_with_synthetic_marker() + { + await base.User_member_named_marker_does_not_collide_with_synthetic_marker(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`marker`, `r0`.`marker0` AS `marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `marker`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker0` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Anon_whole_object_GroupJoin_DefaultIfEmpty_sync() + { + await base.Anon_whole_object_GroupJoin_DefaultIfEmpty_sync(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Projected_object_with_decimal_member() + { + await base.Projected_object_with_decimal_member(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Total`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, IIF(SUM(CDEC(`r`.`PickupStatusId`)) IS NULL, 0.0, SUM(CDEC(`r`.`PickupStatusId`))) AS `Total`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Correlated_SelectMany_DefaultIfEmpty_whole_object() + { + await base.Correlated_SelectMany_DefaultIfEmpty_whole_object(); + + AssertSql(); + } + + public override async Task Composed_user_marker_projection_into_subquery_self_heals() + { + await base.Composed_user_marker_projection_into_subquery_self_heals(); + + AssertSql( + """ +SELECT `s0`.`PickupStatusId`, `s0`.`pickupStatusId0` AS `pickupStatusId`, `s0`.`marker`, `s0`.`marker0` AS `marker` +FROM ( + SELECT DISTINCT `s`.`PickupStatusId`, `r0`.`pickupStatusId` AS `pickupStatusId0`, `r0`.`marker`, `r0`.`marker0` + FROM `Statuses` AS `s` + LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `marker`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker0` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` + ) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +) AS `s0` +ORDER BY `s0`.`PickupStatusId`, `s0`.`pickupStatusId0` +"""); + } + + public override async Task Nested_transparent_identifier_of_entities_as_leftjoin_inner() + { + await base.Nested_transparent_identifier_of_entities_as_leftjoin_inner(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `s1`.`Id`, `s1`.`PickupStatusId`, `s1`.`Priority`, `s1`.`PickupStatusId0`, `s1`.`Name` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`Id`, `r`.`PickupStatusId`, `r`.`Priority`, `s0`.`PickupStatusId` AS `PickupStatusId0`, `s0`.`Name` + FROM `Requests` AS `r` + INNER JOIN `Statuses` AS `s0` ON `r`.`PickupStatusId` = `s0`.`PickupStatusId` +) AS `s1` ON `s`.`PickupStatusId` = `s1`.`PickupStatusId0` +ORDER BY `s`.`PickupStatusId` +"""); + } + + public override async Task Distinct_with_unconsumed_marker_is_benign() + { + await base.Distinct_with_unconsumed_marker_is_benign(); + + AssertSql( + """ +SELECT `s0`.`PickupStatusId`, `s0`.`pickupStatusId0`, `s0`.`Count`, `s0`.`marker` +FROM ( + SELECT DISTINCT `s`.`PickupStatusId`, `r0`.`pickupStatusId` AS `pickupStatusId0`, `r0`.`Count`, `r0`.`marker` + FROM `Statuses` AS `s` + LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` + ) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +) AS `s0` +ORDER BY `s0`.`PickupStatusId`, `s0`.`pickupStatusId0` +"""); + } + + public override async Task Member_only_access_nested_two_joins_deep() + { + await base.Member_only_access_nested_two_joins_deep(); + + AssertSql( + """ +SELECT `s0`.`PickupStatusId`, `s0`.`Name`, `s1`.`marker` IS NULL, `s1`.`pickupStatusId0`, `s1`.`Count` +FROM ( + SELECT DISTINCT `s`.`PickupStatusId`, `r0`.`pickupStatusId` AS `pickupStatusId0`, `r0`.`Count`, `r0`.`marker` + FROM `Statuses` AS `s` + LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` + ) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +) AS `s1` +INNER JOIN `Statuses` AS `s0` ON `s1`.`PickupStatusId` = `s0`.`PickupStatusId` +ORDER BY `s0`.`PickupStatusId`, `s1`.`pickupStatusId0` +"""); + } + + public override async Task Dto_constructor_whole_object_LeftJoin() + { + await base.Dto_constructor_whole_object_LeftJoin(); + + AssertSql(); + } + + public override async Task Struct_whole_object_LeftJoin() + { + await base.Struct_whole_object_LeftJoin(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`PickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`PickupStatusId` +"""); + } + + public override async Task Struct_whole_object_GroupJoin_DefaultIfEmpty() + { + await base.Struct_whole_object_GroupJoin_DefaultIfEmpty(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`PickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`PickupStatusId` +"""); + } + + public override async Task RecordStruct_whole_object_LeftJoin() + { + await base.RecordStruct_whole_object_LeftJoin(); + + AssertSql(); + } + + public override async Task Nullable_struct_whole_object_from_nullable_side() + { + await base.Nullable_struct_whole_object_from_nullable_side(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`PickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`PickupStatusId` +"""); + } + + public override async Task ValueTuple_whole_object_from_nullable_side() + { + await base.ValueTuple_whole_object_from_nullable_side(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`PickupStatusId`, `r0`.`c`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId`, COUNT(*) AS `c`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`PickupStatusId` +"""); + } + + public override async Task Second_join_after_then_whole_object() + { + await base.Second_join_after_then_whole_object(); + + AssertSql( + """ +SELECT `s0`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM (`Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId`) +INNER JOIN `Statuses` AS `s0` ON `s`.`PickupStatusId` = `s0`.`PickupStatusId` +ORDER BY `s0`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Plain_inner_no_aggregate_LeftJoin_whole_object() + { + await base.Plain_inner_no_aggregate_LeftJoin_whole_object(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r`.`PickupStatusId`, 1 AS `Count` +FROM `Statuses` AS `s` +LEFT JOIN `Requests` AS `r` ON `s`.`PickupStatusId` = `r`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r`.`Id` +"""); + } + + public override async Task Union_of_two_leftjoin_nonentity() + { + await base.Union_of_two_leftjoin_nonentity(); + + AssertSql(); + } + + public override async Task OrderBy_member_of_nullable_projection() + { + await base.OrderBy_member_of_nullable_projection(); + + AssertSql(); + } + + public override async Task Where_nonentity_projection_not_null_serverside() + { + await base.Where_nonentity_projection_not_null_serverside(); + + AssertSql(); + } + + public override async Task Where_nonentity_projection_null_serverside() + { + await base.Where_nonentity_projection_null_serverside(); + + AssertSql(); + } + + public override async Task Matched_struct_row_with_zero_aggregate_keeps_real_key() + { + await base.Matched_struct_row_with_zero_aggregate_keeps_real_key(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`PickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM `Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId`, COUNT(IIF(`r`.`Priority` > 100, 1, NULL)) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r0`.`PickupStatusId` +"""); + } + + public override async Task RightJoin_whole_object_outer_nullable() + { + await base.RightJoin_whole_object_outer_nullable(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count` +FROM ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` +RIGHT JOIN `Statuses` AS `s` ON `r0`.`pickupStatusId` = `s`.`PickupStatusId` +ORDER BY `s`.`PickupStatusId` +"""); + } + + public override async Task GroupBy_after_join_then_whole_object() + { + await base.GroupBy_after_join_then_whole_object(); + + AssertSql( + """ +SELECT [s1].[PickupStatusId], [s3].[pickupStatusId], [s3].[Count], [s3].[marker], [s3].[c] +FROM ( + SELECT [s].[PickupStatusId] + FROM [Statuses] AS [s] + LEFT JOIN ( + SELECT [r].[PickupStatusId] AS [pickupStatusId] + FROM [Requests] AS [r] + GROUP BY [r].[PickupStatusId] + ) AS [r0] ON [s].[PickupStatusId] = [r0].[pickupStatusId] + GROUP BY [s].[PickupStatusId] +) AS [s1] +LEFT JOIN ( + SELECT [s2].[pickupStatusId], [s2].[Count], [s2].[marker], [s2].[c], [s2].[PickupStatusId0] + FROM ( + SELECT [r1].[pickupStatusId], [r1].[Count], [r1].[marker], 1 AS [c], [s0].[PickupStatusId] AS [PickupStatusId0], ROW_NUMBER() OVER(PARTITION BY [s0].[PickupStatusId] ORDER BY [s0].[PickupStatusId], [r1].[pickupStatusId]) AS [row] + FROM [Statuses] AS [s0] + LEFT JOIN ( + SELECT [r2].[PickupStatusId] AS [pickupStatusId], COUNT(*) AS [Count], 1 AS [marker] + FROM [Requests] AS [r2] + GROUP BY [r2].[PickupStatusId] + ) AS [r1] ON [s0].[PickupStatusId] = [r1].[pickupStatusId] + ) AS [s2] + WHERE [s2].[row] <= 1 +) AS [s3] ON [s1].[PickupStatusId] = [s3].[PickupStatusId0] +ORDER BY [s1].[PickupStatusId] +"""); + } + + public override async Task GroupBy_after_join_then_whole_object_nested_in_wrapper() + { + await base.GroupBy_after_join_then_whole_object_nested_in_wrapper(); + + // SQL is intentionally identical to the flat GroupBy_after_join_then_whole_object variant -- the wrapper is + // client-side-only nesting, so it changes no SQL. This test exists to exercise the nested-node rekey path. + AssertSql( + """ +SELECT [s1].[PickupStatusId], [s3].[pickupStatusId], [s3].[Count], [s3].[marker], [s3].[c] +FROM ( + SELECT [s].[PickupStatusId] + FROM [Statuses] AS [s] + LEFT JOIN ( + SELECT [r].[PickupStatusId] AS [pickupStatusId] + FROM [Requests] AS [r] + GROUP BY [r].[PickupStatusId] + ) AS [r0] ON [s].[PickupStatusId] = [r0].[pickupStatusId] + GROUP BY [s].[PickupStatusId] +) AS [s1] +LEFT JOIN ( + SELECT [s2].[pickupStatusId], [s2].[Count], [s2].[marker], [s2].[c], [s2].[PickupStatusId0] + FROM ( + SELECT [r1].[pickupStatusId], [r1].[Count], [r1].[marker], 1 AS [c], [s0].[PickupStatusId] AS [PickupStatusId0], ROW_NUMBER() OVER(PARTITION BY [s0].[PickupStatusId] ORDER BY [s0].[PickupStatusId], [r1].[pickupStatusId]) AS [row] + FROM [Statuses] AS [s0] + LEFT JOIN ( + SELECT [r2].[PickupStatusId] AS [pickupStatusId], COUNT(*) AS [Count], 1 AS [marker] + FROM [Requests] AS [r2] + GROUP BY [r2].[PickupStatusId] + ) AS [r1] ON [s0].[PickupStatusId] = [r1].[pickupStatusId] + ) AS [s2] + WHERE [s2].[row] <= 1 +) AS [s3] ON [s1].[PickupStatusId] = [s3].[PickupStatusId0] +ORDER BY [s1].[PickupStatusId] +"""); + } + + public override async Task GroupBy_after_join_then_whole_object_dto_memberinit() + { + await base.GroupBy_after_join_then_whole_object_dto_memberinit(); + + AssertSql( + """ +SELECT [s1].[PickupStatusId], [s3].[PickupStatusId], [s3].[Count], [s3].[marker], [s3].[c] +FROM ( + SELECT [s].[PickupStatusId] + FROM [Statuses] AS [s] + LEFT JOIN ( + SELECT [r].[PickupStatusId] + FROM [Requests] AS [r] + GROUP BY [r].[PickupStatusId] + ) AS [r0] ON [s].[PickupStatusId] = [r0].[PickupStatusId] + GROUP BY [s].[PickupStatusId] +) AS [s1] +LEFT JOIN ( + SELECT [s2].[PickupStatusId], [s2].[Count], [s2].[marker], [s2].[c], [s2].[PickupStatusId0] + FROM ( + SELECT [r1].[PickupStatusId], [r1].[Count], [r1].[marker], 1 AS [c], [s0].[PickupStatusId] AS [PickupStatusId0], ROW_NUMBER() OVER(PARTITION BY [s0].[PickupStatusId] ORDER BY [s0].[PickupStatusId], [r1].[PickupStatusId]) AS [row] + FROM [Statuses] AS [s0] + LEFT JOIN ( + SELECT [r2].[PickupStatusId], COUNT(*) AS [Count], 1 AS [marker] + FROM [Requests] AS [r2] + GROUP BY [r2].[PickupStatusId] + ) AS [r1] ON [s0].[PickupStatusId] = [r1].[PickupStatusId] + ) AS [s2] + WHERE [s2].[row] <= 1 +) AS [s3] ON [s1].[PickupStatusId] = [s3].[PickupStatusId0] +ORDER BY [s1].[PickupStatusId] +"""); + } + + public override async Task GroupBy_after_join_then_whole_object_struct() + { + await base.GroupBy_after_join_then_whole_object_struct(); + + AssertSql( + """ +SELECT [s1].[PickupStatusId], [s3].[PickupStatusId], [s3].[Count], [s3].[marker], [s3].[c] +FROM ( + SELECT [s].[PickupStatusId] + FROM [Statuses] AS [s] + LEFT JOIN ( + SELECT [r].[PickupStatusId] + FROM [Requests] AS [r] + GROUP BY [r].[PickupStatusId] + ) AS [r0] ON [s].[PickupStatusId] = [r0].[PickupStatusId] + GROUP BY [s].[PickupStatusId] +) AS [s1] +LEFT JOIN ( + SELECT [s2].[PickupStatusId], [s2].[Count], [s2].[marker], [s2].[c], [s2].[PickupStatusId0] + FROM ( + SELECT [r1].[PickupStatusId], [r1].[Count], [r1].[marker], 1 AS [c], [s0].[PickupStatusId] AS [PickupStatusId0], ROW_NUMBER() OVER(PARTITION BY [s0].[PickupStatusId] ORDER BY [s0].[PickupStatusId], [r1].[PickupStatusId]) AS [row] + FROM [Statuses] AS [s0] + LEFT JOIN ( + SELECT [r2].[PickupStatusId], COUNT(*) AS [Count], 1 AS [marker] + FROM [Requests] AS [r2] + GROUP BY [r2].[PickupStatusId] + ) AS [r1] ON [s0].[PickupStatusId] = [r1].[PickupStatusId] + ) AS [s2] + WHERE [s2].[row] <= 1 +) AS [s3] ON [s1].[PickupStatusId] = [s3].[PickupStatusId0] +ORDER BY [s1].[PickupStatusId] +"""); + } + + public override async Task Two_left_joined_nonentity_objects_second_marker_orphaned() + { + await base.Two_left_joined_nonentity_objects_second_marker_orphaned(); + + AssertSql( + """ +SELECT `s`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker`, `r2`.`pickupStatusId`, `r2`.`Count`, `r2`.`marker` +FROM (`Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId`) +LEFT JOIN ( + SELECT `r1`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r1`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r1` + GROUP BY `r1`.`PickupStatusId` +) AS `r2` ON `s`.`PickupStatusId` = `r2`.`pickupStatusId` +ORDER BY `s`.`PickupStatusId`, `r2`.`pickupStatusId` +"""); + } + + public override async Task Three_sequential_joins_marker_survives_two_remaps() + { + await base.Three_sequential_joins_marker_survives_two_remaps(); + + AssertSql( + """ +SELECT `s1`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM ((`Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId`) +INNER JOIN `Statuses` AS `s0` ON `s`.`PickupStatusId` = `s0`.`PickupStatusId`) +LEFT JOIN `Statuses` AS `s1` ON `s0`.`PickupStatusId` = `s1`.`PickupStatusId` +WHERE `s0`.`PickupStatusId` IS NOT NULL AND `s1`.`PickupStatusId` IS NOT NULL +ORDER BY `s1`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Marker_object_nested_in_outer_wrapper_across_second_join() + { + await base.Marker_object_nested_in_outer_wrapper_across_second_join(); + + AssertSql( + """ +SELECT `s0`.`PickupStatusId`, `r0`.`pickupStatusId`, `r0`.`Count`, `r0`.`marker` +FROM (`Statuses` AS `s` +LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `Count`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` +) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId`) +INNER JOIN `Statuses` AS `s0` ON `s`.`PickupStatusId` = `s0`.`PickupStatusId` +ORDER BY `s0`.`PickupStatusId`, `r0`.`pickupStatusId` +"""); + } + + public override async Task Query_when_null_key_in_database_should_throw() + { + await base.Query_when_null_key_in_database_should_throw(); + + AssertSql( + """ +SELECT `z`.`Id` +FROM `ZeroKey` AS `z` +"""); + } + + public override async Task Mapping_JsonElement_property_throws_a_meaningful_exception() + { + await base.Mapping_JsonElement_property_throws_a_meaningful_exception(); + + AssertSql(); + } + + public override async Task Struct_composed_user_marker_projection_into_subquery_self_heals() + { + await base.Struct_composed_user_marker_projection_into_subquery_self_heals(); + + AssertSql( + """ +SELECT `s0`.`PickupStatusId`, `s0`.`pickupStatusId0` AS `pickupStatusId`, `s0`.`marker`, `s0`.`marker0` AS `marker` +FROM ( + SELECT DISTINCT `s`.`PickupStatusId`, `r0`.`pickupStatusId` AS `pickupStatusId0`, `r0`.`marker`, `r0`.`marker0` + FROM `Statuses` AS `s` + LEFT JOIN ( + SELECT `r`.`PickupStatusId` AS `pickupStatusId`, COUNT(*) AS `marker`, IIF(`r`.`PickupStatusId` IS NULL, NULL, 1) AS `marker0` + FROM `Requests` AS `r` + GROUP BY `r`.`PickupStatusId` + ) AS `r0` ON `s`.`PickupStatusId` = `r0`.`pickupStatusId` +) AS `s0` +ORDER BY `s0`.`PickupStatusId`, `s0`.`pickupStatusId0` +"""); + } + + #endregion + + [Fact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/AdHocNavigationsQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/AdHocNavigationsQueryLibRedTest.cs index dadbe998..8429f689 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/AdHocNavigationsQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/AdHocNavigationsQueryLibRedTest.cs @@ -438,6 +438,55 @@ FROM [IdentityDocument] AS [i0] """); } + public override async Task Consecutive_selects_with_conditional_projection_should_not_include_unnecessary_joins(bool async) + { + await base.Consecutive_selects_with_conditional_projection_should_not_include_unnecessary_joins(async); + + AssertSql( + """ +SELECT TOP(1) [u].[Id], CASE + WHEN [j].[Id] IS NULL THEN CAST(1 AS bit) + ELSE CAST(0 AS bit) +END, [j].[Id] +FROM [Users] AS [u] +LEFT JOIN [Job] AS [j] ON [u].[JobId] = [j].[Id] +WHERE [u].[Id] = CAST(1 AS bigint) +"""); + } + + public override async Task Consecutive_selects_with_conditional_projection_null_navigation_returns_null(bool async) + { + await base.Consecutive_selects_with_conditional_projection_null_navigation_returns_null(async); + + AssertSql( + """ +SELECT TOP(1) [u].[Id], CASE + WHEN [j].[Id] IS NULL THEN CAST(1 AS bit) + ELSE CAST(0 AS bit) +END, [j].[Id] +FROM [Users] AS [u] +LEFT JOIN [Job] AS [j] ON [u].[JobId] = [j].[Id] +WHERE [u].[JobId] IS NULL +"""); + } + + public override async Task Consecutive_selects_with_conditional_projection_nested_navigation_accessed_includes_join(bool async) + { + await base.Consecutive_selects_with_conditional_projection_nested_navigation_accessed_includes_join(async); + + AssertSql( + """ +SELECT TOP(1) [u].[Id], CASE + WHEN [j].[Id] IS NULL THEN CAST(1 AS bit) + ELSE CAST(0 AS bit) +END, [j].[Id], [a].[Id] +FROM [Users] AS [u] +LEFT JOIN [Job] AS [j] ON [u].[JobId] = [j].[Id] +LEFT JOIN [Address] AS [a] ON [j].[AddressId] = [a].[Id] +WHERE [u].[Id] = CAST(1 AS bigint) +"""); + } + public override async Task Using_explicit_interface_implementation_as_navigation_works() { await base.Using_explicit_interface_implementation_as_navigation_works(); @@ -631,4 +680,22 @@ SELECT COUNT(*) FROM `Authors` AS `a` """); } + + public override async Task Filtered_collection_through_optional_navigation_does_not_match_on_null_keys(bool async) + { + await base.Filtered_collection_through_optional_navigation_does_not_match_on_null_keys(async); + + AssertSql( + """ +SELECT `p`.`Name`, `p`.`PersonId`, `p0`.`Name`, `p0`.`PersonId` +FROM (`People` AS `p` +LEFT JOIN `Employers` AS `e` ON `p`.`EmployerId` = `e`.`EmployerId`) +LEFT JOIN `People` AS `p0` ON `e`.`EmployerId` = `p0`.`EmployerId` AND `p`.`PersonId` <> `p0`.`PersonId` +ORDER BY `p`.`PersonId`, `p0`.`PersonId` +"""); + } + + [Fact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/AdHocQueryFiltersQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/AdHocQueryFiltersQueryLibRedTest.cs index 53acae29..be114a77 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/AdHocQueryFiltersQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/AdHocQueryFiltersQueryLibRedTest.cs @@ -19,6 +19,94 @@ public class AdHocQueryFiltersQueryLibRedTest(NonSharedFixture fixture) : AdHocQ protected override ITestStoreFactory NonSharedTestStoreFactory => LibRedTestStoreFactory.Instance; + #region 8576 + + public override async Task Named_query_filters() + { + await base.Named_query_filters(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE (`e`.`Name` LIKE 'Name%') AND NOT (`e`.`IsDeleted`) AND NOT (`e`.`IsDraft`) +"""); + } + + public override async Task Named_query_filters_anonymous() + { + await base.Named_query_filters_anonymous(); + + AssertSql( + """ +@ef_filter___ids1='1' +@ef_filter___ids2='7' + +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE `e`.`Id` NOT IN (@ef_filter___ids1, @ef_filter___ids2) +"""); + } + + public override async Task Named_query_filters_ignore_some() + { + await base.Named_query_filters_ignore_some(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE NOT (`e`.`IsDraft`) +"""); + } + + public override async Task Named_query_filters_ignore_all() + { + await base.Named_query_filters_ignore_all(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +"""); + } + + public override async Task Named_query_filters_anonymous_ignore() + { + await base.Named_query_filters_anonymous_ignore(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +"""); + } + + public override async Task Named_query_filters_overwriting() + { + await base.Named_query_filters_overwriting(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE NOT (`e`.`IsDeleted`) +"""); + } + + public override async Task Named_query_filters_removing() + { + await base.Named_query_filters_removing(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +"""); + } + + #endregion + #region 11803 [Fact] @@ -361,4 +449,81 @@ LEFT JOIN ( ORDER BY `s`.`Name` """); } + + public override async Task Query_filter_with_primary_constructor_parameter() + { + await base.Query_filter_with_primary_constructor_parameter(); + + AssertSql( + """ +@ef_filter__tenantId='00000001-0000-0000-0000-000000000001' + +SELECT `e`.`Id`, `e`.`Name`, `e`.`TenantId` +FROM `Entity38132` AS `e` +WHERE `e`.`TenantId` = @ef_filter__tenantId +"""); + } + + public override async Task Query_filter_with_context_accessor_with_constant(bool async) + { + await base.Query_filter_with_context_accessor_with_constant(async); + + AssertSql( + """ +@ef_filter__p3='False' + +SELECT `f`.`Id`, `f`.`Bar` +FROM `FooBar35111` AS `f` +WHERE IIF(@ef_filter__p3, FALSE, FALSE) +"""); + } + + public override async Task Named_query_filters_caching() + { + await base.Named_query_filters_caching(); + + AssertSql( + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE NOT (`e`.`IsDraft`) +""", + // + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE NOT (`e`.`IsDraft`) +""", + // + """ +SELECT `e`.`Id`, `e`.`IsDeleted`, `e`.`IsDraft`, `e`.`Name` +FROM `Entities` AS `e` +WHERE NOT (`e`.`IsDraft`) +"""); + } + + public override async Task Named_query_filters_combined() + { + await base.Named_query_filters_combined(); + + AssertSql(); + } + + public override async Task Query_filter_with_EF_Constant_throws() + { + await base.Query_filter_with_EF_Constant_throws(); + + AssertSql(); + } + + public override async Task Query_filter_with_EF_Parameter_throws() + { + await base.Query_filter_with_EF_Parameter_throws(); + + AssertSql(); + } + + [Fact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/AdHocQuerySplittingQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/AdHocQuerySplittingQueryLibRedTest.cs index ad493144..93761e5a 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/AdHocQuerySplittingQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/AdHocQuerySplittingQueryLibRedTest.cs @@ -323,4 +323,152 @@ public override async Task NoTracking_split_query_creates_only_required_instance ORDER BY `t`.`Id` """); } + + public override async Task NoTrackingWithIdentityResolution_split_query_basic(bool async) + { + await base.NoTrackingWithIdentityResolution_split_query_basic(async); + + AssertSql( + """ +SELECT `t`.`Id` +FROM `Tests` AS `t` +ORDER BY `t`.`Id` +"""); + } + + public override async Task NoTrackingWithIdentityResolution_split_query_complex(bool async) + { + await base.NoTrackingWithIdentityResolution_split_query_complex(async); + + AssertSql( + """ +SELECT `t`.`Id` +FROM `Tests` AS `t` +ORDER BY `t`.`Id` +"""); + } + + public override async Task Can_query_with_nav_collection_in_projection_with_split_query_in_parallel_async() + { + await base.Can_query_with_nav_collection_in_projection_with_split_query_in_parallel_async(); + + Assert.Equal(400, TestSqlLoggerFactory.SqlStatements.Count); + + AssertContainsSql( + """ +@parentId='e79c82f4-3ae7-4c65-85db-04e08cba6fa7' + +SELECT TOP(2) [p].[Id] +FROM [Parents] AS [p] +WHERE [p].[Id] = @parentId +ORDER BY [p].[Id] +""", + // + """ +@parentId='d6457b52-690a-419e-8982-a1a8551b4572' + +SELECT TOP(2) [p].[Id] +FROM [Parents] AS [p] +WHERE [p].[Id] = @parentId +ORDER BY [p].[Id] +""", + // + """ +@parentId='e79c82f4-3ae7-4c65-85db-04e08cba6fa7' + +SELECT [c2].[Id], [c2].[ParentId], [p0].[Id] +FROM ( + SELECT TOP(1) [p].[Id] + FROM [Parents] AS [p] + WHERE [p].[Id] = @parentId + ORDER BY [p].[Id] +) AS [p0] +INNER JOIN [Collection] AS [c2] ON [p0].[Id] = [c2].[ParentId] +ORDER BY [p0].[Id] +""", + // + """ +@parentId='d6457b52-690a-419e-8982-a1a8551b4572' + +SELECT [c2].[Id], [c2].[ParentId], [p0].[Id] +FROM ( + SELECT TOP(1) [p].[Id] + FROM [Parents] AS [p] + WHERE [p].[Id] = @parentId + ORDER BY [p].[Id] +) AS [p0] +INNER JOIN [Collection] AS [c2] ON [p0].[Id] = [c2].[ParentId] +ORDER BY [p0].[Id] +"""); + } + + public override async Task Can_query_with_nav_collection_in_projection_with_split_query_in_parallel_sync() + { + await base.Can_query_with_nav_collection_in_projection_with_split_query_in_parallel_sync(); + + Assert.Equal(40, TestSqlLoggerFactory.SqlStatements.Count); + + AssertContainsSql( + """ +@parentId='e79c82f4-3ae7-4c65-85db-04e08cba6fa7' + +SELECT TOP(2) [p].[Id] +FROM [Parents] AS [p] +WHERE [p].[Id] = @parentId +ORDER BY [p].[Id] +""", + // + """ +@parentId='d6457b52-690a-419e-8982-a1a8551b4572' + +SELECT TOP(2) [p].[Id] +FROM [Parents] AS [p] +WHERE [p].[Id] = @parentId +ORDER BY [p].[Id] +""", + // + """ +@parentId='e79c82f4-3ae7-4c65-85db-04e08cba6fa7' + +SELECT [c2].[Id], [c2].[ParentId], [p0].[Id] +FROM ( + SELECT TOP(1) [p].[Id] + FROM [Parents] AS [p] + WHERE [p].[Id] = @parentId + ORDER BY [p].[Id] +) AS [p0] +INNER JOIN [Collection] AS [c2] ON [p0].[Id] = [c2].[ParentId] +ORDER BY [p0].[Id] +""", + // + """ +@parentId='d6457b52-690a-419e-8982-a1a8551b4572' + +SELECT [c2].[Id], [c2].[ParentId], [p0].[Id] +FROM ( + SELECT TOP(1) [p].[Id] + FROM [Parents] AS [p] + WHERE [p].[Id] = @parentId + ORDER BY [p].[Id] +) AS [p0] +INNER JOIN [Collection] AS [c2] ON [p0].[Id] = [c2].[ParentId] +ORDER BY [p0].[Id] +"""); + } + + private void AssertContainsSql(params string[] expected) + => TestSqlLoggerFactory.AssertBaseline(expected, assertOrder: false); + + // The two split-include concurrency regression tests interleave concurrent writes (on a separate context that shares this + // fixture's SQL logger) with the split query, so the captured SQL is not deterministic. They assert behavior in the base + // class and are overridden here without a SQL baseline. + public override Task Split_include_collection_throws_for_orphan_child_rows_after_concurrent_insert(bool async) + => base.Split_include_collection_throws_for_orphan_child_rows_after_concurrent_insert(async); + + public override Task Split_include_collection_not_dropped_when_other_parent_made_childless_concurrently(bool async) + => base.Split_include_collection_not_dropped_when_other_parent_made_childless_concurrently(async); + + [Fact] + public virtual void Check_all_tests_overridden() + => TestHelpers.AssertAllMethodsOverridden(GetType()); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/GearsOfWarQueryLibRedFixture.cs b/test/EFCore.LibRed.FunctionalTests/Query/GearsOfWarQueryLibRedFixture.cs index e6fa8ecf..5c167fc8 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/GearsOfWarQueryLibRedFixture.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/GearsOfWarQueryLibRedFixture.cs @@ -92,7 +92,7 @@ public override ISetSource GetExpectedData() mission.Date = mission.Date.AddYears(100); } mission.Timeline = LibRedTestHelpers.GetExpectedValue(mission.Timeline); - mission.Duration = new TimeSpan(mission.Duration.Days, mission.Duration.Hours, mission.Duration.Minutes, mission.Duration.Seconds); + mission.Duration = new TimeSpan(mission.Duration.Days, mission.Duration.Hours, mission.Duration.Minutes, mission.Duration.Seconds, mission.Duration.Milliseconds); } foreach (var tag in data.Tags) diff --git a/test/EFCore.LibRed.FunctionalTests/Query/GearsOfWarQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/GearsOfWarQueryLibRedTest.cs index f2fb0070..986ceec7 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/GearsOfWarQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/GearsOfWarQueryLibRedTest.cs @@ -6078,9 +6078,9 @@ await AssertQuery( AssertSql( """ -@start='1902-01-01T08:30:00.0000000Z' (DbType = DateTime) -@end='1902-01-03T08:30:00.0000000Z' (DbType = DateTime) -@dates1='1902-01-02T08:30:00.0000000Z' (DbType = DateTime) +@start='1902-01-01T08:30:00.1230000Z' (DbType = DateTime) +@end='1902-01-03T08:30:00.1230000Z' (DbType = DateTime) +@dates1='1902-01-02T08:30:00.1230000Z' (DbType = DateTime) SELECT `m`.`Id`, `m`.`CodeName`, `m`.`Date`, `m`.`Difficulty`, `m`.`Duration`, `m`.`Rating`, `m`.`Time`, `m`.`Timeline` FROM `Missions` AS `m` diff --git a/test/EFCore.LibRed.FunctionalTests/Query/NorthwindMiscellaneousQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/NorthwindMiscellaneousQueryLibRedTest.cs index 320f5fac..fd7d4d9d 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/NorthwindMiscellaneousQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/NorthwindMiscellaneousQueryLibRedTest.cs @@ -3599,8 +3599,10 @@ public override async Task Select_expression_date_add_milliseconds_large_number_ await base.Select_expression_date_add_milliseconds_large_number_divided(isAsync); AssertSql( -""" -SELECT `o`.`OrderDate` + """ +@millisecondsPerDay='86400000' + +SELECT DATEADD('ms', CDBL(CLNG(DATEPART('ms', `o`.`OrderDate`)) MOD @millisecondsPerDay), DATEADD('d', CDBL(CLNG(DATEPART('ms', `o`.`OrderDate`)) / @millisecondsPerDay), `o`.`OrderDate`)) AS `OrderDate` FROM `Orders` AS `o` WHERE `o`.`OrderDate` IS NOT NULL """); diff --git a/test/EFCore.LibRed.FunctionalTests/Query/NorthwindSelectQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/NorthwindSelectQueryLibRedTest.cs index 28e96974..67b57f44 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/NorthwindSelectQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/NorthwindSelectQueryLibRedTest.cs @@ -1106,8 +1106,8 @@ public override async Task Select_datetime_millisecond_component(bool isAsync) await base.Select_datetime_millisecond_component(isAsync); AssertSql( -""" -SELECT `o`.`OrderDate` + """ +SELECT DATEPART('ms', `o`.`OrderDate`) FROM `Orders` AS `o` """); } diff --git a/test/EFCore.LibRed.FunctionalTests/Query/TPCGearsOfWarQueryLibRedFixture.cs b/test/EFCore.LibRed.FunctionalTests/Query/TPCGearsOfWarQueryLibRedFixture.cs index 95389478..69f5bcb7 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/TPCGearsOfWarQueryLibRedFixture.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/TPCGearsOfWarQueryLibRedFixture.cs @@ -96,7 +96,7 @@ public override ISetSource GetExpectedData() mission.Date = mission.Date.AddYears(100); } mission.Timeline = LibRedTestHelpers.GetExpectedValue(mission.Timeline); - mission.Duration = new TimeSpan(mission.Duration.Days, mission.Duration.Hours, mission.Duration.Minutes, mission.Duration.Seconds); + mission.Duration = new TimeSpan(mission.Duration.Days, mission.Duration.Hours, mission.Duration.Minutes, mission.Duration.Seconds, mission.Duration.Milliseconds); } foreach (var tag in data.Tags) diff --git a/test/EFCore.LibRed.FunctionalTests/Query/TPCGearsOfWarQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/TPCGearsOfWarQueryLibRedTest.cs index b9a37526..fdf4ad5e 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/TPCGearsOfWarQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/TPCGearsOfWarQueryLibRedTest.cs @@ -8277,9 +8277,9 @@ await AssertQuery( AssertSql( """ -@start='1902-01-01T08:30:00.0000000Z' (DbType = DateTime) -@end='1902-01-03T08:30:00.0000000Z' (DbType = DateTime) -@dates1='1902-01-02T08:30:00.0000000Z' (DbType = DateTime) +@start='1902-01-01T08:30:00.1230000Z' (DbType = DateTime) +@end='1902-01-03T08:30:00.1230000Z' (DbType = DateTime) +@dates1='1902-01-02T08:30:00.1230000Z' (DbType = DateTime) SELECT `m`.`Id`, `m`.`CodeName`, `m`.`Date`, `m`.`Difficulty`, `m`.`Duration`, `m`.`Rating`, `m`.`Time`, `m`.`Timeline` FROM `Missions` AS `m` diff --git a/test/EFCore.LibRed.FunctionalTests/Query/TPTGearsOfWarQueryLibRedFixture.cs b/test/EFCore.LibRed.FunctionalTests/Query/TPTGearsOfWarQueryLibRedFixture.cs index 1ac7a9f6..c4a5aa78 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/TPTGearsOfWarQueryLibRedFixture.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/TPTGearsOfWarQueryLibRedFixture.cs @@ -96,7 +96,7 @@ public override ISetSource GetExpectedData() mission.Date = mission.Date.AddYears(100); } mission.Timeline = LibRedTestHelpers.GetExpectedValue(mission.Timeline); - mission.Duration = new TimeSpan(mission.Duration.Days, mission.Duration.Hours, mission.Duration.Minutes, mission.Duration.Seconds); + mission.Duration = new TimeSpan(mission.Duration.Days, mission.Duration.Hours, mission.Duration.Minutes, mission.Duration.Seconds, mission.Duration.Milliseconds); } foreach (var tag in data.Tags) diff --git a/test/EFCore.LibRed.FunctionalTests/Query/TPTGearsOfWarQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/TPTGearsOfWarQueryLibRedTest.cs index 5105f0f2..616447a5 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/TPTGearsOfWarQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/TPTGearsOfWarQueryLibRedTest.cs @@ -6640,9 +6640,9 @@ await AssertQuery( AssertSql( """ -@start='1902-01-01T08:30:00.0000000Z' (DbType = DateTime) -@end='1902-01-03T08:30:00.0000000Z' (DbType = DateTime) -@dates1='1902-01-02T08:30:00.0000000Z' (DbType = DateTime) +@start='1902-01-01T08:30:00.1230000Z' (DbType = DateTime) +@end='1902-01-03T08:30:00.1230000Z' (DbType = DateTime) +@dates1='1902-01-02T08:30:00.1230000Z' (DbType = DateTime) SELECT `m`.`Id`, `m`.`CodeName`, `m`.`Date`, `m`.`Difficulty`, `m`.`Duration`, `m`.`Rating`, `m`.`Time`, `m`.`Timeline` FROM `Missions` AS `m` diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs index dd0c4572..f675e1e3 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs @@ -292,7 +292,7 @@ public override async Task AddMilliseconds() AssertSql( """ -SELECT `b`.`DateTimeOffset` +SELECT DATEADD('ms', 300.0, `b`.`DateTimeOffset`) FROM `BasicTypesEntities` AS `b` """); } diff --git a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs index 396dce2f..31f707db 100644 --- a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs +++ b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/ManyTypesEntityType.cs @@ -802,7 +802,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - dateTime.TypeMapping = JetDateTimeTypeMapping.Default; + dateTime.TypeMapping = LibRedDateTimeTypeMapping.Default; var dateTimeArray = runtimeEntityType.AddProperty( "DateTimeArray", @@ -845,7 +845,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var dateTimeArrayElementType = dateTimeArray.SetElementType(typeof(DateTime)); dateTimeArrayElementType.TypeMapping = dateTimeArray.TypeMapping.ElementTypeMapping; @@ -1095,7 +1095,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - dateTimeToTicksConverterProperty.TypeMapping = JetDateTimeTypeMapping.Default; + dateTimeToTicksConverterProperty.TypeMapping = LibRedDateTimeTypeMapping.Default; var @decimal = runtimeEntityType.AddProperty( "Decimal", @@ -5412,7 +5412,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - nullableDateTime.TypeMapping = JetDateTimeTypeMapping.Default; + nullableDateTime.TypeMapping = LibRedDateTimeTypeMapping.Default; nullableDateTime.SetComparer(new NullableValueComparer(nullableDateTime.TypeMapping.Comparer)); var nullableDateTimeArray = runtimeEntityType.AddProperty( @@ -5456,7 +5456,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfNullableStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var nullableDateTimeArrayElementType = nullableDateTimeArray.SetElementType(typeof(DateTime?), nullable: true); nullableDateTimeArrayElementType.TypeMapping = nullableDateTimeArray.TypeMapping.ElementTypeMapping; @@ -8963,7 +8963,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - nullableTimeOnly.TypeMapping = JetTimeOnlyTypeMapping.Default; + nullableTimeOnly.TypeMapping = LibRedTimeOnlyTypeMapping.Default; nullableTimeOnly.SetComparer(new NullableValueComparer(nullableTimeOnly.TypeMapping.Comparer)); var nullableTimeOnlyArray = runtimeEntityType.AddProperty( @@ -9007,7 +9007,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonTimeOnlyReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfNullableStructsReaderWriter( JsonTimeOnlyReaderWriter.Instance), - elementMapping: JetTimeOnlyTypeMapping.Default); + elementMapping: LibRedTimeOnlyTypeMapping.Default); var nullableTimeOnlyArrayElementType = nullableTimeOnlyArray.SetElementType(typeof(TimeOnly?), nullable: true); nullableTimeOnlyArrayElementType.TypeMapping = nullableTimeOnlyArray.TypeMapping.ElementTypeMapping; @@ -9045,7 +9045,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - nullableTimeSpan.TypeMapping = JetTimeSpanTypeMapping.Default; + nullableTimeSpan.TypeMapping = LibRedTimeSpanTypeMapping.Default; nullableTimeSpan.SetComparer(new NullableValueComparer(nullableTimeSpan.TypeMapping.Comparer)); var nullableTimeSpanArray = runtimeEntityType.AddProperty( @@ -9089,7 +9089,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonTimeSpanReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfNullableStructsReaderWriter( JsonTimeSpanReaderWriter.Instance), - elementMapping: JetTimeSpanTypeMapping.Default); + elementMapping: LibRedTimeSpanTypeMapping.Default); var nullableTimeSpanArrayElementType = nullableTimeSpanArray.SetElementType(typeof(TimeSpan?), nullable: true); nullableTimeSpanArrayElementType.TypeMapping = nullableTimeSpanArray.TypeMapping.ElementTypeMapping; @@ -10098,7 +10098,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - stringToDateTimeConverterProperty.TypeMapping = JetDateTimeTypeMapping.Default.Clone( + stringToDateTimeConverterProperty.TypeMapping = LibRedDateTimeTypeMapping.Default.Clone( comparer: DefaultValueComparer.Default, providerValueComparer: DefaultValueComparer.Default, mappingInfo: new RelationalTypeMappingInfo( @@ -10140,7 +10140,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - stringToDateTimeOffsetConverterProperty.TypeMapping = JetDateTimeOffsetTypeMapping.Default.Clone( + stringToDateTimeOffsetConverterProperty.TypeMapping = LibRedDateTimeOffsetTypeMapping.Default.Clone( comparer: DefaultValueComparer.Default, providerValueComparer: DefaultDateTimeOffsetValueComparer.Default, mappingInfo: new RelationalTypeMappingInfo( @@ -10384,7 +10384,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - stringToTimeOnlyConverterProperty.TypeMapping = JetTimeOnlyTypeMapping.Default.Clone( + stringToTimeOnlyConverterProperty.TypeMapping = LibRedTimeOnlyTypeMapping.Default.Clone( comparer: DefaultValueComparer.Default, providerValueComparer: DefaultValueComparer.Default, mappingInfo: new RelationalTypeMappingInfo( @@ -10426,7 +10426,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - stringToTimeSpanConverterProperty.TypeMapping = JetTimeSpanTypeMapping.Default.Clone( + stringToTimeSpanConverterProperty.TypeMapping = LibRedTimeSpanTypeMapping.Default.Clone( comparer: DefaultValueComparer.Default, providerValueComparer: DefaultValueComparer.Default, mappingInfo: new RelationalTypeMappingInfo( @@ -10510,7 +10510,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - timeOnly.TypeMapping = JetTimeOnlyTypeMapping.Default; + timeOnly.TypeMapping = LibRedTimeOnlyTypeMapping.Default; var timeOnlyArray = runtimeEntityType.AddProperty( "TimeOnlyArray", @@ -10553,7 +10553,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonTimeOnlyReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonTimeOnlyReaderWriter.Instance), - elementMapping: JetTimeOnlyTypeMapping.Default); + elementMapping: LibRedTimeOnlyTypeMapping.Default); var timeOnlyArrayElementType = timeOnlyArray.SetElementType(typeof(TimeOnly)); timeOnlyArrayElementType.TypeMapping = timeOnlyArray.TypeMapping.ElementTypeMapping; @@ -10674,7 +10674,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - timeSpan.TypeMapping = JetTimeSpanTypeMapping.Default; + timeSpan.TypeMapping = LibRedTimeSpanTypeMapping.Default; var timeSpanArray = runtimeEntityType.AddProperty( "TimeSpanArray", @@ -10717,7 +10717,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonTimeSpanReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonTimeSpanReaderWriter.Instance), - elementMapping: JetTimeSpanTypeMapping.Default); + elementMapping: LibRedTimeSpanTypeMapping.Default); var timeSpanArrayElementType = timeSpanArray.SetElementType(typeof(TimeSpan)); timeSpanArrayElementType.TypeMapping = timeSpanArray.TypeMapping.ElementTypeMapping; diff --git a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedType0EntityType.cs b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedType0EntityType.cs index 6d69c8f6..0a402b05 100644 --- a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedType0EntityType.cs +++ b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedType0EntityType.cs @@ -5,6 +5,7 @@ using System.Reflection; using EntityFrameworkCore.Jet.Metadata; using EntityFrameworkCore.Jet.Storage.Internal; +using EntityFrameworkCore.LibRed.Storage.Internal; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; @@ -431,7 +432,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var valueTypeArrayElementType = valueTypeArray.SetElementType(typeof(DateTime)); valueTypeArrayElementType.TypeMapping = valueTypeArray.TypeMapping.ElementTypeMapping; diff --git a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedTypeEntityType.cs b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedTypeEntityType.cs index 8c261284..6f772192 100644 --- a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedTypeEntityType.cs +++ b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/OwnedTypeEntityType.cs @@ -5,6 +5,7 @@ using System.Reflection; using EntityFrameworkCore.Jet.Metadata; using EntityFrameworkCore.Jet.Storage.Internal; +using EntityFrameworkCore.LibRed.Storage.Internal; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; @@ -445,7 +446,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var valueTypeArrayElementType = valueTypeArray.SetElementType(typeof(DateTime)); valueTypeArrayElementType.TypeMapping = valueTypeArray.TypeMapping.ElementTypeMapping; diff --git a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs index 6e0a0aee..75087921 100644 --- a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs +++ b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel/PrincipalBaseEntityType.cs @@ -5,6 +5,7 @@ using System.Reflection; using EntityFrameworkCore.Jet.Metadata; using EntityFrameworkCore.Jet.Storage.Internal; +using EntityFrameworkCore.LibRed.Storage.Internal; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.ChangeTracking.Internal; @@ -552,7 +553,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var valueTypeArrayElementType = valueTypeArray.SetElementType(typeof(DateTime)); valueTypeArrayElementType.TypeMapping = valueTypeArray.TypeMapping.ElementTypeMapping; diff --git a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/ManyTypesEntityType.cs b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/ManyTypesEntityType.cs index 396dce2f..31f707db 100644 --- a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/ManyTypesEntityType.cs +++ b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/ManyTypesEntityType.cs @@ -802,7 +802,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - dateTime.TypeMapping = JetDateTimeTypeMapping.Default; + dateTime.TypeMapping = LibRedDateTimeTypeMapping.Default; var dateTimeArray = runtimeEntityType.AddProperty( "DateTimeArray", @@ -845,7 +845,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var dateTimeArrayElementType = dateTimeArray.SetElementType(typeof(DateTime)); dateTimeArrayElementType.TypeMapping = dateTimeArray.TypeMapping.ElementTypeMapping; @@ -1095,7 +1095,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - dateTimeToTicksConverterProperty.TypeMapping = JetDateTimeTypeMapping.Default; + dateTimeToTicksConverterProperty.TypeMapping = LibRedDateTimeTypeMapping.Default; var @decimal = runtimeEntityType.AddProperty( "Decimal", @@ -5412,7 +5412,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - nullableDateTime.TypeMapping = JetDateTimeTypeMapping.Default; + nullableDateTime.TypeMapping = LibRedDateTimeTypeMapping.Default; nullableDateTime.SetComparer(new NullableValueComparer(nullableDateTime.TypeMapping.Comparer)); var nullableDateTimeArray = runtimeEntityType.AddProperty( @@ -5456,7 +5456,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfNullableStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var nullableDateTimeArrayElementType = nullableDateTimeArray.SetElementType(typeof(DateTime?), nullable: true); nullableDateTimeArrayElementType.TypeMapping = nullableDateTimeArray.TypeMapping.ElementTypeMapping; @@ -8963,7 +8963,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - nullableTimeOnly.TypeMapping = JetTimeOnlyTypeMapping.Default; + nullableTimeOnly.TypeMapping = LibRedTimeOnlyTypeMapping.Default; nullableTimeOnly.SetComparer(new NullableValueComparer(nullableTimeOnly.TypeMapping.Comparer)); var nullableTimeOnlyArray = runtimeEntityType.AddProperty( @@ -9007,7 +9007,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonTimeOnlyReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfNullableStructsReaderWriter( JsonTimeOnlyReaderWriter.Instance), - elementMapping: JetTimeOnlyTypeMapping.Default); + elementMapping: LibRedTimeOnlyTypeMapping.Default); var nullableTimeOnlyArrayElementType = nullableTimeOnlyArray.SetElementType(typeof(TimeOnly?), nullable: true); nullableTimeOnlyArrayElementType.TypeMapping = nullableTimeOnlyArray.TypeMapping.ElementTypeMapping; @@ -9045,7 +9045,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - nullableTimeSpan.TypeMapping = JetTimeSpanTypeMapping.Default; + nullableTimeSpan.TypeMapping = LibRedTimeSpanTypeMapping.Default; nullableTimeSpan.SetComparer(new NullableValueComparer(nullableTimeSpan.TypeMapping.Comparer)); var nullableTimeSpanArray = runtimeEntityType.AddProperty( @@ -9089,7 +9089,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonTimeSpanReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfNullableStructsReaderWriter( JsonTimeSpanReaderWriter.Instance), - elementMapping: JetTimeSpanTypeMapping.Default); + elementMapping: LibRedTimeSpanTypeMapping.Default); var nullableTimeSpanArrayElementType = nullableTimeSpanArray.SetElementType(typeof(TimeSpan?), nullable: true); nullableTimeSpanArrayElementType.TypeMapping = nullableTimeSpanArray.TypeMapping.ElementTypeMapping; @@ -10098,7 +10098,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - stringToDateTimeConverterProperty.TypeMapping = JetDateTimeTypeMapping.Default.Clone( + stringToDateTimeConverterProperty.TypeMapping = LibRedDateTimeTypeMapping.Default.Clone( comparer: DefaultValueComparer.Default, providerValueComparer: DefaultValueComparer.Default, mappingInfo: new RelationalTypeMappingInfo( @@ -10140,7 +10140,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - stringToDateTimeOffsetConverterProperty.TypeMapping = JetDateTimeOffsetTypeMapping.Default.Clone( + stringToDateTimeOffsetConverterProperty.TypeMapping = LibRedDateTimeOffsetTypeMapping.Default.Clone( comparer: DefaultValueComparer.Default, providerValueComparer: DefaultDateTimeOffsetValueComparer.Default, mappingInfo: new RelationalTypeMappingInfo( @@ -10384,7 +10384,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - stringToTimeOnlyConverterProperty.TypeMapping = JetTimeOnlyTypeMapping.Default.Clone( + stringToTimeOnlyConverterProperty.TypeMapping = LibRedTimeOnlyTypeMapping.Default.Clone( comparer: DefaultValueComparer.Default, providerValueComparer: DefaultValueComparer.Default, mappingInfo: new RelationalTypeMappingInfo( @@ -10426,7 +10426,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - stringToTimeSpanConverterProperty.TypeMapping = JetTimeSpanTypeMapping.Default.Clone( + stringToTimeSpanConverterProperty.TypeMapping = LibRedTimeSpanTypeMapping.Default.Clone( comparer: DefaultValueComparer.Default, providerValueComparer: DefaultValueComparer.Default, mappingInfo: new RelationalTypeMappingInfo( @@ -10510,7 +10510,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - timeOnly.TypeMapping = JetTimeOnlyTypeMapping.Default; + timeOnly.TypeMapping = LibRedTimeOnlyTypeMapping.Default; var timeOnlyArray = runtimeEntityType.AddProperty( "TimeOnlyArray", @@ -10553,7 +10553,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonTimeOnlyReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonTimeOnlyReaderWriter.Instance), - elementMapping: JetTimeOnlyTypeMapping.Default); + elementMapping: LibRedTimeOnlyTypeMapping.Default); var timeOnlyArrayElementType = timeOnlyArray.SetElementType(typeof(TimeOnly)); timeOnlyArrayElementType.TypeMapping = timeOnlyArray.TypeMapping.ElementTypeMapping; @@ -10674,7 +10674,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas shadowIndex: -1, relationshipIndex: -1, storeGenerationIndex: -1); - timeSpan.TypeMapping = JetTimeSpanTypeMapping.Default; + timeSpan.TypeMapping = LibRedTimeSpanTypeMapping.Default; var timeSpanArray = runtimeEntityType.AddProperty( "TimeSpanArray", @@ -10717,7 +10717,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonTimeSpanReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonTimeSpanReaderWriter.Instance), - elementMapping: JetTimeSpanTypeMapping.Default); + elementMapping: LibRedTimeSpanTypeMapping.Default); var timeSpanArrayElementType = timeSpanArray.SetElementType(typeof(TimeSpan)); timeSpanArrayElementType.TypeMapping = timeSpanArray.TypeMapping.ElementTypeMapping; diff --git a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/OwnedType0EntityType.cs b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/OwnedType0EntityType.cs index 8f20b817..ade9fda0 100644 --- a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/OwnedType0EntityType.cs +++ b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/OwnedType0EntityType.cs @@ -423,7 +423,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var valueTypeArrayElementType = valueTypeArray.SetElementType(typeof(DateTime)); valueTypeArrayElementType.TypeMapping = valueTypeArray.TypeMapping.ElementTypeMapping; diff --git a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/OwnedTypeEntityType.cs b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/OwnedTypeEntityType.cs index ceaa2ce0..b182cffe 100644 --- a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/OwnedTypeEntityType.cs +++ b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/OwnedTypeEntityType.cs @@ -413,7 +413,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var valueTypeArrayElementType = valueTypeArray.SetElementType(typeof(DateTime)); valueTypeArrayElementType.TypeMapping = valueTypeArray.TypeMapping.ElementTypeMapping; diff --git a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs index 38fbc347..91c092c7 100644 --- a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs +++ b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/BigModel_with_JSON_columns/PrincipalBaseEntityType.cs @@ -557,7 +557,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var valueTypeArrayElementType = valueTypeArray.SetElementType(typeof(DateTime)); valueTypeArrayElementType.TypeMapping = valueTypeArray.TypeMapping.ElementTypeMapping; diff --git a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs index ab6ed02c..da4935fe 100644 --- a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs +++ b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalBaseEntityType.cs @@ -541,7 +541,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var valueTypeArrayElementType = valueTypeArray.SetElementType(typeof(DateTime)); valueTypeArrayElementType.TypeMapping = valueTypeArray.TypeMapping.ElementTypeMapping; @@ -1175,7 +1175,7 @@ public static RuntimeComplexProperty Create(RuntimeEntityType declaringType) JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var valueTypeArrayElementType = valueTypeArray.SetElementType(typeof(DateTime)); valueTypeArrayElementType.TypeMapping = valueTypeArray.TypeMapping.ElementTypeMapping; valueTypeArray.AddAnnotation("Jet:ValueGenerationStrategy", JetValueGenerationStrategy.None); @@ -2072,7 +2072,7 @@ public static RuntimeComplexProperty Create(RuntimeComplexType declaringType) JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var valueTypeArrayElementType = valueTypeArray.SetElementType(typeof(DateTime)); valueTypeArrayElementType.TypeMapping = valueTypeArray.TypeMapping.ElementTypeMapping; valueTypeArray.AddAnnotation("Jet:ValueGenerationStrategy", JetValueGenerationStrategy.None); diff --git a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalDerivedEntityType.cs b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalDerivedEntityType.cs index aed1968b..9a53cdb0 100644 --- a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalDerivedEntityType.cs +++ b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/ComplexTypes/PrincipalDerivedEntityType.cs @@ -689,7 +689,7 @@ public static RuntimeComplexProperty Create(RuntimeEntityType declaringType) JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var valueTypeArrayElementType = valueTypeArray.SetElementType(typeof(DateTime)); valueTypeArrayElementType.TypeMapping = valueTypeArray.TypeMapping.ElementTypeMapping; @@ -1723,7 +1723,7 @@ public static RuntimeComplexProperty Create(RuntimeComplexType declaringType) JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var valueTypeArrayElementType = valueTypeArray.SetElementType(typeof(DateTime)); valueTypeArrayElementType.TypeMapping = valueTypeArray.TypeMapping.ElementTypeMapping; diff --git a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/Tpc_Sprocs/PrincipalBaseEntityType.cs b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/Tpc_Sprocs/PrincipalBaseEntityType.cs index ae9aade2..50f05031 100644 --- a/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/Tpc_Sprocs/PrincipalBaseEntityType.cs +++ b/test/EFCore.LibRed.FunctionalTests/Scaffolding/Baselines/Tpc_Sprocs/PrincipalBaseEntityType.cs @@ -554,7 +554,7 @@ public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType bas JsonDateTimeReaderWriter.Instance)), jsonValueReaderWriter: new JsonCollectionOfStructsReaderWriter( JsonDateTimeReaderWriter.Instance), - elementMapping: JetDateTimeTypeMapping.Default); + elementMapping: LibRedDateTimeTypeMapping.Default); var valueTypeArrayElementType = valueTypeArray.SetElementType(typeof(DateTime)); valueTypeArrayElementType.TypeMapping = valueTypeArray.TypeMapping.ElementTypeMapping; diff --git a/test/EFCore.LibRed.FunctionalTests/TestUtilities/LibRedTestHelpers.cs b/test/EFCore.LibRed.FunctionalTests/TestUtilities/LibRedTestHelpers.cs index 14d44235..81e6cd74 100644 --- a/test/EFCore.LibRed.FunctionalTests/TestUtilities/LibRedTestHelpers.cs +++ b/test/EFCore.LibRed.FunctionalTests/TestUtilities/LibRedTestHelpers.cs @@ -28,7 +28,7 @@ public override DbContextOptionsBuilder UseProviderOptions(DbContextOptionsBuild public static DateTimeOffset GetExpectedValue(DateTimeOffset value) { var val = value.UtcDateTime; - return new DateTimeOffset(new DateTime(val.Year, val.Month, val.Day, val.Hour, val.Minute, val.Second), TimeSpan.Zero); + return new DateTimeOffset(new DateTime(val.Year, val.Month, val.Day, val.Hour, val.Minute, val.Second, val.Millisecond), TimeSpan.Zero); } } } From 27fca4972cc0b52e14e875be885f741f02117a05 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Wed, 26 Aug 2026 00:13:58 +0800 Subject: [PATCH 39/48] Replace two stale base expectations with what upstream actually asserts Both of these were failing against expectations that no real provider holds - one stale in the base, one faked in our own file. Neither is a Jet or LibRed limitation. Join_local_bytes_closure_is_cached_correctly: the base wraps the query in AssertTranslationFailed, but a byte[] joined to a local collection translates perfectly well - SQL Server does it through VALUES and overrides the base with a real-results reimplementation. We inline the same thing through the #Dual UNION subquery, which is element-type agnostic and so no more troubled by bytes than by ints. Reimplemented following SQL Server's shape. LibRed carries the SQL as probed from a run; Jet keeps SQL Server's bracket baseline, because the whole Join_local family is unported there (the int sibling still has OPENJSON in it) and none of it appears in ACE's green list, so there is nothing to write real Jet SQL from. Correlated_collection_after_distinct_with_complex_projection_not_containing_ original_identifier: replaced a baseline that was SQL Server SQL wearing backticks - OUTER APPLY and DATEPART(month, ...), neither of which Jet can produce - with the assert-throws upstream uses verbatim, comment and issue number included. The exception is EF's own InsufficientInformationToIdentifyElementOfCollectionJoin, raised in SelectExpression.ApplyProjection before any provider SQL is generated: a Distinct projection that drops the identifying information, efcore #24440. It is provider-independent, so asserting the refusal is the whole of the story. Co-Authored-By: Claude Opus 5 --- .../Query/NorthwindJoinQueryJetTest.cs | 34 +++++++++++++++- .../Query/NorthwindSelectQueryJetTest.cs | 29 ++++++-------- .../Query/NorthwindJoinQueryLibRedTest.cs | 39 ++++++++++++++++++- .../Query/NorthwindSelectQueryLibRedTest.cs | 29 ++++++-------- 4 files changed, 91 insertions(+), 40 deletions(-) diff --git a/test/EFCore.Jet.FunctionalTests/Query/NorthwindJoinQueryJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/NorthwindJoinQueryJetTest.cs index bc038080..bd9bee76 100644 --- a/test/EFCore.Jet.FunctionalTests/Query/NorthwindJoinQueryJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Query/NorthwindJoinQueryJetTest.cs @@ -1,8 +1,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Linq; using System.Threading.Tasks; using EntityFrameworkCore.Jet.FunctionalTests.TestUtilities; using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.TestModels.Northwind; using Microsoft.EntityFrameworkCore.TestUtilities; using Xunit; @@ -1029,9 +1031,37 @@ public override async Task Join_local_string_closure_is_cached_correctly(bool as public override async Task Join_local_bytes_closure_is_cached_correctly(bool async) { - await base.Join_local_bytes_closure_is_cached_correctly(async); + var ids = new byte[] { 1, 2 }; + await AssertQueryScalar( + async, + ss => from e in ss.Set() + join id in ids on e.EmployeeID equals id + select e.EmployeeID); - AssertSql(); + ids = [3]; + await AssertQueryScalar( + async, + ss => from e in ss.Set() + join id in ids on e.EmployeeID equals id + select e.EmployeeID); + + AssertSql( + """ +@p1='1' (Size = 1) +@p2='2' (Size = 1) + +SELECT [e].[EmployeeID] +FROM [Employees] AS [e] +INNER JOIN (VALUES (@p1), (@p2)) AS [p]([Value]) ON [e].[EmployeeID] = CAST([p].[Value] AS int) +""", + // + """ +@p1='3' (Size = 1) + +SELECT [e].[EmployeeID] +FROM [Employees] AS [e] +INNER JOIN (VALUES (@p1)) AS [p]([Value]) ON [e].[EmployeeID] = CAST([p].[Value] AS int) +"""); } public override async Task GroupJoin_customers_employees_shadow(bool async) diff --git a/test/EFCore.Jet.FunctionalTests/Query/NorthwindSelectQueryJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/NorthwindSelectQueryJetTest.cs index f1b3a4ea..f8b4b6aa 100644 --- a/test/EFCore.Jet.FunctionalTests/Query/NorthwindSelectQueryJetTest.cs +++ b/test/EFCore.Jet.FunctionalTests/Query/NorthwindSelectQueryJetTest.cs @@ -1,11 +1,12 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; -using System.Linq; -using System.Threading.Tasks; using EntityFrameworkCore.Jet.FunctionalTests.TestUtilities; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.TestUtilities; +using System; +using System.Linq; +using System.Threading.Tasks; using Xunit; namespace EntityFrameworkCore.Jet.FunctionalTests.Query @@ -2288,22 +2289,14 @@ OUTER APPLY ( public override async Task Correlated_collection_after_distinct_with_complex_projection_not_containing_original_identifier(bool async) { - await base.Correlated_collection_after_distinct_with_complex_projection_not_containing_original_identifier(async); + // Identifier set for Distinct. Issue efcore #24440. + Assert.Equal( + RelationalStrings.InsufficientInformationToIdentifyElementOfCollectionJoin, + (await Assert.ThrowsAsync(() + => base.Correlated_collection_after_distinct_with_complex_projection_not_containing_original_identifier(async))) + .Message); - AssertSql( - """ - SELECT `t`.`OrderDate`, `t`.`CustomerID`, `t`.`Complex`, `t0`.`Outer1`, `t0`.`Outer2`, `t0`.`Outer3`, `t0`.`Inner`, `t0`.`OrderDate` - FROM ( - SELECT DISTINCT `o`.`OrderDate`, `o`.`CustomerID`, DATEPART(month, `o`.`OrderDate`) AS `Complex` - FROM `Orders` AS `o` - ) AS `t` - OUTER APPLY ( - SELECT `t`.`OrderDate` AS `Outer1`, `t`.`CustomerID` AS `Outer2`, `t`.`Complex` AS `Outer3`, `o0`.`OrderID` AS `Inner`, `o0`.`OrderDate` - FROM `Orders` AS `o0` - WHERE `o0`.`OrderID` IN (10248, 10249, 10250) AND ((`t`.`CustomerID` = `o0`.`CustomerID`) OR (`t`.`CustomerID` IS NULL AND `o0`.`CustomerID` IS NULL)) - ) AS `t0` - ORDER BY `t`.`OrderDate`, `t`.`CustomerID`, `t`.`Complex`, `t0`.`Inner` - """); + AssertSql(); } public override async Task Correlated_collection_after_groupby_with_complex_projection_containing_original_identifier(bool async) diff --git a/test/EFCore.LibRed.FunctionalTests/Query/NorthwindJoinQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/NorthwindJoinQueryLibRedTest.cs index c71febc9..f4e1c7d7 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/NorthwindJoinQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/NorthwindJoinQueryLibRedTest.cs @@ -1,8 +1,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Linq; using System.Threading.Tasks; using EntityFrameworkCore.LibRed.FunctionalTests.TestUtilities; using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.TestModels.Northwind; using Microsoft.EntityFrameworkCore.TestUtilities; using Xunit; @@ -1049,9 +1051,42 @@ public override async Task Join_local_string_closure_is_cached_correctly(bool as public override async Task Join_local_bytes_closure_is_cached_correctly(bool async) { - await base.Join_local_bytes_closure_is_cached_correctly(async); + var ids = new byte[] { 1, 2 }; + await AssertQueryScalar( + async, + ss => from e in ss.Set() + join id in ids on e.EmployeeID equals id + select e.EmployeeID); - AssertSql(); + ids = [3]; + await AssertQueryScalar( + async, + ss => from e in ss.Set() + join id in ids on e.EmployeeID equals id + select e.EmployeeID); + + AssertSql( + """ +@p1='1' (Size = 1) +@p2='2' (Size = 1) + +SELECT `e`.`EmployeeID` +FROM `Employees` AS `e` +INNER JOIN (SELECT @p1 AS `Value` +FROM (SELECT COUNT(*) FROM `#Dual`) AS `p_0` +UNION +SELECT @p2 AS `Value` +FROM (SELECT COUNT(*) FROM `#Dual`) AS `p_1`) AS `p` ON `e`.`EmployeeID` = `p`.`Value` +""", + // + """ +@p1='3' (Size = 1) + +SELECT `e`.`EmployeeID` +FROM `Employees` AS `e` +INNER JOIN (SELECT @p1 AS `Value` +FROM (SELECT COUNT(*) FROM `#Dual`) AS `p_0`) AS `p` ON `e`.`EmployeeID` = `p`.`Value` +"""); } public override async Task GroupJoin_customers_employees_shadow(bool async) diff --git a/test/EFCore.LibRed.FunctionalTests/Query/NorthwindSelectQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/NorthwindSelectQueryLibRedTest.cs index 67b57f44..59672702 100644 --- a/test/EFCore.LibRed.FunctionalTests/Query/NorthwindSelectQueryLibRedTest.cs +++ b/test/EFCore.LibRed.FunctionalTests/Query/NorthwindSelectQueryLibRedTest.cs @@ -1,11 +1,12 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; -using System.Linq; -using System.Threading.Tasks; using EntityFrameworkCore.LibRed.FunctionalTests.TestUtilities; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.TestUtilities; +using System; +using System.Linq; +using System.Threading.Tasks; using Xunit; namespace EntityFrameworkCore.LibRed.FunctionalTests.Query @@ -2307,22 +2308,14 @@ OUTER APPLY ( public override async Task Correlated_collection_after_distinct_with_complex_projection_not_containing_original_identifier(bool async) { - await base.Correlated_collection_after_distinct_with_complex_projection_not_containing_original_identifier(async); + // Identifier set for Distinct. Issue efcore #24440. + Assert.Equal( + RelationalStrings.InsufficientInformationToIdentifyElementOfCollectionJoin, + (await Assert.ThrowsAsync(() + => base.Correlated_collection_after_distinct_with_complex_projection_not_containing_original_identifier(async))) + .Message); - AssertSql( - """ - SELECT `t`.`OrderDate`, `t`.`CustomerID`, `t`.`Complex`, `t0`.`Outer1`, `t0`.`Outer2`, `t0`.`Outer3`, `t0`.`Inner`, `t0`.`OrderDate` - FROM ( - SELECT DISTINCT `o`.`OrderDate`, `o`.`CustomerID`, DATEPART(month, `o`.`OrderDate`) AS `Complex` - FROM `Orders` AS `o` - ) AS `t` - OUTER APPLY ( - SELECT `t`.`OrderDate` AS `Outer1`, `t`.`CustomerID` AS `Outer2`, `t`.`Complex` AS `Outer3`, `o0`.`OrderID` AS `Inner`, `o0`.`OrderDate` - FROM `Orders` AS `o0` - WHERE `o0`.`OrderID` IN (10248, 10249, 10250) AND ((`t`.`CustomerID` = `o0`.`CustomerID`) OR (`t`.`CustomerID` IS NULL AND `o0`.`CustomerID` IS NULL)) - ) AS `t0` - ORDER BY `t`.`OrderDate`, `t`.`CustomerID`, `t`.`Complex`, `t0`.`Inner` - """); + AssertSql(); } public override async Task Correlated_collection_after_groupby_with_complex_projection_containing_original_identifier(bool async) From 48f7de14ca26bc5a78abcfd5889534ffa8597c83 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Wed, 26 Aug 2026 00:56:53 +0800 Subject: [PATCH 40/48] LibRed: make a connection observe two ADO.NET contracts it was ignoring Both fixes are ports of what JetConnection already does, which is in turn what SqlConnection does - all of ConnectionSpecificationTest passes on Jet, and now does on LibRed too (5 failures to 0 of 27). A disposed connection has to be unusable. Dispose() only called Close(), and nothing recorded that the connection was finished with, so a later Open() just reopened the file. Clearing the connection string is what enforces it: Open() then fails its existing "missing a Data Source" guard. That also fixes the exception TYPE, which matters here - the tests use xunit's exact-match Assert.Throws, so an ObjectDisposedException would be rejected despite deriving from it. Assigned to the field directly, since the property setter refuses to change while the connection is open. That one line fixed four tests rather than the one it was chased for: the two Can_specify_owned_connection_in_OnConfiguring variants and both Can_specify_no_connection_in_OnConfiguring(contextOwnsConnection: true) ones were the same contract seen from different angles. Open() and Close() now raise OnStateChange. They were assigning _state directly and telling nobody, so a caller watching StateChange - EF's connection diagnostics among them - saw a connection that never opened or closed. Close() is not guarded against being called on an already closed connection, and Dispose() calls it, so the event is raised only on a real transition; firing unconditionally would report a second close that never happened. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Ado/LibRedConnection.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/LibRed/LibRed.Ado/LibRedConnection.cs b/src/LibRed/LibRed.Ado/LibRedConnection.cs index 391f276f..37d6fb1d 100644 --- a/src/LibRed/LibRed.Ado/LibRedConnection.cs +++ b/src/LibRed/LibRed.Ado/LibRedConnection.cs @@ -192,6 +192,7 @@ public override void Open() _database = JetDatabase.Open(path, readOnly: false); Engine = new QueryEngine(_database); _state = ConnectionState.Open; + OnStateChange(new StateChangeEventArgs(ConnectionState.Closed, ConnectionState.Open)); } public override void Close() @@ -208,7 +209,14 @@ public override void Close() _database?.Dispose(); _database = null; Engine = null; + + // Only a real transition raises the event. Close() is not guarded against being called on an already + // closed connection - and Dispose() calls it - so firing unconditionally would report a second close + // that never happened. EF's connection diagnostics count these. + if (_state == ConnectionState.Closed) return; + _state = ConnectionState.Closed; + OnStateChange(new StateChangeEventArgs(ConnectionState.Open, ConnectionState.Closed)); } public override void ChangeDatabase(string databaseName) => @@ -234,7 +242,15 @@ protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLeve protected override void Dispose(bool disposing) { + // Clearing the connection string is what makes a disposed connection unusable, as ADO.NET requires: + // Open() then fails its existing "missing a Data Source" guard instead of quietly reopening the file. + // SqlConnection and JetConnection both do exactly this, and it is why the resulting exception is an + // InvalidOperationException rather than an ObjectDisposedException. Assigned to the field directly + // because the property setter refuses to change while the connection is still open. + _connectionString = string.Empty; + if (disposing) Close(); + base.Dispose(disposing); } From 9b9944141925b7e2bde0f43f115abc48b433cb7b Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Wed, 26 Aug 2026 00:59:05 +0800 Subject: [PATCH 41/48] Move the Ado temporal tests onto the millisecond boundary Three unit tests still asserted the whole-second truncation that "LibRed: carry milliseconds through the temporal path" deliberately replaced. That commit updated the functional fixtures and missed these, so they have been red since. They are updated rather than deleted: each still pins a truncation boundary, just the one that now exists. Sub-millisecond is where values stop surviving, because .NET's ToOADate/FromOADate quantise there, so that is what the tests prove is discarded. - DateTime_parameter_is_truncated_to_whole_seconds is renamed for milliseconds and now inserts a sub-millisecond value, expecting the millisecond back. The `WHERE d = @p` half is kept, reusing the sub-millisecond parameter so the write and comparison paths still have to agree on the same boundary. - TimeSpan_parameter_round_trips_through_a_datetime_column expects its 678 ms back instead of whole seconds. - TemporalParameterTests' "sub-second stripped both ways" case becomes a millisecond that survives, plus a new case storing 7.5 ms and querying 7 ms to pin the boundary from the other side. LibRed.Ado.Tests 60/60. Engine 1020/1020 and Core 793 passed / 46 skipped were already clean and are unaffected. Co-Authored-By: Claude Opus 5 --- test/LibRed.Ado.Tests/LibRedCommandTests.cs | 23 ++++++++++--------- .../TemporalParameterTests.cs | 4 +++- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/test/LibRed.Ado.Tests/LibRedCommandTests.cs b/test/LibRed.Ado.Tests/LibRedCommandTests.cs index b635fe8d..64925b71 100644 --- a/test/LibRed.Ado.Tests/LibRedCommandTests.cs +++ b/test/LibRed.Ado.Tests/LibRedCommandTests.cs @@ -66,10 +66,11 @@ public void Column_metadata_is_available_before_the_first_read() } [Fact] - public void DateTime_parameter_is_truncated_to_whole_seconds() + public void DateTime_parameter_is_truncated_to_whole_milliseconds() { - // Jet/ACE stores a DateTime only to 1-second resolution. A parameter carrying milliseconds must be - // stripped as early as the command, so the stored value AND a `WHERE d = @p` comparison agree. + // ACE stores a DateTime to 1-second resolution; LibRed keeps the full OA double, so a millisecond + // survives. Nothing below one does - .NET's ToOADate/FromOADate quantise there - so the command + // truncates the parameter to that boundary, making the stored value AND a `WHERE d = @p` agree. string path = Path.Combine(Path.GetTempPath(), $"libred-dt-{Guid.NewGuid():N}.accdb"); File.Copy(Northwind, path); try @@ -81,24 +82,24 @@ public void DateTime_parameter_is_truncated_to_whole_seconds() { create.CommandText = "CREATE TABLE `T` (`Id` INTEGER PRIMARY KEY, `D` DATETIME)"; create.ExecuteNonQuery(); } var withMs = new DateTime(2020, 1, 2, 3, 4, 5, 678); - var seconds = new DateTime(2020, 1, 2, 3, 4, 5); + var subMs = withMs.AddTicks(4567); // 678.4567 ms - finer than the store can hold using (var ins = conn.CreateCommand()) { ins.CommandText = "INSERT INTO `T` (`Id`, `D`) VALUES (1, @d)"; - var p = ins.CreateParameter(); p.ParameterName = "@d"; p.Value = withMs; ins.Parameters.Add(p); + var p = ins.CreateParameter(); p.ParameterName = "@d"; p.Value = subMs; ins.Parameters.Add(p); Assert.Equal(1, ins.ExecuteNonQuery()); } - // Stored value has no milliseconds. + // The millisecond survives; the sub-millisecond remainder does not. using (var sel = conn.CreateCommand()) - { sel.CommandText = "SELECT `D` FROM `T` WHERE `Id` = 1"; Assert.Equal(seconds, (DateTime)sel.ExecuteScalar()!); } + { sel.CommandText = "SELECT `D` FROM `T` WHERE `Id` = 1"; Assert.Equal(withMs, (DateTime)sel.ExecuteScalar()!); } - // A WHERE that reuses the millisecond-bearing parameter still matches the seconds-only row. + // A WHERE that reuses the sub-millisecond parameter still matches the truncated row. using (var q = conn.CreateCommand()) { q.CommandText = "SELECT `Id` FROM `T` WHERE `D` = @d"; - var p = q.CreateParameter(); p.ParameterName = "@d"; p.Value = withMs; q.Parameters.Add(p); + var p = q.CreateParameter(); p.ParameterName = "@d"; p.Value = subMs; q.Parameters.Add(p); Assert.Equal(1, Convert.ToInt32(q.ExecuteScalar())); } } @@ -164,7 +165,7 @@ public void TimeSpan_parameter_round_trips_through_a_datetime_column() insert.CommandText = "INSERT INTO `T` (`Id`, `Dur`) VALUES (1, @d)"; var p = insert.CreateParameter(); p.ParameterName = "@d"; - p.Value = new TimeSpan(0, 5, 30, 0, 678); // 5h30m0.678s — the milliseconds must be stripped + p.Value = new TimeSpan(0, 5, 30, 0, 678); // 5h30m0.678s — the milliseconds must survive insert.Parameters.Add(p); Assert.Equal(1, insert.ExecuteNonQuery()); } @@ -173,7 +174,7 @@ public void TimeSpan_parameter_round_trips_through_a_datetime_column() read.CommandText = "SELECT `Dur` FROM `T` WHERE `Id` = 1"; using var reader = read.ExecuteReader(); Assert.True(reader.Read()); - Assert.Equal(new TimeSpan(5, 30, 0), reader.GetFieldValue(0)); // seconds only + Assert.Equal(new TimeSpan(0, 5, 30, 0, 678), reader.GetFieldValue(0)); // to the millisecond } } finally { try { File.Delete(path); } catch (IOException) { } } diff --git a/test/LibRed.Ado.Tests/TemporalParameterTests.cs b/test/LibRed.Ado.Tests/TemporalParameterTests.cs index 8e3b91a5..1176faad 100644 --- a/test/LibRed.Ado.Tests/TemporalParameterTests.cs +++ b/test/LibRed.Ado.Tests/TemporalParameterTests.cs @@ -75,7 +75,9 @@ public void The_stored_epoch_reads_back_as_default_datetime() public static IEnumerable TemporalCases() => [ [new TimeSpan(10, 9, 8), new TimeSpan(10, 9, 8)], - [new TimeSpan(0, 10, 9, 8, 7), new TimeSpan(10, 9, 8)], // sub-second stripped both ways + [new TimeSpan(0, 10, 9, 8, 7), new TimeSpan(0, 10, 9, 8, 7)], // the millisecond survives storage + // Sub-millisecond is the boundary: 7.5 ms stored and 7 ms queried are the same value to the store. + [new TimeSpan(0, 10, 9, 8, 7) + TimeSpan.FromTicks(5000), new TimeSpan(0, 10, 9, 8, 7)], [new TimeOnly(12, 30, 45), new TimeOnly(12, 30, 45)], [new DateOnly(2020, 3, 1), new DateOnly(2020, 3, 1)], ]; From b918d1bdea28f4bee47adbbff977f392a4e2f82f Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Wed, 26 Aug 2026 20:25:33 +0800 Subject: [PATCH 42/48] LibRed: DATETIME2 end to end - write, index, create, upgrade Date/Time Extended (JetDataType 0x14, ACE 17) was read-only. It now round-trips through the engine, keys an index, and can have a file created at - or raised to - the format it needs. AccessTypeMapper had a version gate for DATETIME2 but no mapping arm, so the type fell through to "not supported yet" and the gate was never reached. The write codec's trailing byte was wrong: ACE NUL-pads the 42-byte field, it does not space-pad it. Caught by reading rows ACE had written while working out the index key format, and it would not have stayed cosmetic - the whole 42 bytes go into the key verbatim, so every key LibRed wrote would have been one byte out of step with ACE's and its seeks would have missed our rows. Index keys go through the existing Binary chunking rather than folding to a number the way DateTime folds to its OA double, which works because the stored form is already order-preserving. Verified byte-for-byte against ACE, ascending and descending. IndexKeyDecoder is deliberately unchanged: a chunked key stops the in-place walk, as it already does for Binary and Text. CreateDatabase now takes a JetVersion, defaulting to ACE 12 so an ordinary database still opens in every Access from 2007. CreateEmpty refuses a non-ACCDB version rather than stamping "Standard ACE DB" over a Jet-4 version byte - a file it could not then have reopened. DDL introducing BIGINT or DATETIME2 raises the open file's version instead of refusing, which is what Access does. The raise joins the statement's transaction, so a failed CREATE/ALTER takes it back down; both rollback paths re-derive the in-memory format from the byte then visible, since that half is not transactional on its own. A saved query's parameter type is excluded - it declares no storage, and what ACE does with a new-type parameter in MSysQueries has not been probed. Verified against the real engine throughout, ending with ACE opening a file LibRed created at ACE 12, upgraded in place, and wrote a 100-ns value into. Docs updated per the spec-sync rule: the NUL padding and index-key scheme in data-types.md, the upgrade mechanics in page-00-database.md, and the indexable-type coverage in page-03-04-index-btree.md. Co-Authored-By: Claude Opus 5 --- src/LibRed/LibRed.Ado/LibRedConnection.cs | 17 +- src/LibRed/LibRed.Core/IO/PageChannel.cs | 42 +++- src/LibRed/LibRed.Core/JetDatabase.cs | 32 +++ .../LibRed.Core/Storage/DatabaseCreator.cs | 10 +- .../LibRed.Core/Storage/IndexKeyEncoder.cs | 18 +- .../LibRed.Core/Storage/Types/JetTypeCodec.cs | 42 +++- .../Execution/AccessTypeMapper.cs | 43 +++-- .../Execution/StatementExecutor.cs | 33 +++- src/LibRed/README.md | 17 +- src/LibRed/docs/format/data-types.md | 118 ++++++++++++ src/LibRed/docs/format/page-00-database.md | 33 ++++ .../docs/format/page-03-04-index-btree.md | 19 +- .../AceDateTime2UpgradeTests.cs | 177 +++++++++++++++++ .../DateTime2KeyEncodingTests.cs | 95 +++++++++ .../DateTime2CreatedDatabaseAccessTests.cs | 108 +++++++++++ test/LibRed.Engine.Tests/DdlDmlTests.cs | 182 ++++++++++++++++-- 16 files changed, 936 insertions(+), 50 deletions(-) create mode 100644 test/LibRed.Core.Tests/AceDateTime2UpgradeTests.cs create mode 100644 test/LibRed.Core.Tests/DateTime2KeyEncodingTests.cs create mode 100644 test/LibRed.Engine.AccessTests/DateTime2CreatedDatabaseAccessTests.cs diff --git a/src/LibRed/LibRed.Ado/LibRedConnection.cs b/src/LibRed/LibRed.Ado/LibRedConnection.cs index 37d6fb1d..0b715769 100644 --- a/src/LibRed/LibRed.Ado/LibRedConnection.cs +++ b/src/LibRed/LibRed.Ado/LibRedConnection.cs @@ -111,19 +111,28 @@ public static string GetConnectionString(string fileNameOrConnectionString) /// /// synthesises the file from scratch (page 0, /// the free map, and the bootstrap system catalog), then LibRed's ordinary writers populate it. - /// Produces an ACE 2007-format (.accdb) database that LibRed reads and writes fully; the - /// remaining Access-compatibility system tables are still being filled in. + /// Produces an .accdb that LibRed reads and writes fully; the remaining Access-compatibility + /// system tables are still being filled in. /// /// 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. - public static void CreateDatabase(string connectionString, Catalog.Collation? collation = null) + /// The file format to create. Defaults to + /// (ACE 12), which every Access from 2007 on can open — + /// raise it only for a database that needs a later type, since an older Access cannot open a newer file + /// at all. is what BIGINT requires and + /// what DATETIME2 requires; AccessTypeMapper + /// refuses either type below its version rather than write a file Access could not read. + public static void CreateDatabase( + string connectionString, + Catalog.Collation? collation = null, + Formats.JetVersion version = Formats.JetVersion.Version12_2007) { string path = ParseDataSource(connectionString); if (string.IsNullOrEmpty(path)) throw new ArgumentException("The connection string is missing a Data Source.", nameof(connectionString)); - Storage.DatabaseCreator.CreateEmpty(path, collation: collation); + Storage.DatabaseCreator.CreateEmpty(path, (byte)version, collation); CreateDualTable(path); } diff --git a/src/LibRed/LibRed.Core/IO/PageChannel.cs b/src/LibRed/LibRed.Core/IO/PageChannel.cs index 37700dea..7b318796 100644 --- a/src/LibRed/LibRed.Core/IO/PageChannel.cs +++ b/src/LibRed/LibRed.Core/IO/PageChannel.cs @@ -63,7 +63,12 @@ private PageChannel(FileStream stream, JetFormatBase format, bool readOnly, stri /// Whether a transaction is currently open on this channel. public bool InTransaction => _active is not null; - public JetFormatBase Format { get; } + /// + /// The resolved on-disk format. Settable only by and its rollback + /// counterpart: a database's format version can move up in place when DDL introduces a type that needs a + /// newer one, which is what Access itself does. + /// + public JetFormatBase Format { get; private set; } internal long SchemaGeneration => _cache.SchemaGeneration; @@ -429,6 +434,40 @@ public void RollbackTransaction() _commitBaselines.Clear(); _schemaDirty = false; _active = null; + ResyncFormatVersion(); // a discarded format raise must not stay raised in memory + } + + /// + /// Raises the file's format version byte (page 0, 0x14) to , in place, and + /// swaps to match. Returns false — writing nothing — when the file already meets it, + /// so callers can call this unconditionally. + /// + /// + /// The write goes through rather than to the stream, so it joins the calling + /// statement's transaction overlay: the upgrade commits with the DDL that needed it, or is discarded with + /// it. Page 0 is never page-encrypted, so the write is byte-transparent even on an encrypted file. + /// Only the version byte moves. The ACE format classes above 0x02 override nothing but + /// — same page size, same offsets — so the swap changes what the + /// database reports about itself and nothing about how it is parsed. + /// + internal bool RaiseFormatVersion(byte version) + { + byte[] page0 = ReadPage(0).Span.ToArray(); + if (page0[JetFormatBase.VersionOffset] >= version) return false; + + page0[JetFormatBase.VersionOffset] = version; + WritePage(0, page0); + Format = JetFormatBase.FromVersionByte(version); + return true; + } + + /// Re-derives from the version byte now visible on page 0. Cheap, and only + /// on the rollback paths, so it costs nothing in the ordinary case. + private void ResyncFormatVersion() + { + byte onDisk = ReadPage(0).Span[JetFormatBase.VersionOffset]; + if ((byte)Format.Version != onDisk) + Format = JetFormatBase.FromVersionByte(onDisk); } /// Opens a savepoint in the current transaction; pass the handle to @@ -473,6 +512,7 @@ private void RestoreOverlay(List> before, int pageCou _txPageCount = pageCount; foreach (int page in _commitBaselines.Keys.Where(p => !_overlay.ContainsKey(p)).ToArray()) _commitBaselines.Remove(page); + ResyncFormatVersion(); // page 0 may have been one of the restored images } private byte[]? ReadCommittedPageOrNull(int pageNumber) diff --git a/src/LibRed/LibRed.Core/JetDatabase.cs b/src/LibRed/LibRed.Core/JetDatabase.cs index b1f406f7..27ec8339 100644 --- a/src/LibRed/LibRed.Core/JetDatabase.cs +++ b/src/LibRed/LibRed.Core/JetDatabase.cs @@ -97,6 +97,36 @@ public static JetDatabase Open(string path, bool readOnly = true, string? passwo /// The resolved on-disk format/version of the database. public JetFormatBase Format => _channel.Format; + /// + /// Raises the database's format version to if it is below it, and reports + /// whether it moved. Writing nothing when the file already qualifies, so callers can call it + /// unconditionally. + /// + /// + /// This is what lets a `BIGINT` or `DATETIME2` column be added to an older file: the type cannot be + /// represented below a given format, and Access's own engine responds by upgrading the file rather than + /// refusing the DDL — verified by having ACE add a Date/Time Extended column to an ACE 12 database and + /// diffing the result, which moved the version byte to 0x06 and nothing else + /// (docs/format/page-00-database.md). + /// The upgrade is one-way and there is no downgrade: a raised file cannot be opened by an Access + /// older than the new format. That is inherent to the type, not a choice here — the alternative is a file + /// whose columns Access cannot read at all. + /// It joins the caller's transaction, so it commits with the statement that needed it and is undone + /// with a statement that fails. + /// + public bool EnsureFormatAtLeast(JetVersion minimum) + { + if (Format.Version >= minimum) return false; + if (!_channel.RaiseFormatVersion((byte)minimum)) return false; + + RereadDefinitionPage(); + return true; + } + + /// Re-decodes page 0 into — after the format version moves, and + /// after a rollback that may have put it back. + private void RereadDefinitionPage() => DefinitionPage.Read(_channel.ReadPage(0), _channel.Format); + /// Whether a transaction is currently open. public bool InTransaction => _channel.InTransaction; @@ -126,6 +156,7 @@ public void Rollback() if (!_channel.InTransaction) return; _channel.RollbackTransaction(); Catalog.Invalidate(markChanged: false); + RereadDefinitionPage(); // page 0 moves too, when a rolled-back statement raised the format version } /// Opens a savepoint within the current transaction (used to make a single statement atomic @@ -139,6 +170,7 @@ public void RollbackToSavepoint(Savepoint savepoint) { _channel.RollbackToSavepoint(savepoint); Catalog.Invalidate(markChanged: false); + RereadDefinitionPage(); } /// Releases , merging its writes into the enclosing scope. diff --git a/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs b/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs index 81717b1b..003b97b8 100644 --- a/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs +++ b/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs @@ -249,6 +249,14 @@ public static void CreateEmpty(string path, byte version = 0x02, Collation? coll { Collation sortOrder = collation ?? Collation.GeneralLegacy; JetFormatBase format = JetFormatBase.FromVersionByte(version); + + // Only the ACCDB versions can be created. Page 0 is stamped "Standard ACE DB" below, and pairing that + // identifier with a Jet-4 version byte produces exactly the mismatch JetFormatBase.Detect refuses — a + // file this method wrote and could not then reopen. Jet 3 is rejected by FromVersionByte already. + if (!format.IsAccdb) + throw new NotSupportedException( + $"Cannot create a database at version 0x{version:X2} ({format.Version}): LibRed creates ACCDB " + + $"formats only (0x02 ACE 12 through 0x06 ACE 17)."); // The four core system tables live at the exact pages the page-0 bootstrap pointers name (2/3/4/5); // their usage maps follow at 6..9. Access uses those pointers to find the catalog. const int objPage = 2, acesPage = 3, queriesPage = 4, relPage = 5; @@ -262,7 +270,7 @@ public static void CreateEmpty(string path, byte version = 0x02, Collation? coll const int seedPages = 10; // page 0, page 1, 4 core TDEFs (2..5), 4 usage maps (6..9) byte[][] seed = [ - BuildDefinitionPage(version, isAccdb: true, 1252, sortOrder, + BuildDefinitionPage(version, format.IsAccdb, 1252, sortOrder, BitConverter.Int64BitsToDouble(SeedCreationDateBits)), BuildFreeMapPage(format, seedPages), // page 1: global free-pages map objTdef, acesTdef, queriesTdef, relTdef, // pages 2..5: core TDEFs diff --git a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs index 46a6b947..f321ed38 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs @@ -3,6 +3,7 @@ using System.Runtime.InteropServices; using LibRed.Catalog; using LibRed.Formats; +using LibRed.Storage.Types; namespace LibRed.Storage; @@ -16,7 +17,8 @@ namespace LibRed.Storage; /// 0x80 / 0xFF descending). Fixed/numeric types use the reversible transform (sign-bit flip + /// big-endian for integers; an IEEE transform for floating point); descending inverts the bytes. /// GUID keys are encoded byte-faithfully (string-order halves split by 0x09, terminated by 0x08). -/// Text uses Jet's collation; general Binary keys use the same 0x09-chunked layout for any length. +/// Text uses Jet's collation; general Binary keys — and DATETIME2, whose stored form is already +/// order-preserving — use the same 0x09-chunked layout for any length. /// public static class IndexKeyEncoder { @@ -173,6 +175,20 @@ private static byte[] Encode( continue; } + // DATETIME2 keys the whole 42-byte stored value through that same chunking, rather than folding + // it to a number the way DateTime folds to its OA double — verified against ACE, which stores + // 7F <8B> 09 … over exactly the bytes on the page. It works because the encoding + // is already order-preserving: both numeric fields are zero-padded to 19 digits, so byte order is + // chronological order. Note the value's 42nd byte is a NUL (see JetTypeCodec) and lands in the key. + if (column.Type == JetDataType.DateTimeExtended) + { + EncodeBinaryChunked( + buffer, + JetTypeCodec.EncodeExtendedDateTime(Convert.ToDateTime(value, CultureInfo.InvariantCulture)), + ascending); + continue; + } + int size = FixedKeySize(column.Type); if (size <= 0) throw new NotSupportedException( diff --git a/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs b/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs index b00b8d8e..7ef28b0a 100644 --- a/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs +++ b/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs @@ -23,7 +23,7 @@ public static class JetTypeCodec JetDataType.Int64 or JetDataType.Double or JetDataType.DateTime or JetDataType.Currency => 8, JetDataType.Guid => 16, JetDataType.FixedPoint => 17, - JetDataType.DateTimeExtended => 42, + JetDataType.DateTimeExtended => ExtendedDateTimeLength, _ => -1, }; if (expectedLength >= 0 && value.Length != expectedLength) @@ -48,7 +48,7 @@ public static class JetTypeCodec return BinaryPrimitives.ReadDoubleLittleEndian(value); case JetDataType.DateTime: return DateTime.FromOADate(BinaryPrimitives.ReadDoubleLittleEndian(value)); - case JetDataType.DateTimeExtended: // ACE 16 DATETIME2 + case JetDataType.DateTimeExtended: // ACE 17 DATETIME2 return DecodeExtendedDateTime(value); case JetDataType.Currency: return BinaryPrimitives.ReadInt64LittleEndian(value) / 10000m; @@ -71,7 +71,7 @@ public static class JetTypeCodec } /// - /// Decodes an ACE 16 DATETIME2 value: a fixed 42-byte ASCII string + /// Decodes an ACE 17 DATETIME2 value: a fixed 42-byte ASCII string /// "<day>:<time>:<precision>" where day is the .NET day number and /// time is the count of 100-ns ticks within the day. Both are zero-padded to 19 /// digits so that byte order equals chronological order. @@ -90,6 +90,40 @@ private static DateTime DecodeExtendedDateTime(ReadOnlySpan value) return new DateTime(day * TimeSpan.TicksPerDay + time); } + /// + /// Encodes an ACE 17 DATETIME2 value — the inverse of . The 42 bytes are + /// ASCII "<day>:<time>:<precision>": the .NET day number and the 100-ns ticks within + /// that day, each zero-padded to 19 digits so byte order equals chronological order, then the fractional + /// precision — 41 characters, NUL-padded to the field's 42 (19 + 1 + 19 + 1 + 1 = 41). + /// + /// + /// The padding byte is 0x00, not a space: verified by reading the row bytes ACE itself wrote + /// (… 3A 37 00). It matters beyond byte-faithfulness — the whole 42 bytes go into the index key + /// verbatim (see IndexKeyEncoder), so a space there would put every key we wrote out of step with + /// ACE's and make its seeks miss our rows. + /// The precision is always 7. ACE's DDL accepts no other form: DATETIME2(7) and every other + /// parenthesised spelling is a syntax error, so a Date/Time Extended column can only be declared bare, and + /// the value ACE itself writes for one carries 7 (verified against Microsoft.ACE.OLEDB.16.0). A + /// column's is not consulted: those descriptor bytes carry + /// precision/scale for FixedPoint columns, not this. + /// + internal static byte[] EncodeExtendedDateTime(DateTime value) + { + long day = value.Ticks / TimeSpan.TicksPerDay; + long time = value.Ticks % TimeSpan.TicksPerDay; + + string text = string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"{day:D19}:{time:D19}:7"); + + byte[] bytes = new byte[ExtendedDateTimeLength]; + Encoding.ASCII.GetBytes(text, bytes); // the 42nd byte stays 0x00 + return bytes; + } + + /// The fixed on-disk width of a DATETIME2 (Date/Time Extended) value. + internal const int ExtendedDateTimeLength = 42; + /// /// Decodes a Jet Decimal/Numeric value (17 bytes): a sign byte (0x80 = negative) followed /// by a 128-bit magnitude stored as four 32-bit little-endian words in big-endian word @@ -164,6 +198,8 @@ public static byte[] Encode(ColumnDef column, object value) return Bytes(8, b => BinaryPrimitives.WriteDoubleLittleEndian(b, Convert.ToDouble(value, c))); case JetDataType.DateTime: return Bytes(8, b => BinaryPrimitives.WriteDoubleLittleEndian(b, ToOaDate(value, c))); + case JetDataType.DateTimeExtended: // ACE 17 DATETIME2 + return EncodeExtendedDateTime(Convert.ToDateTime(value, c)); case JetDataType.Currency: return Bytes(8, b => BinaryPrimitives.WriteInt64LittleEndian(b, (long)decimal.Round(Convert.ToDecimal(value, c) * 10000m))); case JetDataType.Guid: diff --git a/src/LibRed/LibRed.Engine/Execution/AccessTypeMapper.cs b/src/LibRed/LibRed.Engine/Execution/AccessTypeMapper.cs index 9f6c4e61..30ac70e9 100644 --- a/src/LibRed/LibRed.Engine/Execution/AccessTypeMapper.cs +++ b/src/LibRed/LibRed.Engine/Execution/AccessTypeMapper.cs @@ -16,23 +16,36 @@ internal static class AccessTypeMapper public static ColumnSpec ToColumnSpec(ColumnDefinition column, JetVersion version) => MapType(column, version) with { IsNullable = !column.NotNull }; - private static ColumnSpec MapType(ColumnDefinition column, JetVersion version) - { - // Collapse any internal whitespace so two-word aliases ("character varying") match. - string t = string.Join(' ', column.TypeName.ToUpperInvariant().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); - - // BIGINT and DATETIME2 were added in DIFFERENT format versions — verified against files authored with - // each feature enabled: BIGINT (Large Number) forces the ACE 16 / Access 2016 format (version byte - // 0x05), while DATETIME2 (Date/Time Extended) forces the ACE 17 / 2019+ format (0x06). On any older - // format the engine can't represent the type, so refuse to create/alter a column to one rather than - // silently producing a file the real Access version couldn't open. This is the single DDL choke point. - (JetVersion Min, string Label)? gate = t switch + /// + /// The minimum file format a declared type needs, or null for the types every format can hold. + /// + /// + /// BIGINT and DATETIME2 arrived in DIFFERENT format versions — verified against files authored with each + /// feature enabled: BIGINT (Large Number) forces the ACE 16 / Access 2016 format (version byte 0x05), + /// DATETIME2 (Date/Time Extended) the ACE 17 / 2019+ format (0x06). Below those the engine cannot + /// represent the type at all. + /// Callers that are about to write storage raise the file to meet this (see + /// StatementExecutor.MapColumn), which is what Access itself does. still + /// refuses a type the open file is too old for, so a caller that skips the upgrade — or cannot perform it, + /// on a read-only database — fails loudly instead of writing a column Access could not read. + /// + public static (JetVersion Min, string Label)? RequiredVersion(string typeName) => + Normalize(typeName) switch { "BIGINT" => (JetVersion.Version16_2016, "Access 2016 (ACE 16)"), "DATETIME2" => (JetVersion.Version17_2019, "Access 2019+ (ACE 17)"), _ => null, }; - if (gate is { } g && version < g.Min) + + /// Collapses internal whitespace so two-word aliases ("character varying") match. + private static string Normalize(string typeName) => + string.Join(' ', typeName.ToUpperInvariant().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + private static ColumnSpec MapType(ColumnDefinition column, JetVersion version) + { + string t = Normalize(column.TypeName); + + if (RequiredVersion(t) is { } g && version < g.Min) throw new NotSupportedException( $"Column type '{column.TypeName}' requires {g.Label} or later; this database is {version}."); @@ -64,6 +77,12 @@ private static ColumnSpec MapType(ColumnDefinition column, JetVersion version) // no file-format upgrade (verified vs ACE: SMALLDATETIME → DateTime, version byte unchanged). "DATETIME" or "DATE" or "TIME" or "TIMESTAMP" or "SMALLDATETIME" => Fixed(column, JetDataType.DateTime, 8), + // Date/Time Extended: a fixed 42-byte ASCII triple, not the 8-byte OA double (see JetTypeCodec). + // Version-gated to ACE 17 above. ACE's own DDL accepts only this bare spelling - DATETIME2(7), + // DATETIMEEXTENDED and DATE/TIME EXTENDED are all syntax errors - so there are no aliases to fold + // in, and the declared precision is always 7. + "DATETIME2" + => Fixed(column, JetDataType.DateTimeExtended, 42), "BIT" or "YESNO" or "BOOLEAN" or "LOGICAL" or "LOGICAL1" => Fixed(column, JetDataType.Boolean, 1), "GUID" or "UNIQUEIDENTIFIER" diff --git a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs index c3dc1cbd..bb637270 100644 --- a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs +++ b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs @@ -37,9 +37,31 @@ internal sealed class StatementExecutor(JetDatabase database, IReadOnlyDictionar _ => throw new NotSupportedException($"{statement.GetType().Name} cannot be executed as a non-query."), }; + /// + /// Maps a declared column type to its storage spec, first raising the database's format version if the + /// type needs a newer one than the file currently is. + /// + /// + /// Access does the upgrade itself rather than refusing the DDL — adding a Date/Time Extended column to an + /// ACE 12 database moves its version byte to 0x06, and for DATETIME2 that byte is the entire upgrade + /// (docs/format/page-00-database.md). Refusing instead would leave LibRed unable to do something the + /// engine it mirrors does routinely. + /// The raise joins this statement's transaction, so a failed CREATE/ALTER takes its format bump + /// back down with it. It is still one-way in the sense that matters: once committed, an Access older than + /// the new format cannot open the file — unavoidable, since the column it would find is one it cannot + /// read either. + /// + private ColumnSpec MapColumn(ColumnDefinition column) + { + if (AccessTypeMapper.RequiredVersion(column.TypeName) is { } required) + _database.EnsureFormatAtLeast(required.Min); + + return AccessTypeMapper.ToColumnSpec(column, _database.Format.Version); + } + private int ExecuteCreateTable(CreateTableStatement statement) { - var columns = statement.Columns.Select(c => AccessTypeMapper.ToColumnSpec(c, _database.Format.Version)).ToList(); + var columns = statement.Columns.Select(MapColumn).ToList(); foreach (var (spec, def) in columns.Zip(statement.Columns)) ValidateColumnDefault(spec, def.Default); IReadOnlyList? primaryKey = statement.PrimaryKey.Count > 0 ? statement.PrimaryKey : null; @@ -375,6 +397,11 @@ private int ExecuteCreateProcedure(CreateProcedureStatement statement) { // A procedure is a parameterized stored query: a view spec plus a parameter row per declared // parameter (name + Jet type code, resolved from the declared Access type name). + // + // Deliberately NOT MapColumn: this declares no storage, so there is no column forcing the file's + // 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, @@ -482,7 +509,7 @@ private int DropConstraint(string table, string name) private int AddColumn(string table, ColumnDefinition column) { // NOT NULL and DEFAULT are written to the column's LvProp properties (Required / DefaultValue). - ColumnSpec spec = AccessTypeMapper.ToColumnSpec(column, _database.Format.Version); + ColumnSpec spec = MapColumn(column); ValidateColumnDefault(spec, column.Default); if (!_database.AddColumn(table, spec, column.Default)) throw new InvalidOperationException($"ALTER TABLE '{table}' ADD COLUMN '{column.Name}': the column already exists."); @@ -560,7 +587,7 @@ private int AddCheck(string table, AddCheckAction chk) private int AlterColumn(string table, AlterColumnAction alter) { var colDef = new ColumnDefinition(alter.Field, alter.TypeName, alter.Size, alter.Scale, NotNull: false, PrimaryKey: false); - _database.AlterColumn(table, alter.Field, AccessTypeMapper.ToColumnSpec(colDef, _database.Format.Version)); + _database.AlterColumn(table, alter.Field, MapColumn(colDef)); // Apply DEFAULT before Required so the column's property map keeps ACE's order (DefaultValue, then Required). if (alter.Default is not null) // ALTER COLUMN … DEFAULT: set the column's default after the type change _database.SetColumnDefault(table, alter.Field, alter.Default); diff --git a/src/LibRed/README.md b/src/LibRed/README.md index e267bf00..e534e427 100644 --- a/src/LibRed/README.md +++ b/src/LibRed/README.md @@ -127,10 +127,19 @@ LibRed-side `CHECK` enforcement, self-pointing self-references, and writing Memo both `CASCADE` directions work. - **1:1 relationships** (`dbRelationUnique` = `0x01`) — never written; a 1:1 migration would render as 1:many in Access (the unique index still enforces uniqueness). Probe a real 1:1's `grbit` first. -- **ACE-16 types** — `Int64`/BIGINT (`0x13`) index-key encoding is trivial but unverified; - `DateTimeExtended`/DATETIME2 (`0x14`) is **read-only** (no write codec), which also blocks indexing it. - The **ACE-16 auto-upgrade** (using BIGINT/DATETIME2 bumps the format version) is deliberately refused - until we probe what else the upgrade changes. +- **ACE-16 types** — `Int64`/BIGINT (`0x13`) index-key encoding is trivial but unverified. + `DateTimeExtended`/DATETIME2 (`0x14`) is **done**: read, write, `CREATE TABLE`, index keys (ascending and + descending), and native creation at the format it needs — `LibRedConnection.CreateDatabase(…, version:)` + takes a `JetVersion`, defaulting to ACE 12 so an ordinary database still opens in every Access from 2007. + Each step is verified against ACE, ending with Access's own engine reading a 100-ns value out of a file + LibRed synthesised from nothing (`DateTime2CreatedDatabaseAccessTests`). + The **format auto-upgrade** is implemented too: DDL that introduces one of these types raises the open + file's version byte rather than refusing, which is what Access itself does — adding a Date/Time Extended + column to an ACE 12 database moves it to `0x06`, and that byte is the entire upgrade + (`docs/format/page-00-database.md`). The raise joins the statement's transaction, so a failed CREATE/ALTER + takes it back down. ACE opens a file LibRed upgraded this way and reads the value that forced it. Note the + upgrade is one-way and unavoidable: an older Access cannot open the result, but neither could it read the + column. `CreateDatabase(…, version:)` remains the way to start at a format rather than arrive at one. - **Non-English text collations** — index-key weights exist for the two General (1033) orders only: General-Legacy (v0) and General (v1). Any other locale is refused by `IndexKeyEncoder` rather than encoded with the English table. *(The v1 gap is closed: v1 keys are the Windows NLS weights verbatim, from the diff --git a/src/LibRed/docs/format/data-types.md b/src/LibRed/docs/format/data-types.md index e4afc0ee..8eb0fdce 100644 --- a/src/LibRed/docs/format/data-types.md +++ b/src/LibRed/docs/format/data-types.md @@ -40,6 +40,20 @@ that byte order equals chronological order (an order-preserving inline encoding) `new DateTime(day * TicksPerDay + time)`; e.g. `…693593:…0:7` is the 1899-12-30 epoch and `…737590:…495300000000:7` is 2020-06-15 13:45:30. Sub-second precision (to 100 ns) is preserved. +Those three fields occupy **41** characters (19 + 1 + 19 + 1 + 1); the 42nd byte is a **NUL (`0x00`), not a +space** — verified by reading rows ACE itself wrote (`… 3A 37 00`). The distinction is not cosmetic: the whole +42 bytes go into the index key verbatim, so a space there would put every key out of step with ACE's and make +its seeks miss those rows. + +**Indexing.** ACE does permit an index on the type, and keys it through the same 8-byte chunking it uses for +`Binary` (§10.4) — start flag, 8 bytes, `0x09` while more follow, then the final chunk and its real-byte count +— rather than folding the value to a number the way `DateTime` folds to its OA double. It can do that because +the stored form is already order-preserving. Verified ascending and descending in +`DateTime2KeyEncodingTests`. + +The bytes on disk are correct, but **ACE's own OLE DB provider cannot read this type back** — see the +[footnote](#footnote--reading-datetime2-through-aces-own-drivers) at the bottom of this page. + ## 7. Compressed Unicode @@ -74,3 +88,107 @@ Points verified against ACE that aren't obvious from that page: - The grammar parses **two-word** type names (`CHARACTER VARYING`, `BIT VARYING`); three-word (`NATIONAL CHARACTER VARYING`) is not parsed yet. `HYPERLINK`/`XML`/`SQL_VARIANT`/`VARIANT`/`COMP` have no mapping (rejected, as ACE also rejects them). + + +--- + +## Footnote — reading `DATETIME2` through ACE's own drivers + +*Driver behaviour, not file format. Recorded here because it is the reason LibRed cannot cross-check this one +type against ACE the way it does every other type, and because it silently corrupts data in the wider repo.* + +**The bytes on disk are correct; ACE's OLE DB read path is broken in three independent ways.** Verified +2026-08-26 against **Access / Microsoft 365 version 2608 (build 20326.20100 Click-to-Run, Current Channel, +x64)** — i.e. the then-current shipping build, not an old redistributable. Values were inserted through ACE and +then read back three ways — ACE OLE DB, ACE ODBC, and LibRed reading the file directly: + +| literal | OLE DB (`Microsoft.ACE.OLEDB.16.0`) | ODBC (`ACEODBC.DLL`) | LibRed | +| --- | --- | --- | --- | +| `#2021-01-15 05:06:07#` | `ArgumentOutOfRangeException` | `byte[42]` `"…737804:…183670000000:7 "` | 2021-01-15 05:06:07 | +| `#2021-03-04 05:06:07#` | 2021-**02**-04 05:06:07 | `byte[42]` `"…737852:…183670000000:7 "` | 2021-03-04 05:06:07 | +| `#2021-12-25 13:14:15#` | 2021-**11**-25 13:14:15 | `byte[42]` `"…738148:…476550000000:7 "` | 2021-12-25 13:14:15 | +| `#2020-02-29 00:00:00#` | 2020-**01**-29 00:00:00 | `byte[42]` `"…737483:…0:7 "` | 2020-02-29 00:00:00 | + +**ODBC** does not convert at all — it hands back the raw 42 bytes, exactly as stored, so it is *uncorrupted* but +must be decoded by the caller (the same parse LibRed does). Note `OdbcConnection.ServerVersion` reports the +**file's** engine level, not the driver's: `12.00.0000` for an ACE 12 file, `16.00.12600` for a `0x06` one. + +> **Neither driver's name tells you its age.** The `Microsoft.ACE.OLEDB.16.0` ProgID and `ACEODBC.DLL` have been +> stable since Office 2016; the binaries behind them ship with Office and follow its update channel. A `16.0` in +> the connection string is *not* evidence of an out-of-date component. + +### What the provider actually does + +Measured with a consumer calling the OLE DB COM vtables directly — `CoCreateInstance` → `IDataInitialize` → +`IDBInitialize` → `IDBCreateSession` → `ICommandText` → `IColumnsInfo`/`IAccessor`/`IRowset`, every buffer +natively allocated. **No ADO, no `System.Data.OleDb`, no ODBC in the path**, so everything below is the +provider's own behaviour with nothing in between. + +The column's `DBCOLUMNINFO`: + +``` +wType = 135 (0x0087) = DBTYPE_DBTIMESTAMP <- a 16-byte struct +ulColumnSize = 42 <- the on-disk ASCII length +bPrecision = 255, bScale = 255 +``` + +**That contradiction is the root defect** — the provider declares a 16-byte type for a 42-byte value. (Control: +a plain `DATETIME` column in the same table reports `wType = 7 DBTYPE_DATE, ulColumnSize = 8`.) + +**1 — It overruns the consumer's buffer.** Given a `DBTIMESTAMP` binding with `cbMaxLen = 16`, in a buffer +pre-filled with `0xCD` sentinels: + +``` ++2048 E5 07 02 00 04 00 05 00 06 00 07 00 00 00 00 00 <- the 16 bytes it was allowed ++2064 38 35 32 3A 30 30 30 30 30 30 30 31 38 33 36 37 "852:000000018367" ++2080 30 30 30 30 30 30 30 3A 37 00 CD CD CD CD CD CD "0000000:7" NUL +``` + +**26 bytes past the slot**, and they are exactly characters 16–40 of the 42-byte on-disk string. The provider +copies the whole 42-byte ASCII value to `obValue`, NUL-terminates at offset 41, overwrites the first 16 bytes +with the converted struct, then reports `cbLength = 16, DBSTATUS_S_OK`. It never consults `cbMaxLen` — a +512-byte slot produces the identical 42-byte footprint. Bindings for narrower types fare worse still: `DBDATE` +(6 bytes) and `DBTYPE_R8` (8 bytes) each get a 16-byte struct splatted at `obValue` regardless, then return +`E_DATAOVERFLOW` — or, for `R8`, the flatly wrong `DBSTATUS_S_ISNULL`. + +This is a genuine consumer buffer overrun, and it explains the `0xC0000374` / `0xC0000409` process crashes seen +under OLE DB reader churn: `System.Data.OleDb` places `obValue` at 16 in a 32-byte row buffer, so ACE writes 26 +bytes off the end of a managed allocation. + +**2 — Its `DBTIMESTAMP` conversion is one month short.** The 16 bytes it wrote for `2021-03-04 05:06:07`: + +``` +E5 07 | 02 00 | 04 00 | 05 00 | 06 00 | 07 00 | 00 00 00 00 +year month day hour minute second fraction +2021 2 4 5 6 7 0 +``` + +The month field literally holds `2`; every other field is right. It looks like a 0-based `tm_mon` copied into +the 1-based `DBTIMESTAMP.month` without the `+1`. It reproduces with a 512-byte slot, so it is independent of +the overrun, and the provider demonstrably knows better: `SELECT Month(E) FROM X` through the same raw rowset +returns **3**, and a plain `DATETIME` column holding the same instant decodes as **3**. `System.Data.OleDb` is +faithful here — it builds a `DateTime` straight from the struct (`ColumnBinding.Value_DBTIMESTAMP`), so January +throws (month `0` is not representable) while every other month corrupts **silently**: 2020-02-29 becomes a +perfectly valid 2020-01-29. + +**3 — The string conversions are garbage.** `DBTYPE_STR` and `DBTYPE_WSTR` both return +`"12336-12336-12336 12336:12336:12336.926103344"`. `12336 = 0x3030 = "00"` — the string path reinterprets the +42-byte ASCII payload *as* a `DBTIMESTAMP` struct and formats the result. `DBTYPE_BYTES` and `DBTYPE_VARIANT` +are refused outright at `CreateAccessor` (`DB_E_ERRORSOCCURRED`, `DBBINDSTATUS_UNSUPPORTEDCONVERSION`), so +there is no binding that returns the raw value either. + +**There is no binding through which the OLE DB provider returns this column correctly.** Reading the file +directly — what LibRed does — is not merely an alternative; it is the only correct path. + +### Why this has gone unnoticed + +Server-side comparison is unaffected — a `WHERE dt2 = #…#` matches correctly, because only *materialisation* +goes through the broken conversion. And it is live in the real provider stack, not just in a probe: +`AdHocMiscellaneousQueryJetTest` seeds **nine** `datetime2` columns (precisions 0–7), materialises all of them +in `Where_not_equals_DateTime_Now`, and is green — only because every seeded date is in **September**, which +merely shifts to August, and because the test asserts `Assert.Single` rather than any value. Changing one seeded +date to January makes it fail immediately with the `ArgumentOutOfRangeException` above (verified by doing it, +then reverting). So: predicates are right, corruption is silent outside January, Access itself never reads +through OLE DB, and the suites that do exercise the type check row counts rather than values. + +Test: `AceDateTime2UpgradeTests.LibRed_decodes_datetime2_values_that_ace_reads_back_wrongly`. diff --git a/src/LibRed/docs/format/page-00-database.md b/src/LibRed/docs/format/page-00-database.md index fe25ed21..be244b5d 100644 --- a/src/LibRed/docs/format/page-00-database.md +++ b/src/LibRed/docs/format/page-00-database.md @@ -61,6 +61,39 @@ A genuinely **unknown** version byte on an `.accdb` that still carries the clear unrecognised byte is almost certainly a newer 4KB ACE variant; the `"4.0"` guard stops a genuinely different future engine (e.g. a `"5.0"` string) from being mis-read as ACE. +**Upgrading an existing file is that byte and nothing else** (verified 2026-08-26 against ACE over OLE DB, +from a DAO-created ACE 12 baseline — `dbVersion120`, version `0x02`). Adding a `DATETIME2` column through ACE +changes exactly one byte of page 0: `0x14`, `0x02` → `0x06`. A control arm adding an ordinary `DATETIME` column +to the same baseline is what isolates it — the only other byte either arm touched was the opening user's +commit slot at `0xE02` (§2.2), which moves for any write at all. + +The byte is **sufficient, not merely necessary**: writing `0x06` to `0x14` by hand upgrades an ACE 12 file in +place. ACE then opens it, data written before the flip is still readable, and `ALTER TABLE … ADD COLUMN … +DATETIME2`, `INSERT`, `SELECT` and `CREATE TABLE` with the type all work — ACE adding nothing further to page 0 +of its own. Guard: `AceDateTime2UpgradeTests`. ACE's DDL accepts only the bare spelling **`DATETIME2`**; +`DATETIME2(7)`, `DATETIMEEXTENDED`, `DATE/TIME EXTENDED` and `DATETIMEOFFSET` are all syntax errors. + +> Only the `0x06` / `DATETIME2` route was tested. The `0x05` / **Large Number** upgrade is *assumed* to work the +> same way — not verified. + +**LibRed performs this upgrade itself**, as ACE does: DDL introducing a type the open file is too old for +raises the version byte instead of refusing (`StatementExecutor.MapColumn` → +`JetDatabase.EnsureFormatAtLeast` → `PageChannel.RaiseFormatVersion`). Three properties are worth recording, +because each is a place the obvious implementation goes wrong: + +- The write goes through `PageChannel.WritePage`, not the stream, so it **joins the statement's transaction**. + A `CREATE TABLE` that raises the format and then fails takes the raise back down with it. +- The in-memory `Format` is *not* transactional on its own, so both rollback paths (`RollbackTransaction` + and the savepoint `RestoreOverlay`) re-derive it from the version byte then visible, and `JetDatabase` + re-reads `DefinitionPage` alongside. Getting only the disk half right leaves an open database claiming a + version its file does not have. +- It is one-way: there is no downgrade, and an Access older than the new format can no longer open the file. + That is inherent — the column it would find is one it could not read either. + +Verified against the real engine: ACE opens a file LibRed upgraded in place and reads the value that forced +the upgrade (`DateTime2CreatedDatabaseAccessTests`). A saved query's *parameter* type is deliberately excluded +— it declares no storage, and what ACE does with a new-type parameter in `MSysQueries` has not been probed. + **Catalog bootstrap.** Reading the database is a two-step hop from page 0: the pointer at `0x20` gives the `MSysObjects` TDEF page (2), and `MSysObjects` then lists every other object (each table's row `Id` is *its* TDEF page). LibRed reads `0x20` into `DatabaseDefinitionPage.CatalogRootPage` and hands it to `JetCatalog` 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 12a7dd42..ae085401 100644 --- a/src/LibRed/docs/format/page-03-04-index-btree.md +++ b/src/LibRed/docs/format/page-03-04-index-btree.md @@ -830,8 +830,17 @@ The split mechanics: > **Indexable types — coverage vs ACE (§10.4).** `IndexKeyEncoder` encodes every type ACE lets you index — -> Boolean, Byte, Int16, Int32, Currency, Single, Double, DateTime, Text, GUID, Binary, FixedPoint, and Memo -> (its first 255 chars) — all byte-verified. ACE correctly **refuses** to index `OLE` (`0x0B`) and `Complex` -> (`0x12`). Two ACE-16-only types remain unencoded: **`Int64`/BIGINT** (`0x13`) — trivial (an int64 like -> Currency, sign-bit flipped) but unverified — and **`DateTimeExtended`/DATETIME2** (`0x14`), blocked on its -> missing write codec ([data-types](data-types.md)). See the worklist in `src/LibRed/README.md`. +> Boolean, Byte, Int16, Int32, Currency, Single, Double, DateTime, Text, GUID, Binary, FixedPoint, Memo +> (its first 255 chars), and **`DateTimeExtended`/DATETIME2** (`0x14`) — all byte-verified. ACE correctly +> **refuses** to index `OLE` (`0x0B`) and `Complex` (`0x12`). One ACE-16-only type remains unencoded: +> **`Int64`/BIGINT** (`0x13`) — trivial (an int64 like Currency, sign-bit flipped) but unverified. See the +> worklist in `src/LibRed/README.md`. +> +> `DateTimeExtended` is **not** a fixed-width numeric key. ACE runs its whole 42-byte stored value through the +> Binary chunking above — start flag, 8 bytes, `0x09` while more follow, then the final chunk and its +> real-byte count — instead of folding it to a number the way `DateTime` folds to its OA double. That works +> because the stored encoding is already order-preserving (both fields zero-padded to 19 digits), and it means +> the value's trailing NUL is part of the key ([data-types](data-types.md)). Descending inverts every byte +> except the `0x09` markers, exactly as for Binary. Verified both directions in `DateTime2KeyEncodingTests`. +> `IndexKeyDecoder` does not decode it, for the same reason it does not decode Binary or Text: the chunked +> form stops the in-place walk, and the caller falls back to reading the row. diff --git a/test/LibRed.Core.Tests/AceDateTime2UpgradeTests.cs b/test/LibRed.Core.Tests/AceDateTime2UpgradeTests.cs new file mode 100644 index 00000000..ea6584b2 --- /dev/null +++ b/test/LibRed.Core.Tests/AceDateTime2UpgradeTests.cs @@ -0,0 +1,177 @@ +using System.Data.Common; +using LibRed; +using LibRed.Catalog; +using Xunit; + +namespace LibRed.Core.Tests; + +// Upgrading a file to the ACE 17 format is one byte: page 0 offset 0x14, 0x02 -> 0x06. That was established by +// diffing an ACE-authored DATETIME2 table against a control that added an ordinary DATETIME column to the same +// DAO-created ACE 12 baseline; the only other byte either arm touched was the opening user's commit slot, which +// moves for any write at all. See docs/format/page-00-database.md. +// +// Requires DAO and the ACE OLE DB provider; skips when DAO is absent, as the other ACE probes do. ACE +// heap-corrupts (0xC0000374) under connection churn in this shape, reproducibly, and takes the test process +// with it - so each phase uses ONE connection for all of its statements. A connection is only reopened where +// the file has to be closed in between. +public class AceDateTime2UpgradeTests(ITestOutputHelper output) +{ + // The guard for the half of the finding LibRed would come to depend on: that the byte is SUFFICIENT, not + // merely necessary. If a future ACE wanted a companion flag, LibRed would be silently writing files Access + // rejects, and nothing else in the suite would catch it - LibRed reading its own file back proves nothing + // about whether Access will accept it. + [Fact] + public void Writing_the_version_byte_is_a_complete_upgrade_to_datetime2() + { + if (!TryCreateAce12Database("dt2-upgrade-", out string path)) return; + try + { + Assert.Equal(0x02, VersionByte(path)); + + // Start from a file that already holds data, which is what a real upgrade has to preserve. + using (DbConnection connection = AceTestDatabase.Open(path)) + { + Execute(connection, "CREATE TABLE T (Id LONG, D DATETIME)"); + Execute(connection, "INSERT INTO T (Id, D) VALUES (1, #2020-01-02 03:04:05#)"); + } + Assert.Equal(0x02, VersionByte(path)); // an ordinary DATETIME does not move it + + SetVersionByte(path, 0x06); + Assert.Equal(0x06, VersionByte(path)); + + using (DbConnection connection = AceTestDatabase.Open(path)) + { + // The data written before the upgrade is still there and still correct. + Assert.Equal( + new DateTime(2020, 1, 2, 3, 4, 5), + Convert.ToDateTime(Scalar(connection, "SELECT D FROM T WHERE Id = 1"))); + + // And the type the upgrade exists for is now usable, in both the forms that matter: added to + // the existing table, and used by a new one. The value is verified below through LibRed rather + // than ACE, whose own DATETIME2 read-back is off by a month (see the other test). + Execute(connection, "ALTER TABLE T ADD COLUMN E DATETIME2"); + Execute(connection, "INSERT INTO T (Id, E) VALUES (2, #2021-03-04 05:06:07#)"); + Execute(connection, "CREATE TABLE T2 (D2 DATETIME2)"); + } + + // ACE left the version byte where we put it - it had no upgrade of its own left to do. + Assert.Equal(0x06, VersionByte(path)); + + using var db = JetDatabase.Open(path); + ColumnDef extended = db.Catalog.Tables.Single(t => t.Name == "T").Columns.Single(c => c.Name == "E"); + Assert.Equal(JetDataType.DateTimeExtended, extended.Type); + Assert.Equal(42, extended.Length); + Assert.True(extended.IsFixedLength); + } + finally { TemporaryDatabase.Delete(path); } + } + + // ACE's own OLE DB provider reads a DATETIME2 column back with the MONTH ONE SHORT. Measured against + // Microsoft.ACE.OLEDB.16.0 (engine 04.00.0000) on 2026-08-26, inserting and reading in one connection: + // + // literal ACE DATETIME2 ACE DATETIME LibRed + // #2021-01-15 05:06:07# ArgumentOutOfRangeException correct correct + // #2021-03-04 05:06:07# 2021-02-04 05:06:07 correct correct + // #2021-12-25 13:14:15# 2021-11-25 13:14:15 correct correct + // #2020-02-29 00:00:00# 2020-01-29 00:00:00 correct correct + // + // January throws instead of shifting because the month arrives as 0, which is not a representable DateTime: + // System.Data.OleDb builds one straight out of the DBTIMESTAMP struct (ColumnBinding.Value_DBTIMESTAMP). An + // ordinary DATETIME column in the SAME row, read by the SAME reader, is correct - so the managed layer is + // fine and it is the provider's DATETIME2 -> DBTIMESTAMP conversion that is off by one. Note 2020-02-29 + // becomes a valid 2020-01-29: outside January this corrupts silently rather than throwing. + // + // The bytes on disk are right - which is what this asserts. ACE's misreading is recorded above rather than + // asserted: it is not our defect, and a fix on Microsoft's side should not fail our suite. + [Fact] + public void LibRed_decodes_datetime2_values_that_ace_reads_back_wrongly() + { + (string Literal, DateTime Expected)[] cases = + [ + ("#2021-01-15 05:06:07#", new DateTime(2021, 1, 15, 5, 6, 7)), + ("#2021-03-04 05:06:07#", new DateTime(2021, 3, 4, 5, 6, 7)), + ("#2021-12-25 13:14:15#", new DateTime(2021, 12, 25, 13, 14, 15)), + ("#2020-02-29 00:00:00#", new DateTime(2020, 2, 29)), + ]; + + if (!TryCreateAce12Database("dt2-decode-", out string path)) return; + try + { + SetVersionByte(path, 0x06); + + using (DbConnection connection = AceTestDatabase.Open(path)) + { + Execute(connection, "CREATE TABLE X (Id LONG, E DATETIME2)"); + for (int i = 0; i < cases.Length; i++) + Execute(connection, $"INSERT INTO X (Id, E) VALUES ({i}, {cases[i].Literal})"); + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("X"); + int id = table.Definition.Columns.Single(c => c.Name == "Id").Index; + int extended = table.Definition.Columns.Single(c => c.Name == "E").Index; + + var stored = table.Rows().ToDictionary(r => Convert.ToInt32(r[id]), r => (DateTime)r[extended]!); + Assert.Equal(cases.Length, stored.Count); + for (int i = 0; i < cases.Length; i++) + Assert.Equal(cases[i].Expected, stored[i]); + } + finally { TemporaryDatabase.Delete(path); } + } + + /// Creates an ACE 12 (version byte 0x02) database through DAO — the format LibRed itself + /// creates. Returns false, having reported it, when DAO is not installed. + private bool TryCreateAce12Database(string prefix, out string path) + { + path = ""; + object? engine = null; + foreach (int n in new[] { 170, 160, 150, 140, 130, 120 }) + { + Type? type = Type.GetTypeFromProgID($"DAO.DBEngine.{n}"); + if (type is null) continue; + try { engine = Activator.CreateInstance(type); break; } catch (Exception) { } + } + if (engine is null) { output.WriteLine("DAO unavailable - skipped."); return false; } + + path = TemporaryDatabase.CreatePath(prefix); + File.Delete(path); // DAO creates the file itself and refuses an existing one + + // 128 == dbVersion120, the ACE 12 / Access 2007 format. + object workspace = Invoke(engine, "CreateWorkspace", "", "admin", "", 2)!; + object database = Invoke(workspace, "CreateDatabase", path, ";LANGID=0x0409;CP=1252;COUNTRY=0", 128)!; + Invoke(database, "Close"); + return true; + } + + private static void Execute(DbConnection connection, string sql) + { + using DbCommand command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + private static object? Scalar(DbConnection connection, string sql) + { + using DbCommand command = connection.CreateCommand(); + command.CommandText = sql; + return command.ExecuteScalar(); + } + + private static byte VersionByte(string path) + { + using var stream = File.OpenRead(path); + stream.Seek(0x14, SeekOrigin.Begin); + int b = stream.ReadByte(); + return b < 0 ? throw new EndOfStreamException() : (byte)b; + } + + private static void SetVersionByte(string path, byte version) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Write); + stream.Seek(0x14, SeekOrigin.Begin); + stream.WriteByte(version); + } + + private static object? Invoke(object target, string member, params object?[] args) => + target.GetType().InvokeMember(member, System.Reflection.BindingFlags.InvokeMethod, null, target, args); +} diff --git a/test/LibRed.Core.Tests/DateTime2KeyEncodingTests.cs b/test/LibRed.Core.Tests/DateTime2KeyEncodingTests.cs new file mode 100644 index 00000000..bd500d6b --- /dev/null +++ b/test/LibRed.Core.Tests/DateTime2KeyEncodingTests.cs @@ -0,0 +1,95 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Catalog; +using LibRed.Storage; +using Xunit; + +namespace LibRed.Core.Tests; + +// DATETIME2 (Date/Time Extended) index keys, byte-for-byte against ACE. +// +// ACE does permit an index on the type, and it keys the whole 42-byte stored value through the same 8-byte +// chunking it uses for Binary - start flag, 8 bytes, 0x09 while more follow, then the final chunk and its +// real-byte count - rather than folding the value to a number the way an ordinary DATETIME folds to its OA +// double. That works because the stored form is already order-preserving: both numeric fields are zero-padded +// to 19 digits, so byte order is chronological order. +// +// The fixture is Northwind (ACE 12) raised to version byte 0x06, which is the entire upgrade - see +// AceDateTime2UpgradeTests, which proves ACE asks for nothing more. +public class DateTime2KeyEncodingTests +{ + // Spread across the range, and deliberately including a January date: ACE's own OLE DB reader cannot + // return January for this type at all, so anything that round-trips a value through ACE rather than + // reading the page would fail here for a reason that has nothing to do with index keys. + private static readonly string[] Literals = + [ + "#2021-03-04 05:06:07#", + "#2021-01-15 05:06:07#", + "#1900-01-01 00:00:00#", + "#2099-12-31 23:59:59#", + ]; + + [Fact] + public void Encoded_datetime2_keys_match_access_byte_for_byte_ascending() + => AssertKeysMatchAccess("CREATE INDEX IX_EKey ON EKey (K)"); + + [Fact] + public void Encoded_datetime2_keys_match_access_byte_for_byte_descending() + => AssertKeysMatchAccess("CREATE INDEX IX_EKey ON EKey (K DESC)"); + + private static void AssertKeysMatchAccess(string indexDdl) + { + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-dt2key-"); + try + { + SetVersionByte(path, 0x06); + + using (OleDbConnection connection = AceTestDatabase.Open(path)) + { + Exec(connection, "CREATE TABLE EKey (K DATETIME2, V LONG)"); + Exec(connection, indexDdl); + for (int i = 0; i < Literals.Length; i++) + Exec(connection, $"INSERT INTO EKey (K, V) VALUES ({Literals[i]}, {i})"); + } + + using var db = JetDatabase.Open(path); + var table = db.OpenTable("EKey"); + var def = table.Definition; + IndexDef index = def.Indexes.Single(i => i.Columns.Any(c => c.Column.Name == "K")); + int kIdx = def.FindColumn("K")!.Index; + var decoder = new RowDecoder(def.Columns, db.Format); + + int checkedKeys = 0; + foreach ((byte[] accessKey, RowId rowId) in new IndexCursor(table.Channel, index.RootPage).RawEntries()) + { + var value = (DateTime)decoder.Decode(db.ReadDataPage(rowId.Page).GetRow(rowId.Row))[kIdx]!; + + var values = new object?[def.Columns.Count]; + values[kIdx] = value; + byte[] ours = IndexKeyEncoder.Encode(index.Columns, values); + + Assert.True(accessKey.AsSpan().SequenceEqual(ours), + $"{value:O}: access={Convert.ToHexString(accessKey)} ours={Convert.ToHexString(ours)}"); + checkedKeys++; + } + + Assert.Equal(Literals.Length, checkedKeys); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static void Exec(OleDbConnection connection, string sql) + { + using OleDbCommand command = connection.CreateCommand(); + command.CommandText = sql; + command.ExecuteNonQuery(); + } + + /// Raises a copied file to the ACE 17 format. Page 0 offset 0x14 is the whole upgrade. + private static void SetVersionByte(string path, byte version) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Write); + stream.Seek(0x14, SeekOrigin.Begin); + stream.WriteByte(version); + } +} diff --git a/test/LibRed.Engine.AccessTests/DateTime2CreatedDatabaseAccessTests.cs b/test/LibRed.Engine.AccessTests/DateTime2CreatedDatabaseAccessTests.cs new file mode 100644 index 00000000..671a7105 --- /dev/null +++ b/test/LibRed.Engine.AccessTests/DateTime2CreatedDatabaseAccessTests.cs @@ -0,0 +1,108 @@ +using System.Data.OleDb; +using LibRed; +using LibRed.Data; +using LibRed.Engine; +using LibRed.Formats; +using Xunit; + +namespace LibRed.Engine.Tests; + +// The end of the DATETIME2 story: a file LibRed synthesised from nothing, at the format the type needs, +// holding a value LibRed wrote — opened and agreed with by Access's own engine. +// +// Everything up to here proves LibRed self-consistent, which proves little: LibRed reading back what LibRed +// wrote would pass just as happily on an encoding ACE has never seen. This is the direction that counts. +// +// The value is read back through ACE's own Year()/Month()/Day()/Hour()/Minute()/Second() rather than by +// materialising the column, because ACE's OLE DB provider cannot return a Date/Time Extended value correctly — +// it hands back a DBTIMESTAMP with the month one short, and throws outright for January. Those scalar +// functions are computed inside the engine and are right (see docs/format/data-types.md), so they read the +// stored bytes without going through the broken conversion. Asserting on them tests our file, not their bug. +[Collection(AceCollection.Name)] +public class DateTime2CreatedDatabaseAccessTests : TempDatabaseTest +{ + // The upgrade path, end to end and against the real engine. LibRed creates an ACE 12 file, then a DDL + // statement needing Date/Time Extended raises it to ACE 17 in place — which is what Access does, but doing + // it ourselves means the file was rewritten by us rather than by them. So the question is not whether + // LibRed can still read it (it wrote it) but whether ACE will still open it at all. + [Fact] + public void Ace_opens_a_database_libred_upgraded_in_place_and_reads_the_datetime2_it_forced() + { + var value = new DateTime(2021, 3, 4, 5, 6, 7).AddTicks(1234567); + + string path = TemporaryDatabase.CreatePath("libred-upgrade-ace-"); + File.Delete(path); + try + { + // Created at the DEFAULT format — ACE 12, the one that cannot hold the type. + LibRedConnection.CreateDatabase($"Data Source={path}"); + Assert.Equal(0x02, VersionByte(path)); + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var engine = new QueryEngine(db); + engine.ExecuteNonQuery("CREATE TABLE `E` (`Id` INTEGER PRIMARY KEY, `V` DATETIME2 NULL)"); + Assert.Equal(JetVersion.Version17_2019, db.Format.Version); + + engine.ExecuteNonQuery("INSERT INTO `E` (`Id`, `V`) VALUES (1, @v)", + new Dictionary { ["v"] = value }); + } + + Assert.Equal(0x06, VersionByte(path)); + + using var connection = AceTestDatabase.Open(path); + using var command = connection.CreateCommand(); + command.CommandText = + "SELECT Year(V), Month(V), Day(V), Hour(V), Minute(V), Second(V) FROM E WHERE Id = 1"; + using OleDbDataReader reader = command.ExecuteReader(); + + Assert.True(reader.Read()); + Assert.Equal( + [value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second], + Enumerable.Range(0, 6).Select(i => Convert.ToInt32(reader.GetValue(i))).ToArray()); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static byte VersionByte(string path) + { + using var stream = File.OpenRead(path); + stream.Seek(0x14, SeekOrigin.Begin); + return (byte)stream.ReadByte(); + } + + [Fact] + public void Ace_reads_a_datetime2_value_libred_wrote_into_a_database_libred_created() + { + // Sub-second ticks deliberately: an ordinary 8-byte DATETIME could not carry them, so a value that + // survives to here could only have gone through the 42-byte encoding. + var value = new DateTime(2021, 3, 4, 5, 6, 7).AddTicks(1234567); + + string path = TemporaryDatabase.CreatePath("libred-dt2-ace-"); + File.Delete(path); // CreateDatabase synthesises the file and refuses an existing one + try + { + LibRedConnection.CreateDatabase($"Data Source={path}", version: JetVersion.Version17_2019); + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var engine = new QueryEngine(db); + engine.ExecuteNonQuery("CREATE TABLE `E` (`Id` INTEGER PRIMARY KEY, `V` DATETIME2 NULL)"); + engine.ExecuteNonQuery("INSERT INTO `E` (`Id`, `V`) VALUES (1, @v)", + new Dictionary { ["v"] = value }); + } + + using var connection = AceTestDatabase.Open(path); + using var command = connection.CreateCommand(); + command.CommandText = + "SELECT Year(V), Month(V), Day(V), Hour(V), Minute(V), Second(V) FROM E WHERE Id = 1"; + using OleDbDataReader reader = command.ExecuteReader(); + + Assert.True(reader.Read()); + Assert.Equal( + [value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second], + Enumerable.Range(0, 6).Select(i => Convert.ToInt32(reader.GetValue(i))).ToArray()); + } + finally { TemporaryDatabase.Delete(path); } + } +} diff --git a/test/LibRed.Engine.Tests/DdlDmlTests.cs b/test/LibRed.Engine.Tests/DdlDmlTests.cs index 17ecacee..cbf22f45 100644 --- a/test/LibRed.Engine.Tests/DdlDmlTests.cs +++ b/test/LibRed.Engine.Tests/DdlDmlTests.cs @@ -1,5 +1,6 @@ using LibRed; using LibRed.Engine; +using LibRed.Formats; using Xunit; namespace LibRed.Engine.Tests; @@ -12,36 +13,185 @@ private static string CopyToTemp() return path; } - // BIGINT (Large Number) and DATETIME2 (Date/Time Extended) were added in DIFFERENT format versions: - // BIGINT needs Access 2016 (ACE 16, version 0x05), DATETIME2 needs Access 2019+ (ACE 17, 0x06) — verified - // against files authored with each feature. On an older format (Northwind is ACE 12 / Access 2007) LibRed - // must refuse to create a column to either, rather than write a file the real Access engine couldn't open. + // BIGINT (Large Number) and DATETIME2 (Date/Time Extended) arrived in DIFFERENT format versions: BIGINT + // needs Access 2016 (ACE 16, version byte 0x05), DATETIME2 needs Access 2019+ (ACE 17, 0x06) — verified + // against files authored with each feature. Northwind is ACE 12 (0x02), so using either type forces the + // file's hand, and LibRed does what Access does: raise the version rather than refuse the DDL. Verified by + // having ACE add a Date/Time Extended column to an ACE 12 database and diffing — the version byte moved, + // and for DATETIME2 it is the whole upgrade (docs/format/page-00-database.md). [Theory] - [InlineData("CREATE TABLE `T` (`Id` INTEGER PRIMARY KEY, `V` BIGINT)", "Access 2016")] - [InlineData("CREATE TABLE `T` (`Id` INTEGER PRIMARY KEY, `V` DATETIME2)", "Access 2019")] - public void Bigint_and_datetime2_are_rejected_when_creating_on_a_pre_2016_format(string sql, string expectedVersionInMessage) + [InlineData("BIGINT", JetVersion.Version16_2016, 0x05)] + [InlineData("DATETIME2", JetVersion.Version17_2019, 0x06)] + public void Creating_a_column_of_a_newer_type_raises_the_file_format( + string typeName, JetVersion expected, byte expectedByte) { string path = CopyToTemp(); try { - using var db = JetDatabase.Open(path, readOnly: false); - var ex = Assert.Throws(() => new QueryEngine(db).ExecuteNonQuery(sql)); - Assert.Contains(expectedVersionInMessage, ex.Message); + using (var db = JetDatabase.Open(path, readOnly: false)) + { + Assert.Equal(JetVersion.Version12_2007, db.Format.Version); + + new QueryEngine(db).ExecuteNonQuery($"CREATE TABLE `T` (`Id` INTEGER PRIMARY KEY, `V` {typeName})"); + + Assert.Equal(expected, db.Format.Version); + Assert.Equal(expectedByte, db.DefinitionPage.JetVersion); // page 0 was re-read, not left stale + } + + Assert.Equal(expectedByte, VersionByte(path)); // and it reached the file } finally { TemporaryDatabase.Delete(path); } } [Fact] - public void Altering_a_column_to_bigint_is_rejected_on_a_pre_2016_format() + public void Altering_a_column_to_bigint_raises_the_file_format() { string path = CopyToTemp(); try { - using var db = JetDatabase.Open(path, readOnly: false); - var e = new QueryEngine(db); - e.ExecuteNonQuery("CREATE TABLE `T` (`Id` INTEGER PRIMARY KEY, `V` INTEGER)"); - var ex = Assert.Throws(() => e.ExecuteNonQuery("ALTER TABLE `T` ALTER COLUMN `V` BIGINT")); - Assert.Contains("Access 2016", ex.Message); + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var e = new QueryEngine(db); + e.ExecuteNonQuery("CREATE TABLE `T` (`Id` INTEGER PRIMARY KEY, `V` INTEGER)"); + Assert.Equal(JetVersion.Version12_2007, db.Format.Version); + + e.ExecuteNonQuery("ALTER TABLE `T` ALTER COLUMN `V` BIGINT"); + Assert.Equal(JetVersion.Version16_2016, db.Format.Version); + } + + Assert.Equal(0x05, VersionByte(path)); + } + finally { TemporaryDatabase.Delete(path); } + } + + // The upgrade rides in the statement's own transaction, so a CREATE that fails after the format was + // raised must take the raise back down with it — on disk AND in memory. Getting only the disk half right + // would leave the open database claiming a version its file does not have, and the next DATETIME2 column + // would then be written into a file that never got upgraded. + [Fact] + public void A_failed_statement_does_not_leave_the_format_raised() + { + string path = CopyToTemp(); + try + { + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var e = new QueryEngine(db); + e.ExecuteNonQuery("CREATE TABLE `T` (`Id` INTEGER PRIMARY KEY)"); + + // Same statement needs the upgrade AND cannot succeed: the table already exists. + Assert.ThrowsAny(() => + e.ExecuteNonQuery("CREATE TABLE `T` (`Id` INTEGER PRIMARY KEY, `V` DATETIME2)")); + + Assert.Equal(JetVersion.Version12_2007, db.Format.Version); + Assert.Equal(0x02, db.DefinitionPage.JetVersion); + } + + Assert.Equal(0x02, VersionByte(path)); + } + finally { TemporaryDatabase.Delete(path); } + } + + private static byte VersionByte(string path) + { + using var stream = File.OpenRead(path); + stream.Seek(0x14, SeekOrigin.Begin); + return (byte)stream.ReadByte(); + } + + // Date/Time Extended end to end through LibRed alone — CREATE, INSERT, SELECT — on an ACE 17 file. + // The fixture is Northwind (ACE 12) with its version byte raised to 0x06, which IS the whole upgrade: ACE + // itself asks for nothing more (AceDateTime2UpgradeTests proves the byte is sufficient against the real + // engine). Doing it that way rather than shipping a second fixture keeps this suite free of any Access + // dependency, so it still runs on the Linux/macOS/ARM legs. + // + // The values are chosen for what the 42-byte encoding has to get right rather than for what an ordinary + // 8-byte DATETIME could already do: 100-ns sub-second ticks, which are the reason the type exists and + // which an OA double cannot hold; DateTime.MinValue, where both 19-digit fields pad to all zeros; the top + // of the range at MaxValue; and a January date — the one month ACE's own OLE DB reader cannot return at + // all. A NULL covers the null bitmap for a fixed-length column. + [Fact] + public void Datetime2_round_trips_through_libred_on_the_ace17_format() + { + (int Id, DateTime? Value)[] cases = + [ + (1, new DateTime(2021, 3, 4, 5, 6, 7)), + (2, new DateTime(2021, 3, 4, 5, 6, 7).AddTicks(1234567)), + (3, new DateTime(2021, 1, 15, 5, 6, 7)), + (4, DateTime.MinValue), + (5, DateTime.MaxValue), + (6, null), + ]; + + string path = CopyToTemp(); + try + { + SetVersionByte(path, 0x06); + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var e = new QueryEngine(db); + e.ExecuteNonQuery("CREATE TABLE `E` (`Id` INTEGER PRIMARY KEY, `V` DATETIME2 NULL)"); + foreach ((int id, DateTime? value) in cases) + e.ExecuteNonQuery("INSERT INTO `E` (`Id`, `V`) VALUES (@id, @v)", + new Dictionary { ["id"] = id, ["v"] = value }); + } + + // Reopened, so the values are read back off the page rather than out of anything still in memory. + using (var db = JetDatabase.Open(path)) + { + var rows = new QueryEngine(db).ExecuteQuery("SELECT `Id`, `V` FROM `E` ORDER BY `Id`").Rows + .ToDictionary(r => Convert.ToInt32(r[0]), r => (DateTime?)r[1]); + + Assert.Equal(cases.Length, rows.Count); + foreach ((int id, DateTime? value) in cases) + Assert.Equal(value, rows[id]); + } + } + finally { TemporaryDatabase.Delete(path); } + } + + /// Raises a copied file to the ACE 17 format. Page 0 offset 0x14 is the entire upgrade — see + /// docs/format/page-00-database.md and AceDateTime2UpgradeTests. + private static void SetVersionByte(string path, byte version) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Write); + stream.Seek(0x14, SeekOrigin.Begin); + stream.WriteByte(version); + } + + // Creating at a chosen format, rather than upgrading someone else's file. The default stays ACE 12 so an + // ordinary database keeps opening in every Access from 2007; asking for ACE 17 up front is how you get a + // DATETIME2 database without the file ever having been at an older version. + // + // Both arms end up able to hold the type — that is the point of the upgrade — but only one of them was + // ever ACE 12, and a caller who says Version17_2019 should not have to rely on a later DDL side effect. + [Theory] + [InlineData(JetVersion.Version12_2007, 0x02, 0x06)] + [InlineData(JetVersion.Version17_2019, 0x06, 0x06)] + public void A_natively_created_database_is_stamped_at_the_requested_format( + JetVersion version, byte createdByte, byte afterDatetime2) + { + var value = new DateTime(2021, 3, 4, 5, 6, 7).AddTicks(1234567); + + string path = TemporaryDatabase.CreatePath("libred-create-"); + File.Delete(path); // CreateDatabase synthesises the file and refuses an existing one + try + { + LibRed.Data.LibRedConnection.CreateDatabase($"Data Source={path}", version: version); + Assert.Equal(createdByte, VersionByte(path)); + + using (var db = JetDatabase.Open(path, readOnly: false)) + { + var e = new QueryEngine(db); + e.ExecuteNonQuery("CREATE TABLE `E` (`Id` INTEGER PRIMARY KEY, `V` DATETIME2 NULL)"); + e.ExecuteNonQuery("INSERT INTO `E` (`Id`, `V`) VALUES (1, @v)", + new Dictionary { ["v"] = value }); + + Assert.Equal(value, e.ExecuteQuery("SELECT `V` FROM `E`").Rows.Single()[0]); + } + + Assert.Equal(afterDatetime2, VersionByte(path)); } finally { TemporaryDatabase.Delete(path); } } From c423c160d1213e45133d3930a59b3fdc0ebda0d9 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Wed, 26 Aug 2026 20:53:21 +0800 Subject: [PATCH 43/48] LibRed: BIGINT - index keys, the variable-length fix, and the 0x05 upgrade measured Large Number (JetDataType 0x13) was the last type ACE lets you index that IndexKeyEncoder could not encode, and the last of the two new-format types whose upgrade route was on record as assumed rather than measured. Both closed, and a real bug fell out of the probe on the way. AccessTypeMapper created BIGINT as a FIXED 8-byte column. ACE stores it behind the row's variable offset table - a descriptor carrying length 8 with the fixed flag clear - so LibRed was writing the value somewhere Access does not look for it. The docs had this right (data-types.md and appendix-structures.md both said variable); only the mapper disagreed. Note a LibRed-only round trip cannot catch this: reading back what you wrote agrees with itself whichever region it used. It shows up only against ACE. Index keys are what had long been guessed - an int64, sign bit flipped, big-endian, exactly as Currency - now measured across 0, +/-1, +/-42 and both extremes, ascending and descending. The variable-length storage does not affect it: the key dispatch is on the column's type, not on where the row keeps the bytes. IndexKeyDecoder handles it too, unlike DATETIME2, since it is a plain fixed-width numeric key. The 0x05 route is no longer assumed. CREATE TABLE ... BIGINT issued through ACE against an ACE 12 file moves the version byte to 0x05 - not 0x06 - confirming the two new types really do sit at different formats rather than both landing on the newest. Also recorded, because it costs a day to rediscover: an OleDbType.BigInt parameter carries NO value into a Large Number column through this provider, zero included, so the one type named for the job is the only one that cannot do it. Numeric, Decimal and Variant each round-trip the full range exactly; VarNumeric is rejected outright; Double succeeds quietly for small values and overflows near +/-2^63. EFCore.Jet's JetLongTypeMapping already forces the same workaround for its own long parameters, found years earlier from the decimal side - its comment said "Needs to be Object" while the code sets Numeric, so that is corrected here and annotated with what the BIGINT column measurements add to it. Verified against the real engine throughout, ending with ACE reading BIGINT values out of a file LibRed created at ACE 12 and upgraded in place. Co-Authored-By: Claude Opus 5 --- .../Storage/Internal/JetLongTypeMapping.cs | 11 +- .../LibRed.Core/Storage/IndexKeyDecoder.cs | 4 +- .../LibRed.Core/Storage/IndexKeyEncoder.cs | 6 +- .../Execution/AccessTypeMapper.cs | 6 +- src/LibRed/README.md | 23 +-- src/LibRed/docs/format/data-types.md | 22 ++- src/LibRed/docs/format/page-00-database.md | 6 +- .../docs/format/page-03-04-index-btree.md | 17 ++- .../BigIntKeyEncodingTests.cs | 143 ++++++++++++++++++ .../BigIntCreatedDatabaseAccessTests.cs | 70 +++++++++ test/LibRed.Engine.Tests/DdlDmlTests.cs | 48 ++++++ 11 files changed, 330 insertions(+), 26 deletions(-) create mode 100644 test/LibRed.Core.Tests/BigIntKeyEncodingTests.cs create mode 100644 test/LibRed.Engine.AccessTests/BigIntCreatedDatabaseAccessTests.cs diff --git a/src/EFCore.Jet/Storage/Internal/JetLongTypeMapping.cs b/src/EFCore.Jet/Storage/Internal/JetLongTypeMapping.cs index 4ab52dec..dc32bb5f 100644 --- a/src/EFCore.Jet/Storage/Internal/JetLongTypeMapping.cs +++ b/src/EFCore.Jet/Storage/Internal/JetLongTypeMapping.cs @@ -32,9 +32,16 @@ protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters p protected override void ConfigureParameter(DbParameter parameter) { base.ConfigureParameter(parameter); - //Needs to be Object. Using BigInt doesn't always work + //Needs to be Numeric. Using BigInt doesn't always work //If the argument value is a long and within Int32 range, having it as BigInt in x86 works - //When running in x64 it fails to convert. Using Object bypasses the conversion + //When running in x64 it fails to convert. Using Numeric bypasses the conversion + //Confirmed since against a real ACE Large Number (BIGINT) column, where it is not "doesn't + //always" but never: an OleDbType.BigInt parameter carries no value at all, zero included, + //while Numeric, Decimal and Variant each round-trip the full Int64 range exactly. DBTYPE_I8 + //looks simply never to have been wired into ACE's VARIANT-based coercion - which has been able + //to hold a decimal since the beginning, hence the type that works being the one for decimals. + //OdbcType.Numeric = 7; + //OleDbType.Numeric = 131; var setodbctype = parameter.GetType().GetMethods().FirstOrDefault(x => x.Name == "set_OdbcType"); var setoledbtype = parameter.GetType().GetMethods().FirstOrDefault(x => x.Name == "set_OleDbType"); diff --git a/src/LibRed/LibRed.Core/Storage/IndexKeyDecoder.cs b/src/LibRed/LibRed.Core/Storage/IndexKeyDecoder.cs index 6fbaa54f..3eb505f7 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexKeyDecoder.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexKeyDecoder.cs @@ -77,7 +77,7 @@ public static class IndexKeyDecoder JetDataType.Int32 => 4, JetDataType.Single => 4, JetDataType.Double or JetDataType.DateTime => 8, - JetDataType.Currency => 8, + JetDataType.Currency or JetDataType.Int64 => 8, _ => -1, }; @@ -95,6 +95,8 @@ private static object DecodeFixed(JetDataType type, Span raw, bool ascendi return (int)DecodeInteger(raw, ascending); case JetDataType.Currency: return DecodeInteger(raw, ascending) / 10000m; + case JetDataType.Int64: + return DecodeInteger(raw, ascending); case JetDataType.Single: return BitConverter.Int32BitsToSingle((int)DecodeFloatBits(raw, ascending)); diff --git a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs index f321ed38..a6f4bee5 100644 --- a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs +++ b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs @@ -265,7 +265,9 @@ private static void EncodeBinaryChunked(List buffer, byte[] data, bool asc JetDataType.Int32 => 4, JetDataType.Single => 4, JetDataType.Double or JetDataType.DateTime => 8, - JetDataType.Currency => 8, + // Int64/BIGINT keys like Currency — both are an int64, sign bit flipped, big-endian. Its VARIABLE + // storage does not change that: this dispatch is on the type, not on where the row keeps it. + JetDataType.Currency or JetDataType.Int64 => 8, JetDataType.FixedPoint => 17, // sign byte + 16-byte big-endian magnitude _ => -1, }; @@ -283,6 +285,8 @@ private static byte[] EncodeFixed(ColumnDef column, object value) return EncodeInteger(Convert.ToInt32(value, c), 4); case JetDataType.Currency: return EncodeInteger((long)decimal.Round(Convert.ToDecimal(value, c) * 10000m), 8); + case JetDataType.Int64: // BIGINT — verified against ACE across 0, ±1, ±42 and both extremes + return EncodeInteger(Convert.ToInt64(value, c), 8); case JetDataType.Single: return EncodeFloatBits(BitConverter.SingleToInt32Bits(Convert.ToSingle(value, c)), 4); case JetDataType.Double: diff --git a/src/LibRed/LibRed.Engine/Execution/AccessTypeMapper.cs b/src/LibRed/LibRed.Engine/Execution/AccessTypeMapper.cs index 30ac70e9..06cdbf5d 100644 --- a/src/LibRed/LibRed.Engine/Execution/AccessTypeMapper.cs +++ b/src/LibRed/LibRed.Engine/Execution/AccessTypeMapper.cs @@ -63,8 +63,12 @@ private static ColumnSpec MapType(ColumnDefinition column, JetVersion version) => Fixed(column, JetDataType.Int16, 2), "BYTE" or "TINYINT" or "INTEGER1" => Fixed(column, JetDataType.Byte, 1), + // Large Number. Always 8 bytes, yet ACE stores it in the row's VARIABLE region, not the fixed one + // — a descriptor carrying length 8 with the fixed flag clear (verified: a column ACE created reads + // back `length=8 fixed=False`, and the row lays it out behind the variable offset table). Declaring + // it fixed would put it somewhere ACE does not look for it. "BIGINT" - => Fixed(column, JetDataType.Int64, 8), + => new ColumnSpec(column.Name, JetDataType.Int64, 8, IsFixedLength: false), "REAL" or "SINGLE" or "IEEESINGLE" or "FLOAT4" => Fixed(column, JetDataType.Single, 4), "FLOAT" or "DOUBLE" or "DOUBLE PRECISION" or "IEEEDOUBLE" or "FLOAT8" or "NUMBER" diff --git a/src/LibRed/README.md b/src/LibRed/README.md index e534e427..6818dda1 100644 --- a/src/LibRed/README.md +++ b/src/LibRed/README.md @@ -127,17 +127,18 @@ LibRed-side `CHECK` enforcement, self-pointing self-references, and writing Memo both `CASCADE` directions work. - **1:1 relationships** (`dbRelationUnique` = `0x01`) — never written; a 1:1 migration would render as 1:many in Access (the unique index still enforces uniqueness). Probe a real 1:1's `grbit` first. -- **ACE-16 types** — `Int64`/BIGINT (`0x13`) index-key encoding is trivial but unverified. - `DateTimeExtended`/DATETIME2 (`0x14`) is **done**: read, write, `CREATE TABLE`, index keys (ascending and - descending), and native creation at the format it needs — `LibRedConnection.CreateDatabase(…, version:)` - takes a `JetVersion`, defaulting to ACE 12 so an ordinary database still opens in every Access from 2007. - Each step is verified against ACE, ending with Access's own engine reading a 100-ns value out of a file - LibRed synthesised from nothing (`DateTime2CreatedDatabaseAccessTests`). - The **format auto-upgrade** is implemented too: DDL that introduces one of these types raises the open - file's version byte rather than refusing, which is what Access itself does — adding a Date/Time Extended - column to an ACE 12 database moves it to `0x06`, and that byte is the entire upgrade - (`docs/format/page-00-database.md`). The raise joins the statement's transaction, so a failed CREATE/ALTER - takes it back down. ACE opens a file LibRed upgraded this way and reads the value that forced it. Note the +- **ACE 16/17 types — `Int64`/BIGINT (`0x13`) and `DateTimeExtended`/DATETIME2 (`0x14`) are both done**: read, + write, `CREATE TABLE`, index keys (ascending and descending), and native creation at the format each needs. + `LibRedConnection.CreateDatabase(…, version:)` takes a `JetVersion`, defaulting to ACE 12 so an ordinary + database still opens in every Access from 2007. Every step is verified against ACE, ending with Access's own + engine reading values out of files LibRed synthesised from nothing + (`DateTime2CreatedDatabaseAccessTests`, `BigIntCreatedDatabaseAccessTests`). + Two traps worth knowing: BIGINT is stored **variable**-length despite always being 8 bytes, and the two + types sit at **different** formats — `0x05` for BIGINT, `0x06` for DATETIME2. + The **format auto-upgrade** is implemented too: DDL that introduces either type raises the open file's + version byte rather than refusing, which is what Access itself does (`docs/format/page-00-database.md`, + where both routes are now measured). The raise joins the statement's transaction, so a failed CREATE/ALTER + takes it back down. ACE opens a file LibRed upgraded this way and reads the value that forced it. The upgrade is one-way and unavoidable: an older Access cannot open the result, but neither could it read the column. `CreateDatabase(…, version:)` remains the way to start at a format rather than arrive at one. - **Non-English text collations** — index-key weights exist for the two General (1033) orders only: diff --git a/src/LibRed/docs/format/data-types.md b/src/LibRed/docs/format/data-types.md index 8eb0fdce..be2780bd 100644 --- a/src/LibRed/docs/format/data-types.md +++ b/src/LibRed/docs/format/data-types.md @@ -21,7 +21,7 @@ | `0x0F` | GUID | 16 raw bytes | | `0x10` | FixedPoint (Numeric/Decimal) | 17 bytes: sign byte (`0x80` = negative) + 128-bit magnitude (four 32-bit little-endian words, low word last); value = magnitude / 10^scale. Precision/scale from the column descriptor (§3.4) | | `0x12` | Complex (multi-value / attachment) | descriptor parsed; contents not materialized (out of scope for SQL/EF) | -| `0x13` | Int64 — **BIGINT** (ACE 16 / Access 2016) | 8-byte little-endian signed integer. Stored as a *variable*-length column | +| `0x13` | Int64 — **BIGINT** (ACE 16 / Access 2016) | 8-byte little-endian signed integer. Stored as a *variable*-length column (see below) | | `0x14` | DateTimeExtended — **DATETIME2** (ACE 17 / Access 2019+) | fixed 42-byte ASCII `: private bool TryCreateAce12Database(string prefix, out string path) { + // Both tests here have ACE itself create or read a DATETIME2 column, which an ACE below 17 cannot do + // at all — CI installs the 2016 redistributable. Skip rather than fail: a machine without the type + // proves nothing either way about the upgrade. + Assert.SkipUnless( + AceTestDatabase.SupportsColumnType(TestDatabases.NorthwindAccdb, "DATETIME2"), + AceTestDatabase.UnsupportedColumnTypeReason("DATETIME2")); + path = ""; object? engine = null; foreach (int n in new[] { 170, 160, 150, 140, 130, 120 }) diff --git a/test/LibRed.Core.Tests/BigIntKeyEncodingTests.cs b/test/LibRed.Core.Tests/BigIntKeyEncodingTests.cs index d495de7e..0cead87b 100644 --- a/test/LibRed.Core.Tests/BigIntKeyEncodingTests.cs +++ b/test/LibRed.Core.Tests/BigIntKeyEncodingTests.cs @@ -33,9 +33,16 @@ public class BigIntKeyEncodingTests // small values and overflows near ±2^63. private const OleDbType ParameterType = OleDbType.Numeric; + /// ACE 16 / Access 2016 only. CI has installed older redistributables, and a machine whose ACE + /// predates Large Number cannot create the column at all — which says nothing about LibRed. + private static void RequireBigInt() => Assert.SkipUnless( + AceTestDatabase.SupportsColumnType(TestDatabases.NorthwindAccdb, "BIGINT"), + AceTestDatabase.UnsupportedColumnTypeReason("BIGINT")); + [Fact] public void Adding_a_bigint_column_makes_ace_raise_the_file_to_ace16() { + RequireBigInt(); string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-bigint-ver-"); try { @@ -57,6 +64,7 @@ public void Adding_a_bigint_column_makes_ace_raise_the_file_to_ace16() [Fact] public void Ace_stores_a_bigint_column_as_variable_length() { + RequireBigInt(); string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-bigint-shape-"); try { @@ -83,6 +91,7 @@ public void Encoded_bigint_keys_match_access_byte_for_byte_descending() private static void AssertKeysMatchAccess(string indexDdl) { + RequireBigInt(); string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-bigintkey-"); try { diff --git a/test/LibRed.Core.Tests/DateTime2KeyEncodingTests.cs b/test/LibRed.Core.Tests/DateTime2KeyEncodingTests.cs index bd500d6b..11e184e2 100644 --- a/test/LibRed.Core.Tests/DateTime2KeyEncodingTests.cs +++ b/test/LibRed.Core.Tests/DateTime2KeyEncodingTests.cs @@ -39,6 +39,12 @@ public void Encoded_datetime2_keys_match_access_byte_for_byte_descending() private static void AssertKeysMatchAccess(string indexDdl) { + // ACE 17 / Access 2019+ only. CI installs the 2016 redistributable, which cannot create the column at + // all — a failure there would say nothing about LibRed's encoding. + Assert.SkipUnless( + AceTestDatabase.SupportsColumnType(TestDatabases.NorthwindAccdb, "DATETIME2"), + AceTestDatabase.UnsupportedColumnTypeReason("DATETIME2")); + string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-dt2key-"); try { diff --git a/test/LibRed.Engine.AccessTests/BigIntCreatedDatabaseAccessTests.cs b/test/LibRed.Engine.AccessTests/BigIntCreatedDatabaseAccessTests.cs index 7b871d1c..c13c210c 100644 --- a/test/LibRed.Engine.AccessTests/BigIntCreatedDatabaseAccessTests.cs +++ b/test/LibRed.Engine.AccessTests/BigIntCreatedDatabaseAccessTests.cs @@ -25,6 +25,13 @@ public class BigIntCreatedDatabaseAccessTests : TempDatabaseTest [Fact] public void Ace_reads_bigint_values_libred_wrote_into_a_database_libred_upgraded() { + // ACE 16 / Access 2016 only — an older engine cannot read the column back, which would say nothing + // about the file LibRed wrote. + Assert.SkipUnless( + AceTestDatabase.SupportsColumnType( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "BIGINT"), + AceTestDatabase.UnsupportedColumnTypeReason("BIGINT")); + // Both extremes and both signs: the index/storage transforms are sign-sensitive, and a positives-only // sample would agree with almost any encoding. long[] values = [0L, 1L, -1L, 42L, -42L, long.MaxValue, long.MinValue]; diff --git a/test/LibRed.Engine.AccessTests/DateTime2CreatedDatabaseAccessTests.cs b/test/LibRed.Engine.AccessTests/DateTime2CreatedDatabaseAccessTests.cs index 671a7105..b9fb1b8d 100644 --- a/test/LibRed.Engine.AccessTests/DateTime2CreatedDatabaseAccessTests.cs +++ b/test/LibRed.Engine.AccessTests/DateTime2CreatedDatabaseAccessTests.cs @@ -25,9 +25,17 @@ public class DateTime2CreatedDatabaseAccessTests : TempDatabaseTest // statement needing Date/Time Extended raises it to ACE 17 in place — which is what Access does, but doing // it ourselves means the file was rewritten by us rather than by them. So the question is not whether // LibRed can still read it (it wrote it) but whether ACE will still open it at all. + /// ACE 17 / Access 2019+ only — an older engine cannot read the column back at all, so a failure + /// there would be about the installed driver rather than about the file LibRed wrote. + private static void RequireDateTime2() => Assert.SkipUnless( + AceTestDatabase.SupportsColumnType( + Path.Combine(AppContext.BaseDirectory, "Data", "Northwind.accdb"), "DATETIME2"), + AceTestDatabase.UnsupportedColumnTypeReason("DATETIME2")); + [Fact] public void Ace_opens_a_database_libred_upgraded_in_place_and_reads_the_datetime2_it_forced() { + RequireDateTime2(); var value = new DateTime(2021, 3, 4, 5, 6, 7).AddTicks(1234567); string path = TemporaryDatabase.CreatePath("libred-upgrade-ace-"); @@ -74,6 +82,7 @@ private static byte VersionByte(string path) [Fact] public void Ace_reads_a_datetime2_value_libred_wrote_into_a_database_libred_created() { + RequireDateTime2(); // Sub-second ticks deliberately: an ordinary 8-byte DATETIME could not carry them, so a value that // survives to here could only have gone through the 42-byte encoding. var value = new DateTime(2021, 3, 4, 5, 6, 7).AddTicks(1234567); diff --git a/test/LibRed.Shared/AceTestDatabase.cs b/test/LibRed.Shared/AceTestDatabase.cs index d8bdacf6..85573e55 100644 --- a/test/LibRed.Shared/AceTestDatabase.cs +++ b/test/LibRed.Shared/AceTestDatabase.cs @@ -1,5 +1,6 @@ // Explicit usings — see the note in TemporaryDatabase.cs. using System; +using System.Collections.Generic; using System.Data.OleDb; using System.Threading; @@ -40,4 +41,55 @@ public static OleDbConnection Open(string path, string? password = null, int att throw new InvalidOperationException("No Microsoft ACE OLE DB provider could open the test database.", last); } + + private static readonly Dictionary ColumnTypeSupport = []; + + /// + /// Whether the ACE installed on this machine can create a column of , asked by + /// trying it once and caching the answer. + /// + /// + /// The new-format types are not available on every ACE: DATETIME2 (Date/Time Extended) needs ACE 17 + /// / Access 2019+, BIGINT (Large Number) needs ACE 16 / Access 2016. CI installs the **2016** + /// redistributable, so a test written against a developer machine running Microsoft 365 will fail there + /// for a reason that says nothing about LibRed. Guard those with + /// Assert.SkipUnless(AceTestDatabase.SupportsColumnType(...), ...). + /// The probe runs against a throwaway copy and needs no particular format version: ACE raises the + /// file itself when a column demands a newer one. + /// It assumes an ACE that does not know a type name rejects it rather than silently + /// coercing it to something else — reasonable, since ACE is strict enough about these names to reject + /// even DATETIME2(7) and DATETIMEEXTENDED as syntax errors. If a guarded test ever fails on + /// an older engine instead of skipping, this assumption is where to look. + /// + public static bool SupportsColumnType(string sourceDatabase, string typeName) + { + lock (ColumnTypeSupport) + { + if (ColumnTypeSupport.TryGetValue(typeName, out bool known)) return known; + + bool supported; + string path = TemporaryDatabase.CopyPath(sourceDatabase, "ace-typeprobe-"); + try + { + using OleDbConnection connection = Open(path); + using OleDbCommand command = connection.CreateCommand(); + command.CommandText = $"CREATE TABLE AceTypeProbe (V {typeName})"; + command.ExecuteNonQuery(); + supported = true; + } + catch (Exception) + { + supported = false; + } + finally { TemporaryDatabase.Delete(path); } + + ColumnTypeSupport[typeName] = supported; + return supported; + } + } + + /// The skip reason for a type this ACE cannot create. + public static string UnsupportedColumnTypeReason(string typeName) => + $"The installed ACE cannot create a {typeName} column - it predates the type. " + + $"DATETIME2 needs ACE 17 (Access 2019+/365); BIGINT needs ACE 16 (Access 2016)."; } From 834cd1c53d6c60276ff762017a15f72e18b33a64 Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Thu, 27 Aug 2026 00:06:04 +0800 Subject: [PATCH 47/48] Run LibRed.Engine.AccessTests on push, as it already does on pull_request The LibRedAccess job in pull_request.yml runs Core, Engine.AccessTests, Ado and EFCore; push.yml was missing the Engine.AccessTests step, so those cross-checks against the real Access engine only ran on pull requests and a push could go green without them. Added verbatim from the pull_request version, comment included, so the two job definitions now match step for step. Co-Authored-By: Claude Opus 5 --- .github/workflows/push.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 7a8747e0..9f934379 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -686,6 +686,12 @@ jobs: if: env.skipTests != 'true' shell: pwsh run: dotnet test .\test\LibRed.Core.Tests --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' + if: always() && env.skipTests != 'true' + shell: pwsh + run: dotnet test .\test\LibRed.Engine.AccessTests --configuration '${{ env.buildConfiguration }}' -p:FixedTestOrder=${{ env.deterministicTests }} --blame-hang-timeout 5m - name: 'Run Tests: LibRed.Ado.Tests' if: always() && env.skipTests != 'true' shell: pwsh From c717da69c6b2742304b8d6ca815843c7fffe9f8a Mon Sep 17 00:00:00 2001 From: Christopher Jolly Date: Thu, 27 Aug 2026 00:07:44 +0800 Subject: [PATCH 48/48] Add settings.json to restrict shell command usage Introduced settings.json to explicitly allow only certain Bash and PowerShell commands (mainly dotnet and git). Added pre-tool-use hooks to deny shell commands that edit source files or run Python, and to restrict file reading commands to scratchpad/log files. This enhances safety and control over shell usage in the project. --- .claude/settings.json | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..e6afd137 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,42 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet test *)", + "Bash(dotnet build *)", + "Bash(dotnet restore *)", + "Bash(dotnet nuget list source)", + "Bash(dotnet --info)", + "Bash(dotnet --version)", + "Bash(dotnet --list-sdks)", + "Bash(dotnet --list-runtimes)", + "PowerShell(dotnet test *)", + "PowerShell(dotnet build *)", + "PowerShell(dotnet restore *)", + "Bash(git status*)", + "Bash(git diff*)", + "Bash(git log*)", + "Bash(git show*)", + "Bash(git branch*)", + "Bash(git commit*)", + "Bash(git push*)", + "PowerShell" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|PowerShell", + "hooks": [ + { + "type": "command", + "command": "grep -qE '(^|[;&|(\")])[[:space:]]*(python[0-9.]*|pythonw|py)([[:space:]]|\")|sed[[:space:]]+-[a-zA-Z]*i|sed[[:space:]]+--in-place|>[[:space:]]*[^[:space:];&|]*\\.(cs|md|csproj|props|targets|sln|json|ps1|g4)|tee[[:space:]][^;&|]*\\.(cs|md|csproj|props|targets|sln|json|ps1|g4)' && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Shell edits to source files (and Python) are disallowed: they bypass file checkpointing, so /rewind cannot undo them. Use Edit or Write instead. Writing logs to the scratchpad is fine.\"}}' || true" + }, + { + "type": "command", + "command": "in=$(cat); if printf '%s' \"$in\" | grep -qE '(^|[;&(\")]|\\\\n)[[:space:]]*(grep|egrep|fgrep|rg|cat|head|tail|ls|dir|find|fd|fdfind|Get-Content|gc|Get-ChildItem|gci|Select-String|sls)([[:space:]]|\")' && ! printf '%s' \"$in\" | grep -qE '(scratchpad|\\.log|\\$log|/tmp/)'; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Reading files through the shell costs a permission prompt and bypasses the dedicated tools. Use Grep for content, Glob for filenames, Read for file contents. Shell text tools stay allowed on scratchpad logs (paths containing scratchpad, .log or $log).\"}}'; fi; true" + } + ] + } + ] + } +}

    )zfnOQNxk{?Sw-;4DN`gFr{B=D!4Q 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 O+F2Q4)wLh1uKx$=P!P}n literal 0 HcmV?d00001 diff --git a/test/LibRed.Core.Tests/Data/Indic.accdb b/test/LibRed.Core.Tests/Data/Indic.accdb new file mode 100644 index 0000000000000000000000000000000000000000..b75ff1b4e8e1a0ede531d3e5d4ae46ab85237998 GIT binary patch literal 491520 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