diff --git a/.golangci.yml b/.golangci.yml index 19232d9..9e436d5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -67,5 +67,8 @@ issues: - linters: - errcheck # Error checking omitted for brevity. - gosec + - wsl + - ineffassign + - wastedassign path: "example_" diff --git a/README.md b/README.md index 512304b..ac362ac 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Field tags (`db` by default) act as a source of truth for column names to allow `Storage` is a high level service that provides query building, query executing and result fetching facilities as easy to use facades. +`StorageOf[V any]` typed query builder and scanner for specific table(s). + `Mapper` is a lower level tool that focuses on managing `squirrel` query builder with row structures. `Referencer` helps to build complex statements by providing fully qualified and properly escaped names for @@ -37,6 +39,10 @@ s, _ := sqluct.Open( "postgres://pqgotest:password@localhost/pqgotest?sslmode=disable", ) +// Or if you already have an *sql.DB or *sqlx.DB instances, you can use them: +// db, _ := sql.Open("postgres", "postgres://pqgotest:password@localhost/pqgotest?sslmode=disable") +// s := sqluct.NewStorage(sqlx.NewDb(db, "postgres")) + ctx := context.TODO() const tableName = "products" @@ -162,6 +168,106 @@ fmt.Println(args) // [John] ``` +## Typed Storage + +`sqluct.Table[RowType](storageInstance, tableName)` creates a type-safe storage accessor to a table with `RowType`. +This accessor can help to retrieve or store data. Columns from multiple tables can be joined using field pointers. + +Please check features overview in an example below. + +```go +var ( + st = sqluct.NewStorage(sqlx.NewDb(sql.OpenDB(dumpConnector{}), "postgres")) + ctx = context.Background() +) + +st.IdentifierQuoter = sqluct.QuoteANSI + +type User struct { + ID int `db:"id"` + RoleID int `db:"role_id"` + Name string `db:"name"` +} + +// Users repository. +ur := sqluct.Table[User](st, "users") + +// Pointer to row, that can be used to reference columns via struct fields. +_ = ur.R + +// Single user record can be inserted, last insert id (if available) and error are returned. +fmt.Println("Insert single user.") +_, _ = ur.InsertRow(ctx, User{Name: "John Doe", ID: 123}) + +// Multiple user records can be inserted with sql.Result and error returned. +fmt.Println("Insert two users.") +_, _ = ur.InsertRows(ctx, []User{{Name: "Jane Doe", ID: 124}, {Name: "Richard Roe", ID: 125}}) + +// Update statement for a single user with condition. +fmt.Println("Update a user with new name.") +_, _ = ur.UpdateStmt(User{Name: "John Doe, Jr.", ID: 123}).Where(ur.Eq(&ur.R.ID, 123)).ExecContext(ctx) + +// Delete statement for a condition. +fmt.Println("Delete a user with id 123.") +_, _ = ur.DeleteStmt().Where(ur.Eq(&ur.R.ID, 123)).ExecContext(ctx) + +fmt.Println("Get single user with id = 123.") +user, _ := ur.Get(ctx, ur.SelectStmt().Where(ur.Eq(&ur.R.ID, 123))) + +// Squirrel expression can be formatted with %s reference(s) to column pointer. +fmt.Println("Get multiple users with names starting with 'John '.") +users, _ := ur.List(ctx, ur.SelectStmt().Where(ur.Fmt("%s LIKE ?", &ur.R.Name), "John %")) + +// Squirrel expressions can be applied. +fmt.Println("Get multiple users with id != 123.") +users, _ = ur.List(ctx, ur.SelectStmt().Where(squirrel.NotEq(ur.Eq(&ur.R.ID, 123)))) + +fmt.Println("Get all users.") +users, _ = ur.List(ctx, ur.SelectStmt()) + +// More complex statements can be made with references to other tables. + +type Role struct { + ID int `db:"id"` + Name string `db:"name"` +} + +// Roles repository. +rr := sqluct.Table[Role](st, "roles") + +// To be able to resolve "roles" columns, we need to attach roles repo to users repo. +ur.AddTableAlias(rr.R, "roles") + +fmt.Println("Get users with role 'admin'.") +users, _ = ur.List(ctx, ur.SelectStmt(). + LeftJoin(ur.Fmt("%s ON %s = %s", rr.R, &rr.R.ID, &ur.R.RoleID)). + Where(ur.Fmt("%s = ?", &rr.R.Name), "admin"), +) + +_ = user +_ = users + +// Output: +// Insert single user. +// exec INSERT INTO "users" ("id","role_id","name") VALUES ($1,$2,$3) [123 0 John Doe] +// Insert two users. +// exec INSERT INTO "users" ("id","role_id","name") VALUES ($1,$2,$3),($4,$5,$6) [124 0 Jane Doe 125 0 Richard Roe] +// Update a user with new name. +// exec UPDATE "users" SET "id" = $1, "role_id" = $2, "name" = $3 WHERE "users"."id" = $4 [123 0 John Doe, Jr. 123] +// Delete a user with id 123. +// exec DELETE FROM "users" WHERE "users"."id" = $1 [123] +// Get single user with id = 123. +// query SELECT "users"."id", "users"."role_id", "users"."name" FROM "users" WHERE "users"."id" = $1 [123] +// Get multiple users with names starting with 'John '. +// query SELECT "users"."id", "users"."role_id", "users"."name" FROM "users" WHERE "users"."name" LIKE $1 [John %] +// Get multiple users with id != 123. +// query SELECT "users"."id", "users"."role_id", "users"."name" FROM "users" WHERE "users"."id" <> $1 [123] +// Get all users. +// query SELECT "users"."id", "users"."role_id", "users"."name" FROM "users" [] +// Get users with role 'admin'. +// query SELECT "users"."id", "users"."role_id", "users"."name" FROM "users" LEFT JOIN "roles" ON "roles"."id" = "users"."role_id" WHERE "roles"."name" = $1 [admin] +``` + ## Omitting Zero Values When building `WHERE` conditions from row structure it is often needed skip empty fields from condition. diff --git a/example_storageof_test.go b/example_storageof_test.go new file mode 100644 index 0000000..b3cb8e1 --- /dev/null +++ b/example_storageof_test.go @@ -0,0 +1,107 @@ +//go:build go1.18 +// +build go1.18 + +package sqluct_test + +import ( + "context" + "database/sql" + "fmt" + + "github.com/Masterminds/squirrel" + "github.com/bool64/sqluct" + "github.com/jmoiron/sqlx" +) + +func ExampleTable() { + var ( + st = sqluct.NewStorage(sqlx.NewDb(sql.OpenDB(dumpConnector{}), "postgres")) + ctx = context.Background() + ) + + st.IdentifierQuoter = sqluct.QuoteANSI + + type User struct { + ID int `db:"id"` + RoleID int `db:"role_id"` + Name string `db:"name"` + } + + // Users repository. + ur := sqluct.Table[User](st, "users") + + // Pointer to row, that can be used to reference columns via struct fields. + _ = ur.R + + // Single user record can be inserted, last insert id (if available) and error are returned. + fmt.Println("Insert single user.") + _, _ = ur.InsertRow(ctx, User{Name: "John Doe", ID: 123}) + + // Multiple user records can be inserted with sql.Result and error returned. + fmt.Println("Insert two users.") + _, _ = ur.InsertRows(ctx, []User{{Name: "Jane Doe", ID: 124}, {Name: "Richard Roe", ID: 125}}) + + // Update statement for a single user with condition. + fmt.Println("Update a user with new name.") + _, _ = ur.UpdateStmt(User{Name: "John Doe, Jr.", ID: 123}).Where(ur.Eq(&ur.R.ID, 123)).ExecContext(ctx) + + // Delete statement for a condition. + fmt.Println("Delete a user with id 123.") + _, _ = ur.DeleteStmt().Where(ur.Eq(&ur.R.ID, 123)).ExecContext(ctx) + + fmt.Println("Get single user with id = 123.") + user, _ := ur.Get(ctx, ur.SelectStmt().Where(ur.Eq(&ur.R.ID, 123))) + + // Squirrel expression can be formatted with %s reference(s) to column pointer. + fmt.Println("Get multiple users with names starting with 'John '.") + users, _ := ur.List(ctx, ur.SelectStmt().Where(ur.Fmt("%s LIKE ?", &ur.R.Name), "John %")) + + // Squirrel expressions can be applied. + fmt.Println("Get multiple users with id != 123.") + users, _ = ur.List(ctx, ur.SelectStmt().Where(squirrel.NotEq(ur.Eq(&ur.R.ID, 123)))) + + fmt.Println("Get all users.") + users, _ = ur.List(ctx, ur.SelectStmt()) + + // More complex statements can be made with references to other tables. + + type Role struct { + ID int `db:"id"` + Name string `db:"name"` + } + + // Roles repository. + rr := sqluct.Table[Role](st, "roles") + + // To be able to resolve "roles" columns, we need to attach roles repo to users repo. + ur.AddTableAlias(rr.R, "roles") + + fmt.Println("Get users with role 'admin'.") + users, _ = ur.List(ctx, ur.SelectStmt(). + LeftJoin(ur.Fmt("%s ON %s = %s", rr.R, &rr.R.ID, &ur.R.RoleID)). + Where(ur.Fmt("%s = ?", &rr.R.Name), "admin"), + ) + + _ = user + _ = users + + // Output: + // Insert single user. + // exec INSERT INTO "users" ("id","role_id","name") VALUES ($1,$2,$3) [123 0 John Doe] + // Insert two users. + // exec INSERT INTO "users" ("id","role_id","name") VALUES ($1,$2,$3),($4,$5,$6) [124 0 Jane Doe 125 0 Richard Roe] + // Update a user with new name. + // exec UPDATE "users" SET "id" = $1, "role_id" = $2, "name" = $3 WHERE "users"."id" = $4 [123 0 John Doe, Jr. 123] + // Delete a user with id 123. + // exec DELETE FROM "users" WHERE "users"."id" = $1 [123] + // Get single user with id = 123. + // query SELECT "users"."id", "users"."role_id", "users"."name" FROM "users" WHERE "users"."id" = $1 [123] + // Get multiple users with names starting with 'John '. + // query SELECT "users"."id", "users"."role_id", "users"."name" FROM "users" WHERE "users"."name" LIKE $1 [John %] + // Get multiple users with id != 123. + // query SELECT "users"."id", "users"."role_id", "users"."name" FROM "users" WHERE "users"."id" <> $1 [123] + // Get all users. + // query SELECT "users"."id", "users"."role_id", "users"."name" FROM "users" [] + // Get users with role 'admin'. + // query SELECT "users"."id", "users"."role_id", "users"."name" FROM "users" LEFT JOIN "roles" ON "roles"."id" = "users"."role_id" WHERE "roles"."name" = $1 [admin] +} diff --git a/go.mod b/go.mod index 72d9a95..4dae043 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/Masterminds/squirrel v1.5.4 github.com/bool64/ctxd v1.2.1 github.com/bool64/dev v0.2.34 - github.com/jmoiron/sqlx v1.3.5 + github.com/jmoiron/sqlx v1.4.0 github.com/stretchr/testify v1.8.2 ) diff --git a/go.sum b/go.sum index 0b23c1d..0a90f0c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= @@ -9,19 +11,19 @@ github.com/bool64/dev v0.2.34/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= -github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= -github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= -github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/referencer.go b/referencer.go index 4eda903..80ba796 100644 --- a/referencer.go +++ b/referencer.go @@ -70,25 +70,25 @@ type Referencer struct { // // Argument is either a structure pointer or string alias. func (r *Referencer) ColumnsOf(rowStructPtr interface{}) func(o *Options) { - var table string + var table Quoted switch v := rowStructPtr.(type) { case string: - table = v + table = r.Q(v) case Quoted: - table = string(v) + table = v default: t, found := r.refs[rowStructPtr] if !found { panic("row structure pointer needs to be added first with AddTableAlias") } - table = string(t) + table = t } return func(o *Options) { o.PrepareColumn = func(col string) string { - return string(r.Q(table, col)) + return string(table + "." + r.Q(col)) } } } diff --git a/storage_go1.18.go b/storage_go1.18.go index ca7a26c..c092148 100644 --- a/storage_go1.18.go +++ b/storage_go1.18.go @@ -93,6 +93,12 @@ func (s *StorageOf[V]) Get(ctx context.Context, qb ToSQL) (V, error) { // SelectStmt creates query statement with table name and row columns. func (s *StorageOf[V]) SelectStmt(options ...func(*Options)) squirrel.SelectBuilder { + if len(options) == 0 { + options = []func(*Options){ + s.ColumnsOf(s.R), + } + } + return s.s.SelectStmt(s.tableName, s.R, options...) } diff --git a/storage_test.go b/storage_test.go index e4d36c9..d0611c6 100644 --- a/storage_test.go +++ b/storage_test.go @@ -2,7 +2,9 @@ package sqluct_test import ( "context" + "database/sql/driver" "errors" + "fmt" "testing" sqlmock "github.com/DATA-DOG/go-sqlmock" @@ -13,6 +15,59 @@ import ( "github.com/stretchr/testify/require" ) +type ( + dumpConnector struct{} + dumpDriver struct{} + dumpConn struct{} + dumpStmt struct{ query string } +) + +func (d dumpStmt) Close() error { + return nil +} + +func (d dumpStmt) NumInput() int { + return -1 +} + +func (d dumpStmt) Exec(args []driver.Value) (driver.Result, error) { + fmt.Println("exec", d.query, args) + + return nil, errors.New("skip") +} + +func (d dumpStmt) Query(args []driver.Value) (driver.Rows, error) { + fmt.Println("query", d.query, args) + + return nil, errors.New("skip") +} + +func (d dumpConn) Prepare(query string) (driver.Stmt, error) { + return dumpStmt{query: query}, nil +} + +func (d dumpConn) Close() error { + return nil +} + +func (d dumpConn) Begin() (driver.Tx, error) { + return nil, nil +} + +func (d dumpDriver) Open(name string) (driver.Conn, error) { + fmt.Println("open", name) + + return dumpConn{}, nil +} + +func (d dumpConnector) Connect(_ context.Context) (driver.Conn, error) { + return dumpConn{}, nil +} + +func (d dumpConnector) Driver() driver.Driver { + return dumpDriver{} +} + func TestStorage_InTx_FailToStart(t *testing.T) { db, mock, err := sqlmock.New() require.NoError(t, err)