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"
+ }
+ ]
+ }
+ ]
+ }
+}
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/.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
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/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/EFCore.Jet/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs
index 35ce3589..aa43e466 100644
--- a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs
+++ b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetByteArrayMethodTranslator.cs
@@ -69,57 +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[]))
+
+ if (method.IsGenericMethod
+ && method.DeclaringType == typeof(Enumerable))
{
- 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),
+ 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));
+
+ return _sqlExpressionFactory.GreaterThan(
_sqlExpressionFactory.Function(
- "STRCONV",
- [source, _sqlExpressionFactory.Constant(64)],
+ "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: [true, false],
- typeof(string)),
- value,
- _sqlExpressionFactory.Constant(0)
- ],
- nullable: true,
- argumentsPropagateNullability: [false, true, true, false],
- typeof(int)),
- _sqlExpressionFactory.Constant(0));
- }
+ argumentsPropagateNullability: [false, true, true, false],
+ typeof(int)),
+ _sqlExpressionFactory.Constant(0));
+ }
- 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));
+ // 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;
diff --git a/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs b/src/EFCore.Jet/Query/ExpressionTranslators/Internal/JetDateTimeMemberTranslator.cs
index cad85fe2..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), "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..4ae71188 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/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/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/EFCore.Jet/Query/Sql/Internal/JetQuerySqlGenerator.cs b/src/EFCore.Jet/Query/Sql/Internal/JetQuerySqlGenerator.cs
index 4fb5f0de..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);
@@ -1117,6 +1121,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/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/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/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 a17091ea..095a0977 100644
--- a/src/LibRed/LibRed.Ado/LibRedCommand.cs
+++ b/src/LibRed/LibRed.Ado/LibRedCommand.cs
@@ -72,9 +72,15 @@ 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);
+ Connection?.ReconcileSqlTransactionControl();
}
catch (LibRed.ConstraintViolationException e)
{
@@ -84,6 +90,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);
@@ -149,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.Ado/LibRedConnection.cs b/src/LibRed/LibRed.Ado/LibRedConnection.cs
index abe6b161..0b715769 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() =>
@@ -103,16 +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.
///
- 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.
+ /// 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);
+ Storage.DatabaseCreator.CreateEmpty(path, (byte)version, collation);
CreateDualTable(path);
}
@@ -181,6 +201,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()
@@ -197,7 +218,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) =>
@@ -223,7 +251,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);
}
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.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/Collation.cs b/src/LibRed/LibRed.Core/Catalog/Collation.cs
index 6e2b2879..befc0d91 100644
--- a/src/LibRed/LibRed.Core/Catalog/Collation.cs
+++ b/src/LibRed/LibRed.Core/Catalog/Collation.cs
@@ -3,38 +3,71 @@ 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 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,
/// 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
+ German = 1031, // with sort id 1 = "German Phone Book"
General = 1033, // English, German, French, Portuguese — the default
- Spanish = 1034,
- Hebrew = 1037,
- Hungarian = 1038,
+ Spanish = 1034, // Spanish Traditional: "ch" and "ll" are letters (DAO's dbSortSpanish)
+ French = 1036,
+ Hebrew = 1037, // inert
+ Hungarian = 1038, // with sort id 1 = "Hungarian Technical"
Icelandic = 1039,
Japanese = 1041,
Korean = 1042,
- Dutch = 1043,
+ Dutch = 1043, // inert
+ Norwegian = 1044, // Access's "Norwegian/Danish" — note DAO's dbSortNorwDan is Danish 1030 instead
Polish = 1045,
- Cyrillic = 1049,
+ 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,
}
///
@@ -42,15 +75,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;
@@ -58,11 +98,24 @@ 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 (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;
+ // 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/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/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/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/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/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..7b318796 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)
@@ -58,7 +63,20 @@ 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;
+
+ internal void MarkSchemaChanged()
+ {
+ if (_active is not null) _schemaDirty = true;
+ else _cache.MarkSchemaChanged();
+ }
public int PageSize => Format.PageSize;
@@ -231,6 +249,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 +265,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 +336,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 +354,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,7 +431,43 @@ public void RollbackTransaction()
{
if (_active is null) return;
_overlay.Clear();
+ _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
@@ -377,8 +510,35 @@ 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);
+ ResyncFormatVersion(); // page 0 may have been one of the restored images
+ }
+
+ 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..27ec8339 100644
--- a/src/LibRed/LibRed.Core/JetDatabase.cs
+++ b/src/LibRed/LibRed.Core/JetDatabase.cs
@@ -41,15 +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'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 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 => DefinitionPage.Collation;
/// Reads and decodes the table definition (TDEF) page at .
public TableDefinitionPage ReadTableDefinition(int pageNumber)
@@ -68,7 +74,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
@@ -91,9 +97,48 @@ 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;
+ /// 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 +155,8 @@ public void Rollback()
{
if (!_channel.InTransaction) return;
_channel.RollbackTransaction();
- Catalog.Invalidate();
+ 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
@@ -123,7 +169,8 @@ public void Rollback()
public void RollbackToSavepoint(Savepoint savepoint)
{
_channel.RollbackToSavepoint(savepoint);
- Catalog.Invalidate();
+ Catalog.Invalidate(markChanged: false);
+ RereadDefinitionPage();
}
/// Releases , merging its writes into the enclosing scope.
diff --git a/src/LibRed/LibRed.Core/LibRed.Core.csproj b/src/LibRed/LibRed.Core/LibRed.Core.csproj
index 3d4baea0..ce7327eb 100644
--- a/src/LibRed/LibRed.Core/LibRed.Core.csproj
+++ b/src/LibRed/LibRed.Core/LibRed.Core.csproj
@@ -8,4 +8,24 @@
AnyCPU;x86;x64
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/Resources/SortKeyTableV0.bin b/src/LibRed/LibRed.Core/Resources/SortKeyTableV0.bin
new file mode 100644
index 00000000..a046e389
Binary files /dev/null and b/src/LibRed/LibRed.Core/Resources/SortKeyTableV0.bin differ
diff --git a/src/LibRed/LibRed.Core/Resources/SortKeyTableV1.bin b/src/LibRed/LibRed.Core/Resources/SortKeyTableV1.bin
new file mode 100644
index 00000000..8b9c7c75
Binary files /dev/null and b/src/LibRed/LibRed.Core/Resources/SortKeyTableV1.bin differ
diff --git a/src/LibRed/LibRed.Core/Resources/SortKeyTableV1Overrides.bin b/src/LibRed/LibRed.Core/Resources/SortKeyTableV1Overrides.bin
new file mode 100644
index 00000000..7d8047b6
Binary files /dev/null and b/src/LibRed/LibRed.Core/Resources/SortKeyTableV1Overrides.bin differ
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/DatabaseCreator.cs b/src/LibRed/LibRed.Core/Storage/DatabaseCreator.cs
index f0db3fbd..003b97b8 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);
@@ -149,6 +151,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 +232,46 @@ 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".
+ ///
+ /// 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;
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;
- 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, 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
objMap, acesMap, queriesMap, relMap, // pages 6..9: their usage maps
@@ -255,6 +333,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 +343,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 +456,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 +464,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/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 170b8ca2..a6f4bee5 100644
--- a/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs
+++ b/src/LibRed/LibRed.Core/Storage/IndexKeyEncoder.cs
@@ -1,7 +1,9 @@
using System.Buffers.Binary;
using System.Globalization;
+using System.Runtime.InteropServices;
using LibRed.Catalog;
using LibRed.Formats;
+using LibRed.Storage.Types;
namespace LibRed.Storage;
@@ -15,16 +17,48 @@ 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
{
/// Access indexes only the first 255 characters of a Memo (Long Text) value (verified vs ACE).
private const int MemoKeyMaxChars = 255;
- public static byte[] Encode(IReadOnlyList<(ColumnDef Column, bool Ascending)> columns, object?[] values)
+ ///
+ /// 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) =>
+ 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++)
{
@@ -54,21 +88,31 @@ 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 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 legacy 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)
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.");
+ LocaleTailoring? tailoring = JetLocaleTailoring.For(column.Collation);
+ bool encoded = column.Collation.Version == Collation.GeneralVersion
+ ? JetTextCollationV1.TryEncode(text, ascendingKey, tailoring, out bool wordSort)
+ : JetTextCollation.TryEncode(text, ascendingKey, tailoring, out wordSort);
+ anyWordSortRecord |= wordSort;
+ 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)
{
@@ -131,6 +175,20 @@ public static byte[] Encode(IReadOnlyList<(ColumnDef Column, bool Ascending)> co
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(
@@ -143,7 +201,30 @@ public static byte[] Encode(IReadOnlyList<(ColumnDef Column, bool Ascending)> co
buffer.AddRange(raw);
}
- return [.. buffer];
+ // 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), 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.");
+
+ 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;
}
///
@@ -154,6 +235,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
@@ -181,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,
};
@@ -199,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.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/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/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/JetLocaleTailoring.cs b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs
new file mode 100644
index 00000000..96d50fbf
--- /dev/null
+++ b/src/LibRed/LibRed.Core/Storage/JetLocaleTailoring.cs
@@ -0,0 +1,433 @@
+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 dž , 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,
+ bool reverseDiacritics = false)
+ {
+ Entries = entries;
+ DoublesDigraphs = doublesDigraphs;
+ ReverseDiacritics = reverseDiacritics;
+ MaxLength = entries.Count == 0 ? 0 : entries.Keys.Max(k => k.Length);
+ }
+
+ public IReadOnlyDictionary Entries { get; }
+
+ 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.
+ 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);
+ }
+
+ ///
+ /// 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--)
+ {
+ 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([]),
+
+ // --- 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
+ // (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.
+ [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])]),
+
+ // --- 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. 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]),
+ ("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),
+ // 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(
+ [("Ć", [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),
+ ("Ǣ", [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),
+ // 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, 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
+ // 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(
+ // 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
+ // — 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]),
+ ("Ơ", [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(
+ [("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])]),
+ };
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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, DŽ and
+ /// dž and Dž . 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
+ /// 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,
+ 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, reverseDiacritics);
+ }
+}
diff --git a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs
index 823fe4e3..7a331d98 100644
--- a/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs
+++ b/src/LibRed/LibRed.Core/Storage/JetTextCollation.cs
@@ -28,6 +28,40 @@ internal static class JetTextCollation
private const byte InlineMid = 0x06;
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
@@ -36,11 +70,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.
@@ -49,6 +89,35 @@ 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
@@ -58,6 +127,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).
@@ -77,39 +148,182 @@ 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],
};
+ ///
+ /// Ligature characters, which ACE weighs as their decomposition rather than as anything of their own —
+ /// DŽ encodes exactly as the string DŽ , 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
/// 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) =>
+ 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).
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;
- 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 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))
- foreach (byte w in weights) Add(w);
- else if (!TryAddAccented(u, Add))
+
+ // 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) ||
+ 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)
+ {
+ AddWeight([JetKanaSection.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([JetKanaSection.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;
+ }
+
+ // 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;
+ }
+ // 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
}
@@ -118,20 +332,56 @@ public static bool TryEncode(string value, List output)
// 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;
+
+ if (kana.Count > 0) JetKanaSection.Append(output, kana, prolonged);
- // Apostrophe/hyphen inline (tertiary) section.
+ // 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)
{
- output.Add(0x01);
- output.Add(0x01);
- output.Add(0x01);
+ 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);
- 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);
}
@@ -139,17 +389,95 @@ public static bool TryEncode(string value, List output)
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);
+ // 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 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);
+ }
+ else if (!TryAddAccented(upper, Add))
+ return false;
+ return true;
+ }
+
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 weights too, so both sections index
+ // 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);
+ }
}
- /// 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)
+ /// 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))
{
@@ -161,6 +489,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/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
new file mode 100644
index 00000000..bbefb25c
--- /dev/null
+++ b/src/LibRed/LibRed.Core/Storage/JetTextCollationV1.cs
@@ -0,0 +1,490 @@
+using System.Buffers.Binary;
+using System.Diagnostics.CodeAnalysis;
+using System.IO.Compression;
+using System.Reflection;
+using System.Text;
+
+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 A 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;
+
+ // 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;
+
+ /// [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. 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;
+
+ /// [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;
+
+ ///
+ /// [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);
+
+ ///
+ /// 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, 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, LocaleTailoring? tailoring, out bool hasWordSortRecord)
+ {
+ hasWordSortRecord = false;
+ 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)>();
+
+ // 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;
+
+ // 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.
+ //
+ // 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.
+ //
+ // 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 == EastAsiaSpecialScriptMember)
+ {
+ 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))
+ {
+ // 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))
+ {
+ 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)
+ {
+ // 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;
+ }
+
+ 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]);
+
+ hasWordSortRecord = inline.Count > 0;
+
+ if (kana.Count > 0) JetKanaSection.Append(output, kana, prolonged);
+
+ // 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)
+ {
+ 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)
+ {
+ // [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);
+ 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/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/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/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/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.Core/Storage/TableCreator.cs b/src/LibRed/LibRed.Core/Storage/TableCreator.cs
index 3f8b7f59..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.
@@ -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();
@@ -752,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/Types/JetTypeCodec.cs b/src/LibRed/LibRed.Core/Storage/Types/JetTypeCodec.cs
index 0e6b0452..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:
@@ -171,7 +207,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 +217,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 +261,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/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.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/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/AccessTypeMapper.cs b/src/LibRed/LibRed.Engine/Execution/AccessTypeMapper.cs
index 9f6c4e61..06cdbf5d 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}.");
@@ -50,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"
@@ -64,6 +81,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/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..19828fa9 100644
--- a/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs
+++ b/src/LibRed/LibRed.Engine/Execution/ExpressionEvaluator.cs
@@ -135,12 +135,15 @@ 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),
+ "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
@@ -168,12 +171,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),
@@ -290,6 +296,71 @@ 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),
+ "NULLIF" => (2, 2),
+ "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
@@ -304,6 +375,31 @@ private static bool TryNiladicFunction(ColumnReference c, out object? value)
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
@@ -435,6 +531,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,
};
@@ -1114,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."),
};
}
@@ -1126,7 +1226,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,
@@ -1136,6 +1251,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."),
};
}
@@ -1186,7 +1302,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;
@@ -1199,9 +1315,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/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs b/src/LibRed/LibRed.Engine/Execution/QueryExecutor.cs
index 2eea5006..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
@@ -1215,6 +1268,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/Execution/StatementExecutor.cs b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs
index b30b40f6..bb637270 100644
--- a/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs
+++ b/src/LibRed/LibRed.Engine/Execution/StatementExecutor.cs
@@ -30,15 +30,38 @@ 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),
_ => 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;
@@ -374,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,
@@ -481,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.");
@@ -559,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);
@@ -618,6 +646,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 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
+ // 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);
@@ -649,11 +781,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 +796,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 +814,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/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 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.Engine/QueryEngine.cs b/src/LibRed/LibRed.Engine/QueryEngine.cs
index beb3461e..92784e59 100644
--- a/src/LibRed/LibRed.Engine/QueryEngine.cs
+++ b/src/LibRed/LibRed.Engine/QueryEngine.cs
@@ -37,8 +37,33 @@ 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);
+ 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) =>
+ // 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)
@@ -55,6 +80,13 @@ public ResultSet ExecuteQuery(string sql, IReadOnlyDictionary?
public int ExecuteNonQuery(string sql, 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
@@ -77,7 +109,16 @@ 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));
+ }
+
+ 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);
@@ -107,6 +148,11 @@ private CommandResult Route(SqlStatement statement, IReadOnlyDictionary 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/Ast/Statements.cs b/src/LibRed/LibRed.Sql/Ast/Statements.cs
index 081ec501..9a1f7ee3 100644
--- a/src/LibRed/LibRed.Sql/Ast/Statements.cs
+++ b/src/LibRed/LibRed.Sql/Ast/Statements.cs
@@ -27,7 +27,18 @@ public sealed record SelectStatement(
// DISTINCTROW: dedupe on the underlying rows of the output-contributing tables (Access-specific).
bool DistinctRow = false,
// TOP n PERCENT: Top is a percentage (0–100) of the row count rather than an absolute count.
- bool TopPercent = false) : SqlStatement;
+ bool TopPercent = false,
+ ///
+ /// 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).
@@ -55,14 +66,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..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.
//
@@ -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 )
;
@@ -205,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
@@ -247,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 ;
@@ -345,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 9c9b6ba0..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:
@@ -3868,6 +3869,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 +3878,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 +3894,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 +3928,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 +3965,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 +4068,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)) {
+ while ((((_la) & ~0x3f) == 0 && ((1L << _la) & 446676598784L) != 0)) {
{
{
- State = 667;
+ State = 670;
setOperator();
- State = 668;
+ State = 671;
queryTerm();
}
}
- State = 674;
+ State = 677;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
}
@@ -4120,14 +4143,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 +4158,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 +4205,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 +4228,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;
@@ -4233,6 +4256,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);
@@ -4240,6 +4264,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);
}
@@ -4258,6 +4283,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)
{
@@ -4279,76 +4307,88 @@ 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)) {
+ if ((((_la) & ~0x3f) == 0 && ((1L << _la) & 75161927680L) != 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 = 703;
+ ErrorHandler.Sync(this);
+ _la = TokenStream.LA(1);
+ 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 = 698;
+ State = 705;
fromClause();
}
}
- State = 702;
+ State = 709;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==WHERE) {
{
- State = 701;
+ State = 708;
whereClause();
}
}
- State = 705;
+ State = 712;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==GROUP) {
{
- State = 704;
+ State = 711;
groupByClause();
}
}
- State = 708;
+ State = 715;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==HAVING) {
{
- State = 707;
+ State = 714;
havingClause();
}
}
- State = 711;
+ State = 718;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==ORDER) {
{
- State = 710;
+ State = 717;
orderByClause();
}
}
@@ -4391,9 +4431,9 @@ public SelectPredicateContext selectPredicate() {
try {
EnterOuterAlt(_localctx, 1);
{
- State = 713;
+ 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 {
@@ -4447,25 +4487,25 @@ public GroupByClauseContext groupByClause() {
try {
EnterOuterAlt(_localctx, 1);
{
- State = 715;
+ State = 722;
Match(GROUP);
- State = 716;
+ State = 723;
Match(BY);
- State = 717;
+ State = 724;
expression(0);
- State = 722;
+ State = 729;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
while (_la==COMMA) {
{
{
- State = 718;
+ State = 725;
Match(COMMA);
- State = 719;
+ State = 726;
expression(0);
}
}
- State = 724;
+ State = 731;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
}
@@ -4507,9 +4547,9 @@ public HavingClauseContext havingClause() {
try {
EnterOuterAlt(_localctx, 1);
{
- State = 725;
+ State = 732;
Match(HAVING);
- State = 726;
+ State = 733;
expression(0);
}
}
@@ -4564,18 +4604,18 @@ public TopClauseContext topClause() {
int _alt;
EnterOuterAlt(_localctx, 1);
{
- State = 728;
+ State = 735;
Match(TOP);
- State = 729;
+ State = 736;
topOperand();
- State = 734;
+ State = 741;
ErrorHandler.Sync(this);
- _alt = Interpreter.AdaptivePredict(TokenStream,84,Context);
+ _alt = Interpreter.AdaptivePredict(TokenStream,86,Context);
while ( _alt!=2 && _alt!=global::Antlr4.Runtime.Atn.ATN.INVALID_ALT_NUMBER ) {
if ( _alt==1 ) {
{
{
- State = 730;
+ State = 737;
_la = TokenStream.LA(1);
if ( !(_la==PLUS || _la==MINUS) ) {
ErrorHandler.RecoverInline(this);
@@ -4584,21 +4624,21 @@ public TopClauseContext topClause() {
ErrorHandler.ReportMatch(this);
Consume();
}
- State = 731;
+ State = 738;
topOperand();
}
}
}
- State = 736;
+ State = 743;
ErrorHandler.Sync(this);
- _alt = Interpreter.AdaptivePredict(TokenStream,84,Context);
+ _alt = Interpreter.AdaptivePredict(TokenStream,86,Context);
}
- State = 738;
+ State = 745;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==PERCENT) {
{
- State = 737;
+ State = 744;
_localctx.percent = Match(PERCENT);
}
}
@@ -4642,31 +4682,31 @@ public TopOperandContext topOperand() {
TopOperandContext _localctx = new TopOperandContext(Context, State);
EnterRule(_localctx, 80, RULE_topOperand);
try {
- State = 746;
+ State = 753;
ErrorHandler.Sync(this);
switch (TokenStream.LA(1)) {
case INTEGER_LITERAL:
EnterOuterAlt(_localctx, 1);
{
- State = 740;
+ State = 747;
Match(INTEGER_LITERAL);
}
break;
case PARAM:
EnterOuterAlt(_localctx, 2);
{
- State = 741;
+ State = 748;
Match(PARAM);
}
break;
case LPAREN:
EnterOuterAlt(_localctx, 3);
{
- State = 742;
+ State = 749;
Match(LPAREN);
- State = 743;
+ State = 750;
expression(0);
- State = 744;
+ State = 751;
Match(RPAREN);
}
break;
@@ -4716,13 +4756,13 @@ public SelectListContext selectList() {
EnterRule(_localctx, 82, RULE_selectList);
int _la;
try {
- State = 757;
+ State = 764;
ErrorHandler.Sync(this);
switch (TokenStream.LA(1)) {
case STAR:
EnterOuterAlt(_localctx, 1);
{
- State = 748;
+ State = 755;
Match(STAR);
}
break;
@@ -4750,21 +4790,21 @@ public SelectListContext selectList() {
case IDENTIFIER:
EnterOuterAlt(_localctx, 2);
{
- State = 749;
+ State = 756;
selectItem();
- State = 754;
+ State = 761;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
while (_la==COMMA) {
{
{
- State = 750;
+ State = 757;
Match(COMMA);
- State = 751;
+ State = 758;
selectItem();
}
}
- State = 756;
+ State = 763;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
}
@@ -4836,18 +4876,18 @@ public SelectItemContext selectItem() {
EnterRule(_localctx, 84, RULE_selectItem);
int _la;
try {
- State = 770;
+ State = 777;
ErrorHandler.Sync(this);
- switch ( Interpreter.AdaptivePredict(TokenStream,91,Context) ) {
+ switch ( Interpreter.AdaptivePredict(TokenStream,93,Context) ) {
case 1:
_localctx = new QualifiedStarSelectItemContext(_localctx);
EnterOuterAlt(_localctx, 1);
{
- State = 759;
+ State = 766;
((QualifiedStarSelectItemContext)_localctx).qualifier = identifier();
- State = 760;
+ State = 767;
Match(DOT);
- State = 761;
+ State = 768;
Match(STAR);
}
break;
@@ -4855,24 +4895,24 @@ public SelectItemContext selectItem() {
_localctx = new ExpressionSelectItemContext(_localctx);
EnterOuterAlt(_localctx, 2);
{
- State = 763;
+ State = 770;
expression(0);
- State = 768;
+ 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 = 765;
+ State = 772;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==AS) {
{
- State = 764;
+ State = 771;
Match(AS);
}
}
- State = 767;
+ State = 774;
((ExpressionSelectItemContext)_localctx).alias = identifier();
}
}
@@ -4925,23 +4965,23 @@ public FromClauseContext fromClause() {
try {
EnterOuterAlt(_localctx, 1);
{
- State = 772;
+ State = 779;
Match(FROM);
- State = 773;
+ State = 780;
tableSource();
- State = 778;
+ State = 785;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
while (_la==COMMA) {
{
{
- State = 774;
+ State = 781;
Match(COMMA);
- State = 775;
+ State = 782;
tableSource();
}
}
- State = 780;
+ State = 787;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
}
@@ -4989,19 +5029,19 @@ public TableSourceContext tableSource() {
try {
EnterOuterAlt(_localctx, 1);
{
- State = 781;
+ State = 788;
tablePrimary();
- State = 785;
+ 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 = 782;
+ State = 789;
joinClause();
}
}
- State = 787;
+ State = 794;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
}
@@ -5088,31 +5128,31 @@ public TablePrimaryContext tablePrimary() {
EnterRule(_localctx, 90, RULE_tablePrimary);
int _la;
try {
- State = 808;
+ State = 815;
ErrorHandler.Sync(this);
- switch ( Interpreter.AdaptivePredict(TokenStream,98,Context) ) {
+ switch ( Interpreter.AdaptivePredict(TokenStream,100,Context) ) {
case 1:
_localctx = new NamedTablePrimaryContext(_localctx);
EnterOuterAlt(_localctx, 1);
{
- State = 788;
+ State = 795;
((NamedTablePrimaryContext)_localctx).table = identifier();
- State = 793;
+ 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 = 790;
+ State = 797;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==AS) {
{
- State = 789;
+ State = 796;
Match(AS);
}
}
- State = 792;
+ State = 799;
((NamedTablePrimaryContext)_localctx).alias = identifier();
}
}
@@ -5123,28 +5163,28 @@ public TablePrimaryContext tablePrimary() {
_localctx = new SubqueryPrimaryContext(_localctx);
EnterOuterAlt(_localctx, 2);
{
- State = 795;
+ State = 802;
Match(LPAREN);
- State = 796;
+ State = 803;
queryExpression();
- State = 797;
+ State = 804;
Match(RPAREN);
- State = 802;
+ 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 = 799;
+ State = 806;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==AS) {
{
- State = 798;
+ State = 805;
Match(AS);
}
}
- State = 801;
+ State = 808;
((SubqueryPrimaryContext)_localctx).alias = identifier();
}
}
@@ -5155,11 +5195,11 @@ public TablePrimaryContext tablePrimary() {
_localctx = new ParenJoinPrimaryContext(_localctx);
EnterOuterAlt(_localctx, 3);
{
- State = 804;
+ State = 811;
Match(LPAREN);
- State = 805;
+ State = 812;
tableSource();
- State = 806;
+ State = 813;
Match(RPAREN);
}
break;
@@ -5208,15 +5248,15 @@ public JoinClauseContext joinClause() {
try {
EnterOuterAlt(_localctx, 1);
{
- State = 810;
+ State = 817;
joinType();
- State = 811;
+ State = 818;
Match(JOIN);
- State = 812;
+ State = 819;
tablePrimary();
- State = 813;
+ State = 820;
Match(ON);
- State = 814;
+ State = 821;
expression(0);
}
}
@@ -5254,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); }
@@ -5282,7 +5333,7 @@ public JoinTypeContext joinType() {
EnterRule(_localctx, 94, RULE_joinType);
int _la;
try {
- State = 827;
+ State = 838;
ErrorHandler.Sync(this);
switch (TokenStream.LA(1)) {
case INNER:
@@ -5290,12 +5341,12 @@ public JoinTypeContext joinType() {
_localctx = new InnerJoinContext(_localctx);
EnterOuterAlt(_localctx, 1);
{
- State = 817;
+ State = 824;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==INNER) {
{
- State = 816;
+ State = 823;
Match(INNER);
}
}
@@ -5306,14 +5357,14 @@ public JoinTypeContext joinType() {
_localctx = new LeftJoinContext(_localctx);
EnterOuterAlt(_localctx, 2);
{
- State = 819;
+ State = 826;
Match(LEFT);
- State = 821;
+ State = 828;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==OUTER) {
{
- State = 820;
+ State = 827;
Match(OUTER);
}
}
@@ -5324,14 +5375,32 @@ public JoinTypeContext joinType() {
_localctx = new RightJoinContext(_localctx);
EnterOuterAlt(_localctx, 3);
{
- State = 823;
+ State = 830;
Match(RIGHT);
- State = 825;
+ State = 832;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==OUTER) {
{
- State = 824;
+ State = 831;
+ Match(OUTER);
+ }
+ }
+
+ }
+ 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);
}
}
@@ -5378,9 +5447,9 @@ public WhereClauseContext whereClause() {
try {
EnterOuterAlt(_localctx, 1);
{
- State = 829;
+ State = 840;
Match(WHERE);
- State = 830;
+ State = 841;
expression(0);
}
}
@@ -5429,25 +5498,25 @@ public OrderByClauseContext orderByClause() {
try {
EnterOuterAlt(_localctx, 1);
{
- State = 832;
+ State = 843;
Match(ORDER);
- State = 833;
+ State = 844;
Match(BY);
- State = 834;
+ State = 845;
orderByItem();
- State = 839;
+ State = 850;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
while (_la==COMMA) {
{
{
- State = 835;
+ State = 846;
Match(COMMA);
- State = 836;
+ State = 847;
orderByItem();
}
}
- State = 841;
+ State = 852;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
}
@@ -5492,14 +5561,14 @@ public OrderByItemContext orderByItem() {
try {
EnterOuterAlt(_localctx, 1);
{
- State = 842;
+ State = 853;
expression(0);
- State = 844;
+ State = 855;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==ASC || _la==DESC) {
{
- State = 843;
+ State = 854;
_localctx.dir = TokenStream.LT(1);
_la = TokenStream.LA(1);
if ( !(_la==ASC || _la==DESC) ) {
@@ -5856,7 +5925,7 @@ private ExpressionContext expression(int _p) {
int _alt;
EnterOuterAlt(_localctx, 1);
{
- State = 854;
+ State = 865;
ErrorHandler.Sync(this);
switch (TokenStream.LA(1)) {
case NOT:
@@ -5865,9 +5934,9 @@ private ExpressionContext expression(int _p) {
Context = _localctx;
_prevctx = _localctx;
- State = 847;
+ State = 858;
Match(NOT);
- State = 848;
+ State = 859;
expression(16);
}
break;
@@ -5876,9 +5945,9 @@ private ExpressionContext expression(int _p) {
_localctx = new BitNotExprContext(_localctx);
Context = _localctx;
_prevctx = _localctx;
- State = 849;
+ State = 860;
Match(BNOT);
- State = 850;
+ State = 861;
expression(15);
}
break;
@@ -5887,9 +5956,9 @@ private ExpressionContext expression(int _p) {
_localctx = new NegateExprContext(_localctx);
Context = _localctx;
_prevctx = _localctx;
- State = 851;
+ State = 862;
Match(MINUS);
- State = 852;
+ State = 863;
expression(14);
}
break;
@@ -5916,7 +5985,7 @@ private ExpressionContext expression(int _p) {
_localctx = new PrimaryExprContext(_localctx);
Context = _localctx;
_prevctx = _localctx;
- State = 853;
+ State = 864;
primary();
}
break;
@@ -5924,28 +5993,28 @@ private ExpressionContext expression(int _p) {
throw new NoViableAltException(this);
}
Context.Stop = TokenStream.LT(-1);
- State = 925;
+ State = 936;
ErrorHandler.Sync(this);
- _alt = Interpreter.AdaptivePredict(TokenStream,113,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 = 923;
+ State = 934;
ErrorHandler.Sync(this);
- switch ( Interpreter.AdaptivePredict(TokenStream,112,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 = 856;
+ State = 867;
if (!(Precpred(Context, 13))) throw new FailedPredicateException(this, "Precpred(Context, 13)");
- State = 857;
+ State = 868;
Match(CARET);
- State = 858;
+ State = 869;
((PowExprContext)_localctx).right = expression(14);
}
break;
@@ -5954,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 = 859;
+ State = 870;
if (!(Precpred(Context, 12))) throw new FailedPredicateException(this, "Precpred(Context, 12)");
- State = 860;
+ 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 = 861;
+ State = 872;
((MulDivExprContext)_localctx).right = expression(13);
}
break;
@@ -5975,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 = 862;
+ State = 873;
if (!(Precpred(Context, 11))) throw new FailedPredicateException(this, "Precpred(Context, 11)");
- State = 863;
+ 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 = 864;
+ State = 875;
((AddConcatExprContext)_localctx).right = expression(12);
}
break;
@@ -5996,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 = 865;
+ State = 876;
if (!(Precpred(Context, 10))) throw new FailedPredicateException(this, "Precpred(Context, 10)");
- State = 866;
+ 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 = 867;
+ State = 878;
((ComparisonExprContext)_localctx).right = expression(11);
}
break;
@@ -6017,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 = 868;
+ State = 879;
if (!(Precpred(Context, 9))) throw new FailedPredicateException(this, "Precpred(Context, 9)");
- State = 870;
+ State = 881;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==NOT) {
{
- State = 869;
+ State = 880;
((BetweenExprContext)_localctx).not = Match(NOT);
}
}
- State = 872;
+ State = 883;
Match(BETWEEN);
- State = 873;
+ State = 884;
((BetweenExprContext)_localctx).lo = expression(0);
- State = 874;
+ State = 885;
Match(AND);
- State = 875;
+ State = 886;
((BetweenExprContext)_localctx).hi = expression(10);
}
break;
@@ -6044,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 = 877;
+ State = 888;
if (!(Precpred(Context, 8))) throw new FailedPredicateException(this, "Precpred(Context, 8)");
- State = 879;
+ State = 890;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==NOT) {
{
- State = 878;
+ State = 889;
((LikeExprContext)_localctx).not = Match(NOT);
}
}
- State = 881;
+ State = 892;
Match(LIKE);
- State = 882;
+ State = 893;
((LikeExprContext)_localctx).right = expression(9);
}
break;
@@ -6067,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 = 883;
+ State = 894;
if (!(Precpred(Context, 4))) throw new FailedPredicateException(this, "Precpred(Context, 4)");
- State = 884;
+ State = 895;
((BitwiseExprContext)_localctx).op = TokenStream.LT(1);
_la = TokenStream.LA(1);
if ( !((((_la) & ~0x3f) == 0 && ((1L << _la) & 3584L) != 0)) ) {
@@ -6079,7 +6148,7 @@ private ExpressionContext expression(int _p) {
ErrorHandler.ReportMatch(this);
Consume();
}
- State = 885;
+ State = 896;
((BitwiseExprContext)_localctx).right = expression(5);
}
break;
@@ -6088,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 = 886;
+ State = 897;
if (!(Precpred(Context, 3))) throw new FailedPredicateException(this, "Precpred(Context, 3)");
- State = 887;
+ State = 898;
Match(AND);
- State = 888;
+ State = 899;
((AndExprContext)_localctx).right = expression(4);
}
break;
@@ -6101,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 = 889;
+ State = 900;
if (!(Precpred(Context, 2))) throw new FailedPredicateException(this, "Precpred(Context, 2)");
- State = 890;
+ State = 901;
Match(OR);
- State = 891;
+ State = 902;
((OrExprContext)_localctx).right = expression(3);
}
break;
@@ -6114,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 = 892;
+ State = 903;
if (!(Precpred(Context, 7))) throw new FailedPredicateException(this, "Precpred(Context, 7)");
- State = 894;
+ State = 905;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==NOT) {
{
- State = 893;
+ State = 904;
((InSubqueryExprContext)_localctx).not = Match(NOT);
}
}
- State = 896;
+ State = 907;
Match(IN);
- State = 897;
+ State = 908;
Match(LPAREN);
- State = 898;
+ State = 909;
((InSubqueryExprContext)_localctx).sub = selectStatement();
- State = 899;
+ State = 910;
Match(RPAREN);
}
break;
@@ -6141,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 = 901;
+ State = 912;
if (!(Precpred(Context, 6))) throw new FailedPredicateException(this, "Precpred(Context, 6)");
- State = 903;
+ State = 914;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==NOT) {
{
- State = 902;
+ State = 913;
((InExprContext)_localctx).not = Match(NOT);
}
}
- State = 905;
+ State = 916;
Match(IN);
- State = 906;
+ State = 917;
Match(LPAREN);
- State = 907;
+ State = 918;
((InExprContext)_localctx)._expression = expression(0);
((InExprContext)_localctx)._items.Add(((InExprContext)_localctx)._expression);
- State = 912;
+ State = 923;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
while (_la==COMMA) {
{
{
- State = 908;
+ State = 919;
Match(COMMA);
- State = 909;
+ State = 920;
((InExprContext)_localctx)._expression = expression(0);
((InExprContext)_localctx)._items.Add(((InExprContext)_localctx)._expression);
}
}
- State = 914;
+ State = 925;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
}
- State = 915;
+ State = 926;
Match(RPAREN);
}
break;
@@ -6186,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 = 917;
+ State = 928;
if (!(Precpred(Context, 5))) throw new FailedPredicateException(this, "Precpred(Context, 5)");
- State = 918;
+ State = 929;
Match(IS);
- State = 920;
+ State = 931;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==NOT) {
{
- State = 919;
+ State = 930;
((IsNullExprContext)_localctx).not = Match(NOT);
}
}
- State = 922;
+ State = 933;
Match(NULL);
}
break;
}
}
}
- State = 927;
+ State = 938;
ErrorHandler.Sync(this);
- _alt = Interpreter.AdaptivePredict(TokenStream,113,Context);
+ _alt = Interpreter.AdaptivePredict(TokenStream,116,Context);
}
}
}
@@ -6341,14 +6410,14 @@ public PrimaryContext primary() {
PrimaryContext _localctx = new PrimaryContext(Context, State);
EnterRule(_localctx, 104, RULE_primary);
try {
- State = 946;
+ State = 957;
ErrorHandler.Sync(this);
- switch ( Interpreter.AdaptivePredict(TokenStream,114,Context) ) {
+ switch ( Interpreter.AdaptivePredict(TokenStream,117,Context) ) {
case 1:
_localctx = new LiteralPrimaryContext(_localctx);
EnterOuterAlt(_localctx, 1);
{
- State = 928;
+ State = 939;
literal();
}
break;
@@ -6356,7 +6425,7 @@ public PrimaryContext primary() {
_localctx = new FunctionCallPrimaryContext(_localctx);
EnterOuterAlt(_localctx, 2);
{
- State = 929;
+ State = 940;
functionCall();
}
break;
@@ -6364,7 +6433,7 @@ public PrimaryContext primary() {
_localctx = new ColumnPrimaryContext(_localctx);
EnterOuterAlt(_localctx, 3);
{
- State = 930;
+ State = 941;
columnRef();
}
break;
@@ -6372,7 +6441,7 @@ public PrimaryContext primary() {
_localctx = new ParamPrimaryContext(_localctx);
EnterOuterAlt(_localctx, 4);
{
- State = 931;
+ State = 942;
Match(PARAM);
}
break;
@@ -6380,7 +6449,7 @@ public PrimaryContext primary() {
_localctx = new SystemVariablePrimaryContext(_localctx);
EnterOuterAlt(_localctx, 5);
{
- State = 932;
+ State = 943;
Match(SYSVAR);
}
break;
@@ -6388,13 +6457,13 @@ public PrimaryContext primary() {
_localctx = new ExistsPrimaryContext(_localctx);
EnterOuterAlt(_localctx, 6);
{
- State = 933;
+ State = 944;
Match(EXISTS);
- State = 934;
+ State = 945;
Match(LPAREN);
- State = 935;
+ State = 946;
selectStatement();
- State = 936;
+ State = 947;
Match(RPAREN);
}
break;
@@ -6402,11 +6471,11 @@ public PrimaryContext primary() {
_localctx = new ScalarSubqueryPrimaryContext(_localctx);
EnterOuterAlt(_localctx, 7);
{
- State = 938;
+ State = 949;
Match(LPAREN);
- State = 939;
+ State = 950;
selectStatement();
- State = 940;
+ State = 951;
Match(RPAREN);
}
break;
@@ -6414,11 +6483,11 @@ public PrimaryContext primary() {
_localctx = new ParenPrimaryContext(_localctx);
EnterOuterAlt(_localctx, 8);
{
- State = 942;
+ State = 953;
Match(LPAREN);
- State = 943;
+ State = 954;
expression(0);
- State = 944;
+ State = 955;
Match(RPAREN);
}
break;
@@ -6477,16 +6546,16 @@ public FunctionCallContext functionCall() {
try {
EnterOuterAlt(_localctx, 1);
{
- State = 948;
+ State = 959;
_localctx.name = functionName();
- State = 949;
+ State = 960;
Match(LPAREN);
- State = 962;
+ State = 973;
ErrorHandler.Sync(this);
switch (TokenStream.LA(1)) {
case STAR:
{
- State = 950;
+ State = 961;
_localctx.star = Match(STAR);
}
break;
@@ -6515,31 +6584,31 @@ public FunctionCallContext functionCall() {
case IDENTIFIER:
{
{
- State = 952;
+ State = 963;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==DISTINCT) {
{
- State = 951;
+ State = 962;
_localctx.distinct = Match(DISTINCT);
}
}
- State = 954;
+ State = 965;
expression(0);
- State = 959;
+ State = 970;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
while (_la==COMMA) {
{
{
- State = 955;
+ State = 966;
Match(COMMA);
- State = 956;
+ State = 967;
expression(0);
}
}
- State = 961;
+ State = 972;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
}
@@ -6551,7 +6620,7 @@ public FunctionCallContext functionCall() {
default:
break;
}
- State = 964;
+ State = 975;
Match(RPAREN);
}
}
@@ -6591,7 +6660,7 @@ public FunctionNameContext functionName() {
FunctionNameContext _localctx = new FunctionNameContext(Context, State);
EnterRule(_localctx, 108, RULE_functionName);
try {
- State = 970;
+ State = 981;
ErrorHandler.Sync(this);
switch (TokenStream.LA(1)) {
case BRACKET_ID:
@@ -6599,28 +6668,28 @@ public FunctionNameContext functionName() {
case IDENTIFIER:
EnterOuterAlt(_localctx, 1);
{
- State = 966;
+ State = 977;
identifier();
}
break;
case LEFT:
EnterOuterAlt(_localctx, 2);
{
- State = 967;
+ State = 978;
Match(LEFT);
}
break;
case RIGHT:
EnterOuterAlt(_localctx, 3);
{
- State = 968;
+ State = 979;
Match(RIGHT);
}
break;
case ASC:
EnterOuterAlt(_localctx, 4);
{
- State = 969;
+ State = 980;
Match(ASC);
}
break;
@@ -6669,19 +6738,19 @@ public ColumnRefContext columnRef() {
try {
EnterOuterAlt(_localctx, 1);
{
- State = 975;
+ State = 986;
ErrorHandler.Sync(this);
- switch ( Interpreter.AdaptivePredict(TokenStream,119,Context) ) {
+ switch ( Interpreter.AdaptivePredict(TokenStream,122,Context) ) {
case 1:
{
- State = 972;
+ State = 983;
_localctx.qualifier = identifier();
- State = 973;
+ State = 984;
Match(DOT);
}
break;
}
- State = 977;
+ State = 988;
_localctx.name = identifier();
}
}
@@ -6721,9 +6790,9 @@ public IdentifierContext identifier() {
try {
EnterOuterAlt(_localctx, 1);
{
- State = 979;
+ 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 {
@@ -6851,14 +6920,14 @@ public LiteralContext literal() {
LiteralContext _localctx = new LiteralContext(Context, State);
EnterRule(_localctx, 114, RULE_literal);
try {
- State = 990;
+ State = 1001;
ErrorHandler.Sync(this);
switch (TokenStream.LA(1)) {
case INTEGER_LITERAL:
_localctx = new IntLiteralContext(_localctx);
EnterOuterAlt(_localctx, 1);
{
- State = 981;
+ State = 992;
Match(INTEGER_LITERAL);
}
break;
@@ -6866,7 +6935,7 @@ public LiteralContext literal() {
_localctx = new NumberLiteralContext(_localctx);
EnterOuterAlt(_localctx, 2);
{
- State = 982;
+ State = 993;
Match(NUMBER_LITERAL);
}
break;
@@ -6874,7 +6943,7 @@ public LiteralContext literal() {
_localctx = new HexLiteralContext(_localctx);
EnterOuterAlt(_localctx, 3);
{
- State = 983;
+ State = 994;
Match(HEX_LITERAL);
}
break;
@@ -6882,7 +6951,7 @@ public LiteralContext literal() {
_localctx = new StringLiteralContext(_localctx);
EnterOuterAlt(_localctx, 4);
{
- State = 984;
+ State = 995;
Match(STRING_LITERAL);
}
break;
@@ -6890,7 +6959,7 @@ public LiteralContext literal() {
_localctx = new DateLiteralContext(_localctx);
EnterOuterAlt(_localctx, 5);
{
- State = 985;
+ State = 996;
Match(DATE_LITERAL);
}
break;
@@ -6898,7 +6967,7 @@ public LiteralContext literal() {
_localctx = new GuidLiteralContext(_localctx);
EnterOuterAlt(_localctx, 6);
{
- State = 986;
+ State = 997;
Match(GUID_LITERAL);
}
break;
@@ -6906,7 +6975,7 @@ public LiteralContext literal() {
_localctx = new TrueLiteralContext(_localctx);
EnterOuterAlt(_localctx, 7);
{
- State = 987;
+ State = 998;
Match(TRUE);
}
break;
@@ -6914,7 +6983,7 @@ public LiteralContext literal() {
_localctx = new FalseLiteralContext(_localctx);
EnterOuterAlt(_localctx, 8);
{
- State = 988;
+ State = 999;
Match(FALSE);
}
break;
@@ -6922,7 +6991,7 @@ public LiteralContext literal() {
_localctx = new NullLiteralContext(_localctx);
EnterOuterAlt(_localctx, 9);
{
- State = 989;
+ State = 1000;
Match(NULL);
}
break;
@@ -6996,21 +7065,21 @@ public TransactionStatementContext transactionStatement() {
EnterRule(_localctx, 116, RULE_transactionStatement);
int _la;
try {
- State = 1004;
+ State = 1015;
ErrorHandler.Sync(this);
switch (TokenStream.LA(1)) {
case BEGIN:
_localctx = new BeginTransactionStatementContext(_localctx);
EnterOuterAlt(_localctx, 1);
{
- State = 992;
+ State = 1003;
Match(BEGIN);
- State = 994;
+ State = 1005;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==TRANSACTION || _la==WORK) {
{
- State = 993;
+ State = 1004;
_la = TokenStream.LA(1);
if ( !(_la==TRANSACTION || _la==WORK) ) {
ErrorHandler.RecoverInline(this);
@@ -7028,14 +7097,14 @@ public TransactionStatementContext transactionStatement() {
_localctx = new CommitTransactionStatementContext(_localctx);
EnterOuterAlt(_localctx, 2);
{
- State = 996;
+ State = 1007;
Match(COMMIT);
- State = 998;
+ State = 1009;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==TRANSACTION || _la==WORK) {
{
- State = 997;
+ State = 1008;
_la = TokenStream.LA(1);
if ( !(_la==TRANSACTION || _la==WORK) ) {
ErrorHandler.RecoverInline(this);
@@ -7053,14 +7122,14 @@ public TransactionStatementContext transactionStatement() {
_localctx = new RollbackTransactionStatementContext(_localctx);
EnterOuterAlt(_localctx, 3);
{
- State = 1000;
+ State = 1011;
Match(ROLLBACK);
- State = 1002;
+ State = 1013;
ErrorHandler.Sync(this);
_la = TokenStream.LA(1);
if (_la==TRANSACTION || _la==WORK) {
{
- State = 1001;
+ State = 1012;
_la = TokenStream.LA(1);
if ( !(_la==TRANSACTION || _la==WORK) ) {
ErrorHandler.RecoverInline(this);
@@ -7114,9 +7183,9 @@ public StandaloneExpressionContext standaloneExpression() {
try {
EnterOuterAlt(_localctx, 1);
{
- State = 1006;
+ State = 1017;
expression(0);
- State = 1007;
+ State = 1018;
Match(Eof);
}
}
@@ -7156,7 +7225,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,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,
@@ -7206,318 +7275,324 @@ 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,
+ 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,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,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,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,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,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,
+ 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,103,0,0,140,139,1,0,0,
+ 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,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,
+ 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,101,
+ 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,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,
+ 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,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,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,101,0,0,214,216,3,16,8,0,215,213,1,0,
+ 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,104,0,0,221,223,5,5,0,0,222,221,1,0,0,0,222,223,
+ 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,78,0,0,228,233,3,28,14,0,229,230,5,101,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,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,
+ 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,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,
+ 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,48,0,0,329,381,3,54,27,0,330,332,5,45,0,0,331,333,5,50,0,0,332,331,
+ 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,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,
+ 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,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,
+ 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,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,
+ 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,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,
+ 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,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,
+ 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,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,
+ 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,100,0,0,510,512,1,0,0,0,511,500,1,0,0,0,511,
+ 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,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
+ 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/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/AstBuilder.cs b/src/LibRed/LibRed.Sql/Parsing/AstBuilder.cs
index cfe14e99..e0587b06 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,
};
@@ -542,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,
};
@@ -552,6 +560,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]);
}
@@ -639,7 +652,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
@@ -701,6 +715,7 @@ private static TableReference BuildTableSource(TableSourceContext ctx)
{
LeftJoinContext => JoinKind.Left,
RightJoinContext => JoinKind.Right,
+ FullJoinContext => JoinKind.Full,
_ => JoinKind.Inner,
};
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/src/LibRed/README.md b/src/LibRed/README.md
index 911f3708..6818dda1 100644
--- a/src/LibRed/README.md
+++ b/src/LibRed/README.md
@@ -127,13 +127,28 @@ 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.
-- **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).
+- **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:
+ 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/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/appendix-structures.md b/src/LibRed/docs/format/appendix-structures.md
index c8170980..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) |
@@ -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) |
@@ -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/data-types.md b/src/LibRed/docs/format/data-types.md
index e4afc0ee..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 `::` (see below) |
LibRed's scalar reader requires the exact fixed widths listed above before invoking the numeric,
@@ -29,9 +29,27 @@ GUID, date, or decimal codec. Text, Binary, Memo/OLE descriptors, and Complex va
variable-length. A width mismatch is treated as row corruption (`InvalidDataException`) rather
than being allowed to fail incidentally inside a primitive decoder.
+**`BIGINT` is variable-length despite being a fixed 8 bytes.** ACE puts it behind the row's variable offset
+table rather than in the fixed region — 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 the value out at a variable-column
+start offset). Declaring it fixed would write the value somewhere ACE does not look for it. Its *index* key is
+unaffected — that dispatches on the column's type, not on where the row keeps the bytes — and is the same
+sign-bit-flipped big-endian int64 as Currency (§10.4).
+
+> **Writing one through ACE's OLE DB provider: not `DBTYPE_I8`.** An `OleDbType.BigInt` (20) parameter carries
+> **no** value into a Large Number column — every value fails with "data value could not be converted", zero
+> included — so the one type named for the job is the only one that cannot do it. Use **`Numeric` (131)**;
+> `Decimal` (14) and `Variant` (12) also round-trip the full range exactly. `VarNumeric` (139) is rejected
+> outright ("Type name is invalid"), and `Double` (5) is the trap: it succeeds quietly for small values and
+> overflows near ±2⁶³. Measured against both extremes. EFCore.Jet's `JetLongTypeMapping.ConfigureParameter`
+> already forces OLE DB 131 / ODBC 7 for its own `long` parameters, commented *"Using BigInt doesn't always
+> work … When running in x64 it fails to convert"* — the same defect, found from the other direction and years
+> earlier, though that mapping targets a `decimal(20,0)` column rather than a real `0x13` one.
+
**New-type format versions — the two are NOT the same version** (verified against files authored with each
feature enabled: enabling BigInt made the file version byte `0x05`, enabling Date/Time Extended made it
-`0x06`). **`BIGINT` (Large Number)** requires the **ACE 16 / Access 2016** format (`0x05`); **`DATETIME2`
+`0x06`; and measured again from the other direction — issuing `CREATE TABLE … BIGINT` against an ACE 12 file
+raises it to `0x05`, `DATETIME2` to `0x06`). **`BIGINT` (Large Number)** requires the **ACE 16 / Access 2016** format (`0x05`); **`DATETIME2`
(Date/Time Extended)** requires the **ACE 17 / Access 2019+** format (`0x06`) — it arrived later (Access for
Microsoft 365). LibRed gates each accordingly (`AccessTypeMapper`). `DATETIME2` is a fixed 42-byte ASCII string of
three colon-separated fields: the .NET **day number**, the count of **100-ns ticks within the
@@ -40,6 +58,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 +106,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 21686be8..9d34914d 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) |
@@ -61,6 +61,41 @@ 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.
+
+The `0x05` / **Large Number** route behaves the same way and is now measured too (verified 2026-08-26): a
+`CREATE TABLE … BIGINT` issued through ACE against an ACE 12 file moves `0x14` from `0x02` to **`0x05`** — not
+to `0x06`, confirming the two types really do sit at different formats. Guard:
+`BigIntKeyEncodingTests.Adding_a_bigint_column_makes_ace_raise_the_file_to_ace16`.
+
+**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`
@@ -76,6 +111,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
@@ -133,17 +193,31 @@ 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`.
- **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.
-- **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)).
+- **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` (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`
@@ -170,11 +244,51 @@ relocated to the end of the larger 4 KB page:
The first slot is the **exclusive-mode** commit state; the remaining 255 are shared-mode users. Each 2-byte
value is a commit/lock status Jet uses (with the matching user lock in the `.ldb`/`.laccdb`) to coordinate
concurrency — this table is only the per-user *overall status*; the `.ldb`/`.laccdb` holds the actual
-page-level read/write registration. Observed: an idle/unused slot reads **`00 01`**; the head slots carry
-per-file last-commit states (`01 01`, `05 01`, …). **`00 00` means "mid-write to disk"**, and `01 00` means
+page-level read/write registration. **`00 00` means "mid-write to disk"**, and `01 00` means
"accessed a corrupted page" — either one *without a matching user lock* makes Jet declare the database
suspect and demand a repair before it will open.
+#### The slot is a little-endian commit counter (verified 2026-08-26)
+
+Above those low reserved values, a slot is **one 16-bit little-endian counter of that user's committed
+writes** — not an enumerated state, which is how this file previously described it and how the note in
+[page-05](page-05-usage-maps.md) half-described it ("bumps a counter … not yet decoded"). Measured against
+`Microsoft.ACE.OLEDB.16.0` on one connection, watching `0xE02` (slot 1, the first shared user):
+
+| | slot 1 | as LE16 |
+| --- | --- | --- |
+| baseline | `D2 02` | 722 |
+| after `SELECT` | `D2 02` | 722 — **reads never move it** |
+| after `CREATE TABLE` | `D3 02` | 723 |
+| after 3 × `INSERT` | `D5 02` | 725 |
+| after `UPDATE`, after `DELETE` | `D6 02`, `D7 02` | 726, 727 |
+| after `CREATE INDEX` | `D9 02` | 729 |
+| +40 inserts | `01 03` | 769 — exactly +40 |
+| +240 more | `F1 03` | 1009 — exactly +240 |
+
+Four properties fall out, each of which the "state" reading would have got wrong:
+
+- **The two bytes are one value.** Driving the low byte past `0xFF` carries into the high byte —
+ `0x03F1` + 16 = `0x0401` — which independent bytes would not do. It also explains why a well-used file
+ shows a high byte of `02`/`03`/`04` rather than the `01` of an untouched slot: the low byte has wrapped
+ that many times.
+- **The idle `00 01` is that counter's start value**, 256 — not a magic constant. It sits just above the
+ reserved `00 00`/`01 00` status codes, and a freshly compacted file starts there (below).
+- **The on-disk value lags the last write by one.** A statement's increment is not flushed until the *next*
+ write, or until the connection closes (which lands the pending one plus its own). So a burst of *n* inserts
+ reads as *n−1* until something follows it. Measure between two mid-burst samples and the lag cancels.
+- **Every committed write costs exactly one**, DDL included. The lag makes this easy to misread: in the run
+ above `CREATE INDEX` appears to move it by 2 and the first `INSERT` by 0, but the whole seven-statement
+ sequence is 722 → 729, exactly +7. Isolating `CREATE INDEX` — the same trailing five inserts with and
+ without it — gives +5 versus +6, so it is one commit like anything else.
+
+**Reopening does not reset it; compacting does.** The counter carries straight across a close and reopen
+(`…DA` before, `…DA` after). A DAO `CompactDatabase` writes a whole new file and its slot 1 starts at **256**,
+the idle value, then counts normally from there (744 → 256 → 260 after five inserts).
+
+Tests: `CommitByteTableTests`. LibRed still does not read or maintain the table — ACE opens and queries
+LibRed-created tables with the counter untouched — so this is documentation of the format, not a dependency.
+
This region is **undocumented by mdbtools and Jackcess** — LibRed's own decode, cross-checked three ways:
the white paper's Jet 2.x/3.x structure, the raw bytes of real ACE files, and the Microsoft **LDBView**
utility (Jet 2/3 only), which shows `1` for every unregistered slot — matching the idle `00 01`.
@@ -308,10 +422,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 +469,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-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/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/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 17434afe..1d651b44 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,10 @@ 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 *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
> 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
@@ -45,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
@@ -72,15 +93,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**:
@@ -114,6 +133,215 @@ 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 **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
+> 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 **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
+> ` `, 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.
+>
+> **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.
+>
+> **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
+> what ACE stores, nothing refused and nothing left unhandled (`Probe_full_bmp_coverage`, needs
+> `LIBRED_FULL_BMP`). Other locales are still refused under v1.
+>
+> **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: 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< 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
@@ -124,28 +352,209 @@ 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.
+
+ > 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` =
+ > `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
+ 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
+ > 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`.)
+
+ **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).
+ > **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. 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**.
+ >
+ > **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.
+ >
+ > **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.
+ > 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`,
+ **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). 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**,
+ 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
@@ -156,8 +565,201 @@ 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`).
+
+ 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.
+ - **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 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** |
+ | 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
+ 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, 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).
+ 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
@@ -171,10 +773,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
@@ -224,9 +829,23 @@ The split mechanics:
> point to revisit when validating large indexes against Access.
-> **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`.
+> **Indexable types — coverage vs ACE (§10.4).** `IndexKeyEncoder` now encodes **every type ACE lets you
+> index**, all byte-verified: Boolean, Byte, Int16, Int32, Currency, Single, Double, DateTime, Text, GUID,
+> Binary, FixedPoint, Memo (its first 255 chars), **`Int64`/BIGINT** (`0x13`) and
+> **`DateTimeExtended`/DATETIME2** (`0x14`). ACE correctly **refuses** to index `OLE` (`0x0B`) and `Complex`
+> (`0x12`).
+>
+> `Int64`/BIGINT keys exactly as Currency does — an int64, sign bit flipped, big-endian — which had long been
+> the guess on record and is now measured across `0`, `±1`, `±42` and both extremes, ascending and descending
+> (`BigIntKeyEncodingTests`). Note its **variable-length storage does not change this**: the key dispatch is on
+> the column's type, not on where the row keeps the bytes. `IndexKeyDecoder` decodes it too, unlike DATETIME2 —
+> it is a plain fixed-width numeric key.
+>
+> `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/src/LibRed/docs/format/page-05-usage-maps.md b/src/LibRed/docs/format/page-05-usage-maps.md
index 1a28f2b4..b4df237b 100644
--- a/src/LibRed/docs/format/page-05-usage-maps.md
+++ b/src/LibRed/docs/format/page-05-usage-maps.md
@@ -150,9 +150,13 @@ one cleared bit per page taken.
> free targets, and non-contiguous growth targets are rejected before a free bit is cleared or set.
>
> **Create-table side effects.** An ACE `CREATE TABLE` *also* (1) adds two rows to **`MSysACEs`**
-> (the new object's permission entries) and updates its `ObjectId` index, and (2) bumps a counter in
-> page 0's obfuscated region at `~0xE02` (not yet decoded). **(1) is now done** —
+> (the new object's permission entries) and updates its `ObjectId` index, and (2) bumps the opening user's
+> commit counter at `0xE02`. **(1) is now done** —
> `TableCreator.AddPermissionRows` writes both permission rows (§11), and ACE opens LibRed-created
> tables without repair (`CreateTableAccessTests`). **(2) appears not to be required:** LibRed does not
> touch the page-0 counter, yet ACE opens/queries the created tables — so it's either unused for
> table open or benign when stale. (Views likewise get their two `MSysACEs` rows now — §11.)
+>
+> That counter is **no longer undecoded**: it is a 16-bit little-endian count of the user's committed writes,
+> and it is not specific to `CREATE TABLE` — every committed write moves it, reads never do. See
+> [page-00 §2.2](page-00-database.md#the-slot-is-a-little-endian-commit-counter-verified-2026-08-26).
diff --git a/src/LibRed/docs/format/system-catalog.md b/src/LibRed/docs/format/system-catalog.md
index 789c1a23..5cdeda76 100644
--- a/src/LibRed/docs/format/system-catalog.md
+++ b/src/LibRed/docs/format/system-catalog.md
@@ -258,8 +258,43 @@
> 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`.
+ > - **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/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.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.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.Jet.FunctionalTests/Query/Translations/ByteArrayTranslationsJetTest.cs b/test/EFCore.Jet.FunctionalTests/Query/Translations/ByteArrayTranslationsJetTest.cs
index e29524b5..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()
{
@@ -97,9 +78,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.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.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.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/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/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
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..bbe5a69d 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)
@@ -472,7 +472,7 @@ public async Task Where_equals_new_DateTime(bool async)
"""
SELECT `d`.`Id`, `d`.`DateTime`, `d`.`DateTime2`, `d`.`DateTime2_0`, `d`.`DateTime2_1`, `d`.`DateTime2_2`, `d`.`DateTime2_3`, `d`.`DateTime2_4`, `d`.`DateTime2_5`, `d`.`DateTime2_6`, `d`.`DateTime2_7`, `d`.`SmallDateTime`
FROM `Dates` AS `d`
-WHERE `d`.`SmallDateTime` = #1970-09-03 12:00:00# AND `d`.`DateTime` = #1971-09-03 12:00:10# AND `d`.`DateTime2` = #1972-09-03 12:00:10# AND `d`.`DateTime2_0` = #1973-09-03 12:00:10# AND `d`.`DateTime2_1` = #1974-09-03 12:00:10# AND `d`.`DateTime2_2` = #1975-09-03 12:00:10# AND `d`.`DateTime2_3` = #1976-09-03 12:00:10# AND `d`.`DateTime2_4` = #1977-09-03 12:00:10# AND `d`.`DateTime2_5` = #1978-09-03 12:00:10# AND `d`.`DateTime2_6` = #1979-09-03 12:00:10# AND `d`.`DateTime2_7` = #1980-09-03 12:00:10#
+WHERE `d`.`SmallDateTime` = #1970-09-03 12:00:00# AND `d`.`DateTime` = #1971-09-03 12:00:10.220# AND `d`.`DateTime2` = #1972-09-03 12:00:10.333# AND `d`.`DateTime2_0` = #1973-09-03 12:00:10# AND `d`.`DateTime2_1` = #1974-09-03 12:00:10.500# AND `d`.`DateTime2_2` = #1975-09-03 12:00:10.660# AND `d`.`DateTime2_3` = #1976-09-03 12:00:10.777# AND `d`.`DateTime2_4` = #1977-09-03 12:00:10.888# AND `d`.`DateTime2_5` = #1978-09-03 12:00:10.999# AND `d`.`DateTime2_6` = #1979-09-03 12:00:10.111# AND `d`.`DateTime2_7` = #1980-09-03 12:00:10.222#
""");
}
@@ -520,10 +520,231 @@ public async Task Where_contains_DateTime_literals(bool async)
AssertSql(
"""
- SELECT `d`.`Id`, `d`.`DateTime`, `d`.`DateTime2`, `d`.`DateTime2_0`, `d`.`DateTime2_1`, `d`.`DateTime2_2`, `d`.`DateTime2_3`, `d`.`DateTime2_4`, `d`.`DateTime2_5`, `d`.`DateTime2_6`, `d`.`DateTime2_7`, `d`.`SmallDateTime`
- FROM `Dates` AS `d`
- WHERE `d`.`SmallDateTime` IN (#1970-09-03 12:00:00#, #1971-09-03 12:00:10#, #1972-09-03 12:00:10#, #1973-09-03 12:00:10#, #1974-09-03 12:00:10#, #1975-09-03 12:00:10#, #1976-09-03 12:00:10#, #1977-09-03 12:00:10#, #1978-09-03 12:00:10#, #1979-09-03 12:00:10#, #1980-09-03 12:00:10#) AND `d`.`DateTime` IN (#1970-09-03 12:00:00#, #1971-09-03 12:00:10#, #1972-09-03 12:00:10#, #1973-09-03 12:00:10#, #1974-09-03 12:00:10#, #1975-09-03 12:00:10#, #1976-09-03 12:00:10#, #1977-09-03 12:00:10#, #1978-09-03 12:00:10#, #1979-09-03 12:00:10#, #1980-09-03 12:00:10#) AND `d`.`DateTime2` IN (#1970-09-03 12:00:00#, #1971-09-03 12:00:10#, #1972-09-03 12:00:10#, #1973-09-03 12:00:10#, #1974-09-03 12:00:10#, #1975-09-03 12:00:10#, #1976-09-03 12:00:10#, #1977-09-03 12:00:10#, #1978-09-03 12:00:10#, #1979-09-03 12:00:10#, #1980-09-03 12:00:10#) AND `d`.`DateTime2_0` IN (#1970-09-03 12:00:00#, #1971-09-03 12:00:10#, #1972-09-03 12:00:10#, #1973-09-03 12:00:10#, #1974-09-03 12:00:10#, #1975-09-03 12:00:10#, #1976-09-03 12:00:10#, #1977-09-03 12:00:10#, #1978-09-03 12:00:10#, #1979-09-03 12:00:10#, #1980-09-03 12:00:10#) AND `d`.`DateTime2_1` IN (#1970-09-03 12:00:00#, #1971-09-03 12:00:10#, #1972-09-03 12:00:10#, #1973-09-03 12:00:10#, #1974-09-03 12:00:10#, #1975-09-03 12:00:10#, #1976-09-03 12:00:10#, #1977-09-03 12:00:10#, #1978-09-03 12:00:10#, #1979-09-03 12:00:10#, #1980-09-03 12:00:10#) AND `d`.`DateTime2_2` IN (#1970-09-03 12:00:00#, #1971-09-03 12:00:10#, #1972-09-03 12:00:10#, #1973-09-03 12:00:10#, #1974-09-03 12:00:10#, #1975-09-03 12:00:10#, #1976-09-03 12:00:10#, #1977-09-03 12:00:10#, #1978-09-03 12:00:10#, #1979-09-03 12:00:10#, #1980-09-03 12:00:10#) AND `d`.`DateTime2_3` IN (#1970-09-03 12:00:00#, #1971-09-03 12:00:10#, #1972-09-03 12:00:10#, #1973-09-03 12:00:10#, #1974-09-03 12:00:10#, #1975-09-03 12:00:10#, #1976-09-03 12:00:10#, #1977-09-03 12:00:10#, #1978-09-03 12:00:10#, #1979-09-03 12:00:10#, #1980-09-03 12:00:10#) AND `d`.`DateTime2_4` IN (#1970-09-03 12:00:00#, #1971-09-03 12:00:10#, #1972-09-03 12:00:10#, #1973-09-03 12:00:10#, #1974-09-03 12:00:10#, #1975-09-03 12:00:10#, #1976-09-03 12:00:10#, #1977-09-03 12:00:10#, #1978-09-03 12:00:10#, #1979-09-03 12:00:10#, #1980-09-03 12:00:10#) AND `d`.`DateTime2_5` IN (#1970-09-03 12:00:00#, #1971-09-03 12:00:10#, #1972-09-03 12:00:10#, #1973-09-03 12:00:10#, #1974-09-03 12:00:10#, #1975-09-03 12:00:10#, #1976-09-03 12:00:10#, #1977-09-03 12:00:10#, #1978-09-03 12:00:10#, #1979-09-03 12:00:10#, #1980-09-03 12:00:10#) AND `d`.`DateTime2_6` IN (#1970-09-03 12:00:00#, #1971-09-03 12:00:10#, #1972-09-03 12:00:10#, #1973-09-03 12:00:10#, #1974-09-03 12:00:10#, #1975-09-03 12:00:10#, #1976-09-03 12:00:10#, #1977-09-03 12:00:10#, #1978-09-03 12:00:10#, #1979-09-03 12:00:10#, #1980-09-03 12:00:10#) AND `d`.`DateTime2_7` IN (#1970-09-03 12:00:00#, #1971-09-03 12:00:10#, #1972-09-03 12:00:10#, #1973-09-03 12:00:10#, #1974-09-03 12:00:10#, #1975-09-03 12:00:10#, #1976-09-03 12:00:10#, #1977-09-03 12:00:10#, #1978-09-03 12:00:10#, #1979-09-03 12:00:10#, #1980-09-03 12:00:10#)
- """);
+@dateTimes1='1970-09-03T12:00:00.0000000' (DbType = DateTime)
+@dateTimes2='1971-09-03T12:00:10.2200000' (DbType = DateTime)
+@dateTimes3='1972-09-03T12:00:10.3330000' (DbType = DateTime)
+@dateTimes4='1973-09-03T12:00:10.0000000' (DbType = DateTime)
+@dateTimes5='1974-09-03T12:00:10.5000000' (DbType = DateTime)
+@dateTimes6='1975-09-03T12:00:10.6600000' (DbType = DateTime)
+@dateTimes7='1976-09-03T12:00:10.7770000' (DbType = DateTime)
+@dateTimes8='1977-09-03T12:00:10.8880000' (DbType = DateTime)
+@dateTimes9='1978-09-03T12:00:10.9990000' (DbType = DateTime)
+@dateTimes10='1979-09-03T12:00:10.1110000' (DbType = DateTime)
+@dateTimes11='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes12='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes13='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes14='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes15='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes16='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes17='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes18='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes19='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes20='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes21='1970-09-03T12:00:00.0000000' (DbType = DateTime)
+@dateTimes22='1971-09-03T12:00:10.2200000' (DbType = DateTime)
+@dateTimes23='1972-09-03T12:00:10.3330000' (DbType = DateTime)
+@dateTimes24='1973-09-03T12:00:10.0000000' (DbType = DateTime)
+@dateTimes25='1974-09-03T12:00:10.5000000' (DbType = DateTime)
+@dateTimes26='1975-09-03T12:00:10.6600000' (DbType = DateTime)
+@dateTimes27='1976-09-03T12:00:10.7770000' (DbType = DateTime)
+@dateTimes28='1977-09-03T12:00:10.8880000' (DbType = DateTime)
+@dateTimes29='1978-09-03T12:00:10.9990000' (DbType = DateTime)
+@dateTimes30='1979-09-03T12:00:10.1110000' (DbType = DateTime)
+@dateTimes31='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes32='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes33='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes34='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes35='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes36='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes37='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes38='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes39='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes40='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes41='1970-09-03T12:00:00.0000000' (DbType = DateTime)
+@dateTimes42='1971-09-03T12:00:10.2200000' (DbType = DateTime)
+@dateTimes43='1972-09-03T12:00:10.3330000' (DbType = DateTime)
+@dateTimes44='1973-09-03T12:00:10.0000000' (DbType = DateTime)
+@dateTimes45='1974-09-03T12:00:10.5000000' (DbType = DateTime)
+@dateTimes46='1975-09-03T12:00:10.6600000' (DbType = DateTime)
+@dateTimes47='1976-09-03T12:00:10.7770000' (DbType = DateTime)
+@dateTimes48='1977-09-03T12:00:10.8880000' (DbType = DateTime)
+@dateTimes49='1978-09-03T12:00:10.9990000' (DbType = DateTime)
+@dateTimes50='1979-09-03T12:00:10.1110000' (DbType = DateTime)
+@dateTimes51='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes52='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes53='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes54='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes55='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes56='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes57='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes58='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes59='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes60='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes61='1970-09-03T12:00:00.0000000' (DbType = DateTime)
+@dateTimes62='1971-09-03T12:00:10.2200000' (DbType = DateTime)
+@dateTimes63='1972-09-03T12:00:10.3330000' (DbType = DateTime)
+@dateTimes64='1973-09-03T12:00:10.0000000' (DbType = DateTime)
+@dateTimes65='1974-09-03T12:00:10.5000000' (DbType = DateTime)
+@dateTimes66='1975-09-03T12:00:10.6600000' (DbType = DateTime)
+@dateTimes67='1976-09-03T12:00:10.7770000' (DbType = DateTime)
+@dateTimes68='1977-09-03T12:00:10.8880000' (DbType = DateTime)
+@dateTimes69='1978-09-03T12:00:10.9990000' (DbType = DateTime)
+@dateTimes70='1979-09-03T12:00:10.1110000' (DbType = DateTime)
+@dateTimes71='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes72='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes73='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes74='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes75='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes76='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes77='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes78='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes79='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes80='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes81='1970-09-03T12:00:00.0000000' (DbType = DateTime)
+@dateTimes82='1971-09-03T12:00:10.2200000' (DbType = DateTime)
+@dateTimes83='1972-09-03T12:00:10.3330000' (DbType = DateTime)
+@dateTimes84='1973-09-03T12:00:10.0000000' (DbType = DateTime)
+@dateTimes85='1974-09-03T12:00:10.5000000' (DbType = DateTime)
+@dateTimes86='1975-09-03T12:00:10.6600000' (DbType = DateTime)
+@dateTimes87='1976-09-03T12:00:10.7770000' (DbType = DateTime)
+@dateTimes88='1977-09-03T12:00:10.8880000' (DbType = DateTime)
+@dateTimes89='1978-09-03T12:00:10.9990000' (DbType = DateTime)
+@dateTimes90='1979-09-03T12:00:10.1110000' (DbType = DateTime)
+@dateTimes91='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes92='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes93='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes94='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes95='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes96='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes97='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes98='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes99='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes100='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes101='1970-09-03T12:00:00.0000000' (DbType = DateTime)
+@dateTimes102='1971-09-03T12:00:10.2200000' (DbType = DateTime)
+@dateTimes103='1972-09-03T12:00:10.3330000' (DbType = DateTime)
+@dateTimes104='1973-09-03T12:00:10.0000000' (DbType = DateTime)
+@dateTimes105='1974-09-03T12:00:10.5000000' (DbType = DateTime)
+@dateTimes106='1975-09-03T12:00:10.6600000' (DbType = DateTime)
+@dateTimes107='1976-09-03T12:00:10.7770000' (DbType = DateTime)
+@dateTimes108='1977-09-03T12:00:10.8880000' (DbType = DateTime)
+@dateTimes109='1978-09-03T12:00:10.9990000' (DbType = DateTime)
+@dateTimes110='1979-09-03T12:00:10.1110000' (DbType = DateTime)
+@dateTimes111='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes112='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes113='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes114='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes115='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes116='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes117='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes118='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes119='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes120='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes121='1970-09-03T12:00:00.0000000' (DbType = DateTime)
+@dateTimes122='1971-09-03T12:00:10.2200000' (DbType = DateTime)
+@dateTimes123='1972-09-03T12:00:10.3330000' (DbType = DateTime)
+@dateTimes124='1973-09-03T12:00:10.0000000' (DbType = DateTime)
+@dateTimes125='1974-09-03T12:00:10.5000000' (DbType = DateTime)
+@dateTimes126='1975-09-03T12:00:10.6600000' (DbType = DateTime)
+@dateTimes127='1976-09-03T12:00:10.7770000' (DbType = DateTime)
+@dateTimes128='1977-09-03T12:00:10.8880000' (DbType = DateTime)
+@dateTimes129='1978-09-03T12:00:10.9990000' (DbType = DateTime)
+@dateTimes130='1979-09-03T12:00:10.1110000' (DbType = DateTime)
+@dateTimes131='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes132='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes133='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes134='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes135='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes136='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes137='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes138='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes139='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes140='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes141='1970-09-03T12:00:00.0000000' (DbType = DateTime)
+@dateTimes142='1971-09-03T12:00:10.2200000' (DbType = DateTime)
+@dateTimes143='1972-09-03T12:00:10.3330000' (DbType = DateTime)
+@dateTimes144='1973-09-03T12:00:10.0000000' (DbType = DateTime)
+@dateTimes145='1974-09-03T12:00:10.5000000' (DbType = DateTime)
+@dateTimes146='1975-09-03T12:00:10.6600000' (DbType = DateTime)
+@dateTimes147='1976-09-03T12:00:10.7770000' (DbType = DateTime)
+@dateTimes148='1977-09-03T12:00:10.8880000' (DbType = DateTime)
+@dateTimes149='1978-09-03T12:00:10.9990000' (DbType = DateTime)
+@dateTimes150='1979-09-03T12:00:10.1110000' (DbType = DateTime)
+@dateTimes151='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes152='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes153='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes154='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes155='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes156='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes157='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes158='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes159='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes160='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes161='1970-09-03T12:00:00.0000000' (DbType = DateTime)
+@dateTimes162='1971-09-03T12:00:10.2200000' (DbType = DateTime)
+@dateTimes163='1972-09-03T12:00:10.3330000' (DbType = DateTime)
+@dateTimes164='1973-09-03T12:00:10.0000000' (DbType = DateTime)
+@dateTimes165='1974-09-03T12:00:10.5000000' (DbType = DateTime)
+@dateTimes166='1975-09-03T12:00:10.6600000' (DbType = DateTime)
+@dateTimes167='1976-09-03T12:00:10.7770000' (DbType = DateTime)
+@dateTimes168='1977-09-03T12:00:10.8880000' (DbType = DateTime)
+@dateTimes169='1978-09-03T12:00:10.9990000' (DbType = DateTime)
+@dateTimes170='1979-09-03T12:00:10.1110000' (DbType = DateTime)
+@dateTimes171='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes172='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes173='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes174='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes175='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes176='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes177='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes178='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes179='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes180='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes181='1970-09-03T12:00:00.0000000' (DbType = DateTime)
+@dateTimes182='1971-09-03T12:00:10.2200000' (DbType = DateTime)
+@dateTimes183='1972-09-03T12:00:10.3330000' (DbType = DateTime)
+@dateTimes184='1973-09-03T12:00:10.0000000' (DbType = DateTime)
+@dateTimes185='1974-09-03T12:00:10.5000000' (DbType = DateTime)
+@dateTimes186='1975-09-03T12:00:10.6600000' (DbType = DateTime)
+@dateTimes187='1976-09-03T12:00:10.7770000' (DbType = DateTime)
+@dateTimes188='1977-09-03T12:00:10.8880000' (DbType = DateTime)
+@dateTimes189='1978-09-03T12:00:10.9990000' (DbType = DateTime)
+@dateTimes190='1979-09-03T12:00:10.1110000' (DbType = DateTime)
+@dateTimes191='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes192='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes193='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes194='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes195='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes196='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes197='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes198='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes199='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes200='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes201='1970-09-03T12:00:00.0000000' (DbType = DateTime)
+@dateTimes202='1971-09-03T12:00:10.2200000' (DbType = DateTime)
+@dateTimes203='1972-09-03T12:00:10.3330000' (DbType = DateTime)
+@dateTimes204='1973-09-03T12:00:10.0000000' (DbType = DateTime)
+@dateTimes205='1974-09-03T12:00:10.5000000' (DbType = DateTime)
+@dateTimes206='1975-09-03T12:00:10.6600000' (DbType = DateTime)
+@dateTimes207='1976-09-03T12:00:10.7770000' (DbType = DateTime)
+@dateTimes208='1977-09-03T12:00:10.8880000' (DbType = DateTime)
+@dateTimes209='1978-09-03T12:00:10.9990000' (DbType = DateTime)
+@dateTimes210='1979-09-03T12:00:10.1110000' (DbType = DateTime)
+@dateTimes211='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes212='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes213='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes214='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes215='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes216='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes217='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes218='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes219='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+@dateTimes220='1980-09-03T12:00:10.2220000' (DbType = DateTime)
+
+SELECT `d`.`Id`, `d`.`DateTime`, `d`.`DateTime2`, `d`.`DateTime2_0`, `d`.`DateTime2_1`, `d`.`DateTime2_2`, `d`.`DateTime2_3`, `d`.`DateTime2_4`, `d`.`DateTime2_5`, `d`.`DateTime2_6`, `d`.`DateTime2_7`, `d`.`SmallDateTime`
+FROM `Dates` AS `d`
+WHERE `d`.`SmallDateTime` IN (CDATE(@dateTimes1), CDATE(@dateTimes2), CDATE(@dateTimes3), CDATE(@dateTimes4), CDATE(@dateTimes5), CDATE(@dateTimes6), CDATE(@dateTimes7), CDATE(@dateTimes8), CDATE(@dateTimes9), CDATE(@dateTimes10), CDATE(@dateTimes11), CDATE(@dateTimes12), CDATE(@dateTimes13), CDATE(@dateTimes14), CDATE(@dateTimes15), CDATE(@dateTimes16), CDATE(@dateTimes17), CDATE(@dateTimes18), CDATE(@dateTimes19), CDATE(@dateTimes20)) AND `d`.`DateTime` IN (CDATE(@dateTimes21), CDATE(@dateTimes22), CDATE(@dateTimes23), CDATE(@dateTimes24), CDATE(@dateTimes25), CDATE(@dateTimes26), CDATE(@dateTimes27), CDATE(@dateTimes28), CDATE(@dateTimes29), CDATE(@dateTimes30), CDATE(@dateTimes31), CDATE(@dateTimes32), CDATE(@dateTimes33), CDATE(@dateTimes34), CDATE(@dateTimes35), CDATE(@dateTimes36), CDATE(@dateTimes37), CDATE(@dateTimes38), CDATE(@dateTimes39), CDATE(@dateTimes40)) AND `d`.`DateTime2` IN (CDATE(@dateTimes41), CDATE(@dateTimes42), CDATE(@dateTimes43), CDATE(@dateTimes44), CDATE(@dateTimes45), CDATE(@dateTimes46), CDATE(@dateTimes47), CDATE(@dateTimes48), CDATE(@dateTimes49), CDATE(@dateTimes50), CDATE(@dateTimes51), CDATE(@dateTimes52), CDATE(@dateTimes53), CDATE(@dateTimes54), CDATE(@dateTimes55), CDATE(@dateTimes56), CDATE(@dateTimes57), CDATE(@dateTimes58), CDATE(@dateTimes59), CDATE(@dateTimes60)) AND `d`.`DateTime2_0` IN (CDATE(@dateTimes61), CDATE(@dateTimes62), CDATE(@dateTimes63), CDATE(@dateTimes64), CDATE(@dateTimes65), CDATE(@dateTimes66), CDATE(@dateTimes67), CDATE(@dateTimes68), CDATE(@dateTimes69), CDATE(@dateTimes70), CDATE(@dateTimes71), CDATE(@dateTimes72), CDATE(@dateTimes73), CDATE(@dateTimes74), CDATE(@dateTimes75), CDATE(@dateTimes76), CDATE(@dateTimes77), CDATE(@dateTimes78), CDATE(@dateTimes79), CDATE(@dateTimes80)) AND `d`.`DateTime2_1` IN (CDATE(@dateTimes81), CDATE(@dateTimes82), CDATE(@dateTimes83), CDATE(@dateTimes84), CDATE(@dateTimes85), CDATE(@dateTimes86), CDATE(@dateTimes87), CDATE(@dateTimes88), CDATE(@dateTimes89), CDATE(@dateTimes90), CDATE(@dateTimes91), CDATE(@dateTimes92), CDATE(@dateTimes93), CDATE(@dateTimes94), CDATE(@dateTimes95), CDATE(@dateTimes96), CDATE(@dateTimes97), CDATE(@dateTimes98), CDATE(@dateTimes99), CDATE(@dateTimes100)) AND `d`.`DateTime2_2` IN (CDATE(@dateTimes101), CDATE(@dateTimes102), CDATE(@dateTimes103), CDATE(@dateTimes104), CDATE(@dateTimes105), CDATE(@dateTimes106), CDATE(@dateTimes107), CDATE(@dateTimes108), CDATE(@dateTimes109), CDATE(@dateTimes110), CDATE(@dateTimes111), CDATE(@dateTimes112), CDATE(@dateTimes113), CDATE(@dateTimes114), CDATE(@dateTimes115), CDATE(@dateTimes116), CDATE(@dateTimes117), CDATE(@dateTimes118), CDATE(@dateTimes119), CDATE(@dateTimes120)) AND `d`.`DateTime2_3` IN (CDATE(@dateTimes121), CDATE(@dateTimes122), CDATE(@dateTimes123), CDATE(@dateTimes124), CDATE(@dateTimes125), CDATE(@dateTimes126), CDATE(@dateTimes127), CDATE(@dateTimes128), CDATE(@dateTimes129), CDATE(@dateTimes130), CDATE(@dateTimes131), CDATE(@dateTimes132), CDATE(@dateTimes133), CDATE(@dateTimes134), CDATE(@dateTimes135), CDATE(@dateTimes136), CDATE(@dateTimes137), CDATE(@dateTimes138), CDATE(@dateTimes139), CDATE(@dateTimes140)) AND `d`.`DateTime2_4` IN (CDATE(@dateTimes141), CDATE(@dateTimes142), CDATE(@dateTimes143), CDATE(@dateTimes144), CDATE(@dateTimes145), CDATE(@dateTimes146), CDATE(@dateTimes147), CDATE(@dateTimes148), CDATE(@dateTimes149), CDATE(@dateTimes150), CDATE(@dateTimes151), CDATE(@dateTimes152), CDATE(@dateTimes153), CDATE(@dateTimes154), CDATE(@dateTimes155), CDATE(@dateTimes156), CDATE(@dateTimes157), CDATE(@dateTimes158), CDATE(@dateTimes159), CDATE(@dateTimes160)) AND `d`.`DateTime2_5` IN (CDATE(@dateTimes161), CDATE(@dateTimes162), CDATE(@dateTimes163), CDATE(@dateTimes164), CDATE(@dateTimes165), CDATE(@dateTimes166), CDATE(@dateTimes167), CDATE(@dateTimes168), CDATE(@dateTimes169), CDATE(@dateTimes170), CDATE(@dateTimes171), CDATE(@dateTimes172), CDATE(@dateTimes173), CDATE(@dateTimes174), CDATE(@dateTimes175), CDATE(@dateTimes176), CDATE(@dateTimes177), CDATE(@dateTimes178), CDATE(@dateTimes179), CDATE(@dateTimes180)) AND `d`.`DateTime2_6` IN (CDATE(@dateTimes181), CDATE(@dateTimes182), CDATE(@dateTimes183), CDATE(@dateTimes184), CDATE(@dateTimes185), CDATE(@dateTimes186), CDATE(@dateTimes187), CDATE(@dateTimes188), CDATE(@dateTimes189), CDATE(@dateTimes190), CDATE(@dateTimes191), CDATE(@dateTimes192), CDATE(@dateTimes193), CDATE(@dateTimes194), CDATE(@dateTimes195), CDATE(@dateTimes196), CDATE(@dateTimes197), CDATE(@dateTimes198), CDATE(@dateTimes199), CDATE(@dateTimes200)) AND `d`.`DateTime2_7` IN (CDATE(@dateTimes201), CDATE(@dateTimes202), CDATE(@dateTimes203), CDATE(@dateTimes204), CDATE(@dateTimes205), CDATE(@dateTimes206), CDATE(@dateTimes207), CDATE(@dateTimes208), CDATE(@dateTimes209), CDATE(@dateTimes210), CDATE(@dateTimes211), CDATE(@dateTimes212), CDATE(@dateTimes213), CDATE(@dateTimes214), CDATE(@dateTimes215), CDATE(@dateTimes216), CDATE(@dateTimes217), CDATE(@dateTimes218), CDATE(@dateTimes219), CDATE(@dateTimes220))
+""");
}
protected class Context14095(DbContextOptions options) : DbContext(options)
@@ -1391,6 +1612,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 +2075,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/NorthwindJoinQueryLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/NorthwindJoinQueryLibRedTest.cs
index ebfeeca9..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;
@@ -284,9 +286,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 +298,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`
""");
}
@@ -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/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..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
@@ -1106,8 +1107,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`
""");
}
@@ -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)
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/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/ByteArrayTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/ByteArrayTranslationsLibRedTest.cs
index 92ec0cd9..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()
{
@@ -97,9 +78,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/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
""");
}
diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Operators/MiscellaneousOperatorTranslationsLibRedTest.cs
index 0dd88959..eea6c867 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 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/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`
diff --git a/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs b/test/EFCore.LibRed.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsLibRedTest.cs
index 9d01d684..f675e1e3 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
""");
}
@@ -292,7 +292,7 @@ public override async Task AddMilliseconds()
AssertSql(
"""
-SELECT `b`.`DateTimeOffset`
+SELECT DATEADD('ms', 300.0, `b`.`DateTimeOffset`)
FROM `BasicTypesEntities` AS `b`
""");
}
@@ -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
""");
}
@@ -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
""");
}
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);
}
}
}
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.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/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/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.Ado.Tests/TemporalParameterTests.cs b/test/LibRed.Ado.Tests/TemporalParameterTests.cs
index a35974cd..1176faad 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;
@@ -73,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)],
];
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/AceAutoNumberOverflowRegressionTests.cs b/test/LibRed.Core.Tests/AceAutoNumberOverflowRegressionTests.cs
new file mode 100644
index 00000000..58694214
--- /dev/null
+++ b/test/LibRed.Core.Tests/AceAutoNumberOverflowRegressionTests.cs
@@ -0,0 +1,290 @@
+using System.Data.OleDb;
+using LibRed;
+using LibRed.Catalog;
+using Xunit;
+
+namespace LibRed.Core.Tests;
+
+// 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
+// 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 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) => 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)
+ {
+ 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 = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, tag);
+ 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 Ace_counter_wraps_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)
+ {
+ Assert.Fail($"ACE rejected the boundary COUNTER DDL: {ex.Message.Trim()}");
+ }
+
+ 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 { TemporaryDatabase.Delete(path); }
+ }
+
+ [Fact]
+ public void Ace_counter_wraps_after_an_explicit_max_value()
+ {
+ 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) { Assert.Fail($"ACE step '{label}' failed: {ex.Message.Trim()}"); }
+ 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'");
+
+ 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 { TemporaryDatabase.Delete(path); }
+ }
+
+ [Fact]
+ public void Ace_counter_advances_past_a_colliding_wrapped_id()
+ {
+ 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) { 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
+ // 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 { TemporaryDatabase.Delete(path); }
+ }
+
+ // -------------------------------------------------------------- LibRed ---------------------------------------
+
+ [Theory]
+ [InlineData("lib-max", 2147483646, 1, new[] { 2147483646, 2147483647, -2147483648, -2147483647 })]
+ [InlineData("lib-min", -2147483647, -1, new[] { -2147483647, -2147483648, 2147483647, 2147483646 })]
+ public void Libred_counter_matches_ace_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 { TemporaryDatabase.Delete(path); }
+ }
+
+ [Fact]
+ public void Libred_counter_matches_ace_after_an_explicit_max_value()
+ {
+ 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 { 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/AceDateTime2UpgradeTests.cs b/test/LibRed.Core.Tests/AceDateTime2UpgradeTests.cs
new file mode 100644
index 00000000..8087c468
--- /dev/null
+++ b/test/LibRed.Core.Tests/AceDateTime2UpgradeTests.cs
@@ -0,0 +1,184 @@
+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)
+ {
+ // 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 })
+ {
+ 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/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/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/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/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/BigIntKeyEncodingTests.cs b/test/LibRed.Core.Tests/BigIntKeyEncodingTests.cs
new file mode 100644
index 00000000..0cead87b
--- /dev/null
+++ b/test/LibRed.Core.Tests/BigIntKeyEncodingTests.cs
@@ -0,0 +1,152 @@
+using System.Data.OleDb;
+using LibRed;
+using LibRed.Catalog;
+using LibRed.Formats;
+using LibRed.Storage;
+using Xunit;
+
+namespace LibRed.Core.Tests;
+
+// BIGINT (Large Number, JetDataType 0x13) as ACE stores it: the format upgrade it forces, the row layout it
+// takes, and its index keys byte-for-byte.
+//
+// Northwind is ACE 12 (version byte 0x02); Large Number needs ACE 16 (0x05). Unlike the DATETIME2 tests
+// nothing flips the byte by hand here — ACE is left to do it, because whether it does was the open question.
+public class BigIntKeyEncodingTests
+{
+ // Both extremes and both signs. The key transform is a sign-bit flip, so a sample of positives would pass
+ // against almost any encoding; Northwind itself has no negative data to catch that by accident.
+ private static readonly long[] Values =
+ [
+ 0L, 1L, -1L, 42L, -42L, long.MaxValue, long.MinValue,
+ ];
+
+ // Numeric, not BigInt. OleDbType.BigInt carries NO value at all into a Large Number column through this
+ // provider — every one of these fails with "data value could not be converted", zero included — so the one
+ // type named for the job is the only one that cannot do it. EFCore.Jet already forces the same workaround
+ // for its own long parameters (JetLongTypeMapping.ConfigureParameter sets OLE DB 131 / ODBC 7, commented
+ // "Using BigInt doesn't always work ... When running in x64 it fails to convert"), though that mapping
+ // targets a decimal(20,0) column rather than a real 0x13 one.
+ //
+ // Measured across both extremes: Numeric, Decimal and Variant each round-trip the full range exactly;
+ // VarNumeric is rejected outright ("Type name is invalid"); Double is the trap — it succeeds quietly for
+ // 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
+ {
+ Assert.Equal(0x02, VersionByte(path));
+
+ using (OleDbConnection connection = AceTestDatabase.Open(path))
+ Exec(connection, "CREATE TABLE BVer (K BIGINT, V LONG)");
+
+ // 0x05, not the 0x06 that DATETIME2 forces: the two types arrived in different formats.
+ Assert.Equal(0x05, VersionByte(path));
+ using var db = JetDatabase.Open(path);
+ Assert.Equal(JetVersion.Version16_2016, db.Format.Version);
+ }
+ finally { TemporaryDatabase.Delete(path); }
+ }
+
+ // Always 8 bytes, yet ACE keeps it in the row's VARIABLE region rather than the fixed one. LibRed has to
+ // create the column the same way or it writes the value somewhere ACE does not look for it.
+ [Fact]
+ public void Ace_stores_a_bigint_column_as_variable_length()
+ {
+ RequireBigInt();
+ string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-bigint-shape-");
+ try
+ {
+ using (OleDbConnection connection = AceTestDatabase.Open(path))
+ Exec(connection, "CREATE TABLE BShape (K BIGINT, V LONG)");
+
+ using var db = JetDatabase.Open(path);
+ ColumnDef k = db.OpenTable("BShape").Definition.FindColumn("K")!;
+
+ Assert.Equal(JetDataType.Int64, k.Type);
+ Assert.Equal(8, k.Length);
+ Assert.False(k.IsFixedLength);
+ }
+ finally { TemporaryDatabase.Delete(path); }
+ }
+
+ [Fact]
+ public void Encoded_bigint_keys_match_access_byte_for_byte_ascending()
+ => AssertKeysMatchAccess("CREATE INDEX IX_BKey ON BKey (K)");
+
+ [Fact]
+ public void Encoded_bigint_keys_match_access_byte_for_byte_descending()
+ => AssertKeysMatchAccess("CREATE INDEX IX_BKey ON BKey (K DESC)");
+
+ private static void AssertKeysMatchAccess(string indexDdl)
+ {
+ RequireBigInt();
+ string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-bigintkey-");
+ try
+ {
+ using (OleDbConnection connection = AceTestDatabase.Open(path))
+ {
+ Exec(connection, "CREATE TABLE BKey (K BIGINT, V LONG)");
+ Exec(connection, indexDdl);
+
+ for (int i = 0; i < Values.Length; i++)
+ {
+ using OleDbCommand insert = connection.CreateCommand();
+ insert.CommandText = "INSERT INTO BKey (K, V) VALUES (?, ?)";
+ insert.Parameters.Add(new OleDbParameter("k", ParameterType) { Value = Values[i] });
+ insert.Parameters.AddWithValue("v", i);
+ insert.ExecuteNonQuery();
+ }
+ }
+
+ using var db = JetDatabase.Open(path);
+ var table = db.OpenTable("BKey");
+ 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 = (long)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}: access={Convert.ToHexString(accessKey)} ours={Convert.ToHexString(ours)}");
+ checkedKeys++;
+ }
+
+ Assert.Equal(Values.Length, checkedKeys);
+ }
+ finally { TemporaryDatabase.Delete(path); }
+ }
+
+ private static void Exec(OleDbConnection connection, string sql)
+ {
+ using OleDbCommand command = connection.CreateCommand();
+ command.CommandText = sql;
+ command.ExecuteNonQuery();
+ }
+
+ private static byte VersionByte(string path)
+ {
+ using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+ stream.Seek(0x14, SeekOrigin.Begin);
+ return (byte)stream.ReadByte();
+ }
+}
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..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
{
@@ -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,26 +82,37 @@ 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]
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/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/CommitByteTableTests.cs b/test/LibRed.Core.Tests/CommitByteTableTests.cs
new file mode 100644
index 00000000..68f55b05
--- /dev/null
+++ b/test/LibRed.Core.Tests/CommitByteTableTests.cs
@@ -0,0 +1,170 @@
+using System.Data.OleDb;
+using Xunit;
+
+namespace LibRed.Core.Tests;
+
+// What the 2-byte user commit slots at 0xE00 actually hold (measured 2026-08-26 against
+// Microsoft.ACE.OLEDB.16.0). The format docs had described them two different ways — page-00 §2.2 as
+// "per-file last-commit states", page-05 as an undecoded "counter" — so this pins it down.
+//
+// It is a little-endian 16-bit COUNTER of the user's committed writes. The two bytes are one value: driving
+// the low byte past 0xFF carries into the high byte, which a pair of independent bytes would not do.
+//
+// ONE connection for the whole sequence — ACE heap-corrupts under connection churn (0xC0000374) and takes the
+// test process with it.
+public class CommitByteTableTests
+{
+ private const int Slot1 = 0xE02; // 0xE00 is slot 0 (exclusive mode); slot 1 is the first shared user
+
+ // The counter on disk LAGS the last write by one: its final increment is not flushed until the next write
+ // (or until the connection closes, which lands the pending one plus its own). So the count is taken
+ // between two mid-burst samples, where both ends carry the same one-write lag and it cancels.
+ //
+ // 280 also carries the low byte past 0xFF from any starting value, which is the part that proves the two
+ // bytes are one 16-bit value rather than two independent ones. Northwind's slot 1 starts around 0x02D2.
+ private const int Lead = 10;
+ private const int Measured = 280;
+
+ [Fact]
+ public void The_commit_slot_is_a_little_endian_counter_of_committed_writes()
+ {
+ string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-commitbyte-");
+ try
+ {
+ using var connection = AceTestDatabase.Open(path);
+ Exec(connection, "CREATE TABLE CB (Id LONG, T TEXT(20))");
+
+ // Reads do not touch it — only committed writes do.
+ int beforeRead = Counter(path, Slot1);
+ Scalar(connection, "SELECT COUNT(*) FROM Customers");
+ Scalar(connection, "SELECT COUNT(*) FROM CB");
+ Assert.Equal(beforeRead, Counter(path, Slot1));
+
+ // One increment per committed write — DDL included; a CREATE INDEX measured in isolation costs
+ // exactly one, like anything else. (The lag makes that easy to misread: in a mixed run a statement
+ // can appear to move it by 0 or 2 while the sequence as a whole is exactly one per statement.)
+ for (int i = 0; i < Lead; i++)
+ Exec(connection, $"INSERT INTO CB (Id, T) VALUES ({i}, 'lead')");
+
+ int before = Counter(path, Slot1);
+ for (int i = 0; i < Measured; i++)
+ Exec(connection, $"INSERT INTO CB (Id, T) VALUES ({Lead + i}, 'row')");
+
+ Assert.Equal(before + Measured, Counter(path, Slot1));
+ }
+ finally { TemporaryDatabase.Delete(path); }
+ }
+
+ [Fact]
+ public void Only_the_active_user_slot_moves()
+ {
+ string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-commitbyte-slots-");
+ try
+ {
+ byte[] before = Slots(path);
+
+ using (var connection = AceTestDatabase.Open(path))
+ {
+ Exec(connection, "CREATE TABLE CB (Id LONG)");
+ Exec(connection, "INSERT INTO CB (Id) VALUES (1)");
+ }
+
+ byte[] after = Slots(path);
+
+ // Slot 0 is the exclusive-mode state and stays put for a shared open; slots 2.. stay at the
+ // neutral 00 01 because no second user ever registered.
+ Assert.Equal(before[0..2], after[0..2]);
+ Assert.Equal(before[4..16], after[4..16]);
+ Assert.All(Enumerable.Range(2, 6), i => Assert.Equal([0x00, 0x01], after[(i * 2)..(i * 2 + 2)]));
+
+ // And the one that did move only went up.
+ Assert.True(Counter(path, Slot1) > (before[2] | (before[3] << 8)));
+ }
+ finally { TemporaryDatabase.Delete(path); }
+ }
+
+ // Reopening carries the count; compacting starts a new one. The second half is what makes 256 meaningful:
+ // it is not a magic "idle" constant but where a genuinely fresh file's counter begins.
+ [Fact]
+ public void Compacting_restarts_the_counter_but_reopening_carries_it()
+ {
+ object? engine = DaoEngine();
+ if (engine is null) return; // DAO absent - as the other ACE probes do
+
+ string path = TemporaryDatabase.CopyPath(TestDatabases.NorthwindAccdb, "libred-commitbyte-compact-");
+ string compacted = path.Replace(".accdb", "-compacted.accdb");
+ try
+ {
+ using (var connection = AceTestDatabase.Open(path))
+ {
+ Exec(connection, "CREATE TABLE CB (Id LONG)");
+ for (int i = 0; i < 20; i++) Exec(connection, $"INSERT INTO CB (Id) VALUES ({i})");
+ }
+ int afterWrites = Counter(path, Slot1);
+ Assert.True(afterWrites > 256, $"expected the fixture's counter to be well past a fresh 256, got {afterWrites}");
+
+ // Reopening does not reset it.
+ using (var connection = AceTestDatabase.Open(path))
+ Exec(connection, "INSERT INTO CB (Id) VALUES (999)");
+ Assert.True(Counter(path, Slot1) >= afterWrites);
+
+ // Compacting writes a whole new file, whose slot starts at the idle value.
+ File.Delete(compacted);
+ Invoke(engine, "CompactDatabase", path, compacted);
+ Assert.Equal(256, Counter(compacted, Slot1));
+ }
+ finally
+ {
+ TemporaryDatabase.Delete(path);
+ try { File.Delete(compacted); } catch (Exception) { }
+ }
+ }
+
+ private static object? DaoEngine()
+ {
+ foreach (int n in new[] { 170, 160, 150, 140, 130, 120 })
+ {
+ Type? type = Type.GetTypeFromProgID($"DAO.DBEngine.{n}");
+ if (type is null) continue;
+ try { return Activator.CreateInstance(type); } catch (Exception) { }
+ }
+ return null;
+ }
+
+ private static object? Invoke(object target, string member, params object?[] args) =>
+ target.GetType().InvokeMember(member, System.Reflection.BindingFlags.InvokeMethod, null, target, args);
+
+ /// The slot's 16-bit little-endian value, read while ACE still holds the file open.
+ private static int Counter(string path, int offset)
+ {
+ byte[] pair = new byte[2];
+ using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+ stream.Seek(offset, SeekOrigin.Begin);
+ stream.ReadExactly(pair);
+ return pair[0] | (pair[1] << 8);
+ }
+
+ /// The first eight slots (0xE00..0xE0F).
+ private static byte[] Slots(string path)
+ {
+ byte[] slots = new byte[16];
+ using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+ stream.Seek(0xE00, SeekOrigin.Begin);
+ stream.ReadExactly(slots);
+ return slots;
+ }
+
+ private static void Exec(OleDbConnection connection, string sql)
+ {
+ using OleDbCommand command = connection.CreateCommand();
+ command.CommandText = sql;
+ command.ExecuteNonQuery();
+ }
+
+ private static void Scalar(OleDbConnection connection, string sql)
+ {
+ using OleDbCommand command = connection.CreateCommand();
+ command.CommandText = sql;
+ command.ExecuteScalar();
+ }
+}
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/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/ContractionProbeTest.cs b/test/LibRed.Core.Tests/ContractionProbeTest.cs
new file mode 100644
index 00000000..42c948da
--- /dev/null
+++ b/test/LibRed.Core.Tests/ContractionProbeTest.cs
@@ -0,0 +1,449 @@
+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åö",
+ ]),
+ ];
+
+ // 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.
+ // 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); }
+ }
+
+ // 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()
+ {
+ 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()
+ {
+ 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()
+ {
+ 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/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/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();
+ }
+}
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/DaoLocaleCollationProbeTest.cs b/test/LibRed.Core.Tests/DaoLocaleCollationProbeTest.cs
new file mode 100644
index 00000000..0815a00a
--- /dev/null
+++ b/test/LibRed.Core.Tests/DaoLocaleCollationProbeTest.cs
@@ -0,0 +1,219 @@
+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
+ // 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]
+ 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/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/Data/Bosnian.accdb b/test/LibRed.Core.Tests/Data/Bosnian.accdb
new file mode 100644
index 00000000..8b114117
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Bosnian.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Croatian.accdb b/test/LibRed.Core.Tests/Data/Croatian.accdb
new file mode 100644
index 00000000..02033df5
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Croatian.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/CroatianLegacy.accdb b/test/LibRed.Core.Tests/Data/CroatianLegacy.accdb
new file mode 100644
index 00000000..482a306b
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/CroatianLegacy.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Czech.accdb b/test/LibRed.Core.Tests/Data/Czech.accdb
new file mode 100644
index 00000000..d24a9ce2
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Czech.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Estonian.accdb b/test/LibRed.Core.Tests/Data/Estonian.accdb
new file mode 100644
index 00000000..a42071f1
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Estonian.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/French.accdb b/test/LibRed.Core.Tests/Data/French.accdb
new file mode 100644
index 00000000..5b51c149
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/French.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/GeorgianModern.accdb b/test/LibRed.Core.Tests/Data/GeorgianModern.accdb
new file mode 100644
index 00000000..3dfde7ad
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/GeorgianModern.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/GermanPhoneBook.accdb b/test/LibRed.Core.Tests/Data/GermanPhoneBook.accdb
new file mode 100644
index 00000000..8f4dd0cb
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/GermanPhoneBook.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Hungarian.accdb b/test/LibRed.Core.Tests/Data/Hungarian.accdb
new file mode 100644
index 00000000..b7521122
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Hungarian.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/HungarianTechnical.accdb b/test/LibRed.Core.Tests/Data/HungarianTechnical.accdb
new file mode 100644
index 00000000..f2b79e4d
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/HungarianTechnical.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Icelandic.accdb b/test/LibRed.Core.Tests/Data/Icelandic.accdb
new file mode 100644
index 00000000..b96b8d5b
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Icelandic.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Indic.accdb b/test/LibRed.Core.Tests/Data/Indic.accdb
new file mode 100644
index 00000000..b75ff1b4
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Indic.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Latvian.accdb b/test/LibRed.Core.Tests/Data/Latvian.accdb
new file mode 100644
index 00000000..9a6addd3
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Latvian.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Lithuanian.accdb b/test/LibRed.Core.Tests/Data/Lithuanian.accdb
new file mode 100644
index 00000000..f6abfde5
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Lithuanian.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Macedonian.accdb b/test/LibRed.Core.Tests/Data/Macedonian.accdb
new file mode 100644
index 00000000..b6f2467a
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Macedonian.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/NorwegianDanish.accdb b/test/LibRed.Core.Tests/Data/NorwegianDanish.accdb
new file mode 100644
index 00000000..31201ce1
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/NorwegianDanish.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Polish.accdb b/test/LibRed.Core.Tests/Data/Polish.accdb
new file mode 100644
index 00000000..d54c5ab2
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Polish.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Romanian.accdb b/test/LibRed.Core.Tests/Data/Romanian.accdb
new file mode 100644
index 00000000..2ebb999f
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Romanian.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/RomanianLegacy.accdb b/test/LibRed.Core.Tests/Data/RomanianLegacy.accdb
new file mode 100644
index 00000000..e97a09b7
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/RomanianLegacy.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Serbian.accdb b/test/LibRed.Core.Tests/Data/Serbian.accdb
new file mode 100644
index 00000000..11768d9a
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Serbian.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Slovak.accdb b/test/LibRed.Core.Tests/Data/Slovak.accdb
new file mode 100644
index 00000000..9493d198
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Slovak.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Slovenian.accdb b/test/LibRed.Core.Tests/Data/Slovenian.accdb
new file mode 100644
index 00000000..b1d8fac8
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Slovenian.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/SpanishModern.accdb b/test/LibRed.Core.Tests/Data/SpanishModern.accdb
new file mode 100644
index 00000000..25346b2f
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/SpanishModern.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/SpanishTraditional.accdb b/test/LibRed.Core.Tests/Data/SpanishTraditional.accdb
new file mode 100644
index 00000000..f1c09139
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/SpanishTraditional.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/SwedishFinnish.accdb b/test/LibRed.Core.Tests/Data/SwedishFinnish.accdb
new file mode 100644
index 00000000..efb15046
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/SwedishFinnish.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Thai.accdb b/test/LibRed.Core.Tests/Data/Thai.accdb
new file mode 100644
index 00000000..fa7bcf35
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Thai.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Turkish.accdb b/test/LibRed.Core.Tests/Data/Turkish.accdb
new file mode 100644
index 00000000..7bdfb8e4
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Turkish.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Ukrainian.accdb b/test/LibRed.Core.Tests/Data/Ukrainian.accdb
new file mode 100644
index 00000000..52383cb9
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Ukrainian.accdb differ
diff --git a/test/LibRed.Core.Tests/Data/Vietnamese.accdb b/test/LibRed.Core.Tests/Data/Vietnamese.accdb
new file mode 100644
index 00000000..bb197392
Binary files /dev/null and b/test/LibRed.Core.Tests/Data/Vietnamese.accdb differ
diff --git a/test/LibRed.Core.Tests/DatabaseCreatorTests.cs b/test/LibRed.Core.Tests/DatabaseCreatorTests.cs
index 46b2b3a2..a2e30559 100644
--- a/test/LibRed.Core.Tests/DatabaseCreatorTests.cs
+++ b/test/LibRed.Core.Tests/DatabaseCreatorTests.cs
@@ -19,8 +19,8 @@ public void Synthesized_page0_header_matches_a_real_file(string fixture)
var dp = db.DefinitionPage;
byte[] synth = DatabaseCreator.BuildDefinitionPage(
- dp.JetVersion, isAccdb: true, dp.CodePage, dp.DefaultCollationLcid,
- dp.DefaultCollationVersion, (dp.DatabaseCreationDate - new DateTime(1899, 12, 30)).TotalDays);
+ dp.JetVersion, isAccdb: true, dp.CodePage, dp.Collation,
+ (dp.DatabaseCreationDate - new DateTime(1899, 12, 30)).TotalDays);
// The whole page-0 header (0x00–0x9F: identifier, version, the masked field block, and the
// cleartext "4.0" tail) is reproduced byte-for-byte. (0xA0–0xDFF is zero; 0xE00+ is an
@@ -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,14 +66,15 @@ 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]
public void Synthesized_page0_round_trips_through_the_reader()
{
var created = new DateTime(2026, 7, 14, 12, 0, 0);
- byte[] page = DatabaseCreator.BuildDefinitionPage(0x02, isAccdb: true, 1252, 1033, 0, (created - new DateTime(1899, 12, 30)).TotalDays);
+ byte[] page = DatabaseCreator.BuildDefinitionPage(
+ 0x02, isAccdb: true, 1252, Collation.GeneralLegacy, (created - new DateTime(1899, 12, 30)).TotalDays);
var dp = new LibRed.Pages.DatabaseDefinitionPage();
dp.Read(new LibRed.IO.PageBuffer(page, 0), LibRed.Formats.JetFormatBase.FromVersionByte(0x02));
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/DateTime2KeyEncodingTests.cs b/test/LibRed.Core.Tests/DateTime2KeyEncodingTests.cs
new file mode 100644
index 00000000..11e184e2
--- /dev/null
+++ b/test/LibRed.Core.Tests/DateTime2KeyEncodingTests.cs
@@ -0,0 +1,101 @@
+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)
+ {
+ // 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
+ {
+ 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.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/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/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/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..d878507f
--- /dev/null
+++ b/test/LibRed.Core.Tests/GeneralV1CollationTests.cs
@@ -0,0 +1,252 @@
+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]);
+ }
+
+ // 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_unassigned_character_is_ignorable_as_ACE_stores_it()
+ {
+ Assert.Equal(Hex(Encode("ab", Collation.General)), Hex(Encode("ab", 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);
+ }
+
+ // Past the limit ACE keeps the first 508 bytes and replaces the rest with a checksum over what it
+ // dropped. These are ACE's own bytes, read back out of an index it wrote.
+ [Theory]
+ [InlineData(254, 'a', "F602")] // 253 'a' then 'z': the key would be 511 bytes
+ [InlineData(255, 'a', "DE26")]
+ [InlineData(129, '一', "8C8D")] // Han costs four primary bytes, so the cut comes far sooner
+ [InlineData(171, 'á', "2F70")]
+ public void A_key_past_the_limit_is_truncated_with_ACEs_checksum(int length, char unit, string checksum)
+ {
+ byte[] key = Encode(new string(unit, length - 1) + "z", Collation.General);
+ Assert.Equal(510, key.Length);
+ Assert.Equal(checksum, Hex(key)[^4..]);
+ }
+
+ // The one case that is refused rather than truncated: a discarded word-sort record cannot be verified,
+ // because the record is in the part ACE dropped and what it held is unobservable.
+ [Fact]
+ public void A_key_past_the_limit_holding_a_word_sort_record_is_refused()
+ {
+ var error = Assert.Throws(
+ () => 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 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()
+ {
+ 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);
+ 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
+ // 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)));
+ }
+
+ // 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()
+ {
+ 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/GlobalReferenceFreeMapTests.cs b/test/LibRed.Core.Tests/GlobalReferenceFreeMapTests.cs
index 5c8e4370..eeeb07e4 100644
--- a/test/LibRed.Core.Tests/GlobalReferenceFreeMapTests.cs
+++ b/test/LibRed.Core.Tests/GlobalReferenceFreeMapTests.cs
@@ -25,8 +25,8 @@ public void Allocate_and_free_through_a_reference_type_global_map()
// Page 0 — use a valid unencrypted header. A bare identifier/version leaves the masked database-key
// field invalid and makes PageChannel correctly treat this synthetic file as encrypted.
DatabaseCreator.BuildDefinitionPage(
- 0x02, isAccdb: true, codePage: 1252, collationLcid: 1033,
- collationVersion: 0, creationDays: 45000).CopyTo(file, 0);
+ 0x02, isAccdb: true, codePage: 1252, collation: LibRed.Catalog.Collation.GeneralLegacy,
+ creationDays: 45000).CopyTo(file, 0);
// Page 1 — a data page whose row 0 is a reference-type global free map: slot 0 → bitmap page 2,
// slot 1 → bitmap page 3. (The 69-byte record is packed at the page end, as ACE packs rows.)
@@ -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/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();
+ }
+}
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/LegacyJetPasswordTests.cs b/test/LibRed.Core.Tests/LegacyJetPasswordTests.cs
index 82a65a46..d1bdd818 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, collation: LibRed.Catalog.Collation.GeneralLegacy,
+ 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..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
@@ -31,6 +35,12 @@
+
+
+
+
+
@@ -48,25 +58,12 @@
PreserveNewest
-
-
- PreserveNewest
-
-
- PreserveNewest
-
-
- PreserveNewest
-
-
- PreserveNewest
-
-
- PreserveNewest
-
-
-
+
+
PreserveNewest
diff --git a/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs b/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs
new file mode 100644
index 00000000..cd70684a
--- /dev/null
+++ b/test/LibRed.Core.Tests/LocaleCollationAccessTests.cs
@@ -0,0 +1,188 @@
+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",
+ // 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",
+ // Thai, whose contraction is built as a rule rather than a table of entries.
+ "Thai",
+ ];
+
+ [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");
+
+ // 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
+ {
+ 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(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),
+ (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
+ ];
+ 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",
+ "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",
+ // 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",
+ // 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",
+ ]);
+ 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/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