4 Commits

20 changed files with 175 additions and 68 deletions

2
.gitignore vendored
View File

@@ -25,4 +25,4 @@ go.work.sum
# env file # env file
.env .env
example/local_* playground/local_*

View File

@@ -1,5 +1,74 @@
# pgm (Postgres Mapper) # pgm - PostgreSQL query mapper
A simple ORM that will work on top of [jackc/pgx](https://github.com/jackc/pgx) db connection pool.
Simple query builder to work with Go:PG apps.
Will work along side with [dbmate](https://github.com/amacneil/dbmate), will consume schema.sql file created by dbmate ## ORMs in go eco system tha I like
- [ent](https://github.com/ent/ent)
- [sqlc](https://github.com/sqlc-dev/sqlc)
## Why not to use ent?
ent is feature loadedm, you can easily define complex schemas, it has nice integration with graphQL.
Very nice auto migration feature. Basically all the things you need in an ORM.
But to me its overkill for simple apps, I have seen ent related code is taking significatn space in app binary(feels bloated).
## Why not to use sqlc?
sqlc is nice as well, but using it will start feeling that now your DB layer has its own Models and you need map you apps model with it or have no choice but to sart using DB layer models, which to me is not a good thing.
## Things that I am not happy with
- Auto migrations, at many points you will see ORM is either not providing or have a complex way to do the simple db schema related changes. Its like obscure db schema. you can simply tell and test it in sql query editor.
DB must be a in form sql statement that you see, fine tune and event run/test in sql query editor.
Thers is lots of mature tools available in Go ecosystem, [dbmate](https://github.com/amacneil/dbmate) is one of them, its a nice tool to manage migrations, can be use in code or in cli
- To much extra code is generated for various condition and scenarios that you may not use
- Auto genearted models for select queries that will now force you to either use them or to map them to required model.
## Okay, what to do now, plain old sql queries?
Yes and No may hybrid. Plain old sql queries are not bad, just have have few concerns
- Change in schema are not detected well.
- Sql in jection issue if not using parameterized queries.
We can address these issues using pgm, pgm cli will help to cream light weight DB schema go files that will help in writing sql queries this can help keeping eye on schema changes, goal is not to hard code table and table.column names
## Generate pgm schema files
run following command to generate pgm schema files
```bash
go run code.patial.tech/go/pgm/cmd -o ./db ./schema.sql
```
It will create bunch of files and filders under `./db` directory
```go
package main
import "code.patial.tech/go/pgm"
type MyModel struct {
ID string
Email string
}
func main() {
println("init pgx connection pool")
pgm.InitPool(pgm.Config{
ConnString: url,
})
// Select query with first record to scan
// it is assuming that schema is already created and is in "db" package and has User table init
var v MyModel
err := db.User.Select(user.ID, user.Email).
Where(user.Email.Like("anki%")).
First(context.TODO(), &v.Email, &v.ID)
if err != nil {
println("error, ", err.Error())
return
}
println("user email", v.Email)
}
```

View File

@@ -36,7 +36,7 @@ func generate(scheamPath, outDir string) error {
// schema.go will hold all tables info // schema.go will hold all tables info
var sb strings.Builder var sb strings.Builder
sb.WriteString("// Code generated by code.patial.tech/go/pgm/cmd\n// DO NOT EDIT.\n\n") sb.WriteString("// Code generated by code.patial.tech/go/pgm/cmd DO NOT EDIT.\n\n")
sb.WriteString(fmt.Sprintf("package %s \n", filepath.Base(outDir))) sb.WriteString(fmt.Sprintf("package %s \n", filepath.Base(outDir)))
sb.WriteString(` sb.WriteString(`
import "code.patial.tech/go/pgm" import "code.patial.tech/go/pgm"
@@ -91,7 +91,7 @@ func generate(scheamPath, outDir string) error {
func writeColFile(tblName string, cols []*Column, outDir string, caser cases.Caser) error { func writeColFile(tblName string, cols []*Column, outDir string, caser cases.Caser) error {
var sb strings.Builder var sb strings.Builder
sb.WriteString("// Code generated by db-gen. DO NOT EDIT.\n\n") sb.WriteString("// Code generated by code.patial.tech/go/pgm/cmd DO NOT EDIT.\n\n")
sb.WriteString(fmt.Sprintf("package %s\n\n", filepath.Base(outDir))) sb.WriteString(fmt.Sprintf("package %s\n\n", filepath.Base(outDir)))
sb.WriteString(fmt.Sprintf("import %q\n\n", "code.patial.tech/go/pgm")) sb.WriteString(fmt.Sprintf("import %q\n\n", "code.patial.tech/go/pgm"))
sb.WriteString("const (") sb.WriteString("const (")

9
go.mod
View File

@@ -3,7 +3,7 @@ module code.patial.tech/go/pgm
go 1.24.5 go 1.24.5
require ( require (
github.com/jackc/pgx v3.6.2+incompatible github.com/jackc/pgx/v5 v5.7.5
golang.org/x/text v0.27.0 golang.org/x/text v0.27.0
) )
@@ -11,11 +11,6 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/sync v0.16.0 // indirect golang.org/x/sync v0.16.0 // indirect
) )
require (
github.com/jackc/pgx/v5 v5.7.5
github.com/pkg/errors v0.9.1 // indirect
golang.org/x/crypto v0.40.0 // indirect
)

11
go.sum
View File

@@ -1,20 +1,21 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o=
github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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= 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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
@@ -23,3 +24,5 @@ golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

54
pgm.go
View File

@@ -4,11 +4,9 @@
package pgm package pgm
import ( import (
"errors"
"strings" "strings"
"time" "time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
@@ -51,6 +49,31 @@ func (f Field) Avg() Field {
return Field("AVG(" + f.String() + ")") return Field("AVG(" + f.String() + ")")
} }
func (f Field) Sum() Field {
return Field("SUM(" + f.String() + ")")
}
func (f Field) Max() Field {
return Field("MAX(" + f.String() + ")")
}
func (f Field) Min() Field {
return Field("Min(" + f.String() + ")")
}
func (f Field) Lower() Field {
return Field("LOWER(" + f.String() + ")")
}
func (f Field) Upper() Field {
return Field("UPPER(" + f.String() + ")")
}
func (f Field) Trim() Field {
return Field("TRIM(" + f.String() + ")")
}
// Eq is equal
func (f Field) Eq(val any) Conditioner { func (f Field) Eq(val any) Conditioner {
col := f.String() col := f.String()
return &Cond{Field: col, Val: val, op: " = $", len: len(col) + 5} return &Cond{Field: col, Val: val, op: " = $", len: len(col) + 5}
@@ -72,11 +95,21 @@ func (f Field) Gt(val any) Conditioner {
return &Cond{Field: col, Val: val, op: " > $", len: len(col) + 5} return &Cond{Field: col, Val: val, op: " > $", len: len(col) + 5}
} }
func (f Field) Lt(val any) Conditioner {
col := f.String()
return &Cond{Field: col, Val: val, op: " < $", len: len(col) + 5}
}
func (f Field) Gte(val any) Conditioner { func (f Field) Gte(val any) Conditioner {
col := f.String() col := f.String()
return &Cond{Field: col, Val: val, op: " >= $", len: len(col) + 5} return &Cond{Field: col, Val: val, op: " >= $", len: len(col) + 5}
} }
func (f Field) Lte(val any) Conditioner {
col := f.String()
return &Cond{Field: col, Val: val, op: " <= $", len: len(col) + 5}
}
func (f Field) Like(val string) Conditioner { func (f Field) Like(val string) Conditioner {
col := f.String() col := f.String()
return &Cond{Field: col, Val: val, op: " LIKE $", len: len(f.String()) + 5} return &Cond{Field: col, Val: val, op: " LIKE $", len: len(f.String()) + 5}
@@ -116,19 +149,14 @@ func PgTimeNow() pgtype.Timestamptz {
return pgtype.Timestamptz{Time: time.Now(), Valid: true} return pgtype.Timestamptz{Time: time.Now(), Valid: true}
} }
// IsNotFound error check func ConcatWs(sep string, fields ...Field) Field {
func IsNotFound(err error) bool { return Field("concat_ws('" + sep + "'," + joinFileds(fields) + ")")
return errors.Is(err, pgx.ErrNoRows)
} }
func ConcatWs(sep string, fields ...Field) string { func StringAgg(exp, sep string) Field {
return "concat_ws('" + sep + "'," + joinFileds(fields) + ")" return Field("string_agg(" + exp + ",'" + sep + "')")
} }
func StringAgg(exp, sep string) string { func StringAggCast(exp, sep string) Field {
return "string_agg(" + exp + ",'" + sep + "')" return Field("string_agg(cast(" + exp + " as varchar),'" + sep + "')")
}
func StringAggCast(exp, sep string) string {
return "string_agg(cast(" + exp + " as varchar),'" + sep + "')"
} }

View File

@@ -1,3 +1,3 @@
//go:generate go run code.patial.tech/go/pgm/cmd -o ./db ./schema.sql //go:generate go run code.patial.tech/go/pgm/cmd -o ./db ./schema.sql
package example package playground

View File

@@ -1,10 +1,10 @@
package example package playground
import ( import (
"testing" "testing"
"code.patial.tech/go/pgm/example/db" "code.patial.tech/go/pgm/playground/db"
"code.patial.tech/go/pgm/example/db/user" "code.patial.tech/go/pgm/playground/db/user"
) )
func TestDelete(t *testing.T) { func TestDelete(t *testing.T) {

View File

@@ -1,11 +1,11 @@
package example package playground
import ( import (
"testing" "testing"
"code.patial.tech/go/pgm" "code.patial.tech/go/pgm"
"code.patial.tech/go/pgm/example/db" "code.patial.tech/go/pgm/playground/db"
"code.patial.tech/go/pgm/example/db/user" "code.patial.tech/go/pgm/playground/db/user"
) )
func TestInsertQuery(t *testing.T) { func TestInsertQuery(t *testing.T) {

View File

@@ -1,14 +1,14 @@
package example package playground
import ( import (
"testing" "testing"
"code.patial.tech/go/pgm" "code.patial.tech/go/pgm"
"code.patial.tech/go/pgm/example/db" "code.patial.tech/go/pgm/playground/db"
"code.patial.tech/go/pgm/example/db/branchuser" "code.patial.tech/go/pgm/playground/db/branchuser"
"code.patial.tech/go/pgm/example/db/employee" "code.patial.tech/go/pgm/playground/db/employee"
"code.patial.tech/go/pgm/example/db/user" "code.patial.tech/go/pgm/playground/db/user"
"code.patial.tech/go/pgm/example/db/usersession" "code.patial.tech/go/pgm/playground/db/usersession"
) )
func TestQryBuilder2(t *testing.T) { func TestQryBuilder2(t *testing.T) {

View File

@@ -1,11 +1,11 @@
package example package playground
import ( import (
"testing" "testing"
"code.patial.tech/go/pgm" "code.patial.tech/go/pgm"
"code.patial.tech/go/pgm/example/db" "code.patial.tech/go/pgm/playground/db"
"code.patial.tech/go/pgm/example/db/user" "code.patial.tech/go/pgm/playground/db/user"
) )
func TestUpdateQuery(t *testing.T) { func TestUpdateQuery(t *testing.T) {

56
pool.go
View File

@@ -24,40 +24,45 @@ var (
}, },
} }
ErrInitTX = errors.New("failed to init db.tx") ErrConnStringMissing = errors.New("connection string is empty")
ErrCommitTX = errors.New("failed to commit db.tx") ErrInitTX = errors.New("failed to init db.tx")
ErrNoRows = errors.New("no data found") ErrCommitTX = errors.New("failed to commit db.tx")
ErrNoRows = errors.New("no data found")
) )
type Config struct { type Config struct {
ConnString string
MaxConns int32 MaxConns int32
MinConns int32 MinConns int32
MaxConnLifetime time.Duration MaxConnLifetime time.Duration
MaxConnIdleTime time.Duration MaxConnIdleTime time.Duration
} }
func Init(connString string, conf *Config) { // InitPool will create new pgxpool.Pool and will keep it for its working
cfg, err := pgxpool.ParseConfig(connString) func InitPool(conf Config) {
if conf.ConnString == "" {
panic(ErrConnStringMissing)
}
cfg, err := pgxpool.ParseConfig(conf.ConnString)
if err != nil { if err != nil {
panic(err) panic(err)
} }
if conf != nil { if conf.MaxConns > 0 {
if conf.MaxConns > 0 { cfg.MaxConns = conf.MaxConns // 100
cfg.MaxConns = conf.MaxConns // 100 }
}
if conf.MinConns > 0 { if conf.MinConns > 0 {
cfg.MinConns = conf.MaxConns // 5 cfg.MinConns = conf.MaxConns // 5
} }
if conf.MaxConnLifetime > 0 { if conf.MaxConnLifetime > 0 {
cfg.MaxConnLifetime = conf.MaxConnLifetime // time.Minute * 10 cfg.MaxConnLifetime = conf.MaxConnLifetime // time.Minute * 10
} }
if conf.MaxConnIdleTime > 0 { if conf.MaxConnIdleTime > 0 {
cfg.MaxConnIdleTime = conf.MaxConnIdleTime // time.Minute * 5 cfg.MaxConnIdleTime = conf.MaxConnIdleTime // time.Minute * 5
}
} }
p, err := pgxpool.NewWithConfig(context.Background(), cfg) p, err := pgxpool.NewWithConfig(context.Background(), cfg)
@@ -72,10 +77,6 @@ func Init(connString string, conf *Config) {
poolPGX.Store(p) poolPGX.Store(p)
} }
func GetPool() *pgxpool.Pool {
return poolPGX.Load()
}
// get string builder from pool // get string builder from pool
func getSB() *strings.Builder { func getSB() *strings.Builder {
return poolStringBuilder.Get().(*strings.Builder) return poolStringBuilder.Get().(*strings.Builder)
@@ -87,6 +88,12 @@ func putSB(sb *strings.Builder) {
poolStringBuilder.Put(sb) poolStringBuilder.Put(sb)
} }
// GetPool instance
func GetPool() *pgxpool.Pool {
return poolPGX.Load()
}
// BeginTx begins a pgx poll transaction
func BeginTx(ctx context.Context) (pgx.Tx, error) { func BeginTx(ctx context.Context) (pgx.Tx, error) {
tx, err := poolPGX.Load().Begin(ctx) tx, err := poolPGX.Load().Begin(ctx)
if err != nil { if err != nil {
@@ -96,3 +103,8 @@ func BeginTx(ctx context.Context) (pgx.Tx, error) {
return tx, err return tx, err
} }
// IsNotFound error check
func IsNotFound(err error) bool {
return errors.Is(err, pgx.ErrNoRows)
}