Skip to content

Commit a0d0167

Browse files
author
MPCoreDeveloper
committed
fix: resolve 5 of 6 known engine limitations - IS NULL/IS NOT NULL across all WHERE paths with DBNull, German collation IgnoreNonSpace, COALESCE/scalar func parsing, trailing token error recovery, LINQ enum Convert. Dispose improved but deadlock re-skipped. 1490 tests pass.
1 parent 92e46ae commit a0d0167

15 files changed

+475
-2104
lines changed

src/SharpCoreDB/CultureInfoCollation.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,11 @@ public int Compare(string? left, string? right, string localeName, bool ignoreCa
114114
if (right is null) return 1;
115115

116116
var compareInfo = GetCompareInfo(localeName);
117-
var options = ignoreCase ? CompareOptions.IgnoreCase : CompareOptions.None;
117+
// IgnoreNonSpace gives primary-level (base letter) comparison:
118+
// ß = ss in German, accented chars equivalent (é = e, etc.)
119+
var options = ignoreCase
120+
? CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace
121+
: CompareOptions.None;
118122
return compareInfo.Compare(left, right, options);
119123
}
120124

@@ -133,7 +137,11 @@ public bool Equals(string? left, string? right, string localeName, bool ignoreCa
133137
if (left is null || right is null) return false;
134138

135139
var compareInfo = GetCompareInfo(localeName);
136-
var options = ignoreCase ? CompareOptions.IgnoreCase : CompareOptions.None;
140+
// IgnoreNonSpace gives primary-level (base letter) comparison:
141+
// ß = ss in German, accented chars equivalent (é = e, etc.)
142+
var options = ignoreCase
143+
? CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace
144+
: CompareOptions.None;
137145
return compareInfo.Compare(left, right, options) == 0;
138146
}
139147

src/SharpCoreDB/DataStructures/Table.Scanning.cs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -144,15 +144,16 @@ private int FindPatternInRowData(ReadOnlySpan<byte> rowData, byte pattern)
144144

