protoc-gen-pgx-orm is a powerful Protocol Buffers plugin that automatically generates type-safe ORM (Object-Relational Mapping) for PostgreSQL using the PGX v5 library. The project allows creating complete SQL schemas and Go code for database operations based on protobuf definitions.
- Automatic SQL DDL generation from protobuf messages
- Type-safe Go ORM with full PGX v5 support
- Support for all major PostgreSQL SQL types
- Table relationships (One-to-Many, Many-to-Many)
- Virtual fields and computed columns
- Embedded messages support
- OneOf fields with automatic serialization
- Arrays and complex data types
- Custom casters for type transformations
- Connection pools and transactions
TEXT,INTEGER,BIGINT,SMALLINTDOUBLE_PRECISION,REALBOOLEAN,TIMESTAMPTZCHAR,JSONB,HSTORE- Arrays of all supported types
- Installation
- Quick Start
- Project Architecture
- Protobuf Syntax
- Code Generation
- Using the ORM
- Advanced Features
- Examples
- API Reference
- Go 1.24.3+
- Protocol Buffers compiler (protoc)
- PostgreSQL
go install github.com/yaroher/protoc-gen-pgx-orm@latestgit clone https://github.com/yaroher/protoc-gen-pgx-orm.git
cd protoc-gen-pgx-orm
make buildsyntax = "proto3";
package example;
import "protopgx/pgx.proto";
message User {
option (sql.sql_table) = {
generate: true
table_name: "users"
virtual_fields: [
{
sql_name: "created_at"
sql_type: {type: TIMESTAMPTZ}
constraints: {default_value: "NOW()"}
is_nullable: false
}
]
};
int64 id = 1 [(sql.sql_field) = {
constraints: {primary_key: true}
sql_type: {type: BIGINT}
}];
string email = 2 [(sql.sql_field) = {
constraints: {unique: true}
sql_type: {type: TEXT}
}];
string name = 3 [(sql.sql_field) = {
sql_type: {type: TEXT}
constraints: {constraint: "NOT NULL"}
}];
}protoc --plugin=./protoc-gen-pgx-orm \
--pgx-orm_out=./generated \
--pgx-orm_opt=paths=source_relative,sql_file=./generated/models.sql,orm_folder=./generated/orm \
--proto_path=. your_proto_file.protopackage main
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"yourproject/generated/orm"
"yourproject/proto"
)
func main() {
ctx := context.Background()
// Database connection
pool, err := pgxpool.New(ctx, "postgres://user:password@localhost/dbname")
if err != nil {
panic(err)
}
defer pool.Close()
// Create repository
userRepo := orm.NewUserRepository(func(ctx context.Context, op orm.SqlOpType) orm.DB {
return pool
})
// Create user
user := &proto.User{
Email: "user@example.com",
Name: "John Doe",
}
// Insert into database
err = userRepo.Insert(ctx, user)
if err != nil {
panic(err)
}
// Find user
foundUsers, err := userRepo.Select().
Where(orm.User.Email.Equals("user@example.com")).
Query(ctx)
if err != nil {
panic(err)
}
fmt.Printf("Found user: %+v\n", foundUsers[0])
}protoc-gen-pgx-orm/
βββ main.go # Plugin entry point
βββ protopgx/ # Protobuf definitions for SQL annotations
β βββ pgx.proto # Main options for fields and tables
β βββ pgx.pb.go # Generated Go code
βββ orm/ # ORM generator core
β βββ generator.go # Go code generator
β βββ table.go # Table abstractions
β βββ select.go # SELECT queries
β βββ insert.go # INSERT queries
β βββ update.go # UPDATE queries
β βββ delete.go # DELETE queries
β βββ casters.go # Type converters
β βββ types.go # Base types and interfaces
β βββ clause.go # SQL conditions (WHERE, HAVING)
β βββ column.go # Column abstractions
β βββ scanrepo.go # Scanner repositories
β βββ protorepo.go # Protobuf repositories
β βββ tables.go.tmpl # Table generation template
βββ tabletree/ # Table tree and relationships
β βββ tabletree.go # Main tree building logic
β βββ field.go # Field processing
β βββ sql_type.go # SQL type mapping
β βββ helpers.go # Helper functions
βββ test/ # Test examples
β βββ test.proto # Test protobuf file
β βββ models.sql # Generated SQL
βββ help/ # Helper utilities
βββ help.go # Logger and utilities
- Entry point for protoc
- Parses protobuf files
- Calls SQL and Go code generators
- Builds table tree from protobuf
- Processes table relationships
- Resolves dependencies and connections
- Generates type-safe Go code
- Creates SQL queries
- Provides database abstractions
- Defines protobuf options for SQL
- Extends standard protobuf capabilities
message MyTable {
option (sql.sql_table) = {
generate: true // Generate table
table_name: "custom_table_name" // Custom table name
virtual_fields: [ // Virtual fields
{
sql_name: "created_at"
sql_type: {type: TIMESTAMPTZ}
constraints: {default_value: "NOW()"}
is_nullable: false
}
]
constraints: [ // Table constraints
"CONSTRAINT check_age CHECK (age >= 0)"
]
};
}int64 id = 1 [(sql.sql_field) = {
constraints: {primary_key: true} // Primary key
sql_type: {type: BIGINT} // Explicit SQL type
}];
string email = 2 [(sql.sql_field) = {
constraints: {unique: true} // Unique field
sql_type: {type: TEXT}
}];
string name = 3 [(sql.sql_field) = {
sql_type: {type: TEXT}
constraints: {constraint: "NOT NULL"} // Custom constraint
}];
repeated string tags = 4 [(sql.sql_field) = {
sql_type: {type: TEXT} // Array (automatic)
}];
// Skip field
string internal = 5 [(sql.sql_field) = {
skip: true
}];message User {
// ... user fields
repeated Post posts = 10 [(sql.sql_relation) = {
one_to_many: {
ref_name: "user_id" // Foreign key name
constraint: "FOREIGN KEY (user_id) REFERENCES users(id)"
on_delete_cascade: true // CASCADE on delete
}
}];
}
message Post {
// ... post fields
int64 user_id = 5 [(sql.sql_field) = {
sql_type: {type: BIGINT}
}];
}message User {
repeated Tag tags = 13 [(sql.sql_relation) = {
many_to_many: {
table: { // Junction table
generate: true
table_name: "user_tags"
}
ref_on_delete_cascade: true
ref_constraint: "FOREIGN KEY (user_id) REFERENCES users(id)"
back_ref_on_delete_cascade: false
back_ref_constraint: "FOREIGN KEY (tag_id) REFERENCES tags(id)"
}
}];
}message Address {
string street = 1 [(sql.sql_field) = {sql_type: {type: TEXT}}];
string city = 2 [(sql.sql_field) = {sql_type: {type: TEXT}}];
}
message User {
// Embedded message (fields will be added to users table)
Address address = 9 [(sql.sql_field) = {
embedded_message: true
}];
// Serialized message (will be stored as JSONB)
UserPreferences preferences = 10 [(sql.sql_field) = {
serialized_message: true
sql_type: {type: JSONB}
}];
}message Notification {
oneof delivery_method {
EmailDelivery email = 10 [(sql.sql_field) = {
serialized_message: true
}];
SmsDelivery sms = 11 [(sql.sql_field) = {
serialized_message: true
}];
}
}message User {
Ulid ulid = 999 [(sql.sql_field) = {
constraints: {primary_key: true}
sql_type: {type: TEXT, user_cast: true} // Requires custom caster
}];
}protoc --plugin=./protoc-gen-pgx-orm \
--pgx-orm_out=<output_dir> \
--pgx-orm_opt=<options> \
--proto_path=<proto_path> \
<proto_files>paths=source_relative- Use relative pathssql_file=<path>- Path for SQL file generationorm_folder=<path>- Folder for Go ORM code generation
.PHONY: generate
generate: build
protoc --plugin=./protoc-gen-pgx-orm \
--pgx-orm_out=./generated \
--pgx-orm_opt=paths=source_relative,sql_file=./generated/models.sql,orm_folder=./generated/orm \
--proto_path=. $(shell find . -name "*.proto")
.PHONY: build
build:
go build ./// For protobuf messages
userRepo := orm.NewUserRepository(dbGetter, customCaster1, customCaster2)
// For scanners (without protobuf)
scannerRepo := orm.NewUserScannerRepository(dbGetter)// Simple insert
err := userRepo.Insert(ctx, user)
// Insert with returning fields
err := userRepo.Insert(ctx, user,
orm.User.CreatedAt,
orm.User.UpdatedAt,
)
// Insert with conflict handling
err := userRepo.Insert(ctx, user).
OnConflict(orm.User.Email).
DoUpdate(orm.User.Name, orm.User.UpdatedAt).
Exec(ctx)// Select all fields
users, err := userRepo.SelectAll().Query(ctx)
// Select specific fields
users, err := userRepo.Select(orm.User.Id, orm.User.Name).Query(ctx)
// With conditions
users, err := userRepo.Select().
Where(
orm.User.Age.GreaterThan(18),
orm.User.Email.IsNotNull(),
).
OrderByASC(orm.User.Name).
Limit(10).
Query(ctx)
// Single record
user, err := userRepo.Select().
Where(orm.User.Id.Equals(123)).
QueryRow(ctx)
// Aggregate functions
count, err := userRepo.Select(orm.User.Id.Count()).
Where(orm.User.IsActive.IsTrue()).
QueryRow(ctx)// Simple update
err := userRepo.Update().
Set(
orm.User.Name.Set("New Name"),
orm.User.UpdatedAt.Set(time.Now()),
).
Where(orm.User.Id.Equals(123)).
Exec(ctx)
// Update with returning
updatedUser, err := userRepo.Update().
Set(orm.User.Name.Set("Updated")).
Where(orm.User.Id.Equals(123)).
ReturningAll().
QueryRow(ctx)// Delete with condition
err := userRepo.Delete().
Where(orm.User.Id.Equals(123)).
Exec(ctx)
// Delete with returning
deletedUser, err := userRepo.Delete().
Where(orm.User.Id.Equals(123)).
ReturningAll().
QueryRow(ctx)// Equality
orm.User.Email.Equals("test@example.com")
// Comparisons
orm.User.Age.GreaterThan(18)
orm.User.Age.LessThanOrEqual(65)
orm.User.Balance.Between(100.0, 1000.0)
// IN / NOT IN
orm.User.Status.In([]string{"active", "pending"})
orm.User.Id.NotIn([]int64{1, 2, 3})
// NULL checks
orm.User.Email.IsNotNull()
orm.User.DeletedAt.IsNull()
// LIKE operators
orm.User.Name.StartsWith("John")
orm.User.Name.Contains("test")
orm.User.Name.EndsWith("@example.com")// AND (default)
orm.User.Age.GreaterThan(18).And(orm.User.Email.IsNotNull())
// OR
orm.User.Status.Equals("active").Or(orm.User.Status.Equals("pending"))
// Complex conditions
orm.User.And(
orm.User.Age.GreaterThan(18),
orm.User.Or(
orm.User.Status.Equals("active"),
orm.User.Email.IsNotNull(),
),
)// EXISTS
orm.User.ExistsOf(
orm.Post.Select().
Where(orm.Post.UserId.Equals(orm.User.Id)),
)
// NOT EXISTS
orm.User.NotExistsOf(
orm.Post.Select().
Where(orm.Post.UserId.Equals(orm.User.Id)),
)
// Raw SQL
orm.User.ExistsRaw("SELECT 1 FROM posts WHERE user_id = $1", orm.User.Id)func TransferMoney(ctx context.Context, fromID, toID int64, amount float64) error {
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// Use transaction
dbGetter := func(ctx context.Context, op orm.SqlOpType) orm.DB {
return tx
}
userRepo := orm.NewUserRepository(dbGetter)
// Debit money
err = userRepo.Update().
Set(orm.User.Balance.Set(orm.User.Balance.Sub(amount))).
Where(orm.User.Id.Equals(fromID)).
Exec(ctx)
if err != nil {
return err
}
// Credit money
err = userRepo.Update().
Set(orm.User.Balance.Set(orm.User.Balance.Add(amount))).
Where(orm.User.Id.Equals(toID)).
Exec(ctx)
if err != nil {
return err
}
return tx.Commit(ctx)
}For complex data types, you can define custom transformers:
// Define custom caster for ULID
func ULIDToString(ulid *Ulid) string {
if ulid == nil {
return ""
}
return ulid.Id
}
func StringToULID(s string) *Ulid {
if s == "" {
return nil
}
return &Ulid{Id: s}
}
// Usage in repository
userRepo := orm.NewUserRepository(
dbGetter,
ULIDToString, // for ulid field
StringToULID, // for ulid field
)Virtual fields don't exist in protobuf but are present in SQL:
message User {
option (sql.sql_table) = {
virtual_fields: [
{
sql_name: "full_name"
sql_type: {type: TEXT}
constraints: {constraint: "GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED"}
is_nullable: false
},
{
sql_name: "created_at"
sql_type: {type: TIMESTAMPTZ}
constraints: {default_value: "NOW()"}
is_nullable: false
}
]
};
}When working with virtual fields:
// For insert/update you need to pass virtual field values
err := userRepo.Insert(ctx, user,
orm.User.WithUserCreatedAt(time.Now()),
)// Working with arrays
user := &proto.User{
Tags: []string{"tag1", "tag2", "tag3"},
}
// Search by arrays
users, err := userRepo.Select().
Where(orm.User.Tags.Contains("tag1")).
Query(ctx)
// JSONB fields
preferences := &proto.UserPreferences{
Theme: "dark",
Language: "en",
}
user := &proto.User{
Preferences: preferences,
}OneOf fields are automatically serialized to JSONB:
notification := &proto.Notification{
Title: "Welcome",
Message: "Welcome to our service!",
DeliveryMethod: &proto.Notification_Email{
Email: &proto.EmailDelivery{
RecipientEmail: "user@example.com",
Subject: "Welcome",
},
},
}
// Search by oneof type
notifications, err := notificationRepo.Select().
Where(orm.Notification.NotificationType.Equals("email")).
Query(ctx)// Batch insert
users := []*proto.User{user1, user2, user3}
err := userRepo.InsertBatch(ctx, users)
// Batch update
err := userRepo.UpdateBatch(ctx, users,
orm.User.Name,
orm.User.UpdatedAt,
)
// CopyFrom for fast insertion
scanners := make([]*orm.UserScanner, len(users))
for i, user := range users {
scanners[i] = userRepo.ProtoToScanner(user)
}
rowsAffected, err := userRepo.CopyFrom(ctx, db, scanners)// user.proto
syntax = "proto3";
package user;
import "protopgx/pgx.proto";
message User {
option (sql.sql_table) = {
generate: true
table_name: "users"
virtual_fields: [
{
sql_name: "created_at"
sql_type: {type: TIMESTAMPTZ}
constraints: {default_value: "NOW()"}
is_nullable: false
},
{
sql_name: "updated_at"
sql_type: {type: TIMESTAMPTZ}
constraints: {default_value: "NOW()"}
is_nullable: false
}
]
constraints: [
"CONSTRAINT check_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$')"
]
};
int64 id = 1 [(sql.sql_field) = {
constraints: {primary_key: true}
sql_type: {type: BIGINT}
}];
string email = 2 [(sql.sql_field) = {
constraints: {unique: true}
sql_type: {type: TEXT}
}];
string first_name = 3 [(sql.sql_field) = {
sql_type: {type: TEXT}
constraints: {constraint: "NOT NULL"}
}];
string last_name = 4 [(sql.sql_field) = {
sql_type: {type: TEXT}
constraints: {constraint: "NOT NULL"}
}];
int32 age = 5 [(sql.sql_field) = {
sql_type: {type: INTEGER}
constraints: {default_value: "0"}
}];
bool is_active = 6 [(sql.sql_field) = {
sql_type: {type: BOOLEAN}
constraints: {default_value: "true"}
}];
}// service/user_service.go
package service
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"yourproject/generated/orm"
"yourproject/proto/user"
)
type UserService struct {
pool *pgxpool.Pool
repo orm.UserRepository
}
func NewUserService(pool *pgxpool.Pool) *UserService {
dbGetter := func(ctx context.Context, op orm.SqlOpType) orm.DB {
return pool
}
return &UserService{
pool: pool,
repo: orm.NewUserRepository(dbGetter),
}
}
func (s *UserService) CreateUser(ctx context.Context, req *user.CreateUserRequest) (*user.User, error) {
// Check if email exists
existing, err := s.repo.Select().
Where(orm.User.Email.Equals(req.Email)).
QueryRow(ctx)
if err == nil {
return nil, errors.New("user already exists")
}
newUser := &user.User{
Email: req.Email,
FirstName: req.FirstName,
LastName: req.LastName,
Age: int32(req.Age),
IsActive: true,
}
err = s.repo.Insert(ctx, newUser)
if err != nil {
return nil, err
}
return newUser, nil
}
func (s *UserService) GetUser(ctx context.Context, id int64) (*user.User, error) {
return s.repo.Select().
Where(orm.User.Id.Equals(id)).
QueryRow(ctx)
}
func (s *UserService) ListUsers(ctx context.Context, req *user.ListUsersRequest) ([]*user.User, error) {
query := s.repo.Select()
if req.Search != "" {
query = query.Where(
orm.User.Or(
orm.User.FirstName.Contains(req.Search),
orm.User.LastName.Contains(req.Search),
orm.User.Email.Contains(req.Search),
),
)
}
if req.MinAge > 0 {
query = query.Where(orm.User.Age.GreaterThanOrEqual(int32(req.MinAge)))
}
if req.MaxAge > 0 {
query = query.Where(orm.User.Age.LessThanOrEqual(int32(req.MaxAge)))
}
if req.ActiveOnly {
query = query.Where(orm.User.IsActive.IsTrue())
}
query = query.
OrderByASC(orm.User.LastName, orm.User.FirstName).
Limit(int(req.Limit)).
Offset(int(req.Offset))
return query.Query(ctx)
}
func (s *UserService) UpdateUser(ctx context.Context, id int64, req *user.UpdateUserRequest) (*user.User, error) {
updates := []orm.ValueSetter[orm.UserField]{}
if req.FirstName != nil {
updates = append(updates, orm.User.FirstName.Set(*req.FirstName))
}
if req.LastName != nil {
updates = append(updates, orm.User.LastName.Set(*req.LastName))
}
if req.Age != nil {
updates = append(updates, orm.User.Age.Set(int32(*req.Age)))
}
if req.IsActive != nil {
updates = append(updates, orm.User.IsActive.Set(*req.IsActive))
}
// Always update updated_at
updates = append(updates, orm.User.UpdatedAt.Set(time.Now()))
return s.repo.Update().
Set(updates...).
Where(orm.User.Id.Equals(id)).
ReturningAll().
QueryRow(ctx)
}
func (s *UserService) DeleteUser(ctx context.Context, id int64) error {
return s.repo.Delete().
Where(orm.User.Id.Equals(id)).
Exec(ctx)
}Main table interface:
type TableI[F fieldAlias, T targeter[F]] interface {
AllFields() []F
AllFieldsExcept(field ...F) []F
Name() string
NewScanner() T
Select(field ...F) *SelectQuery[F]
Select1() *SelectQuery[F]
SelectAll() *SelectQuery[F]
Insert() *InsertQuery[F]
Update() *UpdateQuery[F]
Delete() *DeleteQuery[F]
Query(ctx context.Context, db DB, query ormQuery) ([]T, error)
QueryRow(ctx context.Context, db DB, query ormQuery) (T, error)
Execute(ctx context.Context, db DB, query ormQuery) (int64, error)
}Repository interface:
type ProtoRepository[F fieldAlias, T targeter[F], P proto.Message] interface {
Insert(ctx context.Context, entity P, opts ...ProtoCallOption[F, T, P]) error
Update(ctx context.Context, entity P, opts ...ProtoCallOption[F, T, P]) error
Delete(ctx context.Context, entity P, opts ...ProtoCallOption[F, T, P]) error
Select() *SelectQuery[F]
// ... other methods
}Basic operators for all types:
Equals(value any) Clause[F]
NotEquals(value any) Clause[F]
In(values []any) Clause[F]
NotIn(values []any) Clause[F]
IsNull() Clause[F]
IsNotNull() Clause[F]Operators for scalar types:
GreaterThan(value any) Clause[F]
GreaterThanOrEqual(value any) Clause[F]
LessThan(value any) Clause[F]
LessThanOrEqual(value any) Clause[F]
Between(min, max any) Clause[F]Operators for strings:
Contains(value string) Clause[F]
StartsWith(value string) Clause[F]
EndsWith(value string) Clause[F]
Like(pattern string) Clause[F]
ILike(pattern string) Clause[F]| Proto Type | SQL Type | PGX Type |
|---|---|---|
| string | TEXT | string |
| int32 | INTEGER | int32 |
| int64 | BIGINT | int64 |
| float | REAL | float32 |
| double | DOUBLE_PRECISION | float64 |
| bool | BOOLEAN | bool |
| google.protobuf.Timestamp | TIMESTAMPTZ | time.Time |
| repeated T | T[] | []T |
| message | JSONB | pgtype.JSONB |
# Clone repository
git clone https://github.com/yaroher/protoc-gen-pgx-orm.git
cd protoc-gen-pgx-orm
# Install dependencies
go mod download
# Run tests
make test
# Generate test code
make build-test
# Build plugin
make build# Run all tests
go test ./...
# Run tests with coverage
go test -cover ./...
# Run benchmarks
go test -bench=. ./...MIT License - see LICENSE file for details.
If you have questions or issues:
- Check Issues
- Create a new Issue with detailed description
- Include a minimal reproducible example
protoc-gen-pgx-orm is a powerful tool for automating database operations in Go projects using Protocol Buffers.