@@ -342,4 +342,147 @@ public void ExecuteSQL_DropTable_WithQuotedTableName_DropsSuccessfully()
342342 ( db as IDisposable ) ? . Dispose ( ) ;
343343 }
344344 }
345+
346+ /// <summary>
347+ /// JOIN tests — single-file SELECT is routed through the shared SqlParser,
348+ /// so all join variants already work. These tests lock in that behaviour.
349+ /// </summary>
350+ [ Fact ]
351+ public void ExecuteQuery_InnerJoin_ReturnsMatchingRows ( )
352+ {
353+ // Arrange
354+ var options = DatabaseOptions . CreateSingleFileDefault ( ) ;
355+ var db = _factory . CreateWithOptions ( _testFilePath , "test_password" , options ) ;
356+
357+ try
358+ {
359+ db . ExecuteSQL ( "CREATE TABLE orders (order_id INTEGER, customer_id INTEGER, amount REAL)" ) ;
360+ db . ExecuteSQL ( "CREATE TABLE customers (customer_id INTEGER, name TEXT)" ) ;
361+ db . ExecuteBatchSQL ( [
362+ "INSERT INTO customers VALUES (1, 'Alice')" ,
363+ "INSERT INTO customers VALUES (2, 'Bob')" ,
364+ "INSERT INTO orders VALUES (10, 1, 99.50)" ,
365+ "INSERT INTO orders VALUES (11, 1, 45.00)" ,
366+ "INSERT INTO orders VALUES (12, 2, 200.00)"
367+ ] ) ;
368+
369+ // Act
370+ var results = db . ExecuteQuery (
371+ "SELECT orders.order_id, customers.name FROM orders " +
372+ "INNER JOIN customers ON orders.customer_id = customers.customer_id" ) ;
373+
374+ // Assert — all three orders have a matching customer
375+ Assert . Equal ( 3 , results . Count ) ;
376+ }
377+ finally
378+ {
379+ ( db as IDisposable ) ? . Dispose ( ) ;
380+ }
381+ }
382+
383+ [ Fact ]
384+ public void ExecuteQuery_LeftJoin_IncludesUnmatchedLeftRows ( )
385+ {
386+ // Arrange
387+ var options = DatabaseOptions . CreateSingleFileDefault ( ) ;
388+ var db = _factory . CreateWithOptions ( _testFilePath , "test_password" , options ) ;
389+
390+ try
391+ {
392+ db . ExecuteSQL ( "CREATE TABLE products (product_id INTEGER, name TEXT)" ) ;
393+ db . ExecuteSQL ( "CREATE TABLE reviews (review_id INTEGER, product_id INTEGER, score INTEGER)" ) ;
394+ db . ExecuteBatchSQL ( [
395+ "INSERT INTO products VALUES (1, 'Widget')" ,
396+ "INSERT INTO products VALUES (2, 'Gadget')" ,
397+ "INSERT INTO reviews VALUES (100, 1, 5)"
398+ // product 2 has no review
399+ ] ) ;
400+
401+ // Act
402+ var results = db . ExecuteQuery (
403+ "SELECT products.name, reviews.score FROM products " +
404+ "LEFT JOIN reviews ON products.product_id = reviews.product_id" ) ;
405+
406+ // Assert — both products appear; Gadget row has NULL score
407+ Assert . Equal ( 2 , results . Count ) ;
408+ var gadget = results . First ( r => r [ "name" ] ? . ToString ( ) == "Gadget" ) ;
409+ // SQL NULL is represented as DBNull.Value in dictionary results
410+ Assert . True ( gadget [ "score" ] is null or DBNull ) ;
411+ }
412+ finally
413+ {
414+ ( db as IDisposable ) ? . Dispose ( ) ;
415+ }
416+ }
417+
418+ [ Fact ]
419+ public void ExecuteQuery_Join_WithWhereFilter_ReturnsFilteredRows ( )
420+ {
421+ // Arrange
422+ var options = DatabaseOptions . CreateSingleFileDefault ( ) ;
423+ var db = _factory . CreateWithOptions ( _testFilePath , "test_password" , options ) ;
424+
425+ try
426+ {
427+ db . ExecuteSQL ( "CREATE TABLE emp (emp_id INTEGER, dept_id INTEGER, name TEXT)" ) ;
428+ db . ExecuteSQL ( "CREATE TABLE dept (dept_id INTEGER, dept_name TEXT)" ) ;
429+ db . ExecuteBatchSQL ( [
430+ "INSERT INTO dept VALUES (1, 'Engineering')" ,
431+ "INSERT INTO dept VALUES (2, 'Marketing')" ,
432+ "INSERT INTO emp VALUES (1, 1, 'Alice')" ,
433+ "INSERT INTO emp VALUES (2, 1, 'Bob')" ,
434+ "INSERT INTO emp VALUES (3, 2, 'Carol')"
435+ ] ) ;
436+
437+ // Act — only Engineering employees
438+ var results = db . ExecuteQuery (
439+ "SELECT emp.name, dept.dept_name FROM emp " +
440+ "INNER JOIN dept ON emp.dept_id = dept.dept_id " +
441+ "WHERE dept.dept_name = 'Engineering'" ) ;
442+
443+ // Assert
444+ Assert . Equal ( 2 , results . Count ) ;
445+ Assert . All ( results , r => Assert . Equal ( "Engineering" , r [ "dept_name" ] ? . ToString ( ) ) ) ;
446+ }
447+ finally
448+ {
449+ ( db as IDisposable ) ? . Dispose ( ) ;
450+ }
451+ }
452+
453+ [ Fact ]
454+ public void ExecuteQuery_ThreeTableJoin_ReturnsCorrectRows ( )
455+ {
456+ // Arrange
457+ var options = DatabaseOptions . CreateSingleFileDefault ( ) ;
458+ var db = _factory . CreateWithOptions ( _testFilePath , "test_password" , options ) ;
459+
460+ try
461+ {
462+ db . ExecuteSQL ( "CREATE TABLE authors (author_id INTEGER, author_name TEXT)" ) ;
463+ db . ExecuteSQL ( "CREATE TABLE books (book_id INTEGER, author_id INTEGER, title TEXT)" ) ;
464+ db . ExecuteSQL ( "CREATE TABLE sales (sale_id INTEGER, book_id INTEGER, qty INTEGER)" ) ;
465+ db . ExecuteBatchSQL ( [
466+ "INSERT INTO authors VALUES (1, 'Tolkien')" ,
467+ "INSERT INTO books VALUES (10, 1, 'The Hobbit')" ,
468+ "INSERT INTO books VALUES (11, 1, 'LOTR')" ,
469+ "INSERT INTO sales VALUES (100, 10, 5)" ,
470+ "INSERT INTO sales VALUES (101, 11, 12)"
471+ ] ) ;
472+
473+ // Act
474+ var results = db . ExecuteQuery (
475+ "SELECT authors.author_name, books.title, sales.qty FROM authors " +
476+ "INNER JOIN books ON authors.author_id = books.author_id " +
477+ "INNER JOIN sales ON books.book_id = sales.book_id" ) ;
478+
479+ // Assert — both books sold by Tolkien appear
480+ Assert . Equal ( 2 , results . Count ) ;
481+ Assert . All ( results , r => Assert . Equal ( "Tolkien" , r [ "author_name" ] ? . ToString ( ) ) ) ;
482+ }
483+ finally
484+ {
485+ ( db as IDisposable ) ? . Dispose ( ) ;
486+ }
487+ }
345488}
0 commit comments