# pgm - PostgreSQL Query Mapper

[![Go Reference](https://pkg.go.dev/badge/code.patial.tech/go/pgm.svg)](https://pkg.go.dev/code.patial.tech/go/pgm)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

A lightweight, type-safe PostgreSQL query builder for Go, built on top of [jackc/pgx](https://github.com/jackc/pgx). **pgm** generates Go code from your SQL schema, enabling you to write SQL queries with compile-time safety and autocompletion support.

## Features

- **Type-safe queries** - Column and table names are validated at compile time
- **Zero reflection** - Fast performance with no runtime reflection overhead
- **SQL schema-based** - Generate Go code directly from your SQL schema files
- **Fluent API** - Intuitive query builder with method chaining
- **Transaction support** - First-class support for pgx transactions
- **Full-text search** - Built-in PostgreSQL full-text search helpers
- **Connection pooling** - Leverages pgx connection pool for optimal performance
- **Minimal code generation** - Only generates what you need, no bloat

## Table of Contents

- [Why pgm?](#why-pgm)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Usage Examples](#usage-examples)
  - [SELECT Queries](#select-queries)
  - [INSERT Queries](#insert-queries)
  - [UPDATE Queries](#update-queries)
  - [DELETE Queries](#delete-queries)
  - [Joins](#joins)
  - [Transactions](#transactions)
  - [Full-Text Search](#full-text-search)
- [CLI Tool](#cli-tool)
- [API Documentation](#api-documentation)
- [Contributing](#contributing)
- [License](#license)

## Why pgm?

### The Problem with Existing ORMs

While Go has excellent ORMs like [ent](https://github.com/ent/ent) and [sqlc](https://github.com/sqlc-dev/sqlc), they come with tradeoffs:

**ent** - Feature-rich but heavy:
- Generates extensive code for features you may never use
- Significantly increases binary size
- Complex schema definition in Go instead of SQL
- Auto-migrations can obscure actual database schema

**sqlc** - Great tool, but:
- Creates separate database models, forcing model mapping
- Query results require their own generated types
- Less flexibility in dynamic query building

### The pgm Approach

**pgm** takes a hybrid approach:

✅ **Schema as SQL** - Define your database schema in pure SQL, where it belongs  
✅ **Minimal generation** - Only generates table and column definitions  
✅ **Your models** - Use your own application models, no forced abstractions  
✅ **Type safety** - Catch schema changes at compile time  
✅ **SQL power** - Full control over your queries with a fluent API  
✅ **Migration-friendly** - Use mature tools like [dbmate](https://github.com/amacneil/dbmate) for migrations

## Installation

```bash
go get code.patial.tech/go/pgm
```

Install the CLI tool for schema code generation:

```bash
go install code.patial.tech/go/pgm/cmd@latest
```

### Building from Source

Build with automatic version detection (uses git tags):

```bash
# Build with version from git tags
make build

# Build with specific version
make build VERSION=v1.2.3

# Install to GOPATH/bin
make install

# Or build manually with version
go build -ldflags "-X main.version=v1.2.3" -o pgm ./cmd
```

Check the version:

```bash
pgm -version
```

## Quick Start

### 1. Create Your Schema

Create a SQL schema file `schema.sql` or use the one created by [dbmate](https://github.com/amacneil/dbmate):

```sql
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(255) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE posts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id),
    title VARCHAR(500) NOT NULL,
    content TEXT,
    published BOOLEAN DEFAULT false,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

### 2. Generate Go Code

Run the pgm CLI tool:

```bash
pgm -o ./db ./schema.sql
```

This generates Go files for each table in `./db/`:
- `db/users/users.go` - Table and column definitions for users
- `db/posts/posts.go` - Table and column definitions for posts

### 3. Use in Your Code

```go
package main

import (
    "context"
    "log"
    
    "code.patial.tech/go/pgm"
    "yourapp/db/users"
    "yourapp/db/posts"
)

func main() {
    // Initialize connection pool
    pgm.InitPool(pgm.Config{
        ConnString: "postgres://user:pass@localhost:5432/dbname",
        MaxConns:   25,
        MinConns:   5,
    })
    defer pgm.ClosePool() // Ensure graceful shutdown
    
    ctx := context.Background()
    
    // Query a user
    var email string
    err := users.User.Select(users.Email).
        Where(users.ID.Eq("some-uuid")).
        First(ctx, &email)
    if err != nil {
        log.Fatal(err)
    }
    
    log.Printf("User email: %s", email)
}
```

## Important: Query Builder Lifecycle

### ✅ Conditional Building (CORRECT)

Query builders are **mutable by design** to support conditional query building:

```go
// ✅ CORRECT - Conditional building pattern
query := users.User.Select(users.ID, users.Email, users.Name)

// Add conditions based on filters
if nameFilter != "" {
    query = query.Where(users.Name.Like("%" + nameFilter + "%"))
}

if statusFilter > 0 {
    query = query.Where(users.Status.Eq(statusFilter))
}

if sortByName {
    query = query.OrderBy(users.Name.Asc())
}

// Execute the final query with all accumulated conditions
err := query.First(ctx, &id, &email, &name)
```

**This is the intended use!** The builder accumulates your conditions, which is powerful and flexible.

### ❌ Unintentional Reuse (INCORRECT)

Don't try to create a "base query" and reuse it for **multiple different queries**:

```go
// ❌ WRONG - Trying to reuse for multiple separate queries
baseQuery := users.User.Select(users.ID, users.Email)

// First query - adds ID condition
baseQuery.Where(users.ID.Eq(1)).First(ctx, &id1, &email1)

// Second query - ALSO has ID=1 from above PLUS Status=2!
baseQuery.Where(users.Status.Eq(2)).First(ctx, &id2, &email2)
// This executes: WHERE users.id = 1 AND users.status = 2 (WRONG!)

// ✅ CORRECT - Each separate query gets its own builder
users.User.Select(users.ID, users.Email).Where(users.ID.Eq(1)).First(ctx, &id1, &email1)
users.User.Select(users.ID, users.Email).Where(users.Status.Eq(2)).First(ctx, &id2, &email2)
```

**Why?** Query builders are mutable and accumulate state. Each method call modifies the builder, so reusing the same builder causes conditions to stack up.

### Thread Safety

⚠️ **Query builders are NOT thread-safe** and must not be shared across goroutines:

```go
// ✅ CORRECT - Each goroutine creates its own query
for i := 0; i < 10; i++ {
    go func(id int) {
        var email string
        err := users.User.Select(users.Email).
            Where(users.ID.Eq(id)).
            First(ctx, &email)
        // Process result...
    }(i)
}

// ❌ WRONG - Sharing query builder across goroutines
baseQuery := users.User.Select(users.Email)
for i := 0; i < 10; i++ {
    go func(id int) {
        var email string
        baseQuery.Where(users.ID.Eq(id)).First(ctx, &email)
        // RACE CONDITION! Multiple goroutines modifying shared state
    }(i)
}
```

**Thread-Safe Components:**
- ✅ Connection Pool - Safe for concurrent use
- ✅ Table objects - Safe to share
- ❌ Query builders - Create new instance per goroutine

## Usage Examples

### SELECT Queries

#### Basic Select

```go
var user struct {
    ID    string
    Email string
    Name  string
}

err := users.User.Select(users.ID, users.Email, users.Name).
    Where(users.Email.Eq("john@example.com")).
    First(ctx, &user.ID, &user.Email, &user.Name)
```

#### Select with Multiple Conditions

```go
err := users.User.Select(users.ID, users.Email).
    Where(
        users.Email.Like("john%"),
        users.CreatedAt.Gt(time.Now().AddDate(0, -1, 0)),
    ).
    OrderBy(users.CreatedAt.Desc()).
    Limit(10).
    First(ctx, &user.ID, &user.Email)
```

#### Select All with Callback

```go
var userList []User

err := users.User.Select(users.ID, users.Email, users.Name).
    Where(users.Name.Like("J%")).
    OrderBy(users.Name.Asc()).
    All(ctx, func(row pgm.RowScanner) error {
        var u User
        if err := row.Scan(&u.ID, &u.Email, &u.Name); err != nil {
            return err
        }
        userList = append(userList, u)
        return nil
    })
```

#### Pagination

```go
page := 2
pageSize := 20

err := users.User.Select(users.ID, users.Email).
    OrderBy(users.CreatedAt.Desc()).
    Limit(pageSize).
    Offset((page - 1) * pageSize).
    All(ctx, func(row pgm.RowScanner) error {
        // Process rows
    })
```

#### Grouping and Having

```go
err := posts.Post.Select(posts.UserID, pgm.Count(posts.ID)).
    GroupBy(posts.UserID).
    Having(pgm.Count(posts.ID).Gt(5)).
    All(ctx, func(row pgm.RowScanner) error {
        var userID string
        var postCount int
        return row.Scan(&userID, &postCount)
    })
```

### INSERT Queries

#### Simple Insert

```go
err := users.User.Insert().
    Set(users.Email, "jane@example.com").
    Set(users.Name, "Jane Doe").
    Set(users.CreatedAt, pgm.PgTimeNow()).
    Exec(ctx)
```

#### Insert with Map

```go
data := map[pgm.Field]any{
    users.Email:     "jane@example.com",
    users.Name:      "Jane Doe",
    users.CreatedAt: pgm.PgTimeNow(),
}

err := users.User.Insert().
    SetMap(data).
    Exec(ctx)
```

#### Insert with RETURNING

```go
var newID string

err := users.User.Insert().
    Set(users.Email, "jane@example.com").
    Set(users.Name, "Jane Doe").
    Returning(users.ID).
    First(ctx, &newID)
```

#### Upsert (INSERT ... ON CONFLICT)

```go
// Do nothing on conflict
err := users.User.Insert().
    Set(users.Email, "jane@example.com").
    Set(users.Name, "Jane Doe").
    OnConflict(users.Email).
    DoNothing().
    Exec(ctx)

// Update on conflict
err := users.User.Insert().
    Set(users.Email, "jane@example.com").
    Set(users.Name, "Jane Doe Updated").
    OnConflict(users.Email).
    DoUpdate(users.Name).
    Exec(ctx)
```

### UPDATE Queries

#### Simple Update

```go
err := users.User.Update().
    Set(users.Name, "John Smith").
    Where(users.ID.Eq("some-uuid")).
    Exec(ctx)
```

#### Update Multiple Fields

```go
updates := map[pgm.Field]any{
    users.Name:  "John Smith",
    users.Email: "john.smith@example.com",
}

err := users.User.Update().
    SetMap(updates).
    Where(users.ID.Eq("some-uuid")).
    Exec(ctx)
```

#### Conditional Update

```go
err := users.User.Update().
    Set(users.Name, "Updated Name").
    Where(
        users.Email.Like("%@example.com"),
        users.CreatedAt.Lt(time.Now().AddDate(-1, 0, 0)),
    ).
    Exec(ctx)
```

### DELETE Queries

#### Simple Delete

```go
err := users.User.Delete().
    Where(users.ID.Eq("some-uuid")).
    Exec(ctx)
```

#### Conditional Delete

```go
err := posts.Post.Delete().
    Where(
        posts.Published.Eq(false),
        posts.CreatedAt.Lt(time.Now().AddDate(0, 0, -30)),
    ).
    Exec(ctx)
```

### Joins

#### Inner Join

```go
err := posts.Post.Select(posts.Title, users.Name).
    Join(users.User, posts.UserID, users.ID).
    Where(users.Email.Eq("john@example.com")).
    All(ctx, func(row pgm.RowScanner) error {
        var title, userName string
        return row.Scan(&title, &userName)
    })
```

#### Left Join

```go
err := users.User.Select(users.Name, posts.Title).
    LeftJoin(posts.Post, users.ID, posts.UserID).
    All(ctx, func(row pgm.RowScanner) error {
        var userName, postTitle string
        return row.Scan(&userName, &postTitle)
    })
```

#### Join with Additional Conditions

```go
err := posts.Post.Select(posts.Title, users.Name).
    Join(users.User, posts.UserID, users.ID, users.Email.Like("%@example.com")).
    Where(posts.Published.Eq(true)).
    All(ctx, func(row pgm.RowScanner) error {
        // Process rows
    })
```

### Transactions

#### Basic Transaction

```go
tx, err := pgm.BeginTx(ctx)
if err != nil {
    log.Fatal(err)
}
defer tx.Rollback(ctx)

// Insert user
var userID string
err = users.User.Insert().
    Set(users.Email, "jane@example.com").
    Set(users.Name, "Jane Doe").
    Returning(users.ID).
    FirstTx(ctx, tx, &userID)
if err != nil {
    return err
}

// Insert post
err = posts.Post.Insert().
    Set(posts.UserID, userID).
    Set(posts.Title, "My First Post").
    Set(posts.Content, "Hello, World!").
    ExecTx(ctx, tx)
if err != nil {
    return err
}

// Commit transaction
if err := tx.Commit(ctx); err != nil {
    return err
}
```

### Full-Text Search

PostgreSQL full-text search helpers:

```go
// Search with AND operator (all terms must match)
searchQuery := pgm.TsAndQuery("golang database")
// Result: "golang & database"

// Search with prefix matching
searchQuery := pgm.TsPrefixAndQuery("gol data")
// Result: "gol:* & data:*"

// Search with OR operator (any term matches)
searchQuery := pgm.TsOrQuery("golang rust")
// Result: "golang | rust"

// Prefix OR search
searchQuery := pgm.TsPrefixOrQuery("go ru")
// Result: "go:* | ru:*"

// Use in query (assuming you have a tsvector column)
err := posts.Post.Select(posts.Title, posts.Content).
    Where(posts.SearchVector.Match(pgm.TsPrefixAndQuery(searchTerm))).
    OrderBy(posts.CreatedAt.Desc()).
    All(ctx, func(row pgm.RowScanner) error {
        // Process results
    })
```

## CLI Tool

### Usage

```bash
pgm -o <output_directory> <schema.sql>
```

### Options

```bash
-o string      Output directory path (required)
-version       Show version information
```

### Examples

```bash
# Generate from a single schema file
pgm -o ./db ./schema.sql

# Generate from concatenated migrations
cat migrations/*.sql > /tmp/schema.sql && pgm -o ./db /tmp/schema.sql

# Check version
pgm -version
```

### Known Limitations

The CLI tool uses a regex-based SQL parser with the following limitations:

- ❌ Multi-line comments `/* */` are not supported
- ❌ Complex data types (arrays, JSON, JSONB) may not parse correctly
- ❌ Quoted identifiers with special characters may fail
- ❌ Advanced PostgreSQL features (PARTITION BY, INHERITS) not supported
- ❌ Some constraints (CHECK, EXCLUDE) are not parsed

**Workarounds:**
- Use simple CREATE TABLE statements
- Avoid complex PostgreSQL-specific syntax in schema files
- Split complex schemas into multiple simple statements
- Remove comments before running the generator

For complex schemas, consider contributing a more robust parser or using a proper SQL parser library.

### Generated Code Structure

For a table named `users`, pgm generates:

```
db/
└── users/
    └── users.go
```

The generated file contains:
- Generated code header with version and timestamp
- Table definition (`User`)
- Column field definitions (`ID`, `Email`, `Name`, etc.)
- Type-safe query builders (`Select()`, `Insert()`, `Update()`, `Delete()`)

**Example header:**
```go
// Code generated by code.patial.tech/go/pgm/cmd v1.2.3 on 2025-01-27 15:04:05 DO NOT EDIT.
```

The version in generated files helps track which version of the CLI tool was used, making it easier to identify when regeneration is needed after upgrades.

## API Documentation

### Connection Pool

#### InitPool

Initialize the connection pool (must be called once at startup):

```go
pgm.InitPool(pgm.Config{
    ConnString:      "postgres://...",
    MaxConns:        25,
    MinConns:        5,
    MaxConnLifetime: time.Hour,
    MaxConnIdleTime: time.Minute * 30,
})
```

**Configuration Validation:**
- MinConns cannot be greater than MaxConns
- Connection counts cannot be negative
- Connection string is required

#### ClosePool

Close the connection pool gracefully (call during application shutdown):

```go
func main() {
    pgm.InitPool(pgm.Config{
        ConnString: "postgres://...",
    })
    defer pgm.ClosePool() // Ensures proper cleanup
    
    // Your application code
}
```

#### GetPool

Get the underlying pgx pool:

```go
pool := pgm.GetPool()
```

### Query Conditions

Available condition methods on fields:

- `Eq(value)` - Equal to
- `NotEq(value)` - Not equal to
- `Gt(value)` - Greater than
- `Gte(value)` - Greater than or equal to
- `Lt(value)` - Less than
- `Lte(value)` - Less than or equal to
- `Like(pattern)` - LIKE pattern match
- `ILike(pattern)` - Case-insensitive LIKE
- `In(values...)` - IN list
- `NotIn(values...)` - NOT IN list
- `IsNull()` - IS NULL
- `IsNotNull()` - IS NOT NULL
- `Between(start, end)` - BETWEEN range

### Field Methods

- `Asc()` - Sort ascending
- `Desc()` - Sort descending
- `Name()` - Get column name
- `String()` - Get fully qualified name (table.column)

### Utilities

- `pgm.PgTime(t time.Time)` - Convert Go time to PostgreSQL timestamptz
- `pgm.PgTimeNow()` - Current time as PostgreSQL timestamptz
- `pgm.IsNotFound(err)` - Check if error is "no rows found"

## Best Practices

1. **Use transactions for related operations** - Ensure data consistency
2. **Define schema in SQL** - Use migration tools like dbmate for schema management
3. **Regenerate after schema changes** - Run the CLI tool after any schema modifications
4. **Use your own models** - Don't let the database dictate your domain models
5. **Handle pgx.ErrNoRows** - Use `pgm.IsNotFound(err)` for cleaner error checking
6. **Always use context with timeouts** - Prevent queries from running indefinitely
7. **Validate UPDATE queries** - Ensure Set() is called before Exec()
8. **Be careful with DELETE** - Always use Where() unless you want to delete all rows

## Important Safety Notes

### ⚠️ DELETE Operations

DELETE without WHERE clause will delete ALL rows in the table:

```go
// ❌ DANGEROUS - Deletes ALL rows!
users.User.Delete().Exec(ctx)

// ✅ SAFE - Deletes specific rows
users.User.Delete().Where(users.ID.Eq("user-id")).Exec(ctx)
```

### ⚠️ UPDATE Operations

UPDATE requires at least one Set() call:

```go
// ❌ ERROR - No columns to update
users.User.Update().Where(users.ID.Eq(1)).Exec(ctx)
// Returns: "update query has no columns to update"

// ✅ CORRECT
users.User.Update().
    Set(users.Name, "New Name").
    Where(users.ID.Eq(1)).
    Exec(ctx)
```

### ⚠️ Query Timeouts

Always use context with timeout to prevent hanging queries:

```go
// ✅ RECOMMENDED
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

err := users.User.Select(users.Email).
    Where(users.ID.Eq("some-id")).
    First(ctx, &email)
```

### ⚠️ Connection String Security

Never log or expose database connection strings as they contain credentials. The library does not sanitize connection strings in error messages.

## Performance

**pgm** is designed for performance:

- Zero reflection overhead
- Efficient string building with sync.Pool
- Leverages pgx's high-performance connection pooling
- Minimal allocations in query building
- Direct scanning into your types

## Requirements

- Go 1.20 or higher
- PostgreSQL 12 or higher

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

## License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.

## Author


**Ankit Patial** - [Patial Tech](https://code.patial.tech)

## Acknowledgments

Built on top of the excellent [jackc/pgx](https://github.com/jackc/pgx) library.

---

**Made with ❤️ for the Go community**
