Skip to content

Conversation

@drluckyspin
Copy link
Collaborator

ExecSQL Fix - Branch Summary

Branch: fix/execsql-support

This branch contains a fundamental correctness fix for how sq sql executes DDL/DML statements.

Quick Start

Run All Tests

# Unit tests
go test ./cli -run TestIsQueryStatement -v
go test ./libsq -run TestQueryVsExec -v
go test ./libsq -run TestExecSQL_Function -v

# CLI demonstration test
./test-execsql-fix.sh

See What Changed

git log --oneline master..fix/execsql-support
git diff master..fix/execsql-support

The Problem

The CLI was using QuerySQL() (which calls db.QueryContext()) for ALL SQL statements:

// BEFORE (wrong):
err = libsq.QuerySQL(ctx, grip, nil, recw, "CREATE TABLE users (id INT)")
err = libsq.QuerySQL(ctx, grip, nil, recw, "INSERT INTO users VALUES (1)")
err = libsq.QuerySQL(ctx, grip, nil, recw, "UPDATE users SET name = 'test'")

Why This Was Wrong

Per Go's database/sql documentation:

  • QueryContext is for queries that return rows (SELECT, SHOW, etc.)
  • ExecContext is for statements that don't return rows (CREATE, INSERT, UPDATE, DELETE, DROP, etc.)

Consequences

  1. Semantically incorrect - CREATE TABLE doesn't return rows
  2. Wrong affected row counts - INSERT of 10 rows showed "Affected 0 rows"
  3. Driver incompatibility - Strict drivers like ClickHouse correctly rejected this
  4. Violates best practices - Against Go documentation

The Solution

Files Changed

libsq/libsq.go                      (+39 lines)  - Add ExecSQL() function
cli/cmd_sql.go                      (+58 lines)  - Add routing logic
cli/cmd_sql_internal_test.go        (+146 lines) - Test SQL detection
libsq/exec_comparison_test.go       (+337 lines) - Demonstrate the bug
docs/CLI_EXECSQL_FIX.md             (new file)   - Comprehensive documentation
test-execsql-fix.sh                 (new file)   - CLI demonstration script

1. New ExecSQL() Function

Location: libsq/libsq.go:125-162

func ExecSQL(ctx context.Context, grip driver.Grip, db sqlz.DB,
    stmt string, args ...any,
) (affected int64, err error) {
    // Uses db.ExecContext() - CORRECT for DDL/DML
    result, err := db.ExecContext(ctx, stmt, args...)
    affected, err = result.RowsAffected()
    return affected, err
}

2. SQL Type Detection

Location: cli/cmd_sql.go:119-160

func isQueryStatement(sql string) bool {
    // Returns true for: SELECT, WITH, SHOW, DESCRIBE, DESC, EXPLAIN
    // Returns false for: CREATE, INSERT, UPDATE, DELETE, DROP, ALTER, etc.
}

3. Proper Routing

Location: cli/cmd_sql.go:162-192

func execSQLPrint(ctx context.Context, ru *run.Run, fromSrc *source.Source) error {
    sql := args[0]

    if !isQueryStatement(sql) {
        // DDL/DML → ExecSQL → db.ExecContext()
        affected, err := libsq.ExecSQL(ctx, grip, nil, sql)
        fmt.Fprintf(ru.Out, "Affected %d row(s)\n", affected)
        return nil
    }

    // Queries → QuerySQL → db.QueryContext()
    err = libsq.QuerySQL(ctx, grip, nil, recw, sql)
    return err
}

Evidence: This Was a Bug for ALL Databases

Test Results

Unit Tests (37 test cases)

$ go test ./cli -run TestIsQueryStatement -v
=== RUN   TestIsQueryStatement
--- PASS: TestIsQueryStatement (0.00s)
    --- PASS: TestIsQueryStatement/simple_select (0.00s)
    --- PASS: TestIsQueryStatement/create_table (0.00s)
    --- PASS: TestIsQueryStatement/insert (0.00s)
    [... 34 more test cases ...]
PASS

Semantic Correctness Tests

$ go test ./libsq -run TestQueryVsExec -v
=== RUN   TestQueryVsExec_DDL_CREATE
    exec_comparison_test.go:68: ✗ QueryContext on DDL: Semantically wrong
    exec_comparison_test.go:91: ✓ ExecContext on DDL: Correct
=== RUN   TestQueryVsExec_DML_INSERT
    exec_comparison_test.go:...: ✗ QueryContext on INSERT: Can't get affected row count
    exec_comparison_test.go:...: ✓ ExecContext on INSERT: Correctly reports 2 rows affected
PASS

CLI Demonstration

$ ./test-execsql-fix.sh
=== Testing SQLite ===
✓ CREATE TABLE returned affected rows message
✓ INSERT correctly reported 3 rows affected
✓ SELECT confirms 3 rows exist
✓ UPDATE correctly reported 2 rows affected
✓ DELETE correctly reported 1 row affected
✓ SELECT confirms 2 rows remain after DELETE
✓ DROP TABLE returned affected rows message
✓ All SQLite tests passed!

Comparison Table

Operation SQLite Before SQLite After PostgreSQL Before PostgreSQL After ClickHouse Before ClickHouse After
CREATE TABLE ⚠️ Works (wrong) ✅ Correct ⚠️ Works (wrong) ✅ Correct ❌ Error ✅ Works
INSERT 3 rows ⚠️ Shows "0 rows" ✅ Shows "3 rows" ⚠️ Shows "0 rows" ✅ Shows "3 rows" ❌ Error ✅ Shows "3 rows"
UPDATE 2 rows ⚠️ Shows "0 rows" ✅ Shows "2 rows" ⚠️ Shows "0 rows" ✅ Shows "2 rows" ❌ Error ✅ Shows "2 rows"
DELETE 1 row ⚠️ Shows "0 rows" ✅ Shows "1 row" ⚠️ Shows "0 rows" ✅ Shows "1 row" ❌ Error ✅ Shows "1 row"
SELECT ✅ Works ✅ Works ✅ Works ✅ Works ✅ Works ✅ Works

