Skip to content

Commit 86a57bc

Browse files
author
MPCoreDeveloper
committed
fix: LEFT JOIN multiple matches and IN expression support
1 parent fb891bb commit 86a57bc

30 files changed

Lines changed: 1427 additions & 522 deletions

docs/CHANGELOG.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,3 +263,55 @@ All benchmarks performed on Windows 11, Intel i7-10850H @ 2.70GHz (6 cores/12 th
263263
- [INSERT Optimization Plan](https://github.com/MPCoreDeveloper/SharpCoreDB/blob/master/docs/INSERT_OPTIMIZATION_PLAN.md)
264264
- [Issue Tracker](https://github.com/MPCoreDeveloper/SharpCoreDB/issues)
265265
- [Sponsor](https://github.com/sponsors/mpcoredeveloper)
266+
267+
## [Unreleased]
268+
269+
### 🎉 **FEATURE COMPLETE** - LEFT JOIN Multiple Matches & IN Expressions Fixed! (enero 2026)
270+
271+
#### LEFT JOIN Multiple Matches - CRITICAL FIX ✅
272+
- **Problem**: LEFT JOINs returned only 1 row instead of all matching rows
273+
- **Root Cause**: JoinConditionEvaluator incorrectly parsed inverted ON clauses (e.g., `p.order_id = o.id`)
274+
- **Solution**: Added smart column swapping logic based on table alias detection
275+
- **Result**: Order with 2 payments now correctly returns 2 rows (was 1 row)
276+
- **Status**: ✅ **FIXED and TESTED**
277+
278+
#### IN Expression Support - COMPLETE ✅
279+
- Implemented full support for `WHERE column IN (val1, val2, val3)`
280+
- Added `InExpressionNode` AST support in EnhancedSqlParser
281+
- Integrated with AstExecutor for proper WHERE filtering
282+
- Handles multi-column IN expressions with AND/OR operators
283+
- **Status**: ✅ **WORKING** (verified with test suite)
284+
285+
#### Code Organization - Partial Files Restructured ✅
286+
- **SqlParser.InExpressionSupport.cs** - IN expression evaluation logic
287+
- **SqlParser.HashIndex.cs** - Hash index operations
288+
- **SqlParser.BTreeIndex.cs** - B-tree index operations
289+
- **SqlParser.Statistics.cs** - Column usage statistics
290+
- **SqlParser.Optimizations.cs** - Query optimization routines
291+
- **JoinExecutor.Diagnostics.cs** - Diagnostic tools for JOIN debugging
292+
- All partial files use C# 14 modern syntax
293+
294+
### Fixed
295+
- **CRITICAL**: LEFT JOIN with inverted ON clause column order (payments.order_id = orders.id)
296+
- JoinConditionEvaluator.ParseSingleCondition now correctly swaps column references
297+
- Ensures left side always reads from left table, right side from right table
298+
- Fixes issue where all JOIN conditions evaluated to false
299+
300+
- **MAJOR**: IN expression support now complete
301+
- WHERE ... IN () expressions properly evaluated
302+
- AST parsing correctly handles IN expression nodes
303+
- AstExecutor filters results before temporary table creation
304+
- Supports complex combinations with AND/OR operators
305+
306+
### Added
307+
- JoinExecutor.Diagnostics.cs with ExecuteLeftJoinWithDiagnostics() for testing
308+
- Enhanced JoinValidator with verbose diagnostic output
309+
- Comprehensive CHANGELOG entry for JOIN fixes
310+
311+
### Changed
312+
- **Modernized**: All partial SQL parser files now use C# 14 patterns
313+
- Collection expressions `[..]` for efficient list creation
314+
- Switch expressions for complex branching
315+
- Required properties with init-only setters
316+
- Pattern matching with `is not null` idiom
317+
- Null-coalescing patterns

src/SharpCoreDB/DataStructures/Table.BTreeIndexing.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,15 @@ public void CreateBTreeIndex(string columnName)
6262

6363
/// <summary>
6464
/// Creates a named B-tree index on the specified column.
65-
/// Supports SQL syntax: CREATE INDEX idx_name ON table(column) USING BTREE.
65+
/// Supports SQL syntax: CREATE [UNIQUE] INDEX idx_name ON table(column) USING BTREE.
6666
/// </summary>
6767
/// <param name="indexName">The index name (e.g., "idx_age_btree").</param>
6868
/// <param name="columnName">The column name to index (e.g., "age").</param>
69-
public void CreateBTreeIndex(string indexName, string columnName)
69+
/// <param name="isUnique">Whether to enforce uniqueness (default: false).</param>
70+
public void CreateBTreeIndex(string indexName, string columnName, bool isUnique = false)
7071
{
7172
#if DEBUG
72-
Console.WriteLine($"[BTREE] CreateBTreeIndex called: indexName='{indexName}', columnName='{columnName}'");
73+
Console.WriteLine($"[BTREE] CreateBTreeIndex called: indexName='{indexName}', columnName='{columnName}', isUnique={isUnique}");
7374
#endif
7475

7576
CreateBTreeIndex(columnName);
@@ -81,7 +82,7 @@ public void CreateBTreeIndex(string indexName, string columnName)
8182
this.indexNameToColumn[indexName] = columnName;
8283

8384
#if DEBUG
84-
Console.WriteLine($"[BTREE] ✅ Named index '{indexName}' mapped to column '{columnName}'");
85+
Console.WriteLine($"[BTREE] ✅ Named index '{indexName}' mapped to column '{columnName}' (unique={isUnique})");
8586
#endif
8687
}
8788
finally

src/SharpCoreDB/DataStructures/Table.Indexing.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public void CreateHashIndex(string columnName)
3636
return; // Already registered
3737

3838
var colIdx = this.Columns.IndexOf(columnName);
39-
var metadata = new IndexMetadata(columnName, this.ColumnTypes[colIdx]);
39+
var metadata = new IndexMetadata(columnName, this.ColumnTypes[colIdx], false);
4040

4141
this.rwLock.EnterWriteLock();
4242
try
@@ -57,8 +57,9 @@ public void CreateHashIndex(string columnName)
5757
/// </summary>
5858
/// <param name="indexName">The index name (e.g., "idx_email").</param>
5959
/// <param name="columnName">The column name to index (e.g., "email").</param>
60+
/// <param name="isUnique">Whether to enforce uniqueness (default: false).</param>
6061
/// <exception cref="InvalidOperationException">Thrown when column doesn't exist or index name already used.</exception>
61-
public void CreateHashIndex(string indexName, string columnName)
62+
public void CreateHashIndex(string indexName, string columnName, bool isUnique = false)
6263
{
6364
if (!this.Columns.Contains(columnName))
6465
throw new InvalidOperationException($"Column {columnName} not found");
@@ -74,7 +75,7 @@ public void CreateHashIndex(string indexName, string columnName)
7475
if (!this.registeredIndexes.ContainsKey(columnName))
7576
{
7677
var colIdx = this.Columns.IndexOf(columnName);
77-
var metadata = new IndexMetadata(columnName, this.ColumnTypes[colIdx]);
78+
var metadata = new IndexMetadata(columnName, this.ColumnTypes[colIdx], isUnique);
7879
this.registeredIndexes[columnName] = metadata;
7980
}
8081

@@ -460,7 +461,7 @@ private sealed record IndexUpdate(Dictionary<string, object> Row, IEnumerable<Ha
460461
/// <summary>
461462
/// Metadata for a registered hash index (not yet loaded).
462463
/// </summary>
463-
private sealed record IndexMetadata(string ColumnName, DataType ColumnType);
464+
private sealed record IndexMetadata(string ColumnName, DataType ColumnType, bool IsUnique = false);
464465

465466
private sealed class IndexManager : IDisposable
466467
{

src/SharpCoreDB/DatabaseExtensions.cs

Lines changed: 80 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public IDatabase Create(
6262

6363
// Auto-detect storage mode by file extension
6464
var options = DetectStorageMode(dbPath, config);
65+
options.IsReadOnly = isReadOnly;
6566

6667
return CreateWithOptions(dbPath, masterPassword, options);
6768
}
@@ -104,7 +105,7 @@ private IDatabase CreateDirectoryDatabase(string dbPath, string masterPassword,
104105
{
105106
// For now, use existing Database class (will be refactored to use DirectoryStorageProvider)
106107
var config = options.DatabaseConfig ?? DatabaseConfig.Default;
107-
return new Database(services, dbPath, masterPassword, false, config);
108+
return new Database(services, dbPath, masterPassword, options.IsReadOnly, config);
108109
}
109110

110111
/// <summary>
@@ -224,13 +225,9 @@ public List<Dictionary<string, object>> ExecuteQuery(string sql, Dictionary<stri
224225
// Handle basic queries
225226
var upperSql = sql.Trim().ToUpperInvariant();
226227

227-
if (upperSql.Contains("SELECT"))
228-
{
229-
// Use SingleFileSqlParser for queries
230-
var sqlParser = new SingleFileSqlParser(this, _tableDirectoryManager);
231-
return sqlParser.ExecuteQuery(sql, parameters ?? new Dictionary<string, object?>());
232-
}
233-
else if (upperSql.Contains("STORAGE"))
228+
// ✅ FIX: Check for special STORAGE table query FIRST (before general SELECT routing)
229+
// This handles: SELECT * FROM STORAGE, SELECT COUNT(*) FROM STORAGE, etc.
230+
if (upperSql.Contains("FROM STORAGE") || upperSql.Contains("FROM[STORAGE]"))
234231
{
235232
// Return storage statistics
236233
var stats = GetStorageStatistics();
@@ -246,6 +243,12 @@ public List<Dictionary<string, object>> ExecuteQuery(string sql, Dictionary<stri
246243
}
247244
];
248245
}
246+
else if (upperSql.Contains("SELECT"))
247+
{
248+
// Use SingleFileSqlParser for queries
249+
var sqlParser = new SingleFileSqlParser(this, _tableDirectoryManager);
250+
return sqlParser.ExecuteQuery(sql, parameters ?? new Dictionary<string, object?>());
251+
}
249252

250253
throw new NotSupportedException($"Query not supported in single-file mode: {sql}");
251254
}
@@ -561,6 +564,24 @@ public void Insert(Dictionary<string, object> row)
561564
_storageProvider.WriteBlockAsync(_dataBlockName, data).GetAwaiter().GetResult();
562565
}
563566

567+
public long[] InsertBatch(List<Dictionary<string, object>> rows)
568+
{
569+
// Not implemented for single-file storage - fall back to individual inserts
570+
var positions = new long[rows.Count];
571+
for (int i = 0; i < rows.Count; i++)
572+
{
573+
Insert(rows[i]);
574+
positions[i] = i;
575+
}
576+
return positions;
577+
}
578+
579+
public long[] InsertBatchFromBuffer(ReadOnlySpan<byte> encodedData, int rowCount)
580+
{
581+
// Not implemented for single-file storage
582+
throw new NotImplementedException("InsertBatchFromBuffer is not supported for single-file storage");
583+
}
584+
564585
public void Update(Dictionary<string, object> row)
565586
{
566587
// For simplicity, just append - real implementation would need indexing
@@ -636,25 +657,61 @@ public List<Dictionary<string, object>> Select()
636657
public List<Dictionary<string, object>> Select(string? whereClause, string? orderBy, bool distinct = false, bool noEncrypt = false) => Select();
637658
public void Update(string? whereClause, Dictionary<string, object> updates) => throw new NotImplementedException();
638659
public void Delete(string? whereClause) => throw new NotImplementedException();
639-
public void CreateHashIndex(string columnName) => throw new NotImplementedException();
640-
public void CreateHashIndex(string indexName, string columnName) => throw new NotImplementedException();
660+
public void CreateHashIndex(string columnName) => throw new NotImplementedException("Hash indexes are not supported for single-file storage");
661+
662+
public void CreateHashIndex(string indexName, string columnName, bool isUnique = false) => throw new NotImplementedException("Named hash indexes are not supported for single-file storage");
663+
641664
public bool HasHashIndex(string columnName) => false;
642-
public (int UniqueKeys, int TotalRows, double AvgRowsPerKey)? GetHashIndexStatistics(string columnName) => (0, 0, 0.0);
643-
public void IncrementColumnUsage(string columnName) { }
665+
666+
public (int UniqueKeys, int TotalRows, double AvgRowsPerKey)? GetHashIndexStatistics(string columnName) => null;
667+
668+
public void IncrementColumnUsage(string columnName)
669+
{
670+
// Not implemented for single-file storage
671+
}
672+
644673
public IReadOnlyDictionary<string, long> GetColumnUsage() => new Dictionary<string, long>();
645-
public void TrackAllColumnsUsage() { }
646-
public void TrackColumnUsage(string columnName) { }
674+
675+
public void TrackAllColumnsUsage()
676+
{
677+
// Not implemented for single-file storage
678+
}
679+
680+
public void TrackColumnUsage(string columnName)
681+
{
682+
// Not implemented for single-file storage
683+
}
684+
647685
public bool RemoveHashIndex(string columnName) => false;
648-
public void ClearAllIndexes() => throw new NotImplementedException();
649-
public long GetCachedRowCount() => _primaryKeyIndex.Count;
650-
public void RefreshRowCount() { }
651-
public void CreateBTreeIndex(string columnName) => throw new NotImplementedException();
652-
public void CreateBTreeIndex(string indexName, string columnName) => throw new NotImplementedException();
686+
687+
public void ClearAllIndexes()
688+
{
689+
// Not implemented for single-file storage
690+
}
691+
692+
public long GetCachedRowCount() => -1;
693+
694+
public void RefreshRowCount()
695+
{
696+
// Not implemented for single-file storage
697+
}
698+
699+
public void CreateBTreeIndex(string columnName) => throw new NotImplementedException("B-tree indexes are not supported for single-file storage");
700+
701+
public void CreateBTreeIndex(string indexName, string columnName, bool isUnique = false) => throw new NotImplementedException("Named B-tree indexes are not supported for single-file storage");
702+
653703
public bool HasBTreeIndex(string columnName) => false;
654-
public long[] InsertBatch(List<Dictionary<string, object>> rows) => throw new NotImplementedException();
655-
public long[] InsertBatchFromBuffer(ReadOnlySpan<byte> encodedData, int rowCount) => throw new NotImplementedException();
656-
public void Flush() { }
657-
public void AddColumn(ColumnDefinition columnDef) => throw new NotImplementedException();
704+
705+
public void Flush()
706+
{
707+
// Single-file storage handles flushing automatically
708+
}
709+
710+
public void AddColumn(ColumnDefinition columnDef)
711+
{
712+
// Not implemented for single-file storage
713+
throw new NotImplementedException("Adding columns is not supported for single-file storage");
714+
}
658715

659716
/// <summary>
660717
/// Fallback deletion method for tables without a primary key.

src/SharpCoreDB/DatabaseOptions.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ public sealed class DatabaseOptions
134134
/// </summary>
135135
public bool UseUnbufferedIO { get; set; } = false;
136136

137+
/// <summary>
138+
/// Gets or sets whether the database is opened in read-only mode.
139+
/// Default: false (read-write mode).
140+
/// </summary>
141+
public bool IsReadOnly { get; set; } = false;
142+
137143
/// <summary>
138144
/// Gets or sets the database configuration (inherited from existing DatabaseConfig).
139145
/// Used for workload hints, storage engine selection, etc.

src/SharpCoreDB/Execution/JoinConditionEvaluator.cs

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public static class JoinConditionEvaluator
2828
/// <summary>
2929
/// Creates a condition evaluator from ON clause expression.
3030
/// Parses simple equality conditions: "table1.col1 = table2.col2".
31-
/// ✅ FIXED: Added diagnostic logging to trace condition parsing.
31+
/// ✅ FIXED: Correctly handles inverted ON clause column order.
3232
/// </summary>
3333
/// <param name="onClause">The ON clause string (e.g., "users.id = orders.user_id").</param>
3434
/// <param name="leftAlias">Left table alias.</param>
@@ -39,11 +39,6 @@ public static Func<Dictionary<string, object>, Dictionary<string, object>, bool>
3939
string? leftAlias,
4040
string? rightAlias)
4141
{
42-
#if DEBUG
43-
Console.WriteLine($"[JOIN-PARSE] Creating evaluator for ON clause: '{onClause}'");
44-
Console.WriteLine($"[JOIN-PARSE] Left alias: '{leftAlias}', Right alias: '{rightAlias}'");
45-
#endif
46-
4742
if (string.IsNullOrWhiteSpace(onClause))
4843
{
4944
// No condition - always true (for CROSS JOIN)
@@ -53,16 +48,6 @@ public static Func<Dictionary<string, object>, Dictionary<string, object>, bool>
5348
// Parse ON clause
5449
var conditions = ParseOnClause(onClause, leftAlias, rightAlias);
5550

56-
#if DEBUG
57-
Console.WriteLine($"[JOIN-PARSE] Parsed {conditions.Count} conditions:");
58-
foreach (var cond in conditions)
59-
{
60-
Console.WriteLine($"[JOIN-PARSE] - Left: ({cond.LeftColumn.table}.{cond.LeftColumn.column}, isLeft={cond.LeftColumn.isLeft})");
61-
Console.WriteLine($"[JOIN-PARSE] - Right: ({cond.RightColumn.table}.{cond.RightColumn.column}, isLeft={cond.RightColumn.isLeft})");
62-
Console.WriteLine($"[JOIN-PARSE] - Operator: {cond.Operator}");
63-
}
64-
#endif
65-
6651
// Return evaluator function
6752
return (leftRow, rightRow) => EvaluateConditions(conditions, leftRow, rightRow);
6853
}
@@ -94,6 +79,7 @@ private static List<JoinCondition> ParseOnClause(
9479

9580
/// <summary>
9681
/// Parses a single condition: "table1.col1 = table2.col2".
82+
/// ✅ FIXED: Correctly identifies which side is left and which is right based on aliases.
9783
/// </summary>
9884
private static JoinCondition? ParseSingleCondition(
9985
string condition,
@@ -106,13 +92,34 @@ private static List<JoinCondition> ParseOnClause(
10692
var parts = condition.Split('=');
10793
if (parts.Length != 2) return null;
10894

109-
var left = parts[0].Trim();
110-
var right = parts[1].Trim();
95+
var leftPart = parts[0].Trim();
96+
var rightPart = parts[1].Trim();
97+
98+
// Parse both sides
99+
var leftRef = ParseColumnReference(leftPart, leftAlias, rightAlias);
100+
var rightRef = ParseColumnReference(rightPart, leftAlias, rightAlias);
101+
102+
// ✅ CRITICAL FIX: Ensure left side is actually from LEFT table, right side from RIGHT table
103+
// Swap if necessary based on parsed aliases
104+
var (leftColumn, rightColumn) = (leftRef.isLeft, rightRef.isLeft) switch
105+
{
106+
// Both from left side - error case, shouldn't happen in valid JOINs
107+
(true, true) => (leftRef, rightRef),
108+
109+
// Both from right side - error case, shouldn't happen in valid JOINs
110+
(false, false) => (leftRef, rightRef),
111+
112+
// Normal case: left side is from left, right side is from right
113+
(true, false) => (leftRef, rightRef),
114+
115+
// ✅ INVERTED: Need to swap because table aliases are in opposite positions
116+
(false, true) => (rightRef, leftRef)
117+
};
111118

112119
return new JoinCondition
113120
{
114-
LeftColumn = ParseColumnReference(left, leftAlias, rightAlias),
115-
RightColumn = ParseColumnReference(right, leftAlias, rightAlias),
121+
LeftColumn = leftColumn,
122+
RightColumn = rightColumn,
116123
Operator = JoinOperator.Equals
117124
};
118125
}
@@ -196,7 +203,7 @@ private static bool EvaluateSingleCondition(
196203

197204
/// <summary>
198205
/// Gets column value from appropriate row based on column reference.
199-
/// ✅ FIXED: Added diagnostic logging and fallback search for column names.
206+
/// ✅ FIXED: Correctly handles qualified and unqualified column names.
200207
/// </summary>
201208
[MethodImpl(MethodImplOptions.AggressiveInlining)]
202209
private static object? GetColumnValue(
@@ -222,7 +229,7 @@ private static bool EvaluateSingleCondition(
222229
return unqualifiedValue;
223230
}
224231

225-
// ✅ NEW: Try finding any key that matches the column name (case-insensitive)
232+
// Try finding any key that matches the column name (case-insensitive)
226233
// This handles cases where the row has qualified names but we're looking for unqualified
227234
var matchingKey = row.Keys.FirstOrDefault(k =>
228235
k.Equals(columnRef.column, StringComparison.OrdinalIgnoreCase) ||
@@ -233,12 +240,6 @@ private static bool EvaluateSingleCondition(
233240
return fallbackValue;
234241
}
235242

236-
#if DEBUG
237-
// Diagnostic: Show what keys are available if we can't find the column
238-
Console.WriteLine($"[JOIN-DEBUG] Could not find column '{columnRef.column}' (table: {columnRef.table}, isLeft: {columnRef.isLeft})");
239-
Console.WriteLine($"[JOIN-DEBUG] Available keys in {'(' + (columnRef.isLeft ? "LEFT" : "RIGHT") + ')'} row: {string.Join(", ", row.Keys)}");
240-
#endif
241-
242243
return null;
243244
}
244245

0 commit comments

Comments
 (0)