145145
/// <summary>
146146
/// Evaluates a WHERE clause against a row.
147-
/// Supports operators: equals, not equals, greater than, less than, greater or equal, less or equal
147+
/// Supports operators: equals, not equals, greater than, less than, greater or equal, less or equal,
148+
/// IS NULL, IS NOT NULL.
148149
/// </summary>
149150
private bool EvaluateWhere(Dictionary<string, object> row, string? where)
150151
{
151152
if (string.IsNullOrEmpty(where)) return true;
152-
153+
153154
var parts = where.Split(' ', StringSplitOptions.RemoveEmptyEntries);
154155
if (parts.Length < 3) return true;
155-
156+
156157
var columnName = parts[0].Trim('"', '[', ']', '`');
157158

158159
// ✅ FIX: Strip table alias prefix from column names (e.g., "u"."Username" → Username)
@@ -164,9 +165,26 @@ private bool EvaluateWhere(Dictionary<string, object> row, string? where)
164165
{
165166
columnName = columnName[(dotIdx + 1)..].Trim('"', '[', ']', '`');
166167
}
168+
169+
// Handle IS NOT NULL (4 tokens: column IS NOT NULL)
170+
if (parts.Length >= 4
171+
&& parts[1].Equals("IS", StringComparison.OrdinalIgnoreCase)
172+
&& parts[2].Equals("NOT", StringComparison.OrdinalIgnoreCase)
173+
&& parts[3].Equals("NULL", StringComparison.OrdinalIgnoreCase))
174+
{
175+
return row.TryGetValue(columnName, out var v) && v is not null && v is not DBNull;
176+
}
177+
178+
// Handle IS NULL (3 tokens: column IS NULL)
179+
if (parts[1].Equals("IS", StringComparison.OrdinalIgnoreCase)
180+
&& parts[2].Equals("NULL", StringComparison.OrdinalIgnoreCase))
181+
{
182+
return !row.TryGetValue(columnName, out var v) || v is null || v is DBNull;
183+
}
184+
167185
var op = parts[1];
168186
var value = parts[2].Trim('"').Trim((char)39);
169-
187+
170188
if (!row.TryGetValue(columnName, out var rowValue) || rowValue == null)
171189
return false;
172190

src/SharpCoreDB/Linq/GenericLinqToSqlTranslator.Core.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ private void Visit(Expression? expression)
7878
break;
7979

8080
case ExpressionType.Not:
81+
case ExpressionType.Convert:
82+
case ExpressionType.ConvertChecked:
8183
VisitUnary((UnaryExpression)expression);
8284
break;
8385

src/SharpCoreDB/Linq/GenericLinqToSqlTranslator.Expressions.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,24 @@ private void VisitUnary(UnaryExpression expression)
8080
Visit(expression.Operand);
8181
_sql.Append(')');
8282
}
83+
else if (expression.NodeType is ExpressionType.Convert or ExpressionType.ConvertChecked)
84+
{
85+
// Unwrap enum/numeric casts — visit the inner operand directly
86+
if (expression.Operand is ConstantExpression constant)
87+
{
88+
// Resolve enum values to their underlying integral value
89+
var value = constant.Value;
90+
if (value is not null && value.GetType().IsEnum)
91+
{
92+
value = System.Convert.ChangeType(value, Enum.GetUnderlyingType(value.GetType()));
93+
}
94+
VisitConstant(System.Linq.Expressions.Expression.Constant(value, expression.Type));
95+
}
96+
else
97+
{
98+
Visit(expression.Operand);
99+
}
100+
}
83101
else
84102
{
85103
Visit(expression.Operand);

src/SharpCoreDB/Services/EnhancedSqlParser.Expressions.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@ private ExpressionNode ParseComparisonExpression()
6363
{
6464
var left = ParsePrimaryExpression();
6565

66+
// Check for IS NULL / IS NOT NULL
67+
if (MatchKeyword("IS"))
68+
{
69+
bool isNot = MatchKeyword("NOT");
70+
if (MatchKeyword("NULL"))
71+
{
72+
return new BinaryExpressionNode
73+
{
74+
Position = _position,
75+
Left = left,
76+
Operator = isNot ? "IS NOT NULL" : "IS NULL",
77+
Right = new LiteralNode { Position = _position, Value = null }
78+
};
79+
}
80+
}
81+
6682
// Check for IN expression
6783
if (MatchKeyword("IN"))
6884
{

src/SharpCoreDB/Services/EnhancedSqlParser.Select.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,38 @@ private List<ColumnNode> ParseSelectColumns()
179179
}
180180

181181
// Parse table.column or column
182+
// First check for parenthesized expression (scalar subquery or grouped expression)
183+
var remaining = _sql.Substring(_position);
184+
if (remaining.TrimStart().StartsWith('('))
185+
{
186+
var expr = ParsePrimaryExpression();
187+
column.Expression = expr;
188+
column.Name = expr is SubqueryExpressionNode ? "(subquery)" : "(expr)";
189+
190+
if (MatchKeyword("AS"))
191+
column.Alias = ConsumeIdentifier();
192+
193+
return column;
194+
}
195+
196+
// Check for scalar function calls (COALESCE, IIF, NULLIF, etc.)
197+
var scalarFuncMatch = Regex.Match(
198+
remaining,
199+
@"^\s*(\w+)\s*\(",
200+
RegexOptions.IgnoreCase);
201+
if (scalarFuncMatch.Success)
202+
{
203+
// This is a scalar function — parse as expression
204+
var funcExpr = ParseFunctionCall();
205+
column.Expression = funcExpr;
206+
column.Name = funcExpr.FunctionName;
207+
208+
if (MatchKeyword("AS"))
209+
column.Alias = ConsumeIdentifier();
210+
211+
return column;
212+
}
213+
182214
var identifier = ConsumeIdentifier();
183215
if (identifier is null)
184216
return null;

src/SharpCoreDB/Services/EnhancedSqlParser.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public partial class EnhancedSqlParser(ISqlDialect? dialect = null)
5050
{
5151
var keyword = PeekKeyword();
5252

53-
return keyword?.ToUpperInvariant() switch
53+
var result = keyword?.ToUpperInvariant() switch
5454
{
5555
"SELECT" => ParseSelect(),
5656
"INSERT" => ParseInsert(),
@@ -60,6 +60,15 @@ public partial class EnhancedSqlParser(ISqlDialect? dialect = null)
6060
"ALTER" => ParseAlter(),
6161
_ => throw new InvalidOperationException($"Unsupported statement type: {keyword}")
6262
};
63+
64+
// Validate no unparsed trailing tokens remain
65+
var remaining = _sql.Substring(_position).Trim();
66+
if (remaining.Length > 0)
67+
{
68+
RecordError($"Unexpected trailing content: '{remaining}'");
69+
}
70+
71+
return result;
6372
}
6473
catch (Exception ex)
6574
{

src/SharpCoreDB/Services/SqlAst.Nodes.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ public class ColumnNode : SqlNode
108108
/// </summary>
109109
public double? AggregateArgument { get; set; }
110110

111+
/// <summary>
112+
/// Gets or sets a parsed expression for scalar functions (e.g., COALESCE, IIF, NULLIF).
113+
/// When set, this expression represents the full column value computation.
114+
/// </summary>
115+
public ExpressionNode? Expression { get; set; }
116+
111117
/// <inheritdoc/>
112118
public override TResult Accept<TResult>(ISqlVisitor<TResult> visitor) => visitor.VisitColumn(this);
113119
}

src/SharpCoreDB/Services/SqlParser.Helpers.cs

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,24 @@ private static bool EvaluateSimpleCondition(Dictionary<string, object> row, stri
263263
{
264264
var key = parts[0].Trim();
265265
var op = parts[1].Trim();
266+
267+
// Handle IS NOT NULL (parts: key, IS, NOT, NULL)
268+
if (op.Equals("IS", StringComparison.OrdinalIgnoreCase)
269+
&& parts.Length >= 4
270+
&& parts[2].Trim().Equals("NOT", StringComparison.OrdinalIgnoreCase)
271+
&& parts[3].Trim().Equals("NULL", StringComparison.OrdinalIgnoreCase))
272+
{
273+
return row.TryGetValue(key, out var v) && v is not null && v is not DBNull;
274+
}
275+
276+
// Handle IS NULL (parts: key, IS, NULL)
277+
if (op.Equals("IS", StringComparison.OrdinalIgnoreCase)
278+
&& parts.Length >= 3
279+
&& parts[2].Trim().Equals("NULL", StringComparison.OrdinalIgnoreCase))
280+
{
281+
return !row.TryGetValue(key, out var v) || v is null || v is DBNull;
282+
}
283+
266284
var value = parts[2].Trim().Trim('\'');
267285

268286
if (value.Equals("NULL", StringComparison.OrdinalIgnoreCase))
@@ -292,18 +310,42 @@ private static bool EvaluateComplexCondition(Dictionary<string, object> row, str
292310
{
293311
var key = parts[i].Trim();
294312
var op = parts[i + 1].Trim();
295-
var value = parts[i + 2].Trim().Trim('\'');
296313

297-
if (value.Equals("NULL", StringComparison.OrdinalIgnoreCase))
314+
bool expr;
315+
int consumed;
316+
317+
// Handle IS NOT NULL (4 tokens: key IS NOT NULL)
318+
if (op.Equals("IS", StringComparison.OrdinalIgnoreCase)
319+
&& i + 3 < parts.Length
320+
&& parts[i + 2].Trim().Equals("NOT", StringComparison.OrdinalIgnoreCase)
321+
&& parts[i + 3].Trim().Equals("NULL", StringComparison.OrdinalIgnoreCase))
298322
{
299-
value = null;
323+
expr = row.TryGetValue(key, out var v) && v is not null && v is not DBNull;
324+
consumed = 4;
300325
}
301-
302-
bool expr = false;
303-
if (row.ContainsKey(key))
326+
// Handle IS NULL (3 tokens: key IS NULL)
327+
else if (op.Equals("IS", StringComparison.OrdinalIgnoreCase)
328+
&& parts[i + 2].Trim().Equals("NULL", StringComparison.OrdinalIgnoreCase))
304329
{
305-
var rowValue = row[key];
306-
expr = EvaluateOperator(rowValue, op, value);
330+
expr = !row.TryGetValue(key, out var v) || v is null || v is DBNull;
331+
consumed = 3;
332+
}
333+
else
334+
{
335+
var value = parts[i + 2].Trim().Trim('\'');
336+
337+
if (value.Equals("NULL", StringComparison.OrdinalIgnoreCase))
338+
{
339+
value = null;
340+
}
341+
342+
expr = false;
343+
if (row.ContainsKey(key))
344+
{
345+
var rowValue = row[key];
346+
expr = EvaluateOperator(rowValue, op, value);
347+
}
348+
consumed = 3;
307349
}
308350

309351
if (current is null)
@@ -320,7 +362,7 @@ private static bool EvaluateComplexCondition(Dictionary<string, object> row, str
320362
}
321363

322364
// Move index to next token after this expression
323-
i += 3;
365+
i += consumed;
324366
// Read logical connector if present
325367
if (i < parts.Length)
326368
{

src/SharpCoreDB/Services/SqlParser.PerformanceOptimizations.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,14 +244,26 @@ private static Func<Dictionary<string, object>, bool> CompilePredicateFromParts(
244244
private static Func<Dictionary<string, object>, bool> CompileSingleCondition(string condition)
245245
{
246246
condition = condition.Trim();
247-
247+
248+
// Check for IS NULL / IS NOT NULL first (no right-hand value)
249+
var isNullPattern = new Regex(@"^(\w+)\s+IS\s+(NOT\s+)?NULL\s*$", RegexOptions.IgnoreCase);
250+
var isNullMatch = isNullPattern.Match(condition);
251+
if (isNullMatch.Success)
252+
{
253+
string col = isNullMatch.Groups[1].Value.Trim();
254+
bool isNotNull = isNullMatch.Groups[2].Success;
255+
return isNotNull
256+
? row => row.TryGetValue(col, out var val) && val is not null && val is not DBNull
257+
: row => !row.TryGetValue(col, out var val) || val is null || val is DBNull;
258+
}
259+
248260
// Try to match: column operator value
249261
// Operators: =, !=, >, <, >=, <=, IN, LIKE
250-
262+
251263
var operatorPattern = new Regex(@"(\w+)\s*(=|!=|>=|<=|>|<|IN|LIKE)\s*(.+)",
252264
RegexOptions.IgnoreCase);
253265
var match = operatorPattern.Match(condition);
254-
266+
255267
if (!match.Success)
256268
{
257269
// Can't parse, accept all

0 commit comments

Comments
 (0)