diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 859d5c4..ba4cc2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,9 @@ jobs: restore-keys: | nuget-${{ runner.os }}- + - name: Verify vendored ANTLR tool JAR + run: test -f build/antlr/antlr4-4.13.1-complete.jar + - name: Restore run: dotnet restore OutWit.slnx @@ -49,7 +52,7 @@ jobs: run: dotnet build OutWit.slnx --configuration Release --no-restore - name: Test - run: dotnet test OutWit.slnx --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx" --results-directory TestResults + run: dotnet test OutWit.slnx --configuration Release --no-build --verbosity normal --filter "Category!=Performance" --logger "trx;LogFileName=test-results.trx" --results-directory TestResults - name: Upload Test Results if: always() diff --git a/Docs/WitSQL.md b/Docs/WitSQL.md index bc22b06..26dd2e2 100644 --- a/Docs/WitSQL.md +++ b/Docs/WitSQL.md @@ -918,6 +918,7 @@ WitSQL supports named and positional parameters: -- Named parameters SELECT * FROM Users WHERE Id = @UserId; SELECT * FROM Users WHERE Name = :name; +SELECT * FROM Users WHERE MigrationId = $id; -- Positional parameters SELECT * FROM Users WHERE Id = ?; diff --git a/Sources/Core/OutWit.Database.Core.Tests/LSM/LsmParallelWriterTests.cs b/Sources/Core/OutWit.Database.Core.Tests/LSM/LsmParallelWriterTests.cs index 36be874..159d411 100644 --- a/Sources/Core/OutWit.Database.Core.Tests/LSM/LsmParallelWriterTests.cs +++ b/Sources/Core/OutWit.Database.Core.Tests/LSM/LsmParallelWriterTests.cs @@ -348,6 +348,117 @@ public void DisposedWriterThrowsTest() #endregion + #region Shutdown Durability Tests + + // These lock in the drain-before-cancel contract: every buffer submitted to the + // merge channel must be durably written through to the store on shutdown, and + // awaited writes must complete successfully rather than be faulted/dropped by a + // premature cancellation. A large maxPendingBuffers guarantees every submit is + // accepted (never silently rejected by a full bounded channel), so the assertions + // are deterministic: anything missing means the shutdown path lost queued work. + + [Test] + public void DisposeDrainsAllQueuedBuffersTest() + { + var dir = Path.Combine(m_testDir, "dispose_drain_sync"); + var options = new LsmOptions { EnableWal = false, MemTableSizeLimit = 1024 * 1024 }; + + using var store = new StoreLsm(dir, options); + + const int count = 200; + var writer = new LsmParallelWriter(store, maxPendingBuffers: count * 2); + + // Queue one buffer per key; do NOT wait for the background merge to catch up. + for (int i = 0; i < count; i++) + { + writer.Put(ToBytes($"k{i:D5}"), ToBytes($"v{i:D5}")); + writer.FlushCurrentBuffer(); + } + + // Dispose must drain the whole queue before returning. + writer.Dispose(); + + for (int i = 0; i < count; i++) + Assert.That(store.Get(ToBytes($"k{i:D5}")), Is.EqualTo(ToBytes($"v{i:D5}")), $"Missing k{i:D5} after Dispose"); + } + + [Test] + public async Task DisposeAsyncDrainsAllQueuedBuffersTest() + { + var dir = Path.Combine(m_testDir, "dispose_drain_async"); + var options = new LsmOptions { EnableWal = false, MemTableSizeLimit = 1024 * 1024 }; + + using var store = new StoreLsm(dir, options); + + const int count = 200; + var writer = new LsmParallelWriter(store, maxPendingBuffers: count * 2); + + for (int i = 0; i < count; i++) + { + writer.Put(ToBytes($"k{i:D5}"), ToBytes($"v{i:D5}")); + writer.FlushCurrentBuffer(); + } + + await writer.DisposeAsync(); + + for (int i = 0; i < count; i++) + Assert.That(store.Get(ToBytes($"k{i:D5}")), Is.EqualTo(ToBytes($"v{i:D5}")), $"Missing k{i:D5} after DisposeAsync"); + } + + [Test] + public async Task DisposeCompletesAwaitedWritesSuccessfullyTest() + { + var dir = Path.Combine(m_testDir, "dispose_awaited"); + var options = new LsmOptions { EnableWal = false, MemTableSizeLimit = 1024 * 1024 }; + + using var store = new StoreLsm(dir, options); + + const int count = 50; + var writer = new LsmParallelWriter(store, maxPendingBuffers: count * 2); + + // Issue awaited writes but capture the tasks WITHOUT awaiting them yet, so they + // are still in flight when shutdown begins. WriteAsync completes synchronously + // on the un-full channel, so all buffers + completions are enqueued before the + // state machines yield at 'await completion.Task'. + var flushTasks = new List(); + for (int i = 0; i < count; i++) + { + writer.Put(ToBytes($"k{i:D5}"), ToBytes($"v{i:D5}")); + flushTasks.Add(writer.FlushCurrentBufferAsync()); + } + + await writer.DisposeAsync(); + + // Every awaited write must resolve successfully - not faulted or cancelled. + await Task.WhenAll(flushTasks); + Assert.That(flushTasks.All(t => t.IsCompletedSuccessfully), Is.True, "An awaited write did not complete successfully across shutdown"); + + for (int i = 0; i < count; i++) + Assert.That(store.Get(ToBytes($"k{i:D5}")), Is.EqualTo(ToBytes($"v{i:D5}")), $"Missing k{i:D5} after awaited write + Dispose"); + } + + [Test] + public void DisposeCompletesPromptlyWithoutHangingTest() + { + var dir = Path.Combine(m_testDir, "dispose_no_hang"); + var options = new LsmOptions { EnableWal = false, MemTableSizeLimit = 1024 * 1024 }; + + using var store = new StoreLsm(dir, options); + + var writer = new LsmParallelWriter(store, maxPendingBuffers: 500); + for (int i = 0; i < 100; i++) + { + writer.Put(ToBytes($"k{i:D5}"), ToBytes($"v{i:D5}")); + writer.FlushCurrentBuffer(); + } + + // The drain join is bounded; Dispose must return well within it. + var disposeTask = Task.Run(() => writer.Dispose()); + Assert.That(disposeTask.Wait(TimeSpan.FromSeconds(10)), Is.True, "Dispose did not complete within the bounded drain window"); + } + + #endregion + #region Integration Tests [Test] diff --git a/Sources/Core/OutWit.Database.Core/Indexes/IndexMetadataJsonContext.cs b/Sources/Core/OutWit.Database.Core/Indexes/IndexMetadataJsonContext.cs new file mode 100644 index 0000000..29fce02 --- /dev/null +++ b/Sources/Core/OutWit.Database.Core/Indexes/IndexMetadataJsonContext.cs @@ -0,0 +1,7 @@ +using System.Text.Json.Serialization; + +namespace OutWit.Database.Core.Indexes; + +[JsonSerializable(typeof(IndexMetadata))] +[JsonSerializable(typeof(List))] +internal sealed partial class IndexMetadataJsonContext : JsonSerializerContext; diff --git a/Sources/Core/OutWit.Database.Core/Indexes/IndexMetadataStore.cs b/Sources/Core/OutWit.Database.Core/Indexes/IndexMetadataStore.cs index a53da61..de722e5 100644 --- a/Sources/Core/OutWit.Database.Core/Indexes/IndexMetadataStore.cs +++ b/Sources/Core/OutWit.Database.Core/Indexes/IndexMetadataStore.cs @@ -56,7 +56,7 @@ public void SaveIndex(string name, bool isUnique) var metadata = new IndexMetadata { Name = name, IsUnique = isUnique }; var key = CreateKey(name); - var value = JsonSerializer.SerializeToUtf8Bytes(metadata); + var value = JsonSerializer.SerializeToUtf8Bytes(metadata, IndexMetadataJsonContext.Default.IndexMetadata); m_store.Put(key, value); @@ -84,7 +84,7 @@ public void SaveIndex(string name, bool isUnique) if (value == null) return null; - return JsonSerializer.Deserialize(value); + return JsonSerializer.Deserialize(value, IndexMetadataJsonContext.Default.IndexMetadata); } /// @@ -150,7 +150,7 @@ public async ValueTask SaveIndexAsync(string name, bool isUnique, CancellationTo var metadata = new IndexMetadata { Name = name, IsUnique = isUnique }; var key = CreateKey(name); - var value = JsonSerializer.SerializeToUtf8Bytes(metadata); + var value = JsonSerializer.SerializeToUtf8Bytes(metadata, IndexMetadataJsonContext.Default.IndexMetadata); await m_store.PutAsync(key, value, cancellationToken).ConfigureAwait(false); @@ -177,7 +177,7 @@ public async ValueTask SaveIndexAsync(string name, bool isUnique, CancellationTo if (value == null) return null; - return JsonSerializer.Deserialize(value); + return JsonSerializer.Deserialize(value, IndexMetadataJsonContext.Default.IndexMetadata); } /// @@ -244,7 +244,7 @@ private List LoadCatalog() try { - return JsonSerializer.Deserialize>(value) ?? []; + return JsonSerializer.Deserialize(value, IndexMetadataJsonContext.Default.ListString) ?? []; } catch { @@ -254,7 +254,7 @@ private List LoadCatalog() private void SaveCatalog(List catalog) { - var value = JsonSerializer.SerializeToUtf8Bytes(catalog); + var value = JsonSerializer.SerializeToUtf8Bytes(catalog, IndexMetadataJsonContext.Default.ListString); m_store.Put(CATALOG_KEY, value); } @@ -271,7 +271,7 @@ private async ValueTask> LoadCatalogAsync(CancellationToken cancell try { - return JsonSerializer.Deserialize>(value) ?? []; + return JsonSerializer.Deserialize(value, IndexMetadataJsonContext.Default.ListString) ?? []; } catch { @@ -281,7 +281,7 @@ private async ValueTask> LoadCatalogAsync(CancellationToken cancell private async ValueTask SaveCatalogAsync(List catalog, CancellationToken cancellationToken = default) { - var value = JsonSerializer.SerializeToUtf8Bytes(catalog); + var value = JsonSerializer.SerializeToUtf8Bytes(catalog, IndexMetadataJsonContext.Default.ListString); await m_store.PutAsync(CATALOG_KEY, value, cancellationToken).ConfigureAwait(false); } diff --git a/Sources/Core/OutWit.Database.Core/LSM/LsmMemTableFlusher.cs b/Sources/Core/OutWit.Database.Core/LSM/LsmMemTableFlusher.cs index 5019077..a353214 100644 --- a/Sources/Core/OutWit.Database.Core/LSM/LsmMemTableFlusher.cs +++ b/Sources/Core/OutWit.Database.Core/LSM/LsmMemTableFlusher.cs @@ -270,10 +270,9 @@ public void Dispose() m_disposed = true; m_flushChannel.Writer.Complete(); - m_cts.Cancel(); - Task.WaitAll(m_flushTasks, TimeSpan.FromSeconds(10)); + m_cts.Cancel(); m_cts.Dispose(); } @@ -287,10 +286,9 @@ public async ValueTask DisposeAsync() m_disposed = true; m_flushChannel.Writer.Complete(); - await m_cts.CancelAsync(); - await Task.WhenAll(m_flushTasks).WaitAsync(TimeSpan.FromSeconds(10)); + await m_cts.CancelAsync(); m_cts.Dispose(); } diff --git a/Sources/Core/OutWit.Database.Core/LSM/LsmParallelCompactor.cs b/Sources/Core/OutWit.Database.Core/LSM/LsmParallelCompactor.cs index 4718e31..80c89f5 100644 --- a/Sources/Core/OutWit.Database.Core/LSM/LsmParallelCompactor.cs +++ b/Sources/Core/OutWit.Database.Core/LSM/LsmParallelCompactor.cs @@ -272,10 +272,9 @@ public void Dispose() m_disposed = true; m_jobChannel.Writer.Complete(); - m_cts.Cancel(); - Task.WaitAll(m_workerTasks, TimeSpan.FromSeconds(30)); + m_cts.Cancel(); m_cts.Dispose(); } @@ -289,10 +288,9 @@ public async ValueTask DisposeAsync() m_disposed = true; m_jobChannel.Writer.Complete(); - await m_cts.CancelAsync(); - await Task.WhenAll(m_workerTasks).WaitAsync(TimeSpan.FromSeconds(30)); + await m_cts.CancelAsync(); m_cts.Dispose(); } diff --git a/Sources/Core/OutWit.Database.Core/LSM/LsmParallelWriter.cs b/Sources/Core/OutWit.Database.Core/LSM/LsmParallelWriter.cs index adfbe1c..b554a73 100644 --- a/Sources/Core/OutWit.Database.Core/LSM/LsmParallelWriter.cs +++ b/Sources/Core/OutWit.Database.Core/LSM/LsmParallelWriter.cs @@ -260,68 +260,68 @@ private async Task MergeLoopAsync() try { - while (!token.IsCancellationRequested) + while (true) { - // Wait for buffers with timeout for periodic flush - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token); - timeoutCts.CancelAfter(m_flushIntervalMs); - - try - { - if (await reader.WaitToReadAsync(timeoutCts.Token)) - { - // Collect multiple buffers for batch processing - var buffersToMerge = new List<(LsmWriteBuffer Buffer, TaskCompletionSource? Completion)>(); - - while (reader.TryRead(out var item)) - { - buffersToMerge.Add(item); - - // Limit batch size to avoid holding too many buffers - if (buffersToMerge.Count >= 16) - break; - } - - if (buffersToMerge.Count > 0) - { - MergeBuffersBatch(buffersToMerge); - } - } - } - catch (OperationCanceledException) when (!token.IsCancellationRequested) + // Wait for buffers with a periodic timeout. WaitToReadAsync returns + // false once the channel is completed AND fully drained, which is the + // clean-shutdown exit: Dispose calls Writer.Complete() and joins this + // task BEFORE cancelling the token (mirrors LsmMemTableFlusher / + // LsmParallelCompactor), so the final merges below run against a live + // store and every queued/awaited buffer is durably written, never + // dropped or faulted by a premature cancellation. + bool hasData; + using (var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token)) { - // Timeout - check for any remaining buffers - var buffersToMerge = new List<(LsmWriteBuffer Buffer, TaskCompletionSource? Completion)>(); - while (reader.TryRead(out var item)) + timeoutCts.CancelAfter(m_flushIntervalMs); + + try { - buffersToMerge.Add(item); + hasData = await reader.WaitToReadAsync(timeoutCts.Token); } - - if (buffersToMerge.Count > 0) + catch (OperationCanceledException) when (!token.IsCancellationRequested) { - MergeBuffersBatch(buffersToMerge); + // Periodic flush tick - merge anything queued, then keep waiting. + DrainPendingBuffers(reader); + continue; } } + + // Channel completed and empty -> clean shutdown. + if (!hasData) + break; + + // Collect a bounded batch and merge. + var buffersToMerge = new List<(LsmWriteBuffer Buffer, TaskCompletionSource? Completion)>(); + while (buffersToMerge.Count < 16 && reader.TryRead(out var item)) + buffersToMerge.Add(item); + + if (buffersToMerge.Count > 0) + MergeBuffersBatch(buffersToMerge); } } catch (OperationCanceledException) when (token.IsCancellationRequested) { - // Normal shutdown - drain remaining buffers - var buffersToMerge = new List<(LsmWriteBuffer Buffer, TaskCompletionSource? Completion)>(); - while (reader.TryRead(out var item)) - { - buffersToMerge.Add(item); - } - - if (buffersToMerge.Count > 0) - { - MergeBuffersBatch(buffersToMerge); - } + // Hard cancel - only happens if the drain join in Dispose timed out. } catch (ChannelClosedException) { - // Channel closed during shutdown + // Channel completed concurrently - nothing more to do. } + finally + { + // Safety net: merge any buffer that slipped in after Complete(). + DrainPendingBuffers(reader); + } + } + + private void DrainPendingBuffers(ChannelReader<(LsmWriteBuffer Buffer, TaskCompletionSource? Completion)> reader) + { + var buffersToMerge = new List<(LsmWriteBuffer Buffer, TaskCompletionSource? Completion)>(); + while (reader.TryRead(out var item)) + buffersToMerge.Add(item); + + if (buffersToMerge.Count > 0) + MergeBuffersBatch(buffersToMerge); } /// @@ -478,12 +478,20 @@ public void Dispose() if (m_disposed) return; m_disposed = true; - // Signal shutdown + // Stop accepting new buffers, then let the merge loop drain the queue and + // write it through to the (still-live) store before we cancel. Cancelling + // first would tear the merge loop down mid-drain and fault awaited writes. m_bufferChannel.Writer.Complete(); - m_cts.Cancel(); - // Wait for merge task - m_mergeTask.Wait(TimeSpan.FromSeconds(5)); + if (!m_mergeTask.Wait(TimeSpan.FromSeconds(5))) + { + // Drain is taking too long - force the loop to stop. + m_cts.Cancel(); + m_mergeTask.Wait(TimeSpan.FromSeconds(1)); + } + + // Idempotent: ensure the token is cancelled before it is disposed. + m_cts.Cancel(); // Dispose thread-local buffers foreach (var buffer in m_threadLocalBuffer.Values) @@ -504,12 +512,29 @@ public async ValueTask DisposeAsync() if (m_disposed) return; m_disposed = true; - // Signal shutdown + // Drain-before-cancel, same ordering as the synchronous Dispose. m_bufferChannel.Writer.Complete(); - await m_cts.CancelAsync(); - // Wait for merge task - await m_mergeTask.WaitAsync(TimeSpan.FromSeconds(5)); + try + { + await m_mergeTask.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (TimeoutException) + { + // Drain is taking too long - force the loop to stop. + await m_cts.CancelAsync(); + try + { + await m_mergeTask.WaitAsync(TimeSpan.FromSeconds(1)); + } + catch (TimeoutException) + { + // Give up waiting; cancellation has been requested. + } + } + + // Idempotent: ensure the token is cancelled before it is disposed. + await m_cts.CancelAsync(); // Dispose thread-local buffers foreach (var buffer in m_threadLocalBuffer.Values) diff --git a/Sources/Core/OutWit.Database.Core/OutWit.Database.Core.csproj b/Sources/Core/OutWit.Database.Core/OutWit.Database.Core.csproj index ba277a1..c4d002a 100644 --- a/Sources/Core/OutWit.Database.Core/OutWit.Database.Core.csproj +++ b/Sources/Core/OutWit.Database.Core/OutWit.Database.Core.csproj @@ -5,7 +5,7 @@ enable enable - 1.0.0 + 1.1.0 High-performance embedded key-value database engine for .NET. Features B+Tree and LSM-Tree storage engines, MVCC transactions, AES-256-GCM encryption, and full ACID compliance. OutWit;database;embedded;key-value;btree;lsm-tree;mvcc;transactions;encryption;acid;storage diff --git a/Sources/Engine/OutWit.Database.Parser.Tests/ExpressionParserTests.cs b/Sources/Engine/OutWit.Database.Parser.Tests/ExpressionParserTests.cs index 2744368..801278e 100644 --- a/Sources/Engine/OutWit.Database.Parser.Tests/ExpressionParserTests.cs +++ b/Sources/Engine/OutWit.Database.Parser.Tests/ExpressionParserTests.cs @@ -776,6 +776,16 @@ public void ParsePositionalParameterTest() Assert.That(param.ParameterType, Is.EqualTo(ParameterType.Positional)); } + [Test] + public void ParseDollarNamedParameterTest() + { + var expr = WitSql.ParseExpression("$id"); + Assert.That(expr, Is.InstanceOf()); + var param = (WitSqlExpressionParameter)expr; + Assert.That(param.ParameterType, Is.EqualTo(ParameterType.DollarNamed)); + Assert.That(param.Name, Is.EqualTo("id")); + } + [Test] public void ParseNumberedParameterTest() { @@ -786,6 +796,14 @@ public void ParseNumberedParameterTest() Assert.That(param.Position, Is.EqualTo(1)); } + [Test] + public void ParseStatementWithDollarNamedParametersTest() + { + var stmt = WitSql.ParseStatement( + "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = $id"); + Assert.That(stmt, Is.InstanceOf()); + } + [Test] public void ParseStatementWithNamedParametersTest() { diff --git a/Sources/Engine/OutWit.Database.Parser.Tests/Expressions/InSubqueryParserTests.cs b/Sources/Engine/OutWit.Database.Parser.Tests/Expressions/InSubqueryParserTests.cs new file mode 100644 index 0000000..1ea1cc1 --- /dev/null +++ b/Sources/Engine/OutWit.Database.Parser.Tests/Expressions/InSubqueryParserTests.cs @@ -0,0 +1,30 @@ +using OutWit.Database.Parser.Expressions; +using OutWit.Database.Parser.Statements; + +namespace OutWit.Database.Parser.Tests.Expressions; + +/// +/// SQLite-compat: ORDER BY / LIMIT in subqueries used by IN / NOT IN. +/// +[TestFixture] +public sealed class InSubqueryParserTests +{ + [Test] + public void ParseNotInSubqueryWithOrderByLimitTest() + { + var stmt = WitSql.ParseStatement( + "DELETE FROM t WHERE id NOT IN (SELECT id FROM t ORDER BY id DESC LIMIT @p0)"); + + Assert.That(stmt, Is.InstanceOf()); + var delete = (WitSqlStatementDelete)stmt; + Assert.That(delete.WhereClause, Is.Not.Null); + + var where = delete.WhereClause!; + Assert.That(where, Is.InstanceOf()); + var inExpr = (WitSqlExpressionIn)where; + Assert.That(inExpr.IsNot, Is.True); + Assert.That(inExpr.Subquery, Is.Not.Null); + Assert.That(inExpr.Subquery!.OrderByClause, Is.Not.Null); + Assert.That(inExpr.Subquery.LimitCount, Is.Not.Null); + } +} diff --git a/Sources/Engine/OutWit.Database.Parser/Expressions/WitSqlExpressionParameter.cs b/Sources/Engine/OutWit.Database.Parser/Expressions/WitSqlExpressionParameter.cs index 5d94e39..3d3ecd5 100644 --- a/Sources/Engine/OutWit.Database.Parser/Expressions/WitSqlExpressionParameter.cs +++ b/Sources/Engine/OutWit.Database.Parser/Expressions/WitSqlExpressionParameter.cs @@ -8,7 +8,7 @@ namespace OutWit.Database.Parser.Expressions; /// /// Represents a parameter placeholder in a SQL statement. -/// Supports named (@param, :param), positional (?), and numbered ($1) parameters. +/// Supports named (@param, :param, $param), positional (?), and numbered ($1) parameters. /// public class WitSqlExpressionParameter : WitSqlExpression { diff --git a/Sources/Engine/OutWit.Database.Parser/Grammars/WitSqlLexer.g4 b/Sources/Engine/OutWit.Database.Parser/Grammars/WitSqlLexer.g4 index e10297e..7e11917 100644 --- a/Sources/Engine/OutWit.Database.Parser/Grammars/WitSqlLexer.g4 +++ b/Sources/Engine/OutWit.Database.Parser/Grammars/WitSqlLexer.g4 @@ -529,6 +529,7 @@ IDENTIFIER PARAM_NAMED: '@' [a-zA-Z_] [a-zA-Z0-9_]*; PARAM_COLON: ':' [a-zA-Z_] [a-zA-Z0-9_]*; PARAM_POSITIONAL: '?'; +PARAM_DOLLAR_NAMED: '$' [a-zA-Z_] [a-zA-Z0-9_]*; PARAM_NUMBERED: '$' DIGIT+; // ============================================================================ diff --git a/Sources/Engine/OutWit.Database.Parser/Grammars/WitSqlParser.g4 b/Sources/Engine/OutWit.Database.Parser/Grammars/WitSqlParser.g4 index bbcd1da..2a9ef22 100644 --- a/Sources/Engine/OutWit.Database.Parser/Grammars/WitSqlParser.g4 +++ b/Sources/Engine/OutWit.Database.Parser/Grammars/WitSqlParser.g4 @@ -151,7 +151,7 @@ fromClause tableSource : tableName (AS? alias)? # simpleTableSource | tableSource joinType tableSource (ON expression)? # joinTableSource - | LPAREN selectStatement RPAREN AS alias # subqueryTableSource + | LPAREN queryExpression RPAREN AS alias # subqueryTableSource ; joinType @@ -474,8 +474,8 @@ expression | functionCall # functionCallExpr | parameter # parameterExpr | LPAREN expression RPAREN # parenExpr - | LPAREN selectStatement RPAREN # subqueryExpr - | NOT? EXISTS LPAREN selectStatement RPAREN # existsExpr + | LPAREN queryExpression RPAREN # subqueryExpr + | NOT? EXISTS LPAREN queryExpression RPAREN # existsExpr | (PLUS | MINUS | NOT | TILDE) expression # unaryExpr | expression (STAR | SLASH | PERCENT) expression # mulDivExpr | expression (PLUS | MINUS) expression # addSubExpr @@ -486,10 +486,10 @@ expression | expression (EQ | NE | NE2) expression # equalityExpr | expression IS NOT? NULL # isNullExpr | expression NOT? BETWEEN expression AND expression # betweenExpr - | expression NOT? IN LPAREN (expression (COMMA expression)* | selectStatement) RPAREN # inExpr + | expression NOT? IN LPAREN (expression (COMMA expression)* | queryExpression) RPAREN # inExpr | expression NOT? LIKE expression (ESCAPE expression)? # likeExpr | expression NOT? GLOB expression # globExpr - | expression comparisonOp (ANY | SOME | ALL) LPAREN selectStatement RPAREN # quantifiedExpr + | expression comparisonOp (ANY | SOME | ALL) LPAREN queryExpression RPAREN # quantifiedExpr | expression AND expression # andExpr | expression OR expression # orExpr | CASE expression? (WHEN expression THEN expression)+ (ELSE expression)? END # caseExpr @@ -509,6 +509,7 @@ collationName parameter : PARAM_NAMED # namedParameter | PARAM_COLON # colonParameter + | PARAM_DOLLAR_NAMED # dollarNamedParameter | PARAM_POSITIONAL # positionalParameter | PARAM_NUMBERED # numberedParameter ; diff --git a/Sources/Engine/OutWit.Database.Parser/OutWit.Database.Parser.csproj b/Sources/Engine/OutWit.Database.Parser/OutWit.Database.Parser.csproj index 41fcb97..adee8a3 100644 --- a/Sources/Engine/OutWit.Database.Parser/OutWit.Database.Parser.csproj +++ b/Sources/Engine/OutWit.Database.Parser/OutWit.Database.Parser.csproj @@ -5,8 +5,9 @@ enable enable $(MSBuildProjectDirectory)\MakeInternal.ps1 + <_AntlrVendoredJar>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../../../build/antlr/antlr4-4.13.1-complete.jar')) - 1.0.0 + 1.1.0 SQL parser for WitDatabase. ANTLR4-based parser for WitSQL dialect with full SQL-92 compatibility and .NET type extensions. OutWit;database;sql;parser;antlr;witsql;query;syntax @@ -17,10 +18,21 @@ + CSharp OutWit.Database.Parser.Generated + $(_AntlrVendoredJar) diff --git a/Sources/Engine/OutWit.Database.Parser/Schema/Types/ParameterType.cs b/Sources/Engine/OutWit.Database.Parser/Schema/Types/ParameterType.cs index 339abb8..234502c 100644 --- a/Sources/Engine/OutWit.Database.Parser/Schema/Types/ParameterType.cs +++ b/Sources/Engine/OutWit.Database.Parser/Schema/Types/ParameterType.cs @@ -20,6 +20,11 @@ public enum ParameterType /// Positional, + /// + /// SQLite-style named parameter with $ prefix: $paramName (not $1, $2). + /// + DollarNamed, + /// /// Numbered parameter: $1, $2, etc. /// diff --git a/Sources/Engine/OutWit.Database.Parser/Serializers/WitSqlExpressionSerializer.cs b/Sources/Engine/OutWit.Database.Parser/Serializers/WitSqlExpressionSerializer.cs index 254acce..8b5ef7e 100644 --- a/Sources/Engine/OutWit.Database.Parser/Serializers/WitSqlExpressionSerializer.cs +++ b/Sources/Engine/OutWit.Database.Parser/Serializers/WitSqlExpressionSerializer.cs @@ -359,6 +359,7 @@ public string VisitExpressionParameter(WitSqlExpressionParameter node) { ParameterType.Named => $"@{node.Name}", ParameterType.Colon => $":{node.Name}", + ParameterType.DollarNamed => $"${node.Name}", ParameterType.Positional => "?", ParameterType.Numbered => $"${node.Position}", _ => throw new NotSupportedException($"Unsupported parameter type: {node.ParameterType}") diff --git a/Sources/Engine/OutWit.Database.Parser/Visitor/WitSqlVisitor.DML.cs b/Sources/Engine/OutWit.Database.Parser/Visitor/WitSqlVisitor.DML.cs index 25f7055..336162d 100644 --- a/Sources/Engine/OutWit.Database.Parser/Visitor/WitSqlVisitor.DML.cs +++ b/Sources/Engine/OutWit.Database.Parser/Visitor/WitSqlVisitor.DML.cs @@ -206,7 +206,7 @@ private TableSource VisitTableSource(WitSqlParser.TableSourceContext context) }, WitSqlParser.SubqueryTableSourceContext sub => new TableSourceSubquery { - Subquery = VisitSelectStatement(sub.selectStatement()), + Subquery = VisitQueryExpression(sub.queryExpression()), Alias = NormalizeIdentifier(sub.alias().GetText()) }, _ => throw new InvalidOperationException($"Unknown table source type: {context.GetType()}") diff --git a/Sources/Engine/OutWit.Database.Parser/Visitor/WitSqlVisitor.Expressions.cs b/Sources/Engine/OutWit.Database.Parser/Visitor/WitSqlVisitor.Expressions.cs index a3d0a0b..5248e0e 100644 --- a/Sources/Engine/OutWit.Database.Parser/Visitor/WitSqlVisitor.Expressions.cs +++ b/Sources/Engine/OutWit.Database.Parser/Visitor/WitSqlVisitor.Expressions.cs @@ -23,13 +23,13 @@ public WitSqlExpression VisitExpression(WitSqlParser.ExpressionContext context) { Line = sub.Start.Line, Column = sub.Start.Column, - Query = VisitSelectStatement(sub.selectStatement()) + Query = VisitQueryExpression(sub.queryExpression()) }, WitSqlParser.ExistsExprContext exists => new WitSqlExpressionExists { Line = exists.Start.Line, Column = exists.Start.Column, - Query = VisitSelectStatement(exists.selectStatement()), + Query = VisitQueryExpression(exists.queryExpression()), IsNot = exists.NOT() != null }, WitSqlParser.UnaryExprContext unary => new WitSqlExpressionUnary @@ -100,10 +100,10 @@ public WitSqlExpression VisitExpression(WitSqlParser.ExpressionContext context) Line = inExpr.Start.Line, Column = inExpr.Start.Column, Expression = VisitExpression(inExpr.expression(0)), - Values = inExpr.selectStatement() == null + Values = inExpr.queryExpression() == null ? inExpr.expression().Skip(1).Select(VisitExpression).ToList() : null, - Subquery = inExpr.selectStatement() is { } inSelect ? VisitSelectStatement(inSelect) : null, + Subquery = inExpr.queryExpression() is { } inQuery ? VisitQueryExpression(inQuery) : null, IsNot = inExpr.NOT() != null }, WitSqlParser.LikeExprContext like => new WitSqlExpressionLike @@ -196,7 +196,7 @@ private WitSqlExpressionQuantified VisitQuantifiedExpression(WitSqlParser.Quanti Expression = VisitExpression(context.expression()), Operator = op, QuantifierType = quantifierType, - Subquery = VisitSelectStatement(context.selectStatement()) + Subquery = VisitQueryExpression(context.queryExpression()) }; } @@ -238,6 +238,13 @@ private WitSqlExpressionParameter VisitParameter(WitSqlParser.ParameterContext c ParameterType = ParameterType.Colon, Name = colon.GetText()[1..] // Remove : prefix }, + WitSqlParser.DollarNamedParameterContext dollarNamed => new WitSqlExpressionParameter + { + Line = line, + Column = col, + ParameterType = ParameterType.DollarNamed, + Name = dollarNamed.GetText()[1..] // Remove $ prefix + }, WitSqlParser.PositionalParameterContext => new WitSqlExpressionParameter { Line = line, diff --git a/Sources/Engine/OutWit.Database.Tests/Engine/WitSqlEngineSqliteDollarNamedParameterTests.cs b/Sources/Engine/OutWit.Database.Tests/Engine/WitSqlEngineSqliteDollarNamedParameterTests.cs new file mode 100644 index 0000000..d33084c --- /dev/null +++ b/Sources/Engine/OutWit.Database.Tests/Engine/WitSqlEngineSqliteDollarNamedParameterTests.cs @@ -0,0 +1,111 @@ +namespace OutWit.Database.Tests; + +/// +/// SQLite-style $name parameters (Microsoft.Data.Sqlite / EF ADO compat). +/// +[TestFixture] +public sealed class WitSqlEngineSqliteDollarNamedParameterTests : WitSqlEngineTestsBase +{ + [Test] + public void SelectWhereDollarNamedParameterTest() + { + m_engine.Execute("CREATE TABLE history (MigrationId TEXT PRIMARY KEY)"); + m_engine.Execute( + "INSERT INTO history (MigrationId) VALUES (@seed)", + new Dictionary { ["seed"] = "20260612034124_Initial" }); + + var count = m_engine.ExecuteScalar( + """ + SELECT COUNT(*) FROM history + WHERE MigrationId = $id + """, + new Dictionary { ["$id"] = "20260612034124_Initial" }).AsInt64(); + + Assert.That(count, Is.EqualTo(1)); + } + + [Test] + public void NumberedParameterStillDistinctFromDollarNamedTest() + { + m_engine.Execute("CREATE TABLE t (slot INTEGER PRIMARY KEY, label TEXT)"); + m_engine.Execute("INSERT INTO t (slot, label) VALUES (1, 'first')"); + + var label = m_engine.ExecuteScalar( + "SELECT label FROM t WHERE slot = $1", + new Dictionary { ["$1"] = 1L }).AsString(); + + Assert.That(label, Is.EqualTo("first")); + } + + [Test] + public void DollarNamedResolvesBareCallerKeyTest() + { + m_engine.Execute("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)"); + m_engine.Execute("INSERT INTO t (id, label) VALUES (1, 'one')"); + + // SQL uses $id, caller registers the value under the bare name "id". + var label = m_engine.ExecuteScalar( + "SELECT label FROM t WHERE id = $id", + new Dictionary { ["id"] = 1L }).AsString(); + + Assert.That(label, Is.EqualTo("one")); + } + + [Test] + public void DollarNamedResolvesAtPrefixedCallerKeyTest() + { + m_engine.Execute("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)"); + m_engine.Execute("INSERT INTO t (id, label) VALUES (1, 'one')"); + + // SQL uses $id, caller registers the value under "@id". + var label = m_engine.ExecuteScalar( + "SELECT label FROM t WHERE id = $id", + new Dictionary { ["@id"] = 1L }).AsString(); + + Assert.That(label, Is.EqualTo("one")); + } + + [Test] + public void ColonNamedResolvesBareCallerKeyTest() + { + m_engine.Execute("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)"); + m_engine.Execute("INSERT INTO t (id, label) VALUES (1, 'one')"); + + // SQL uses :id, caller registers the value under the bare name "id". + var label = m_engine.ExecuteScalar( + "SELECT label FROM t WHERE id = :id", + new Dictionary { ["id"] = 1L }).AsString(); + + Assert.That(label, Is.EqualTo("one")); + } + + [Test] + public void ExactDollarKeyWinsOverNormalizedFallbackTest() + { + m_engine.Execute("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)"); + m_engine.Execute("INSERT INTO t (id, label) VALUES (1, 'one')"); + m_engine.Execute("INSERT INTO t (id, label) VALUES (2, 'two')"); + + // Both an exact "$id" and a bare "id" (normalized to "@id") are supplied. + // The exact placeholder key must win - the fallback must never override it. + var label = m_engine.ExecuteScalar( + "SELECT label FROM t WHERE id = $id", + new Dictionary { ["$id"] = 1L, ["id"] = 2L }).AsString(); + + Assert.That(label, Is.EqualTo("one")); + } + + [Test] + public void MixedPrefixPlaceholdersResolveBareCallerKeysTest() + { + m_engine.Execute("CREATE TABLE t (a INTEGER, b INTEGER, c INTEGER, label TEXT)"); + m_engine.Execute("INSERT INTO t (a, b, c, label) VALUES (1, 2, 3, 'row')"); + + // One statement mixing @-, $- and :-prefixed placeholders, all supplied as bare names. + var label = m_engine.ExecuteScalar( + "SELECT label FROM t WHERE a = @x AND b = $y AND c = :z", + new Dictionary { ["x"] = 1L, ["y"] = 2L, ["z"] = 3L }).AsString(); + + Assert.That(label, Is.EqualTo("row")); + } +} diff --git a/Sources/Engine/OutWit.Database.Tests/Engine/WitSqlEngineSqlitePruneTests.cs b/Sources/Engine/OutWit.Database.Tests/Engine/WitSqlEngineSqlitePruneTests.cs new file mode 100644 index 0000000..77a38fa --- /dev/null +++ b/Sources/Engine/OutWit.Database.Tests/Engine/WitSqlEngineSqlitePruneTests.cs @@ -0,0 +1,33 @@ +namespace OutWit.Database.Tests; + +/// +/// SQLite-style prune: DELETE WHERE id NOT IN (SELECT id ... ORDER BY id DESC LIMIT n). +/// +[TestFixture] +public sealed class WitSqlEngineSqlitePruneTests : WitSqlEngineTestsBase +{ + [Test] + public void DeleteNotInOrderedLimitedSubqueryTest() + { + m_engine.Execute("CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)"); + for (var i = 0; i < 5; i++) + { + m_engine.Execute("INSERT INTO t (v) VALUES (@v)", new Dictionary { ["v"] = $"r{i}" }); + } + + m_engine.Execute( + """ + DELETE FROM t + WHERE id NOT IN ( + SELECT id FROM t ORDER BY id DESC LIMIT @keep + ) + """, + new Dictionary { ["keep"] = 3L }); + + var count = m_engine.ExecuteScalar("SELECT COUNT(*) FROM t").AsInt64(); + Assert.That(count, Is.EqualTo(3)); + + var minId = m_engine.ExecuteScalar("SELECT MIN(id) FROM t").AsInt64(); + Assert.That(minId, Is.EqualTo(3)); + } +} diff --git a/Sources/Engine/OutWit.Database.Tests/Expressions/ExpressionEvaluatorCoreTests.cs b/Sources/Engine/OutWit.Database.Tests/Expressions/ExpressionEvaluatorCoreTests.cs index 542d2f8..ee80f00 100644 --- a/Sources/Engine/OutWit.Database.Tests/Expressions/ExpressionEvaluatorCoreTests.cs +++ b/Sources/Engine/OutWit.Database.Tests/Expressions/ExpressionEvaluatorCoreTests.cs @@ -217,6 +217,22 @@ public void EvaluateParameterPositionalTest() Assert.That(result.AsDouble(), Is.EqualTo(3.14).Within(0.001)); } + [Test] + public void EvaluateParameterDollarNamedTest() + { + m_context.Parameters["$id"] = WitSqlValue.FromText("20260612034124_Initial"); + var evaluator = new ExpressionEvaluator(m_context); + var param = new WitSqlExpressionParameter + { + ParameterType = ParameterType.DollarNamed, + Name = "id" + }; + + var result = evaluator.Evaluate(param, CreateEmptyRow()); + + Assert.That(result.AsString(), Is.EqualTo("20260612034124_Initial")); + } + [Test] public void EvaluateParameterNumberedTest() { diff --git a/Sources/Engine/OutWit.Database.Tests/Performance/DmlPerformanceTests.cs b/Sources/Engine/OutWit.Database.Tests/Performance/DmlPerformanceTests.cs index 439a339..6b32d3b 100644 --- a/Sources/Engine/OutWit.Database.Tests/Performance/DmlPerformanceTests.cs +++ b/Sources/Engine/OutWit.Database.Tests/Performance/DmlPerformanceTests.cs @@ -12,6 +12,7 @@ namespace OutWit.Database.Tests.Performance; /// Tests single-row fast path and batch IN (...) fast path optimizations. /// [TestFixture] +[Category("Performance")] public class DmlPerformanceTests { #region Fields diff --git a/Sources/Engine/OutWit.Database.Tests/Performance/InsertPerformanceTests.cs b/Sources/Engine/OutWit.Database.Tests/Performance/InsertPerformanceTests.cs index ed93b16..1b12ebe 100644 --- a/Sources/Engine/OutWit.Database.Tests/Performance/InsertPerformanceTests.cs +++ b/Sources/Engine/OutWit.Database.Tests/Performance/InsertPerformanceTests.cs @@ -4,6 +4,7 @@ namespace OutWit.Database.Tests.Performance; /// Tests for INSERT performance and memory characteristics. /// [TestFixture] +[Category("Performance")] public class InsertPerformanceTests : PerformanceTestsBase { #region Constants diff --git a/Sources/Engine/OutWit.Database.Tests/Performance/Level3_ConstraintValidationTests.cs b/Sources/Engine/OutWit.Database.Tests/Performance/Level3_ConstraintValidationTests.cs index 4e0e824..a68e224 100644 --- a/Sources/Engine/OutWit.Database.Tests/Performance/Level3_ConstraintValidationTests.cs +++ b/Sources/Engine/OutWit.Database.Tests/Performance/Level3_ConstraintValidationTests.cs @@ -9,6 +9,7 @@ namespace OutWit.Database.Tests.Performance; /// These tests verify that implicit PK index provides O(n log n) UNIQUE constraint checking. /// [TestFixture] +[Category("Performance")] public class Level3_ConstraintValidationTests { #region Fields @@ -82,7 +83,7 @@ Value DOUBLE } // Verify linear growth (not quadratic) - // Time for 2000 should be roughly 4x time for 500 (2x scale = 4x time for O(n), 16x for O(n)) + // Time for 2000 should be roughly 4x time for 500 (2x scale = 4x time for O(n), 16x for O(n)) var ratio = times[3].Ms / times[1].Ms; TestContext.Out.WriteLine($" Scaling ratio (2000/500): {ratio:F2}x (linear=4x, quadratic=16x)"); @@ -218,15 +219,15 @@ Value DOUBLE TestContext.Out.WriteLine($" {count,5} rows: {ms,8:F2} ms ({ms / count:F4} ms/row)"); } - // Check for O(n log n) behavior (not O(n)) - // Compare 1000 to 100: linear = 10x, O(n log n) ? 13x, O(n) = 100x + // Check for O(n log n) behavior (not O(n)) + // Compare 1000 to 100: linear = 10x, O(n log n) ? 13x, O(n) = 100x var ratio = times[3].Ms / times[0].Ms; - TestContext.Out.WriteLine($" Scaling ratio (1000/100): {ratio:F2}x (linear=10x, O(n)=100x)"); + TestContext.Out.WriteLine($" Scaling ratio (1000/100): {ratio:F2}x (linear=10x, O(n)=100x)"); - // With implicit index, should be much better than O(n) + // With implicit index, should be much better than O(n) // Allow up to 50x to account for variability and JIT warmup in CI environments // Key point: this was 76x+ before implicit index implementation - Assert.That(ratio, Is.LessThan(50), "INSERT with implicit PK index should scale as O(n log n), not O(n)"); + Assert.That(ratio, Is.LessThan(50), "INSERT with implicit PK index should scale as O(n log n), not O(n)"); } /// diff --git a/Sources/Engine/OutWit.Database.Tests/Performance/Level3_SqlEngineTests.cs b/Sources/Engine/OutWit.Database.Tests/Performance/Level3_SqlEngineTests.cs index 86d99cf..328f2fb 100644 --- a/Sources/Engine/OutWit.Database.Tests/Performance/Level3_SqlEngineTests.cs +++ b/Sources/Engine/OutWit.Database.Tests/Performance/Level3_SqlEngineTests.cs @@ -10,6 +10,7 @@ namespace OutWit.Database.Tests.Performance; /// Focuses on SQL parsing, execution planning, and row operations. /// [TestFixture] +[Category("Performance")] public class Level3_SqlEngineTests { #region Fields diff --git a/Sources/Engine/OutWit.Database.Tests/Performance/MemoryLeakTests.cs b/Sources/Engine/OutWit.Database.Tests/Performance/MemoryLeakTests.cs index bb16186..bfcb691 100644 --- a/Sources/Engine/OutWit.Database.Tests/Performance/MemoryLeakTests.cs +++ b/Sources/Engine/OutWit.Database.Tests/Performance/MemoryLeakTests.cs @@ -4,6 +4,7 @@ namespace OutWit.Database.Tests.Performance; /// Tests to verify there are no memory leaks in repeated operations. /// [TestFixture] +[Category("Performance")] public class MemoryLeakTests : PerformanceTestsBase { #region Constants diff --git a/Sources/Engine/OutWit.Database.Tests/Performance/ProfilingTests.cs b/Sources/Engine/OutWit.Database.Tests/Performance/ProfilingTests.cs index 11b3cb3..1c63321 100644 --- a/Sources/Engine/OutWit.Database.Tests/Performance/ProfilingTests.cs +++ b/Sources/Engine/OutWit.Database.Tests/Performance/ProfilingTests.cs @@ -8,6 +8,7 @@ namespace OutWit.Database.Tests.Performance; /// Detailed profiling tests to identify specific bottlenecks. /// [TestFixture] +[Category("Performance")] public class ProfilingTests : PerformanceTestsBase { #region Constants diff --git a/Sources/Engine/OutWit.Database.Tests/Performance/SelectPerformanceTests.cs b/Sources/Engine/OutWit.Database.Tests/Performance/SelectPerformanceTests.cs index 924b4bb..f3aa7b0 100644 --- a/Sources/Engine/OutWit.Database.Tests/Performance/SelectPerformanceTests.cs +++ b/Sources/Engine/OutWit.Database.Tests/Performance/SelectPerformanceTests.cs @@ -5,6 +5,7 @@ namespace OutWit.Database.Tests.Performance; /// These tests verify that streaming works correctly and memory usage is bounded. /// [TestFixture] +[Category("Performance")] public class SelectPerformanceTests : PerformanceTestsBase { #region Constants diff --git a/Sources/Engine/OutWit.Database/Engine/WitSqlEngine.cs b/Sources/Engine/OutWit.Database/Engine/WitSqlEngine.cs index 4de83a2..8e349a7 100644 --- a/Sources/Engine/OutWit.Database/Engine/WitSqlEngine.cs +++ b/Sources/Engine/OutWit.Database/Engine/WitSqlEngine.cs @@ -183,7 +183,7 @@ private WitSqlResult ExecuteInternal(string sql, { foreach (var (key, value) in parameters) { - var paramName = key.StartsWith("@") ? key : $"@{key}"; + var paramName = WitSqlParameterKeys.ToContextKey(key); context.Parameters[paramName] = WitSqlValue.FromObject(value); } } diff --git a/Sources/Engine/OutWit.Database/Engine/WitSqlEngineStatement.cs b/Sources/Engine/OutWit.Database/Engine/WitSqlEngineStatement.cs index 0721f19..be9a79c 100644 --- a/Sources/Engine/OutWit.Database/Engine/WitSqlEngineStatement.cs +++ b/Sources/Engine/OutWit.Database/Engine/WitSqlEngineStatement.cs @@ -62,7 +62,7 @@ internal WitSqlEngineStatement(IDatabase database, IReadOnlyList> parameterSets, context.Parameters.Clear(); foreach (var (key, value) in paramSet) { - var paramName = key.StartsWith('@') ? key : $"@{key}"; + var paramName = WitSqlParameterKeys.ToContextKey(key); context.Parameters[paramName] = WitSqlValue.FromObject(value); } diff --git a/Sources/Engine/OutWit.Database/Engine/WitSqlParameterKeys.cs b/Sources/Engine/OutWit.Database/Engine/WitSqlParameterKeys.cs new file mode 100644 index 0000000..eda66d6 --- /dev/null +++ b/Sources/Engine/OutWit.Database/Engine/WitSqlParameterKeys.cs @@ -0,0 +1,15 @@ +namespace OutWit.Database.Engine; + +internal static class WitSqlParameterKeys +{ + internal static string ToContextKey(string name) + { + if (string.IsNullOrEmpty(name)) + return name; + + if (name.StartsWith('@') || name.StartsWith(':') || name.StartsWith('$')) + return name; + + return $"@{name}"; + } +} diff --git a/Sources/Engine/OutWit.Database/Expressions/ExpressionEvaluator.Core.cs b/Sources/Engine/OutWit.Database/Expressions/ExpressionEvaluator.Core.cs index df893fd..b4b02ed 100644 --- a/Sources/Engine/OutWit.Database/Expressions/ExpressionEvaluator.Core.cs +++ b/Sources/Engine/OutWit.Database/Expressions/ExpressionEvaluator.Core.cs @@ -117,6 +117,7 @@ private WitSqlValue EvaluateParameter(WitSqlExpressionParameter param) { ParameterType.Named => $"@{param.Name}", ParameterType.Colon => $":{param.Name}", + ParameterType.DollarNamed => $"${param.Name}", ParameterType.Positional => "?", ParameterType.Numbered => $"${param.Position}", _ => throw new NotSupportedException($"Parameter type not supported: {param.ParameterType}") @@ -125,9 +126,19 @@ private WitSqlValue EvaluateParameter(WitSqlExpressionParameter param) if (m_context.Parameters.TryGetValue(key, out var value)) return value; - // For named parameters, try without prefix as fallback - if (param.Name != null && m_context.Parameters.TryGetValue(param.Name, out value)) - return value; + // Prefix-agnostic fallback for named placeholders ($name / :name / @name). + // A caller may register the value under a bare name (which the engine + // normalizes to "@name") or under a prefix style different from the one used + // in the SQL. The exact-key match above always wins first, so this fallback + // only resolves when there is no exact match - it never overrides an explicit + // binding, so it cannot bind the wrong parameter. + if (param.Name != null) + { + if (m_context.Parameters.TryGetValue(param.Name, out value)) + return value; + if (m_context.Parameters.TryGetValue($"@{param.Name}", out value)) + return value; + } throw new KeyNotFoundException($"Parameter '{key}' not found"); } diff --git a/Sources/Engine/OutWit.Database/OutWit.Database.csproj b/Sources/Engine/OutWit.Database/OutWit.Database.csproj index 6e0a26b..8e4da98 100644 --- a/Sources/Engine/OutWit.Database/OutWit.Database.csproj +++ b/Sources/Engine/OutWit.Database/OutWit.Database.csproj @@ -5,7 +5,7 @@ enable enable - 1.0.1 + 1.1.0 SQL execution engine for WitDatabase. Full SQL support including JOINs, subqueries, CTEs, window functions, triggers, and 60+ built-in functions. OutWit;database;sql;engine;query;execution;cte;window-functions;triggers diff --git a/Sources/Providers/OutWit.Database.AdoNet.Tests/Parameter/WitDbDollarNamedParameterTests.cs b/Sources/Providers/OutWit.Database.AdoNet.Tests/Parameter/WitDbDollarNamedParameterTests.cs new file mode 100644 index 0000000..4c60ba1 --- /dev/null +++ b/Sources/Providers/OutWit.Database.AdoNet.Tests/Parameter/WitDbDollarNamedParameterTests.cs @@ -0,0 +1,37 @@ +using NUnit.Framework; + +namespace OutWit.Database.AdoNet.Tests.Parameter; + +/// +/// ADO.NET binding for SQLite-style $name SQL parameters. +/// +[TestFixture] +public sealed class WitDbDollarNamedParameterTests +{ + [Test] + public void CommandSelectWhereDollarNamedParameterTest() + { + using var connection = new WitDbConnection("Data Source=:memory:"); + connection.Open(); + + using (var setup = connection.CreateCommand()) + { + setup.CommandText = "CREATE TABLE history (MigrationId TEXT PRIMARY KEY)"; + setup.ExecuteNonQuery(); + setup.CommandText = "INSERT INTO history (MigrationId) VALUES ('20260612034124_Initial')"; + setup.ExecuteNonQuery(); + } + + using var command = connection.CreateCommand(); + command.CommandText = + """ + SELECT COUNT(*) FROM history + WHERE MigrationId = $id + """; + command.Parameters.Add(new WitDbParameter("$id", "20260612034124_Initial")); + + var count = Convert.ToInt64(command.ExecuteScalar()); + + Assert.That(count, Is.EqualTo(1)); + } +} diff --git a/Sources/Providers/OutWit.Database.AdoNet/OutWit.Database.AdoNet.csproj b/Sources/Providers/OutWit.Database.AdoNet/OutWit.Database.AdoNet.csproj index ef0d5bd..c3814f0 100644 --- a/Sources/Providers/OutWit.Database.AdoNet/OutWit.Database.AdoNet.csproj +++ b/Sources/Providers/OutWit.Database.AdoNet/OutWit.Database.AdoNet.csproj @@ -5,7 +5,7 @@ enable enable - 1.0.0 + 1.1.0 ADO.NET data provider for WitDatabase. Full System.Data compatibility with connection pooling, transactions, and parameterized queries. OutWit;database;ado-net;data-provider;sql;connection;transactions;dotnet diff --git a/Sources/Providers/OutWit.Database.AdoNet/WitDbConnection.cs b/Sources/Providers/OutWit.Database.AdoNet/WitDbConnection.cs index aa9c902..bef6fb0 100644 --- a/Sources/Providers/OutWit.Database.AdoNet/WitDbConnection.cs +++ b/Sources/Providers/OutWit.Database.AdoNet/WitDbConnection.cs @@ -527,7 +527,8 @@ public override string Database return DEFAULT_DATABASE_NAME; var options = new WitDbConnectionStringBuilder(m_connectionString); - return Path.GetFileNameWithoutExtension(options.DataSource) ?? DEFAULT_DATABASE_NAME; + var dataSource = options.DataSource.Replace('\\', Path.DirectorySeparatorChar); + return Path.GetFileNameWithoutExtension(dataSource) ?? DEFAULT_DATABASE_NAME; } } diff --git a/Sources/Providers/OutWit.Database.EntityFramework/OutWit.Database.EntityFramework.csproj b/Sources/Providers/OutWit.Database.EntityFramework/OutWit.Database.EntityFramework.csproj index ed13ac7..fbf9965 100644 --- a/Sources/Providers/OutWit.Database.EntityFramework/OutWit.Database.EntityFramework.csproj +++ b/Sources/Providers/OutWit.Database.EntityFramework/OutWit.Database.EntityFramework.csproj @@ -5,7 +5,7 @@ enable enable - 1.0.3 + 1.1.0 Entity Framework Core provider for WitDatabase. Full EF Core support including migrations, scaffolding, LINQ translation, and design-time services. OutWit;database;entity-framework;ef-core;orm;linq;migrations;scaffolding diff --git a/build/antlr/antlr4-4.13.1-complete.jar b/build/antlr/antlr4-4.13.1-complete.jar new file mode 100644 index 0000000..f539ab0 Binary files /dev/null and b/build/antlr/antlr4-4.13.1-complete.jar differ