diff --git a/reader_base.go b/reader_base.go index d925e68..f0b0437 100644 --- a/reader_base.go +++ b/reader_base.go @@ -25,7 +25,19 @@ func getReaderBase(ctx context.Context, query dal.Query, execute executeQueryFun args := q.Args() a = make([]any, len(args)) for i, arg := range args { - a[i] = arg + // dal.QueryArg{Name, Value} is dalgo's own bind-value shape, not + // something database/sql understands directly — passing the + // struct itself as a[i] fails every call with a non-empty Args() + // ("unsupported type dal.QueryArg, a struct"). A named arg + // (Name != "") becomes a sql.NamedArg via sql.Named, so an + // @name/:name/$name placeholder in the query text binds by + // name; a positional arg (Name == "") passes its Value straight + // through for ordinary ?/$N placeholders. + if arg.Name != "" { + a[i] = sql.Named(arg.Name, arg.Value) + } else { + a[i] = arg.Value + } } case dal.StructuredQuery: // emitSQL rewrites dalgo's `SELECT TOP N` into ANSI `LIMIT N` diff --git a/reader_base_textquery_args_test.go b/reader_base_textquery_args_test.go new file mode 100644 index 0000000..a9ed635 --- /dev/null +++ b/reader_base_textquery_args_test.go @@ -0,0 +1,105 @@ +package dalgo2sql + +import ( + "context" + "testing" + + "github.com/dal-go/dalgo/dal" +) + +// TestGetReaderBase_TextQuery_PositionalArgs proves a positional +// dal.QueryArg{Value: ...} (no Name) reaches database/sql as its plain +// Value, not the dal.QueryArg struct itself — against a real, in-memory +// SQLite database (modernc.org/sqlite), not a mock, so a wrong bind value +// is a genuine driver-level error, not a mock-expectation mismatch. +func TestGetReaderBase_TextQuery_PositionalArgs(t *testing.T) { + db := openTestSQLiteDB(t, `CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT)`) + if _, err := db.Exec(`INSERT INTO widgets (id, name) VALUES (1, 'bolt'), (2, 'nut')`); err != nil { + t.Fatalf("seed insert: %v", err) + } + + query := dal.NewTextQuery("SELECT name FROM widgets WHERE id = ?", nil, dal.QueryArg{Value: 2}) + rb, err := getReaderBase(context.Background(), query, db.QueryContext) + if err != nil { + t.Fatalf("getReaderBase: %v", err) + } + defer func() { _ = rb.rows.Close() }() + + if !rb.rows.Next() { + t.Fatalf("expected one row, got none (err: %v)", rb.rows.Err()) + } + var name string + if err := rb.rows.Scan(&name); err != nil { + t.Fatalf("Scan: %v", err) + } + if name != "nut" { + t.Errorf("name = %q, want %q", name, "nut") + } +} + +// TestGetReaderBase_TextQuery_NamedArgs proves a named +// dal.QueryArg{Name: "id", Value: ...} reaches database/sql as a proper +// sql.NamedArg (via sql.Named), so a native SQL query written with an +// @-prefixed placeholder — the shape pkg/secureread.RunNativeSQL's own +// callers use — actually binds, instead of every row silently matching +// (or the driver rejecting the query outright) because the whole +// dal.QueryArg struct was passed as the bind value. +func TestGetReaderBase_TextQuery_NamedArgs(t *testing.T) { + db := openTestSQLiteDB(t, `CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT)`) + if _, err := db.Exec(`INSERT INTO widgets (id, name) VALUES (1, 'bolt'), (2, 'nut')`); err != nil { + t.Fatalf("seed insert: %v", err) + } + + query := dal.NewTextQuery("SELECT name FROM widgets WHERE id = @id", nil, dal.QueryArg{Name: "id", Value: 2}) + rb, err := getReaderBase(context.Background(), query, db.QueryContext) + if err != nil { + t.Fatalf("getReaderBase: %v", err) + } + defer func() { _ = rb.rows.Close() }() + + if !rb.rows.Next() { + t.Fatalf("expected one row, got none (err: %v)", rb.rows.Err()) + } + var name string + if err := rb.rows.Scan(&name); err != nil { + t.Fatalf("Scan: %v", err) + } + if name != "nut" { + t.Errorf("name = %q, want %q", name, "nut") + } + + if rb.rows.Next() { + t.Errorf("expected exactly one row, got a second one — the @id filter did not apply") + } +} + +// TestGetReaderBase_TextQuery_MixedArgs proves a query mixing a named and a +// positional arg binds both correctly at once. +func TestGetReaderBase_TextQuery_MixedArgs(t *testing.T) { + db := openTestSQLiteDB(t, `CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT, qty INTEGER)`) + if _, err := db.Exec(`INSERT INTO widgets (id, name, qty) VALUES (1, 'bolt', 5), (2, 'nut', 5), (3, 'nut', 9)`); err != nil { + t.Fatalf("seed insert: %v", err) + } + + query := dal.NewTextQuery( + "SELECT id FROM widgets WHERE name = @name AND qty = ?", nil, + dal.QueryArg{Name: "name", Value: "nut"}, + dal.QueryArg{Value: 5}, + ) + rb, err := getReaderBase(context.Background(), query, db.QueryContext) + if err != nil { + t.Fatalf("getReaderBase: %v", err) + } + defer func() { _ = rb.rows.Close() }() + + if !rb.rows.Next() { + t.Fatalf("expected one row, got none (err: %v)", rb.rows.Err()) + } + var id int + if err := rb.rows.Scan(&id); err != nil { + t.Fatalf("Scan: %v", err) + } + if id != 2 { + t.Errorf("id = %d, want 2", id) + } +}