Merge 'Implement transaction support in Go adapter' from Jonathan Ness

This PR implements basic transaction support in the Limbo Go adapter by
adding the required methods to fulfill the `driver.Tx` interface.
## Changes
- Add `Begin()` method to `limboConn` to start a transaction
- Add `BeginTx()` method with context support and proper handling of
transaction options
- Implement `Commit()` method to commit transaction changes
- Implement `Rollback()` method with appropriate error handling
- Add transaction tests
## Implementation Details
- Uses the standard SQLite transaction commands (BEGIN, COMMIT,
ROLLBACK)
- Follows the same pattern as other SQL operations in the adapter
(prepare-execute-close)
- Maintains consistent locking and error handling patterns
## Limitations
- Currently, ROLLBACK operations will return an error as they're not yet
fully supported in the underlying Limbo implementation
- Only the default isolation level is supported; all other isolation
levels return `driver.ErrSkip`
- Read-only transactions are not supported and return `driver.ErrSkip`
## Testing
- Added basic transaction tests that verify BEGIN and COMMIT operations
- Adjusted tests to work with the current Limbo implementation
capabilities
These transaction methods enable the Go adapter to be used in
applications that require transaction support while providing clear
error messages when unsupported features are requested.  I'll add to it
when Limbo supports ROLLBACK and/or additional isolation levels.

Closes #1435
This commit is contained in:
Pekka Enberg 2025-05-10 07:58:29 +03:00
commit a105c20f69
3 changed files with 167 additions and 4 deletions

View file

@ -67,4 +67,4 @@ cargo build ${CARGO_ARGS} --package limbo-go
echo "Copying $OUTPUT_NAME to $OUTPUT_DIR/"
cp "../../target/${TARGET_DIR}/$OUTPUT_NAME" "$OUTPUT_DIR/"
echo "Library built successfully for $PLATFORM ($BUILD_TYPE build)"
echo "Library built successfully for $PLATFORM ($BUILD_TYPE build)"

View file

@ -1,6 +1,7 @@
package limbo
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
@ -136,7 +137,94 @@ func (c *limboConn) Prepare(query string) (driver.Stmt, error) {
return newStmt(stmtPtr, query), nil
}
// begin is needed to implement driver.Conn.. for now not implemented
func (c *limboConn) Begin() (driver.Tx, error) {
return nil, errors.New("transactions not implemented")
// limboTx implements driver.Tx
type limboTx struct {
conn *limboConn
}
// Begin starts a new transaction with default isolation level
func (c *limboConn) Begin() (driver.Tx, error) {
c.Lock()
defer c.Unlock()
if c.ctx == 0 {
return nil, errors.New("connection closed")
}
// Execute BEGIN statement
stmtPtr := connPrepare(c.ctx, "BEGIN")
if stmtPtr == 0 {
return nil, c.getError()
}
stmt := newStmt(stmtPtr, "BEGIN")
defer stmt.Close()
_, err := stmt.Exec(nil)
if err != nil {
return nil, err
}
return &limboTx{conn: c}, nil
}
// BeginTx starts a transaction with the specified options.
// Currently only supports default isolation level and non-read-only transactions.
func (c *limboConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
// Skip handling non-default isolation levels and read-only mode
// for now, letting database/sql package handle these cases
if opts.Isolation != driver.IsolationLevel(sql.LevelDefault) || opts.ReadOnly {
return nil, driver.ErrSkip
}
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
return c.Begin()
}
}
// Commit commits the transaction
func (tx *limboTx) Commit() error {
tx.conn.Lock()
defer tx.conn.Unlock()
if tx.conn.ctx == 0 {
return errors.New("connection closed")
}
stmtPtr := connPrepare(tx.conn.ctx, "COMMIT")
if stmtPtr == 0 {
return tx.conn.getError()
}
stmt := newStmt(stmtPtr, "COMMIT")
defer stmt.Close()
_, err := stmt.Exec(nil)
return err
}
// Rollback aborts the transaction.
// Note: This operation is not currently fully supported by Limbo and will return an error.
func (tx *limboTx) Rollback() error {
tx.conn.Lock()
defer tx.conn.Unlock()
if tx.conn.ctx == 0 {
return errors.New("connection closed")
}
stmtPtr := connPrepare(tx.conn.ctx, "ROLLBACK")
if stmtPtr == 0 {
return tx.conn.getError()
}
stmt := newStmt(stmtPtr, "ROLLBACK")
defer stmt.Close()
_, err := stmt.Exec(nil)
return err
}

View file

@ -282,6 +282,81 @@ func TestDriverRowsErrorMessages(t *testing.T) {
t.Log("Rows error behavior test passed")
}
func TestTransaction(t *testing.T) {
// Open database connection
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("Error opening database: %v", err)
}
defer db.Close()
// Create a test table
_, err = db.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
if err != nil {
t.Fatalf("Error creating table: %v", err)
}
// Insert initial data
_, err = db.Exec("INSERT INTO test (id, name) VALUES (1, 'Initial')")
if err != nil {
t.Fatalf("Error inserting initial data: %v", err)
}
// Begin a transaction
tx, err := db.Begin()
if err != nil {
t.Fatalf("Error starting transaction: %v", err)
}
// Insert data within the transaction
_, err = tx.Exec("INSERT INTO test (id, name) VALUES (2, 'Transaction')")
if err != nil {
t.Fatalf("Error inserting data in transaction: %v", err)
}
// Commit the transaction
err = tx.Commit()
if err != nil {
t.Fatalf("Error committing transaction: %v", err)
}
// Verify both rows are visible after commit
rows, err := db.Query("SELECT id, name FROM test ORDER BY id")
if err != nil {
t.Fatalf("Error querying data after commit: %v", err)
}
defer rows.Close()
expected := []struct {
id int
name string
}{
{1, "Initial"},
{2, "Transaction"},
}
i := 0
for rows.Next() {
var id int
var name string
if err := rows.Scan(&id, &name); err != nil {
t.Fatalf("Error scanning row: %v", err)
}
if id != expected[i].id || name != expected[i].name {
t.Errorf("Row %d: expected (%d, %s), got (%d, %s)",
i, expected[i].id, expected[i].name, id, name)
}
i++
}
if i != 2 {
t.Fatalf("Expected 2 rows, got %d", i)
}
t.Log("Transaction test passed")
}
func TestVectorOperations(t *testing.T) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {