From d20eb14f0a66f8525a7fcc17d7f6c77c2431f059 Mon Sep 17 00:00:00 2001 From: Nik <73077675+tmzane@users.noreply.github.com> Date: Sat, 11 Oct 2025 13:50:52 +0500 Subject: [PATCH 1/3] feat(builder): support Oracle DB placeholder style --- builder.go | 6 +- builder_test.go | 4 + tests/compose.yaml | 7 ++ tests/go.mod | 1 + tests/go.sum | 2 + tests/integration_test.go | 232 ++++++++++++++++++++------------------ 6 files changed, 143 insertions(+), 109 deletions(-) diff --git a/builder.go b/builder.go index fd25d13..b24a58b 100644 --- a/builder.go +++ b/builder.go @@ -29,6 +29,7 @@ type Builder struct { // | MySQL, MariaDB, SQLite | %? | ? | // | PostgreSQL | %$ | $N | // | Microsoft SQL Server | %@ | @pN | +// | Oracle Database | %: | :N | // ----------------------------------------------- // // Here, N is an auto-incrementing counter. @@ -68,7 +69,7 @@ type formatter struct { // Format implements [fmt.Formatter]. func (f formatter) Format(s fmt.State, verb rune) { switch verb { - case '?', '$', '@': + case '?', '$', '@', ':': if f.builder.placeholder == 0 { f.builder.placeholder = verb } @@ -96,6 +97,9 @@ func appendOne(w io.Writer, b *Builder, verb rune, arg any) { case '@': b.counter++ fmt.Fprintf(w, "@p%d", b.counter) + case ':': + b.counter++ + fmt.Fprintf(w, ":%d", b.counter) } b.args = append(b.args, arg) } diff --git a/builder_test.go b/builder_test.go index 481d1fd..510127f 100644 --- a/builder_test.go +++ b/builder_test.go @@ -39,6 +39,10 @@ func TestBuilder_dialects(t *testing.T) { format: "SELECT * FROM tbl WHERE foo = %@ AND bar = %@ AND baz = %@", query: "SELECT * FROM tbl WHERE foo = @p1 AND bar = @p2 AND baz = @p3", }, + ":": { + format: "SELECT * FROM tbl WHERE foo = %: AND bar = %: AND baz = %:", + query: "SELECT * FROM tbl WHERE foo = :1 AND bar = :2 AND baz = :3", + }, } for name, test := range tests { diff --git a/tests/compose.yaml b/tests/compose.yaml index 0c85d4b..b0905f1 100644 --- a/tests/compose.yaml +++ b/tests/compose.yaml @@ -25,3 +25,10 @@ services: environment: - ACCEPT_EULA=Y - MSSQL_SA_PASSWORD=root+1234 + oracle: + # https://hub.docker.com/r/gvenzl/oracle-free + image: gvenzl/oracle-free:latest-faststart + ports: + - 1521:1521 + environment: + - ORACLE_PASSWORD=root diff --git a/tests/go.mod b/tests/go.mod index f607783..f6c20e8 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -7,6 +7,7 @@ require ( github.com/jackc/pgx/v5 v5.7.3 github.com/lib/pq v1.10.9 github.com/microsoft/go-mssqldb v1.9.3 + github.com/sijms/go-ora/v2 v2.9.0 go-simpler.org/assert v0.9.0 go-simpler.org/queries v0.1.0 modernc.org/sqlite v1.38.0 diff --git a/tests/go.sum b/tests/go.sum index b96ebe1..4b24477 100644 --- a/tests/go.sum +++ b/tests/go.sum @@ -53,6 +53,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg= +github.com/sijms/go-ora/v2 v2.9.0/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/tests/integration_test.go b/tests/integration_test.go index de8f5b4..c01b164 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -1,6 +1,7 @@ package tests import ( + "cmp" "context" "database/sql" "database/sql/driver" @@ -10,32 +11,33 @@ import ( "github.com/go-sql-driver/mysql" pgx "github.com/jackc/pgx/v5/stdlib" "github.com/lib/pq" - mssql "github.com/microsoft/go-mssqldb" + mssqldb "github.com/microsoft/go-mssqldb" + ora "github.com/sijms/go-ora/v2" "go-simpler.org/assert" . "go-simpler.org/assert/EF" "go-simpler.org/queries" "modernc.org/sqlite" ) -// ---------------------------------------------------------------------------------------------------------------------- -// | Interface / Driver | lib/pq | jackc/pgx | go-sql-driver/mysql | modernc.org/sqlite | microsoft/go-mssqldb | -// |-----------------------------|--------|-----------|---------------------|--------------------|----------------------| -// | [driver.DriverContext] | - | + | + | - | - | -// | [driver.Pinger] | + | + | + | + | + | -// | [driver.ExecerContext] | + | + | + | + | - | -// | [driver.QueryerContext] | + | + | + | + | - | -// | [driver.ConnPrepareContext] | + | + | + | + | + | -// | [driver.ConnBeginTx] | + | + | + | + | + | -// | [driver.SessionResetter] | + | + | + | + | + | -// | [driver.Validator] | + | - | + | + | + | -// | [driver.NamedValueChecker] | - | + | + | - | + | -// ---------------------------------------------------------------------------------------------------------------------- +// ------------------------------------------------------------------------------------------------------------------------------------- +// | Interface / Driver | lib/pq | jackc/pgx | go-sql-driver/mysql | modernc.org/sqlite | microsoft/go-mssqldb | sijms/go-ora | +// |-----------------------------|--------|-----------|---------------------|--------------------|----------------------|--------------| +// | [driver.DriverContext] | - | + | + | - | - | + | +// | [driver.Pinger] | + | + | + | + | + | + | +// | [driver.ExecerContext] | + | + | + | + | - | + | +// | [driver.QueryerContext] | + | + | + | + | - | + | +// | [driver.ConnPrepareContext] | + | + | + | + | + | + | +// | [driver.ConnBeginTx] | + | + | + | + | + | + | +// | [driver.SessionResetter] | + | + | + | + | + | + | +// | [driver.Validator] | + | - | + | + | + | - | +// | [driver.NamedValueChecker] | - | + | + | - | + | + | +// ------------------------------------------------------------------------------------------------------------------------------------- // // See https://go.dev/wiki/SQLDrivers for the full list of drivers. var databases = map[string]struct { - dataSourceName string - insertFixturesQuery string - drivers map[string]driver.Driver + dataSourceName string + insertFixturesQueryFormat string + drivers map[string]driver.Driver }{ "postgres": { "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable", @@ -56,14 +58,21 @@ var databases = map[string]struct { "test.sqlite", "INSERT INTO users (id, name) VALUES (%?, %?), (%?, %?)", map[string]driver.Driver{ - "modernc.org/sqlite": new(sqlite.Driver), // https://gitlab.com/cznic/sqlite , + "modernc.org/sqlite": new(sqlite.Driver), // https://gitlab.com/cznic/sqlite }, }, "mssql": { "sqlserver://sa:root+1234@localhost:1433/msdb", "INSERT INTO users (id, name) VALUES (%@, %@), (%@, %@)", map[string]driver.Driver{ - "microsoft/go-mssqldb": new(mssql.Driver), // https://github.com/microsoft/go-mssqldb , + "microsoft/go-mssqldb": new(mssqldb.Driver), // https://github.com/microsoft/go-mssqldb + }, + }, + "oracle": { + "oracle://sys:root@localhost:1521/freepdb1", + "INSERT INTO users (id, name) VALUES (%:, %:), (%:, %:)", + map[string]driver.Driver{ + "sijms/go-ora": new(ora.OracleDriver), // https://github.com/sijms/go-ora }, }, } @@ -74,113 +83,120 @@ func TestIntegration(t *testing.T) { type dto struct { ID int `sql:"id"` Name string `sql:"name"` + + // In Oracle, all columns are uppercase. + ID2 int `sql:"ID"` + Name2 string `sql:"NAME"` } table := []dto{ {ID: 1, Name: "Alice"}, {ID: 2, Name: "Bob"}, } - for _, params := range databases { - for driverName, driverIface := range params.drivers { - var execCalls int - var queryCalls int - var prepareCalls int - - interceptor := queries.Interceptor{ - Driver: driverIface, - ExecContext: func(ctx context.Context, query string, args []driver.NamedValue, execer driver.ExecerContext) (driver.Result, error) { - execCalls++ - t.Logf("[%s] ExecContext: %s %v", driverName, query, namedToAny(args)) - return execer.ExecContext(ctx, query, args) - }, - QueryContext: func(ctx context.Context, query string, args []driver.NamedValue, queryer driver.QueryerContext) (driver.Rows, error) { - queryCalls++ - t.Logf("[%s] QueryContext: %s %v", driverName, query, namedToAny(args)) - return queryer.QueryContext(ctx, query, args) - }, - PrepareContext: func(ctx context.Context, query string, preparer driver.ConnPrepareContext) (driver.Stmt, error) { - prepareCalls++ - t.Logf("[%s] PrepareContext: %s", driverName, query) - return preparer.PrepareContext(ctx, query) - }, - } - - sql.Register(driverName, interceptor) - db, err := sql.Open(driverName, params.dataSourceName) - assert.NoErr[F](t, err) - defer db.Close() - - // wait until the database is ready. - for attempt := 0; ; attempt++ { - err := db.PingContext(ctx) - if err == nil { - break - } - if attempt == 10 { - t.Fatal(err) + for dbName, dbParams := range databases { + for driverName, driverIface := range dbParams.drivers { + t.Run(dbName+"+"+driverName, func(t *testing.T) { + var execCalls int + var queryCalls int + var prepareCalls int + + interceptor := queries.Interceptor{ + Driver: driverIface, + ExecContext: func(ctx context.Context, query string, args []driver.NamedValue, execer driver.ExecerContext) (driver.Result, error) { + execCalls++ + t.Logf("ExecContext: %s %v", query, namedToAny(args)) + return execer.ExecContext(ctx, query, args) + }, + QueryContext: func(ctx context.Context, query string, args []driver.NamedValue, queryer driver.QueryerContext) (driver.Rows, error) { + queryCalls++ + t.Logf("QueryContext: %s %v", query, namedToAny(args)) + return queryer.QueryContext(ctx, query, args) + }, + PrepareContext: func(ctx context.Context, query string, preparer driver.ConnPrepareContext) (driver.Stmt, error) { + prepareCalls++ + t.Logf("PrepareContext: %s", query) + return preparer.PrepareContext(ctx, query) + }, } - time.Sleep(time.Second) - } - _, err = db.ExecContext(ctx, "CREATE TABLE users (id INTEGER PRIMARY KEY, name VARCHAR(8))") - assert.NoErr[F](t, err) + driverName += "+interceptor" + sql.Register(driverName, interceptor) - query, args := queries.Build(params.insertFixturesQuery, - table[0].ID, table[0].Name, - table[1].ID, table[1].Name, - ) - _, err = db.ExecContext(ctx, query, args...) - assert.NoErr[F](t, err) - - tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable}) - assert.NoErr[F](t, err) - defer tx.Rollback() - - for _, queryer := range []queries.Queryer{db, tx} { - _, err := queries.QueryRow[string](ctx, queryer, "SELECT name FROM users WHERE id = 0") - assert.IsErr[E](t, err, sql.ErrNoRows) + db, err := sql.Open(driverName, dbParams.dataSourceName) + assert.NoErr[F](t, err) + defer db.Close() + + for attempt := 0; ; attempt++ { + err := db.PingContext(ctx) + if err == nil { + break + } + if attempt == 10 { + t.Fatal(err) + } + time.Sleep(time.Second) + } - name, err := queries.QueryRow[string](ctx, queryer, "SELECT name FROM users WHERE id = 1") + _, err = db.ExecContext(ctx, "CREATE TABLE users (id INTEGER PRIMARY KEY, name VARCHAR(8))") assert.NoErr[F](t, err) - assert.Equal[E](t, name, table[0].Name) - names, err := queries.Collect(queries.Query[string](ctx, queryer, "SELECT name FROM users")) + query, args := queries.Build(dbParams.insertFixturesQueryFormat, + table[0].ID, table[0].Name, + table[1].ID, table[1].Name, + ) + _, err = db.ExecContext(ctx, query, args...) assert.NoErr[F](t, err) - assert.Equal[E](t, names, []string{table[0].Name, table[1].Name}) - user, err := queries.QueryRow[dto](ctx, queryer, "SELECT id, name FROM users WHERE id = 1") + tx, err := db.BeginTx(ctx, nil) assert.NoErr[F](t, err) - assert.Equal[E](t, user.ID, table[0].ID) - assert.Equal[E](t, user.Name, table[0].Name) + defer tx.Rollback() + + for _, queryer := range []queries.Queryer{db, tx} { + _, err := queries.QueryRow[string](ctx, queryer, "SELECT name FROM users WHERE id = 0") + assert.IsErr[E](t, err, sql.ErrNoRows) - var i int - for user, err := range queries.Query[dto](ctx, queryer, "SELECT id, name FROM users ORDER BY id") { + name, err := queries.QueryRow[string](ctx, queryer, "SELECT name FROM users WHERE id = 1") assert.NoErr[F](t, err) - assert.Equal[E](t, user.ID, table[i].ID) - assert.Equal[E](t, user.Name, table[i].Name) - i++ + assert.Equal[E](t, name, table[0].Name) + + names, err := queries.Collect(queries.Query[string](ctx, queryer, "SELECT name FROM users")) + assert.NoErr[F](t, err) + assert.Equal[E](t, names, []string{table[0].Name, table[1].Name}) + + user, err := queries.QueryRow[dto](ctx, queryer, "SELECT id, name FROM users WHERE id = 1") + assert.NoErr[F](t, err) + assert.Equal[E](t, cmp.Or(user.ID, user.ID2), table[0].ID) + assert.Equal[E](t, cmp.Or(user.Name, user.Name2), table[0].Name) + + var i int + for user, err := range queries.Query[dto](ctx, queryer, "SELECT id, name FROM users ORDER BY id") { + assert.NoErr[F](t, err) + assert.Equal[E](t, cmp.Or(user.ID, user.ID2), table[i].ID) + assert.Equal[E](t, cmp.Or(user.Name, user.Name2), table[i].Name) + i++ + } + } + + assert.NoErr[F](t, tx.Commit()) + + _, err = db.ExecContext(ctx, "DROP TABLE users") + assert.NoErr[F](t, err) + + switch db.Driver().(type) { + case *mysql.MySQLDriver: // falls back to PrepareContext for queries with arguments. + assert.Equal[E](t, execCalls, 3) + assert.Equal[E](t, queryCalls, 5*2) + assert.Equal[E](t, prepareCalls, 1) + case *mssqldb.Driver: // always uses PrepareContext. + assert.Equal[E](t, execCalls, 0) + assert.Equal[E](t, queryCalls, 0) + assert.Equal[E](t, prepareCalls, 3+5*2) + default: + assert.Equal[E](t, execCalls, 3) + assert.Equal[E](t, queryCalls, 5*2) + assert.Equal[E](t, prepareCalls, 0) } - } - - assert.NoErr[F](t, tx.Commit()) - - _, err = db.ExecContext(ctx, "DROP TABLE users") - assert.NoErr[F](t, err) - - switch db.Driver().(type) { - case *mysql.MySQLDriver: // falls back to PrepareContext for queries with arguments. - assert.Equal[E](t, execCalls, 3) - assert.Equal[E](t, queryCalls, 5*2) - assert.Equal[E](t, prepareCalls, 1) - case *mssql.Driver: // always uses PrepareContext. - assert.Equal[E](t, execCalls, 0) - assert.Equal[E](t, queryCalls, 0) - assert.Equal[E](t, prepareCalls, 3+5*2) - default: - assert.Equal[E](t, execCalls, 3) - assert.Equal[E](t, queryCalls, 5*2) - assert.Equal[E](t, prepareCalls, 0) - } + }) } } } From 027028f22e16f5fb3434f1cf4d7c5d4129f044e8 Mon Sep 17 00:00:00 2001 From: Nik <73077675+tmzane@users.noreply.github.com> Date: Sat, 11 Oct 2025 13:54:15 +0500 Subject: [PATCH 2/3] update README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 130ee57..cee2cb1 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Integration tests cover the following databases and drivers: - MySQL with [go-sql-driver/mysql][5] - SQLite with [modernc.org/sqlite][6] - Microsoft SQL Server with [microsoft/go-mssqldb][7] +- Oracle Database with [sijms/go-ora][8] See [integration_test.go](tests/integration_test.go) for details. @@ -120,3 +121,4 @@ See [integration_test.go](tests/integration_test.go) for details. [5]: https://github.com/go-sql-driver/mysql [6]: https://gitlab.com/cznic/sqlite [7]: https://github.com/microsoft/go-mssqldb +[8]: https://github.com/sijms/go-ora From ba3639f255dea745f5087d813989b7787e2014dd Mon Sep 17 00:00:00 2001 From: Nik <73077675+tmzane@users.noreply.github.com> Date: Sat, 11 Oct 2025 13:56:11 +0500 Subject: [PATCH 3/3] update README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cee2cb1..a5295b9 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ db.QueryContext(ctx, query, args...) The following database placeholders are supported: - `?` (used by MySQL and SQLite) - `$1`, `$2`, ..., `$N` (used by PostgreSQL) -- `@p1`, `@p2`, ..., `@pN` (used by MSSQL) +- `@p1`, `@p2`, ..., `@pN` (used by Microsoft SQL Server) +- `:1`, `:2`, ..., `:N` (used by Oracle Database): ### Scanner