diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 266f14fa..71dd839d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > bucket had collected since the `v2.1.1` tag — the statement-path literal scanner below — **shipped in > `[2.1.2]`**. New entries start here. +### Fixed + +- **A surplus value in a batched `INSERT … VALUES` is rejected instead of silently dropped — the last copy of the known-issue-7 truncation.** The prepared-insert fast path, the one `ExecuteBatchSQL` uses for bulk inserts, stopped counting a tuple at the table's column count: `INSERT INTO docs VALUES (1, 'a', 0,5, 21)` against a three-column table bound the first three values, discarded the rest and **succeeded**, where the statement path has rejected that shape since `[2.1.2]`. Both paths now throw, and the message names the table, the tuple and both counts; a *short* tuple keeps its documented meaning (the missing columns fall to `NULL`). Found by `tests/SharpCoreDB.Tests/PreparedInsertValuesParsingTests.cs`, which parses every case through **both** prepared parsers and asserts they agree — nothing covered either one before, which is how the two could drift. Verified on this tree: core **2118 / 0 failed**, and the §7 reproduction published in `docs/sharpcoredb-known-issues.md` still prints the fixed output for all five bound-value shapes. + +### Changed + +- **Internal: one VALUES splitter and one literal skipper instead of five copies.** `PreparedInsertStatement.ParseValues` and `ParseValuesToArray` carried identical splitting loops; they now share a `ValueRangeEnumerator` `ref struct` (allocation-free, one branch per character, identical behaviour). The two canonical-shape scanners in `Database.Batch.cs` re-implemented the quote machine a third and fourth time and now call `SqlLiteralScanner` through a `TrySkipLiteral` helper, and `PreparedStatement`'s `?`-position extractor — a byte-for-byte copy of `ParameterBinder.ExtractPositionalParameterPositions` — is gone. This is what the CI Sonar gate flagged (duplication above the 3 % ceiling on new lines). + ## [2.1.2] - 2026-10-26 > **What this release is.** A patch release with no API changes, cut from `v2.1.1..HEAD`, and a data-correctness diff --git a/src/SharpCoreDB/Database/Execution/Database.Batch.cs b/src/SharpCoreDB/Database/Execution/Database.Batch.cs index 0d15dc6e..8289d371 100644 --- a/src/SharpCoreDB/Database/Execution/Database.Batch.cs +++ b/src/SharpCoreDB/Database/Execution/Database.Batch.cs @@ -108,58 +108,26 @@ internal PreparedInsertStatement(string tableName, List columns, List ParseValues(ReadOnlySpan valuesClause) { var row = new Dictionary(Columns.Count); - - int valueStart = 0; + + // One splitter for both parsers (ValueRangeEnumerator): this method and ParseValuesToArray used to + // carry identical copies of it, so a fix in one could silently miss the other. + var ranges = new ValueRangeEnumerator(valuesClause); int valueIndex = 0; - int parenDepth = 0; - int bracketDepth = 0; // bracketed literals ([0.5, 0.25] — a VECTOR value) never split - for (int i = 0; i < valuesClause.Length && valueIndex < Columns.Count; i++) + while (ranges.MoveNext(out int start, out int end)) { - char c = valuesClause[i]; - - // Skip a whole literal through SqlLiteralScanner (a doubled quote is one quote and does not close - // the literal; a backslash is data), so a comma inside a value cannot split the row. - if (c == '\'') + // ✅ FIX: Strict bounds check before slicing to prevent ArgumentOutOfRangeException + if (valueIndex < Columns.Count && valueIndex < ColumnTypes.Count && start < end) { - int closing = SqlLiteralScanner.FindClosingQuote(valuesClause, i, out _); - if (closing < 0) - { - break; - } - - i = closing; - continue; + var valueSpan = valuesClause.Slice(start, end - start).Trim(); + var value = ParseValueFast(valueSpan, ColumnTypes[valueIndex]); + row[Columns[valueIndex]] = value ?? DBNull.Value; } - if (c == '(') parenDepth++; - else if (c == ')') parenDepth--; - else if (c == '[') bracketDepth++; - else if (c == ']' && bracketDepth > 0) bracketDepth--; - else if (c == ',' && parenDepth == 0 && bracketDepth == 0) - { - // ✅ FIX: Strict bounds check before Slice to prevent ArgumentOutOfRangeException - if (valueStart < i && valueIndex < ColumnTypes.Count) - { - var valueSpan = valuesClause.Slice(valueStart, i - valueStart).Trim(); - var value = ParseValueFast(valueSpan, ColumnTypes[valueIndex]); - row[Columns[valueIndex]] = value ?? DBNull.Value; - } - - valueStart = i + 1; - valueIndex++; - } + // Counted even when it is not stored: a surplus value has to be reported, not dropped. + valueIndex++; } - - // Parse last value - ✅ FIX: Strict bounds check (< instead of <=) to prevent ArgumentOutOfRangeException - if (valueIndex < Columns.Count && valueStart < valuesClause.Length) - { - var valueSpan = valuesClause.Slice(valueStart).Trim(); - var value = ParseValueFast(valueSpan, ColumnTypes[valueIndex]); - row[Columns[valueIndex]] = value ?? DBNull.Value; - valueIndex++; - } - + // ✅ FIX: Verify we parsed all expected columns to catch malformed SQL early if (valueIndex != Columns.Count) { @@ -167,7 +135,7 @@ public Dictionary ParseValues(ReadOnlySpan valuesClause) $"Column count mismatch in INSERT VALUES: expected {Columns.Count} values, but parsed {valueIndex}. " + $"Table '{TableName}' requires columns: {string.Join(", ", Columns)}"); } - + return row; } @@ -182,45 +150,19 @@ public object[] ParseValuesToArray(ReadOnlySpan valuesClause) { var values = new object[Columns.Count]; - int valueStart = 0; + // Same splitter as ParseValues (ValueRangeEnumerator) — the two bodies used to be copies of each other. + var ranges = new ValueRangeEnumerator(valuesClause); int valueIndex = 0; - int parenDepth = 0; - int bracketDepth = 0; - for (int i = 0; i < valuesClause.Length && valueIndex < Columns.Count; i++) + while (ranges.MoveNext(out int start, out int end)) { - char c = valuesClause[i]; - - // Whole literals are skipped (SqlLiteralScanner is the single quote definition), so a comma or a - // parenthesis inside a value cannot end it — and a bracketed VECTOR literal stays together. - if (c == '\'') + if (valueIndex < Columns.Count && valueIndex < ColumnTypes.Count && start < end) { - int closing = SqlLiteralScanner.FindClosingQuote(valuesClause, i, out _); - if (closing < 0) - { - break; - } - - i = closing; - continue; + var valueSpan = valuesClause.Slice(start, end - start).Trim(); + values[valueIndex] = ParseValueFast(valueSpan, ColumnTypes[valueIndex]) ?? DBNull.Value; } - if (c == '(') parenDepth++; - else if (c == ')') parenDepth--; - else if (c == '[') bracketDepth++; - else if (c == ']' && bracketDepth > 0) bracketDepth--; - else if (c == ',' && parenDepth == 0 && bracketDepth == 0) - { - CommitParsedValue(valuesClause, i, ref valueStart, valueIndex, values); - valueIndex++; - } - } - - // Parse last value - if (valueIndex < Columns.Count && valueStart < valuesClause.Length) - { - var valueSpan = valuesClause.Slice(valueStart).Trim(); - values[valueIndex] = ParseValueFast(valueSpan, ColumnTypes[valueIndex]) ?? DBNull.Value; + // Counted even when it is not stored: a surplus value has to be reported, not dropped. valueIndex++; } @@ -235,15 +177,104 @@ public object[] ParseValuesToArray(ReadOnlySpan valuesClause) return values; } - private void CommitParsedValue(ReadOnlySpan valuesClause, int commaIndex, ref int valueStart, int valueIndex, object[] values) - { - if (valueStart < commaIndex && valueIndex < ColumnTypes.Count) - { - var valueSpan = valuesClause.Slice(valueStart, commaIndex - valueStart).Trim(); - values[valueIndex] = ParseValueFast(valueSpan, ColumnTypes[valueIndex]) ?? DBNull.Value; - } + /// + /// Splits a VALUES clause into raw (untrimmed) value ranges at top-level commas, so + /// and share one splitter instead of each carrying + /// a copy of the loop (they had drifted into two identical bodies). + /// + /// A quoted literal is skipped as a whole through , the single quote + /// definition — a doubled quote is one quote and does not close the literal, a backslash is data — so a comma + /// inside a value cannot split the row. A parenthesised list and a bracketed VECTOR literal + /// ([0.5, 0.25]) stay together as well. + /// + /// + /// A ref struct on purpose: the prepared-insert path must not allocate per row. + /// + /// + private ref struct ValueRangeEnumerator + { + private readonly ReadOnlySpan _clause; + private int _scan; + private int _valueStart; + private int _parenDepth; + private int _bracketDepth; + + internal ValueRangeEnumerator(ReadOnlySpan clause) + { + _clause = clause; + _scan = 0; + _valueStart = 0; + _parenDepth = 0; + _bracketDepth = 0; + } + + /// + /// Yields the next value as a [start, end) slice of the clause, or false once the clause is + /// consumed. The text after the last top-level comma is yielded as the final value; an empty clause + /// yields nothing. + /// + /// Start index of the value in the clause. + /// Index just past the value. + /// true when a value was yielded. + internal bool MoveNext(out int start, out int end) + { + for (int i = _scan; i < _clause.Length; i++) + { + char c = _clause[i]; + + if (c == '\'') + { + int closing = SqlLiteralScanner.FindClosingQuote(_clause, i, out _); + if (closing < 0) + { + _scan = _clause.Length; + break; // unterminated literal: the rest of the clause cannot be split + } + + i = closing; + continue; + } + + if (c == '(') + { + _parenDepth++; + } + else if (c == ')') + { + _parenDepth--; + } + else if (c == '[') + { + _bracketDepth++; + } + else if (c == ']' && _bracketDepth > 0) + { + _bracketDepth--; + } + else if (c == ',' && _parenDepth == 0 && _bracketDepth == 0) + { + start = _valueStart; + end = i; + _valueStart = i + 1; + _scan = i + 1; + return true; + } + } + + _scan = _clause.Length; + + if (_valueStart < _clause.Length) + { + start = _valueStart; + end = _clause.Length; + _valueStart = _clause.Length; + return true; + } - valueStart = commaIndex + 1; + start = 0; + end = 0; + return false; + } } /// @@ -849,33 +880,19 @@ private static bool IsInsertStatement(string sql) } int v0 = i; - bool inString = false; - char quote = '\0'; while (i < s.Length) { char ch = s[i]; - if (inString) + + if (ch is '\'' or '"') { - if (ch == quote) + // SqlLiteralScanner is the one quote definition (doubling honoured, a backslash is data), so these + // canonical-shape scanners do not carry a second copy of the rules. + if (!TrySkipLiteral(s, ref i)) { - if (i + 1 < s.Length && s[i + 1] == quote) - { - i += 2; // escaped '' inside a string literal - continue; - } - - inString = false; + return false; // unterminated string literal } - i++; - continue; - } - - if (ch is '\'' or '"') - { - inString = true; - quote = ch; - i++; continue; } @@ -887,11 +904,6 @@ private static bool IsInsertStatement(string sql) i++; } - if (inString) - { - return false; // unterminated string literal - } - var whereVal = s[v0..].Trim(); if (whereVal.IsEmpty) { @@ -913,6 +925,26 @@ private static bool TryConsumeWhitespace(ReadOnlySpan s, ref int i) return i > start; } + /// + /// Skips the quoted literal that starts at , leaving just past its + /// closing quote. Returns false when the literal is unterminated. The quote rules themselves live in + /// , so the canonical-shape scanners call this instead of re-implementing them. + /// + /// The statement text. + /// Index of the opening quote; advanced past the closing quote on success. + /// true when a complete literal was skipped. + private static bool TrySkipLiteral(ReadOnlySpan s, ref int i) + { + int closing = SqlLiteralScanner.FindClosingQuote(s, i, out _); + if (closing < 0) + { + return false; + } + + i = closing + 1; + return true; + } + private static bool TryConsumeKeyword(ReadOnlySpan s, ref int i, string keyword) { if (i + keyword.Length > s.Length) @@ -985,34 +1017,18 @@ private static bool TryReadLiteralUntilWhere(ReadOnlySpan s, ref int i, ou { value = default; int v0 = i; - bool inString = false; - char quote = '\0'; while (i < s.Length) { char ch = s[i]; - if (inString) + + if (ch is '\'' or '"') { - if (ch == quote) + if (!TrySkipLiteral(s, ref i)) { - if (i + 1 < s.Length && s[i + 1] == quote) - { - i += 2; - continue; - } - - inString = false; + return false; // unterminated string literal -> not canonical } - i++; - continue; - } - - if (ch is '\'' or '"') - { - inString = true; - quote = ch; - i++; continue; } diff --git a/src/SharpCoreDB/Services/PreparedStatements.cs b/src/SharpCoreDB/Services/PreparedStatements.cs index ca22eae9..26877302 100644 --- a/src/SharpCoreDB/Services/PreparedStatements.cs +++ b/src/SharpCoreDB/Services/PreparedStatements.cs @@ -62,8 +62,9 @@ internal PreparedStatement(CachedQueryPlan plan) { _plan = plan ?? throw new ArgumentNullException(nameof(plan)); - // Pre-compute parameter positions for fast binding - _parameterPositions = ExtractParameterPositions(_plan.Sql); + // Pre-compute parameter positions for fast binding. The one ?-scanner lives on ParameterBinder; this class + // used to carry a byte-for-byte copy of it, which is how the two could disagree about quotes. + _parameterPositions = ParameterBinder.ExtractPositionalParameterPositions(_plan.Sql); // Extract named parameters if present _namedParameters = ExtractNamedParameters(_plan.Sql); @@ -97,43 +98,6 @@ public string BindParameters(Dictionary parameters) return ParameterBinder.BindNamedParameters(_plan.Sql, _namedParameters, parameters); } - /// Extracts positions of ? placeholders in SQL. - /// - /// Quoted literals are skipped as a whole through , the single quote - /// definition: a doubled quote is one quote and does not close the literal, and a backslash is data. The - /// previous form toggled on every quote whose predecessor was not a backslash, so 'it''s ?' was read - /// as "outside the literal" after the doubled quote and its ? counted as a placeholder. - /// - private static int[] ExtractParameterPositions(string sql) - { - var positions = new List(); - - for (int i = 0; i < sql.Length; i++) - { - char c = sql[i]; - - if (c is '\'' or '"') - { - int closing = SqlLiteralScanner.FindClosingQuote(sql, i, out _); - if (closing < 0) - { - break; // unterminated literal: nothing after it is a placeholder - } - - i = closing; - continue; - } - - // Count ? outside strings - if (c == '?') - { - positions.Add(i); - } - } - - return positions.ToArray(); - } - /// Extracts named parameters (@paramName) from SQL. private static Dictionary? ExtractNamedParameters(string sql) { @@ -143,7 +107,7 @@ private static int[] ExtractParameterPositions(string sql) { char c = sql[i]; - // Skip a whole literal (see ExtractParameterPositions for the rule). + // Skip a whole literal (see ParameterBinder.ExtractPositionalParameterPositions for the rule). if (c is '\'' or '"') { int closing = SqlLiteralScanner.FindClosingQuote(sql, i, out _); @@ -430,6 +394,14 @@ internal static string Bind(string sql, Dictionary parameters) } /// Extracts positions of '?' placeholders from SQL, outside string literals. + /// + /// The single ?-scanner: and the binder both call this one — they used + /// to carry identical copies, which is how the two could drift apart. Quoted literals are skipped as a whole + /// through , the single quote definition: a doubled quote is one quote and does + /// not close the literal, and a backslash is data. The previous form toggled on every quote whose predecessor + /// was not a backslash, so 'it''s ?' was read as "outside the literal" after the doubled quote and its + /// ? counted as a placeholder. + /// internal static int[] ExtractPositionalParameterPositions(string sql) { var positions = new List(); diff --git a/tests/SharpCoreDB.Tests/DefaultEngineSelectionTests.cs b/tests/SharpCoreDB.Tests/DefaultEngineSelectionTests.cs index 855ccdc2..42fa8cb8 100644 --- a/tests/SharpCoreDB.Tests/DefaultEngineSelectionTests.cs +++ b/tests/SharpCoreDB.Tests/DefaultEngineSelectionTests.cs @@ -70,7 +70,9 @@ public void DefaultDatabase_NewPkTable_UsesColumnarFixedWidthAndContiguousDelete var stmts = new List(1000); for (int i = 1; i <= 1000; i++) { - stmts.Add($"INSERT INTO docs VALUES ({i}, 'user{i}', {i * 0.5})"); + // Invariant culture: {i * 0.5} renders as "0,5" on a Dutch machine, which is two SQL values, not one. + var score = (i * 0.5).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture); + stmts.Add($"INSERT INTO docs VALUES ({i}, 'user{i}', {score})"); } db.ExecuteBatchSQL(stmts); diff --git a/tests/SharpCoreDB.Tests/FixedWidthBulkUpdateTests.cs b/tests/SharpCoreDB.Tests/FixedWidthBulkUpdateTests.cs index 092a1d7b..f7af4868 100644 --- a/tests/SharpCoreDB.Tests/FixedWidthBulkUpdateTests.cs +++ b/tests/SharpCoreDB.Tests/FixedWidthBulkUpdateTests.cs @@ -123,7 +123,7 @@ public void GappedUpdates_FallBackToGenericLoop_AndStayCorrect() var db = CreateDb(); try { - db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL, age INTEGER)"); InsertDocs(db, 1, 1000); db.Flush(); @@ -158,7 +158,7 @@ public void RepeatedCommits_EngageAgain_WithCurrentBytes() var db = CreateDb(); try { - db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL, age INTEGER)"); InsertDocs(db, 1, 500); db.Flush(); @@ -185,7 +185,7 @@ public void IndexedColumnUpdate_FallsBack_AndRepointsIndex() var db = CreateDb(); try { - db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL, age INTEGER)"); db.ExecuteSQL("CREATE INDEX idx_docs_name ON docs(name)"); InsertDocs(db, 1, 800); db.Flush(); @@ -220,7 +220,7 @@ public void DefaultPlaintextConfig_EngagesBulkPath_AndStaysCorrect() var db = CreateDb(noEncrypt: false); try { - db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL, age INTEGER)"); InsertDocs(db, 1, 300); db.Flush(); @@ -247,7 +247,7 @@ public void PerRecordEncryption_UsesContiguousFastPath_AndPersists() IDatabase? db = CreateEncryptedDb(); try { - db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL)"); + db.ExecuteSQL("CREATE TABLE docs (id INTEGER PRIMARY KEY, name TEXT, score REAL, age INTEGER)"); InsertDocs(db, 1, 400); db.Flush(); diff --git a/tests/SharpCoreDB.Tests/FormatCompatPolicyTests.cs b/tests/SharpCoreDB.Tests/FormatCompatPolicyTests.cs index 78d37544..9b892f6f 100644 --- a/tests/SharpCoreDB.Tests/FormatCompatPolicyTests.cs +++ b/tests/SharpCoreDB.Tests/FormatCompatPolicyTests.cs @@ -42,7 +42,10 @@ private static void InsertRange(IDatabase db, int from, int to) var stmts = new List(to - from + 1); for (int i = from; i <= to; i++) { - stmts.Add($"INSERT INTO docs VALUES ({i}, 'user{i}', {i * 0.5})"); + // Invariant culture: {i * 0.5} renders as "0,5" on a Dutch machine, which is two SQL values, not one — + // and a four-value tuple for a three-column table is rejected, not silently truncated. + var score = (i * 0.5).ToString("0.0", System.Globalization.CultureInfo.InvariantCulture); + stmts.Add($"INSERT INTO docs VALUES ({i}, 'user{i}', {score})"); } db.ExecuteBatchSQL(stmts); diff --git a/tests/SharpCoreDB.Tests/PreparedInsertValuesParsingTests.cs b/tests/SharpCoreDB.Tests/PreparedInsertValuesParsingTests.cs new file mode 100644 index 00000000..0cfbd744 --- /dev/null +++ b/tests/SharpCoreDB.Tests/PreparedInsertValuesParsingTests.cs @@ -0,0 +1,93 @@ +// +// Copyright (c) 2026 MPCoreDeveloper. All rights reserved. +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace SharpCoreDB.Tests; + +using System; +using System.Globalization; +using Xunit; + +/// +/// Pins the value splitter behind the prepared-insert hot path +/// ( and +/// ). +/// +/// The two parsers used to carry their own copy of the same splitting loop — one returning a row dictionary, one a +/// column-ordered array — so a fix could land in one and silently miss the other. They now share a single +/// ref struct enumerator, and every case here asserts through that both parsers +/// produce the same values for the same clause. Re-forking the splitter fails these tests. +/// +/// +/// The rules pinned are the ones pins for the SQL path: a quoted literal is +/// skipped as a whole ('' is one quote of data, a backslash is data and can be the last character), a comma +/// inside quotes or inside a bracketed VECTOR literal does not split, a parenthesised list stays together, and +/// every value is trimmed. +/// +/// +public sealed class PreparedInsertValuesParsingTests +{ + private static Database.PreparedInsertStatement PrepareTwoColumns() + => new("t", ["id", "name"], [DataType.Integer, DataType.String]); + + /// + /// Parses the same clause with both parsers, asserts they agree, and returns the array's values. + /// + private static object[] ReadBoth(Database.PreparedInsertStatement prepared, string clause) + { + var row = prepared.ParseValues(clause); + var values = prepared.ParseValuesToArray(clause); + + Assert.Equal(prepared.Columns.Count, values.Length); + for (int i = 0; i < prepared.Columns.Count; i++) + { + Assert.Equal(row[prepared.Columns[i]], values[i]); + } + + return values; + } + + // Whitespace is trimmed around a value, never inside its quotes: ' padded ' is a value of four leading and + // four trailing spaces, exactly as SQL reads it. + [Theory] + [InlineData("7, 'a,b'", 7, "a,b")] + [InlineData("8, 'it''s, ok'", 8, "it's, ok")] + [InlineData("9, 'C:\\tmp\\'", 9, "C:\\tmp\\")] + [InlineData("10, '[0.5, 0.25]'", 10, "[0.5, 0.25]")] + [InlineData("11, (1,2)", 11, "(1,2)")] + [InlineData("12, ' padded ' ", 12, " padded ")] + public void BothParsers_SplitValuesIdentically(string clause, int expectedId, string expectedName) + { + var prepared = PrepareTwoColumns(); + + var values = ReadBoth(prepared, clause); + + Assert.Equal(expectedId, Convert.ToInt32(values[0], CultureInfo.InvariantCulture)); + Assert.Equal(expectedName, values[1]); + } + + [Fact] + public void BothParsers_KeepAnUnquotedNumberAsItself() + { + var prepared = PrepareTwoColumns(); + + var values = ReadBoth(prepared, " 42 , NULL "); + + Assert.Equal(42, Convert.ToInt32(values[0], CultureInfo.InvariantCulture)); + Assert.Equal(DBNull.Value, values[1]); + } + + [Theory] + [InlineData("'only a name'")] + [InlineData("1, 'a', 3")] + [InlineData("1,")] + [InlineData("")] + public void BothParsers_RejectAClauseThatDoesNotFillTheColumns(string clause) + { + var prepared = PrepareTwoColumns(); + + Assert.Throws(() => prepared.ParseValues(clause)); + Assert.Throws(() => prepared.ParseValuesToArray(clause)); + } +}