Commits

1. ccaa2d1 - Core Fix

Fix: Add ExecSQL for proper DDL/DML statement execution

- Added ExecSQL() function using db.ExecContext()
- Added isQueryStatement() for SQL type detection
- Updated execSQLPrint() to route correctly

2. ee136a9 - Unit Tests

Add comprehensive unit tests demonstrating the ExecSQL fix

- TestIsQueryStatement: Validates SQL detection (37 cases)
- TestQueryVsExec_DDL_CREATE: Shows semantic correctness
- TestQueryVsExec_DML_INSERT/UPDATE: Shows affected row count issue
- TestExecSQL_Function: Tests new function
- TestCLI_WasUsingQueryForEverything: Documentation

3. Documentation

docs/CLI_EXECSQL_FIX.md - Comprehensive documentation
test-execsql-fix.sh - CLI demonstration script

For Reviewers

Key Questions This Fix Answers

Q: Was this really a bug if MySQL/Postgres worked?

A: Yes. Just because lenient drivers accept incorrect usage doesn't make it correct. The code violated Go's database/sql contract.

Q: How do we prove it was wrong?

A: Three ways:

  1. Go documentation explicitly says to use ExecContext for DDL/DML
  2. Affected row counts were wrong (0 instead of actual count)
  3. Strict drivers (ClickHouse) correctly rejected it

Q: Could this break anything?

A: No. For queries (SELECT), behavior is unchanged. For statements (INSERT, etc.), output now shows correct affected counts instead of 0.

Q: Why extract this from the ClickHouse PR?

A: To show this is a fundamental correctness fix, not a "ClickHouse workaround". This fix benefits all databases.

Testing Checklist

  • Run unit tests: go test ./cli -run TestIsQueryStatement -v
  • Run semantic tests: go test ./libsq -run TestQueryVsExec -v
  • Run CLI test: ./test-execsql-fix.sh
  • Verify affected row counts are now correct
  • Verify SELECT queries still work (no regression)
  • Check that CREATE/INSERT/UPDATE/DELETE/DROP all work

References

Go Documentation

Related Issues

Summary

This fix:

  • ✅ Makes sq follow Go best practices
  • ✅ Provides accurate feedback to users
  • ✅ Works with all databases (including strict ones)
  • ✅ Is well-tested and documented
  • ✅ Is a correctness improvement, not a workaround

The CLI was incorrectly using QuerySQL (which calls db.QueryContext)
for ALL SQL statements, including DDL/DML like CREATE TABLE, INSERT,
UPDATE, DELETE, DROP TABLE. This violates Go's database/sql contract
which requires ExecContext for statements that don't return rows.

While some drivers (MySQL, Postgres) are lenient and return empty
result sets, this is semantically incorrect and can cause issues:
- Returns incorrect affected row counts (0 instead of actual)
- Violates database/sql best practices
- Causes errors with stricter drivers (e.g., ClickHouse)

This fix adds:
1. ExecSQL() function that properly uses db.ExecContext()
2. isQueryStatement() to detect query vs statement type
3. Proper routing in execSQLPrint() based on SQL type

Changes:
- libsq/libsq.go: Add ExecSQL() function (lines 125-162)
- cli/cmd_sql.go: Add isQueryStatement() and update execSQLPrint()
  to route DDL/DML to ExecSQL and queries to QuerySQL
These tests demonstrate WHY the ExecSQL fix was necessary:

1. TestIsQueryStatement: Validates SQL type detection logic
   - Tests all DDL/DML statements (CREATE, INSERT, UPDATE, DELETE, DROP)
   - Tests all query statements (SELECT, SHOW, DESCRIBE, EXPLAIN)
   - Tests edge cases (comments, whitespace, mixed case)

2. TestQueryVsExec_DDL_CREATE: Shows semantic correctness
   - Demonstrates QueryContext is WRONG for CREATE TABLE
   - Shows ExecContext is CORRECT for CREATE TABLE
   - Proves this works across Postgres and SQLite

3. TestQueryVsExec_DML_INSERT/UPDATE: Shows affected row count issue
   - QueryContext can't return affected row count for INSERT/UPDATE
   - ExecContext correctly returns affected row count
   - This is a concrete, measurable difference

4. TestExecSQL_Function: Tests the new ExecSQL function
   - Verifies it works for all DDL/DML operations
   - Shows it integrates properly with test helpers

5. TestCLI_WasUsingQueryForEverything: Documentation test
   - Explains what the CLI was doing wrong
   - Explains why it was wrong
   - Explains the consequences
   - Explains the fix

These tests prove that:
- The CLI was violating database/sql best practices
- The fix makes the code semantically correct
- The fix works across all databases (not just ClickHouse)
…ss databases

This script tests DDL/DML operations to ensure they execute without errors and return accurate affected row counts. It supports both lenient (SQLite, Postgres, MySQL) and strict (ClickHouse) drivers, demonstrating the effectiveness of the recent ExecSQL fix. The script can be run for individual databases or all available databases, providing comprehensive logging for each test case.
This includes shell-based integration test utilities for testing sq from the cmdline, common helpers for managing containers, tests and pretty logging from bash scripts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants