# Documentation # Documentation [#documentation] Welcome to the NetLife Guru documentation. NetLife Guru provides practical Go packages for building fast, maintainable backend systems. This documentation covers package usage, examples, configuration, and implementation details. ## Go packages [#go-packages] Explore the available Go packages: * [DB](/docs/go/db) — shared database layer for querying, execution, transactions, dialect SQL, and result mapping. * [Form](/docs/go/form) — type-safe form and request validation for Go applications. * [Logger](/docs/go/logger) — high-performance structured logger built on top of `log/slog`. * [Mapper](/docs/go/mapper) — lightweight row mapper for scanning database rows into structs, maps, or custom handlers. * [Router](/docs/go/router) — fast HTTP router focused on low-allocation request handling and clean application structure. ## Getting started [#getting-started] Choose a package from the sidebar or start with one of the main guides: * [Go DB](/docs/go/db) * [Go Form](/docs/go/form) * [Go Logger](/docs/go/logger) * [Go Mapper](/docs/go/mapper) * [Go Router](/docs/go/router) ## Requirements [#requirements] Most NetLife Guru Go packages are designed for modern Go projects and require Go `1.22` or newer. # About # NLG Form [#nlg-form] `form` is a type-safe form and request validation package for Go applications. It helps you define reusable validation schemas using typed fields, composable rules, optional values, conditional logic, and structured error responses. The package is designed for validating JSON requests, API payloads, forms, and application input while keeping validation logic explicit, reusable, and easy to test. It provides strongly typed field definitions, reusable validation pipelines, optional and conditional validation helpers, HTTP form integration, and structured validation error handling while remaining lightweight and fully compatible with Go’s standard library. ## Features [#features] * **Type-Safe Form Schemas**: Define reusable validation schemas using Go generics and typed field accessors * **Composable Validation Rules**: Build reusable validation pipelines from small chainable rules * **Reusable Schema Composition**: Combine smaller schemas into larger validation workflows * **String, Number, Boolean, Slice, and Time Rules**: Validate common Go types through dedicated rule helpers * **Required and Optional Fields**: Support required values, nullable inputs, pointer fields, and optional validation flows * **Conditional Validation**: Apply validation rules dynamically based on runtime conditions * **Cross-Field Validation**: Compare values between fields such as password confirmation, ranges, or dependent inputs * **Format Validators**: Validate emails, URLs, UUIDs, IP addresses, JSON payloads, timezones, and custom formats * **Custom Validation Rules**: Create application-specific validators with reusable logic * **HTTP Request Binding**: Decode and validate JSON request bodies directly from `net/http` handlers * **Structured Validation Errors**: Return validation errors as field maps, flat lists, or custom response formats * **Custom Error Codes**: Override default validation codes for application-specific API responses * **Unique Error Codes**: Optionally remove duplicated validation codes per field * **Nested Validation Support**: Compose deeply structured validation trees from reusable field definitions * **Concurrency-Safe Design**: Safe for concurrent validation in APIs, services, and background workers * **Standard Library Friendly**: Works naturally with Go structs, `encoding/json`, `net/http`, and standard application layers * **Minimal Dependencies**: Lightweight implementation built primarily on Go’s standard library * **Practical Validation Workflows**: Suitable for APIs, registration flows, sign-in forms, profile updates, billing systems, CLI tools, and internal services ## Requirements [#requirements] This package requires Go 1.22 or newer. It is designed for modern Go projects and may use language and standard library features introduced in recent Go versions. * **Go:** `1.22` or newer * **Dependencies:** Standard library only * **Features used:** Generics, concurrency primitives The package is optimized for clean validation workflows and reusable schema composition, making it suitable for APIs, backend services, form validation, request processing, and structured application input handling. # About # DB [#db] DB is a shared database layer for Go applications. It provides one common API for querying, executing statements, handling transactions, loading SQL models, and mapping database results across supported NetLifeGuru drivers. The `db` package is not a standalone database driver. To connect to a real database, install and use one of the driver packages: * `github.com/netlifeguru/db-mysql` * `github.com/netlifeguru/db-postgres` * `github.com/netlifeguru/db-scylla` Installing a driver also installs the shared packages: * `github.com/netlifeguru/db` * `github.com/netlifeguru/mapper` ## Features [#features] * **Shared Database Layer**: Provides common querying and execution APIs for supported drivers * **Driver-Based Usage**: Works through concrete drivers such as MySQL, PostgreSQL, and Scylla * **Unified Connection Interface**: Uses a common `db.Conn` interface across drivers * **Typed Query Helpers**: Supports typed helpers such as `List`, `Get`, `Value`, and `Maps` * **Prepared Query Objects**: Supports reusable `db.Query` values through `Raw` and query-based helpers * **Dialect SQL Support**: Selects SQL automatically from `db.DialectSQL` based on the active driver * **SQL Model Loading**: Loads driver-specific SQL files such as `model.sql`, `model.psql`, and `model.cql` * **Result Mapping**: Integrates with `github.com/netlifeguru/mapper` for scanning rows into structs and maps * **Semantic Exec Helpers**: Provides `Insert`, `Update`, and `Delete` as readable wrappers around `Exec` * **Transaction Support**: Supports transactions for drivers that provide transaction behavior * **Scylla Batch Support**: Supports Scylla-specific batch workflows through the Scylla driver * **Multi-Driver Applications**: Supports applications that can run against different database engines * **Context-Aware Operations**: Query and execution helpers use `context.Context` * **Standard Go Friendly**: Built around explicit SQL, structs, interfaces, and small helper functions ## Requirements [#requirements] This package requires Go `1.22` or newer. It is designed for modern Go projects and may use language and standard library features introduced in recent Go versions. * **Go:** `1.22` or newer * **Requires a driver:** MySQL, PostgreSQL, or Scylla * **Shared dependencies:** `github.com/netlifeguru/mapper` * **Features used:** Generics, context-aware APIs, interfaces, reflection-based result mapping ## Quick Example [#quick-example] Install a concrete driver first. ```bash go get github.com/netlifeguru/db-mysql ``` Then create a connection using the driver package. ```go conn := mysql.New() err := conn.CreatePool(db.Config{ Identifier: "default", Host: "127.0.0.1", Port: 3306, Database: "app", Username: "root", Password: "secret", }) if err != nil { return err } defer conn.Close() ``` Use the shared `db` API with the driver connection. ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` } users, err := db.List[User]( context.Background(), conn.Fork(), ` SELECT * FROM users ORDER BY id DESC `, ) if err != nil { return err } ``` The same `db.List`, `db.Get`, `db.Value`, `db.Maps`, `db.Insert`, `db.Update`, and `db.Delete` APIs can be used with the supported drivers. ## Main APIs [#main-apis] | API | Purpose | | ---------------- | -------------------------------------------------------- | | `db.Conn` | Common connection interface implemented by drivers | | `db.Config` | Shared connection configuration structure | | `db.Raw` | Create a `db.Query` from SQL/CQL and arguments | | `db.List` | Scan multiple rows into `[]T` | | `db.Get` | Scan zero or one row into `T` | | `db.Value` | Read a single scalar value | | `db.Maps` | Read rows as `[]map[string]any` | | `db.Exec` | Execute a statement | | `db.Insert` | Execute an insert statement | | `db.Update` | Execute an update statement | | `db.Delete` | Execute a delete statement | | `db.Dialect` | Build a query from `db.DialectSQL` for the active driver | | `db.LoadModel` | Load driver-specific SQL model files | | `db.WithConn` | Store a connection in `context.Context` | | `db.ConnFromCtx` | Read a connection from `context.Context` | ## Driver Packages [#driver-packages] The shared `db` package is used through concrete driver packages. | Driver | Package | Notes | | -------- | ------------------------------------ | --------------------------------------------------------- | | MySQL | `github.com/netlifeguru/db-mysql` | Uses MySQL placeholders and `LastInsertId` behavior | | Postgres | `github.com/netlifeguru/db-postgres` | Uses PostgreSQL placeholders and `RETURNING` patterns | | Scylla | `github.com/netlifeguru/db-scylla` | Uses CQL, consistency settings, query tables, and batches | ## Not an ORM [#not-an-orm] DB is not an ORM. It does not generate SQL, manage schemas, define models, or hide database-specific behavior. Instead, it gives you a small shared layer for writing explicit SQL while keeping query execution, result mapping, dialect selection, and driver integration consistent across supported databases. # Multi-Driver The shared `db` package can be used in applications that support more than one database driver. A multi-driver application keeps repository code mostly independent from the selected database engine, while still keeping SQL explicit. This is useful when: * the same application can run with MySQL or PostgreSQL * different tenants use different database engines * tests run against a different driver than production * a migration or tooling package needs to support multiple databases * Scylla and SQL databases share some application-level workflows ## Basic Idea [#basic-idea] Multi-driver support is built around three concepts: | Concept | Purpose | | --------------- | -------------------------------------------------------- | | `db.Conn` | Runtime connection interface implemented by each driver | | `db.DialectSQL` | Holds SQL or CQL variants for supported drivers | | `db.Dialect` | Selects the correct SQL or CQL for the active connection | Instead of hard-coding one query string in repository code, keep driver-specific query text in a `db.DialectSQL` value. ```go type Queries struct { GetUser db.DialectSQL `json:"GetUser"` } ``` Then execute the query through the active connection. ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` The active driver decides which SQL or CQL string is used. ## DialectSQL [#dialectsql] `db.DialectSQL` stores one query per supported driver. ```go type DialectSQL struct { Postgres string `json:"postgres"` Mysql string `json:"mysql"` Scylla string `json:"scylla"` } ``` Example: ```go var getUser = db.DialectSQL{ Mysql: ` SELECT * FROM users WHERE id = ? LIMIT 1 `, Postgres: ` SELECT * FROM users WHERE id = $1 LIMIT 1 `, Scylla: ` SELECT * FROM users_by_id WHERE id = ? `, } ``` Use the same repository function with different connections. ```go func GetUser(ctx context.Context, conn db.Conn, id any) (User, bool, error) { return db.GetDialect[User](ctx, conn, getUser, id) } ``` ## Why Dialect SQL Exists [#why-dialect-sql-exists] The shared `db` API is the same across drivers, but SQL syntax is not always the same. Examples: | Topic | MySQL | Postgres | Scylla | | ------------ | ------------------------- | ------------------------- | ------------------------ | | Placeholders | `?` | `$1`, `$2` | `?` | | Insert ID | `LastInsertId()` | `RETURNING id` | application-generated ID | | Query tables | usually normalized tables | usually normalized tables | query-driven tables | | SQL file | `model.sql` | `model.psql` | `model.cql` | `db.DialectSQL` keeps those differences explicit. It does not try to translate SQL automatically. ## Using db.Dialect [#using-dbdialect] Use `db.Dialect` when you want to build a `db.Query` manually. ```go q, err := db.Dialect(conn, queries.GetUser, id) if err != nil { return User{}, false, err } return db.GetQuery[User](ctx, conn, q) ``` This is useful when: * you want low-level query control * you want to log or inspect `db.Query` * you want to pass the query into another helper * you want to use `ExecQuery`, `GetQuery`, `ValueQuery`, or `MapsQuery` ## High-Level Dialect Helpers [#high-level-dialect-helpers] For most code, use the high-level dialect helpers. ```go users, err := db.ListDialect[User](ctx, conn, queries.ListUsers, limit) ``` ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` ```go total, found, err := db.ValueDialect[int64](ctx, conn, queries.CountUsers) ``` ```go rows, err := db.MapsDialect(ctx, conn, queries.Report, from, to) ``` These helpers select the correct query and execute it in one step. ## Query Model [#query-model] A common pattern is to define a `Queries` struct. ```go type Queries struct { ListUsers db.DialectSQL `json:"ListUsers"` GetUser db.DialectSQL `json:"GetUser"` CountUsers db.DialectSQL `json:"CountUsers"` } ``` Then load it once during application startup. ```go func LoadQueries(conn db.Conn) (Queries, error) { var queries Queries if err := db.LoadModel(conn, ".", &queries); err != nil { return Queries{}, err } return queries, nil } ``` After loading, repository functions can accept the loaded queries. ```go func ListUsers(ctx context.Context, conn db.Conn, queries Queries) ([]User, error) { return db.ListDialect[User](ctx, conn, queries.ListUsers) } ``` ## SQL Files [#sql-files] Multi-driver applications often keep driver-specific queries in separate files. ```text model.sql -> MySQL model.psql -> PostgreSQL model.cql -> Scylla ``` Each file can contain the same section names with driver-specific query text. Example `model.sql`: ```sql --ListUsers SELECT * FROM users ORDER BY created_at DESC --GetUser SELECT * FROM users WHERE id = ? LIMIT 1 --CountUsers SELECT COUNT(*) FROM users ``` Example `model.psql`: ```sql --ListUsers SELECT * FROM users ORDER BY created_at DESC --GetUser SELECT * FROM users WHERE id = $1 LIMIT 1 --CountUsers SELECT COUNT(*) FROM users ``` Example `model.cql`: ```sql --ListUsers SELECT * FROM users_by_created_at --GetUser SELECT * FROM users_by_id WHERE id = ? --CountUsers SELECT COUNT(*) FROM users_count ``` `db.LoadModel` reads the file for the active driver and fills the matching fields in your `Queries` struct. ## Repository Example [#repository-example] ```go package main import ( "context" "time" "github.com/netlifeguru/db" ) type User struct { ID any `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } type Queries struct { ListUsers db.DialectSQL `json:"ListUsers"` GetUser db.DialectSQL `json:"GetUser"` CountUsers db.DialectSQL `json:"CountUsers"` } func LoadQueries(conn db.Conn) (Queries, error) { var queries Queries if err := db.LoadModel(conn, ".", &queries); err != nil { return Queries{}, err } return queries, nil } func ListUsers(ctx context.Context, conn db.Conn, queries Queries) ([]User, error) { return db.ListDialect[User](ctx, conn, queries.ListUsers) } func GetUser(ctx context.Context, conn db.Conn, queries Queries, id any) (User, bool, error) { return db.GetDialect[User](ctx, conn, queries.GetUser, id) } func CountUsers(ctx context.Context, conn db.Conn, queries Queries) (int64, bool, error) { return db.ValueDialect[int64](ctx, conn, queries.CountUsers) } ``` ## Application Example [#application-example] ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { ctx := context.Background() err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } queries, err := LoadQueries(conn) if err != nil { log.Fatal(err) } users, err := ListUsers(ctx, conn, queries) if err != nil { log.Fatal(err) } for _, user := range users { fmt.Println(user.ID, user.Name, user.Email) } } ``` ## Multi-Tenant Drivers [#multi-tenant-drivers] A multi-tenant application may select a different driver or connection per tenant. For example: ```go func TenantConn(ctx context.Context, tenant Tenant) (db.Conn, error) { switch tenant.Driver { case "mysql": return connectMySQLTenant(tenant) case "postgres": return connectPostgreSQLTenant(tenant) case "scylla": return connectScyllaTenant(tenant) default: return nil, errors.New("unsupported tenant driver") } } ``` Repository code can still depend on `db.Conn`. ```go func HandleTenantRequest(ctx context.Context, tenant Tenant) error { conn, err := TenantConn(ctx, tenant) if err != nil { return err } queries, err := LoadQueries(conn) if err != nil { return err } users, err := ListUsers(ctx, conn, queries) if err != nil { return err } fmt.Println(len(users)) return nil } ``` The repository does not need to know which concrete driver was selected. ## Inserts in Multi-Driver Code [#inserts-in-multi-driver-code] Inserts often differ more than selects. For example: * MySQL commonly uses `LastInsertId` * PostgreSQL commonly uses `RETURNING id` * Scylla commonly generates IDs in application code and writes query tables For shared insert flows, use driver-specific `db.DialectSQL` and choose the appropriate execution helper. MySQL-style insert: ```go q, err := db.Dialect(conn, queries.InsertUser, name, email, active) if err != nil { return 0, err } result, err := db.ExecQuery(ctx, conn, q) if err != nil { return 0, err } return result.LastInsertId(), nil ``` PostgreSQL-style insert: ```go q, err := db.Dialect(conn, queries.InsertUser, name, email, active) if err != nil { return 0, err } id, found, err := db.ValueQuery[int64](ctx, conn, q) if err != nil { return 0, err } if !found { return 0, errors.New("insert did not return id") } return id, nil ``` Scylla-style insert: ```go id := gocql.TimeUUID() q, err := db.Dialect(conn, queries.InsertUserByID, id, email, name, active, createdAt) if err != nil { return "", err } if _, err := db.ExecQuery(ctx, conn, q); err != nil { return "", err } return id.String(), nil ``` ## When to Use Multi-Driver Patterns [#when-to-use-multi-driver-patterns] Use multi-driver patterns when: * your app can run on different database engines * you use SQL files for multiple drivers * repository code should remain driver-independent * each tenant may use a different database engine * you need explicit SQL per driver without duplicating Go code ## When Not to Use Them [#when-not-to-use-them] Do not add multi-driver abstractions if your application will only ever use one database. For a single driver, simple raw helpers are often clearer. ```go users, err := db.List[User](ctx, conn, ` SELECT * FROM users `) ``` Use dialect SQL when it solves a real driver-selection problem. ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [PostgreSQL multi-driver SQL files](https://github.com/netlifeguru/examples/db/postgresql/33_multi_driver_sql_files) # SQL Files The `db` package can load SQL or CQL queries from driver-specific files. This is useful when you want to keep query text outside Go code while still using the shared `db` API. SQL files are especially useful for: * larger queries * multi-driver applications * keeping SQL readable * separating query text from repository code * using different SQL syntax per driver * sharing one Go query model across MySQL, PostgreSQL, and Scylla ## File Names [#file-names] Each driver uses its own model file name. | Driver | File | | -------- | ------------ | | MySQL | `model.sql` | | Postgres | `model.psql` | | Scylla | `model.cql` | The active connection decides which file is loaded. For example, if the active connection is PostgreSQL, `db.LoadModel` loads: ```text model.psql ``` If the active connection is MySQL, it loads: ```text model.sql ``` If the active connection is Scylla, it loads: ```text model.cql ``` ## Query Sections [#query-sections] SQL files are split into named sections. Each section starts with a line in this format: ```sql --SectionName ``` Example: ```sql --ListUsers SELECT * FROM users ORDER BY created_at DESC --GetUser SELECT * FROM users WHERE id = ? LIMIT 1 --CountUsers SELECT COUNT(*) FROM users ``` The section name is used as the key in your Go query model. ## Query Model [#query-model] Define a Go struct that matches the section names. ```go type Queries struct { ListUsers db.DialectSQL `json:"ListUsers"` GetUser db.DialectSQL `json:"GetUser"` CountUsers db.DialectSQL `json:"CountUsers"` } ``` Then load the queries with `db.LoadModel`. ```go func LoadQueries(conn db.Conn) (Queries, error) { var queries Queries if err := db.LoadModel(conn, ".", &queries); err != nil { return Queries{}, err } return queries, nil } ``` The second argument is the directory where the model file is located. ```go db.LoadModel(conn, ".", &queries) ``` loads the active driver file from the current directory. ## How Loaded Queries Are Stored [#how-loaded-queries-are-stored] Each field is a `db.DialectSQL`. ```go type DialectSQL struct { Postgres string `json:"postgres"` Mysql string `json:"mysql"` Scylla string `json:"scylla"` } ``` When a MySQL connection loads `model.sql`, the MySQL field is filled. ```go queries.GetUser.Mysql ``` When a PostgreSQL connection loads `model.psql`, the PostgreSQL field is filled. ```go queries.GetUser.Postgres ``` When a Scylla connection loads `model.cql`, the Scylla field is filled. ```go queries.GetUser.Scylla ``` Most application code should not access these fields directly. Prefer dialect helpers such as: ```go db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` or low-level query selection: ```go q, err := db.Dialect(conn, queries.GetUser, id) if err != nil { return User{}, false, err } return db.GetQuery[User](ctx, conn, q) ``` ## MySQL File Example [#mysql-file-example] `model.sql` ```sql --ListUsers SELECT * FROM users ORDER BY created_at DESC --GetUser SELECT * FROM users WHERE id = ? LIMIT 1 --CountUsers SELECT COUNT(*) FROM users ``` MySQL uses `?` placeholders. ## PostgreSQL File Example [#postgresql-file-example] `model.psql` ```sql --ListUsers SELECT * FROM users ORDER BY created_at DESC --GetUser SELECT * FROM users WHERE id = $1 LIMIT 1 --CountUsers SELECT COUNT(*) FROM users ``` PostgreSQL uses numbered placeholders such as `$1`, `$2`, and `$3`. ## Scylla File Example [#scylla-file-example] `model.cql` ```sql --ListUsers SELECT * FROM users_by_created_at --GetUser SELECT * FROM users_by_id WHERE id = ? --CountUsers SELECT count FROM users_count WHERE bucket = ? ``` Scylla uses `?` placeholders and query-driven tables. ## Complete Example [#complete-example] This example loads queries from the active driver file and uses shared dialect helpers. ```go package main import ( "context" "time" "github.com/netlifeguru/db" ) type User struct { ID any `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } type Queries struct { ListUsers db.DialectSQL `json:"ListUsers"` GetUser db.DialectSQL `json:"GetUser"` CountUsers db.DialectSQL `json:"CountUsers"` } func LoadQueries(conn db.Conn) (Queries, error) { var queries Queries if err := db.LoadModel(conn, ".", &queries); err != nil { return Queries{}, err } return queries, nil } func ListUsers(ctx context.Context, conn db.Conn, queries Queries) ([]User, error) { return db.ListDialect[User](ctx, conn, queries.ListUsers) } func GetUser(ctx context.Context, conn db.Conn, queries Queries, id any) (User, bool, error) { return db.GetDialect[User](ctx, conn, queries.GetUser, id) } func CountUsers(ctx context.Context, conn db.Conn, queries Queries, bucket any) (int64, bool, error) { return db.ValueDialect[int64](ctx, conn, queries.CountUsers, bucket) } ``` ## Low-Level Usage [#low-level-usage] Use `db.Dialect` when you want the selected query as a `db.Query`. ```go q, err := db.Dialect(conn, queries.GetUser, id) if err != nil { return User{}, false, err } return db.GetQuery[User](ctx, conn, q) ``` This is useful when: * you want to inspect the selected query * you want to log query metadata * you want to pass a `db.Query` to lower-level helpers * you are using `ExecQuery`, `GetQuery`, `ValueQuery`, or `MapsQuery` ## Insert Queries [#insert-queries] Insert queries can also be stored in SQL files. The query shape may differ by driver. MySQL `model.sql`: ```sql --InsertUser INSERT INTO users (name, email, active) VALUES (?, ?, ?) ``` PostgreSQL `model.psql`: ```sql --InsertUser INSERT INTO users (name, email, active) VALUES ($1, $2, $3) RETURNING id ``` Scylla `model.cql`: ```sql --InsertUserByID INSERT INTO users_by_id (id, email, name, active, created_at) VALUES (?, ?, ?, ?, ?) ``` For MySQL: ```go q, err := db.Dialect(conn, queries.InsertUser, name, email, active) if err != nil { return 0, err } result, err := db.ExecQuery(ctx, conn, q) if err != nil { return 0, err } return result.LastInsertId(), nil ``` For PostgreSQL: ```go q, err := db.Dialect(conn, queries.InsertUser, name, email, active) if err != nil { return 0, err } id, found, err := db.ValueQuery[int64](ctx, conn, q) if err != nil { return 0, err } if !found { return 0, errors.New("insert did not return id") } return id, nil ``` For Scylla, generate the ID in application code and pass it into the selected query. ```go id := gocql.TimeUUID() createdAt := time.Now().UTC() q, err := db.Dialect(conn, queries.InsertUserByID, id, email, name, active, createdAt) if err != nil { return "", err } if _, err := db.ExecQuery(ctx, conn, q); err != nil { return "", err } return id.String(), nil ``` ## Section Names [#section-names] Section names must match the `json` tags in your query model. ```go type Queries struct { ListUsers db.DialectSQL `json:"ListUsers"` } ``` SQL file: ```sql --ListUsers SELECT * FROM users ``` If the section name does not match, the field will not be populated. ## Invalid SQL [#invalid-sql] When loading a model file, the active driver analyzes each SQL section. If a section contains invalid syntax for the driver’s placeholder or string parsing rules, `db.LoadModel` returns an error. Examples include: * unterminated single-quoted strings * unterminated double-quoted identifiers * unterminated block comments * invalid PostgreSQL placeholders * unterminated PostgreSQL dollar-quoted strings This helps catch invalid SQL files during startup instead of later during runtime. ## When to Use SQL Files [#when-to-use-sql-files] Use SQL files when: * queries are large * queries are easier to read outside Go code * you support multiple database drivers * you want the same section names across different SQL dialects * you want startup-time query loading * you want repository code to use `db.DialectSQL` ## When Not to Use SQL Files [#when-not-to-use-sql-files] Do not use SQL files when simple inline queries are clearer. For example: ```go users, err := db.List[User](ctx, conn, ` SELECT * FROM users `) ``` For small single-driver applications, inline SQL can be easier to maintain. Use SQL files when they reduce duplication or improve clarity. ## Recommended Pattern [#recommended-pattern] Load queries during application startup. ```go queries, err := LoadQueries(conn) if err != nil { return err } ``` Pass the loaded query model into repositories or services. ```go users, err := ListUsers(ctx, conn, queries) ``` Keep repository code focused on execution: ```go return db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` Keep driver-specific SQL in: ```text model.sql model.psql model.cql ``` ## Related Example [#related-example] A standalone example is available in the examples repository: [PostgreSQL multi-driver SQL files](https://github.com/netlifeguru/examples/db/postgresql/28_multi_driver_sql_files) # Logger Introduction High-performance structured logger for Go built on top of `log/slog`. Focused on **minimal allocations**, **high performance**, and **simple file rotation** while staying compatible with the Go standard logging ecosystem. > Use logger to combine colorized console output, JSON logging, file rotation, and daily log archiving through a clean > slog-compatible API. ## Features [#features] * **Standard Compatible**: Built directly on top of Go’s standard `log/slog` package * **Colorized Terminal Output**: Human-readable colored logs for local development * **Structured JSON Logging**: Machine-friendly JSON logs for production environments * **Automatic Log Directory**: Creates the default `log` directory automatically * **File Rotation**: Daily and size-based log rotation support * **Automatic Cleanup**: Removes old archived log files automatically * **Daily Log Naming**: Generates files such as `2026-05-11-0001.log` * **Separate Log Levels**: Independent minimum levels for terminal and file output * **Custom Notice Level**: Includes `LevelNotice` support for important console-visible messages * **Context Logging**: Supports `InfoContext`, `WarnContext`, and `ErrorContext` * **Structured Chaining**: Reusable structured loggers with `logger.With(...)` * **Source Tracking**: Optional source file and line number logging * **Thread-Safe Design**: Safe for concurrent workloads and server environments * **Optimized File Writer**: High-throughput logging with reduced allocation overhead * **Graceful Shutdown**: Safe logger shutdown through the returned `Closer` *** ## Requirements [#requirements] This package requires Go 1.22 or newer. It is designed for modern Go projects and may use language and standard library features introduced in recent Go versions. * **Go:** `1.22` or newer * **Dependencies:** Standard library only * **Features used:** Generics, concurrency primitives # About # NLG HTTP Router [#nlg-http-router] `router` is a production-oriented HTTP routing package for Go focused on performance, composability, and clean application structure. It combines radix-tree based route matching with middleware pipelines, hierarchical route grouping, request-scoped context utilities, recovery handling, rate limiting, profiling support, static asset serving, and multi-server orchestration in a lightweight API built on top of Go’s standard `net/http` interfaces. ## Features [#features] * **Zero-Allocation Route Matching**: Static, wildcard, parameterized, and mounted route lookups run with `0 B/op` and `0 allocs/op` in benchmarks. * **Fast Radix Router**: Optimized radix-tree based route lookup for static, wildcard, and parameterized routes. * **Prepared Pattern Matching**: Built-in prepared matchers for common route constraints such as UUIDs, digits, slugs, dates, hex values, base64 values, and safe path segments. * **Regex Route Parameters**: Define custom route parameters with regular expressions and validation. * **Route Groups**: Organize routes using hierarchical groups with inherited middleware. * **Middleware Pipeline**: Compose global, grouped, and route-level middleware. * **Mount Support**: Mount existing `http.Handler` and `http.HandlerFunc` implementations under route prefixes. * **Pooled Request Context**: Per-request context with parameter access and temporary key/value storage backed by `sync.Pool`. * **Health check Endpoints**: Built-in liveness and readiness route helpers. * **Rate Limiting Guard**: Built-in middleware for request throttling and cooldown-based protection. * **Built-in pprof Profiling**: Enable a dedicated profiling server with Go’s standard `net/http/pprof`. * **Static File Serving**: Serve static directories with automatic `favicon.ico` support. * **Custom NotFound Handler**: Override the default `404 page not found` response. * **Custom Recovery Handler**: Recover from panics and provide your own fallback response. * **Panic Logging**: Panic details are logged through Go’s standard `log/slog`, including request method and path. * **Access Logging**: Optional middleware for structured request logging. * **Multi-Server Support**: Run multiple listeners from a single router, useful for services exposing multiple ports. * **Graceful Shutdown**: Handles interrupt and termination signals with safe HTTP server shutdown. * **Efficient Method Matching**: Uses HTTP method bitmasks for fast method validation and `405 Method Not Allowed` handling. * **Standard Library Compatible**: Works with `net/http`, `http.Handler`, `http.HandlerFunc`, and `log/slog`. ## Requirements [#requirements] This package requires Go 1.25 or newer. It is designed for modern Go projects and may use language and standard library features introduced in recent Go versions. * **Go:** `1.25` or newer * **Dependencies:** Standard library + `github.com/netlifeguru/logger` * **Features used:** Generics, concurrency primitives The package is optimized for concurrent workloads and low allocation overhead, making it suitable for APIs, internal services, edge gateways, backend platforms, and modern distributed systems. # Dynamic Rows Mapper is most commonly used to scan database rows into structs. For dynamic use cases, it can also scan rows into `map[string]any`, fill structs from maps, or provide typed access to row values. This is useful when: * the result shape is dynamic * you do not want to define a struct for every query * you are working with joins, reports, exports, or admin tooling * you need custom mapping logic * you want to inspect raw database values before assigning them ## Scan Rows Into Maps [#scan-rows-into-maps] Use `ScanMapRows` when you want each database row as a `map[string]any`. ```go rows, err := db.Query(` SELECT * FROM users `) if err != nil { return err } defer rows.Close() err = mapper.ScanMapRows(rows, func(row map[string]any) error { fmt.Println(row["id"], row["name"]) return nil }) if err != nil { return err } ``` Each returned row is represented as a map where: * the key is the database column name * the value is the scanned database value Example result: ```go map[string]any{ "id": "u_123", "name": "Alice", "email": "alice@example.com", "active": true, } ``` ## When to Use Maps [#when-to-use-maps] Maps are useful when the result does not have a stable struct shape. For example: ```go rows, err := db.Query(` SELECT status, COUNT(*) AS total FROM users GROUP BY status `) ``` You can process the result dynamically: ```go err = mapper.ScanMapRows(rows, func(row map[string]any) error { status := row["status"] total := row["total"] fmt.Println(status, total) return nil }) ``` For stable application models, structs are usually preferred because they provide stronger type safety. ## The `Row` Type [#the-row-type] `Row` is a convenience type for working with `map[string]any`. ```go type Row map[string]any ``` It provides typed helper methods for common values. ```go row := mapper.Row{ "id": int64(123), "name": "Alice", "active": true, } ``` You can read values using typed accessors: ```go id, ok := row.Int64("id") if !ok { return errors.New("invalid id") } name, ok := row.String("name") if !ok { return errors.New("invalid name") } active, ok := row.Bool("active") if !ok { return errors.New("invalid active value") } ``` ## Row Helpers [#row-helpers] The `Row` type provides these helper methods: | Method | Purpose | | -------- | --------------------------- | | `Int` | Read a value as `int` | | `Int64` | Read a value as `int64` | | `String` | Read a value as `string` | | `Bool` | Read a value as `bool` | | `Time` | Read a value as `time.Time` | Example: ```go createdAt, ok := row.Time("created_at") if !ok { return errors.New("invalid created_at value") } ``` Each helper returns two values: ```go value, ok := row.String("name") ``` If the value can be converted, `ok` is `true`. If the value is missing or cannot be converted, `ok` is `false`. ## Standalone Converters [#standalone-converters] The same conversions are also available as standalone functions. ```go name, ok := mapper.AsString(row["name"]) active, ok := mapper.AsBool(row["active"]) createdAt, ok := mapper.AsTime(row["created_at"]) ``` Available converters: | Function | Purpose | | ---------- | ------------------------------ | | `AsInt` | Convert a value to `int` | | `AsInt64` | Convert a value to `int64` | | `AsString` | Convert a value to `string` | | `AsBool` | Convert a value to `bool` | | `AsTime` | Convert a value to `time.Time` | These helpers are useful when working with raw row maps or custom mapping logic. ## Fill a Struct From a Map [#fill-a-struct-from-a-map] Use `FillFromMap` when you already have a `map[string]any` and want to fill a struct. ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` } row := map[string]any{ "id": int64(1), "name": "Alice", "email": "alice@example.com", "active": true, } var user User err := mapper.FillFromMap(&user, row) if err != nil { return err } ``` The same mapping rules are used as with row scanning: 1. `db` tag 2. `json` tag 3. Go field name 4. snake\_case fallback ## Maps and Column Names [#maps-and-column-names] When scanning rows into maps, mapper uses the column names returned by the database driver. This means SQL aliases become map keys. ```go rows, err := db.Query(` SELECT u.id AS user_id, u.name AS user_name, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id `) ``` The row map will contain keys such as: ```go map[string]any{ "user_id": "u_123", "user_name": "Alice", "role_name": "Admin", } ``` Aliases are recommended when joining tables that contain columns with the same name. ## Custom Mapping With `ScanMapper` [#custom-mapping-with-scanmapper] For advanced cases, a struct can implement the `ScanMapper` interface. ```go type User struct { ID string Name string } func (u *User) ScanMap(row map[string]any) error { id, ok := mapper.AsString(row["id"]) if !ok { return errors.New("invalid id") } name, ok := mapper.AsString(row["name"]) if !ok { return errors.New("invalid name") } u.ID = id u.Name = name return nil } ``` This gives you full control over how a row map is converted into a struct. Custom mapping is useful when: * column names do not match struct fields * values need custom parsing * multiple columns should be combined into one field * fallback values are needed * validation should happen during mapping ## Structs vs Maps [#structs-vs-maps] Use structs when the result shape is known. ```go users, err := mapper.ScanStructSlice[User](rows) ``` Use maps when the result shape is dynamic. ```go err = mapper.ScanMapRows(rows, func(row map[string]any) error { fmt.Println(row) return nil }) ``` In most application code, structs are preferred. Maps are better for generic tooling, reports, exports, debugging, and custom data processing. ## Recommended Usage [#recommended-usage] For regular application queries: ```go users, err := mapper.ScanStructSlice[User](rows) ``` For dynamic query results: ```go err = mapper.ScanMapRows(rows, func(row map[string]any) error { fmt.Println(row) return nil }) ``` For manual mapping: ```go var user User err := mapper.FillFromMap(&user, row) if err != nil { return err } ``` For custom parsing and validation, implement `ScanMapper`. # Edge Cases Mapper is designed to handle common database scanning edge cases without requiring manual scan logic in every query. This page summarizes how mapper behaves when values are missing, nullable, extra, converted, or not assignable. ## Overview [#overview] | Case | Behavior | | ------------------ | ---------------------------------------------------------------------------------------------- | | `NULL` values | Assigned to pointers, nullable structs, maps, or left as zero value where applicable | | Extra columns | Ignored when no matching struct field exists | | Missing columns | Matching struct fields keep their zero value | | Type conversions | Common numeric, string, boolean, time, slice, and map conversions are handled by `AssignValue` | | Pointer fields | Mapper allocates and assigns pointer values when the source value is not `NULL` | | JSON fields | JSON strings or byte slices can be assigned into slices and maps | | Empty result | `ScanStructSlice` returns an empty slice, `ScanStructOne` returns `ErrNoRows` | | Too many rows | `ScanStructOne` returns `ErrTooManyRows` | | Invalid assignment | Mapper returns an error when a value cannot be assigned to the target field | ## Null Values [#null-values] When a database value is `NULL`, mapper handles it according to the target field type. ```go type User struct { ID string `db:"id"` Email *string `db:"email"` } ``` If `email` is `NULL`, the `Email` field remains `nil`. If `email` contains a value, mapper allocates the pointer and assigns the value. ```go if user.Email != nil { fmt.Println(*user.Email) } ``` For non-pointer primitive fields, a `NULL` value leaves the field unchanged, usually its zero value. ```go type User struct { ID string `db:"id"` Active bool `db:"active"` } ``` If `active` is `NULL`, the field remains `false`. ## Nullable Structs [#nullable-structs] Mapper supports nullable-style structs with a `Valid` field and a supported value field. Supported value field names are: * `String` * `Time` * `Bool` * `Int64` * `Float64` Example: ```go type NullString struct { String string Valid bool } type User struct { ID string `db:"id"` Email NullString `db:"email"` } ``` If `email` is `NULL`, the nullable struct is reset to its zero value. If `email` contains a value, mapper assigns the value and sets `Valid` to `true`. ```go if user.Email.Valid { fmt.Println(user.Email.String) } ``` This works with nullable structs that follow the same field shape, including standard-library-like nullable types. ## Extra Columns [#extra-columns] Extra columns are ignored when no matching struct field exists. ```go type User struct { ID string `db:"id"` Name string `db:"name"` } ``` Query: ```sql SELECT id, name, created_at FROM users ``` The `created_at` column is ignored because the struct does not define a matching field. This makes mapper safe to use with: * `SELECT *` * joined queries * aliased queries * queries that return additional computed values ## Missing Columns [#missing-columns] If a result set does not contain a column for a struct field, that field keeps its zero value. ```go type User struct { ID string `db:"id"` Name string `db:"name"` Active bool `db:"active"` } ``` Query: ```sql SELECT id, name FROM users ``` The `Active` field is not present in the result set, so it remains `false`. This behavior is useful for partial queries and read models. ## Type Conversions [#type-conversions] Mapper uses `AssignValue` to assign scanned values into struct fields. It supports common conversions between database values and Go types. Examples include: | Source value | Target field | | ------------------------- | ------------------------------------------------------------ | | `[]byte` | `string` | | `string` | `string` | | numeric values | `int`, `int64`, `uint`, `float64`, and related numeric types | | `bool` | `bool` | | `time.Time` | `time.Time` | | `string` or `[]byte` JSON | slices and maps | | non-`NULL` values | pointer fields | Example: ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` CreatedAt time.Time `db:"created_at"` } ``` If a value cannot be converted safely, mapper returns an error. ## Boolean Values [#boolean-values] Boolean assignment depends on the API being used. When assigning directly into struct fields, mapper expects a real boolean value for `bool` fields. ```go type User struct { Active bool `db:"active"` } ``` For dynamic row maps, the helper functions are more flexible. ```go active, ok := mapper.AsBool(row["active"]) ``` `AsBool` accepts booleans, numeric values, and common string values such as: * `true` * `false` * `1` * `0` * `yes` * `no` ## Pointer Fields [#pointer-fields] Pointer fields are useful for optional database values. ```go type User struct { ID string `db:"id"` Email *string `db:"email"` DeletedAt *time.Time `db:"deleted_at"` } ``` When the source value is not `NULL`, mapper creates and assigns the pointer value. When the source value is `NULL`, the pointer remains `nil`. ```go if user.DeletedAt == nil { fmt.Println("not deleted") } ``` ## JSON Fields [#json-fields] Mapper can assign JSON strings or byte slices into slice and map fields. ```go type User struct { ID string `db:"id"` Tags []string `db:"tags"` Metadata map[string]string `db:"metadata"` } ``` The database driver must return the JSON value as a `string` or `[]byte`. Example database values: ```json ["admin", "active"] ``` ```json {"source":"import","role":"admin"} ``` Mapper unmarshals the JSON into the target slice or map. If the JSON is invalid, mapper returns an error. ## Empty Results [#empty-results] `ScanStructSlice` returns an empty slice when there are no rows. ```go users, err := mapper.ScanStructSlice[User](rows) if err != nil { return err } fmt.Println(len(users)) // 0 ``` `ScanStructRows` simply does not call the callback when there are no rows. ```go err := mapper.ScanStructRows[User](rows, func(user *User) error { fmt.Println(user.Name) return nil }) ``` `ScanStructOne` returns `mapper.ErrNoRows`. ```go user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrNoRows) { return nil } if err != nil { return err } ``` ## Too Many Rows [#too-many-rows] `ScanStructOne` expects exactly one row. If more than one row is returned, it returns `mapper.ErrTooManyRows`. ```go user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrTooManyRows) { return errors.New("expected only one user") } if err != nil { return err } fmt.Println(user.Name) ``` Use `ScanStructSlice` when multiple rows are expected. ## Invalid Assignment [#invalid-assignment] Mapper returns an error when a source value cannot be assigned to the target field. Example: ```go type User struct { CreatedAt time.Time `db:"created_at"` } ``` If the database returns a non-time value that cannot be assigned to `time.Time`, mapper returns an error. This helps catch schema mismatches early. ## Recommended Practices [#recommended-practices] Use explicit `db` tags for stable mapping. ```go type User struct { ID string `db:"id"` Name string `db:"name"` CreatedAt time.Time `db:"created_at"` } ``` Use pointer fields or nullable structs for optional database values. ```go type User struct { Email *string `db:"email"` } ``` Use SQL aliases for joins and computed columns. ```sql SELECT u.id AS user_id, u.name AS user_name, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id ``` Use `ScanStructOne` only when the query is expected to return exactly one row. Use `ScanStructSlice` or `ScanStructRows` when multiple rows are expected. # About # Mapper [#mapper] Mapper is a small standalone utility package for scanning database rows into Go structs, maps, or custom row handlers. You usually do not need to learn it deeply unless you want direct control over row scanning. When using NetLifeGuru database drivers, mapper is already used internally. It works with any database driver that can be adapted to the `mapper.Rows` interface. ## Note [#note] > Mapper is database-agnostic and can be used with different SQL-compatible systems such as MySQL, PostgreSQL, ScyllaDB, CockroachDB, MariaDB, and similar drivers. The package does not depend on a specific database engine; it only needs rows that can be adapted to the mapper.Rows interface. For clarity and consistency, the examples in this documentation use MySQL, but the same mapping concepts apply to other supported database systems. ## Features [#features] * **Standalone Package**: Can be used independently without the NetLifeGuru database layer * **Database Agnostic**: Works with any driver that can expose rows through the `mapper.Rows` interface * **Struct Mapping**: Scans database rows directly into Go structs * **Column Name Matching**: Maps columns by name instead of relying on scan position * **Tag Support**: Uses `db` tags first, then `json` tags, and falls back to field names * **Snake Case Fallback**: Automatically supports snake\_case column names for exported struct fields * **Map Scanning**: Scans rows into `map[string]any` for dynamic use cases * **Custom Mapping**: Supports custom row mapping through the `ScanMapper` interface * **Nullable Value Support**: Handles nullable-style structs with fields such as `String`, `Time`, `Bool`, `Int64`, `Float64`, and `Valid` * **Pointer Support**: Assigns scanned values into pointer fields when needed * **JSON Field Support**: Can assign JSON strings or byte slices into slices and maps * **Typed Row Helpers**: Provides helper methods for reading `int`, `int64`, `string`, `bool`, and `time.Time` from row maps * **Scan Plan Cache**: Caches struct metadata and scan plans for repeated row scanning * **Workspace Pooling**: Reuses internal scan workspaces to reduce allocation overhead * **Standard Go Friendly**: Designed around simple interfaces, structs, generics, and `database/sql`-style row behavior ## Requirements [#requirements] This package requires Go 1.22 or newer. It is designed for modern Go projects and may use language and standard library features introduced in recent Go versions. * **Go:** `1.22` or newer * **Dependencies:** Standard library only * **Features used:** Generics, reflection, concurrency primitives ## Quick Example [#quick-example] ```go type User struct { ID string `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } rows, err := db.Query(` SELECT * FROM users ORDER BY created_at DESC `) if err != nil { return err } defer rows.Close() users, err := mapper.ScanStructSlice[User](rows) if err != nil { return err } ``` ## Main APIs [#main-apis] | API | Purpose | | ----------------- | ---------------------------------------- | | `ScanStructRows` | Stream rows into structs with a callback | | `ScanStructSlice` | Scan all rows into `[]T` | | `ScanStructOne` | Scan exactly one row | | `ScanMapRows` | Scan rows into `map[string]any` | | `FillFromMap` | Fill a struct from `map[string]any` | | `Row` converters | Typed access to map values | # Mapping Mapper maps database columns to Go struct fields by name. Instead of relying on scan position, mapper reads the column names returned by the database driver and matches them against exported struct fields. This makes queries easier to maintain, especially when using aliases, joins, reordered columns, or `SELECT *`. ## Basic Mapping [#basic-mapping] Use `db` tags to define how database columns should map to struct fields. ```go type User struct { ID string `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` Example query: ```go rows, err := db.Query(` SELECT * FROM users `) ``` The mapper matches each returned column to the corresponding struct field: | Column | Struct field | | ------------ | ------------ | | `id` | `ID` | | `name` | `Name` | | `email` | `Email` | | `active` | `Active` | | `created_at` | `CreatedAt` | ## Matching Order [#matching-order] When mapper builds field metadata for a struct, it resolves field names in this order: 1. `db` tag 2. `json` tag 3. Go field name 4. snake\_case fallback based on the Go field name This means the following struct can be mapped in multiple ways. ```go type User struct { ID string `db:"id"` FullName string `json:"full_name"` Email string CreatedAt time.Time } ``` Supported column names: | Struct field | Matched column names | | ------------ | ------------------------- | | `ID` | `id` | | `FullName` | `full_name` | | `Email` | `Email`, `email` | | `CreatedAt` | `CreatedAt`, `created_at` | The explicit `db` tag has the highest priority and should be preferred for database models. ## Using `db` Tags [#using-db-tags] The `db` tag is the recommended way to describe database column names. ```go type Product struct { ID int64 `db:"id"` Name string `db:"name"` Price float64 `db:"price"` CreatedAt time.Time `db:"created_at"` } ``` This keeps the mapping stable even if Go field names change later. ## Using `json` Tags [#using-json-tags] If a field does not define a `db` tag, mapper can use the `json` tag. ```go type Product struct { ID int64 `json:"id"` Name string `json:"name"` Price float64 `json:"price"` } ``` This is useful when the same struct is used for database mapping and JSON responses. For database-specific structs, prefer `db` tags. ## Field Name Fallback [#field-name-fallback] If no `db` or `json` tag is present, mapper falls back to the Go field name. ```go type User struct { ID string Name string Email string } ``` This can match columns such as: ```sql SELECT ID, Name, Email FROM users ``` This fallback is useful for simple cases, but explicit tags are usually clearer. ## Snake Case Fallback [#snake-case-fallback] Mapper also registers a snake\_case version of each exported field name. ```go type User struct { CreatedAt time.Time UpdatedAt time.Time } ``` This can match columns such as: ```sql SELECT created_at, updated_at FROM users ``` So this struct works even without tags: ```go type User struct { CreatedAt time.Time UpdatedAt time.Time } ``` For public documentation and long-term application code, explicit `db` tags are still recommended. ## Ignoring Fields [#ignoring-fields] Use `db:"-"` to exclude a field from mapping. ```go type User struct { ID string `db:"id"` Name string `db:"name"` Password string `db:"-"` } ``` Ignored fields are not mapped from database columns. The same behavior applies to `json:"-"` when no `db` tag is present. ## Column Aliases [#column-aliases] Column aliases are often useful when joining tables or returning computed values. ```go type UserWithRole struct { UserID string `db:"user_id"` UserName string `db:"user_name"` RoleName string `db:"role_name"` } ``` ```go rows, err := db.Query(` SELECT u.id AS user_id, u.name AS user_name, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id `) ``` Mapper uses the returned column names, so aliases work naturally. ## Extra Columns [#extra-columns] If a query returns a column that has no matching struct field, mapper ignores it. ```go type User struct { ID string `db:"id"` Name string `db:"name"` } ``` ```sql SELECT id, name, created_at FROM users ``` In this case, `created_at` is ignored because the struct does not define a matching field. This makes mapper friendly to `SELECT *`, joins, and queries that return additional values. ## Missing Columns [#missing-columns] If a struct field has no matching column in the result set, the field keeps its zero value. ```go type User struct { ID string `db:"id"` Name string `db:"name"` Active bool `db:"active"` } ``` ```sql SELECT id, name FROM users ``` The `Active` field is not present in the result set, so it remains the zero value for `bool`, which is `false`. ## Type Assignment [#type-assignment] Mapper assigns scanned values into struct fields using `AssignValue`. This handles common Go types such as: * strings * booleans * signed and unsigned integers * floating point numbers * `time.Time` * pointers * byte slices * slices and maps from JSON values * nullable-style structs Example: ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` If a value cannot be assigned to the target field, mapper returns an error. ## Pointer Fields [#pointer-fields] Pointer fields are supported. ```go type User struct { ID string `db:"id"` Name string `db:"name"` Email *string `db:"email"` } ``` When the database value is not `NULL`, mapper allocates and assigns the pointer value. When the source value is `NULL`, the pointer remains `nil`. ## Nullable Values [#nullable-values] Mapper supports nullable-style structs with a `Valid` field and a supported value field. Supported value field names include: * `String` * `Time` * `Bool` * `Int64` * `Float64` Example: ```go type NullString struct { String string Valid bool } type User struct { ID string `db:"id"` Email NullString `db:"email"` } ``` When the source value is `NULL`, the nullable struct is reset to its zero value. When the source value is present, mapper assigns the value and sets `Valid` to `true`. ## JSON Fields [#json-fields] Mapper can assign JSON strings or byte slices into slice and map fields. ```go type User struct { ID string `db:"id"` Tags []string `db:"tags"` Metadata map[string]string `db:"metadata"` } ``` This is useful when a database column contains JSON data. The database driver must return the JSON value as a `string` or `[]byte`. ## Custom Mapping [#custom-mapping] For cases where automatic field mapping is not enough, implement `ScanMapper`. ```go type User struct { ID string Name string } func (u *User) ScanMap(row map[string]any) error { u.ID, _ = mapper.AsString(row["id"]) u.Name, _ = mapper.AsString(row["name"]) return nil } ``` `ScanMapper` is useful when you need custom parsing, derived fields, fallback values, or non-standard column naming. ## Recommended Style [#recommended-style] For most application code, prefer explicit `db` tags. ```go type User struct { ID string `db:"id"` Name string `db:"name"` Email string `db:"email"` CreatedAt time.Time `db:"created_at"` } ``` This makes the mapping clear, stable, and independent from JSON naming or Go field naming conventions. # Conditional Rules Conditional rule helpers provide shortcuts for common conditional validation patterns. They are built on top of `conditional.When`, but make frequent validation cases easier to read and reuse. Use conditional rule helpers when validation depends on other fields in the same request. ## Common Use Cases [#common-use-cases] Conditional rules are commonly used for: * company-only fields * country-specific billing fields * admin-only notes * contact method validation * address-dependent fields * backup contact information * onboarding flows * profile completion * dynamic form sections ## TL;DR [#tldr] | Helper | Description | | ------------------------------------------------------------------- | ----------------------------------------------------------- | | `conditional.RequiredIfStr(field, cond)` | Requires a string field when `cond` returns `true` | | `conditional.RequiredIfStrWithCode(field, cond, code)` | Same as `RequiredIfStr` with a custom error code | | `conditional.ProhibitedIfStr(field, cond)` | Forbids a non-blank string value when `cond` returns `true` | | `conditional.ProhibitedIfStrWithCode(field, cond, code)` | Same as `ProhibitedIfStr` with a custom error code | | `conditional.RequiredWithAnyStr(field, others...)` | Requires a string field when any other field is non-blank | | `conditional.RequiredWithAnyStrWithCode(field, code, others...)` | Same as `RequiredWithAnyStr` with a custom error code | | `conditional.RequiredWithoutAnyStr(field, others...)` | Requires a string field when any other field is blank | | `conditional.RequiredWithoutAnyStrWithCode(field, code, others...)` | Same as `RequiredWithoutAnyStr` with a custom error code | ## Defining Conditional Fields [#defining-conditional-fields] Conditional validation starts with regular typed string fields. ```go ConditionalRulesForm := struct { AccountType form.StringField[ConditionalRulesRequest] CompanyName form.StringField[ConditionalRulesRequest] Country form.StringField[ConditionalRulesRequest] VatNumber form.StringField[ConditionalRulesRequest] Role form.StringField[ConditionalRulesRequest] AdminNote form.StringField[ConditionalRulesRequest] }{ AccountType: form.Str[ConditionalRulesRequest]("account_type", func(r *ConditionalRulesRequest) string { return r.AccountType }), CompanyName: form.Str[ConditionalRulesRequest]("company_name", func(r *ConditionalRulesRequest) string { return r.CompanyName }), Country: form.Str[ConditionalRulesRequest]("country", func(r *ConditionalRulesRequest) string { return r.Country }), VatNumber: form.Str[ConditionalRulesRequest]("vat_number", func(r *ConditionalRulesRequest) string { return r.VatNumber }), Role: form.Str[ConditionalRulesRequest]("role", func(r *ConditionalRulesRequest) string { return r.Role }), AdminNote: form.Str[ConditionalRulesRequest]("admin_note", func(r *ConditionalRulesRequest) string { return r.AdminNote }), } ``` ## Applying Conditional Rules [#applying-conditional-rules] Conditional helpers attach validation behavior to fields based on runtime values. ```go return form.Schema[ConditionalRulesRequest]{ conditional.RequiredIfStr( ConditionalRulesForm.CompanyName, func(r *ConditionalRulesRequest) bool { return r.AccountType == "company" }, ), conditional.RequiredIfStrWithCode( ConditionalRulesForm.VatNumber, func(r *ConditionalRulesRequest) bool { return r.Country == "SK" }, CodeVatNumberRequired, ), conditional.ProhibitedIfStr( ConditionalRulesForm.AdminNote, func(r *ConditionalRulesRequest) bool { return r.Role != "admin" }, ), } ``` ## Rule Examples [#rule-examples] ### RequiredIfStr [#requiredifstr] Requires a string field when the condition returns `true`. ```go conditional.RequiredIfStr( ConditionalRulesForm.CompanyName, func(r *ConditionalRulesRequest) bool { return r.AccountType == "company" }, ) ``` This is useful when one field becomes mandatory based on another field. *** ### RequiredIfStrWithCode [#requiredifstrwithcode] Requires a string field when the condition returns `true` and returns a custom validation code. ```go const CodeVatNumberRequired = form.Code("vat_number_required") conditional.RequiredIfStrWithCode( ConditionalRulesForm.VatNumber, func(r *ConditionalRulesRequest) bool { return r.Country == "SK" }, CodeVatNumberRequired, ) ``` This is useful for country-specific, account-specific, or workflow-specific validation. *** ### ProhibitedIfStr [#prohibitedifstr] Forbids a non-blank string value when the condition returns `true`. ```go conditional.ProhibitedIfStr( ConditionalRulesForm.AdminNote, func(r *ConditionalRulesRequest) bool { return r.Role != "admin" }, ) ``` Validation behavior: | Condition | Field value | Result | | --------- | --------------- | ------- | | `false` | any value | skipped | | `true` | `""` | valid | | `true` | `"secret note"` | invalid | *** ### ProhibitedIfStrWithCode [#prohibitedifstrwithcode] Forbids a non-blank string value when the condition returns `true` and returns a custom validation code. ```go const CodeInternalNoteBlocked = form.Code("internal_note_blocked") conditional.ProhibitedIfStrWithCode( ConditionalRulesForm.InternalNote, func(r *ConditionalRulesRequest) bool { return r.UserType == "external" }, CodeInternalNoteBlocked, ) ``` This is useful for protecting internal-only fields from public or external input. *** ### RequiredWithAnyStr [#requiredwithanystr] Requires a field when any of the referenced fields is non-blank. ```go conditional.RequiredWithAnyStr( ConditionalRulesForm.Email, ConditionalRulesForm.Phone, ) ``` This means: ```text if phone is filled, email is required ``` Typical use cases: * contact method pairs * dependent address fields * partially completed form sections *** ### RequiredWithAnyStrWithCode [#requiredwithanystrwithcode] Requires a field when any referenced field is non-blank and returns a custom validation code. ```go const CodeContactRequired = form.Code("contact_required") conditional.RequiredWithAnyStrWithCode( ConditionalRulesForm.ContactName, CodeContactRequired, ConditionalRulesForm.Address, ConditionalRulesForm.City, ) ``` This means: ```text if address or city is filled, contact_name is required ``` *** ### RequiredWithoutAnyStr [#requiredwithoutanystr] Requires a field when any referenced field is blank. ```go conditional.RequiredWithoutAnyStr( ConditionalRulesForm.Phone, ConditionalRulesForm.Email, ) ``` This means: ```text if email is blank, phone is required ``` Typical use cases: * fallback contact fields * at-least-one contact method * alternative input paths *** ### RequiredWithoutAnyStrWithCode [#requiredwithoutanystrwithcode] Requires a field when any referenced field is blank and returns a custom validation code. ```go const CodeBackupRequired = form.Code("backup_required") conditional.RequiredWithoutAnyStrWithCode( ConditionalRulesForm.BackupEmail, CodeBackupRequired, ConditionalRulesForm.BackupPhone, ) ``` This means: ```text if backup_phone is blank, backup_email is required ``` ## Complete Example [#complete-example] ### schema.go [#schemago] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/conditional" ) const ( CodeVatNumberRequired = form.Code("vat_number_required") CodeInternalNoteBlocked = form.Code("internal_note_blocked") CodeContactRequired = form.Code("contact_required") CodeBackupRequired = form.Code("backup_required") ) type ConditionalRulesRequest struct { AccountType string `json:"account_type"` CompanyName string `json:"company_name"` Country string `json:"country"` VatNumber string `json:"vat_number"` Role string `json:"role"` AdminNote string `json:"admin_note"` UserType string `json:"user_type"` InternalNote string `json:"internal_note"` Phone string `json:"phone"` Email string `json:"email"` Address string `json:"address"` City string `json:"city"` ContactName string `json:"contact_name"` BackupEmail string `json:"backup_email"` BackupPhone string `json:"backup_phone"` } func ConditionalRulesSchema() form.Schema[ConditionalRulesRequest] { ConditionalRulesForm := struct { AccountType form.StringField[ConditionalRulesRequest] CompanyName form.StringField[ConditionalRulesRequest] Country form.StringField[ConditionalRulesRequest] VatNumber form.StringField[ConditionalRulesRequest] Role form.StringField[ConditionalRulesRequest] AdminNote form.StringField[ConditionalRulesRequest] UserType form.StringField[ConditionalRulesRequest] InternalNote form.StringField[ConditionalRulesRequest] Phone form.StringField[ConditionalRulesRequest] Email form.StringField[ConditionalRulesRequest] Address form.StringField[ConditionalRulesRequest] City form.StringField[ConditionalRulesRequest] ContactName form.StringField[ConditionalRulesRequest] BackupEmail form.StringField[ConditionalRulesRequest] BackupPhone form.StringField[ConditionalRulesRequest] }{ AccountType: form.Str[ConditionalRulesRequest]("account_type", func(r *ConditionalRulesRequest) string { return r.AccountType }), CompanyName: form.Str[ConditionalRulesRequest]("company_name", func(r *ConditionalRulesRequest) string { return r.CompanyName }), Country: form.Str[ConditionalRulesRequest]("country", func(r *ConditionalRulesRequest) string { return r.Country }), VatNumber: form.Str[ConditionalRulesRequest]("vat_number", func(r *ConditionalRulesRequest) string { return r.VatNumber }), Role: form.Str[ConditionalRulesRequest]("role", func(r *ConditionalRulesRequest) string { return r.Role }), AdminNote: form.Str[ConditionalRulesRequest]("admin_note", func(r *ConditionalRulesRequest) string { return r.AdminNote }), UserType: form.Str[ConditionalRulesRequest]("user_type", func(r *ConditionalRulesRequest) string { return r.UserType }), InternalNote: form.Str[ConditionalRulesRequest]("internal_note", func(r *ConditionalRulesRequest) string { return r.InternalNote }), Phone: form.Str[ConditionalRulesRequest]("phone", func(r *ConditionalRulesRequest) string { return r.Phone }), Email: form.Str[ConditionalRulesRequest]("email", func(r *ConditionalRulesRequest) string { return r.Email }), Address: form.Str[ConditionalRulesRequest]("address", func(r *ConditionalRulesRequest) string { return r.Address }), City: form.Str[ConditionalRulesRequest]("city", func(r *ConditionalRulesRequest) string { return r.City }), ContactName: form.Str[ConditionalRulesRequest]("contact_name", func(r *ConditionalRulesRequest) string { return r.ContactName }), BackupEmail: form.Str[ConditionalRulesRequest]("backup_email", func(r *ConditionalRulesRequest) string { return r.BackupEmail }), BackupPhone: form.Str[ConditionalRulesRequest]("backup_phone", func(r *ConditionalRulesRequest) string { return r.BackupPhone }), } return form.Schema[ConditionalRulesRequest]{ conditional.RequiredIfStr( ConditionalRulesForm.CompanyName, func(r *ConditionalRulesRequest) bool { return r.AccountType == "company" }, ), conditional.RequiredIfStrWithCode( ConditionalRulesForm.VatNumber, func(r *ConditionalRulesRequest) bool { return r.Country == "SK" }, CodeVatNumberRequired, ), conditional.ProhibitedIfStr( ConditionalRulesForm.AdminNote, func(r *ConditionalRulesRequest) bool { return r.Role != "admin" }, ), conditional.ProhibitedIfStrWithCode( ConditionalRulesForm.InternalNote, func(r *ConditionalRulesRequest) bool { return r.UserType == "external" }, CodeInternalNoteBlocked, ), conditional.RequiredWithAnyStr( ConditionalRulesForm.Email, ConditionalRulesForm.Phone, ), conditional.RequiredWithAnyStrWithCode( ConditionalRulesForm.ContactName, CodeContactRequired, ConditionalRulesForm.Address, ConditionalRulesForm.City, ), conditional.RequiredWithoutAnyStr( ConditionalRulesForm.Phone, ConditionalRulesForm.Email, ), conditional.RequiredWithoutAnyStrWithCode( ConditionalRulesForm.BackupEmail, CodeBackupRequired, ConditionalRulesForm.BackupPhone, ), } } ``` ### main.go [#maingo] ```go package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/form" "github.com/netlifeguru/form/httpform" "github.com/netlifeguru/router" ) func main() { r := router.New() r.HandleFunc("/conditional-rules", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in ConditionalRulesRequest if !httpform.BindAndValidate(w, req, &in, ConditionalRulesSchema(), 1<<20) { fmt.Println("conditional rules validation failed") return } fmt.Println("conditional rules validation passed:", in) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "conditional rules validation passed", "data": in, }) }) validPayload := map[string]any{ "account_type": "company", "company_name": "Acme s.r.o.", "country": "SK", "vat_number": "SK1234567890", "role": "user", "admin_note": "", "user_type": "external", "internal_note": "", "phone": "+421900123456", "email": "john@example.com", "address": "Main Street 1", "city": "", "contact_name": "John Doe", "backup_email": "backup@example.com", "backup_phone": "", } invalidPayload := map[string]any{ "account_type": "company", "company_name": "", "country": "SK", "vat_number": "", "role": "user", "admin_note": "internal only", "user_type": "external", "internal_note": "secret note", "phone": "+421900123456", "email": "", "address": "Main Street 1", "city": "", "contact_name": "", "backup_email": "", "backup_phone": "", } fmt.Println("\n--- Valid request ---") form.SendTestPost(":8080/conditional-rules", validPayload) fmt.Println("\n--- Invalid request ---") form.SendTestPost(":8080/conditional-rules", invalidPayload) if err := r.ListenAndServe(8080); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` ## Notes [#notes] * Conditional rule helpers are shortcuts for common `When` patterns. * Conditions receive the full request structure. * String conditional helpers use blank-string behavior based on trimmed string values. * Use `RequiredIfStr` when a field becomes required in a specific state. * Use `ProhibitedIfStr` when a field must stay empty in a specific state. * Use `RequiredWithAnyStr` when one field depends on another field being filled. * Use `RequiredWithoutAnyStr` when one field is required as a fallback. * Use `When` directly for advanced rule composition or multiple nested rules. # Conditional When `conditional.When` is the low-level primitive for conditional validation. It allows one or more validation rules to run only when a custom predicate returns `true`. Use `When` when predefined conditional helpers are not expressive enough. ## TL;DR [#tldr] | Helper | Description | | ---------------------------------- | ------------------------------------------------- | | `conditional.When(cond, rules...)` | Runs nested rules only when `cond` returns `true` | ## Basic Usage [#basic-usage] ```go conditional.When( func(r *Request) bool { return r.AccountType == "business" }, rules.Required(Form.CompanyName), ) ``` If the condition is `false`, validation is skipped. If the condition is `true`, all nested rules are executed. ## Common Use Cases [#common-use-cases] * business account validation * password change flows * billing sections * role-based validation * feature-specific fields * multi-step forms * PATCH APIs * advanced business rules ## Complete Example [#complete-example] ### schema.go [#schemago] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/conditional" "github.com/netlifeguru/form/rules" ) const ( CodeCompanyRequired = form.Code("company_required") CodePasswordMinLen = form.Code("password_min_len") ) type ConditionalWhenRequest struct { AccountType string `json:"account_type"` CompanyName string `json:"company_name"` ChangePassword bool `json:"change_password"` NewPassword string `json:"new_password"` } func ConditionalWhenSchema() form.Schema[ConditionalWhenRequest] { ConditionalWhenForm := struct { AccountType form.StringField[ConditionalWhenRequest] CompanyName form.StringField[ConditionalWhenRequest] ChangePassword form.BoolField[ConditionalWhenRequest] NewPassword form.StringField[ConditionalWhenRequest] }{ AccountType: form.Str[ConditionalWhenRequest]("account_type", func(r *ConditionalWhenRequest) string { return r.AccountType }), CompanyName: form.Str[ConditionalWhenRequest]("company_name", func(r *ConditionalWhenRequest) string { return r.CompanyName }), ChangePassword: form.Bool[ConditionalWhenRequest]("change_password", func(r *ConditionalWhenRequest) bool { return r.ChangePassword }), NewPassword: form.Str[ConditionalWhenRequest]("new_password", func(r *ConditionalWhenRequest) string { return r.NewPassword }), } return form.Schema[ConditionalWhenRequest]{ conditional.When( func(r *ConditionalWhenRequest) bool { return r.AccountType == "business" }, rules.RequiredWithCode(ConditionalWhenForm.CompanyName, CodeCompanyRequired), ), conditional.When( func(r *ConditionalWhenRequest) bool { return r.ChangePassword }, rules.Required(ConditionalWhenForm.NewPassword), rules.MinLenWithCode(ConditionalWhenForm.NewPassword, 8, CodePasswordMinLen), ), } } ``` ### main.go [#maingo] ```go package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/form" "github.com/netlifeguru/form/httpform" "github.com/netlifeguru/router" ) func main() { r := router.New() r.HandleFunc("/conditional-when", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in ConditionalWhenRequest if !httpform.BindAndValidate(w, req, &in, ConditionalWhenSchema(), 1<<20) { fmt.Println("conditional when validation failed") return } fmt.Println("conditional when validation passed:", in) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "conditional when validation passed", "data": in, }) }) validPayload := map[string]any{ "account_type": "business", "company_name": "Acme s.r.o.", "change_password": true, "new_password": "secret123", } invalidPayload := map[string]any{ "account_type": "business", "company_name": "", "change_password": true, "new_password": "short", } skippedPayload := map[string]any{ "account_type": "personal", "company_name": "", "change_password": false, "new_password": "", } fmt.Println("\n--- Valid request ---") form.SendTestPost(":8080/conditional-when", validPayload) fmt.Println("\n--- Invalid request ---") form.SendTestPost(":8080/conditional-when", invalidPayload) fmt.Println("\n--- Skipped conditional validation request ---") form.SendTestPost(":8080/conditional-when", skippedPayload) if err := r.ListenAndServe(8080); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` ## Validation Flow [#validation-flow] ```text condition is false ↓ nested validation skipped ``` ```text condition is true ↓ nested rules executed ``` ## Notes [#notes] * `When` is the most flexible conditional validation primitive. * Conditions receive the full request structure. * A single `When` block can contain multiple nested rules. * Use `When` for custom business logic. * Use convenience helpers such as `RequiredIfStr` for common conditional cases. * Conditional validation remains explicit and transport-independent. # Conditional Validation Conditional validation allows rules to run only when a runtime condition is true. This is useful when validation depends on another field, request state, account type, feature flag, workflow step, or business rule. ## Why Conditional Validation Exists [#why-conditional-validation-exists] Many real-world forms and API payloads are not static. Some fields are required only when: * another field is filled * a user selects a specific option * an account type changes * a feature is enabled * a workflow enters a specific state * an optional section becomes active Conditional validation makes these cases explicit and reusable. ## Validation Philosophy [#validation-philosophy] Conditional validation separates: * the condition that decides whether validation should run * the validation rules that should run when the condition is true This keeps schemas readable and avoids hiding business logic inside struct tags. ## Available Pages [#available-pages] | Page | Description | | ----------------- | ---------------------------------------------------------------- | | Conditional Rules | Convenience helpers for common conditional validation patterns | | Conditional When | Low-level conditional validation using custom runtime predicates | ## Basic Idea [#basic-idea] A conditional rule checks a predicate first. ```go conditional.When( func(in *Request) bool { return in.AccountType == "business" }, rules.Required(Form.CompanyName), ) ``` If the condition returns `true`, the nested rules are executed. If the condition returns `false`, validation is skipped. ## Validation Flow [#validation-flow] ```text condition is false ↓ validation skipped ``` ```text condition is true ↓ nested rules executed ``` ## Common Use Cases [#common-use-cases] Conditional validation is commonly used for: * business account forms * company billing information * password change flows * dependent address fields * profile completion steps * onboarding workflows * feature-specific settings * role-based validation * multi-step forms * PATCH APIs ## When to Use Conditional Validation [#when-to-use-conditional-validation] Use conditional validation when a field should be validated only in a specific context. Examples: ```go conditional.RequiredIfStr( Form.CompanyName, func(in *Request) bool { return in.AccountType == "business" }, ) ``` ```go conditional.When( func(in *Request) bool { return in.ChangePassword }, rules.Required(Form.NewPassword), rules.MinLen(Form.NewPassword, 8), ) ``` ## Conditional Rules vs When [#conditional-rules-vs-when] Use convenience conditional rules when the validation pattern is common. Use `When` when the condition or nested validation logic is custom. | Approach | Best For | | ------------------------ | ------------------------------------------------- | | Conditional rule helpers | Common cases such as required-if or prohibited-if | | `conditional.When` | Custom predicates and complex rule composition | ## Notes [#notes] * Conditional validation is evaluated at runtime. * Conditions receive the full request structure. * Nested rules run only when the condition returns `true`. * Conditional validation keeps business validation explicit. * Conditional validation works with regular rules, optional rules, and custom rules. * Prefer `When` for complex logic and helper rules for simple common cases. # Installation Add the package to your project using `go get`: ```bash go get github.com/netlifeguru/form ``` Import the package into your application: ```go import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/rules" ) ``` Additional optional modules are available through subpackages: ```go import ( "github.com/netlifeguru/form/conditional" "github.com/netlifeguru/form/httpform" "github.com/netlifeguru/form/optional" ) ``` The package is designed to integrate naturally with Go structs, `net/http` handlers, JSON request processing, and reusable application validation workflows. Once installed, continue with the Quick Start guide to create your first validation schema and validate incoming request data. # Getting Started The example below demonstrates a complete validation workflow using: * HTTP request binding * Typed validation schemas * Reusable validation rules * JSON request validation * Structured request processing The incoming request is a standard HTTP `POST` request with a JSON payload: ```json { "name": "abcdefd", "age": 10 } ``` The validation flow consists of: 1. Defining a request structure 2. Creating reusable typed form fields 3. Building validation rules 4. Binding and validating the HTTP request 5. Returning structured JSON responses *** ## Create the Request Schema [#create-the-request-schema] The schema defines reusable typed fields and validation rules for the incoming request. Schemas are typically defined through functions instead of global variables to keep validation definitions immutable and isolated between application components. ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/rules" ) type PostRequest struct { Name string `json:"name"` Age int `json:"age"` } func PostSchema() form.Schema[PostRequest] { var PostForm = struct { Name form.StringField[PostRequest] Age form.IntField[PostRequest] }{ Name: form.Str[PostRequest]("name", func(r *PostRequest) string { return r.Name }), Age: form.Int[PostRequest]("age", func(r *PostRequest) int { return r.Age }), } var NameSchema = form.Schema[PostRequest]{ rules.Required(PostForm.Name), rules.MinLen(PostForm.Name, 5), } var AgeSchema = form.Schema[PostRequest]{ rules.RequiredInt(PostForm.Age), rules.Min(PostForm.Age, 8), } return form.Rules( NameSchema, AgeSchema, ) } ``` *** ## Create the HTTP Server [#create-the-http-server] The HTTP handler binds the incoming JSON request into the `PostRequest` structure and validates it using the schema. ```go package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/form" "github.com/netlifeguru/form/httpform" "github.com/netlifeguru/router" ) func main() { r := router.New() r.HandleFunc("/", "POST", func(w http.ResponseWriter, r *http.Request, ctx *router.Context) { var in PostRequest if !httpform.BindAndValidate(w, r, &in, PostSchema(), 1<<20) { fmt.Println("form validation failed") return } fmt.Println("request received:", in) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "request received", "data": in, }) }) form.SendTestPost(":8080/", map[string]any{ "name": "abcdefd", "age": 10, }) if err := r.ListenAndServe(8080); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` > **Note** > > The example uses the helper method: > > ```go > form.SendTestPost(":8080/", map[string]any{ > "name": "abcdefd", > "age": 10, > }) > ``` > > to automatically send a test HTTP `POST` request to the local server after startup. > > This helper exists only to make the example self-contained and immediately runnable without requiring external tools such as `curl` or Postman. *** ## Run the Application [#run-the-application] Start the application: ```bash go run . ``` The server starts on: ```text http://localhost:8080 ``` *** ## Example Response [#example-response] Successful validation returns a structured JSON response: ```json { "message": "request received", "data": { "name": "abcdefd", "age": 10 } } ``` If validation fails, the package automatically generates structured validation error responses through the `httpform` module. Continue with the next sections to learn about validation rules, optional fields, conditional validation, custom error messages, schema composition, and advanced HTTP request processing. # Validation Philosophy Unlike traditional Go validators that rely heavily on struct tags and runtime reflection, `form` uses explicit, type-safe validation schemas built with reusable fields and composable rules. The package is designed around: * explicit validation logic * reusable schema composition * type-safe field access * transport-independent validation * immutable schema definitions * reusable validation pipelines This approach intentionally trades a slightly more verbose setup for improved readability, composability, testability, and long-term maintainability. ## Why Not Struct Tags? [#why-not-struct-tags] Many Go validators use struct tags: ```go type User struct { Email string `validate:"required,email"` } ``` While compact, tag-based validation has limitations: * validation logic becomes hidden inside struct metadata * conditional validation becomes difficult * validation rules are harder to reuse * tags depend heavily on reflection * complex validation flows become difficult to compose * transport and validation logic become tightly coupled `form` instead treats validation as explicit application logic. ## Validation Flow [#validation-flow] Validation schemas are built in three steps: 1. Define typed validation fields 2. Define validation rules for each field 3. Combine rules into a reusable schema ## Request Structure [#request-structure] The request structure defines the incoming payload. ```go type PostRequest struct { Name string `json:"name"` Age int `json:"age"` } ``` This structure remains transport-friendly and independent from validation behavior. ## Typed Validation Fields [#typed-validation-fields] The first section defines reusable typed validation fields. ```go var PostForm = struct { Name form.StringField[PostRequest] Age form.IntField[PostRequest] }{ Name: form.Str[PostRequest]("name", func(r *PostRequest) string { return r.Name }), Age: form.Int[PostRequest]("age", func(r *PostRequest) int { return r.Age }), } ``` Each field contains: * field metadata * typed accessors * reusable references for validation rules * field names used in validation responses The accessor function: ```go func(r *PostRequest) string { return r.Name } ``` provides type-safe access to the underlying field without relying on reflection-only field lookups. This allows validation rules to work with strongly typed values while remaining reusable across schemas. ## Validation Rules [#validation-rules] Validation rules are grouped into reusable schema blocks. ```go var NameSchema = form.Schema[PostRequest]{ rules.Required(PostForm.Name), rules.MinLen(PostForm.Name, 5), } var AgeSchema = form.Schema[PostRequest]{ rules.RequiredInt(PostForm.Age), rules.Min(PostForm.Age, 8), } ``` Each rule operates on a typed field reference. This keeps validation logic: * explicit * composable * testable * reusable * independent from transport layers Rules can later be reused across: * HTTP handlers * CLI tools * background workers * internal services * shared validation packages ## Schema Composition [#schema-composition] Individual schema blocks are combined into a reusable validation schema. ```go return form.Rules( NameSchema, AgeSchema, ) ``` This produces the final schema used during validation. The resulting schema can then be reused anywhere in the application. ## Why Schemas Are Usually Functions [#why-schemas-are-usually-functions] Schemas are typically defined through functions: ```go func PostSchema() form.Schema[PostRequest] ``` instead of global mutable variables. This keeps validation definitions: * immutable * isolated * reusable * safe for application composition * predictable during testing This pattern also makes schema construction explicit and easier to evolve as applications grow. ## Design Goals [#design-goals] The package is designed around a few core principles: * explicit over implicit * reusable over duplicated * composable over monolithic * type-safe over reflection-heavy * transport-independent validation * predictable validation behavior While the setup may initially appear more verbose than tag-based validators, the resulting validation logic becomes significantly easier to maintain in larger applications and distributed systems. # Response Customization The `httpform` package provides configurable response options for APIs that require standardized validation responses across frontend applications, backend services, or distributed systems. Response customization is handled through: ```go httpform.ResponseOptions ``` This allows validation handlers to control: * validation messages * invalid JSON messages * response formatting * unique validation error codes ## ResponseOptions [#responseoptions] Example: ```go opts := httpform.ResponseOptions{ ErrorFormat: httpform.ErrorFormatMap, ValidationMessage: "please check your input", InvalidJSONMessage: "request body is not valid JSON", UniqueCodes: true, } ``` ## Custom Validation Messages [#custom-validation-messages] Custom validation messages override the default top-level validation response. Example: ```go ValidationMessage: "please check your input" ``` Response example: ```json { "message": "please check your input", "errors": { "email": [ { "code": "email_invalid", "message": "must be a valid email" } ] } } ``` This is useful for: * API consistency * frontend integrations * localization pipelines * standardized service responses ## Invalid JSON Messages [#invalid-json-messages] The `InvalidJSONMessage` option customizes malformed JSON responses. Example: ```go InvalidJSONMessage: "request body is not valid JSON" ``` This separates: * parsing errors * validation errors * successful requests while keeping response contracts consistent. ## Unique Error Codes [#unique-error-codes] Validation rules may generate duplicate error codes for a single field. Example: ```go UniqueCodes: true ``` When enabled, duplicate validation codes are automatically removed from the response. This is useful for: * frontend validation rendering * API response normalization * validation aggregation * simplified error handling ## Complete Example [#complete-example] ```go opts := httpform.ResponseOptions{ ErrorFormat: httpform.ErrorFormatMap, ValidationMessage: "please check your input", InvalidJSONMessage: "request body is not valid JSON", UniqueCodes: true, } if !httpform.BindAndValidateWithOptions( w, req, &in, ResponseExampleSchema(), 1<<20, opts, ) { return } ``` Response customization allows APIs to standardize validation behavior while keeping validation schemas reusable and transport-independent. ## Notes [#notes] * `ResponseOptions` only affects HTTP response formatting and does not modify schema validation behavior. * Validation schemas remain reusable independently of HTTP transport and response serialization. * `UniqueCodes` removes duplicated validation codes per field but preserves the original validation execution order. * `BindAndValidateWithOptions` is typically used in APIs that require consistent response contracts across multiple services or frontend applications. * Validation helpers automatically write the HTTP response and should generally return immediately after validation failure. * Response customization is transport-level behavior and should remain separate from business validation logic whenever possible. # HTTP Responses The `httpform` package provides helpers for validating HTTP JSON requests and returning consistent API responses when validation fails. It combines three steps into one flow: 1. Decode the incoming JSON request body 2. Bind the payload into a Go struct 3. Validate the struct using a `form.Schema` When validation fails, the helper writes the response automatically and returns `false`. ```go if !httpform.BindAndValidate(w, req, &in, ResponseExampleSchema(), 1<<20) { return } ``` When validation succeeds, it returns `true` and the handler can continue. The last argument defines the maximum allowed request body size. For example, `1<<20` limits the body to `1 MB`. ## Available Helpers [#available-helpers] | Helper | Description | | ---------------------------- | ---------------------------------------------------------------------------------------------------- | | `BindAndValidate` | Binds JSON, validates the input, and returns field-based validation errors | | `BindAndValidateFlat` | Binds JSON, validates the input, and returns validation errors as a flat list | | `BindAndValidateWithOptions` | Binds JSON, validates the input, and allows custom response format, messages, and unique error codes | ## Response Options [#response-options] Use `ResponseOptions` when you need custom API behavior. ```go opts := httpform.ResponseOptions{ ErrorFormat: httpform.ErrorFormatMap, ValidationMessage: "please check your input", InvalidJSONMessage: "request body is not valid JSON", UniqueCodes: true, } ``` Available options: | Option | Description | | -------------------- | --------------------------------------------------------------------- | | `ErrorFormat` | Controls whether validation errors are returned as a map or flat list | | `ValidationMessage` | Custom top-level message for validation failures | | `InvalidJSONMessage` | Custom top-level message for malformed JSON requests | | `UniqueCodes` | Removes duplicated validation error codes per field | ## Example Schema [#example-schema] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/rules" ) const ( CodeEmailRequired = form.Code("email_required") CodePasswordRequired = form.Code("password_required") CodePasswordMinLen = form.Code("password_min_len") ) type ResponseExampleRequest struct { Email string `json:"email"` Password string `json:"password"` } func ResponseExampleSchema() form.Schema[ResponseExampleRequest] { ResponseExampleForm := struct { Email form.StringField[ResponseExampleRequest] Password form.StringField[ResponseExampleRequest] }{ Email: form.Str[ResponseExampleRequest]("email", func(r *ResponseExampleRequest) string { return r.Email }), Password: form.Str[ResponseExampleRequest]("password", func(r *ResponseExampleRequest) string { return r.Password }), } EmailSchema := form.Schema[ResponseExampleRequest]{ rules.RequiredWithCode(ResponseExampleForm.Email, CodeEmailRequired), rules.Email(ResponseExampleForm.Email), } PasswordSchema := form.Schema[ResponseExampleRequest]{ rules.RequiredWithCode(ResponseExampleForm.Password, CodePasswordRequired), rules.MinLenWithCode(ResponseExampleForm.Password, 8, CodePasswordMinLen), } return form.Rules( EmailSchema, PasswordSchema, ) } ``` ## Map Response [#map-response] `BindAndValidate` returns validation errors grouped by field name. ```go r.HandleFunc("/response-map", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in ResponseExampleRequest if !httpform.BindAndValidate(w, req, &in, ResponseExampleSchema(), 1<<20) { return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "map response validation passed", "data": in, }) }) ``` This format is useful for frontend forms because each field can display its own validation errors. ## Flat Response [#flat-response] `BindAndValidateFlat` returns validation errors as a flat list. ```go r.HandleFunc("/response-flat", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in ResponseExampleRequest if !httpform.BindAndValidateFlat(w, req, &in, ResponseExampleSchema(), 1<<20) { return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "flat response validation passed", "data": in, }) }) ``` This format is useful for APIs that display or log validation errors as a single list. ## Custom Response [#custom-response] `BindAndValidateWithOptions` allows you to customize the response format and messages. ```go opts := httpform.ResponseOptions{ ErrorFormat: httpform.ErrorFormatMap, ValidationMessage: "please check your input", InvalidJSONMessage: "request body is not valid JSON", UniqueCodes: true, } if !httpform.BindAndValidateWithOptions(w, req, &in, ResponseExampleSchema(), 1<<20, opts) { return } ``` Use this helper when your API needs consistent error messages, custom response formats, or deduplicated error codes. ## Test Requests [#test-requests] The example can be tested with helper payloads: ```go validPayload := map[string]any{ "email": "john@example.com", "password": "secret123", } invalidPayload := map[string]any{ "email": "invalid-email", "password": "short", } emptyPayload := map[string]any{ "email": "", "password": "", } ``` Example test calls: ```go form.SendTestPost(":8080/response-map", validPayload) form.SendTestPost(":8080/response-map", invalidPayload) form.SendTestPost(":8080/response-flat", invalidPayload) form.SendTestPost(":8080/response-custom", emptyPayload) form.SendTestPost(":8080/response-custom-flat", emptyPayload) ``` > **Note** > > `form.SendTestPost` is a helper used only to make examples self-contained and easy to run locally. > In real applications, requests usually come from frontend clients, API consumers, tests, or tools such as `curl` and Postman. ## Complete Example [#complete-example] ```go package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/form" "github.com/netlifeguru/form/httpform" "github.com/netlifeguru/router" ) func main() { r := router.New() r.HandleFunc("/response-map", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in ResponseExampleRequest if !httpform.BindAndValidate(w, req, &in, ResponseExampleSchema(), 1<<20) { fmt.Println("map response validation failed") return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "map response validation passed", "data": in, }) }) r.HandleFunc("/response-flat", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in ResponseExampleRequest if !httpform.BindAndValidateFlat(w, req, &in, ResponseExampleSchema(), 1<<20) { fmt.Println("flat response validation failed") return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "flat response validation passed", "data": in, }) }) r.HandleFunc("/response-custom", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in ResponseExampleRequest opts := httpform.ResponseOptions{ ErrorFormat: httpform.ErrorFormatMap, ValidationMessage: "please check your input", InvalidJSONMessage: "request body is not valid JSON", UniqueCodes: true, } if !httpform.BindAndValidateWithOptions(w, req, &in, ResponseExampleSchema(), 1<<20, opts) { fmt.Println("custom response validation failed") return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "custom response validation passed", "data": in, }) }) r.HandleFunc("/response-custom-flat", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in ResponseExampleRequest opts := httpform.ResponseOptions{ ErrorFormat: httpform.ErrorFormatFlat, ValidationMessage: "please check your input", InvalidJSONMessage: "request body is not valid JSON", UniqueCodes: true, } if !httpform.BindAndValidateWithOptions(w, req, &in, ResponseExampleSchema(), 1<<20, opts) { fmt.Println("custom flat response validation failed") return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "custom flat response validation passed", "data": in, }) }) validPayload := map[string]any{ "email": "john@example.com", "password": "secret123", } invalidPayload := map[string]any{ "email": "invalid-email", "password": "short", } emptyPayload := map[string]any{ "email": "", "password": "", } form.SendTestPost(":8080/response-map", validPayload) form.SendTestPost(":8080/response-map", invalidPayload) form.SendTestPost(":8080/response-flat", invalidPayload) form.SendTestPost(":8080/response-custom", emptyPayload) form.SendTestPost(":8080/response-custom-flat", emptyPayload) if err := r.ListenAndServe(8080); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` ## Validation Helpers [#validation-helpers] The `httpform` package provides multiple validation helpers depending on the response format and customization level required by the application. ### BindAndValidate [#bindandvalidate] ```go httpform.BindAndValidate(w, r, &in, PostSchema(), 1<<20) ``` Binds the incoming JSON payload into a Go structure, validates it against the provided schema, and returns validation errors as a field-based map response. Typical response format: ```json { "message": "validation failed", "errors": { "email": [ { "code": "email_invalid", "message": "must be a valid email" } ] } } ``` This helper is typically used for: * frontend forms * field-level UI validation * structured API validation responses *** ### BindAndValidateFlat [#bindandvalidateflat] ```go httpform.BindAndValidateFlat(w, r, &in, PostSchema(), 1<<20) ``` Behaves similarly to `BindAndValidate`, but returns validation errors as a flat list instead of grouping them by field. Typical response format: ```json { "message": "validation failed", "errors": [ { "field": "email", "code": "email_invalid", "message": "must be a valid email" } ] } ``` This format is useful for: * logging systems * CLI tools * flat API contracts * validation aggregation pipelines *** ### BindAndValidateWithOptions [#bindandvalidatewithoptions] ```go httpform.BindAndValidateWithOptions( w, r, &in, PostSchema(), 1<<20, opts, ) ``` Provides full control over validation response formatting and behavior. This helper allows: * custom validation messages * custom invalid JSON messages * flat or map response formatting * unique validation error codes * response customization for API contracts Example: ```go opts := httpform.ResponseOptions{ ErrorFormat: httpform.ErrorFormatMap, ValidationMessage: "please check your input", InvalidJSONMessage: "request body is not valid JSON", UniqueCodes: true, } ``` Use this helper when the API requires standardized validation responses across services or frontend applications. # Examples Repository The official examples repository contains runnable demonstrations, validation workflows, HTTP integrations, and practical API validation patterns for the `form` package. Repository: [https://github.com/netlifeguru/examples/tree/main/form](https://github.com/netlifeguru/examples/tree/main/form) The examples are organized by topic and mirror the structure of the documentation. Each example is self-contained and designed to demonstrate a specific validation pattern or workflow. *** ## Conditional Validation [#conditional-validation] Examples demonstrating runtime conditional validation and conditional rule execution. Repository: [https://github.com/netlifeguru/examples/tree/main/form/conditional](https://github.com/netlifeguru/examples/tree/main/form/conditional) **Available Examples** * [Conditional rules](https://github.com/netlifeguru/examples/tree/main/form/conditional/conditional-rules) * [Conditional when](https://github.com/netlifeguru/examples/tree/main/form/conditional/conditional-when) * [Default conditional validation](https://github.com/netlifeguru/examples/tree/main/form/conditional/default) *** ## HTTP Validation [#http-validation] Examples demonstrating HTTP request binding and structured validation responses. Repository: [https://github.com/netlifeguru/examples/tree/main/form/httpform](https://github.com/netlifeguru/examples/tree/main/form/httpform) **Available Examples** * [Custom validation messages](https://github.com/netlifeguru/examples/tree/main/form/httpform/httpform-custom-message) * [Flat validation response](https://github.com/netlifeguru/examples/tree/main/form/httpform/httpform-flat-response) * [Invalid JSON handling](https://github.com/netlifeguru/examples/tree/main/form/httpform/httpform-invalid-json) * [Map validation response](https://github.com/netlifeguru/examples/tree/main/form/httpform/httpform-map-response) * [Default HTTP response](https://github.com/netlifeguru/examples/tree/main/form/httpform/httpform-response) * [Unique validation codes](https://github.com/netlifeguru/examples/tree/main/form/httpform/httpform-unique-codes) *** ## Optional Validation [#optional-validation] Examples demonstrating optional and nullable validation workflows. Repository: [https://github.com/netlifeguru/examples/tree/main/form/optional](https://github.com/netlifeguru/examples/tree/main/form/optional) **Available Examples** * [Optional string validation](https://github.com/netlifeguru/examples/tree/main/form/optional/optiona-string) * [Optional float64 validation](https://github.com/netlifeguru/examples/tree/main/form/optional/optional-float64) * [Optional integer validation](https://github.com/netlifeguru/examples/tree/main/form/optional/optional-int) * [Optional pointer validation](https://github.com/netlifeguru/examples/tree/main/form/optional/optional-ptr) * [Optional slice validation](https://github.com/netlifeguru/examples/tree/main/form/optional/optional-slice) * [Optional time validation](https://github.com/netlifeguru/examples/tree/main/form/optional/optional-time) *** ## Practical Examples [#practical-examples] Production-oriented validation workflows and reusable schema patterns. Repository: [https://github.com/netlifeguru/examples/tree/main/form/practical](https://github.com/netlifeguru/examples/tree/main/form/practical) **Available Examples** * [Change password](https://github.com/netlifeguru/examples/tree/main/form/practical/change-password) * [Company billing](https://github.com/netlifeguru/examples/tree/main/form/practical/company-billing) * [Invalid JSON](https://github.com/netlifeguru/examples/tree/main/form/practical/invalid-json) * [Profile update](https://github.com/netlifeguru/examples/tree/main/form/practical/profile-update) * [Registration](https://github.com/netlifeguru/examples/tree/main/form/practical/registration) * [Custom validation response](https://github.com/netlifeguru/examples/tree/main/form/practical/response-custom-message) * [Flat validation response](https://github.com/netlifeguru/examples/tree/main/form/practical/response-flat) * [Map validation response](https://github.com/netlifeguru/examples/tree/main/form/practical/response-map) * [Schema composition](https://github.com/netlifeguru/examples/tree/main/form/practical/schema-composition) * [Sign in](https://github.com/netlifeguru/examples/tree/main/form/practical/sign-in) * [Tags validation](https://github.com/netlifeguru/examples/tree/main/form/practical/tags) * [Unique validation codes](https://github.com/netlifeguru/examples/tree/main/form/practical/unique-codes) *** ## Validation Rules [#validation-rules] Examples demonstrating built-in validation rules. Repository: [https://github.com/netlifeguru/examples/tree/main/form/rules](https://github.com/netlifeguru/examples/tree/main/form/rules) **Available Examples** * [Compare validation](https://github.com/netlifeguru/examples/tree/main/form/rules/compare) * [Boolean rules](https://github.com/netlifeguru/examples/tree/main/form/rules/rules-bool) * [Float64 rules](https://github.com/netlifeguru/examples/tree/main/form/rules/rules-float64) * [Format rules](https://github.com/netlifeguru/examples/tree/main/form/rules/rules-format) * [Generic rules](https://github.com/netlifeguru/examples/tree/main/form/rules/rules-generic) * [Integer rules](https://github.com/netlifeguru/examples/tree/main/form/rules/rules-int) * [Required rules](https://github.com/netlifeguru/examples/tree/main/form/rules/rules-required) * [Slice rules](https://github.com/netlifeguru/examples/tree/main/form/rules/rules-slices) * [String rules](https://github.com/netlifeguru/examples/tree/main/form/rules/rules-string) * [Time rules](https://github.com/netlifeguru/examples/tree/main/form/rules/rules-time) *** ## Running Examples [#running-examples] Each example is self-contained and can be executed independently. Example: ```bash cd examples/form/rules/rules-string go run . ``` Most examples expose a small HTTP server and automatically send test requests during startup. *** ## Notes [#notes] * Examples are intentionally minimal and focused on a single topic. * Validation schemas are separated from transport logic. * Examples are suitable for experimentation and extension. * Most examples can be copied directly into production applications. * The repository structure mirrors the official documentation structure. * New examples may be added over time as additional validation helpers and workflows are introduced. # Project Information ## Documentation [#documentation] Full package documentation, guides, examples, and validation references are available at: [https://netlife.guru/docs/go/form](https://netlife.guru/docs/go/form) API reference: [https://pkg.go.dev/github.com/netlifeguru/form](https://pkg.go.dev/github.com/netlifeguru/form) GitHub repository: [https://github.com/netlifeguru/form](https://github.com/netlifeguru/form) *** ## Notes [#notes] * Review package-specific validation behavior before integrating schemas into public APIs. * Consider validation flow and response structure consistency across services. * Check performance characteristics when validating large payloads or deeply nested schemas. * See the package examples and documentation for recommended schema organization patterns. *** ## Versioning [#versioning] This project follows Semantic Versioning. Release history, breaking changes, and migration notes are documented in: [https://github.com/netlifeguru/form/blob/main/CHANGELOG.md](https://github.com/netlifeguru/form/blob/main/CHANGELOG.md) *** ## Contributing [#contributing] Community contributions, issue reports, feature suggestions, and pull requests are welcome. Before contributing, please review: [https://github.com/netlifeguru/form/blob/main/CONTRIBUTING.md](https://github.com/netlifeguru/form/blob/main/CONTRIBUTING.md) *** ## Code of Conduct [#code-of-conduct] This project follows a Code of Conduct for community participation and collaboration. Please review: [https://github.com/netlifeguru/form/blob/main/CODE\_OF\_CONDUCT.md](https://github.com/netlifeguru/form/blob/main/CODE_OF_CONDUCT.md) *** ## Author [#author] Created and maintained by NetLife Guru s.r.o. * Documentation: [https://netlife.guru/docs](https://netlife.guru/docs) * GitHub: [https://github.com/netlifeguru](https://github.com/netlifeguru) * Website: [https://netlife.guru](https://netlife.guru) * Contact: [info@netlife.guru](mailto:info@netlife.guru) *** ## License [#license] This project is licensed under the MIT License. See: [https://github.com/netlifeguru/form/blob/main/LICENSE](https://github.com/netlifeguru/form/blob/main/LICENSE) # Config The `db.Config` struct is shared by all NetLifeGuru database drivers. It describes how a driver should create and configure a database connection or connection pool. The same struct is used by: * `github.com/netlifeguru/db-mysql` * `github.com/netlifeguru/db-postgres` * `github.com/netlifeguru/db-scylla` Not every field is used by every driver. Some options are common, while others are driver-specific. ## Config Structure [#config-structure] ```go type Config struct { Identifier string Host string Port int Database string Username string Password string SSLMode string TimeZone string MaxConns int32 MinConns int32 MaxConnIdleTime time.Duration HealthCheckPeriod time.Duration MaxConnLifetime time.Duration ConnectTimeout time.Duration Consistency string } ``` ## Basic Example [#basic-example] ```go cfg := db.Config{ Identifier: "default", Host: "127.0.0.1", Port: 3306, Database: "app", Username: "root", Password: "secret", MaxConns: 50, MinConns: 5, MaxConnIdleTime: 10 * time.Minute, MaxConnLifetime: 2 * time.Hour, HealthCheckPeriod: 30 * time.Second, ConnectTimeout: 10 * time.Second, } ``` Pass the config to the selected driver: ```go conn := mysql.New() if err := conn.CreatePool(cfg); err != nil { return err } ``` After the pool is created, use `Fork` to get a `db.Conn` for application code. ```go return conn.Fork(), nil ``` ## Common Fields [#common-fields] These fields are shared across supported drivers. | Field | Purpose | | ------------------- | ------------------------------------------------------- | | `Identifier` | Logical name for the connection pool | | `Host` | Database host | | `Port` | Database port | | `Database` | Database name or Scylla keyspace | | `Username` | Database username | | `Password` | Database password | | `MaxConns` | Maximum number of open connections | | `MinConns` | Minimum number of idle or retained connections | | `MaxConnIdleTime` | Maximum time an idle connection may remain open | | `MaxConnLifetime` | Maximum lifetime of a connection | | `HealthCheckPeriod` | Driver health check interval where supported | | `ConnectTimeout` | Timeout used when creating or validating the connection | ## Identifier [#identifier] `Identifier` is the logical name of the connection pool. ```go Identifier: "default" ``` Use identifiers when your application manages multiple pools. Examples: ```go Identifier: "default" Identifier: "analytics" Identifier: "tenant-a" Identifier: "tenant-b" ``` The same identifier cannot be reused with a different configuration. If an existing pool already uses the same identifier but with different connection settings, the driver returns a pool identifier conflict error. ## Host and Port [#host-and-port] `Host` and `Port` define where the database server is located. ```go Host: "127.0.0.1", Port: 3306, ``` Default ports: | Driver | Default port | | -------- | ------------ | | MySQL | `3306` | | Postgres | `5432` | | Scylla | `9042` | If `Port` is not set, the driver uses its own default. ## Database [#database] `Database` identifies the selected database or keyspace. ```go Database: "app", ``` Meaning by driver: | Driver | Meaning | | -------- | ------------- | | MySQL | Database name | | Postgres | Database name | | Scylla | Keyspace | For Scylla, `Database` should contain the keyspace name. ```go Database: "app_keyspace", ``` ## Username and Password [#username-and-password] Use `Username` and `Password` for database authentication. ```go Username: "app_user", Password: "secret", ``` Drivers pass these values to their underlying database client. For local development, these values are usually loaded from environment variables. ```go Username: os.Getenv("DB_USER"), Password: os.Getenv("DB_PASSWORD"), ``` ## Pool Settings [#pool-settings] Pool settings control how many connections can be opened and how long they are reused. ```go MaxConns: 50, MinConns: 5, MaxConnIdleTime: 10 * time.Minute, MaxConnLifetime: 2 * time.Hour, HealthCheckPeriod: 30 * time.Second, ``` ### MaxConns [#maxconns] `MaxConns` defines the maximum number of open connections. ```go MaxConns: 50, ``` Use a value that matches your application workload and database capacity. ### MinConns [#minconns] `MinConns` defines the minimum number of retained or idle connections where supported by the driver. ```go MinConns: 5, ``` If `MinConns` is greater than `MaxConns`, drivers normalize it to avoid invalid pool configuration. ### MaxConnIdleTime [#maxconnidletime] `MaxConnIdleTime` defines how long an idle connection can remain open. ```go MaxConnIdleTime: 10 * time.Minute, ``` ### MaxConnLifetime [#maxconnlifetime] `MaxConnLifetime` defines the maximum lifetime of a connection. ```go MaxConnLifetime: 2 * time.Hour, ``` This is useful for rotating long-lived connections. ### HealthCheckPeriod [#healthcheckperiod] `HealthCheckPeriod` defines how often the driver should check connection health where supported. ```go HealthCheckPeriod: 30 * time.Second, ``` ### ConnectTimeout [#connecttimeout] `ConnectTimeout` defines how long the driver waits when opening or validating a connection. ```go ConnectTimeout: 10 * time.Second, ``` ## Driver-Specific Fields [#driver-specific-fields] Some fields are only used by specific drivers. | Field | MySQL | Postgres | Scylla | | ------------- | ----: | -------: | -----: | | `SSLMode` | yes | yes | no | | `TimeZone` | yes | yes | no | | `Consistency` | no | no | yes | ## SSLMode [#sslmode] `SSLMode` controls TLS or SSL behavior for SQL drivers. ```go SSLMode: "disable", ``` Common values: | Driver | Common values | | -------- | ------------------------------------------------------ | | MySQL | `false`, `true`, `skip-verify`, custom TLS config name | | Postgres | `disable`, `require`, `verify-ca`, `verify-full` | Examples: ```go SSLMode: "false", // MySQL local development ``` ```go SSLMode: "disable", // PostgreSQL local development ``` ```go SSLMode: "require", // PostgreSQL or CockroachDB-style secure connection ``` ## TimeZone [#timezone] `TimeZone` configures the connection timezone where supported. ```go TimeZone: "UTC", ``` Typical values: | Driver | Common value | | -------- | ------------ | | MySQL | `Local` | | Postgres | `UTC` | Examples: ```go TimeZone: "Local", ``` ```go TimeZone: "UTC", ``` ## Consistency [#consistency] `Consistency` is used by the Scylla driver. ```go Consistency: "local_quorum", ``` Common values: | Value | Meaning | | -------------- | --------------------------------------------- | | `one` | One replica must respond | | `quorum` | A quorum of replicas must respond | | `local_quorum` | A quorum in the local datacenter must respond | | `all` | All replicas must respond | Use the consistency level that matches your Scylla data model and availability requirements. ## MySQL Config Example [#mysql-config-example] ```go cfg := db.Config{ Identifier: "default", Host: os.Getenv("DB_HOST"), Port: 3306, Database: os.Getenv("DB_NAME"), Username: os.Getenv("DB_USER"), Password: os.Getenv("DB_PASSWORD"), MaxConns: 50, MinConns: 5, MaxConnIdleTime: 10 * time.Minute, MaxConnLifetime: 2 * time.Hour, HealthCheckPeriod: 30 * time.Second, ConnectTimeout: 10 * time.Second, SSLMode: "false", TimeZone: "Local", } ``` ## PostgreSQL Config Example [#postgresql-config-example] ```go cfg := db.Config{ Identifier: "default", Host: os.Getenv("DB_HOST"), Port: 5432, Database: os.Getenv("DB_NAME"), Username: os.Getenv("DB_USER"), Password: os.Getenv("DB_PASSWORD"), MaxConns: 50, MinConns: 5, MaxConnIdleTime: 10 * time.Minute, MaxConnLifetime: 2 * time.Hour, HealthCheckPeriod: 30 * time.Second, ConnectTimeout: 10 * time.Second, SSLMode: "disable", TimeZone: "UTC", } ``` ## Scylla Config Example [#scylla-config-example] ```go cfg := db.Config{ Identifier: "default", Host: os.Getenv("DB_HOST"), Port: 9042, Database: os.Getenv("DB_NAME"), // Scylla keyspace Username: os.Getenv("DB_USER"), Password: os.Getenv("DB_PASSWORD"), MaxConns: 50, MinConns: 5, MaxConnIdleTime: 10 * time.Minute, MaxConnLifetime: 2 * time.Hour, HealthCheckPeriod: 30 * time.Second, ConnectTimeout: 10 * time.Second, Consistency: "local_quorum", } ``` ## Defaults [#defaults] Each driver applies defaults when optional fields are not provided. | Field | MySQL | PostgreSQL | Scylla | | ------------------- | ----------: | ----------: | ----------: | | `Identifier` | `default` | `default` | `default` | | `Host` | `127.0.0.1` | `127.0.0.1` | `127.0.0.1` | | `Port` | `3306` | `5432` | `9042` | | `MaxConns` | `25` | `25` | `25` | | `MinConns` | `2` | `2` | `2` | | `MaxConnIdleTime` | `5m` | `5m` | `5m` | | `MaxConnLifetime` | `1h` | `1h` | `1h` | | `ConnectTimeout` | `5s` | `5s` | `5s` | | `HealthCheckPeriod` | `30s` | `30s` | `30s` | | `SSLMode` | `false` | `disable` | - | | `TimeZone` | `Local` | `UTC` | - | | `Consistency` | - | - | `quorum` | ## Recommended Usage [#recommended-usage] Load connection settings from environment variables. ```go cfg := db.Config{ Identifier: "default", Host: os.Getenv("DB_HOST"), Database: os.Getenv("DB_NAME"), Username: os.Getenv("DB_USER"), Password: os.Getenv("DB_PASSWORD"), } ``` Then add driver-specific options only when needed. For MySQL: ```go cfg.SSLMode = "false" cfg.TimeZone = "Local" ``` For PostgreSQL: ```go cfg.SSLMode = "disable" cfg.TimeZone = "UTC" ``` For Scylla: ```go cfg.Consistency = "local_quorum" ``` Keep the same `db.Config` shape across the application, but let each driver interpret the fields it supports. # Pools Each NetLifeGuru database driver creates and manages its own connection pool. The shared `db` package defines the common connection interface, while the concrete driver package creates the real pool for MySQL, PostgreSQL, or Scylla. A typical flow is: 1. create a driver connection 2. call `CreatePool` 3. return a forked `db.Conn` 4. use the shared `db` helpers in application code 5. close the original driver connection when the application shuts down ## Basic Flow [#basic-flow] ```go conn := mysql.New() err := conn.CreatePool(db.Config{ Identifier: "default", Host: "127.0.0.1", Port: 3306, Database: "app", Username: "root", Password: "secret", }) if err != nil { return nil, err } return conn.Fork(), nil ``` The returned value implements `db.Conn`. ```go func connectDB() (db.Conn, error) { // create pool // return conn.Fork() } ``` ## CreatePool [#createpool] `CreatePool` creates or reuses a connection pool for the selected driver. ```go if err := conn.CreatePool(cfg); err != nil { return nil, err } ``` The config contains connection settings such as: * identifier * host * port * database or keyspace * username * password * pool limits * timeouts * driver-specific options Each driver validates and normalizes the config before opening the connection. ## Identifier [#identifier] Every pool has an identifier. ```go Identifier: "default" ``` The identifier is a logical name for the pool inside the driver. Common examples: ```go Identifier: "default" Identifier: "analytics" Identifier: "tenant-a" Identifier: "tenant-b" ``` Use identifiers when your application needs multiple pools. ## Identifier Conflicts [#identifier-conflicts] The same identifier cannot be reused with different connection settings. For example, this is valid: ```go cfg := db.Config{ Identifier: "default", Host: "127.0.0.1", Database: "app", } ``` Calling `CreatePool` again with the same identifier and the same effective configuration reuses the pool. But this is a conflict: ```go cfg := db.Config{ Identifier: "default", Host: "127.0.0.1", Database: "analytics", } ``` If the identifier already exists with different configuration, the driver returns an error. This protects applications from accidentally reusing the same logical pool name for different databases. ## Shared Pool Reuse [#shared-pool-reuse] Drivers can reuse an existing physical pool when the effective connection configuration is the same. For example, two connection objects with the same host, port, credentials, database, and relevant driver settings can share the same underlying pool. This is useful when different parts of an application create equivalent connections. The driver tracks references internally and only closes the underlying pool when the last reference is closed. ## Fork [#fork] `Fork` returns a connection handle that implements `db.Conn`. ```go appConn := conn.Fork() ``` Use the forked connection in application code: ```go users, err := db.List[User](ctx, appConn, ` SELECT * FROM users `) ``` A forked connection is useful because application code only needs the shared `db.Conn` interface. It does not need to know whether the connection came from MySQL, PostgreSQL, or Scylla. ## Why Return Fork [#why-return-fork] Connection setup usually happens in infrastructure code. ```go func connectDB() (db.Conn, error) { conn := mysql.New() if err := conn.CreatePool(cfg); err != nil { return nil, err } return conn.Fork(), nil } ``` Application and repository code can then depend only on `db.Conn`. ```go func ListUsers(ctx context.Context, conn db.Conn) ([]User, error) { return db.List[User](ctx, conn, ` SELECT * FROM users `) } ``` This keeps query code independent from the selected driver. ## Close [#close] Close the connection when the application shuts down. ```go conn.Close() ``` For MySQL and PostgreSQL, `Close` closes the underlying SQL or pool connection when the last reference is released. For Scylla, `Close` closes the underlying Scylla session when the last reference is released. A common application pattern is: ```go conn := mysql.New() if err := conn.CreatePool(cfg); err != nil { return err } defer conn.Close() ``` If your setup function returns only `conn.Fork()`, keep a reference to the original driver connection when you also need to close it explicitly at shutdown. ## Multiple Pools [#multiple-pools] You can create multiple pools by using different identifiers. ```go primary := mysql.New() err := primary.CreatePool(db.Config{ Identifier: "primary", Host: "127.0.0.1", Database: "app", }) if err != nil { return err } analytics := mysql.New() err = analytics.CreatePool(db.Config{ Identifier: "analytics", Host: "127.0.0.1", Database: "analytics", }) if err != nil { return err } ``` Each pool can then be used through `Fork`. ```go primaryConn := primary.Fork() analyticsConn := analytics.Fork() ``` ## Multi-Tenant Pools [#multi-tenant-pools] Identifiers are useful for tenant-based applications. ```go func connectTenant(identifier string, database string) (db.Conn, error) { conn := mysql.New() err := conn.CreatePool(db.Config{ Identifier: identifier, Host: "127.0.0.1", Database: database, Username: "app", Password: "secret", }) if err != nil { return nil, err } return conn.Fork(), nil } ``` ## Pool Identifier [#pool-identifier] Every pool has an identifier. The identifier is not a database name and it is not necessarily the database host. It is an application-level name used to register, reuse, and protect connection pools. ```go Identifier: "default" ``` Use different identifiers when your application has multiple logical connections: ```go Identifier: "primary" Identifier: "analytics" Identifier: "tenant-a" ``` For multi-tenant applications, the identifier can be based on your tenant routing model. Examples: ```go Identifier: "example.com" Identifier: "customer-a" Identifier: "tenant-42" ``` A domain name can be a good identifier when each tenant is selected by request host. A tenant slug or customer ID is usually better when tenants are selected from application data, authentication claims, or internal routing. Avoid using only the database host as the identifier unless the host is truly the unique logical connection name in your application. The same identifier can be reused only with the same effective connection configuration. If the same identifier is used with different host, database, credentials, or driver settings, the driver returns a pool identifier conflict error. Example identifiers: ```go tenant-a tenant-b tenant-c ``` Each tenant can point to a separate database, keyspace, or cluster depending on the driver and architecture. ## Same API Across Drivers [#same-api-across-drivers] Pool creation is driver-specific, but the returned application connection uses the shared API. ```go conn, err := connectDB() if err != nil { return err } users, err := db.List[User](ctx, conn, ` SELECT * FROM users `) ``` The query helpers do not need to know which driver created the pool. ## Driver Differences [#driver-differences] The pool implementation depends on the selected driver. | Driver | Pool implementation | | -------- | ------------------- | | MySQL | `database/sql` pool | | Postgres | `pgxpool` | | Scylla | `gocql.Session` | The shared `db.Conn` interface hides these details from application code. However, connection behavior, transaction support, insert behavior, and SQL/CQL syntax still depend on the driver. ## Recommended Pattern [#recommended-pattern] Use a small connection function per driver. ```go func connectDB() (db.Conn, func() error, error) { conn := mysql.New() if err := conn.CreatePool(cfg); err != nil { return nil, nil, err } cleanup := func() error { conn.Close() return nil } return conn.Fork(), cleanup, nil } ``` Then use it from `main`. ```go conn, cleanup, err := connectDB() if err != nil { log.Fatal(err) } defer cleanup() ``` This keeps both values available: * `db.Conn` for application code * cleanup function for shutdown ## Notes [#notes] Use `CreatePool` once during application startup when possible. Use `Fork` to pass a shared `db.Conn` into repositories, services, handlers, jobs, or other application layers. Use clear identifiers when managing more than one connection pool. Avoid reusing the same identifier for different databases or tenants. Close the driver connection during application shutdown. # Optional Validation Optional validation allows rules to run only when a value is present. This makes it possible to validate optional input fields without forcing users to provide them. Unlike required validation, optional validation skips nested rules entirely when the field is considered empty. ## Why Optional Validation Exists [#why-optional-validation-exists] Many APIs contain fields that are not mandatory but still require validation when provided. Typical examples include: * optional profile fields * avatar URLs * nicknames * social handles * optional configuration * nullable database values * partial update endpoints * PATCH APIs * onboarding flows * admin forms Optional validation solves this by separating: * presence validation * value validation This keeps schemas explicit and predictable. ## TL;DR [#tldr] | Helper | Description | | ------------------------------- | ---------------------------------------------------- | | `optional.OptionalString(...)` | Runs nested rules only when the string is non-empty | | `optional.OptionalPtr(...)` | Runs nested rules only when the pointer is non-nil | | `optional.OptionalSlice(...)` | Runs nested rules only when the slice contains items | | `optional.OptionalInt(...)` | Optional validation for nullable integer pointers | | `optional.OptionalFloat64(...)` | Optional validation for nullable float pointers | | `optional.OptionalTime(...)` | Optional validation for nullable time pointers | ## Optional vs Required [#optional-vs-required] Required validation: ```go rules.Required(UserForm.Email) ``` The field must exist and must not be empty. *** Optional validation: ```go optional.OptionalString( UserForm.Email, rules.Email(UserForm.Email), ) ``` The field may be empty. But if a value is provided, it must pass validation. ## Optional String Validation [#optional-string-validation] `OptionalString` skips validation when the value is blank. Blank means: * empty string * whitespace-only string Example: ```go optional.OptionalString( UserForm.Nickname, rules.MinLen(UserForm.Nickname, 3), ) ``` Validation behavior: | Input | Result | | -------- | ------- | | `""` | skipped | | `" "` | skipped | | `"ab"` | invalid | | `"john"` | valid | Internally, the helper checks: ```go strings.TrimSpace(value) == "" ``` before applying nested rules. :contentReference\[oaicite:0]{index=0} ## Optional Pointer Validation [#optional-pointer-validation] Pointer helpers are useful for nullable JSON fields and PATCH APIs. Example: ```go type UpdateProfileRequest struct { Age *int `json:"age"` } ``` Field definition: ```go Age: form.OptInt[UpdateProfileRequest]("age", func(r *UpdateProfileRequest) *int { return r.Age }), ``` Validation: ```go optional.OptionalInt( UpdateForm.Age, optional.MinOpt(UpdateForm.Age, 18), ) ``` Validation behavior: | Input | Result | | ------ | ------- | | `null` | skipped | | `10` | invalid | | `25` | valid | Optional pointer validation only runs when the pointer is non-nil. :contentReference\[oaicite:1]{index=1} ## Optional Slice Validation [#optional-slice-validation] `OptionalSlice` skips validation when the slice is empty. Example: ```go optional.OptionalSlice( UserForm.Tags.Field, rules.MinItemsStr(UserForm.Tags, 2), ) ``` Validation behavior: | Input | Result | | --------------- | ------- | | `[]` | skipped | | `["go"]` | invalid | | `["go", "api"]` | valid | This is useful for: * optional tags * optional permissions * optional feature lists * optional metadata arrays ## Optional Float Validation [#optional-float-validation] Optional float validation works with nullable float pointers. Example: ```go optional.OptionalFloat64( ProductForm.Price, optional.MinFloat64Opt(ProductForm.Price, 0.01), ) ``` Validation only runs when the pointer contains a value. :contentReference\[oaicite:2]{index=2} ## Optional Time Validation [#optional-time-validation] Optional time validation is useful for nullable scheduling fields. Example: ```go optional.OptionalTime( EventForm.StartAt, optional.AfterOpt(EventForm.StartAt, time.Now()), ) ``` Validation runs only when the timestamp exists. :contentReference\[oaicite:3]{index=3} ## Complete Example [#complete-example] ### schema.go [#schemago] ```go package main import ( "regexp" "github.com/netlifeguru/form" "github.com/netlifeguru/form/optional" "github.com/netlifeguru/form/rules" ) const ( CodeNicknameMinLen = form.Code("nickname_min_len") CodeBioMaxLen = form.Code("bio_max_len") CodeSlugRegex = form.Code("slug_regex") CodeDescriptionMatch = form.Code("description_match") ) type OptionalStringRequest struct { Nickname string `json:"nickname"` Bio string `json:"bio"` Email string `json:"email"` Slug string `json:"slug"` Description string `json:"description"` } func OptionalStringSchema() form.Schema[OptionalStringRequest] { OptionalForm := struct { Nickname form.StringField[OptionalStringRequest] Bio form.StringField[OptionalStringRequest] Email form.StringField[OptionalStringRequest] Slug form.StringField[OptionalStringRequest] Description form.StringField[OptionalStringRequest] }{ Nickname: form.Str[OptionalStringRequest]("nickname", func(r *OptionalStringRequest) string { return r.Nickname }), Bio: form.Str[OptionalStringRequest]("bio", func(r *OptionalStringRequest) string { return r.Bio }), Email: form.Str[OptionalStringRequest]("email", func(r *OptionalStringRequest) string { return r.Email }), Slug: form.Str[OptionalStringRequest]("slug", func(r *OptionalStringRequest) string { return r.Slug }), Description: form.Str[OptionalStringRequest]("description", func(r *OptionalStringRequest) string { return r.Description }), } slugRegex := regexp.MustCompile(`^[a-z0-9-]+$`) return form.Schema[OptionalStringRequest]{ optional.OptionalString( OptionalForm.Nickname, rules.MinLenWithCode(OptionalForm.Nickname, 3, CodeNicknameMinLen), ), optional.OptionalString( OptionalForm.Bio, rules.MaxLenWithCode(OptionalForm.Bio, 20, CodeBioMaxLen), ), optional.OptionalString( OptionalForm.Email, rules.Email(OptionalForm.Email), ), optional.OptionalString( OptionalForm.Slug, rules.RegexWithCode(OptionalForm.Slug, slugRegex, CodeSlugRegex), ), optional.OptionalString( OptionalForm.Description, rules.ContainsWithCode(OptionalForm.Description, "form", CodeDescriptionMatch), ), } } ``` ### main.go [#maingo] ```go package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/form" "github.com/netlifeguru/form/httpform" "github.com/netlifeguru/router" ) func main() { r := router.New() r.HandleFunc("/optional-string", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in OptionalStringRequest if !httpform.BindAndValidate(w, req, &in, OptionalStringSchema(), 1<<20) { fmt.Println("optional validation failed") return } fmt.Println("optional validation passed:", in) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "optional validation passed", "data": in, }) }) validPayload := map[string]any{ "nickname": "john", "bio": "short bio", "email": "john@example.com", "slug": "hello-world", "description": "form package demo", } invalidPayload := map[string]any{ "nickname": "ab", "bio": "this bio is definitely too long", "email": "invalid-email", "slug": "Hello World!", "description": "validation package", } skippedPayload := map[string]any{ "nickname": "", "bio": "", "email": "", "slug": "", "description": "", } fmt.Println("\n--- Valid request ---") form.SendTestPost(":8080/optional-string", validPayload) fmt.Println("\n--- Invalid request ---") form.SendTestPost(":8080/optional-string", invalidPayload) fmt.Println("\n--- Skipped validation request ---") form.SendTestPost(":8080/optional-string", skippedPayload) if err := r.ListenAndServe(8080); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` ## Validation Flow [#validation-flow] Optional validation behaves like this: ```text empty value ↓ validation skipped ``` ```text non-empty value ↓ nested rules executed ``` This allows APIs to support partial updates without forcing validation on omitted fields. ## Notes [#notes] * Optional validation skips nested rules when the value is empty. * `OptionalString` treats whitespace-only values as empty. * Pointer-based optional validation is useful for PATCH APIs and nullable database fields. * Optional validation can be combined with all regular validation rules. * Optional helpers are composable and reusable. * Optional validation is independent from HTTP transport and JSON decoding. * Optional validation is especially useful for profile updates and partial resource modification workflows. # Optional Float64 Validation `OptionalFloat64` allows decimal validation rules to run only when a nullable float value is present. This is useful for optional prices, discounts, measurements, scores, coordinates, configuration values, and partial update APIs. ## TL;DR [#tldr] | Helper | Description | | ----------------------------------------------------------- | -------------------------------------------------------------- | | `optional.OptionalFloat64(field, rules...)` | Runs nested rules only when the float64 pointer is non-nil | | `optional.MinFloat64Opt(field, n)` | Requires the optional value to be greater than or equal to `n` | | `optional.MinFloat64OptWithCode(field, n, code)` | Same as `MinFloat64Opt` with a custom error code | | `optional.MaxFloat64Opt(field, n)` | Requires the optional value to be less than or equal to `n` | | `optional.MaxFloat64OptWithCode(field, n, code)` | Same as `MaxFloat64Opt` with a custom error code | | `optional.BetweenFloat64Opt(field, min, max)` | Requires the optional value to be within a range | | `optional.BetweenFloat64OptWithCode(field, min, max, code)` | Same as `BetweenFloat64Opt` with a custom error code | ## Defining Optional Float64 Fields [#defining-optional-float64-fields] Optional float64 fields use pointer values. ```go type OptionalFloat64Request struct { Price *float64 `json:"price"` Discount *float64 `json:"discount"` Rating *float64 `json:"rating"` } ``` Field definitions use `form.OptFloat64`. ```go OptionalFloat64Form := struct { Price form.OptFloat64Field[OptionalFloat64Request] Discount form.OptFloat64Field[OptionalFloat64Request] Rating form.OptFloat64Field[OptionalFloat64Request] }{ Price: form.OptFloat64[OptionalFloat64Request]("price", func(r *OptionalFloat64Request) *float64 { return r.Price }), Discount: form.OptFloat64[OptionalFloat64Request]("discount", func(r *OptionalFloat64Request) *float64 { return r.Discount }), Rating: form.OptFloat64[OptionalFloat64Request]("rating", func(r *OptionalFloat64Request) *float64 { return r.Rating }), } ``` ## Applying Optional Float64 Rules [#applying-optional-float64-rules] ```go return form.Schema[OptionalFloat64Request]{ optional.OptionalFloat64( OptionalFloat64Form.Price, optional.MinFloat64Opt(OptionalFloat64Form.Price, 0.01), ), optional.OptionalFloat64( OptionalFloat64Form.Discount, optional.MaxFloat64Opt(OptionalFloat64Form.Discount, 100), ), optional.OptionalFloat64( OptionalFloat64Form.Rating, optional.BetweenFloat64Opt(OptionalFloat64Form.Rating, 1, 5), ), } ``` Validation behavior: | Input | Result | | ------- | ------------------------------------ | | `null` | skipped | | `0.00` | validated | | `0.01` | valid for `MinFloat64Opt(..., 0.01)` | | `-1.00` | invalid | ## Complete Example [#complete-example] ### schema.go [#schemago] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/optional" ) const ( CodePriceMin = form.Code("price_min") CodeDiscountMax = form.Code("discount_max") CodeRatingBetween = form.Code("rating_between") ) type OptionalFloat64Request struct { Price *float64 `json:"price"` Discount *float64 `json:"discount"` Rating *float64 `json:"rating"` } func OptionalFloat64Schema() form.Schema[OptionalFloat64Request] { OptionalFloat64Form := struct { Price form.OptFloat64Field[OptionalFloat64Request] Discount form.OptFloat64Field[OptionalFloat64Request] Rating form.OptFloat64Field[OptionalFloat64Request] }{ Price: form.OptFloat64[OptionalFloat64Request]("price", func(r *OptionalFloat64Request) *float64 { return r.Price }), Discount: form.OptFloat64[OptionalFloat64Request]("discount", func(r *OptionalFloat64Request) *float64 { return r.Discount }), Rating: form.OptFloat64[OptionalFloat64Request]("rating", func(r *OptionalFloat64Request) *float64 { return r.Rating }), } return form.Schema[OptionalFloat64Request]{ optional.OptionalFloat64( OptionalFloat64Form.Price, optional.MinFloat64OptWithCode(OptionalFloat64Form.Price, 0.01, CodePriceMin), ), optional.OptionalFloat64( OptionalFloat64Form.Discount, optional.MaxFloat64OptWithCode(OptionalFloat64Form.Discount, 100, CodeDiscountMax), ), optional.OptionalFloat64( OptionalFloat64Form.Rating, optional.BetweenFloat64OptWithCode(OptionalFloat64Form.Rating, 1, 5, CodeRatingBetween), ), } } ``` ### main.go [#maingo] ```go package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/form" "github.com/netlifeguru/form/httpform" "github.com/netlifeguru/router" ) func main() { r := router.New() r.HandleFunc("/optional-float64", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in OptionalFloat64Request if !httpform.BindAndValidate(w, req, &in, OptionalFloat64Schema(), 1<<20) { fmt.Println("optional float64 validation failed") return } fmt.Println("optional float64 validation passed:", in) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "optional float64 validation passed", "data": in, }) }) validPayload := map[string]any{ "price": 10.99, "discount": 25.5, "rating": 4.5, } invalidPayload := map[string]any{ "price": 0.00, "discount": 101.0, "rating": 6.0, } skippedPayload := map[string]any{ "price": nil, "discount": nil, "rating": nil, } fmt.Println("\n--- Valid request ---") form.SendTestPost(":8080/optional-float64", validPayload) fmt.Println("\n--- Invalid request ---") form.SendTestPost(":8080/optional-float64", invalidPayload) fmt.Println("\n--- Skipped validation request ---") form.SendTestPost(":8080/optional-float64", skippedPayload) if err := r.ListenAndServe(8080); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` ## Notes [#notes] * Optional float64 validation runs only when the pointer is non-nil. * `nil` values are skipped and are not considered validation failures. * Use optional float64 fields for PATCH APIs, nullable database values, and optional decimal input. * Use `MinFloat64Opt`, `MaxFloat64Opt`, and `BetweenFloat64Opt` for decimal bounds. * Custom error codes are recommended for public API responses. * Optional float64 validation remains explicit and transport-independent. # Optional Integer Validation `OptionalInt` allows integer validation rules to run only when a nullable integer value is present. This is useful for optional counters, quantities, pagination values, priorities, limits, scoring systems, configuration values, and partial update APIs. ## TL;DR [#tldr] | Helper | Description | | ---------------------------------------------------- | -------------------------------------------------------------- | | `optional.OptionalInt(field, rules...)` | Runs nested rules only when the integer pointer is non-nil | | `optional.MinOpt(field, n)` | Requires the optional value to be greater than or equal to `n` | | `optional.MinOptWithCode(field, n, code)` | Same as `MinOpt` with a custom error code | | `optional.MaxOpt(field, n)` | Requires the optional value to be less than or equal to `n` | | `optional.MaxOptWithCode(field, n, code)` | Same as `MaxOpt` with a custom error code | | `optional.BetweenOpt(field, min, max)` | Requires the optional value to be within a range | | `optional.BetweenOptWithCode(field, min, max, code)` | Same as `BetweenOpt` with a custom error code | | `optional.PositiveOpt(field)` | Requires the optional value to be positive | | `optional.PositiveOptWithCode(field, code)` | Same as `PositiveOpt` with a custom error code | | `optional.NegativeOpt(field)` | Requires the optional value to be negative | | `optional.NegativeOptWithCode(field, code)` | Same as `NegativeOpt` with a custom error code | ## Defining Optional Integer Fields [#defining-optional-integer-fields] Optional integer fields use pointer values. ```go type OptionalIntRequest struct { Age *int `json:"age"` Quantity *int `json:"quantity"` Priority *int `json:"priority"` } ``` Field definitions use `form.OptInt`. ```go OptionalIntForm := struct { Age form.OptIntField[OptionalIntRequest] Quantity form.OptIntField[OptionalIntRequest] Priority form.OptIntField[OptionalIntRequest] }{ Age: form.OptInt[OptionalIntRequest]("age", func(r *OptionalIntRequest) *int { return r.Age }), Quantity: form.OptInt[OptionalIntRequest]("quantity", func(r *OptionalIntRequest) *int { return r.Quantity }), Priority: form.OptInt[OptionalIntRequest]("priority", func(r *OptionalIntRequest) *int { return r.Priority }), } ``` ## Applying Optional Integer Rules [#applying-optional-integer-rules] ```go return form.Schema[OptionalIntRequest]{ optional.OptionalInt( OptionalIntForm.Age, optional.MinOpt(OptionalIntForm.Age, 18), ), optional.OptionalInt( OptionalIntForm.Quantity, optional.PositiveOpt(OptionalIntForm.Quantity), ), optional.OptionalInt( OptionalIntForm.Priority, optional.BetweenOpt(OptionalIntForm.Priority, 1, 10), ), } ``` Validation behavior: | Input | Result | | ------ | ------------------------- | | `null` | skipped | | `0` | validated | | `5` | valid for `PositiveOpt` | | `-1` | invalid for `PositiveOpt` | ## Complete Example [#complete-example] ### schema.go [#schemago] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/optional" ) const ( CodeAgeMin = form.Code("age_min") CodeQuantityPositive = form.Code("quantity_positive") CodePriorityRange = form.Code("priority_range") ) type OptionalIntRequest struct { Age *int `json:"age"` Quantity *int `json:"quantity"` Priority *int `json:"priority"` } func OptionalIntSchema() form.Schema[OptionalIntRequest] { OptionalIntForm := struct { Age form.OptIntField[OptionalIntRequest] Quantity form.OptIntField[OptionalIntRequest] Priority form.OptIntField[OptionalIntRequest] }{ Age: form.OptInt[OptionalIntRequest]("age", func(r *OptionalIntRequest) *int { return r.Age }), Quantity: form.OptInt[OptionalIntRequest]("quantity", func(r *OptionalIntRequest) *int { return r.Quantity }), Priority: form.OptInt[OptionalIntRequest]("priority", func(r *OptionalIntRequest) *int { return r.Priority }), } return form.Schema[OptionalIntRequest]{ optional.OptionalInt( OptionalIntForm.Age, optional.MinOptWithCode(OptionalIntForm.Age, 18, CodeAgeMin), ), optional.OptionalInt( OptionalIntForm.Quantity, optional.PositiveOptWithCode(OptionalIntForm.Quantity, CodeQuantityPositive), ), optional.OptionalInt( OptionalIntForm.Priority, optional.BetweenOptWithCode(OptionalIntForm.Priority, 1, 10, CodePriorityRange), ), } } ``` ### main.go [#maingo] ```go package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/form" "github.com/netlifeguru/form/httpform" "github.com/netlifeguru/router" ) func main() { r := router.New() r.HandleFunc("/optional-int", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in OptionalIntRequest if !httpform.BindAndValidate(w, req, &in, OptionalIntSchema(), 1<<20) { fmt.Println("optional integer validation failed") return } fmt.Println("optional integer validation passed:", in) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "optional integer validation passed", "data": in, }) }) validPayload := map[string]any{ "age": 25, "quantity": 10, "priority": 5, } invalidPayload := map[string]any{ "age": 10, "quantity": -1, "priority": 50, } skippedPayload := map[string]any{ "age": nil, "quantity": nil, "priority": nil, } fmt.Println("\n--- Valid request ---") form.SendTestPost(":8080/optional-int", validPayload) fmt.Println("\n--- Invalid request ---") form.SendTestPost(":8080/optional-int", invalidPayload) fmt.Println("\n--- Skipped validation request ---") form.SendTestPost(":8080/optional-int", skippedPayload) if err := r.ListenAndServe(8080); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` ## Notes [#notes] * Optional integer validation runs only when the pointer is non-nil. * `nil` values are skipped and are not considered validation failures. * Optional integer fields are useful for PATCH APIs and nullable database values. * Use `MinOpt`, `MaxOpt`, and `BetweenOpt` for integer bounds validation. * Use `PositiveOpt` and `NegativeOpt` for intent-based numeric validation. * Optional integer validation remains explicit and transport-independent. * Custom error codes are recommended for frontend-facing APIs and public validation contracts. # Optional Pointer Validation `OptionalPtr` is the core primitive behind optional validation in `form`. It allows validation rules to execute only when a pointer value exists. This makes it possible to validate nullable input safely while supporting partial updates, PATCH APIs, optional nested objects, and transport-independent validation workflows. ## Why OptionalPtr Exists [#why-optionalptr-exists] Many APIs require fields that: * may be omitted * may explicitly contain `null` * may appear only during partial updates * may be conditionally validated Examples include: * profile updates * billing configuration * nested settings * optional embedded objects * optional metadata * nullable database fields * PATCH endpoints * feature toggles * optional onboarding sections `OptionalPtr` solves this by separating: * existence validation * value validation ## TL;DR [#tldr] | Helper | Description | | --------------------------------------------------- | --------------------------------------------------------- | | `optional.OptionalPtr(field, rules...)` | Runs nested rules only when the pointer is non-nil | | `optional.OptionalPtrWith(field, fn, rules...)` | Runs rules only when a custom condition passes | | `optional.OptionalPtrValue(field, value, rules...)` | Runs rules only when the pointer matches a specific value | ## Pointer-Based Validation [#pointer-based-validation] Optional pointer validation is based on nullable pointer fields. Example: ```go type UpdateProfileRequest struct { Age *int `json:"age"` Price *float64 `json:"price"` Nickname *string `json:"nickname"` } ``` This allows the application to distinguish between: ```json { "nickname": null } ``` and: ```json {} ``` This distinction is extremely important in PATCH APIs and partial updates. ## Defining Optional Pointer Fields [#defining-optional-pointer-fields] Optional pointer fields use typed optional field definitions. ```go OptionalPtrForm := struct { Nickname form.OptStringField[OptionalPtrRequest] Age form.OptIntField[OptionalPtrRequest] Price form.OptFloat64Field[OptionalPtrRequest] }{ Nickname: form.OptString[OptionalPtrRequest]("nickname", func(r *OptionalPtrRequest) *string { return r.Nickname }), Age: form.OptInt[OptionalPtrRequest]("age", func(r *OptionalPtrRequest) *int { return r.Age }), Price: form.OptFloat64[OptionalPtrRequest]("price", func(r *OptionalPtrRequest) *float64 { return r.Price }), } ``` ## Applying OptionalPtr [#applying-optionalptr] `OptionalPtr` executes nested validation rules only when the pointer exists. ```go return form.Schema[OptionalPtrRequest]{ optional.OptionalPtr( OptionalPtrForm.Nickname.Field, rules.MinLen(OptionalPtrForm.Nickname, 3), ), optional.OptionalPtr( OptionalPtrForm.Age.Field, optional.MinOpt(OptionalPtrForm.Age, 18), ), } ``` Validation behavior: | Input | Result | | ------------- | --------- | | `null` | skipped | | omitted field | skipped | | present value | validated | ## OptionalPtrWith [#optionalptrwith] `OptionalPtrWith` allows custom conditional execution. Example: ```go optional.OptionalPtrWith( OptionalPtrForm.Price.Field, func(v *float64) bool { return v != nil && *v > 0 }, optional.MinFloat64Opt(OptionalPtrForm.Price, 1), ) ``` Typical use cases: * conditional billing validation * feature-based validation * advanced business rules * staged onboarding ## OptionalPtrValue [#optionalptrvalue] `OptionalPtrValue` runs validation only when the pointer matches a specific value. Example: ```go optional.OptionalPtrValue( OptionalPtrForm.Role.Field, "admin", rules.Required(AdminForm.AccessLevel), ) ``` Typical use cases: * role-based validation * workflow branching * feature toggles * conditional form sections ## Nested Object Validation [#nested-object-validation] `OptionalPtr` is especially useful for optional nested objects. Example: ```go type Billing struct { VAT string `json:"vat"` } type Request struct { Billing *Billing `json:"billing"` } ``` Validation: ```go optional.OptionalPtr( RequestForm.Billing.Field, BillingSchema(), ) ``` This allows nested validation only when the object exists. ## Complete Example [#complete-example] ### schema.go [#schemago] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/optional" "github.com/netlifeguru/form/rules" ) const ( CodeNicknameMinLen = form.Code("nickname_min_len") CodeAgeMin = form.Code("age_min") CodePriceMin = form.Code("price_min") ) type OptionalPtrRequest struct { Nickname *string `json:"nickname"` Age *int `json:"age"` Price *float64 `json:"price"` } func OptionalPtrSchema() form.Schema[OptionalPtrRequest] { OptionalPtrForm := struct { Nickname form.OptStringField[OptionalPtrRequest] Age form.OptIntField[OptionalPtrRequest] Price form.OptFloat64Field[OptionalPtrRequest] }{ Nickname: form.OptString[OptionalPtrRequest]("nickname", func(r *OptionalPtrRequest) *string { return r.Nickname }), Age: form.OptInt[OptionalPtrRequest]("age", func(r *OptionalPtrRequest) *int { return r.Age }), Price: form.OptFloat64[OptionalPtrRequest]("price", func(r *OptionalPtrRequest) *float64 { return r.Price }), } return form.Schema[OptionalPtrRequest]{ optional.OptionalPtr( OptionalPtrForm.Nickname.Field, rules.MinLenWithCode(OptionalPtrForm.Nickname, 3, CodeNicknameMinLen), ), optional.OptionalPtr( OptionalPtrForm.Age.Field, optional.MinOptWithCode(OptionalPtrForm.Age, 18, CodeAgeMin), ), optional.OptionalPtr( OptionalPtrForm.Price.Field, optional.MinFloat64OptWithCode(OptionalPtrForm.Price, 0.01, CodePriceMin), ), } } ``` ### main.go [#maingo] ```go package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/form" "github.com/netlifeguru/form/httpform" "github.com/netlifeguru/router" ) func main() { r := router.New() r.HandleFunc("/optional-ptr", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in OptionalPtrRequest if !httpform.BindAndValidate(w, req, &in, OptionalPtrSchema(), 1<<20) { fmt.Println("optional ptr validation failed") return } fmt.Println("optional ptr validation passed:", in) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "optional ptr validation passed", "data": in, }) }) validPayload := map[string]any{ "nickname": "john", "age": 25, "price": 19.99, } invalidPayload := map[string]any{ "nickname": "ab", "age": 10, "price": -1, } skippedPayload := map[string]any{ "nickname": nil, "age": nil, "price": nil, } fmt.Println("\n--- Valid request ---") form.SendTestPost(":8080/optional-ptr", validPayload) fmt.Println("\n--- Invalid request ---") form.SendTestPost(":8080/optional-ptr", invalidPayload) fmt.Println("\n--- Skipped validation request ---") form.SendTestPost(":8080/optional-ptr", skippedPayload) if err := r.ListenAndServe(8080); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` ## Validation Flow [#validation-flow] Optional pointer validation behaves like this: ```text nil pointer ↓ validation skipped ``` ```text non-nil pointer ↓ nested rules executed ``` ## Notes [#notes] * `OptionalPtr` is the foundation of nullable validation in `form`. * Validation runs only when the pointer exists. * Nil values are intentionally skipped and are not validation failures. * Pointer validation is especially useful for PATCH APIs and partial updates. * Optional pointer validation supports nested schemas and embedded objects. * Optional validation remains explicit and transport-independent. * Optional pointer validation helps separate field existence from value correctness. # Optional Slice Validation `OptionalSlice` allows slice validation rules to run only when a collection contains items. This is useful for optional arrays, tags, permissions, categories, metadata lists, and partial update APIs where empty collections should be ignored instead of rejected. ## Why OptionalSlice Exists [#why-optionalslice-exists] Many APIs contain collections that are optional. Examples include: * tags * categories * permissions * feature lists * metadata arrays * selected filters * user groups * shopping cart items * labels * batch operations Without optional validation, every collection would require additional conditional logic. `OptionalSlice` keeps validation pipelines clean and reusable. ## TL;DR [#tldr] | Helper | Description | | ------------------------------------------------- | ------------------------------------------------------ | | `optional.OptionalSlice(field, rules...)` | Runs nested rules only when the slice contains items | | `optional.OptionalSliceWith(field, fn, rules...)` | Runs validation only when a custom condition passes | | `optional.OptionalSliceLen(field, n, rules...)` | Runs validation only when the slice length matches `n` | ## Defining Optional Slice Fields [#defining-optional-slice-fields] Optional slice validation starts with typed slice fields. ```go type OptionalSliceRequest struct { Tags []string `json:"tags"` Permissions []string `json:"permissions"` Ids []int `json:"ids"` } ``` Field definitions use `form.Slice`. ```go OptionalSliceForm := struct { Tags form.SliceField[OptionalSliceRequest, string] Permissions form.SliceField[OptionalSliceRequest, string] Ids form.SliceField[OptionalSliceRequest, int] }{ Tags: form.Slice[OptionalSliceRequest]("tags", func(r *OptionalSliceRequest) []string { return r.Tags }), Permissions: form.Slice[OptionalSliceRequest]("permissions", func(r *OptionalSliceRequest) []string { return r.Permissions }), Ids: form.Slice[OptionalSliceRequest]("ids", func(r *OptionalSliceRequest) []int { return r.Ids }), } ``` ## Applying OptionalSlice [#applying-optionalslice] `OptionalSlice` executes nested validation rules only when the slice contains items. ```go return form.Schema[OptionalSliceRequest]{ optional.OptionalSlice( OptionalSliceForm.Tags.Field, rules.MinItems(OptionalSliceForm.Tags, 2), rules.UniqueItems(OptionalSliceForm.Tags), ), } ``` Validation behavior: | Input | Result | | --------------- | ------- | | `[]` | skipped | | `["go"]` | invalid | | `["go", "api"]` | valid | ## OptionalSliceWith [#optionalslicewith] `OptionalSliceWith` allows custom conditional collection validation. Example: ```go optional.OptionalSliceWith( OptionalSliceForm.Tags.Field, func(v []string) bool { return len(v) > 0 }, rules.UniqueItems(OptionalSliceForm.Tags), ) ``` Typical use cases: * advanced filtering * conditional array validation * staged onboarding * dynamic API rules ## OptionalSliceLen [#optionalslicelen] `OptionalSliceLen` executes validation only when the slice length matches a specific value. Example: ```go optional.OptionalSliceLen( OptionalSliceForm.Tags.Field, 1, rules.ContainsItem(OptionalSliceForm.Tags, "featured"), ) ``` Typical use cases: * conditional tagging * restricted batch operations * workflow constraints * dynamic collection rules ## Combining Slice Rules [#combining-slice-rules] Optional slice validation works with all regular slice validation rules. Example: ```go optional.OptionalSlice( OptionalSliceForm.Tags.Field, rules.MinItems(OptionalSliceForm.Tags, 2), rules.MaxItems(OptionalSliceForm.Tags, 5), rules.UniqueItems(OptionalSliceForm.Tags), ) ``` This creates reusable collection validation pipelines. ## Complete Example [#complete-example] ### schema.go [#schemago] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/optional" "github.com/netlifeguru/form/rules" ) const ( CodeTagsMinItems = form.Code("tags_min_items") CodePermissionsUnique = form.Code("permissions_unique") CodeIdsBetween = form.Code("ids_between") ) type OptionalSliceRequest struct { Tags []string `json:"tags"` Permissions []string `json:"permissions"` Ids []int `json:"ids"` } func OptionalSliceSchema() form.Schema[OptionalSliceRequest] { OptionalSliceForm := struct { Tags form.SliceField[OptionalSliceRequest, string] Permissions form.SliceField[OptionalSliceRequest, string] Ids form.SliceField[OptionalSliceRequest, int] }{ Tags: form.Slice[OptionalSliceRequest]("tags", func(r *OptionalSliceRequest) []string { return r.Tags }), Permissions: form.Slice[OptionalSliceRequest]("permissions", func(r *OptionalSliceRequest) []string { return r.Permissions }), Ids: form.Slice[OptionalSliceRequest]("ids", func(r *OptionalSliceRequest) []int { return r.Ids }), } return form.Schema[OptionalSliceRequest]{ optional.OptionalSlice( OptionalSliceForm.Tags.Field, rules.MinItemsWithCode(OptionalSliceForm.Tags, 2, CodeTagsMinItems), ), optional.OptionalSlice( OptionalSliceForm.Permissions.Field, rules.UniqueItemsWithCode(OptionalSliceForm.Permissions, CodePermissionsUnique), ), optional.OptionalSlice( OptionalSliceForm.Ids.Field, rules.ItemsBetweenWithCode(OptionalSliceForm.Ids, 1, 5, CodeIdsBetween), ), } } ``` ### main.go [#maingo] ```go package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/form" "github.com/netlifeguru/form/httpform" "github.com/netlifeguru/router" ) func main() { r := router.New() r.HandleFunc("/optional-slice", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in OptionalSliceRequest if !httpform.BindAndValidate(w, req, &in, OptionalSliceSchema(), 1<<20) { fmt.Println("optional slice validation failed") return } fmt.Println("optional slice validation passed:", in) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "optional slice validation passed", "data": in, }) }) validPayload := map[string]any{ "tags": []string{"go", "api"}, "permissions": []string{"read", "write"}, "ids": []int{1, 2, 3}, } invalidPayload := map[string]any{ "tags": []string{"go"}, "permissions": []string{"read", "read"}, "ids": []int{1, 2, 3, 4, 5, 6}, } skippedPayload := map[string]any{ "tags": []string{}, "permissions": []string{}, "ids": []int{}, } fmt.Println("\n--- Valid request ---") form.SendTestPost(":8080/optional-slice", validPayload) fmt.Println("\n--- Invalid request ---") form.SendTestPost(":8080/optional-slice", invalidPayload) fmt.Println("\n--- Skipped validation request ---") form.SendTestPost(":8080/optional-slice", skippedPayload) if err := r.ListenAndServe(8080); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` ## Validation Flow [#validation-flow] Optional slice validation behaves like this: ```text empty slice ↓ validation skipped ``` ```text non-empty slice ↓ nested rules executed ``` ## Notes [#notes] * Optional slice validation runs only when the slice contains items. * Empty collections are intentionally skipped and are not validation failures. * Optional slice validation is useful for PATCH APIs and partial updates. * Optional slice validation works with all standard slice rules. * Collection validation remains reusable and transport-independent. * Optional slice validation helps keep schemas explicit and composable. * Slice validation is especially useful for APIs that expose optional tags, permissions, metadata, or batch payloads. # Optional String Validation `OptionalString` allows string validation rules to run only when the string contains a meaningful value. This makes it possible to validate optional text input without forcing users to provide the field. It is especially useful for: * profile updates * PATCH APIs * optional descriptions * nicknames * social links * optional metadata * partial onboarding * admin forms * settings pages ## Validation Philosophy [#validation-philosophy] Unlike required validation, optional validation skips nested rules entirely when the value is considered empty. This allows schemas to naturally support optional fields without additional conditional logic. Example: ```go optional.OptionalString( UserForm.Nickname, rules.MinLen(UserForm.Nickname, 3), ) ``` Validation behavior: | Input | Result | | -------- | ------- | | `""` | skipped | | `" "` | skipped | | `"ab"` | invalid | | `"john"` | valid | ## TL;DR [#tldr] | Helper | Description | | ------------------------------------------------------ | ------------------------------------------------------- | | `optional.OptionalString(field, rules...)` | Runs nested rules only when the string is non-empty | | `optional.OptionalStringTrim(field, rules...)` | Trims whitespace before evaluating emptiness | | `optional.OptionalStringWith(field, fn)` | Runs custom conditional string validation | | `optional.OptionalStringValue(field, value, rules...)` | Runs rules only when the string equals a specific value | ## Defining Optional String Fields [#defining-optional-string-fields] Optional string validation starts with regular string field definitions. ```go OptionalForm := struct { Nickname form.StringField[OptionalStringRequest] Bio form.StringField[OptionalStringRequest] Email form.StringField[OptionalStringRequest] }{ Nickname: form.Str[OptionalStringRequest]("nickname", func(r *OptionalStringRequest) string { return r.Nickname }), Bio: form.Str[OptionalStringRequest]("bio", func(r *OptionalStringRequest) string { return r.Bio }), Email: form.Str[OptionalStringRequest]("email", func(r *OptionalStringRequest) string { return r.Email }), } ``` Optional validation wraps normal validation rules. ## OptionalString [#optionalstring] `OptionalString` skips nested validation rules when the string is blank. Example: ```go optional.OptionalString( OptionalForm.Nickname, rules.MinLen(OptionalForm.Nickname, 3), ) ``` Validation flow: ```text empty string ↓ validation skipped ``` ```text non-empty string ↓ nested rules executed ``` Typical use cases: * optional nicknames * optional profile fields * optional labels * optional comments ## OptionalStringTrim [#optionalstringtrim] `OptionalStringTrim` trims whitespace before checking whether the value is empty. Example: ```go optional.OptionalStringTrim( OptionalForm.Bio, rules.MaxLen(OptionalForm.Bio, 200), ) ``` Validation behavior: | Input | Result | | --------- | --------- | | `""` | skipped | | `" "` | skipped | | `"hello"` | validated | This is useful for frontend form input where users may accidentally submit whitespace-only values. ## OptionalStringWith [#optionalstringwith] `OptionalStringWith` allows fully custom conditional validation logic. Example: ```go optional.OptionalStringWith( OptionalForm.Email, func(v string) bool { return strings.Contains(v, "@company.com") }, rules.Email(OptionalForm.Email), ) ``` This helper is useful for: * feature-flagged validation * domain-restricted input * custom conditional validation flows * advanced business rules ## OptionalStringValue [#optionalstringvalue] `OptionalStringValue` only executes validation rules when the string matches a specific value. Example: ```go optional.OptionalStringValue( OptionalForm.Type, "business", rules.Required(OptionalForm.CompanyName), ) ``` Typical use cases: * conditional form sections * account type validation * business onboarding flows * feature-dependent validation ## Combining Optional Rules [#combining-optional-rules] Optional validation can wrap multiple validation rules. Example: ```go optional.OptionalString( OptionalForm.Nickname, rules.MinLen(OptionalForm.Nickname, 3), rules.MaxLen(OptionalForm.Nickname, 20), rules.Regex(OptionalForm.Nickname, nicknameRegex), ) ``` This creates reusable optional validation pipelines. ## Complete Example [#complete-example] ### schema.go [#schemago] ```go package main import ( "regexp" "github.com/netlifeguru/form" "github.com/netlifeguru/form/optional" "github.com/netlifeguru/form/rules" ) const ( CodeNicknameMinLen = form.Code("nickname_min_len") CodeBioMaxLen = form.Code("bio_max_len") CodeSlugRegex = form.Code("slug_regex") ) type OptionalStringRequest struct { Nickname string `json:"nickname"` Bio string `json:"bio"` Email string `json:"email"` Slug string `json:"slug"` } func OptionalStringSchema() form.Schema[OptionalStringRequest] { OptionalForm := struct { Nickname form.StringField[OptionalStringRequest] Bio form.StringField[OptionalStringRequest] Email form.StringField[OptionalStringRequest] Slug form.StringField[OptionalStringRequest] }{ Nickname: form.Str[OptionalStringRequest]("nickname", func(r *OptionalStringRequest) string { return r.Nickname }), Bio: form.Str[OptionalStringRequest]("bio", func(r *OptionalStringRequest) string { return r.Bio }), Email: form.Str[OptionalStringRequest]("email", func(r *OptionalStringRequest) string { return r.Email }), Slug: form.Str[OptionalStringRequest]("slug", func(r *OptionalStringRequest) string { return r.Slug }), } slugRegex := regexp.MustCompile(`^[a-z0-9-]+$`) return form.Schema[OptionalStringRequest]{ optional.OptionalString( OptionalForm.Nickname, rules.MinLenWithCode(OptionalForm.Nickname, 3, CodeNicknameMinLen), ), optional.OptionalStringTrim( OptionalForm.Bio, rules.MaxLenWithCode(OptionalForm.Bio, 20, CodeBioMaxLen), ), optional.OptionalString( OptionalForm.Email, rules.Email(OptionalForm.Email), ), optional.OptionalString( OptionalForm.Slug, rules.RegexWithCode(OptionalForm.Slug, slugRegex, CodeSlugRegex), ), } } ``` ### main.go [#maingo] ```go package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/form" "github.com/netlifeguru/form/httpform" "github.com/netlifeguru/router" ) func main() { r := router.New() r.HandleFunc("/optional-string", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in OptionalStringRequest if !httpform.BindAndValidate(w, req, &in, OptionalStringSchema(), 1<<20) { fmt.Println("optional string validation failed") return } fmt.Println("optional string validation passed:", in) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "optional string validation passed", "data": in, }) }) validPayload := map[string]any{ "nickname": "john", "bio": "short bio", "email": "john@example.com", "slug": "hello-world", } invalidPayload := map[string]any{ "nickname": "ab", "bio": "this bio is definitely too long", "email": "invalid-email", "slug": "Hello World!", } skippedPayload := map[string]any{ "nickname": "", "bio": "", "email": "", "slug": "", } fmt.Println("\n--- Valid request ---") form.SendTestPost(":8080/optional-string", validPayload) fmt.Println("\n--- Invalid request ---") form.SendTestPost(":8080/optional-string", invalidPayload) fmt.Println("\n--- Skipped validation request ---") form.SendTestPost(":8080/optional-string", skippedPayload) if err := r.ListenAndServe(8080); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` ## Notes [#notes] * Optional validation skips nested rules when the string is blank. * `OptionalStringTrim` treats whitespace-only values as empty. * Optional validation is useful for PATCH APIs and partial updates. * Optional validation can wrap any regular string validation rule. * Optional validation keeps schemas reusable and transport-independent. * Empty values are skipped intentionally and are not considered validation failures. * Optional validation is composable and works naturally with conditional validation pipelines. ``` ``` # Optional Time Validation `OptionalTime` allows time validation rules to run only when a nullable `time.Time` value exists. This is useful for optional scheduling fields, expiration timestamps, booking windows, deadlines, onboarding flows, and PATCH APIs where date fields may be omitted. ## Why OptionalTime Exists [#why-optionaltime-exists] Many APIs contain timestamps that are optional. Examples include: * optional booking dates * expiration timestamps * scheduled publishing * availability windows * onboarding deadlines * reminder scheduling * delayed processing * maintenance windows * optional event ranges * partial update APIs Without optional validation, nullable time fields require additional conditional logic throughout the application. `OptionalTime` keeps validation explicit and reusable. ## TL;DR [#tldr] | Helper | Description | | -------------------------------------------------------- | ------------------------------------------------------- | | `optional.OptionalTime(field, rules...)` | Runs nested rules only when the time pointer is non-nil | | `optional.AfterOpt(field, t)` | Requires the optional time value to be after `t` | | `optional.AfterOptWithCode(field, t, code)` | Same as `AfterOpt` with a custom error code | | `optional.BeforeOpt(field, t)` | Requires the optional time value to be before `t` | | `optional.BeforeOptWithCode(field, t, code)` | Same as `BeforeOpt` with a custom error code | | `optional.BetweenTimeOpt(field, min, max)` | Requires the optional time value to be within a range | | `optional.BetweenTimeOptWithCode(field, min, max, code)` | Same as `BetweenTimeOpt` with a custom error code | ## Defining Optional Time Fields [#defining-optional-time-fields] Optional time validation starts with nullable pointer fields. ```go type OptionalTimeRequest struct { StartAt *time.Time `json:"start_at"` EndAt *time.Time `json:"end_at"` ExpireAt *time.Time `json:"expire_at"` } ``` Field definitions use `form.OptTime`. ```go OptionalTimeForm := struct { StartAt form.OptTimeField[OptionalTimeRequest] EndAt form.OptTimeField[OptionalTimeRequest] ExpireAt form.OptTimeField[OptionalTimeRequest] }{ StartAt: form.OptTime[OptionalTimeRequest]("start_at", func(r *OptionalTimeRequest) *time.Time { return r.StartAt }), EndAt: form.OptTime[OptionalTimeRequest]("end_at", func(r *OptionalTimeRequest) *time.Time { return r.EndAt }), ExpireAt: form.OptTime[OptionalTimeRequest]("expire_at", func(r *OptionalTimeRequest) *time.Time { return r.ExpireAt }), } ``` ## Applying Optional Time Rules [#applying-optional-time-rules] `OptionalTime` executes nested validation rules only when the timestamp exists. ```go return form.Schema[OptionalTimeRequest]{ optional.OptionalTime( OptionalTimeForm.StartAt, optional.AfterOpt(OptionalTimeForm.StartAt, time.Now()), ), optional.OptionalTime( OptionalTimeForm.EndAt, optional.BeforeOpt(OptionalTimeForm.EndAt, maxDate), ), optional.OptionalTime( OptionalTimeForm.ExpireAt, optional.BetweenTimeOpt(OptionalTimeForm.ExpireAt, now, maxDate), ), } ``` Validation behavior: | Input | Result | | --------------- | ---------------- | | `null` | skipped | | omitted field | skipped | | valid timestamp | validated | | invalid range | validation error | ## AfterOpt [#afteropt] Requires the optional timestamp to be after a reference time. Example: ```go optional.AfterOpt( OptionalTimeForm.StartAt, time.Now(), ) ``` Typical use cases: * future appointments * booking systems * scheduled jobs * delayed execution ## BeforeOpt [#beforeopt] Requires the optional timestamp to be before a reference time. Example: ```go optional.BeforeOpt( OptionalTimeForm.EndAt, maxDate, ) ``` Typical use cases: * expiration limits * bounded schedules * release deadlines * maintenance windows ## BetweenTimeOpt [#betweentimeopt] Requires the optional timestamp to stay within a time range. Example: ```go optional.BetweenTimeOpt( OptionalTimeForm.ExpireAt, now, maxDate, ) ``` Typical use cases: * booking windows * event scheduling * onboarding periods * subscription validation ## Complete Example [#complete-example] ### schema.go [#schemago] ```go package main import ( "time" "github.com/netlifeguru/form" "github.com/netlifeguru/form/optional" ) const ( CodeStartAtAfter = form.Code("start_at_after") CodeEndAtBefore = form.Code("end_at_before") CodeExpireAtRange = form.Code("expire_at_range") ) type OptionalTimeRequest struct { StartAt *time.Time `json:"start_at"` EndAt *time.Time `json:"end_at"` ExpireAt *time.Time `json:"expire_at"` } func OptionalTimeSchema() form.Schema[OptionalTimeRequest] { now := time.Now() maxDate := now.Add(30 * 24 * time.Hour) OptionalTimeForm := struct { StartAt form.OptTimeField[OptionalTimeRequest] EndAt form.OptTimeField[OptionalTimeRequest] ExpireAt form.OptTimeField[OptionalTimeRequest] }{ StartAt: form.OptTime[OptionalTimeRequest]("start_at", func(r *OptionalTimeRequest) *time.Time { return r.StartAt }), EndAt: form.OptTime[OptionalTimeRequest]("end_at", func(r *OptionalTimeRequest) *time.Time { return r.EndAt }), ExpireAt: form.OptTime[OptionalTimeRequest]("expire_at", func(r *OptionalTimeRequest) *time.Time { return r.ExpireAt }), } return form.Schema[OptionalTimeRequest]{ optional.OptionalTime( OptionalTimeForm.StartAt, optional.AfterOptWithCode(OptionalTimeForm.StartAt, now, CodeStartAtAfter), ), optional.OptionalTime( OptionalTimeForm.EndAt, optional.BeforeOptWithCode(OptionalTimeForm.EndAt, maxDate, CodeEndAtBefore), ), optional.OptionalTime( OptionalTimeForm.ExpireAt, optional.BetweenTimeOptWithCode( OptionalTimeForm.ExpireAt, now, maxDate, CodeExpireAtRange, ), ), } } ``` ### main.go [#maingo] ```go package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "time" "github.com/netlifeguru/form" "github.com/netlifeguru/form/httpform" "github.com/netlifeguru/router" ) func main() { r := router.New() r.HandleFunc("/optional-time", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in OptionalTimeRequest if !httpform.BindAndValidate(w, req, &in, OptionalTimeSchema(), 1<<20) { fmt.Println("optional time validation failed") return } fmt.Println("optional time validation passed:", in) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "optional time validation passed", "data": in, }) }) now := time.Now() validPayload := map[string]any{ "start_at": now.Add(24 * time.Hour), "end_at": now.Add(7 * 24 * time.Hour), "expire_at": now.Add(3 * 24 * time.Hour), } invalidPayload := map[string]any{ "start_at": now.Add(-24 * time.Hour), "end_at": now.Add(60 * 24 * time.Hour), "expire_at": now.Add(90 * 24 * time.Hour), } skippedPayload := map[string]any{ "start_at": nil, "end_at": nil, "expire_at": nil, } fmt.Println("\n--- Valid request ---") form.SendTestPost(":8080/optional-time", validPayload) fmt.Println("\n--- Invalid request ---") form.SendTestPost(":8080/optional-time", invalidPayload) fmt.Println("\n--- Skipped validation request ---") form.SendTestPost(":8080/optional-time", skippedPayload) if err := r.ListenAndServe(8080); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` ## Validation Flow [#validation-flow] Optional time validation behaves like this: ```text nil timestamp ↓ validation skipped ``` ```text existing timestamp ↓ nested rules executed ``` ## Notes [#notes] * Optional time validation runs only when the timestamp pointer is non-nil. * Nil values are intentionally skipped and are not validation failures. * Optional time validation is useful for PATCH APIs and partial updates. * Time validation works naturally with scheduling and expiration workflows. * Use `AfterOpt`, `BeforeOpt`, and `BetweenTimeOpt` for bounded time validation. * Time validation remains reusable and transport-independent. * Be careful when caching schemas that depend on `time.Now()` directly. # Installation The `db` package is a shared database layer. It is not used as a standalone database driver. To create a real database connection, install one of the supported driver packages. Installing a driver also installs the shared dependencies: ```text github.com/netlifeguru/db github.com/netlifeguru/mapper ``` ## Choose a Driver [#choose-a-driver] Install the driver that matches your database. ### MySQL [#mysql] ```bash go get github.com/netlifeguru/db-mysql ``` Use this driver for MySQL and MySQL-compatible databases such as MariaDB. ```go import ( "github.com/netlifeguru/db" "github.com/netlifeguru/db-mysql" ) ``` ### PostgreSQL [#postgresql] ```bash go get github.com/netlifeguru/db-postgres ``` Use this driver for PostgreSQL-compatible databases. ```go import ( "github.com/netlifeguru/db" "github.com/netlifeguru/db-postgres" ) ``` ### Scylla [#scylla] ```bash go get github.com/netlifeguru/db-scylla ``` Use this driver for ScyllaDB and CQL-based workloads supported by the driver. ```go import ( "github.com/netlifeguru/db" "github.com/netlifeguru/db-scylla" ) ``` ## Install Multiple Drivers [#install-multiple-drivers] You can install more than one driver in the same project. ```bash go get github.com/netlifeguru/db-mysql go get github.com/netlifeguru/db-postgres go get github.com/netlifeguru/db-scylla ``` This is useful when: * one application can run with different database engines * tests use a different database than production * a migration tool needs to support multiple databases * you are using dialect SQL files ## Do Not Install DB Alone [#do-not-install-db-alone] > You can technically install the shared package directly: ```bash go get github.com/netlifeguru/db ``` > However, this does not give you a working database connection by itself. > The `db` package defines shared interfaces, helpers, query types, and result mapping behavior. > A concrete driver is still required to connect to MySQL, PostgreSQL, or Scylla. In most applications, install a driver instead: ```bash go get github.com/netlifeguru/db-mysql ``` or: ```bash go get github.com/netlifeguru/db-postgres ``` or: ```bash go get github.com/netlifeguru/db-scylla ``` ## Requirements [#requirements] This package requires Go `1.22` or newer. * **Go:** `1.22` or newer * **Connection driver:** MySQL, PostgreSQL, or Scylla * **Shared database layer:** `github.com/netlifeguru/db` * **Result mapper:** `github.com/netlifeguru/mapper` ## Package Relationship [#package-relationship] The packages are intended to be used together. ```text Application ↓ Driver package ↓ github.com/netlifeguru/db ↓ github.com/netlifeguru/mapper ``` For example, when you install: ```bash go get github.com/netlifeguru/db-postgres ``` your project also receives the shared `db` and `mapper` packages. ## Next Step [#next-step] After installing a driver, continue with the driver-specific getting started guide: * MySQL * PostgreSQL * Scylla # MySQL Use `github.com/netlifeguru/db-mysql` when your application connects to MySQL or a MySQL-compatible database such as MariaDB. The MySQL driver implements the shared `db.Conn` interface, so once the connection is created, the rest of your application can use the common `db` APIs such as `List`, `Get`, `Value`, `Maps`, `Insert`, `Update`, and `Delete`. ## Install [#install] Install the MySQL driver: ```bash go get github.com/netlifeguru/db-mysql ``` Installing the driver also automatically installs: ```text github.com/netlifeguru/db github.com/netlifeguru/mapper ``` ## Import [#import] ```go import ( "github.com/netlifeguru/db" "github.com/netlifeguru/db-mysql" ) ``` ## Connection Example [#connection-example] This example creates a MySQL connection pool from environment variables. ```go package main import ( "os" "strconv" "time" "github.com/netlifeguru/db" "github.com/netlifeguru/db-mysql" ) func connectDB() (db.Conn, error) { conn := mysql.New() host := os.Getenv("DB_HOST") database := os.Getenv("DB_NAME") username := os.Getenv("DB_USER") password := os.Getenv("DB_PASSWORD") port, err := strconv.Atoi(os.Getenv("DB_PORT")) if err != nil { return nil, err } cfg := db.Config{ Identifier: "default", Host: host, Port: port, Database: database, Username: username, Password: password, MaxConns: 50, MinConns: 5, MaxConnIdleTime: 10 * time.Minute, MaxConnLifetime: 2 * time.Hour, HealthCheckPeriod: 30 * time.Second, ConnectTimeout: 10 * time.Second, SSLMode: "false", TimeZone: "Local", } if err := conn.CreatePool(cfg); err != nil { return nil, err } return conn.Fork(), nil } ``` ## Environment Variables [#environment-variables] The example above expects these environment variables: ```text DB_HOST=127.0.0.1 DB_PORT=3306 DB_NAME=app DB_USER=root DB_PASSWORD=secret ``` ## MySQL Defaults [#mysql-defaults] If values are not provided, the MySQL driver applies sensible defaults internally. | Option | Default | | ------------------- | ----------- | | `Identifier` | `default` | | `Host` | `127.0.0.1` | | `Port` | `3306` | | `MaxConns` | `25` | | `MinConns` | `2` | | `MaxConnIdleTime` | `5m` | | `MaxConnLifetime` | `1h` | | `ConnectTimeout` | `5s` | | `HealthCheckPeriod` | `30s` | | `SSLMode` | `false` | | `TimeZone` | `Local` | ## Placeholder Style [#placeholder-style] MySQL uses `?` placeholders. ```go users, err := db.List[User](ctx, conn, ` SELECT * FROM users WHERE active = ? ORDER BY created_at DESC `, true) ``` The shared `db` helpers pass the arguments to the driver in order. ## Insert Behavior [#insert-behavior] MySQL inserts commonly use `db.Insert` and read the generated ID from `LastInsertId`. ```go result, err := db.Insert(ctx, conn, ` INSERT INTO users (name, email, active) VALUES (?, ?, ?) `, name, email, active) if err != nil { return err } fmt.Println(result.LastInsertId(), result.RowsAffected()) ``` For PostgreSQL, the common pattern is different because PostgreSQL usually uses `RETURNING id`. For Scylla, IDs are usually generated by the application and written into one or more query tables. ## Transactions [#transactions] The MySQL driver supports transactions through the shared transaction API. Transaction usage is documented in the Transactions guide. ## SQL Files [#sql-files] When using SQL model files, MySQL uses: ```text model.sql ``` The shared `db.LoadModel` helper can load MySQL SQL sections from this file and store them in `db.DialectSQL` values. SQL files are documented in the SQL Files guide. ## Next Step [#next-step] After creating a MySQL connection, continue with the shared querying guides: * List, Get, Value, and Maps * Query Objects * Dialect SQL * Mutations # Overview The `db` package is a shared database layer used by NetLifeGuru database drivers. It provides common APIs for querying, executing statements, loading SQL models, handling dialect-specific SQL, and mapping database results into Go structs, maps, or scalar values. The `db` package is not a standalone database driver. You do not normally install and use `github.com/netlifeguru/db` by itself. To connect to a real database, install one of the supported driver packages. ## How It Fits Together [#how-it-fits-together] The database stack is split into three layers: | Layer | Package | Purpose | | ------ | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | Mapper | **github.com/netlifeguru/mapper** | Maps rows into structs, maps, and values | | DB | **github.com/netlifeguru/db** | Provides shared query, exec, transaction, and dialect APIs | | Driver | **github.com/netlifeguru/db-mysql**
**github.com/netlifeguru/db-postgres**
**github.com/netlifeguru/db-scylla** | Creates real database connections and implements driver behavior | A typical application installs a driver package. The driver package depends on: ```text github.com/netlifeguru/db github.com/netlifeguru/mapper ``` This means that installing a driver gives you the complete stack needed to connect, query, execute statements, and map results. ## Driver-Based Usage [#driver-based-usage] Choose the driver that matches your database: ```bash go get github.com/netlifeguru/db-mysql ``` ```bash go get github.com/netlifeguru/db-postgres ``` ```bash go get github.com/netlifeguru/db-scylla ``` After installing a driver, you create a connection through that driver. For example, with MySQL: ```go conn := mysql.New() ``` With PostgreSQL: ```go conn := postgres.New() ``` With Scylla: ```go conn := scylla.New() ``` Each driver implements the shared `db.Conn` interface, so the rest of your application can use the same `db` APIs. ## Common API Across Drivers [#common-api-across-drivers] Once a connection is created, the shared API is the same across supported drivers. For example, list users: ```go users, err := db.List[User](ctx, conn, ` SELECT * FROM users ORDER BY created_at DESC `) ``` Get one user: ```go user, found, err := db.Get[User](ctx, conn, ` SELECT * FROM users WHERE id = ? `, id) ``` Read a scalar value: ```go total, found, err := db.Value[int64](ctx, conn, ` SELECT COUNT(*) FROM users `) ``` Execute a statement: ```go result, err := db.Update(ctx, conn, ` UPDATE users SET active = ? WHERE id = ? `, active, id) ``` The exact SQL placeholder style still depends on the selected driver. For example: | Driver | Placeholder style | | -------- | ----------------- | | MySQL | `?` | | Postgres | `$1`, `$2`, `$3` | | Scylla | `?` | The `db` package does not hide SQL differences. It gives you one Go API while keeping SQL explicit. ## What DB Provides [#what-db-provides] The shared `db` layer provides: * typed query helpers such as `List`, `Get`, `Value`, and `Maps` * query object helpers such as `Raw`, `ListQuery`, `GetQuery`, `ValueQuery`, and `MapsQuery` * execution helpers such as `Exec`, `Insert`, `Update`, and `Delete` * dialect SQL helpers for multi-driver applications * SQL model loading from driver-specific files * transaction helpers for drivers that support transactions * context helpers for storing and retrieving a connection * integration with mapper for struct and map scanning ## What DB Does Not Do [#what-db-does-not-do] The `db` package is not an ORM. It does not: * generate SQL * define models * manage migrations * manage schemas * infer relationships * build queries automatically * hide database-specific behavior You write SQL explicitly. The `db` package focuses on making execution, result mapping, driver selection, and common database workflows consistent. ## Driver Differences Still Matter [#driver-differences-still-matter] Even though the Go API is shared, database engines are not identical. Some behavior is driver-specific: | Topic | MySQL | Postgres | Scylla | | -------------- | ---------------------- | ---------------------- | ------------------------------------- | | Insert ID | `LastInsertId()` | usually `RETURNING id` | usually generated in application code | | Transactions | supported | supported | not SQL transactions | | Batch writes | standard exec patterns | standard exec patterns | Scylla batches | | SQL files | `model.sql` | `model.psql` | `model.cql` | | Placeholders | `?` | `$1`, `$2` | `?` | | Database field | database name | database name | keyspace | Shared APIs are documented once in the DB documentation. Driver-specific differences are documented separately so the same concepts are not repeated across MySQL, PostgreSQL, and Scylla pages. ## Recommended Learning Path [#recommended-learning-path] Start with installation and one driver-specific getting started page. Then read the shared query documentation. Recommended order: 1. Install a driver 2. Create a connection 3. Use `db.List`, `db.Get`, `db.Value`, or `db.Maps` 4. Use `db.Insert`, `db.Update`, and `db.Delete` 5. Learn dialect SQL and SQL files if your application may support multiple drivers 6. Learn transactions or Scylla batches depending on your selected database ## Next Step [#next-step] Continue with the Installation guide to choose and install a database driver. # PostgreSQL Use `github.com/netlifeguru/db-postgres` when your application connects to PostgreSQL or a PostgreSQL-compatible database. The PostgreSQL driver implements the shared `db.Conn` interface, so once the connection is created, the rest of your application can use the common `db` APIs such as `List`, `Get`, `Value`, `Maps`, `Insert`, `Update`, and `Delete`. ## Install [#install] Install the PostgreSQL driver: ```bash go get github.com/netlifeguru/db-postgres ``` Installing the driver also installs: ```text github.com/netlifeguru/db github.com/netlifeguru/mapper ``` ## Import [#import] ```go import ( "github.com/netlifeguru/db" "github.com/netlifeguru/db-postgres" ) ``` ## Connection Example [#connection-example] This example creates a PostgreSQL connection pool from environment variables. ```go package main import ( "os" "strconv" "time" "github.com/netlifeguru/db" "github.com/netlifeguru/db-postgres" ) func connectDB() (db.Conn, error) { conn := postgres.New() cfg := db.Config{ Identifier: "default", Host: os.Getenv("DB_HOST"), Database: os.Getenv("DB_NAME"), Username: os.Getenv("DB_USER"), Password: os.Getenv("DB_PASSWORD"), MaxConns: 50, MinConns: 5, MaxConnIdleTime: 10 * time.Minute, MaxConnLifetime: 2 * time.Hour, HealthCheckPeriod: 30 * time.Second, ConnectTimeout: 10 * time.Second, SSLMode: "disable", TimeZone: "UTC", } if port := os.Getenv("DB_PORT"); port != "" { n, err := strconv.Atoi(port) if err != nil { return nil, err } cfg.Port = n } if err := conn.CreatePool(cfg); err != nil { return nil, err } return conn.Fork(), nil } ``` ## Environment Variables [#environment-variables] The example above expects these environment variables: ```text DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=app DB_USER=postgres DB_PASSWORD=secret ``` `DB_PORT` is optional in the example. If it is not provided, the driver uses its default PostgreSQL port. ## PostgreSQL Defaults [#postgresql-defaults] If values are not provided, the PostgreSQL driver applies sensible defaults internally. | Option | Default | | ------------------- | ----------- | | `Identifier` | `default` | | `Host` | `127.0.0.1` | | `Port` | `5432` | | `MaxConns` | `25` | | `MinConns` | `2` | | `MaxConnIdleTime` | `5m` | | `MaxConnLifetime` | `1h` | | `ConnectTimeout` | `5s` | | `HealthCheckPeriod` | `30s` | | `SSLMode` | `disable` | | `TimeZone` | `UTC` | ## Placeholder Style [#placeholder-style] PostgreSQL uses numbered placeholders. ```go users, err := db.List[User](ctx, conn, ` SELECT * FROM users WHERE active = $1 ORDER BY created_at DESC `, true) ``` The shared `db` helpers pass the arguments to the driver in order. ## Insert Behavior [#insert-behavior] PostgreSQL commonly uses `RETURNING` when you need a generated ID or another value from an insert. ```go const insertUserQuery = ` INSERT INTO users (name, email, active) VALUES ($1, $2, $3) RETURNING id ` id, found, err := db.Value[int64](ctx, conn, insertUserQuery, name, email, active) if err != nil { return err } if !found { return errors.New("insert did not return id") } fmt.Println(id) ``` You can also use `db.Insert` when you only need the execution result. ```go result, err := db.Insert(ctx, conn, ` INSERT INTO audit_logs (message) VALUES ($1) `, message) if err != nil { return err } fmt.Println(result.RowsAffected()) ``` ## Transactions [#transactions] The PostgreSQL driver supports transactions through the shared transaction API. Transaction usage is documented in the Transactions guide. ## SQL Files [#sql-files] When using SQL model files, PostgreSQL uses: ```text model.psql ``` The shared `db.LoadModel` helper can load PostgreSQL SQL sections from this file and store them in `db.DialectSQL` values. SQL files are documented in the SQL Files guide. ## CockroachDB Note [#cockroachdb-note] PostgreSQL-compatible systems can often use the PostgreSQL driver, depending on compatibility and connection settings. For CockroachDB-style connections, SSL requirements may differ from local PostgreSQL development setups. ## Next Step [#next-step] After creating a PostgreSQL connection, continue with the shared querying guides: * List, Get, Value, and Maps * Query Objects * Dialect SQL * Mutations # Scylla Use `github.com/netlifeguru/db-scylla` when your application connects to ScyllaDB or CQL-based workloads supported by the driver. The Scylla driver implements the shared `db.Conn` interface, so once the connection is created, the rest of your application can use common `db` APIs such as `List`, `Get`, `Value`, `Maps`, `Insert`, `Update`, and `Delete`. Scylla has database-specific behavior that is different from MySQL and PostgreSQL: * `Database` represents the Scylla keyspace * consistency is configured through `db.Config.Consistency` * placeholders use `?` * SQL transactions are not supported in the same way as MySQL or PostgreSQL * batch operations are supported through the Scylla driver ## Install [#install] Install the Scylla driver: ```bash go get github.com/netlifeguru/db-scylla ``` Installing the driver also installs: ```text github.com/netlifeguru/db github.com/netlifeguru/mapper ``` ## Import [#import] ```go import ( "github.com/netlifeguru/db" "github.com/netlifeguru/db-scylla" ) ``` ## Connection Example [#connection-example] This example creates a Scylla connection from environment variables. ```go package main import ( "os" "strconv" "time" "github.com/netlifeguru/db" "github.com/netlifeguru/db-scylla" ) func connectDB() (db.Conn, error) { conn := scylla.New() cfg := db.Config{ Identifier: "default", Host: os.Getenv("DB_HOST"), Database: os.Getenv("DB_NAME"), // Scylla keyspace Username: os.Getenv("DB_USER"), Password: os.Getenv("DB_PASSWORD"), MaxConns: 50, MinConns: 5, MaxConnIdleTime: 10 * time.Minute, MaxConnLifetime: 2 * time.Hour, HealthCheckPeriod: 30 * time.Second, ConnectTimeout: 10 * time.Second, Consistency: "local_quorum", } if port := os.Getenv("DB_PORT"); port != "" { n, err := strconv.Atoi(port) if err != nil { return nil, err } cfg.Port = n } if err := conn.CreatePool(cfg); err != nil { return nil, err } return conn.Fork(), nil } ``` ## Environment Variables [#environment-variables] The example above expects these environment variables: ```text DB_HOST=127.0.0.1 DB_PORT=9042 DB_NAME=app DB_USER=scylla DB_PASSWORD=secret ``` `DB_NAME` is used as the Scylla keyspace. `DB_PORT` is optional in the example. If it is not provided, the driver uses its default Scylla port. ## Scylla Defaults [#scylla-defaults] If values are not provided, the Scylla driver applies sensible defaults internally. | Option | Default | | ------------------- | ----------- | | `Identifier` | `default` | | `Host` | `127.0.0.1` | | `Port` | `9042` | | `MaxConns` | `25` | | `MinConns` | `2` | | `MaxConnIdleTime` | `5m` | | `MaxConnLifetime` | `1h` | | `ConnectTimeout` | `5s` | | `HealthCheckPeriod` | `30s` | | `Consistency` | `quorum` | ## Placeholder Style [#placeholder-style] Scylla uses `?` placeholders. ```go users, err := db.List[User](ctx, conn, ` SELECT * FROM users_by_email WHERE email = ? `, email) ``` The shared `db` helpers pass the arguments to the driver in order. ## Insert Behavior [#insert-behavior] Scylla data models are often query-driven. Instead of inserting one row into one normalized table, an application may write the same entity into multiple query tables. Example: ```go const insertUserByIDQuery = ` INSERT INTO users_by_id (id, email, name, active, created_at) VALUES (?, ?, ?, ?, ?) ` const insertUserByEmailQuery = ` INSERT INTO users_by_email (email, id, name, active, created_at) VALUES (?, ?, ?, ?, ?) ` ``` A common pattern is to generate the ID in application code and write both query tables. ```go id := gocql.TimeUUID() createdAt := time.Now().UTC() if _, err := db.Insert(ctx, conn, insertUserByIDQuery, id, email, name, active, createdAt); err != nil { return "", err } if _, err := db.Insert(ctx, conn, insertUserByEmailQuery, email, id, name, active, createdAt); err != nil { return "", err } return id.String(), nil ``` ## Transactions [#transactions] Scylla does not use SQL transactions in the same way as MySQL or PostgreSQL. The shared transaction guide applies to drivers that support SQL-style transactions, such as MySQL and PostgreSQL. For Scylla workloads, use data modeling, idempotent writes, batches, or lightweight transactions where appropriate. ## Batches [#batches] The Scylla driver supports batch operations. Batch usage is documented in the Scylla Batches guide. Use batches carefully and only when the data model requires grouped writes. ## SQL Files [#sql-files] When using SQL model files, Scylla uses: ```text model.cql ``` The shared `db.LoadModel` helper can load CQL sections from this file and store them in `db.DialectSQL` values. SQL files are documented in the SQL Files guide. ## Next Step [#next-step] After creating a Scylla connection, continue with the shared querying guides: * List, Get, Value, and Maps * Query Objects * Dialect SQL * Mutations * Scylla Batches # Examples Practical examples are available in the official examples repository: ```text https://github.com/netlifeguru/examples/db ``` The repository contains standalone examples covering connection setup, connection configuration, connection pools, typed queries, pointer-based lookups, maps, scalar values, inserts, updates, deletes, dialect SQL, low-level operations, transactions, SQL files, Scylla batches, and lightweight transactions. ## MySQL Examples [#mysql-examples] MySQL examples are available at: ```text https://github.com/netlifeguru/examples/db/mysql ``` These examples demonstrate the shared DB API with the MySQL driver, including `?` placeholders, `LastInsertId`, dialect SQL, SQL transactions, connection pools, and multi-driver SQL files. ### Getting Started and Connections [#getting-started-and-connections] * [Getting started](https://github.com/netlifeguru/examples/db/mysql/01_getting_started) * [Connection](https://github.com/netlifeguru/examples/db/mysql/02_connection) * [Connection config](https://github.com/netlifeguru/examples/db/mysql/03_connection_config) * [Connection pools](https://github.com/netlifeguru/examples/db/mysql/04_connection_pools) ### Select Helpers [#select-helpers] * [Select list](https://github.com/netlifeguru/examples/db/mysql/05_select_list) * [Select get](https://github.com/netlifeguru/examples/db/mysql/06_select_get) * [Select get pointer](https://github.com/netlifeguru/examples/db/mysql/07_select_get_ptr) * [Select value](https://github.com/netlifeguru/examples/db/mysql/08_select_value) * [Select map](https://github.com/netlifeguru/examples/db/mysql/09_select_map) ### Query Object Helpers [#query-object-helpers] * [Query list](https://github.com/netlifeguru/examples/db/mysql/10_query_list) * [Query get](https://github.com/netlifeguru/examples/db/mysql/11_query-get) * [Query get pointer](https://github.com/netlifeguru/examples/db/mysql/12_query-get_ptr) * [Query value](https://github.com/netlifeguru/examples/db/mysql/13_query_value) * [Query map](https://github.com/netlifeguru/examples/db/mysql/14_query_map) ### Dialect SQL Helpers [#dialect-sql-helpers] * [Dialect list](https://github.com/netlifeguru/examples/db/mysql/15_dialect_list) * [Dialect get](https://github.com/netlifeguru/examples/db/mysql/16_dialect_get) * [Dialect get pointer](https://github.com/netlifeguru/examples/db/mysql/17_dialect_get_ptr) * [Dialect map](https://github.com/netlifeguru/examples/db/mysql/18_dialect_map) * [Dialect value](https://github.com/netlifeguru/examples/db/mysql/19_dialect_value) ### Mutations [#mutations] * [Insert result](https://github.com/netlifeguru/examples/db/mysql/18_insert_result) * [Insert](https://github.com/netlifeguru/examples/db/mysql/20_insert) * [Update](https://github.com/netlifeguru/examples/db/mysql/21_update) * [Delete](https://github.com/netlifeguru/examples/db/mysql/22_delete) * [Insert dialect](https://github.com/netlifeguru/examples/db/mysql/23_insert_dialect) * [Update dialect](https://github.com/netlifeguru/examples/db/mysql/24_update_dialect) * [Delete dialect](https://github.com/netlifeguru/examples/db/mysql/25_delete_dialect) ### Low-Level Mutations [#low-level-mutations] * [Insert low-level](https://github.com/netlifeguru/examples/db/mysql/26_insert_low_level) * [Insert result low-level](https://github.com/netlifeguru/examples/db/mysql/27_insert_result_low_level) * [Update low-level](https://github.com/netlifeguru/examples/db/mysql/28_update_low_level) * [Delete low-level](https://github.com/netlifeguru/examples/db/mysql/29_delete_low_level) ### Transactions and SQL Files [#transactions-and-sql-files] * [Transactions](https://github.com/netlifeguru/examples/db/mysql/30_transactions) * [Low-level transactions](https://github.com/netlifeguru/examples/db/mysql/31_transactions_low_level) * [SQL files](https://github.com/netlifeguru/examples/db/mysql/32_sql_files) * [Multi-driver SQL files](https://github.com/netlifeguru/examples/db/mysql/33_multi_driver_sql_files) *** ## PostgreSQL Examples [#postgresql-examples] PostgreSQL examples are available at: ```text https://github.com/netlifeguru/examples/db/postgresql ``` These examples demonstrate the shared DB API with the PostgreSQL driver, including numbered placeholders, `INSERT ... RETURNING`, dialect SQL, connection pools, SQL transactions, and multi-driver SQL files. ### Getting Started and Connections [#getting-started-and-connections-1] * [Getting started](https://github.com/netlifeguru/examples/db/postgresql/01_getting_started) * [Connection](https://github.com/netlifeguru/examples/db/postgresql/02_connection) * [Connection config](https://github.com/netlifeguru/examples/db/postgresql/03_connection_config) * [Connection pools](https://github.com/netlifeguru/examples/db/postgresql/04_connection_pools) ### Select Helpers [#select-helpers-1] * [Select list](https://github.com/netlifeguru/examples/db/postgresql/05_select_list) * [Select get](https://github.com/netlifeguru/examples/db/postgresql/06_select_get) * [Select get pointer](https://github.com/netlifeguru/examples/db/postgresql/07_select_get_ptr) * [Select value](https://github.com/netlifeguru/examples/db/postgresql/08_select_value) * [Select map](https://github.com/netlifeguru/examples/db/postgresql/09_select_map) ### Query Object Helpers [#query-object-helpers-1] * [Query list](https://github.com/netlifeguru/examples/db/postgresql/10_query_list) * [Query get](https://github.com/netlifeguru/examples/db/postgresql/11_query-get) * [Query get pointer](https://github.com/netlifeguru/examples/db/postgresql/12_query-get_ptr) * [Query value](https://github.com/netlifeguru/examples/db/postgresql/13_query_value) * [Query map](https://github.com/netlifeguru/examples/db/postgresql/14_query_map) ### Dialect SQL Helpers [#dialect-sql-helpers-1] * [Dialect list](https://github.com/netlifeguru/examples/db/postgresql/15_dialect_list) * [Dialect get](https://github.com/netlifeguru/examples/db/postgresql/16_dialect_get) * [Dialect get pointer](https://github.com/netlifeguru/examples/db/postgresql/17_dialect_get_ptr) * [Dialect value](https://github.com/netlifeguru/examples/db/postgresql/18_dialect_value) * [Dialect map](https://github.com/netlifeguru/examples/db/postgresql/19_dialect_map) ### Mutations [#mutations-1] * [Insert](https://github.com/netlifeguru/examples/db/postgresql/20_insert) * [Update](https://github.com/netlifeguru/examples/db/postgresql/21_update) * [Delete](https://github.com/netlifeguru/examples/db/postgresql/22_delete) * [Insert returning dialect](https://github.com/netlifeguru/examples/db/postgresql/23_insert_return_dialect) * [Update dialect](https://github.com/netlifeguru/examples/db/postgresql/24_update_dialect) * [Delete dialect](https://github.com/netlifeguru/examples/db/postgresql/25_delete_dialect) ### Low-Level Mutations [#low-level-mutations-1] * [Insert low-level](https://github.com/netlifeguru/examples/db/postgresql/26_insert_low_level) * [Insert returning low-level](https://github.com/netlifeguru/examples/db/postgresql/27_insert_returning_low_level) * [Update low-level](https://github.com/netlifeguru/examples/db/postgresql/28_update_low_level) * [Delete low-level](https://github.com/netlifeguru/examples/db/postgresql/29_delete_low_level) ### Transactions and SQL Files [#transactions-and-sql-files-1] * [Transactions](https://github.com/netlifeguru/examples/db/postgresql/30_transactions) * [Low-level transactions](https://github.com/netlifeguru/examples/db/postgresql/31_transactions_low_level) * [SQL files](https://github.com/netlifeguru/examples/db/postgresql/32_sql_files) * [Multi-driver SQL files](https://github.com/netlifeguru/examples/db/postgresql/33_multi_driver_sql_files) *** ## Scylla Examples [#scylla-examples] Scylla examples are available at: ```text https://github.com/netlifeguru/examples/db/scylla ``` These examples demonstrate the shared DB API with the Scylla driver, including CQL placeholders, connection pools, query-driven tables, generated IDs, batches, lightweight transactions, and CQL model files. ### Getting Started and Connections [#getting-started-and-connections-2] * [Getting started](https://github.com/netlifeguru/examples/db/scylla/01_getting_started) * [Connection](https://github.com/netlifeguru/examples/db/scylla/02_connection) * [Connection config](https://github.com/netlifeguru/examples/db/scylla/03_connection_config) * [Connection pools](https://github.com/netlifeguru/examples/db/scylla/04_connection_pools) ### Select Helpers [#select-helpers-2] * [Select list](https://github.com/netlifeguru/examples/db/scylla/05_select_list) * [Select get](https://github.com/netlifeguru/examples/db/scylla/06_select_get) * [Select get pointer](https://github.com/netlifeguru/examples/db/scylla/07_select_get_ptr) * [Select value](https://github.com/netlifeguru/examples/db/scylla/08_select_value) * [Select map](https://github.com/netlifeguru/examples/db/scylla/09_select_map) ### Query Object Helpers [#query-object-helpers-2] * [Query list](https://github.com/netlifeguru/examples/db/scylla/10_query_list) * [Query get](https://github.com/netlifeguru/examples/db/scylla/11_query-get) * [Query get pointer](https://github.com/netlifeguru/examples/db/scylla/12_query-get_ptr) * [Query value](https://github.com/netlifeguru/examples/db/scylla/13_query_value) * [Query map](https://github.com/netlifeguru/examples/db/scylla/14_query_map) ### Dialect SQL Helpers [#dialect-sql-helpers-2] * [Dialect list](https://github.com/netlifeguru/examples/db/scylla/15_dialect_list) * [Dialect get](https://github.com/netlifeguru/examples/db/scylla/16_dialect_get) * [Dialect get pointer](https://github.com/netlifeguru/examples/db/scylla/17_dialect_get_ptr) * [Dialect value](https://github.com/netlifeguru/examples/db/scylla/18_dialect_value) * [Dialect map](https://github.com/netlifeguru/examples/db/scylla/19_dialect_map) ### Mutations [#mutations-2] * [Insert](https://github.com/netlifeguru/examples/db/scylla/20_insert) * [Update](https://github.com/netlifeguru/examples/db/scylla/22_update) * [Delete](https://github.com/netlifeguru/examples/db/scylla/23_delete) * [Insert dialect](https://github.com/netlifeguru/examples/db/scylla/24_insert_dialect) * [Update dialect](https://github.com/netlifeguru/examples/db/scylla/25_update_dialect) * [Delete dialect](https://github.com/netlifeguru/examples/db/scylla/26_delete_dialect) ### Low-Level Mutations [#low-level-mutations-2] * [Insert low-level](https://github.com/netlifeguru/examples/db/scylla/27_insert_low_level) * [Update low-level](https://github.com/netlifeguru/examples/db/scylla/28_update_low_level) * [Delete low-level](https://github.com/netlifeguru/examples/db/scylla/29_delete_low_level) ### Batches [#batches] * [Batch](https://github.com/netlifeguru/examples/db/scylla/30_batch) * [Low-level batch](https://github.com/netlifeguru/examples/db/scylla/31_batch_low_level) ### Lightweight Transactions [#lightweight-transactions] * [Lightweight transaction](https://github.com/netlifeguru/examples/db/scylla/32_lightweight_transaction) * [Lightweight transaction map](https://github.com/netlifeguru/examples/db/scylla/33_lightweight_transaction_map) * [Lightweight transaction low-level](https://github.com/netlifeguru/examples/db/scylla/34_lightweight_transaction_low_level) ### SQL Files [#sql-files] * [SQL files](https://github.com/netlifeguru/examples/db/scylla/35_sql_files) *** ## Recommended Starting Points [#recommended-starting-points] Start with the getting-started example for your driver: * [MySQL getting started](https://github.com/netlifeguru/examples/db/mysql/01_getting_started) * [PostgreSQL getting started](https://github.com/netlifeguru/examples/db/postgresql/01_getting_started) * [Scylla getting started](https://github.com/netlifeguru/examples/db/scylla/01_getting_started) Continue with connection configuration and connection pools: * `02_connection` * `03_connection_config` * `04_connection_pools` Then explore the shared select helpers: * `05_select_list` * `06_select_get` * `07_select_get_ptr` * `08_select_value` * `09_select_map` For prepared `db.Query` values, continue with: * `10_query_list` * `11_query-get` * `12_query-get_ptr` * `13_query_value` * `14_query_map` For SQL loaded through `db.DialectSQL`, continue with: * `15_dialect_list` * `16_dialect_get` * `17_dialect_get_ptr` * `18_dialect_value` or `18_dialect_map`, depending on the driver * `19_dialect_map` or `19_dialect_value`, depending on the driver For mutation helpers, see the insert, update, delete, and dialect mutation examples for the selected driver. For lower-level control, continue with the low-level mutation and transaction examples. # Project Information ## Documentation [#documentation] Official package documentation, guides, examples, and integration tutorials are available at: * [https://netlife.guru/docs/go/db](https://netlife.guru/docs/go/db) API reference is available on pkg.go.dev: * [https://pkg.go.dev/github.com/netlifeguru/db](https://pkg.go.dev/github.com/netlifeguru/db) Source code and issue tracking: * [https://github.com/netlifeguru/db](https://github.com/netlifeguru/db) Official examples are available at: * [https://github.com/netlifeguru/examples/db](https://github.com/netlifeguru/examples/db) *** ## Related Packages [#related-packages] The `db` package is a shared database layer and is normally used through one of the supported driver packages: * [https://github.com/netlifeguru/db-mysql](https://github.com/netlifeguru/db-mysql) * [https://github.com/netlifeguru/db-postgres](https://github.com/netlifeguru/db-postgres) * [https://github.com/netlifeguru/db-scylla](https://github.com/netlifeguru/db-scylla) The drivers also use the mapper package for result mapping: * [https://github.com/netlifeguru/mapper](https://github.com/netlifeguru/mapper) *** ## Versioning [#versioning] This project follows Semantic Versioning. See [`CHANGELOG.md`](https://github.com/netlifeguru/db/blob/main/CHANGELOG.md) for release history, version updates, and breaking changes. *** ## Contributing [#contributing] Community contributions, discussions, bug reports, and pull requests are welcome. Please read [`CONTRIBUTING.md`](https://github.com/netlifeguru/db/blob/main/CONTRIBUTING.md) before submitting pull requests or opening issues. *** ## Code of Conduct [#code-of-conduct] This project follows the Contributor Covenant Code of Conduct. Please read [`CODE_OF_CONDUCT.md`](https://github.com/netlifeguru/db/blob/main/CODE_OF_CONDUCT.md) before participating in discussions or contributing to the project. *** ## Author [#author] Created and maintained by NetLife Guru s.r.o. Resources: * Documentation: [https://netlife.guru/docs](https://netlife.guru/docs) * GitHub: [https://github.com/netlifeguru](https://github.com/netlifeguru) * Contact: [info@netlife.guru](mailto:info@netlife.guru) *** ## License [#license] This project is licensed under the MIT License. See [`LICENSE`](https://github.com/netlifeguru/db/blob/main/LICENSE) for full license information. # Delete Use `db.Delete` when you want to execute a delete statement. `Delete` is a semantic wrapper around `Exec`. It does not generate SQL, inspect the statement, or validate that the statement is a `DELETE`. It only makes application code easier to read. ```go result, err := db.Delete(ctx, conn, deleteUserQuery, id) ``` The same `db.Delete` helper can be used with MySQL, PostgreSQL, and Scylla. Only the SQL or CQL syntax and placeholder style differ by driver. ## Overview [#overview] | Driver | Placeholder style | Common usage | | -------- | ----------------- | --------------------------------------- | | MySQL | `?` | delete rows and read `RowsAffected()` | | Postgres | `$1`, `$2` | delete rows and read `RowsAffected()` | | Scylla | `?` | delete rows from query tables using CQL | ## MySQL Delete [#mysql-delete] MySQL uses `?` placeholders. ```go const deleteUserQuery = ` DELETE FROM users WHERE id = ? ` ``` Use `db.Delete`: ```go func DeleteUser(ctx context.Context, conn db.Conn, id int64) (db.Result, error) { return db.Delete(ctx, conn, deleteUserQuery, id) } ``` Usage: ```go result, err := DeleteUser(ctx, conn, 1) if err != nil { return err } fmt.Printf("rows_affected=%d\n", result.RowsAffected()) ``` ## MySQL Dialect Delete [#mysql-dialect-delete] Use `db.DeleteDialect` when the MySQL delete statement is stored in `db.DialectSQL`. The query uses `?` placeholders. ```sql --DeleteUser DELETE FROM users WHERE id = ? ``` ```go func DeleteUser(ctx context.Context, conn db.Conn, queries Queries, id int64) (db.Result, error) { res, err := db.DeleteDialect(ctx, conn, queries.DeleteUser, id) if err != nil { return nil, err } return res, nil } ``` Load the query model before calling the helper. ```go queries, err := LoadQueries(conn) if err != nil { log.Fatal(err) } result, err := DeleteUser(ctx, conn, queries, 28) if err != nil { log.Fatal(err) } fmt.Printf("rows_affected=%d\n", result.RowsAffected()) ``` MySQL returns a `db.Result`, so the caller can inspect `RowsAffected()`. ## PostgreSQL Delete [#postgresql-delete] PostgreSQL uses numbered placeholders. ```go const deleteUserQuery = ` DELETE FROM users WHERE id = $1 ` ``` Use `db.Delete` the same way: ```go func DeleteUser(ctx context.Context, conn db.Conn, id int64) (db.Result, error) { return db.Delete(ctx, conn, deleteUserQuery, id) } ``` Usage: ```go result, err := DeleteUser(ctx, conn, 1) if err != nil { return err } fmt.Printf("rows_affected=%d\n", result.RowsAffected()) ``` ## PostgreSQL Dialect Delete [#postgresql-dialect-delete] Use `db.DeleteDialect` when the PostgreSQL delete statement is stored in `db.DialectSQL`. The query uses numbered placeholders. ```sql --DeleteUser DELETE FROM users WHERE id = $1 ``` ```go func DeleteUser(ctx context.Context, conn db.Conn, queries Queries, id int64) (db.Result, error) { res, err := db.DeleteDialect(ctx, conn, queries.DeleteUser, id) if err != nil { return nil, err } return res, nil } ``` Load the query model before calling the helper. ```go queries, err := LoadQueries(conn) if err != nil { log.Fatal(err) } result, err := DeleteUser(ctx, conn, queries, 28) if err != nil { log.Fatal(err) } fmt.Printf("rows_affected=%d\n", result.RowsAffected()) ``` The Go call is the same as MySQL. Only the SQL placeholder style changes. ## Scylla Delete [#scylla-delete] Scylla uses `?` placeholders. ```go const deleteUserByIDQuery = ` DELETE FROM users_by_id WHERE id = ? ` ``` Use `db.Delete`: ```go func DeleteUserByID(ctx context.Context, conn db.Conn, id string) (db.Result, error) { return db.Delete(ctx, conn, deleteUserByIDQuery, id) } ``` For query-driven Scylla models, the same logical delete may need to be applied to more than one query table. ```go const deleteUserByEmailQuery = ` DELETE FROM users_by_email WHERE email = ? ` func DeleteUserByIDAndEmail( ctx context.Context, conn db.Conn, id string, email string, ) error { if _, err := db.Delete(ctx, conn, deleteUserByIDQuery, id); err != nil { return err } if _, err := db.Delete(ctx, conn, deleteUserByEmailQuery, email); err != nil { return err } return nil } ``` ## Scylla Dialect Delete [#scylla-dialect-delete] Use `db.DeleteDialect` when the Scylla delete statement is stored in `db.DialectSQL`. Scylla uses `?` placeholders. ```sql --DeleteUser DELETE FROM users_by_id WHERE id = ? ``` ```go func DeleteUser(ctx context.Context, conn db.Conn, queries Queries, id string) error { _, err := db.DeleteDialect(ctx, conn, queries.DeleteUser, id) if err != nil { return err } return nil } ``` Load the query model before calling the helper. ```go queries, err := LoadQueries(conn) if err != nil { log.Fatal(err) } err = DeleteUser( ctx, conn, queries, "01efebf6-64d4-11f1-9b56-4ac3b511b961", ) if err != nil { log.Fatal(err) } ``` Scylla does not return a useful affected-row count. For Scylla, treat a successful call as a successful statement execution and do not check `RowsAffected()`. For query-driven Scylla models, the same logical delete may need to be executed against multiple query tables. ## RowsAffected [#rowsaffected] `db.Delete` returns `db.Result`. ```go type Result interface { RowsAffected() int64 LastInsertId() int64 } ``` Use `RowsAffected` to inspect how many rows were removed. ```go result, err := db.Delete(ctx, conn, deleteUserQuery, id) if err != nil { return err } fmt.Println(result.RowsAffected()) ``` `LastInsertId` is not relevant for delete statements. ## Delete vs Exec [#delete-vs-exec] `Delete` is equivalent to `Exec`. ```go result, err := db.Delete(ctx, conn, query, args...) ``` is equivalent to: ```go result, err := db.Exec(ctx, conn, query, args...) ``` Use `Delete` when you want the code to communicate delete intent. Use `Exec` when the statement is generic. ## Dialect Delete [#dialect-delete] Use `db.DeleteDialect` when delete statements are stored in `db.DialectSQL`. This does not replace `db.Delete`. Use `db.Delete` when you pass a direct SQL or CQL query string. Use `db.DeleteDialect` when you pass a `db.DialectSQL` value loaded from query models. ```go type Queries struct { DeleteUser db.DialectSQL `json:"DeleteUser"` } ``` Load the query model before calling dialect helpers. ```go queries, err := LoadQueries(conn) if err != nil { log.Fatal(err) } ``` See [Loading Queries From SQL Files](/docs/go/db/querying/dialect-sql/overview#loading-queries-from-sql-files) for the complete query-loading setup. | Driver | Direct query helper | Dialect helper | Result handling | | ---------- | ------------------- | ------------------ | ------------------------ | | MySQL | `db.Delete` | `db.DeleteDialect` | inspect `db.Result` | | PostgreSQL | `db.Delete` | `db.DeleteDialect` | inspect `db.Result` | | Scylla | `db.Delete` | `db.DeleteDialect` | ignore the result object | MySQL and PostgreSQL return a `db.Result`, so the caller can inspect `RowsAffected()`. Scylla does not return a useful affected-row count, so its result is ignored. ```go _, err := db.DeleteDialect(ctx, conn, queries.DeleteUser, id) ``` ## When to Use Delete [#when-to-use-delete] Use `db.Delete` when: * the operation removes rows * you want readable repository code * the statement does not return rows * you want access to `RowsAffected` * the operation should communicate delete intent ## When Not to Use Delete [#when-not-to-use-delete] Do not use `db.Delete` for statements that return rows. For PostgreSQL statements using `RETURNING`, use `db.Get`, `db.Value`, or query-based helpers instead. Example: ```go deletedID, found, err := db.Value[int64](ctx, conn, ` DELETE FROM users WHERE id = $1 RETURNING id `, id) ``` Use `db.Exec` when the statement is not clearly an insert, update, or delete. ## Recommended Style [#recommended-style] Define the query as a constant: ```go const deleteUserQuery = ` DELETE FROM users WHERE id = ? ` ``` Wrap it in a small function: ```go func DeleteUser(ctx context.Context, conn db.Conn, id int64) (db.Result, error) { return db.Delete(ctx, conn, deleteUserQuery, id) } ``` Use the returned result when you need to inspect affected rows: ```go result, err := DeleteUser(ctx, conn, id) if err != nil { return err } if result.RowsAffected() == 0 { return errors.New("user was not deleted") } ``` # Exec The `db` package provides execution helpers for statements that do not return rows. Use these helpers for inserts, updates, deletes, schema changes, maintenance queries, and other SQL or CQL statements where the result is represented by `db.Result`. ## Overview [#overview] | Function | Purpose | | ----------- | ----------------------------------- | | `Exec` | Execute a raw SQL or CQL statement | | `ExecQuery` | Execute a prepared `db.Query` value | | `Insert` | Execute an insert statement | | `Update` | Execute an update statement | | `Delete` | Execute a delete statement | `Insert`, `Update`, and `Delete` are semantic wrappers around `Exec`. They do not inspect, validate, or rewrite SQL. They exist to make application code easier to read. ## Exec [#exec] Use `Exec` when you want to execute a raw statement with arguments. ```go result, err := db.Exec(ctx, conn, ` UPDATE users SET active = ? WHERE id = ? `, active, id) if err != nil { return err } ``` `Exec` creates a `db.Query` internally and passes it to `ExecQuery`. Conceptually, this: ```go result, err := db.Exec(ctx, conn, query, args...) ``` is equivalent to: ```go q, err := db.Raw(query, args...) if err != nil { return nil, err } result, err := db.ExecQuery(ctx, conn, q) ``` Use `Exec` when the operation is generic or when you do not want to label it as insert, update, or delete. ## ExecQuery [#execquery] Use `ExecQuery` when you already have a `db.Query`. ```go q, err := db.Raw(` UPDATE users SET active = ? WHERE id = ? `, active, id) if err != nil { return err } result, err := db.ExecQuery(ctx, conn, q) if err != nil { return err } ``` This is useful when queries are built or selected before execution. For example: ```go q, err := db.Dialect(conn, queries.UpdateUserStatus, active, id) if err != nil { return err } result, err := db.ExecQuery(ctx, conn, q) if err != nil { return err } ``` ## Semantic Helpers [#semantic-helpers] `Insert`, `Update`, and `Delete` call `Exec` internally. ```go func Insert(ctx context.Context, c Execer, query string, args ...any) (Result, error) { return Exec(ctx, c, query, args...) } func Update(ctx context.Context, c Execer, query string, args ...any) (Result, error) { return Exec(ctx, c, query, args...) } func Delete(ctx context.Context, c Execer, query string, args ...any) (Result, error) { return Exec(ctx, c, query, args...) } ``` These helpers are intentionally simple. They do not make the package an ORM. They only communicate intent in repository or service code. ## Insert [#insert] Use `Insert` when the statement creates new data. ```go result, err := db.Insert(ctx, conn, ` INSERT INTO users (name, email, active) VALUES (?, ?, ?) `, name, email, active) if err != nil { return err } ``` For MySQL, the returned result can expose `LastInsertId`. ```go fmt.Println(result.LastInsertId()) fmt.Println(result.RowsAffected()) ``` PostgreSQL often uses `RETURNING`, so when you need the generated ID, `db.Value` is usually a better fit. ```go id, found, err := db.Value[int64](ctx, conn, ` INSERT INTO users (name, email, active) VALUES ($1, $2, $3) RETURNING id `, name, email, active) ``` Scylla often generates IDs in application code and writes them explicitly. ## Update [#update] Use `Update` when the statement modifies existing data. ```go result, err := db.Update(ctx, conn, ` UPDATE users SET active = ? WHERE id = ? `, active, id) if err != nil { return err } fmt.Println(result.RowsAffected()) ``` `Update` does not validate that the SQL statement is an `UPDATE`. It is a readable wrapper around `Exec`. ## Delete [#delete] Use `Delete` when the statement removes data. ```go result, err := db.Delete(ctx, conn, ` DELETE FROM users WHERE id = ? `, id) if err != nil { return err } fmt.Println(result.RowsAffected()) ``` `Delete` does not validate that the SQL statement is a `DELETE`. It is a readable wrapper around `Exec`. ## Result [#result] Execution helpers return `db.Result`. ```go type Result interface { RowsAffected() int64 LastInsertId() int64 } ``` Use `RowsAffected` to inspect how many rows were changed. ```go affected := result.RowsAffected() ``` Use `LastInsertId` when the selected driver supports it. ```go id := result.LastInsertId() ``` Driver behavior differs: | Driver | `RowsAffected` | `LastInsertId` | | -------- | --------------: | -------------------------------------------: | | MySQL | supported | supported | | Postgres | supported | usually `0`; prefer `RETURNING` with `Value` | | Scylla | driver-specific | usually not used | ## Placeholder Style [#placeholder-style] Execution helpers do not rewrite placeholders. Use the placeholder style required by the selected driver. | Driver | Placeholder style | | -------- | ----------------- | | MySQL | `?` | | Postgres | `$1`, `$2`, `$3` | | Scylla | `?` | MySQL example: ```go result, err := db.Update(ctx, conn, ` UPDATE users SET active = ? WHERE id = ? `, active, id) ``` PostgreSQL example: ```go result, err := db.Update(ctx, conn, ` UPDATE users SET active = $1 WHERE id = $2 `, active, id) ``` Scylla example: ```go result, err := db.Update(ctx, conn, ` UPDATE users_by_id SET active = ? WHERE id = ? `, active, id) ``` ## When to Use Each Helper [#when-to-use-each-helper] | Need | Use | | ---------------------------------- | ----------------------- | | Execute any statement | `Exec` | | Execute an existing `db.Query` | `ExecQuery` | | Communicate insert intent | `Insert` | | Communicate update intent | `Update` | | Communicate delete intent | `Delete` | | Return a value from a statement | `Value` | | Execute dialect-selected statement | `Dialect` + `ExecQuery` | ## Recommended Style [#recommended-style] Use semantic helpers when the operation is clear. ```go result, err := db.Insert(ctx, conn, insertUserQuery, name, email, active) ``` ```go result, err := db.Update(ctx, conn, updateUserQuery, active, id) ``` ```go result, err := db.Delete(ctx, conn, deleteUserQuery, id) ``` Use `Exec` for generic statements. ```go result, err := db.Exec(ctx, conn, query, args...) ``` Use `ExecQuery` when working with `db.Query`. ```go q, err := db.Raw(query, args...) if err != nil { return err } result, err := db.ExecQuery(ctx, conn, q) ``` ## Not an ORM [#not-an-orm] These helpers do not generate SQL and do not know what your statement does. They are small execution helpers over explicit SQL or CQL. You still control: * the statement text * placeholders * arguments * transaction boundaries * driver-specific behavior * schema and data model design # Insert Use `db.Insert` when you want to execute an insert statement. `Insert` is a semantic wrapper around `Exec`. It does not generate SQL, inspect the statement, or behave like an ORM. It only makes application code easier to read. ```go result, err := db.Insert(ctx, conn, insertUserQuery, name, email, active) ``` Insert behavior differs between database engines. The shared `db` API stays the same, but generated IDs, placeholders, and data modeling patterns are driver-specific. ## Overview [#overview] | Driver | Common pattern | | -------- | ------------------------------------------------------------- | | MySQL | `db.Insert` and read `result.LastInsertId()` | | Postgres | `INSERT ... RETURNING id` and read the ID with `db.Value` | | Scylla | Generate IDs in application code and insert into query tables | ## Dialect Insert [#dialect-insert] Use dialect insert helpers when insert SQL is stored in `db.DialectSQL`. This does not replace `db.Insert`. Use `db.Insert` when you pass a direct SQL or CQL query string. Use dialect helpers when you pass a `db.DialectSQL` value loaded from query models. ```go type Queries struct { InsertUser db.DialectSQL `json:"InsertUser"` } ``` The correct helper depends on the active driver and on whether the insert statement returns a value. | Driver | Helper | SQL pattern | Result handling | | ---------- | ------------------------ | ----------------------- | ----------------- | | MySQL | `db.InsertDialect` | `INSERT ... VALUES ...` | check `db.Result` | | PostgreSQL | `db.InsertReturnDialect` | `INSERT ... RETURNING` | check `db.Result` | | Scylla | `db.InsertDialect` | `INSERT ... VALUES ...` | ignore result | `db.InsertDialect` and `db.InsertReturnDialect` are not interchangeable. Use `db.InsertReturnDialect` only when the selected query returns a value, such as PostgreSQL `RETURNING`. ## MySQL Insert [#mysql-insert] MySQL commonly returns the generated auto-increment ID through `LastInsertId`. ```go const insertUserQuery = ` INSERT INTO users (name, email, active) VALUES (?, ?, ?) ` ``` Use `db.Insert`: ```go func InsertUser(ctx context.Context, conn db.Conn, name string, email string, active bool) (db.Result, error) { return db.Insert(ctx, conn, insertUserQuery, name, email, active) } ``` Usage: ```go result, err := InsertUser(ctx, conn, "Jane Doe", "jane.doe@example.com", true) if err != nil { return err } fmt.Printf( "inserted user id=%d rows_affected=%d\n", result.LastInsertId(), result.RowsAffected(), ) ``` MySQL uses `?` placeholders. ## MySQL Insert Dialect [#mysql-insert-dialect] For MySQL, use `db.InsertDialect`. The query uses `?` placeholders. ```sql --InsertUser INSERT INTO users (name, email, active) VALUES (?, ?, ?) ``` ```go func InsertUser(ctx context.Context, conn db.Conn, queries Queries, name string, email string, active bool) (db.Result, error) { result, err := db.InsertDialect(ctx, conn, queries.InsertUser, name, email, active) if err != nil { return nil, err } if result.RowsAffected() != 1 { return nil, fmt.Errorf("expected 1 rows affected, got %d", result.RowsAffected()) } return result, nil } ``` The returned `db.Result` can be used to inspect the insert result. ```go result, err := InsertUser(ctx, conn, queries, "Jane Doe", "jane.doe@example.com", true) if err != nil { log.Fatal(err) } fmt.Printf( "inserted user id=%d rows_affected=%d\n", result.LastInsertId(), result.RowsAffected(), ) ``` ## PostgreSQL Insert With RETURNING [#postgresql-insert-with-returning] PostgreSQL commonly uses `RETURNING` when you need the generated ID. ```go const insertUserQuery = ` INSERT INTO users (name, email, active) VALUES ($1, $2, $3) RETURNING id ` ``` Use `db.Value` to read the returned ID. ```go func InsertUser(ctx context.Context, conn db.Conn, name string, email string, active bool) (db.Result, error) { return db.Insert(ctx, conn, insertUserQuery, name, email, active) } ``` Usage: ```go result, err := InsertUser(ctx, conn, "Alice Doe", "alice.doe@example.com", true) if err != nil { log.Fatal(err) } fmt.Printf("inserted user id=%d rows_affected=%d\n", result.LastInsertId(), result.RowsAffected()) ``` PostgreSQL uses numbered placeholders such as `$1`, `$2`, and `$3`. ## PostgreSQL Insert Without RETURNING [#postgresql-insert-without-returning] If you do not need a generated value, you can also use `db.Insert`. ```go result, err := db.Insert(ctx, conn, ` INSERT INTO audit_logs (message) VALUES ($1) `, message) if err != nil { return err } fmt.Println(result.RowsAffected()) ``` For generated IDs, prefer `RETURNING` with `db.Value`. ## PostgreSQL Insert Dialect [#postgresql-insert-dialect] For PostgreSQL inserts that use `RETURNING`, use `db.InsertReturnDialect`. The query uses numbered placeholders and must include `RETURNING`. ```sql --InsertUser INSERT INTO users (name, email, active) VALUES ($1, $2, $3) RETURNING id ``` ```go func InsertUser(ctx context.Context, conn db.Conn, queries Queries, name string, email string, active bool) (db.Result, error) { result, err := db.InsertReturnDialect(ctx, conn, queries.InsertUser, name, email, active) if err != nil { return nil, err } if result.RowsAffected() != 1 { return nil, fmt.Errorf("expected 1 rows affected, got %d", result.RowsAffected()) } return result, nil } ``` Do not use `db.InsertDialect` for this PostgreSQL statement. `INSERT ... RETURNING` returns data, so the PostgreSQL dialect helper is `db.InsertReturnDialect`. ## Scylla Insert [#scylla-insert] Scylla data models are often query-driven. A single logical entity may be written into multiple query tables. For example: ```go const insertUserByIDQuery = ` INSERT INTO users_by_id (id, email, name, active, created_at) VALUES (?, ?, ?, ?, ?) ` const insertUserByEmailQuery = ` INSERT INTO users_by_email (email, id, name, active, created_at) VALUES (?, ?, ?, ?, ?) ` ``` Generate the ID in application code and write both query tables. ```go func InsertUser(ctx context.Context, conn db.Conn, name string, email string, active bool) (string, error) { id := gocql.TimeUUID() createdAt := time.Now().UTC() if _, err := db.Insert(ctx, conn, insertUserByIDQuery, id, email, name, active, createdAt); err != nil { return "", err } if _, err := db.Insert(ctx, conn, insertUserByEmailQuery, email, id, name, active, createdAt); err != nil { return "", err } return id.String(), nil } ``` Scylla uses `?` placeholders. This pattern makes reads efficient by writing data into the tables required by your queries. ## Scylla Insert Dialect [#scylla-insert-dialect] For Scylla, use `db.InsertDialect`, but generate IDs in application code. Scylla inserts do not return a `db.Result` that should be inspected. ```sql --InsertUser INSERT INTO users_by_id (id, email, name, active, created_at) VALUES (?, ?, ?, ?, ?) ``` ```go func InsertUser(ctx context.Context, conn db.Conn, queries Queries, email string, name string, active bool) (string, error) { id := gocql.TimeUUID() createdAt := time.Now().UTC() _, err := db.InsertDialect(ctx, conn, queries.InsertUser, id, email, name, active, createdAt) if err != nil { return "", err } return id.String(), nil } ``` For Scylla, return the generated ID from application code. Do not check `RowsAffected()` for Scylla inserts. ## Scylla Batches [#scylla-batches] If multiple writes must be grouped, the Scylla driver also supports batches. Use batches carefully and only when the data model requires grouped writes. Batch behavior is documented in the Scylla Batches guide. ## Insert vs Exec [#insert-vs-exec] `Insert` is equivalent to `Exec`. ```go result, err := db.Insert(ctx, conn, query, args...) ``` is equivalent to: ```go result, err := db.Exec(ctx, conn, query, args...) ``` Use `Insert` when you want the code to communicate insert intent. Use `Exec` when the statement is generic. ## Returning Values From Inserts [#returning-values-from-inserts] Use the API that matches your database behavior. | Need | Direct query helper | Dialect helper | | ----------------------------- | ------------------------------------- | ------------------------ | | MySQL generated ID | `db.Insert` + `result.LastInsertId()` | `db.InsertDialect` | | PostgreSQL generated ID | `INSERT ... RETURNING id` | `db.InsertReturnDialect` | | Scylla generated ID | generate ID in application code | `db.InsertDialect` | | Insert without returned value | `db.Insert` | `db.InsertDialect` | ## Dialect Insert [#dialect-insert-1] For applications that support multiple drivers, keep insert SQL in `db.DialectSQL`. Example: ```go type Queries struct { InsertUser db.DialectSQL `json:"InsertUser"` } ``` Then select the correct query for the active driver: ```go q, err := db.Dialect(conn, queries.InsertUser, name, email, active) if err != nil { return err } result, err := db.ExecQuery(ctx, conn, q) if err != nil { return err } ``` For PostgreSQL inserts that return an ID, use `ValueQuery` instead: ```go q, err := db.Dialect(conn, queries.InsertUser, name, email, active) if err != nil { return 0, err } id, found, err := db.ValueQuery[int64](ctx, conn, q) if err != nil { return 0, err } if !found { return 0, errors.New("insert did not return id") } return id, nil ``` ## Recommended Style [#recommended-style] Use explicit SQL and choose the insert pattern based on the driver. For MySQL: ```go result, err := db.Insert(ctx, conn, insertUserQuery, name, email, active) ``` For PostgreSQL when an ID is needed: ```go result, found, err := db.InsertValue(ctx, conn, insertUserQuery, name, email, active) ``` For Scylla: ```go id := gocql.TimeUUID() _, err := db.Insert(ctx, conn, insertUserByIDQuery, id, email, name, active, createdAt) ``` Keep shared application code on `db.Conn`, but keep SQL behavior explicit. # Update Use `db.Update` when you want to execute an update statement. `Update` is a semantic wrapper around `Exec`. It does not generate SQL, inspect the statement, or validate that the statement is an `UPDATE`. It only makes application code easier to read. ```go result, err := db.Update(ctx, conn, updateUserQuery, active, id) ``` The same `db.Update` helper can be used with MySQL, PostgreSQL, and Scylla. Only the SQL or CQL syntax and placeholder style differ by driver. ## Overview [#overview] | Driver | Placeholder style | Common usage | | -------- | ----------------- | ------------------------------------- | | MySQL | `?` | update rows and read `RowsAffected()` | | Postgres | `$1`, `$2` | update rows and read `RowsAffected()` | | Scylla | `?` | update query tables using CQL | ## MySQL Update [#mysql-update] MySQL uses `?` placeholders. ```go const updateUserQuery = ` UPDATE users SET active = ? WHERE id = ? ` ``` Use `db.Update`: ```go func UpdateUserActive(ctx context.Context, conn db.Conn, id int64, active bool) (db.Result, error) { return db.Update(ctx, conn, updateUserQuery, active, id) } ``` Usage: ```go result, err := UpdateUserActive(ctx, conn, 1, false) if err != nil { return err } fmt.Printf("rows_affected=%d\n", result.RowsAffected()) ``` ## MySQL Dialect Update [#mysql-dialect-update] Use `db.UpdateDialect` when the MySQL update statement is stored in `db.DialectSQL`. The query uses `?` placeholders. ```sql --UpdateUser UPDATE users SET name = ?, email = ?, active = ? WHERE id = ? ``` ```go func UpdateUser(ctx context.Context, conn db.Conn, queries Queries, id int64, name string, email string, active bool) (db.Result, error) { res, err := db.UpdateDialect(ctx, conn, queries.UpdateUser, name, email, active, id) if err != nil { return nil, err } return res, nil } ``` Load the query model before calling the helper. ```go queries, err := LoadQueries(conn) if err != nil { log.Fatal(err) } res, err := UpdateUser(ctx, conn, queries, 1, "Jane Doe", "jane@example.com", false) if err != nil { log.Fatal(err) } fmt.Printf("rows_affected=%d\n", res.RowsAffected()) ``` ## PostgreSQL Update [#postgresql-update] PostgreSQL uses numbered placeholders. ```go const updateUserQuery = ` UPDATE users SET active = $1 WHERE id = $2 ` ``` Use `db.Update` the same way: ```go func UpdateUserActive(ctx context.Context, conn db.Conn, id int64, active bool) (db.Result, error) { return db.Update(ctx, conn, updateUserQuery, active, id) } ``` Usage: ```go result, err := UpdateUserActive(ctx, conn, 1, false) if err != nil { return err } fmt.Printf("rows_affected=%d\n", result.RowsAffected()) ``` ## Postgres Dialect Update [#postgres-dialect-update] Use `db.UpdateDialect` when the PostgreSQL update statement is stored in `db.DialectSQL`. The query uses numbered placeholders. ```sql --UpdateUser UPDATE users SET name = $1, email = $2, active = $3 WHERE id = $4 ``` ```go func UpdateUser(ctx context.Context, conn db.Conn, queries Queries, id int64, name string, email string, active bool) (db.Result, error) { res, err := db.UpdateDialect(ctx, conn, queries.UpdateUser, name, email, active, id) if err != nil { return nil, err } return res, nil } ``` Load the query model before calling the helper. ```go queries, err := LoadQueries(conn) if err != nil { log.Fatal(err) } res, err := UpdateUser(ctx, conn, queries, 1, "Jane Doe", "jane@example.com", false) if err != nil { log.Fatal(err) } fmt.Printf("rows_affected=%d\n", res.RowsAffected()) ``` The Go call is the same as MySQL. Only the SQL placeholder style changes. ## Scylla Update [#scylla-update] Scylla uses `?` placeholders. ```go const updateUserByIDQuery = ` UPDATE users_by_id SET active = ? WHERE id = ? ` ``` Use `db.Update`: ```go func UpdateUserActive(ctx context.Context, conn db.Conn, id string, active bool) (db.Result, error) { return db.Update(ctx, conn, updateUserByIDQuery, active, id) } ``` For query-driven Scylla models, the same logical update may need to be applied to more than one query table. ```go const updateUserByEmailQuery = ` UPDATE users_by_email SET active = ? WHERE email = ? ` func UpdateUserActiveByIDAndEmail( ctx context.Context, conn db.Conn, id string, email string, active bool, ) error { if _, err := db.Update(ctx, conn, updateUserByIDQuery, active, id); err != nil { return err } if _, err := db.Update(ctx, conn, updateUserByEmailQuery, active, email); err != nil { return err } return nil } ``` ## Scylla Dialect Update [#scylla-dialect-update] Use `db.UpdateDialect` when the Scylla update statement is stored in `db.DialectSQL`. Scylla uses `?` placeholders. ```sql --UpdateUser UPDATE users_by_id SET email = ?, active = ? WHERE id = ? ``` ```go func UpdateUser(ctx context.Context, conn db.Conn, queries Queries, id string, email string, active bool) error { _, err := db.UpdateDialect(ctx, conn, queries.UpdateUser, email, active, id) if err != nil { return err } return nil } ``` Load the query model before calling the helper. ```go queries, err := LoadQueries(conn) if err != nil { log.Fatal(err) } err = UpdateUser(ctx, conn, queries, "22222222-2222-2222-2222-222222222222", "jane@example.com", false) if err != nil { log.Fatal(err) } ``` Scylla does not return a useful affected-row count. For Scylla, treat a successful call as a successful statement execution and do not check `RowsAffected()`. ## RowsAffected [#rowsaffected] `db.Update` returns `db.Result`. ```go type Result interface { RowsAffected() int64 LastInsertId() int64 } ``` Use `RowsAffected` to inspect how many rows were changed. ```go result, err := db.Update(ctx, conn, updateUserQuery, active, id) if err != nil { return err } fmt.Println(result.RowsAffected()) ``` `LastInsertId` is usually not relevant for update statements. ## Update vs Exec [#update-vs-exec] `Update` is equivalent to `Exec`. ```go result, err := db.Update(ctx, conn, query, args...) ``` is equivalent to: ```go result, err := db.Exec(ctx, conn, query, args...) ``` Use `Update` when you want the code to communicate update intent. Use `Exec` when the statement is generic. ## Dialect Update [#dialect-update] Use dialect update helpers when update statements are stored in `db.DialectSQL`. This does not replace `db.Update`. Use `db.Update` when you pass a direct SQL or CQL query string. Use `db.UpdateDialect` when you pass a `db.DialectSQL` value loaded from query models. ```go type Queries struct { UpdateUser db.DialectSQL `json:"UpdateUser"` } ``` Load the query model before calling dialect helpers. ```go queries, err := LoadQueries(conn) if err != nil { log.Fatal(err) } ``` See [Loading Queries From SQL Files](/docs/go/db/querying/dialect-sql/overview#loading-queries-from-sql-files) for the full `LoadQueries` setup. | Driver | Direct query helper | Dialect helper | Result handling | | ---------- | ------------------- | ------------------ | ----------------- | | MySQL | `db.Update` | `db.UpdateDialect` | check `db.Result` | | PostgreSQL | `db.Update` | `db.UpdateDialect` | check `db.Result` | | Scylla | `db.Update` | `db.UpdateDialect` | ignore result | MySQL and PostgreSQL return a `db.Result`. Scylla does not return a useful affected-row count, so Scylla examples ignore the result. ## When to Use Update [#when-to-use-update] Use `db.Update` when: * the operation modifies existing rows * you want readable repository code * the statement does not return rows * you want access to `RowsAffected` * the operation should communicate update intent ## When Not to Use Update [#when-not-to-use-update] Do not use `db.Update` for statements that return rows. For PostgreSQL statements using `RETURNING`, use `db.Get`, `db.Value`, or query-based helpers instead. Example: ```go updatedAt, found, err := db.Value[time.Time](ctx, conn, ` UPDATE users SET active = $1 WHERE id = $2 RETURNING updated_at `, active, id) ``` Use `db.Exec` when the statement is not clearly an insert, update, or delete. ## Recommended Style [#recommended-style] Define the query as a constant: ```go const updateUserQuery = ` UPDATE users SET active = ? WHERE id = ? ` ``` Wrap it in a small function: ```go func UpdateUserActive(ctx context.Context, conn db.Conn, id int64, active bool) (db.Result, error) { return db.Update(ctx, conn, updateUserQuery, active, id) } ``` Use the returned result when you need to inspect affected rows: ```go result, err := UpdateUserActive(ctx, conn, id, false) if err != nil { return err } if result.RowsAffected() == 0 { return errors.New("user was not updated") } ``` # Boolean Rules Boolean rules validate typed `bool` fields defined with `form.Bool`. They are useful for fields such as terms acceptance, feature flags, admin switches, visibility settings, consent checkboxes, and other true/false application state. ## TL;DR [#tldr] | Rule | Description | | ------------------------------------------------- | ------------------------------------------------------------------------- | | `rules.IsTrue(field)` | Requires the boolean value to be `true` | | `rules.IsTrueWithCode(field, code)` | Requires the value to be `true` and returns a custom error code | | `rules.IsFalse(field)` | Requires the boolean value to be `false` | | `rules.IsFalseWithCode(field, code)` | Requires the value to be `false` and returns a custom error code | | `rules.BoolEquals(field, expected)` | Requires the boolean value to match `expected` | | `rules.BoolEqualsWithCode(field, expected, code)` | Requires the value to match `expected` and returns a custom error code | | `rules.IsBool(field)` | No-op for Go typed inputs; JSON type errors are handled before validation | ## Defining Boolean Fields [#defining-boolean-fields] Boolean validation starts by defining typed boolean fields. ```go BoolForm := struct { TermsAccepted form.BoolField[BoolRulesRequest] Admin form.BoolField[BoolRulesRequest] }{ TermsAccepted: form.Bool[BoolRulesRequest]("terms_accepted", func(r *BoolRulesRequest) bool { return r.TermsAccepted }), Admin: form.Bool[BoolRulesRequest]("admin", func(r *BoolRulesRequest) bool { return r.Admin }), } ``` Each field contains the response field name and a typed accessor function. ## Applying Boolean Rules [#applying-boolean-rules] Boolean rules are then attached to the field references. ```go return form.Schema[BoolRulesRequest]{ rules.IsTrue(BoolForm.TermsAccepted), rules.IsFalse(BoolForm.Admin), } ``` This keeps validation explicit and type-safe. ## Rule Examples [#rule-examples] ### IsTrue [#istrue] Requires the boolean field to be `true`. ```go rules.IsTrue(BoolForm.TermsAccepted) ``` Typical use cases: * terms acceptance * GDPR consent * required confirmation flags *** ### IsTrueWithCode [#istruewithcode] Requires the boolean field to be `true` and returns a custom validation code. ```go const CodeMustBeAccepted = form.Code("must_be_accepted") rules.IsTrueWithCode( BoolForm.TermsAccepted, CodeMustBeAccepted, ) ``` Useful when APIs require stable frontend-facing validation codes. *** ### IsFalse [#isfalse] Requires the boolean field to be `false`. ```go rules.IsFalse(BoolForm.Admin) ``` Typical use cases: * disabled feature flags * restricted admin fields * forbidden public settings *** ### IsFalseWithCode [#isfalsewithcode] Requires the boolean field to be `false` and returns a custom validation code. ```go const CodeMustBeDisabled = form.Code("must_be_disabled") rules.IsFalseWithCode( BoolForm.PublicProfile, CodeMustBeDisabled, ) ``` *** ### BoolEquals [#boolequals] Requires the field value to match the expected boolean value. ```go rules.BoolEquals(BoolForm.Enabled, true) ``` This is useful when the expected value is dynamic or configurable. *** ### BoolEqualsWithCode [#boolequalswithcode] Requires the field value to match the expected value and returns a custom validation code. ```go rules.BoolEqualsWithCode( BoolForm.Enabled, true, CodeMustBeEnabled, ) ``` *** ### IsBool [#isbool] Checks whether the field contains a boolean value. ```go rules.IsBool(BoolForm.Enabled) ``` For typed Go structures this rule is typically unnecessary because JSON decoding already validates the input type before schema validation begins. *** ## Custom Error Codes [#custom-error-codes] Use `WithCode` variants when your API needs stable error codes. ```go const CodeMustBeAccepted = form.Code("must_be_accepted") rules.IsTrueWithCode(BoolForm.TermsAccepted, CodeMustBeAccepted) ``` Custom codes are useful for frontend translations, API contracts, and consistent validation responses. ## Notes [#notes] * Boolean rules work on Go `bool` values. * `IsBool` exists for API symmetry, but it is a no-op for typed Go inputs. * Invalid JSON types are handled during request decoding before schema validation runs. * Use `BoolEquals` when the expected value is dynamic or when the rule should read more explicitly. * Use `IsTrue` and `IsFalse` for common intent-based validation such as required consent or disabled flags. # Compare Rules Compare rules validate relationships between two fields. They are useful when one field depends on another field or when values must match specific relational constraints. ## Common Use Cases [#common-use-cases] Compare rules are commonly used for: * password confirmation * start and end date validation * minimum and maximum values * pricing constraints * score comparison * quantity validation * numeric ranges * matching identifiers * workflow transitions * ordered application state ## TL;DR [#tldr] | Rule | Description | | --------------------------------------- | ------------------------------------------ | | `rules.Compare(a, b, rules.OpEQ)` | Requires `a == b` | | `rules.Compare(a, b, rules.OpNE)` | Requires `a != b` | | `rules.Compare(a, b, rules.OpLT)` | Requires `a < b` | | `rules.Compare(a, b, rules.OpLTE)` | Requires `a <= b` | | `rules.Compare(a, b, rules.OpGT)` | Requires `a > b` | | `rules.Compare(a, b, rules.OpGTE)` | Requires `a >= b` | | `rules.CompareWithCode(a, b, op, code)` | Same as `Compare` with a custom error code | ## Supported Types [#supported-types] Compare rules support ordered comparable values: * strings * integers * unsigned integers * float32 / float64 This allows cross-field validation across common application data types. ## Comparison Operators [#comparison-operators] | Operator | Description | | ------------- | --------------------- | | `rules.OpEQ` | Equal | | `rules.OpNE` | Not equal | | `rules.OpLT` | Less than | | `rules.OpLTE` | Less than or equal | | `rules.OpGT` | Greater than | | `rules.OpGTE` | Greater than or equal | ## Defining Comparable Fields [#defining-comparable-fields] Compare validation starts by defining typed fields. ```go CompareForm := struct { Password form.StringField[CompareRequest] ConfirmPassword form.StringField[CompareRequest] MinPrice form.IntField[CompareRequest] MaxPrice form.IntField[CompareRequest] }{ Password: form.Str[CompareRequest]("password", func(r *CompareRequest) string { return r.Password }), ConfirmPassword: form.Str[CompareRequest]("confirm_password", func(r *CompareRequest) string { return r.ConfirmPassword }), MinPrice: form.Int[CompareRequest]("min_price", func(r *CompareRequest) int { return r.MinPrice }), MaxPrice: form.Int[CompareRequest]("max_price", func(r *CompareRequest) int { return r.MaxPrice }), } ``` Compare rules operate on the underlying field definitions: ```go CompareForm.Password.Field ``` This allows reusable cross-field validation logic. ## Applying Compare Rules [#applying-compare-rules] Compare rules validate relationships between two field values. ```go return form.Schema[CompareRequest]{ rules.Compare( CompareForm.Password.Field, CompareForm.ConfirmPassword.Field, rules.OpEQ, ), rules.Compare( CompareForm.MinPrice.Field, CompareForm.MaxPrice.Field, rules.OpLTE, ), } ``` ## Rule Examples [#rule-examples] ### Equal (OpEQ) [#equal-opeq] Requires both values to be equal. ```go rules.Compare( CompareForm.Password.Field, CompareForm.ConfirmPassword.Field, rules.OpEQ, ) ``` Typical use cases: * password confirmation * repeated email fields * matching identifiers * confirmation workflows *** ### Not Equal (OpNE) [#not-equal-opne] Requires both values to be different. ```go rules.Compare( CompareForm.CurrentPassword.Field, CompareForm.NewPassword.Field, rules.OpNE, ) ``` Typical use cases: * password changes * unique configuration values * preventing duplicated state *** ### Less Than (OpLT) [#less-than-oplt] Requires the first value to be less than the second value. ```go rules.Compare( CompareForm.MinPrice.Field, CompareForm.MaxPrice.Field, rules.OpLT, ) ``` Typical use cases: * price ranges * pagination windows * scoring systems * numeric boundaries *** ### Less Than or Equal (OpLTE) [#less-than-or-equal-oplte] Requires the first value to be less than or equal to the second value. ```go rules.Compare( CompareForm.MinPrice.Field, CompareForm.MaxPrice.Field, rules.OpLTE, ) ``` Useful for inclusive ranges and bounded validation. *** ### Greater Than (OpGT) [#greater-than-opgt] Requires the first value to be greater than the second value. ```go rules.Compare( CompareForm.MaxPrice.Field, CompareForm.MinPrice.Field, rules.OpGT, ) ``` Typical use cases: * increasing values * score progression * upper boundary validation *** ### Greater Than or Equal (OpGTE) [#greater-than-or-equal-opgte] Requires the first value to be greater than or equal to the second value. ```go rules.Compare( CompareForm.MaxPrice.Field, CompareForm.MinPrice.Field, rules.OpGTE, ) ``` Useful for inclusive upper bounds. *** ### CompareWithCode [#comparewithcode] Adds a custom validation code to compare validation failures. ```go const CodePasswordsMismatch = form.Code("passwords_mismatch") rules.CompareWithCode( CompareForm.Password.Field, CompareForm.ConfirmPassword.Field, rules.OpEQ, CodePasswordsMismatch, ) ``` Custom error codes are useful for frontend validation contracts and standardized API responses. ## Schema Example [#schema-example] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/rules" ) const ( CodePasswordsMismatch = form.Code("passwords_mismatch") CodeInvalidRange = form.Code("invalid_range") ) type CompareRequest struct { Password string `json:"password"` ConfirmPassword string `json:"confirm_password"` MinPrice int `json:"min_price"` MaxPrice int `json:"max_price"` } func CompareSchema() form.Schema[CompareRequest] { CompareForm := struct { Password form.StringField[CompareRequest] ConfirmPassword form.StringField[CompareRequest] MinPrice form.IntField[CompareRequest] MaxPrice form.IntField[CompareRequest] }{ Password: form.Str[CompareRequest]("password", func(r *CompareRequest) string { return r.Password }), ConfirmPassword: form.Str[CompareRequest]("confirm_password", func(r *CompareRequest) string { return r.ConfirmPassword }), MinPrice: form.Int[CompareRequest]("min_price", func(r *CompareRequest) int { return r.MinPrice }), MaxPrice: form.Int[CompareRequest]("max_price", func(r *CompareRequest) int { return r.MaxPrice }), } return form.Schema[CompareRequest]{ rules.CompareWithCode( CompareForm.Password.Field, CompareForm.ConfirmPassword.Field, rules.OpEQ, CodePasswordsMismatch, ), rules.CompareWithCode( CompareForm.MinPrice.Field, CompareForm.MaxPrice.Field, rules.OpLTE, CodeInvalidRange, ), } } ``` ## HTTP Example [#http-example] ### main.go [#maingo] ```go package main import ( "encoding/json" "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/form" "github.com/netlifeguru/form/httpform" "github.com/netlifeguru/router" ) func main() { r := router.New() r.HandleFunc("/compare-rules", "POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { var in CompareRequest if !httpform.BindAndValidate(w, req, &in, CompareSchema(), 1<<20) { fmt.Println("compare validation failed") return } fmt.Println("compare validation passed:", in) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "message": "compare validation passed", "data": in, }) }) validPayload := map[string]any{ "password": "secret123", "confirm_password": "secret123", "min_price": 10, "max_price": 100, } invalidPayload := map[string]any{ "password": "secret123", "confirm_password": "secret321", "min_price": 100, "max_price": 10, } fmt.Println("\n--- Valid request ---") form.SendTestPost(":8080/compare-rules", validPayload) fmt.Println("\n--- Invalid request ---") form.SendTestPost(":8080/compare-rules", invalidPayload) if err := r.ListenAndServe(8080); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` ## Notes [#notes] * Compare rules validate relationships between two field values. * Validation errors are attached to the first field passed into the comparison. * Compare rules work with ordered comparable values. * Compare validation is useful for cross-field consistency and bounded ranges. * Unknown comparison operators always fail validation. * Custom validation codes are recommended for frontend-facing APIs. * Compare validation remains explicit and independent from HTTP or JSON transport layers. # Float64 Rules Float64 rules validate typed decimal number fields defined with `form.Float64`. They are useful for validating prices, percentages, ratings, measurements, coordinates, billing values, financial calculations, limits, and other decimal-based application data. ## Common Use Cases [#common-use-cases] Float64 rules are commonly used for: * product pricing * percentages and ratios * latitude and longitude coordinates * invoice totals * payment amounts * tax calculations * rating systems * decimal-based API values * financial validation * measurement systems ## TL;DR [#tldr] | Rule | Description | | ----------------------------------------------------- | ----------------------------------------------------- | | `rules.MinFloat64(field, n)` | Requires the value to be greater than or equal to `n` | | `rules.MinFloat64WithCode(field, n, code)` | Same as `MinFloat64` with a custom error code | | `rules.MaxFloat64(field, n)` | Requires the value to be less than or equal to `n` | | `rules.MaxFloat64WithCode(field, n, code)` | Same as `MaxFloat64` with a custom error code | | `rules.BetweenFloat64(field, min, max)` | Requires the value to be within the specified range | | `rules.BetweenFloat64WithCode(field, min, max, code)` | Same as `BetweenFloat64` with a custom error code | | `rules.Float64Equals(field, expected)` | Requires the value to match the expected float64 | | `rules.Float64EqualsWithCode(field, expected, code)` | Same as `Float64Equals` with a custom error code | | `rules.IsFloat64(field)` | Validates that the field contains a float64 value | ## Defining Float64 Fields [#defining-float64-fields] Float64 validation starts by defining typed float64 fields. ```go FloatForm := struct { Price form.Float64Field[FloatRulesRequest] Discount form.Float64Field[FloatRulesRequest] Temperature form.Float64Field[FloatRulesRequest] }{ Price: form.Float64[FloatRulesRequest]("price", func(r *FloatRulesRequest) float64 { return r.Price }), Discount: form.Float64[FloatRulesRequest]("discount", func(r *FloatRulesRequest) float64 { return r.Discount }), Temperature: form.Float64[FloatRulesRequest]("temperature", func(r *FloatRulesRequest) float64 { return r.Temperature }), } ``` Each field contains: * the validation response field name * typed field accessors * reusable schema references * strongly typed value access ## Applying Float64 Rules [#applying-float64-rules] Float64 rules are attached directly to typed field references. ```go return form.Schema[FloatRulesRequest]{ rules.MinFloat64(FloatForm.Price, 0), rules.MaxFloat64(FloatForm.Discount, 100), rules.BetweenFloat64(FloatForm.Temperature, -50, 100), } ``` This keeps validation logic: * explicit * reusable * type-safe * composable * independent from transport layers ## Rule Examples [#rule-examples] ### MinFloat64 [#minfloat64] Requires the float64 value to be greater than or equal to the provided minimum. ```go rules.MinFloat64(FloatForm.Price, 0) ``` Typical use cases: * positive prices * non-negative totals * minimum percentages * valid measurements *** ### MinFloat64WithCode [#minfloat64withcode] Requires the value to be greater than or equal to the minimum and returns a custom validation code. ```go const CodePriceTooLow = form.Code("price_too_low") rules.MinFloat64WithCode( FloatForm.Price, 0, CodePriceTooLow, ) ``` Useful for frontend-friendly API error contracts. *** ### MaxFloat64 [#maxfloat64] Requires the float64 value to be less than or equal to the provided maximum. ```go rules.MaxFloat64(FloatForm.Discount, 100) ``` Typical use cases: * percentage caps * maximum limits * rating boundaries * financial constraints *** ### MaxFloat64WithCode [#maxfloat64withcode] Requires the value to be below the provided maximum and returns a custom validation code. ```go const CodeDiscountTooHigh = form.Code("discount_too_high") rules.MaxFloat64WithCode( FloatForm.Discount, 100, CodeDiscountTooHigh, ) ``` *** ### BetweenFloat64 [#betweenfloat64] Requires the float64 value to stay within a specific range. ```go rules.BetweenFloat64(FloatForm.Temperature, -50, 100) ``` Typical use cases: * geographic coordinates * normalized scores * percentage ranges * sensor measurements * temperature limits *** ### BetweenFloat64WithCode [#betweenfloat64withcode] Requires the value to remain within the specified range and returns a custom validation code. ```go const CodeOutOfRange = form.Code("out_of_range") rules.BetweenFloat64WithCode( FloatForm.Temperature, -50, 100, CodeOutOfRange, ) ``` *** ### Float64Equals [#float64equals] Requires the value to exactly match the expected float64 value. ```go rules.Float64Equals(FloatForm.Rating, 5.0) ``` Useful when APIs require fixed numeric values. *** ### Float64EqualsWithCode [#float64equalswithcode] Requires the value to exactly match the expected value and returns a custom validation code. ```go const CodeInvalidValue = form.Code("invalid_value") rules.Float64EqualsWithCode( FloatForm.Rating, 5.0, CodeInvalidValue, ) ``` *** ### IsFloat64 [#isfloat64] Checks whether the field contains a valid float64 value. ```go rules.IsFloat64(FloatForm.Price) ``` For typed Go structures this rule is mostly useful for API symmetry because invalid JSON numeric types are usually rejected during request decoding before schema validation begins. ## Complete Example [#complete-example] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/rules" ) type FloatRulesRequest struct { Price float64 `json:"price"` Discount float64 `json:"discount"` Temperature float64 `json:"temperature"` } func FloatRulesSchema() form.Schema[FloatRulesRequest] { FloatForm := struct { Price form.Float64Field[FloatRulesRequest] Discount form.Float64Field[FloatRulesRequest] Temperature form.Float64Field[FloatRulesRequest] }{ Price: form.Float64[FloatRulesRequest]("price", func(r *FloatRulesRequest) float64 { return r.Price }), Discount: form.Float64[FloatRulesRequest]("discount", func(r *FloatRulesRequest) float64 { return r.Discount }), Temperature: form.Float64[FloatRulesRequest]("temperature", func(r *FloatRulesRequest) float64 { return r.Temperature }), } return form.Schema[FloatRulesRequest]{ rules.MinFloat64(FloatForm.Price, 0), rules.MaxFloat64(FloatForm.Discount, 100), rules.BetweenFloat64(FloatForm.Temperature, -50, 100), } } ``` ## Notes [#notes] * Float64 rules operate on typed Go `float64` values. * Invalid JSON numeric types are rejected before validation begins. * Use `BetweenFloat64` when validating normalized ranges such as percentages or coordinates. * Use custom error codes for stable frontend-facing API contracts. * Decimal validation logic remains transport-independent and reusable across services, HTTP handlers, CLI tools, and background workers. * Float comparisons are exact and do not apply epsilon-based precision matching automatically. # Format Rules Format rules validate typed string fields defined with `form.Str`. They are useful when a string must follow a specific external format such as an HTTP URL, IP address, UUID, JSON value, or IANA timezone name. ## Common Use Cases [#common-use-cases] Format rules are commonly used for: * website URLs * callback URLs * IP allowlists * UUID identifiers * JSON configuration strings * timezone preferences * API payload validation * integration settings * user profile settings * system configuration forms ## TL;DR [#tldr] | Rule | Description | | ------------------------------------- | ------------------------------------------------------------- | | `rules.URL(field)` | Requires the string to be a valid `http://` or `https://` URL | | `rules.URLWithCode(field, code)` | Same as `URL` with a custom error code | | `rules.IP(field)` | Requires the string to be a valid IP address | | `rules.IPWithCode(field, code)` | Same as `IP` with a custom error code | | `rules.UUID(field)` | Requires the string to be a valid UUID | | `rules.UUIDWithCode(field, code)` | Same as `UUID` with a custom error code | | `rules.JSON(field)` | Requires the string to contain valid JSON | | `rules.JSONWithCode(field, code)` | Same as `JSON` with a custom error code | | `rules.Timezone(field)` | Requires the string to be a valid IANA timezone | | `rules.TimezoneWithCode(field, code)` | Same as `Timezone` with a custom error code | ## Defining Format Fields [#defining-format-fields] Format validation starts with typed string fields. ```go FormatForm := struct { Website form.StringField[FormatRulesRequest] ClientIP form.StringField[FormatRulesRequest] UserID form.StringField[FormatRulesRequest] Config form.StringField[FormatRulesRequest] Timezone form.StringField[FormatRulesRequest] }{ Website: form.Str[FormatRulesRequest]("website", func(r *FormatRulesRequest) string { return r.Website }), ClientIP: form.Str[FormatRulesRequest]("client_ip", func(r *FormatRulesRequest) string { return r.ClientIP }), UserID: form.Str[FormatRulesRequest]("user_id", func(r *FormatRulesRequest) string { return r.UserID }), Config: form.Str[FormatRulesRequest]("config", func(r *FormatRulesRequest) string { return r.Config }), Timezone: form.Str[FormatRulesRequest]("timezone", func(r *FormatRulesRequest) string { return r.Timezone }), } ``` ## Applying Format Rules [#applying-format-rules] Format rules are attached directly to typed string field references. ```go return form.Schema[FormatRulesRequest]{ rules.URL(FormatForm.Website), rules.IP(FormatForm.ClientIP), rules.UUID(FormatForm.UserID), rules.JSON(FormatForm.Config), rules.Timezone(FormatForm.Timezone), } ``` ## Rule Examples [#rule-examples] ### URL [#url] Requires the string to be a valid HTTP or HTTPS URL. ```go rules.URL(FormatForm.Website) ``` Valid examples: ```text https://example.com http://localhost:8080 ``` *** ### URLWithCode [#urlwithcode] ```go const CodeInvalidURL = form.Code("invalid_url") rules.URLWithCode( FormatForm.Website, CodeInvalidURL, ) ``` *** ### IP [#ip] Requires the string to be a valid IP address. ```go rules.IP(FormatForm.ClientIP) ``` Valid examples: ```text 127.0.0.1 192.168.1.10 ::1 ``` *** ### IPWithCode [#ipwithcode] ```go const CodeInvalidIP = form.Code("invalid_ip") rules.IPWithCode( FormatForm.ClientIP, CodeInvalidIP, ) ``` *** ### UUID [#uuid] Requires the string to be a valid UUID. ```go rules.UUID(FormatForm.UserID) ``` Valid example: ```text 550e8400-e29b-41d4-a716-446655440000 ``` *** ### UUIDWithCode [#uuidwithcode] ```go const CodeInvalidUUID = form.Code("invalid_uuid") rules.UUIDWithCode( FormatForm.UserID, CodeInvalidUUID, ) ``` *** ### JSON [#json] Requires the string to contain valid JSON. ```go rules.JSON(FormatForm.Config) ``` Valid examples: ```json {"enabled":true} ``` ```json ["api", "admin"] ``` *** ### JSONWithCode [#jsonwithcode] ```go const CodeInvalidJSON = form.Code("invalid_json") rules.JSONWithCode( FormatForm.Config, CodeInvalidJSON, ) ``` *** ### Timezone [#timezone] Requires the string to be a valid IANA timezone name. ```go rules.Timezone(FormatForm.Timezone) ``` Valid examples: ```text Europe/Bratislava UTC America/New_York ``` *** ### TimezoneWithCode [#timezonewithcode] ```go const CodeInvalidTimezone = form.Code("invalid_timezone") rules.TimezoneWithCode( FormatForm.Timezone, CodeInvalidTimezone, ) ``` ## Complete Example [#complete-example] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/rules" ) type FormatRulesRequest struct { Website string `json:"website"` ClientIP string `json:"client_ip"` UserID string `json:"user_id"` Config string `json:"config"` Timezone string `json:"timezone"` } func FormatRulesSchema() form.Schema[FormatRulesRequest] { FormatForm := struct { Website form.StringField[FormatRulesRequest] ClientIP form.StringField[FormatRulesRequest] UserID form.StringField[FormatRulesRequest] Config form.StringField[FormatRulesRequest] Timezone form.StringField[FormatRulesRequest] }{ Website: form.Str[FormatRulesRequest]("website", func(r *FormatRulesRequest) string { return r.Website }), ClientIP: form.Str[FormatRulesRequest]("client_ip", func(r *FormatRulesRequest) string { return r.ClientIP }), UserID: form.Str[FormatRulesRequest]("user_id", func(r *FormatRulesRequest) string { return r.UserID }), Config: form.Str[FormatRulesRequest]("config", func(r *FormatRulesRequest) string { return r.Config }), Timezone: form.Str[FormatRulesRequest]("timezone", func(r *FormatRulesRequest) string { return r.Timezone }), } return form.Schema[FormatRulesRequest]{ rules.URL(FormatForm.Website), rules.IP(FormatForm.ClientIP), rules.UUID(FormatForm.UserID), rules.JSON(FormatForm.Config), rules.Timezone(FormatForm.Timezone), } } ``` ## Notes [#notes] * Format rules operate on typed Go `string` values. * Empty strings are ignored unless the field is also marked as required. * Use `rules.Required(field)` together with format rules when the value must be present. * `URL` accepts `http://` and `https://` URLs. * `IP` supports both IPv4 and IPv6 values. * `Timezone` validates IANA timezone names through Go’s `time.LoadLocation`. * Custom error codes are recommended for public API responses and frontend integrations. * Format validation remains explicit, reusable, and independent from HTTP or JSON transport layers. # Generic Rules Generic rules validate typed comparable fields through reusable value constraints. They are useful when a field must match one of a predefined set of allowed values, such as roles, statuses, plans, priorities, levels, feature states, or enum-like application values. ## Common Use Cases [#common-use-cases] Generic rules are commonly used for: * user roles * publishing statuses * subscription plans * enum-like values * priority levels * configuration states * feature flags * workflow states * allowed numeric values * fixed API contracts ## TL;DR [#tldr] | Rule | Description | | ---------------------------------------------- | ----------------------------------------------------------- | | `rules.OneOf(field, allowed...)` | Requires the field value to match one of the allowed values | | `rules.OneOfWithCode(field, code, allowed...)` | Same as `OneOf` with a custom error code | ## Defining Generic Fields [#defining-generic-fields] Generic rules work with any comparable field value. ```go GenericForm := struct { Role form.StringField[GenericRulesRequest] Level form.IntField[GenericRulesRequest] Active form.BoolField[GenericRulesRequest] }{ Role: form.Str[GenericRulesRequest]("role", func(r *GenericRulesRequest) string { return r.Role }), Level: form.Int[GenericRulesRequest]("level", func(r *GenericRulesRequest) int { return r.Level }), Active: form.Bool[GenericRulesRequest]("active", func(r *GenericRulesRequest) bool { return r.Active }), } ``` Generic rules use the underlying field through `.Field`: ```go GenericForm.Role.Field ``` This allows the same rule to work with different comparable value types. ## Applying Generic Rules [#applying-generic-rules] Use `OneOf` when a value must be part of a fixed allowed set. ```go return form.Schema[GenericRulesRequest]{ rules.OneOf[GenericRulesRequest, string]( GenericForm.Role.Field, "admin", "editor", "viewer", ), rules.OneOf[GenericRulesRequest, int]( GenericForm.Level.Field, 1, 2, 3, ), rules.OneOf[GenericRulesRequest, bool]( GenericForm.Active.Field, true, ), } ``` ## Rule Examples [#rule-examples] ### OneOf [#oneof] Requires the field value to match one of the allowed values. ```go rules.OneOf[GenericRulesRequest, string]( GenericForm.Role.Field, "admin", "editor", "viewer", ) ``` Typical use cases: * allowed roles * enum-like string values * predefined workflow states * controlled API input *** ### OneOf with Integers [#oneof-with-integers] `OneOf` can also validate integer values. ```go rules.OneOf[GenericRulesRequest, int]( GenericForm.Level.Field, 1, 2, 3, ) ``` Typical use cases: * priority levels * fixed numeric states * allowed configuration values * numeric enum-like input *** ### OneOf with Booleans [#oneof-with-booleans] `OneOf` can validate boolean values as well. ```go rules.OneOf[GenericRulesRequest, bool]( GenericForm.Active.Field, true, ) ``` This is useful when a boolean value must explicitly match a required state. *** ### OneOfWithCode [#oneofwithcode] Requires the field value to match one of the allowed values and returns a custom validation code. ```go const CodeInvalidStatus = form.Code("invalid_status") rules.OneOfWithCode[GenericRulesRequest, string]( GenericForm.Status.Field, CodeInvalidStatus, "draft", "published", "archived", ) ``` Custom codes are useful for frontend translations, stable API contracts, and consistent validation responses. ## Complete Example [#complete-example] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/rules" ) const ( CodeInvalidStatus = form.Code("invalid_status") CodeInvalidPlan = form.Code("invalid_plan") CodeInvalidPriority = form.Code("invalid_priority") ) type GenericRulesRequest struct { Role string `json:"role"` Status string `json:"status"` Plan string `json:"plan"` Level int `json:"level"` Priority int `json:"priority"` Active bool `json:"active"` } func GenericRulesSchema() form.Schema[GenericRulesRequest] { GenericForm := struct { Role form.StringField[GenericRulesRequest] Status form.StringField[GenericRulesRequest] Plan form.StringField[GenericRulesRequest] Level form.IntField[GenericRulesRequest] Priority form.IntField[GenericRulesRequest] Active form.BoolField[GenericRulesRequest] }{ Role: form.Str[GenericRulesRequest]("role", func(r *GenericRulesRequest) string { return r.Role }), Status: form.Str[GenericRulesRequest]("status", func(r *GenericRulesRequest) string { return r.Status }), Plan: form.Str[GenericRulesRequest]("plan", func(r *GenericRulesRequest) string { return r.Plan }), Level: form.Int[GenericRulesRequest]("level", func(r *GenericRulesRequest) int { return r.Level }), Priority: form.Int[GenericRulesRequest]("priority", func(r *GenericRulesRequest) int { return r.Priority }), Active: form.Bool[GenericRulesRequest]("active", func(r *GenericRulesRequest) bool { return r.Active }), } return form.Schema[GenericRulesRequest]{ rules.OneOf[GenericRulesRequest, string]( GenericForm.Role.Field, "admin", "editor", "viewer", ), rules.OneOfWithCode[GenericRulesRequest, string]( GenericForm.Status.Field, CodeInvalidStatus, "draft", "published", "archived", ), rules.OneOfWithCode[GenericRulesRequest, string]( GenericForm.Plan.Field, CodeInvalidPlan, "free", "pro", "enterprise", ), rules.OneOf[GenericRulesRequest, int]( GenericForm.Level.Field, 1, 2, 3, ), rules.OneOfWithCode[GenericRulesRequest, int]( GenericForm.Priority.Field, CodeInvalidPriority, 10, 20, 30, ), rules.OneOf[GenericRulesRequest, bool]( GenericForm.Active.Field, true, ), } } ``` ## Notes [#notes] * Generic rules work with comparable Go values. * `OneOf` is useful for enum-like validation without introducing custom enum types. * Use `OneOfWithCode` for public APIs that require stable error codes. * Empty values are still validated unless optional or conditional logic is used. * Generic rules use the underlying field through `.Field`. * For specialized validation, prefer type-specific rules such as string, integer, boolean, time, or format rules. * Generic validation remains explicit, reusable, and independent from HTTP or JSON transport layers. # Validation Rules Validation rules are the core building blocks of `form`. They define how application data is validated through reusable, composable, and type-safe validation pipelines. Unlike tag-based validators that hide validation behavior inside struct metadata, `form` treats validation as explicit application logic. ## Validation Philosophy [#validation-philosophy] Rules in `form` are: * explicit * reusable * composable * transport-independent * type-safe * generics-based Validation is performed through reusable field references and rule pipelines instead of reflection-heavy struct tags. Example: ```go return form.Schema[RegisterRequest]{ rules.Required(RegisterForm.Email), rules.Email(RegisterForm.Email), rules.MinLen(RegisterForm.Password, 8), } ``` This keeps validation logic: * readable * testable * reusable * maintainable in larger applications ## Validation Flow [#validation-flow] Validation typically follows three steps: 1. Define typed fields 2. Attach validation rules 3. Combine rules into a reusable schema ## Typed Fields [#typed-fields] Validation starts by defining reusable typed fields. ```go RegisterForm := struct { Email form.StringField[RegisterRequest] Password form.StringField[RegisterRequest] }{ Email: form.Str[RegisterRequest]("email", func(r *RegisterRequest) string { return r.Email }), Password: form.Str[RegisterRequest]("password", func(r *RegisterRequest) string { return r.Password }), } ``` Field definitions contain: * field names * typed accessors * reusable validation references * transport-independent field metadata ## Rule Composition [#rule-composition] Rules are attached directly to typed field references. ```go return form.Schema[RegisterRequest]{ rules.Required(RegisterForm.Email), rules.Email(RegisterForm.Email), rules.MinLen(RegisterForm.Password, 8), } ``` Schemas can later be reused across: * HTTP APIs * background jobs * CLI tools * internal services * shared validation packages ## Rule Categories [#rule-categories] The validation system is split into multiple rule categories. ## Boolean Rules [#boolean-rules] Boolean rules validate `bool` values. Typical use cases: * terms acceptance * feature flags * consent validation * visibility switches Examples: ```go rules.IsTrue(SettingsForm.TermsAccepted) rules.IsFalse(SettingsForm.Admin) ``` *** ## Float Rules [#float-rules] Float rules validate decimal numeric values. Typical use cases: * pricing * percentages * measurements * coordinates Examples: ```go rules.MinFloat64(ProductForm.Price, 0) rules.BetweenFloat64(ProductForm.Discount, 0, 100) ``` *** ## Integer Rules [#integer-rules] Integer rules validate integer values. Typical use cases: * quantities * priorities * age validation * pagination Examples: ```go rules.Min(UserForm.Age, 18) rules.Positive(OrderForm.Quantity) ``` *** ## String Rules [#string-rules] String rules validate textual values. Typical use cases: * usernames * passwords * slugs * labels Examples: ```go rules.Required(UserForm.Username) rules.MinLen(UserForm.Password, 8) rules.HasPrefix(PostForm.Slug, "app-") ``` *** ## Time Rules [#time-rules] Time rules validate `time.Time` values. Typical use cases: * scheduling * bookings * expiration dates * event windows Examples: ```go rules.After(EventForm.StartAt, time.Now()) rules.Before(EventForm.EndAt, maxDate) ``` *** ## Required Rules [#required-rules] Required rules change validation behavior for empty values. Without required validation, empty values are generally skipped. Examples: ```go rules.Required(UserForm.Email) rules.RequiredInt(OrderForm.Quantity) rules.RequiredTime(EventForm.StartAt) ``` *** ## Slice Rules [#slice-rules] Slice rules validate arrays and collections. Typical use cases: * tags * permissions * category selection * batch operations Examples: ```go rules.RequiredSlice(PostForm.Tags) rules.UniqueItems(PostForm.Tags) ``` *** ## Format Rules [#format-rules] Format rules validate structured string formats. Typical use cases: * URLs * UUIDs * IP addresses * JSON strings * timezones Examples: ```go rules.URL(Form.Website) rules.UUID(Form.UserID) rules.IP(Form.ClientIP) ``` *** ## Generic Rules [#generic-rules] Generic rules validate reusable comparable values. Typical use cases: * enums * statuses * plans * workflow states Examples: ```go rules.OneOf(Form.Status.Field, "draft", "published") ``` *** ## Compare Rules [#compare-rules] Compare rules validate relationships between multiple fields. Typical use cases: * password confirmation * numeric ranges * ordered values * matching identifiers Examples: ```go rules.Compare( Form.Password.Field, Form.ConfirmPassword.Field, rules.OpEQ, ) ``` ## Design Goals [#design-goals] The validation system is designed around several core principles: * explicit validation behavior * reusable schemas * composable rule pipelines * transport-independent validation * type-safe field access * predictable validation flow * low reflection overhead ## Notes [#notes] * Validation rules are fully reusable across applications and transport layers. * Empty values are generally skipped unless explicitly required. * Rules are composable and can be grouped into reusable schema fragments. * Validation logic remains independent from JSON, HTTP, CLI, or database layers. * The package favors explicit validation pipelines over reflection-heavy struct tags. * Rules are designed for modern Go applications using generics and reusable application architecture. # Integer Rules Integer rules validate typed integer fields defined with `form.Int`. They are useful for validating ages, quantities, counters, limits, identifiers, priorities, inventory values, pagination parameters, and other integer-based application data. ## Common Use Cases [#common-use-cases] Integer rules are commonly used for: * user age validation * pagination limits * inventory quantities * order counts * retry limits * API rate limits * priorities and weights * billing quantities * numeric identifiers * integer configuration values ## TL;DR [#tldr] | Rule | Description | | ------------------------------------------------ | -------------------------------------------------------------- | | `rules.Min(field, n)` | Requires the integer value to be greater than or equal to `n` | | `rules.MinWithCode(field, n, code)` | Same as `Min` with a custom error code | | `rules.Max(field, n)` | Requires the integer value to be less than or equal to `n` | | `rules.MaxWithCode(field, n, code)` | Same as `Max` with a custom error code | | `rules.Between(field, min, max)` | Requires the integer value to stay within the specified range | | `rules.BetweenWithCode(field, min, max, code)` | Same as `Between` with a custom error code | | `rules.IntEquals(field, expected)` | Requires the integer value to exactly match the expected value | | `rules.IntEqualsWithCode(field, expected, code)` | Same as `IntEquals` with a custom error code | | `rules.Positive(field)` | Requires the integer value to be greater than zero | | `rules.PositiveWithCode(field, code)` | Same as `Positive` with a custom error code | | `rules.Negative(field)` | Requires the integer value to be less than zero | | `rules.NegativeWithCode(field, code)` | Same as `Negative` with a custom error code | | `rules.IsInt(field)` | Validates that the field contains an integer value | ## Defining Integer Fields [#defining-integer-fields] Integer validation starts by defining typed integer fields. ```go IntForm := struct { Age form.IntField[IntRulesRequest] Quantity form.IntField[IntRulesRequest] Priority form.IntField[IntRulesRequest] }{ Age: form.Int[IntRulesRequest]("age", func(r *IntRulesRequest) int { return r.Age }), Quantity: form.Int[IntRulesRequest]("quantity", func(r *IntRulesRequest) int { return r.Quantity }), Priority: form.Int[IntRulesRequest]("priority", func(r *IntRulesRequest) int { return r.Priority }), } ``` Each field contains: * the validation response field name * typed accessors * reusable schema references * strongly typed integer access ## Applying Integer Rules [#applying-integer-rules] Integer rules are attached directly to typed field references. ```go return form.Schema[IntRulesRequest]{ rules.Min(IntForm.Age, 18), rules.Max(IntForm.Quantity, 100), rules.Positive(IntForm.Priority), } ``` This keeps validation logic: * explicit * composable * reusable * transport-independent * type-safe ## Rule Examples [#rule-examples] ### Min [#min] Requires the integer value to be greater than or equal to the provided minimum. ```go rules.Min(IntForm.Age, 18) ``` Typical use cases: * minimum age requirements * minimum quantities * pagination constraints * API limits *** ### MinWithCode [#minwithcode] Requires the value to be greater than or equal to the minimum and returns a custom validation code. ```go const CodeTooYoung = form.Code("too_young") rules.MinWithCode( IntForm.Age, 18, CodeTooYoung, ) ``` Useful for frontend-friendly API validation contracts. *** ### Max [#max] Requires the integer value to be less than or equal to the provided maximum. ```go rules.Max(IntForm.Quantity, 100) ``` Typical use cases: * quantity limits * page size restrictions * retry caps * maximum priorities *** ### MaxWithCode [#maxwithcode] Requires the value to be below the maximum and returns a custom validation code. ```go const CodeTooLarge = form.Code("too_large") rules.MaxWithCode( IntForm.Quantity, 100, CodeTooLarge, ) ``` *** ### Between [#between] Requires the integer value to stay within a specified range. ```go rules.Between(IntForm.Priority, 1, 10) ``` Typical use cases: * rating systems * bounded priorities * normalized integer values * application configuration limits *** ### BetweenWithCode [#betweenwithcode] Requires the value to remain within the specified range and returns a custom validation code. ```go const CodeOutOfRange = form.Code("out_of_range") rules.BetweenWithCode( IntForm.Priority, 1, 10, CodeOutOfRange, ) ``` *** ### IntEquals [#intequals] Requires the integer value to exactly match the expected value. ```go rules.IntEquals(IntForm.Quantity, 5) ``` Useful for fixed numeric requirements and strict API contracts. *** ### IntEqualsWithCode [#intequalswithcode] Requires the value to exactly match the expected integer value and returns a custom validation code. ```go const CodeInvalidQuantity = form.Code("invalid_quantity") rules.IntEqualsWithCode( IntForm.Quantity, 5, CodeInvalidQuantity, ) ``` *** ### Positive [#positive] Requires the integer value to be greater than zero. ```go rules.Positive(IntForm.Quantity) ``` Typical use cases: * order quantities * positive identifiers * retry counts * payment units *** ### PositiveWithCode [#positivewithcode] Requires the integer value to be positive and returns a custom validation code. ```go const CodeMustBePositive = form.Code("must_be_positive") rules.PositiveWithCode( IntForm.Quantity, CodeMustBePositive, ) ``` *** ### Negative [#negative] Requires the integer value to be less than zero. ```go rules.Negative(IntForm.Priority) ``` Typical use cases: * negative offsets * reverse scoring systems * signed internal values *** ### NegativeWithCode [#negativewithcode] Requires the integer value to be negative and returns a custom validation code. ```go const CodeMustBeNegative = form.Code("must_be_negative") rules.NegativeWithCode( IntForm.Priority, CodeMustBeNegative, ) ``` *** ### IsInt [#isint] Checks whether the field contains a valid integer value. ```go rules.IsInt(IntForm.Quantity) ``` For typed Go structures this rule is mainly useful for API symmetry because invalid JSON integer types are typically rejected during request decoding before schema validation begins. ## Complete Example [#complete-example] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/rules" ) type IntRulesRequest struct { Age int `json:"age"` Quantity int `json:"quantity"` Priority int `json:"priority"` } func IntRulesSchema() form.Schema[IntRulesRequest] { IntForm := struct { Age form.IntField[IntRulesRequest] Quantity form.IntField[IntRulesRequest] Priority form.IntField[IntRulesRequest] }{ Age: form.Int[IntRulesRequest]("age", func(r *IntRulesRequest) int { return r.Age }), Quantity: form.Int[IntRulesRequest]("quantity", func(r *IntRulesRequest) int { return r.Quantity }), Priority: form.Int[IntRulesRequest]("priority", func(r *IntRulesRequest) int { return r.Priority }), } return form.Schema[IntRulesRequest]{ rules.Min(IntForm.Age, 18), rules.Max(IntForm.Quantity, 100), rules.Between(IntForm.Priority, 1, 10), } } ``` ## Notes [#notes] * Integer rules operate on typed Go `int` values. * Invalid JSON numeric types are rejected before validation begins. * Use `Between` when values must stay within bounded ranges. * Use `Positive` and `Negative` for intent-based validation readability. * Custom error codes help stabilize frontend-facing validation contracts. * Validation schemas remain reusable across APIs, CLI tools, background workers, and internal services. * Integer validation remains explicit and independent from transport layers. # Required Rules Required rules are one of the most important concepts in `form`. Unlike many traditional validators that implicitly validate zero values, `form` treats empty values differently depending on whether a field is marked as required. This behavior is intentional. ## Validation Philosophy [#validation-philosophy] In `form`, validation rules are generally skipped for empty values unless the field is explicitly marked as required. This means: * empty strings * zero-value numbers * zero `time.Time` * nil optional values * missing optional fields are typically ignored by non-required validation rules. This allows schemas to naturally support optional fields without forcing every validation rule to handle empty-state logic manually. ## Why Required Rules Matter [#why-required-rules-matter] Required rules change validation behavior. Once a field is marked as required: * the field must exist * the field must contain a non-empty value * additional validation rules are executed normally Without a required rule: ```go rules.MinLen(UserForm.Name, 5) ``` an empty value: ```json { "name": "" } ``` is silently ignored. With a required rule: ```go rules.Required(UserForm.Name) ``` the same payload becomes invalid. This allows validation pipelines to distinguish between: * optional fields * required fields * partially validated fields without additional conditional logic. ## Common Use Cases [#common-use-cases] Required rules are commonly used for: * registration forms * login requests * billing information * required API payloads * onboarding workflows * mandatory profile fields * password reset requests * configuration validation ## TL;DR [#tldr] | Rule | Description | | -------------------------------------------- | -------------------------------------------------- | | `rules.Required(field)` | Requires a non-empty string value | | `rules.RequiredWithCode(field, code)` | Same as `Required` with a custom error code | | `rules.RequiredInt(field)` | Requires a non-zero integer value | | `rules.RequiredIntWithCode(field, code)` | Same as `RequiredInt` with a custom error code | | `rules.RequiredFloat64(field)` | Requires a non-zero float64 value | | `rules.RequiredFloat64WithCode(field, code)` | Same as `RequiredFloat64` with a custom error code | | `rules.RequiredBool(field)` | Requires the boolean field to be present | | `rules.RequiredBoolWithCode(field, code)` | Same as `RequiredBool` with a custom error code | | `rules.RequiredTime(field)` | Requires a non-zero `time.Time` value | | `rules.RequiredTimeWithCode(field, code)` | Same as `RequiredTime` with a custom error code | ## Required String Fields [#required-string-fields] String fields are considered empty when the value is: ```go "" ``` Example: ```go rules.Required(UserForm.Name) ``` Typical use cases: * usernames * emails * passwords * required text input *** ### RequiredWithCode [#requiredwithcode] ```go const CodeNameRequired = form.Code("name_required") rules.RequiredWithCode( UserForm.Name, CodeNameRequired, ) ``` Useful for frontend-facing validation APIs and stable error contracts. ## Required Integer Fields [#required-integer-fields] Integer fields are considered empty when the value is: ```go 0 ``` Example: ```go rules.RequiredInt(UserForm.Age) ``` Typical use cases: * required counters * quantities * identifiers * pagination input *** ### RequiredIntWithCode [#requiredintwithcode] ```go const CodeAgeRequired = form.Code("age_required") rules.RequiredIntWithCode( UserForm.Age, CodeAgeRequired, ) ``` ## Required Float64 Fields [#required-float64-fields] Float64 fields are considered empty when the value is: ```go 0.0 ``` Example: ```go rules.RequiredFloat64(PaymentForm.Amount) ``` Typical use cases: * payment amounts * pricing * measurements * decimal configuration values *** ### RequiredFloat64WithCode [#requiredfloat64withcode] ```go const CodeAmountRequired = form.Code("amount_required") rules.RequiredFloat64WithCode( PaymentForm.Amount, CodeAmountRequired, ) ``` ## Required Boolean Fields [#required-boolean-fields] Boolean fields behave differently from strings and numeric values. Because `false` is a valid boolean value, required boolean validation checks whether the field exists and was provided during decoding. Example: ```go rules.RequiredBool(SettingsForm.Accepted) ``` Typical use cases: * required consent flags * explicit feature toggles * mandatory user decisions *** ### RequiredBoolWithCode [#requiredboolwithcode] ```go const CodeConsentRequired = form.Code("consent_required") rules.RequiredBoolWithCode( SettingsForm.Accepted, CodeConsentRequired, ) ``` ## Required Time Fields [#required-time-fields] Time fields are considered empty when the value is: ```go time.Time{} ``` Example: ```go rules.RequiredTime(EventForm.StartAt) ``` Typical use cases: * booking dates * expiration timestamps * scheduled events * required deadlines *** ### RequiredTimeWithCode [#requiredtimewithcode] ```go const CodeStartDateRequired = form.Code("start_date_required") rules.RequiredTimeWithCode( EventForm.StartAt, CodeStartDateRequired, ) ``` ## Validation Behavior [#validation-behavior] Required rules affect how subsequent rules behave. Example: ```go form.Schema[UserRequest]{ rules.Required(UserForm.Name), rules.MinLen(UserForm.Name, 5), } ``` Validation flow: | Input | Result | | ---------- | ---------------- | | `""` | Fails `Required` | | `"abc"` | Fails `MinLen` | | `"abcdef"` | Passes | Without `Required`: ```go form.Schema[UserRequest]{ rules.MinLen(UserForm.Name, 5), } ``` Validation flow: | Input | Result | | ---------- | -------------- | | `""` | Ignored | | `"abc"` | Fails `MinLen` | | `"abcdef"` | Passes | This distinction is one of the core design principles of `form`. ## Complete Example [#complete-example] ```go package main import ( "time" "github.com/netlifeguru/form" "github.com/netlifeguru/form/rules" ) type RequiredRequest struct { Name string `json:"name"` Age int `json:"age"` Amount float64 `json:"amount"` Accepted bool `json:"accepted"` StartAt time.Time `json:"start_at"` } func RequiredSchema() form.Schema[RequiredRequest] { RequiredForm := struct { Name form.StringField[RequiredRequest] Age form.IntField[RequiredRequest] Amount form.Float64Field[RequiredRequest] Accepted form.BoolField[RequiredRequest] StartAt form.TimeField[RequiredRequest] }{ Name: form.Str[RequiredRequest]("name", func(r *RequiredRequest) string { return r.Name }), Age: form.Int[RequiredRequest]("age", func(r *RequiredRequest) int { return r.Age }), Amount: form.Float64[RequiredRequest]("amount", func(r *RequiredRequest) float64 { return r.Amount }), Accepted: form.Bool[RequiredRequest]("accepted", func(r *RequiredRequest) bool { return r.Accepted }), StartAt: form.Time[RequiredRequest]("start_at", func(r *RequiredRequest) time.Time { return r.StartAt }), } return form.Schema[RequiredRequest]{ rules.Required(RequiredForm.Name), rules.RequiredInt(RequiredForm.Age), rules.RequiredFloat64(RequiredForm.Amount), rules.RequiredBool(RequiredForm.Accepted), rules.RequiredTime(RequiredForm.StartAt), } } ``` ## Notes [#notes] * Required rules change validation behavior for empty values. * Non-required validation rules generally ignore empty values. * This design allows schemas to naturally support optional fields without additional conditional logic. * `RequiredBool` differs from other required rules because `false` is a valid boolean value. * Required validation remains explicit and transport-independent. * Required rules are usually the first rules applied within a schema. * This validation model helps separate optional and mandatory input cleanly in larger applications and APIs. # Slice Rules Slice rules validate typed slice fields defined with `form.Slice`. They are useful for validating tags, categories, permissions, identifiers, selected options, API arrays, batch payloads, and other list-based application input. ## Common Use Cases [#common-use-cases] Slice rules are commonly used for: * tags and labels * user roles and permissions * selected categories * batch API operations * filter arrays * allowed identifiers * feature flags * shopping cart items * multi-select frontend forms * configuration lists ## TL;DR [#tldr] | Rule | Description | | --------------------------------------------------- | -------------------------------------------------- | | `rules.RequiredSlice(field)` | Requires the slice to contain at least one element | | `rules.RequiredSliceWithCode(field, code)` | Same as `RequiredSlice` with a custom error code | | `rules.MinItems(field, n)` | Requires the slice length to be at least `n` | | `rules.MinItemsWithCode(field, n, code)` | Same as `MinItems` with a custom error code | | `rules.MaxItems(field, n)` | Requires the slice length to be at most `n` | | `rules.MaxItemsWithCode(field, n, code)` | Same as `MaxItems` with a custom error code | | `rules.ItemsBetween(field, min, max)` | Requires the slice length to stay within a range | | `rules.ItemsBetweenWithCode(field, min, max, code)` | Same as `ItemsBetween` with a custom error code | | `rules.ContainsItem(field, value)` | Requires the slice to contain a specific item | | `rules.ContainsItemWithCode(field, value, code)` | Same as `ContainsItem` with a custom error code | | `rules.UniqueItems(field)` | Requires all slice items to be unique | | `rules.UniqueItemsWithCode(field, code)` | Same as `UniqueItems` with a custom error code | | `rules.IsSlice(field)` | Validates that the field contains a slice value | ## Defining Slice Fields [#defining-slice-fields] Slice validation starts by defining typed slice fields. ```go SliceForm := struct { Tags form.SliceField[SliceRulesRequest, string] Permissions form.SliceField[SliceRulesRequest, string] Ids form.SliceField[SliceRulesRequest, int] }{ Tags: form.Slice[SliceRulesRequest]("tags", func(r *SliceRulesRequest) []string { return r.Tags }), Permissions: form.Slice[SliceRulesRequest]("permissions", func(r *SliceRulesRequest) []string { return r.Permissions }), Ids: form.Slice[SliceRulesRequest]("ids", func(r *SliceRulesRequest) []int { return r.Ids }), } ``` Each field contains: * the validation response field name * typed accessors * reusable schema references * strongly typed slice access ## Applying Slice Rules [#applying-slice-rules] Slice rules are attached directly to typed field references. ```go return form.Schema[SliceRulesRequest]{ rules.RequiredSlice(SliceForm.Tags), rules.MinItems(SliceForm.Tags, 1), rules.UniqueItems(SliceForm.Permissions), } ``` This keeps validation logic: * explicit * reusable * composable * transport-independent * type-safe ## Rule Examples [#rule-examples] ### RequiredSlice [#requiredslice] Requires the slice to contain at least one item. ```go rules.RequiredSlice(SliceForm.Tags) ``` Typical use cases: * required categories * selected permissions * required batch operations * non-empty frontend multi-selects *** ### RequiredSliceWithCode [#requiredslicewithcode] Requires the slice to contain items and returns a custom validation code. ```go const CodeTagsRequired = form.Code("tags_required") rules.RequiredSliceWithCode( SliceForm.Tags, CodeTagsRequired, ) ``` Useful for frontend-friendly API validation contracts. *** ### MinItems [#minitems] Requires the slice length to be greater than or equal to the minimum item count. ```go rules.MinItems(SliceForm.Tags, 2) ``` Typical use cases: * minimum category selection * minimum permissions * required batch sizes * multi-selection validation *** ### MinItemsWithCode [#minitemswithcode] Requires the slice to contain at least the specified number of items and returns a custom validation code. ```go const CodeNotEnoughItems = form.Code("not_enough_items") rules.MinItemsWithCode( SliceForm.Tags, 2, CodeNotEnoughItems, ) ``` *** ### MaxItems [#maxitems] Requires the slice length to stay below the specified maximum. ```go rules.MaxItems(SliceForm.Tags, 10) ``` Typical use cases: * tag limits * API payload protection * frontend selection caps * bounded list validation *** ### MaxItemsWithCode [#maxitemswithcode] Requires the slice length to stay below the maximum and returns a custom validation code. ```go const CodeTooManyItems = form.Code("too_many_items") rules.MaxItemsWithCode( SliceForm.Tags, 10, CodeTooManyItems, ) ``` *** ### ItemsBetween [#itemsbetween] Requires the slice length to stay within a specified range. ```go rules.ItemsBetween(SliceForm.Tags, 1, 5) ``` Typical use cases: * bounded tag selection * controlled multi-select input * permission assignment validation *** ### ItemsBetweenWithCode [#itemsbetweenwithcode] Requires the slice length to stay within the specified range and returns a custom validation code. ```go const CodeInvalidItemCount = form.Code("invalid_item_count") rules.ItemsBetweenWithCode( SliceForm.Tags, 1, 5, CodeInvalidItemCount, ) ``` *** ### ContainsItem [#containsitem] Requires the slice to contain a specific item. ```go rules.ContainsItem(SliceForm.Permissions, "admin") ``` Typical use cases: * required roles * mandatory permissions * enforced categories * required feature flags *** ### ContainsItemWithCode [#containsitemwithcode] Requires the slice to contain a specific item and returns a custom validation code. ```go const CodeMissingPermission = form.Code("missing_permission") rules.ContainsItemWithCode( SliceForm.Permissions, "admin", CodeMissingPermission, ) ``` *** ### UniqueItems [#uniqueitems] Requires all slice items to be unique. ```go rules.UniqueItems(SliceForm.Tags) ``` Typical use cases: * unique tags * permission sets * deduplicated identifiers * API normalization *** ### UniqueItemsWithCode [#uniqueitemswithcode] Requires all slice items to be unique and returns a custom validation code. ```go const CodeDuplicateItems = form.Code("duplicate_items") rules.UniqueItemsWithCode( SliceForm.Tags, CodeDuplicateItems, ) ``` *** ### IsSlice [#isslice] Checks whether the field contains a valid slice value. ```go rules.IsSlice(SliceForm.Tags) ``` For typed Go structures this rule is mostly useful for API symmetry because invalid JSON array types are typically rejected during request decoding before schema validation begins. ## Complete Example [#complete-example] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/rules" ) type SliceRulesRequest struct { Tags []string `json:"tags"` Permissions []string `json:"permissions"` Ids []int `json:"ids"` } func SliceRulesSchema() form.Schema[SliceRulesRequest] { SliceForm := struct { Tags form.SliceField[SliceRulesRequest, string] Permissions form.SliceField[SliceRulesRequest, string] Ids form.SliceField[SliceRulesRequest, int] }{ Tags: form.Slice[SliceRulesRequest]("tags", func(r *SliceRulesRequest) []string { return r.Tags }), Permissions: form.Slice[SliceRulesRequest]("permissions", func(r *SliceRulesRequest) []string { return r.Permissions }), Ids: form.Slice[SliceRulesRequest]("ids", func(r *SliceRulesRequest) []int { return r.Ids }), } return form.Schema[SliceRulesRequest]{ rules.RequiredSlice(SliceForm.Tags), rules.MinItems(SliceForm.Tags, 1), rules.MaxItems(SliceForm.Tags, 5), rules.UniqueItems(SliceForm.Tags), rules.ContainsItem(SliceForm.Permissions, "admin"), } } ``` ## Notes [#notes] * Slice rules operate on typed Go slice values. * Invalid JSON array types are rejected before validation begins. * Slice validation focuses on collection structure and item presence rather than individual item validation. * Use `UniqueItems` to normalize API payloads and avoid duplicate entries. * Use `RequiredSlice` when empty collections should be rejected explicitly. * Slice validation remains reusable across APIs, CLI tools, background workers, and internal services. * Validation logic stays explicit and independent from HTTP or JSON transport layers. # String Rules String rules validate typed string fields defined with `form.Str`. They are useful for validating usernames, passwords, emails, titles, slugs, identifiers, API keys, tokens, labels, descriptions, URLs, and other text-based application input. ## Common Use Cases [#common-use-cases] String rules are commonly used for: * usernames and passwords * email addresses * search queries * API tokens and keys * slugs and identifiers * titles and descriptions * frontend form validation * URL fragments * tags and labels * configuration values ## TL;DR [#tldr] | Rule | Description | | --------------------------------------------------- | ------------------------------------------------------- | | `rules.Required(field)` | Requires the string to be non-empty | | `rules.RequiredWithCode(field, code)` | Same as `Required` with a custom error code | | `rules.MinLen(field, n)` | Requires the string length to be at least `n` | | `rules.MinLenWithCode(field, n, code)` | Same as `MinLen` with a custom error code | | `rules.MaxLen(field, n)` | Requires the string length to be at most `n` | | `rules.MaxLenWithCode(field, n, code)` | Same as `MaxLen` with a custom error code | | `rules.Len(field, n)` | Requires the string length to exactly match `n` | | `rules.LenWithCode(field, n, code)` | Same as `Len` with a custom error code | | `rules.Contains(field, value)` | Requires the string to contain a substring | | `rules.ContainsWithCode(field, value, code)` | Same as `Contains` with a custom error code | | `rules.HasPrefix(field, prefix)` | Requires the string to start with a prefix | | `rules.HasPrefixWithCode(field, prefix, code)` | Same as `HasPrefix` with a custom error code | | `rules.HasSuffix(field, suffix)` | Requires the string to end with a suffix | | `rules.HasSuffixWithCode(field, suffix, code)` | Same as `HasSuffix` with a custom error code | | `rules.StringEquals(field, expected)` | Requires the string to exactly match the expected value | | `rules.StringEqualsWithCode(field, expected, code)` | Same as `StringEquals` with a custom error code | | `rules.NotEmpty(field)` | Alias-style helper for non-empty validation | | `rules.IsString(field)` | Validates that the field contains a string value | ## Defining String Fields [#defining-string-fields] String validation starts by defining typed string fields. ```go StringForm := struct { Username form.StringField[StringRulesRequest] Password form.StringField[StringRulesRequest] Slug form.StringField[StringRulesRequest] }{ Username: form.Str[StringRulesRequest]("username", func(r *StringRulesRequest) string { return r.Username }), Password: form.Str[StringRulesRequest]("password", func(r *StringRulesRequest) string { return r.Password }), Slug: form.Str[StringRulesRequest]("slug", func(r *StringRulesRequest) string { return r.Slug }), } ``` Each field contains: * the validation response field name * typed accessors * reusable schema references * strongly typed string access ## Applying String Rules [#applying-string-rules] String rules are attached directly to typed field references. ```go return form.Schema[StringRulesRequest]{ rules.Required(StringForm.Username), rules.MinLen(StringForm.Password, 8), rules.HasPrefix(StringForm.Slug, "app-"), } ``` This keeps validation logic: * explicit * reusable * composable * transport-independent * type-safe ## Rule Examples [#rule-examples] ### Required [#required] Requires the string value to be non-empty. ```go rules.Required(StringForm.Username) ``` Typical use cases: * usernames * passwords * email fields * required request data *** ### RequiredWithCode [#requiredwithcode] Requires the string to be non-empty and returns a custom validation code. ```go const CodeUsernameRequired = form.Code("username_required") rules.RequiredWithCode( StringForm.Username, CodeUsernameRequired, ) ``` Useful for frontend-friendly API validation responses. *** ### MinLen [#minlen] Requires the string length to be greater than or equal to the minimum length. ```go rules.MinLen(StringForm.Password, 8) ``` Typical use cases: * password policies * usernames * API keys * minimum descriptions *** ### MinLenWithCode [#minlenwithcode] Requires the string to meet the minimum length and returns a custom validation code. ```go const CodePasswordTooShort = form.Code("password_too_short") rules.MinLenWithCode( StringForm.Password, 8, CodePasswordTooShort, ) ``` *** ### MaxLen [#maxlen] Requires the string length to stay below the specified maximum length. ```go rules.MaxLen(StringForm.Username, 32) ``` Typical use cases: * usernames * labels * identifiers * database constraints *** ### MaxLenWithCode [#maxlenwithcode] Requires the string length to stay below the maximum and returns a custom validation code. ```go const CodeUsernameTooLong = form.Code("username_too_long") rules.MaxLenWithCode( StringForm.Username, 32, CodeUsernameTooLong, ) ``` *** ### Len [#len] Requires the string length to exactly match the specified value. ```go rules.Len(StringForm.Token, 64) ``` Typical use cases: * hashes * tokens * identifiers * fixed-length values *** ### LenWithCode [#lenwithcode] Requires the string length to exactly match the expected length and returns a custom validation code. ```go const CodeInvalidTokenLength = form.Code("invalid_token_length") rules.LenWithCode( StringForm.Token, 64, CodeInvalidTokenLength, ) ``` *** ### Contains [#contains] Requires the string to contain a substring. ```go rules.Contains(StringForm.Email, "@") ``` Typical use cases: * simple email checks * keyword validation * token prefixes * partial matching *** ### ContainsWithCode [#containswithcode] Requires the string to contain the substring and returns a custom validation code. ```go const CodeMissingAtSymbol = form.Code("missing_at_symbol") rules.ContainsWithCode( StringForm.Email, "@", CodeMissingAtSymbol, ) ``` *** ### HasPrefix [#hasprefix] Requires the string to start with a specific prefix. ```go rules.HasPrefix(StringForm.Slug, "app-") ``` Typical use cases: * route slugs * internal identifiers * namespaced values * prefixed tokens *** ### HasPrefixWithCode [#hasprefixwithcode] Requires the string to start with the specified prefix and returns a custom validation code. ```go const CodeInvalidPrefix = form.Code("invalid_prefix") rules.HasPrefixWithCode( StringForm.Slug, "app-", CodeInvalidPrefix, ) ``` *** ### HasSuffix [#hassuffix] Requires the string to end with a specific suffix. ```go rules.HasSuffix(StringForm.File, ".json") ``` Typical use cases: * file extensions * domain validation * suffix-based identifiers *** ### HasSuffixWithCode [#hassuffixwithcode] Requires the string to end with the specified suffix and returns a custom validation code. ```go const CodeInvalidSuffix = form.Code("invalid_suffix") rules.HasSuffixWithCode( StringForm.File, ".json", CodeInvalidSuffix, ) ``` *** ### StringEquals [#stringequals] Requires the string to exactly match the expected value. ```go rules.StringEquals(StringForm.Role, "admin") ``` Useful for fixed application states and strict API contracts. *** ### StringEqualsWithCode [#stringequalswithcode] Requires the string to exactly match the expected value and returns a custom validation code. ```go const CodeInvalidRole = form.Code("invalid_role") rules.StringEqualsWithCode( StringForm.Role, "admin", CodeInvalidRole, ) ``` *** ### NotEmpty [#notempty] Validates that the string is not empty. ```go rules.NotEmpty(StringForm.Username) ``` This helper is typically used as a semantic alternative to `Required`. *** ### IsString [#isstring] Checks whether the field contains a valid string value. ```go rules.IsString(StringForm.Username) ``` For typed Go structures this rule is mostly useful for API symmetry because invalid JSON string types are usually rejected during request decoding before schema validation begins. ## Complete Example [#complete-example] ```go package main import ( "github.com/netlifeguru/form" "github.com/netlifeguru/form/rules" ) type StringRulesRequest struct { Username string `json:"username"` Password string `json:"password"` Slug string `json:"slug"` } func StringRulesSchema() form.Schema[StringRulesRequest] { StringForm := struct { Username form.StringField[StringRulesRequest] Password form.StringField[StringRulesRequest] Slug form.StringField[StringRulesRequest] }{ Username: form.Str[StringRulesRequest]("username", func(r *StringRulesRequest) string { return r.Username }), Password: form.Str[StringRulesRequest]("password", func(r *StringRulesRequest) string { return r.Password }), Slug: form.Str[StringRulesRequest]("slug", func(r *StringRulesRequest) string { return r.Slug }), } return form.Schema[StringRulesRequest]{ rules.Required(StringForm.Username), rules.MinLen(StringForm.Password, 8), rules.HasPrefix(StringForm.Slug, "app-"), } } ``` ## Notes [#notes] * String rules operate on typed Go `string` values. * Invalid JSON types are rejected before validation begins. * Use `MinLen` and `MaxLen` for boundary validation instead of manual string checks. * Use custom error codes for stable frontend-facing validation contracts. * String validation rules remain reusable across APIs, background jobs, CLI tools, and internal services. * Validation logic stays explicit and independent from transport layers. * String rules can be combined with format validators such as email, UUID, URL, JSON, and regex validation. # Time Rules Time rules validate typed `time.Time` fields defined with `form.Time`. They are useful for validating dates, deadlines, booking windows, expiration times, scheduled jobs, event ranges, availability periods, and other time-based application data. ## Common Use Cases [#common-use-cases] Time rules are commonly used for: * booking dates * appointment scheduling * event start and end times * token expiration dates * subscription periods * billing cycles * deadline validation * date range validation * availability windows * scheduled background jobs ## TL;DR [#tldr] | Rule | Description | | -------------------------------------------------- | ----------------------------------------------------- | | `rules.After(field, t)` | Requires the time value to be after `t` | | `rules.AfterWithCode(field, t, code)` | Same as `After` with a custom error code | | `rules.Before(field, t)` | Requires the time value to be before `t` | | `rules.BeforeWithCode(field, t, code)` | Same as `Before` with a custom error code | | `rules.BetweenTime(field, min, max)` | Requires the time value to be between `min` and `max` | | `rules.BetweenTimeWithCode(field, min, max, code)` | Same as `BetweenTime` with a custom error code | | `rules.TimeEquals(field, expected)` | Requires the time value to exactly match `expected` | | `rules.TimeEqualsWithCode(field, expected, code)` | Same as `TimeEquals` with a custom error code | | `rules.NotZeroTime(field)` | Requires the time value to be non-zero | | `rules.NotZeroTimeWithCode(field, code)` | Same as `NotZeroTime` with a custom error code | | `rules.IsTime(field)` | Validates that the field contains a time value | ## Defining Time Fields [#defining-time-fields] Time validation starts by defining typed `time.Time` fields. ```go TimeForm := struct { StartAt form.TimeField[TimeRulesRequest] EndAt form.TimeField[TimeRulesRequest] ExpireAt form.TimeField[TimeRulesRequest] }{ StartAt: form.Time[TimeRulesRequest]("start_at", func(r *TimeRulesRequest) time.Time { return r.StartAt }), EndAt: form.Time[TimeRulesRequest]("end_at", func(r *TimeRulesRequest) time.Time { return r.EndAt }), ExpireAt: form.Time[TimeRulesRequest]("expire_at", func(r *TimeRulesRequest) time.Time { return r.ExpireAt }), } ``` Each field contains: * the validation response field name * typed accessors * reusable schema references * strongly typed `time.Time` access ## Applying Time Rules [#applying-time-rules] Time rules are attached directly to typed field references. ```go return form.Schema[TimeRulesRequest]{ rules.NotZeroTime(TimeForm.StartAt), rules.After(TimeForm.StartAt, time.Now()), rules.Before(TimeForm.EndAt, time.Now().Add(30*24*time.Hour)), } ``` This keeps validation logic: * explicit * reusable * composable * transport-independent * type-safe ## Rule Examples [#rule-examples] ### After [#after] Requires the time value to be after the provided reference time. ```go rules.After(TimeForm.StartAt, time.Now()) ``` Typical use cases: * future appointments * booking start dates * scheduled jobs * expiration windows *** ### AfterWithCode [#afterwithcode] Requires the time value to be after the reference time and returns a custom validation code. ```go const CodeMustBeFuture = form.Code("must_be_future") rules.AfterWithCode( TimeForm.StartAt, time.Now(), CodeMustBeFuture, ) ``` Useful for frontend-friendly API validation responses. *** ### Before [#before] Requires the time value to be before the provided reference time. ```go rules.Before(TimeForm.EndAt, time.Now().Add(30*24*time.Hour)) ``` Typical use cases: * maximum booking windows * expiration limits * trial periods * scheduled release deadlines *** ### BeforeWithCode [#beforewithcode] Requires the time value to be before the reference time and returns a custom validation code. ```go const CodeTooFarInFuture = form.Code("too_far_in_future") rules.BeforeWithCode( TimeForm.EndAt, time.Now().Add(30*24*time.Hour), CodeTooFarInFuture, ) ``` *** ### BetweenTime [#betweentime] Requires the time value to be between the provided minimum and maximum time. ```go rules.BetweenTime( TimeForm.StartAt, time.Now(), time.Now().Add(30*24*time.Hour), ) ``` Typical use cases: * valid booking windows * subscription periods * allowed scheduling ranges * event availability windows *** ### BetweenTimeWithCode [#betweentimewithcode] Requires the time value to stay within the provided time range and returns a custom validation code. ```go const CodeInvalidDateRange = form.Code("invalid_date_range") rules.BetweenTimeWithCode( TimeForm.StartAt, time.Now(), time.Now().Add(30*24*time.Hour), CodeInvalidDateRange, ) ``` *** ### TimeEquals [#timeequals] Requires the time value to exactly match the expected time. ```go rules.TimeEquals(TimeForm.StartAt, expectedStart) ``` This is useful for strict workflows where a timestamp must match a known value. *** ### TimeEqualsWithCode [#timeequalswithcode] Requires the time value to exactly match the expected time and returns a custom validation code. ```go const CodeInvalidTimestamp = form.Code("invalid_timestamp") rules.TimeEqualsWithCode( TimeForm.StartAt, expectedStart, CodeInvalidTimestamp, ) ``` *** ### NotZeroTime [#notzerotime] Requires the time value to be non-zero. ```go rules.NotZeroTime(TimeForm.StartAt) ``` Typical use cases: * required date fields * required scheduling input * required expiration values *** ### NotZeroTimeWithCode [#notzerotimewithcode] Requires the time value to be non-zero and returns a custom validation code. ```go const CodeDateRequired = form.Code("date_required") rules.NotZeroTimeWithCode( TimeForm.StartAt, CodeDateRequired, ) ``` *** ### IsTime [#istime] Checks whether the field contains a valid time value. ```go rules.IsTime(TimeForm.StartAt) ``` For typed Go structures this rule is mostly useful for API symmetry because invalid JSON time values are usually rejected during request decoding before schema validation begins. ## Complete Example [#complete-example] ```go package main import ( "time" "github.com/netlifeguru/form" "github.com/netlifeguru/form/rules" ) type TimeRulesRequest struct { StartAt time.Time `json:"start_at"` EndAt time.Time `json:"end_at"` ExpireAt time.Time `json:"expire_at"` } func TimeRulesSchema() form.Schema[TimeRulesRequest] { now := time.Now() max := now.Add(30 * 24 * time.Hour) TimeForm := struct { StartAt form.TimeField[TimeRulesRequest] EndAt form.TimeField[TimeRulesRequest] ExpireAt form.TimeField[TimeRulesRequest] }{ StartAt: form.Time[TimeRulesRequest]("start_at", func(r *TimeRulesRequest) time.Time { return r.StartAt }), EndAt: form.Time[TimeRulesRequest]("end_at", func(r *TimeRulesRequest) time.Time { return r.EndAt }), ExpireAt: form.Time[TimeRulesRequest]("expire_at", func(r *TimeRulesRequest) time.Time { return r.ExpireAt }), } return form.Schema[TimeRulesRequest]{ rules.NotZeroTime(TimeForm.StartAt), rules.After(TimeForm.StartAt, now), rules.Before(TimeForm.EndAt, max), rules.BetweenTime(TimeForm.ExpireAt, now, max), } } ``` ## Notes [#notes] * Time rules operate on typed Go `time.Time` values. * Invalid JSON time formats are rejected during request decoding before validation begins. * Use `NotZeroTime` for required date or timestamp fields. * Use `After`, `Before`, and `BetweenTime` for scheduling, booking, and expiration workflows. * Time comparison uses Go’s standard `time.Time` comparison methods. * Be careful when using `time.Now()` directly in schemas if the schema is cached globally. Prefer schema functions when validation depends on the current time. * Custom error codes help stabilize frontend-facing validation contracts. * Time validation remains explicit and independent from HTTP, JSON, CLI, or background worker transport layers. # Low-Level Transactions Low-level transactions are useful when you want to work with `db.Query` values directly. This is common when: * queries are prepared before execution * queries are loaded from SQL files * queries are selected with `db.Dialect` * you want explicit control over `db.Raw`, `ExecQuery`, `GetQuery`, `ValueQuery`, or `MapsQuery` * transaction logic should stay close to the lower-level `db.Conn` interface The transaction behavior is the same as in the high-level transaction API. If the callback returns an error, the transaction is rolled back. If the callback returns `nil`, the transaction is committed. ## Basic Idea [#basic-idea] Create `db.Query` values using `db.Raw`. ```go q, err := db.Raw(` UPDATE users SET active = ? WHERE id = ? `, active, id) if err != nil { return err } ``` Execute the query inside a transaction with the transactional connection. ```go err = conn.TransactionCtx(ctx, func(tx db.Conn) error { _, err := db.ExecQuery(ctx, tx, q) return err }) ``` Inside the callback, always use `tx`, not the outer connection. ## MySQL Low-Level Transaction [#mysql-low-level-transaction] MySQL uses `?` placeholders. ```go package main import ( "context" "fmt" "time" "github.com/netlifeguru/db" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } func RunLowLevelTransaction(ctx context.Context, conn db.Conn) (int64, error) { var insertedID int64 err := conn.TransactionCtx(ctx, func(tx db.Conn) error { insertQuery, err := db.Raw(` INSERT INTO users (name, email, active) VALUES (?, ?, ?) `, "Low Level User", "low.level@example.com", true) if err != nil { return err } result, err := db.ExecQuery(ctx, tx, insertQuery) if err != nil { return err } insertedID = result.LastInsertId() updateQuery, err := db.Raw(` UPDATE users SET active = ? WHERE id = ? `, false, insertedID) if err != nil { return err } if _, err := db.ExecQuery(ctx, tx, updateQuery); err != nil { return err } getQuery, err := db.Raw(` SELECT * FROM users WHERE id = ? LIMIT 1 `, insertedID) if err != nil { return err } user, found, err := db.GetQuery[User](ctx, tx, getQuery) if err != nil { return err } if found { fmt.Printf("%d | %s | %s | active=%v | created_at=%s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } deleteQuery, err := db.Raw(` DELETE FROM users WHERE id = ? `, insertedID) if err != nil { return err } if _, err := db.ExecQuery(ctx, tx, deleteQuery); err != nil { return err } return nil }) if err != nil { return 0, err } return insertedID, nil } ``` ## PostgreSQL Low-Level Transaction [#postgresql-low-level-transaction] PostgreSQL uses numbered placeholders and commonly reads inserted IDs with `RETURNING`. ```go package main import ( "context" "errors" "fmt" "time" "github.com/netlifeguru/db" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } func RunLowLevelTransaction(ctx context.Context, conn db.Conn) (int64, error) { var insertedID int64 err := conn.TransactionCtx(ctx, func(tx db.Conn) error { insertQuery, err := db.Raw(` INSERT INTO users (name, email, active) VALUES ($1, $2, $3) RETURNING id `, "Low Level User", "low.level@example.com", true) if err != nil { return err } id, found, err := db.ValueQuery[int64](ctx, tx, insertQuery) if err != nil { return err } if !found { return errors.New("insert did not return id") } insertedID = id updateQuery, err := db.Raw(` UPDATE users SET active = $1 WHERE id = $2 `, false, insertedID) if err != nil { return err } if _, err := db.ExecQuery(ctx, tx, updateQuery); err != nil { return err } getQuery, err := db.Raw(` SELECT * FROM users WHERE id = $1 LIMIT 1 `, insertedID) if err != nil { return err } user, found, err := db.GetQuery[User](ctx, tx, getQuery) if err != nil { return err } if found { fmt.Printf("%d | %s | %s | active=%v | created_at=%s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } deleteQuery, err := db.Raw(` DELETE FROM users WHERE id = $1 `, insertedID) if err != nil { return err } if _, err := db.ExecQuery(ctx, tx, deleteQuery); err != nil { return err } return nil }) if err != nil { return 0, err } return insertedID, nil } ``` ## Using Dialect Queries [#using-dialect-queries] Low-level transactions work well with dialect SQL. ```go type Queries struct { InsertUser db.DialectSQL `json:"InsertUser"` UpdateUser db.DialectSQL `json:"UpdateUser"` GetUser db.DialectSQL `json:"GetUser"` DeleteUser db.DialectSQL `json:"DeleteUser"` } ``` Select the correct query for the active driver inside the transaction. ```go err := conn.TransactionCtx(ctx, func(tx db.Conn) error { insertQuery, err := db.Dialect(tx, queries.InsertUser, name, email, active) if err != nil { return err } result, err := db.ExecQuery(ctx, tx, insertQuery) if err != nil { return err } id := result.LastInsertId() getQuery, err := db.Dialect(tx, queries.GetUser, id) if err != nil { return err } user, found, err := db.GetQuery[User](ctx, tx, getQuery) if err != nil { return err } if found { fmt.Println(user.Name) } return nil }) ``` For PostgreSQL inserts with `RETURNING`, use `ValueQuery` instead of `ExecQuery`. ```go insertQuery, err := db.Dialect(tx, queries.InsertUser, name, email, active) if err != nil { return err } id, found, err := db.ValueQuery[int64](ctx, tx, insertQuery) if err != nil { return err } if !found { return errors.New("insert did not return id") } ``` ## When to Use Low-Level Transactions [#when-to-use-low-level-transactions] Use low-level transaction helpers when: * you already have `db.Query` values * you use `db.Raw` explicitly * you use `db.Dialect` to select driver-specific SQL * queries are loaded from SQL files * you want to call `ExecQuery`, `GetQuery`, `ValueQuery`, or `MapsQuery` * you want explicit control over query preparation before execution ## When to Use High-Level Helpers Instead [#when-to-use-high-level-helpers-instead] Use high-level helpers when the SQL is local and simple. ```go err := conn.TransactionCtx(ctx, func(tx db.Conn) error { _, err := db.Update(ctx, tx, ` UPDATE users SET active = ? WHERE id = ? `, active, id) return err }) ``` This is easier to read for small transaction blocks. Use low-level helpers when you need reusable or dialect-selected query objects. ## Commit and Rollback [#commit-and-rollback] Commit and rollback behavior is controlled by the callback return value. Return `nil` to commit. ```go err := conn.TransactionCtx(ctx, func(tx db.Conn) error { q, err := db.Raw(` UPDATE users SET active = ? WHERE id = ? `, active, id) if err != nil { return err } _, err = db.ExecQuery(ctx, tx, q) return err }) ``` Return an error to roll back. ```go err := conn.TransactionCtx(ctx, func(tx db.Conn) error { q, err := db.Raw(` UPDATE users SET active = ? WHERE id = ? `, active, id) if err != nil { return err } if _, err := db.ExecQuery(ctx, tx, q); err != nil { return err } return errors.New("rollback this transaction") }) ``` ## Use the Transaction Connection [#use-the-transaction-connection] Inside the transaction callback, always use the `tx` connection. ```go err := conn.TransactionCtx(ctx, func(tx db.Conn) error { q, err := db.Raw(` UPDATE users SET active = ? WHERE id = ? `, active, id) if err != nil { return err } _, err = db.ExecQuery(ctx, tx, q) return err }) ``` Do not use the outer connection inside the transaction callback. ```go err := conn.TransactionCtx(ctx, func(tx db.Conn) error { q, err := db.Raw(` UPDATE users SET active = ? WHERE id = ? `, active, id) if err != nil { return err } // Wrong: this uses the outer connection, not the transaction. _, err = db.ExecQuery(ctx, conn, q) return err }) ``` ## Driver Support [#driver-support] Low-level SQL transactions are supported by: * MySQL * PostgreSQL Scylla does not use SQL transactions in the same way. For Scylla conditional writes, see the Scylla Lightweight Transactions guide. For grouped Scylla writes, see the Scylla Batches guide. ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL low-level transactions](https://github.com/netlifeguru/examples/db/mysql/31_transactions_low_level) * [PostgreSQL low-level transactions](https://github.com/netlifeguru/examples/db/postgresql/31_transactions_low_level) # Scylla Batches Scylla batches are used to group multiple CQL statements into a single batch request. They are supported by the Scylla driver and are specific to Scylla/CQL workloads. Batches are different from SQL transactions. They do not behave like MySQL or PostgreSQL transactions with `BEGIN`, `COMMIT`, and `ROLLBACK`. They also do not replace lightweight transactions. Use batches when your Scylla data model requires grouped writes. ## Basic Idea [#basic-idea] The Scylla driver exposes batch helpers through the connection. Common batch types include: * logged batch * unlogged batch * counter batch A typical flow is: 1. create a batch 2. add CQL statements 3. execute the batch ```go batch := conn.NewLoggedBatch(ctx) err := batch.AddSQL(` INSERT INTO users_by_id (id, email, name, active, created_at) VALUES (?, ?, ?, ?, ?) `, id, email, name, active, createdAt) if err != nil { return err } err = batch.Execute() if err != nil { return err } ``` ## Batch Types [#batch-types] | Batch type | Purpose | | -------------- | ------------------------------------------------- | | Logged batch | Group writes that should be coordinated by Scylla | | Unlogged batch | Group writes without logged batch coordination | | Counter batch | Group counter updates | The exact behavior depends on Scylla and CQL semantics. Use batches carefully and only when they match your data model. ## Complete Example [#complete-example] This example writes the same user into two query tables. ```go package main import ( "context" "time" "github.com/gocql/gocql" "github.com/netlifeguru/db" "github.com/netlifeguru/db-scylla" ) const insertUserByIDQuery = ` INSERT INTO users_by_id (id, email, name, active, created_at) VALUES (?, ?, ?, ?, ?) ` const insertUserByEmailQuery = ` INSERT INTO users_by_email (email, id, name, active, created_at) VALUES (?, ?, ?, ?, ?) ` func InsertUserWithBatch( ctx context.Context, conn scylla.BatchConn, name string, email string, active bool, ) (string, error) { id := gocql.TimeUUID() createdAt := time.Now().UTC() batch := conn.NewLoggedBatch(ctx) if err := batch.AddSQL(insertUserByIDQuery, id, email, name, active, createdAt); err != nil { return "", err } if err := batch.AddSQL(insertUserByEmailQuery, email, id, name, active, createdAt); err != nil { return "", err } if err := batch.Execute(); err != nil { return "", err } return id.String(), nil } ``` ## Using db.Query [#using-dbquery] You can also add a prepared `db.Query` to a batch. ```go q, err := db.Raw(insertUserByIDQuery, id, email, name, active, createdAt) if err != nil { return err } batch := conn.NewLoggedBatch(ctx) if err := batch.Add(q); err != nil { return err } if err := batch.Execute(); err != nil { return err } ``` This is useful when your queries are selected or prepared before the batch is created. ## Low-Level Batch Example [#low-level-batch-example] The low-level pattern uses `db.Raw` and `batch.Add`. ```go package main import ( "context" "time" "github.com/gocql/gocql" "github.com/netlifeguru/db" "github.com/netlifeguru/db-scylla" ) func InsertUserWithLowLevelBatch( ctx context.Context, conn scylla.BatchConn, name string, email string, active bool, ) (string, error) { id := gocql.TimeUUID() createdAt := time.Now().UTC() q1, err := db.Raw(insertUserByIDQuery, id, email, name, active, createdAt) if err != nil { return "", err } q2, err := db.Raw(insertUserByEmailQuery, email, id, name, active, createdAt) if err != nil { return "", err } batch := conn.NewLoggedBatch(ctx) if err := batch.Add(q1); err != nil { return "", err } if err := batch.Add(q2); err != nil { return "", err } if err := batch.Execute(); err != nil { return "", err } return id.String(), nil } ``` ## Accessing Batch Methods [#accessing-batch-methods] The shared `db.Conn` interface is intentionally database-agnostic. Scylla batch methods are Scylla-specific, so use the Scylla batch interface when you need them. ```go batchConn, ok := conn.(scylla.BatchConn) if !ok { return errors.New("connection does not support Scylla batches") } ``` Then create a batch: ```go batch := batchConn.NewLoggedBatch(ctx) ``` This keeps the shared `db.Conn` interface clean while still allowing Scylla-specific behavior when needed. ## Logged Batch [#logged-batch] Use `NewLoggedBatch` when you need Scylla logged batch behavior. ```go batch := conn.NewLoggedBatch(ctx) ``` Logged batches coordinate batch writes through Scylla’s batch log. Use them only when the data model requires that behavior. ## Unlogged Batch [#unlogged-batch] Use `NewUnloggedBatch` for unlogged batch writes. ```go batch := conn.NewUnloggedBatch(ctx) ``` Unlogged batches avoid the batch log overhead, but they do not provide the same coordination behavior as logged batches. ## Counter Batch [#counter-batch] Use `NewCounterBatch` for counter updates. ```go batch := conn.NewCounterBatch(ctx) ``` Counter batches are intended for CQL counter operations. ## Batches vs Lightweight Transactions [#batches-vs-lightweight-transactions] Batches and lightweight transactions solve different problems. | Feature | Batch | Lightweight transaction | | ---------------- | ----------------------------------------------------- | ------------------------------------ | | Purpose | Group multiple statements | Apply one conditional statement | | Condition check | no | yes | | Uses `[applied]` | no | yes | | Common CQL | `BEGIN BATCH ... APPLY BATCH` behavior through driver | `IF NOT EXISTS`, `IF column = value` | | Best for | grouped writes | compare-and-set style writes | Use lightweight transactions when you need a conditional write. Use batches when you need grouped CQL statements. ## Batches vs SQL Transactions [#batches-vs-sql-transactions] Scylla batches are not SQL transactions. They do not provide the same behavior as: ```sql BEGIN; COMMIT; ROLLBACK; ``` If you come from MySQL or PostgreSQL, do not treat Scylla batches as a direct transaction replacement. Design your Scylla schema and write path according to Scylla data modeling rules. ## When to Use Batches [#when-to-use-batches] Use batches when: * your Scylla data model requires grouped writes * you write the same logical entity into multiple query tables * you need logged, unlogged, or counter batch behavior * you understand the performance and modeling implications ## When Not to Use Batches [#when-not-to-use-batches] Avoid batches when: * you are trying to make Scylla behave like a relational database * the statements are unrelated * a normal single-table write is enough * you want a replacement for SQL transactions * you are using batches for every write without a modeling reason ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [Scylla batch](https://github.com/netlifeguru/examples/db/scylla/30_batch) * [Scylla low-level batch](https://github.com/netlifeguru/examples/db/scylla/31_batch_low_level) # Scylla Lightweight Transactions Scylla lightweight transactions are not SQL transactions in the MySQL or PostgreSQL sense. They do not create a multi-statement transaction block and they do not behave like `BEGIN`, `COMMIT`, or `ROLLBACK`. In Scylla, lightweight transactions are conditional CQL operations. They are commonly used with conditions such as: ```sql IF NOT EXISTS ``` or: ```sql IF column = value ``` The operation returns whether the condition was applied. ## Basic Idea [#basic-idea] A lightweight transaction checks a condition and applies the write only when the condition succeeds. Example: ```sql INSERT INTO users_by_email (email, id, name, active, created_at) VALUES (?, ?, ?, ?, ?) IF NOT EXISTS ``` If the row does not exist, Scylla applies the insert. If the row already exists, Scylla does not apply the insert. The result contains a special column: ```text [applied] ``` Use this value to check whether the write succeeded. ## When to Use Lightweight Transactions [#when-to-use-lightweight-transactions] Use lightweight transactions when you need conditional writes. Typical use cases include: * insert only if a row does not already exist * reserve a unique email * create a record only once * update only if a value still matches an expected state * prevent overwriting existing data accidentally Do not use lightweight transactions as a general replacement for SQL transactions. They are more expensive than regular writes and should be used only when the condition is needed. ## Insert If Not Exists [#insert-if-not-exists] This example inserts a user by email only if the email does not already exist. ```go const insertUserByEmailQuery = ` INSERT INTO users_by_email (email, id, name, active, created_at) VALUES (?, ?, ?, ?, ?) IF NOT EXISTS ` ``` Execute the query using `db.Maps`. ```go rows, err := db.Maps(ctx, conn, insertUserByEmailQuery, email, id, name, active, createdAt) if err != nil { return false, err } ``` Then check the `[applied]` value. ```go if len(rows) == 0 { return false, nil } applied, ok := rows[0]["[applied]"].(bool) if !ok { return false, nil } return applied, nil ``` ## Complete Example [#complete-example] ```go package main import ( "context" "time" "github.com/gocql/gocql" "github.com/netlifeguru/db" ) const insertUserByEmailQuery = ` INSERT INTO users_by_email (email, id, name, active, created_at) VALUES (?, ?, ?, ?, ?) IF NOT EXISTS ` func InsertUserIfEmailAvailable( ctx context.Context, conn db.Conn, name string, email string, active bool, ) (bool, string, error) { id := gocql.TimeUUID() createdAt := time.Now().UTC() rows, err := db.Maps( ctx, conn, insertUserByEmailQuery, email, id, name, active, createdAt, ) if err != nil { return false, "", err } if len(rows) == 0 { return false, "", nil } applied, ok := rows[0]["[applied]"].(bool) if !ok { return false, "", nil } if !applied { return false, "", nil } return true, id.String(), nil } ``` ## Usage Example [#usage-example] ```go applied, id, err := InsertUserIfEmailAvailable( ctx, conn, "Jane Doe", "jane.doe@example.com", true, ) if err != nil { return err } if !applied { fmt.Println("email already exists") return nil } fmt.Printf("created user id=%s\n", id) ``` ## Map Result [#map-result] Lightweight transaction results are easiest to inspect as maps. For an `IF NOT EXISTS` insert, Scylla returns a row that includes: ```text [applied] ``` Example applied result: ```go map[string]any{ "[applied]": true, } ``` Example not-applied result may include existing column values depending on the CQL statement and driver behavior. ```go map[string]any{ "[applied]": false, "email": "jane.doe@example.com", } ``` Always check `[applied]` before treating the operation as successful. ## Low-Level Variant [#low-level-variant] You can also use `db.Raw` and `db.MapsQuery`. ```go q, err := db.Raw( insertUserByEmailQuery, email, id, name, active, createdAt, ) if err != nil { return false, err } rows, err := db.MapsQuery(ctx, conn, q) if err != nil { return false, err } ``` Then inspect `[applied]` the same way. ```go applied, ok := rows[0]["[applied]"].(bool) if !ok || !applied { return false, nil } ``` ## Conditional Update [#conditional-update] Lightweight transactions can also be used for conditional updates. ```sql UPDATE users_by_id SET active = ? WHERE id = ? IF active = ? ``` Example: ```go const updateUserIfActiveQuery = ` UPDATE users_by_id SET active = ? WHERE id = ? IF active = ? ` ``` Execute it and check `[applied]`. ```go rows, err := db.Maps(ctx, conn, updateUserIfActiveQuery, false, id, true) if err != nil { return false, err } applied, ok := rows[0]["[applied]"].(bool) if !ok { return false, nil } return applied, nil ``` ## Lightweight Transactions vs SQL Transactions [#lightweight-transactions-vs-sql-transactions] | Feature | MySQL/PostgreSQL SQL transactions | Scylla lightweight transactions | | ------------------------------- | ------------------------------------- | ------------------------------- | | Multi-statement block | yes | no | | `BEGIN` / `COMMIT` / `ROLLBACK` | yes | no | | Conditional write | possible with SQL logic | primary use case | | Rollback behavior | callback error rolls back transaction | not applicable | | Result check | error or commit/rollback | `[applied]` value | | Common use | atomic multi-step operations | compare-and-set style writes | ## Batches Are Different [#batches-are-different] Scylla batches are not the same as lightweight transactions. A batch groups multiple CQL statements into one batch request. A lightweight transaction checks a condition and applies a write only if the condition succeeds. Use the Scylla Batches guide for batch examples. ## Recommended Usage [#recommended-usage] Use lightweight transactions only when the condition is part of the data model. Good examples: ```sql INSERT ... IF NOT EXISTS ``` ```sql UPDATE ... IF active = true ``` Avoid using lightweight transactions for every write. For normal writes, use `db.Insert`, `db.Update`, or `db.Delete`. ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [Scylla lightweight transaction](https://github.com/netlifeguru/examples/db/scylla/32_lightweight_transaction) * [Scylla lightweight transaction map](https://github.com/netlifeguru/examples/db/scylla/33_lightweight_transaction_map) * [Scylla lightweight transaction low level](https://github.com/netlifeguru/examples/db/scylla/34_lightweight_transaction_low_level) # SQL Transactions SQL transactions are supported by the MySQL and PostgreSQL drivers. Use transactions when multiple database operations must either all succeed or all fail together. Typical use cases include: * inserting related records * updating multiple rows consistently * reading data after a write inside the same transaction * deleting related data * grouping business operations into one atomic unit Scylla does not use SQL transactions in the same way as MySQL or PostgreSQL. For Scylla conditional writes, see the Scylla Lightweight Transactions guide. ## Basic Idea [#basic-idea] Use `TransactionCtx` on a `db.Conn`. ```go err := conn.TransactionCtx(ctx, func(tx db.Conn) error { // use tx inside the transaction return nil }) ``` The callback receives a transactional `db.Conn`. Use that `tx` connection with the same shared helpers: ```go db.Insert(ctx, tx, query, args...) db.Update(ctx, tx, query, args...) db.Get[T](ctx, tx, query, args...) db.Delete(ctx, tx, query, args...) ``` If the callback returns an error, the transaction is rolled back. If the callback returns `nil`, the transaction is committed. ## MySQL Transaction Example [#mysql-transaction-example] MySQL uses `?` placeholders and can read inserted IDs from `LastInsertId`. ```go package main import ( "context" "fmt" "time" "github.com/netlifeguru/db" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } const insertUserQuery = ` INSERT INTO users (name, email, active) VALUES (?, ?, ?) ` const updateUserQuery = ` UPDATE users SET name = ?, email = ?, active = ? WHERE id = ? ` const selectUserQuery = ` SELECT * FROM users WHERE id = ? LIMIT 1 ` const deleteUserQuery = ` DELETE FROM users WHERE id = ? ` func RunUserTransaction(ctx context.Context, conn db.Conn) (int64, error) { var insertedID int64 err := conn.TransactionCtx(ctx, func(tx db.Conn) error { result, err := db.Insert( ctx, tx, insertUserQuery, "Transaction User", "transaction.user@example.com", true, ) if err != nil { return err } insertedID = result.LastInsertId() if _, err := db.Update( ctx, tx, updateUserQuery, "Updated Transaction User", "updated.transaction.user@example.com", false, insertedID, ); err != nil { return err } user, found, err := db.Get[User](ctx, tx, selectUserQuery, insertedID) if err != nil { return err } if found { fmt.Printf("%d | %s | %s | active=%v | created_at=%s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } if _, err := db.Delete(ctx, tx, deleteUserQuery, insertedID); err != nil { return err } return nil }) if err != nil { return 0, err } return insertedID, nil } ``` ## PostgreSQL Transaction Example [#postgresql-transaction-example] PostgreSQL uses numbered placeholders and commonly reads inserted IDs with `RETURNING`. ```go package main import ( "context" "errors" "fmt" "time" "github.com/netlifeguru/db" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } const insertUserQuery = ` INSERT INTO users (name, email, active) VALUES ($1, $2, $3) RETURNING id ` const updateUserQuery = ` UPDATE users SET name = $1, email = $2, active = $3 WHERE id = $4 ` const selectUserQuery = ` SELECT * FROM users WHERE id = $1 LIMIT 1 ` const deleteUserQuery = ` DELETE FROM users WHERE id = $1 ` func RunUserTransaction(ctx context.Context, conn db.Conn) (int64, error) { var insertedID int64 err := conn.TransactionCtx(ctx, func(tx db.Conn) error { id, found, err := db.Value[int64]( ctx, tx, insertUserQuery, "Transaction User", "transaction.user@example.com", true, ) if err != nil { return err } if !found { return errors.New("insert did not return id") } insertedID = id if _, err := db.Update( ctx, tx, updateUserQuery, "Updated Transaction User", "updated.transaction.user@example.com", false, insertedID, ); err != nil { return err } user, found, err := db.Get[User](ctx, tx, selectUserQuery, insertedID) if err != nil { return err } if found { fmt.Printf("%d | %s | %s | active=%v | created_at=%s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } if _, err := db.Delete(ctx, tx, deleteUserQuery, insertedID); err != nil { return err } return nil }) if err != nil { return 0, err } return insertedID, nil } ``` ## Usage Example [#usage-example] The application code is the same for MySQL and PostgreSQL once `connectDB` returns a `db.Conn`. ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { ctx := context.Background() err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } id, err := RunUserTransaction(ctx, conn) if err != nil { log.Fatal(err) } fmt.Printf("transaction completed for user id=%d\n", id) } ``` ## Commit and Rollback [#commit-and-rollback] The transaction callback controls commit and rollback behavior. Return `nil` to commit: ```go err := conn.TransactionCtx(ctx, func(tx db.Conn) error { _, err := db.Update(ctx, tx, updateUserQuery, active, id) if err != nil { return err } return nil }) ``` Return an error to rollback: ```go err := conn.TransactionCtx(ctx, func(tx db.Conn) error { _, err := db.Update(ctx, tx, updateUserQuery, active, id) if err != nil { return err } return errors.New("rollback this transaction") }) ``` If any operation fails, return the error from the callback. ```go err := conn.TransactionCtx(ctx, func(tx db.Conn) error { if _, err := db.Insert(ctx, tx, insertUserQuery, name, email, active); err != nil { return err } if _, err := db.Update(ctx, tx, updateUserQuery, active, id); err != nil { return err } return nil }) ``` ## Use the Transaction Connection [#use-the-transaction-connection] Inside the callback, always use the `tx` connection. ```go err := conn.TransactionCtx(ctx, func(tx db.Conn) error { return db.Update(ctx, tx, updateUserQuery, active, id) }) ``` Do not accidentally use the outer connection inside the transaction callback. ```go err := conn.TransactionCtx(ctx, func(tx db.Conn) error { // Wrong: this uses the outer connection, not the transaction. _, err := db.Update(ctx, conn, updateUserQuery, active, id) return err }) ``` Using the outer connection means the operation may run outside the transaction. ## Driver Differences [#driver-differences] The transaction API is shared, but SQL syntax still depends on the driver. | Topic | MySQL | Postgres | | ----------------- | ----------------------- | --------------------------- | | Placeholder style | `?` | `$1`, `$2`, `$3` | | Insert ID | `result.LastInsertId()` | `RETURNING id` + `db.Value` | | Transaction API | `TransactionCtx` | `TransactionCtx` | | Shared helpers | supported | supported | ## When to Use SQL Transactions [#when-to-use-sql-transactions] Use SQL transactions when: * multiple statements must succeed or fail together * you need atomic write behavior * you read after writing and need transaction consistency * you update related records * rollback should happen automatically on error ## When Not to Use SQL Transactions [#when-not-to-use-sql-transactions] Do not use SQL transactions for Scylla workloads. Scylla uses different patterns such as: * query-driven data modeling * idempotent writes * batches * lightweight transactions with conditional CQL Do not keep transactions open longer than necessary. Avoid: * slow network calls inside a transaction * long-running background work inside a transaction * waiting for user input inside a transaction * large unbounded loops inside a transaction ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL transactions](https://github.com/netlifeguru/examples/db/mysql/25_transactions) * [PostgreSQL transactions](https://github.com/netlifeguru/examples/db/postgresql/25_transactions) # Automatic Cleanup Cleanup behavior is controlled through the `MaxLogFiles` configuration option, which defines the maximum number of managed log files kept on disk. **Example configuration:** ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ Dir: "./logs", MaxLogFiles: 5, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() slog.Info("logger initialized") } ``` **Example directory before cleanup:** ```text logs/ ├── 2026-05-08-0001.log ├── 2026-05-09-0001.log ├── 2026-05-10-0001.log ├── 2026-05-11-0001.log ├── 2026-05-11-0002.log ├── 2026-05-11-0003.log └── 2026-05-11-0004.log ``` **After cleanup:** ```text logs/ ├── 2026-05-10-0001.log ├── 2026-05-11-0001.log ├── 2026-05-11-0002.log ├── 2026-05-11-0003.log └── 2026-05-11-0004.log ``` Cleanup runs automatically during log rotation and removes the oldest managed log files first. **This helps:** * reduce disk usage * simplify maintenance * avoid oversized log directories * keep deployments predictable over time Default value: ```go MaxLogFiles: 5 ``` # Automatic Log Directory This removes the need to manually create directories before starting the application and ensures that log files can be written immediately after the logger is initialized. **By default, logs are stored in:** ```text ./logs ``` **Custom directories can be configured using the `Dir` option:** ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ Dir: "./storage/logs", }) if err != nil { slog.Error(err.Error()) } defer closer.Close() slog.Info("logger initialized") } ``` **Example generated structure:** ```text storage/ └── logs/ └── 2026-05-11-0001.log ``` **This behavior is especially useful for:** * fresh deployments * containerized environments * CI/CD pipelines * ephemeral infrastructure * automated server provisioning # Daily Log Naming Each generated log file includes: * the current date * a sequential rotation number **Example file names:** ```text 2026-05-11-0001.log 2026-05-11-0002.log 2026-05-12-0001.log ``` **This naming strategy makes it easier to:** * identify logs by date * track rotated files * archive logs * debug production incidents * integrate with external log processing systems **Example configuration:** ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ Dir: "./logs", MaxFileSize: 10 * 1024 * 1024, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() slog.Info("application started") } ``` **Generated directory structure:** ```text logs/ ├── 2026-05-11-0001.log ├── 2026-05-11-0002.log └── 2026-05-12-0001.log ``` The sequence number increases automatically whenever file rotation occurs during the same day. A new daily sequence starts automatically after the date changes. # File Rotation Log files are rotated automatically to prevent uncontrolled file growth and to keep log archives organized and manageable in long-running applications. ## Rotation [#rotation] Rotation is controlled through: * `MaxFileSize` * daily file naming **Example configuration:** ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ Dir: "./logs", MaxFileSize: 10 * 1024 * 1024, // 10MB }) if err != nil { slog.Error(err.Error()) } defer closer.Close() slog.Info("logger started") } ``` ## Generated files [#generated-files] **Example output:** ```text logs/ ├── 2026-05-11-0001.log ├── 2026-05-11-0002.log └── 2026-05-12-0001.log ``` **Rotation occurs when:** * the current file exceeds the configured size limit * the current date changes **This provides:** * predictable file sizes * easier log archival * simpler debugging by date * improved compatibility with log collection systems The default maximum file size is `10MB` File rotation is performed automatically during writes without requiring manual maintenance or application restarts. # Graceful Shutdown **Closing the logger is important for:** * flushing pending writes * closing active file descriptors * finalizing rotated log files * ensuring clean application shutdown **Example usage:** ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ TerminalOutput: true, }) if err != nil { slog.Error(err.Error()) return } defer closer.Close() slog.Info("application started") } ``` Using `defer closer.Close()` immediately after initialization is the recommended pattern. This guarantees that the logger is closed correctly even if the application exits due to an error or early return. The shutdown process is safe for concurrent applications and integrates naturally with standard Go application lifecycle patterns. # Structured JSON Logging Each log entry is stored as a single JSON object containing standardized fields such as timestamp, level, and message. **Example log entry:** ```json { "time": "2026-05-11T11:08:42.135294+02:00", "level": "INFO", "msg": "server started" } ``` JSON logging is enabled automatically when file logging is active. ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ Dir: "./logs", MinLevel: slog.LevelInfo, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() slog.Info("server started") slog.Warn("high memory usage") slog.Error("database connection failed") } ``` **Generated log file:** ```toml logs/2026-05-11-0001.log ``` **Structured JSON logs are useful for:** * centralized log aggregation * observability platforms * production monitoring * analytics pipelines * machine parsing and indexing **Additional structured fields can be attached directly through the standard `slog` API:** ```go slog.Info("user authenticated", slog.Int("user_id", 42), slog.String("role", "admin"), ) ``` **File output:** ```json { "time": "2026-05-11T12:23:38.592038+02:00", "level": "INFO", "msg": "user authenticated", "user_id": 42, "role": "admin" } ``` **Terminal output** ```bash 2026-05-11 12:23:38 [INFO] user authenticated user_id=42 role=admin ``` # Configuration * **Dir** `string`: Directory for log files. Defaults to `./logs`. * **TerminalOutput** `bool`: Enables or disables console output. * **MinLevel** `slog.Level`: Minimum level for file logging. Defaults to `slog.LevelInfo`. * **ConsoleMinLevel** `slog.Level`: Minimum level specifically for console output. If not set, it follows `MinLevel`. * **MaxFileSize** `int64`: Maximum size per log file before rotation. Defaults to `10MB`. * **MaxLogFiles** `int`: Maximum number of managed log files to keep. Defaults to `5`. * **DisableColors** `bool`: Disables ANSI colors in console output. * **AddSource** `bool`: Adds source file and line information to structured log output. ```go type Config struct { Dir string TerminalOutput bool MinLevel slog.Level ConsoleMinLevel slog.Level MaxFileSize int64 MaxLogFiles int DisableColors bool AddSource bool } ``` ### Dir [#dir] Directory where log files are stored. If the directory does not exist, it is created automatically during logger initialization. **Default directory:** ```toml ./logs ``` ### TerminalOutput [#terminaloutput] Enables or disables terminal logging. When enabled, logs are written both to files and to the console using a human-readable colored format. Typical usage: * enabled during local development * disabled in production containers ### MinLevel [#minlevel] Defines the minimum log level written to log files. Example: ```toml MinLevel: slog.LevelInfo ``` **This means:** * INFO * WARN * ERROR are written to files, while lower levels such as `DEBUG` are ignored. ### ConsoleMinLevel [#consoleminlevel] Defines the minimum log level used specifically for terminal output. This allows terminal logging to be more or less verbose independently of file logging. **Example:** ```go MinLevel: slog.LevelInfo ConsoleMinLevel: slog.LevelDebug ``` In this configuration: * files store only `INFO` and above * terminal output also includes `DEBUG` If not set, the console uses the same level as `MinLevel`. ### MaxFileSize [#maxfilesize] Maximum size of a single log file before automatic rotation occurs. **Example:** ```go MaxFileSize: 10 * 1024 * 1024 ``` Default: ```toml 10MB ``` When the limit is reached, the logger automatically creates a new file. ### MaxLogFiles [#maxlogfiles] Maximum number of rotated log files kept on disk. Older files are removed automatically during cleanup. **Example:** ```go MaxLogFiles: 5 ``` ### DisableColors [#disablecolors] Disables ANSI terminal colors. Useful when: * logs are redirected to files * running inside CI pipelines * terminal does not support ANSI colors ### AddSource [#addsource] Adds source file and line information to log entries. **Example output:** ```json { "source": { "function": "main.main", "file": "/examples/add_source/main.go", "line": 19 } } ``` Useful during development and debugging, but may slightly increase logging overhead. The configuration system is designed to remain fully compatible with Go’s standard `log/slog` ecosystem while providing production-oriented logging features such as rotation, structured output, and terminal formatting. # Installation The logger package requires Go `1.22` or newer. `logger` is a high-performance structured logging package built directly on top of Go’s standard `log/slog` API. It extends the standard logging ecosystem with: * structured JSON logging * colorized terminal output * automatic file rotation * daily log archiving * context-aware logging * optimized file writing for production workloads ## Install [#install] Install the package using `go get`: ```bash go get github.com/netlifeguru/logger ``` After installation, import the package into your application: ```go import "github.com/netlifeguru/logger" ``` The package is built directly on top of Go’s standard log/slog package and does not require any external dependencies. Once installed, continue with the Quick Start guide to initialize the logger and create your first structured log output. # Quick Start The example below initializes the logger as a lightweight extension over Go’s standard `log/slog` package with colorized terminal output enabled. ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ TerminalOutput: true, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() slog.Info("hello world") } ``` Run: ```bash go mod init example go get github.com/netlifeguru/logger go run main.go ``` During initialization, the logger automatically creates a `log` directory and writes structured JSON log files using daily log rotation. Example log file: ```text log/2026-05-11-0001.log ``` ### Example log entry: [#example-log-entry] ```json { "time": "2026-05-11T10:47:36.067863+02:00", "level": "INFO", "msg": "hello world" } ``` Default log fields: * `time` - log timestamp * `level` - log severity level * `msg` - log message # Optimized File Writer File writes are handled efficiently to reduce allocation overhead and keep logging predictable even in busy applications. **This is especially useful for:** * HTTP APIs * background workers * queue consumers * long-running services * applications with frequent structured logs **Example usage:** ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ Dir: "./logs", TerminalOutput: false, MinLevel: slog.LevelInfo, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() slog.Info("file logging enabled", slog.String("service", "api"), slog.String("environment", "production"), ) } ``` **Example file output:** ```json { "time": "2026-05-11T12:56:31.879333+02:00", "level": "INFO", "msg": "file logging enabled", "service": "api", "environment": "production" } ``` **The file writer is optimized for:** * structured JSON output * repeated log writes * concurrent application workloads * reduced allocation overhead * predictable file rotation behavior For applications where logging is part of a hot path, this keeps persisted logs useful without introducing unnecessary runtime pressure. # Thread Safe Design After initialization, the global `slog` logger can be used from HTTP handlers, background workers, scheduled jobs, and other concurrent parts of the application without additional synchronization. **Example usage:** ```go package main import ( "log/slog" "sync" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ TerminalOutput: true, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func(workerID int) { defer wg.Done() slog.Info("worker completed", slog.Int("worker_id", workerID), ) }(i) } wg.Wait() } ``` **Example terminal output:** ```bash 2026-05-11 12:54:39 [INFO] worker completed worker_id=0 2026-05-11 12:54:39 [INFO] worker completed worker_id=4 2026-05-11 12:54:39 [INFO] worker completed worker_id=2 2026-05-11 12:54:39 [INFO] worker completed worker_id=1 2026-05-11 12:54:39 [INFO] worker completed worker_id=3 ``` **Example file output:** ```json { "time": "2026-05-11T12:54:39.74895+02:00", "level": "INFO", "msg": "worker completed", "worker_id": 0 } { "time": "2026-05-11T12:54:39.748946+02:00", "level": "INFO", "msg": "worker completed", "worker_id": 4 } { "time": "2026-05-11T12:54:39.748947+02:00", "level": "INFO", "msg": "worker completed", "worker_id": 2 } { "time": "2026-05-11T12:54:39.749042+02:00", "level": "INFO", "msg": "worker completed", "worker_id": 1 } { "time": "2026-05-11T12:54:39.748997+02:00", "level": "INFO", "msg": "worker completed", "worker_id": 3 } ``` **Thread-safe logging is important for:** * HTTP servers * background workers * concurrent jobs * message consumers * high-load applications The logger handles concurrent writes internally, so application code does not need to wrap logging calls with mutexes. # Architecture The router is designed as a lightweight HTTP routing layer built around Go’s standard `net/http` ecosystem. Its architecture focuses on: * fast route lookup * predictable middleware execution * low per-request overhead * standard `net/http` compatibility * explicit application behavior * reusable request context handling *** ## High-Level Flow [#high-level-flow] At a high level, each request goes through the following steps: ```text incoming HTTP request ↓ route lookup ↓ middleware chain ↓ handler execution ↓ response ↓ context cleanup ``` *** ## Routing Tree [#routing-tree] Routes are stored internally in a radix-tree based structure. This allows efficient matching for: * static routes * parameterized routes * wildcard routes * mounted handlers * grouped route prefixes Static routes can be resolved directly, while dynamic routes are matched through the routing tree. *** ## Method Matching [#method-matching] HTTP methods are matched using internal bitmasks. This allows the router to efficiently support routes with one or more HTTP methods: ```go r.HandleFunc("/documents", "GET POST", handler) ``` Instead of repeatedly comparing method strings at runtime, supported methods are converted into compact method masks during route registration. *** ## Middleware Pipeline [#middleware-pipeline] Middleware is composed as a lightweight wrapping chain. ```text middleware ↓ middleware ↓ handler ``` Middleware can be registered globally: ```go r.Use(router.RequestID()) ``` or on route groups: ```go api.Use(AuthMiddleware) ``` This keeps middleware behavior explicit and predictable. *** ## Request Context [#request-context] Each router-native handler receives a `*router.Context`. ```go func(w http.ResponseWriter, req *http.Request, ctx *router.Context) ``` The context provides: * route parameters * request-scoped storage * middleware-to-handler communication Contexts are reused internally to reduce per-request allocations. *** ## Standard Library Compatibility [#standard-library-compatibility] The router works directly with Go’s standard HTTP interfaces. It supports router-native handlers: ```go func(w http.ResponseWriter, req *http.Request, ctx *router.Context) ``` and standard `net/http` handlers through mounting: ```go r.Mount("/metrics", promhttp.Handler()) ``` This allows applications to integrate existing Go libraries without adapter-heavy designs. *** ## Logging and Observability [#logging-and-observability] The router uses Go’s standard `log/slog` ecosystem. It does not configure logging automatically. Instead, applications can decide which handler to use, where logs should be written, and whether request logging should be enabled. Request logging is opt-in: ```go r.Use(router.Logger()) ``` *** ## Design Principles [#design-principles] The router follows a few core principles: * explicit configuration * standard library compatibility * predictable runtime behavior * low allocation routing * middleware as composition * no hidden global behavior * simple integration with existing Go services *** ## Notes [#notes] The router is intentionally focused on HTTP routing and request handling. Features such as logging, file rotation, TLS termination, metrics collection, and application-specific dependencies remain explicit so applications can choose the infrastructure that fits their deployment model. # Graceful Shutdown Graceful shutdown allows the HTTP server to stop accepting new requests while allowing active requests to finish safely. This is important for: * production deployments * container orchestration * Kubernetes shutdowns * zero-downtime restarts * resource cleanup * preventing interrupted requests Without graceful shutdown, terminating the application immediately can interrupt active client requests and leave resources in an inconsistent state. *** ## Basic Graceful Shutdown [#basic-graceful-shutdown] The router package supports graceful server shutdown using Go’s standard shutdown mechanisms. Example: ```go package main import ( "context" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/netlifeguru/router" ) func main() { r := router.New() r.GET("/", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Write([]byte("hello world")) }) go func() { if err := r.ListenAndServe(":8000"); err != nil && err != http.ErrServerClosed { slog.Error("failed to start server", "error", err) os.Exit(1) } }() slog.Info("server started", "addr", ":8000") stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM, ) <-stop slog.Info("shutdown signal received") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := r.Shutdown(ctx); err != nil { slog.Error("graceful shutdown failed", "error", err) os.Exit(1) } slog.Info("server stopped gracefully") } ``` *** ## Shutdown Flow [#shutdown-flow] Graceful shutdown follows this lifecycle: ```text signal received ↓ stop accepting new connections ↓ wait for active requests ↓ close resources ↓ shutdown complete ``` This prevents requests from being terminated unexpectedly during deployment or restart operations. *** ## Shutdown Timeout [#shutdown-timeout] Shutdown should always use a timeout. Example: ```go ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ``` This prevents the application from hanging forever if requests never complete. Typical timeout values: | Environment | Recommended Timeout | | --------------------- | ------------------- | | Local Development | `5s` | | APIs | `10s` | | Long-running requests | `30s+` | *** ## Kubernetes Compatibility [#kubernetes-compatibility] Graceful shutdown works well with Kubernetes and container orchestration systems. Typical deployment lifecycle: ```text SIGTERM ↓ readiness probe fails ↓ traffic stops ↓ active requests complete ↓ container exits ``` This helps avoid dropped requests during rolling deployments. *** ## Resource Cleanup [#resource-cleanup] Graceful shutdown is also useful for releasing resources safely. Examples: * database connections * message queues * background workers * file handles * metrics exporters * external clients Example: ```go defer db.Close() defer logger.Close() ``` *** ## Multi-Server Shutdown [#multi-server-shutdown] When using multi-server mode: ```go r.MultiListenAndServe(...) ``` the router manages shutdown for all listeners automatically. This ensures all active HTTP servers stop consistently during application termination. *** ## Logging Shutdown Events [#logging-shutdown-events] Shutdown events should always be logged. Example: ```go slog.Info("shutdown signal received") ``` Example output: ```text 2026-05-12 22:31:10 [INFO] shutdown signal received 2026-05-12 22:31:10 [INFO] server stopped gracefully ``` This simplifies operational monitoring and deployment debugging. *** ## Notes [#notes] Graceful shutdown only waits for active HTTP requests. Applications are still responsible for safely stopping: * background goroutines * queue consumers * scheduled jobs * external connections * long-running workers before exiting the process. # Performance The router package is designed for low-latency HTTP workloads and high request throughput. The routing engine focuses on: * zero-allocation route matching * optimized radix-tree traversal * low-overhead middleware execution * minimized lock contention * pooled request context reuse * efficient HTTP method matching The goal is predictable request handling performance without unnecessary abstraction overhead. *** ## Benchmark Results [#benchmark-results] Benchmarks executed on: ```text Apple M2 Max Go 1.25 darwin/arm64 ``` Example benchmark results: ```text Benchmark_NLG_StaticMatch 21.04 ns/op 0 B/op 0 allocs/op Benchmark_NLG_Wildcard 36.18 ns/op 0 B/op 0 allocs/op Benchmark_NLG_Param_1 28.30 ns/op 0 B/op 0 allocs/op Benchmark_NLG_Param_4 44.80 ns/op 0 B/op 0 allocs/op Benchmark_NLG_Param_7 62.35 ns/op 0 B/op 0 allocs/op Benchmark_NLG_Param_50 329.8 ns/op 0 B/op 0 allocs/op Benchmark_NLG_NotFound 25.60 ns/op 0 B/op 0 allocs/op Benchmark_NLG_Router_Lookup_Bitmask 25.16 ns/op 0 B/op 0 allocs/op ``` These benchmarks include: * static routes * parameterized routes * wildcard routes * large route trees * route lookup operations *** ## Zero Allocation Routing [#zero-allocation-routing] Static route matching performs with: ```text 0 B/op 0 allocs/op ``` This reduces: * garbage collector pressure * latency spikes * memory churn * allocation overhead under load The router avoids unnecessary allocations during route lookup and parameter extraction. *** ## Radix Tree Routing [#radix-tree-routing] Routes are internally stored in a radix-tree structure. This allows efficient lookup for: * static routes * parameterized routes * wildcard branches * grouped routes The router prioritizes: 1. static routes 2. parameterized routes 3. wildcard routes to keep matching predictable and fast. *** ## Fast Pattern Matchers [#fast-pattern-matchers] Prepared pattern matchers are implemented as direct Go functions instead of full regular expressions. Example: ```go /users/{id:isDigits} ``` instead of: ```go /users/{id:(\\d+)} ``` This reduces the overhead of regexp evaluation while keeping route validation readable. Prepared matchers are optimized for common route patterns such as: * IDs * UUIDs * slugs * dates * safe paths * hexadecimal values *** ## Context Pooling [#context-pooling] Request contexts are internally pooled and reused between requests. This minimizes allocations for: * parameter storage * request-scoped values * middleware communication Pooling is automatic and fully transparent to applications. *** ## HTTP Method Bitmasking [#http-method-bitmasking] HTTP methods are internally represented using bitmasks. This allows fast method matching without repeated string comparisons. Example: ```go r.HandleFunc("/users", "GET POST PUT", handler) ``` Method matching remains efficient even when multiple methods are registered on the same route. *** ## Middleware Performance [#middleware-performance] Middleware execution uses a lightweight wrapping chain: ```text middleware -> middleware -> handler ``` The middleware pipeline avoids reflection and runtime dispatching overhead. This keeps per-request middleware execution predictable and efficient. *** ## Wildcard Performance [#wildcard-performance] Wildcard routes are optimized for branch-style matching. Example: ```go /files/* ``` Wildcards are intended for: * static assets * frontend SPAs * mounted services * reverse proxies while still maintaining low lookup overhead. *** ## Benchmarking [#benchmarking] Run benchmarks locally: ```bash go test -bench=. -benchmem ``` Example: ```text Benchmark_NLG_StaticMatch 21.04 ns/op 0 allocs/op ``` Use benchmarks to evaluate route complexity, middleware overhead, and application-specific workloads. *** ## Design Goals [#design-goals] The router is designed around several core principles: * low allocations * predictable routing behavior * standard library compatibility * explicit middleware composition * efficient route matching * production-ready concurrency safety The focus is long-term maintainability and operational simplicity rather than framework abstraction layers. *** ## Notes [#notes] Benchmark results vary depending on: * CPU architecture * Go version * route complexity * middleware stack * request patterns Always benchmark using realistic application workloads before making performance assumptions. # Request Lifecycle The request lifecycle describes what happens when an HTTP request enters the router. Understanding this flow helps when working with: * middleware order * request logging * route parameters * recovery handling * request-scoped context * response behavior *** ## Lifecycle Overview [#lifecycle-overview] ```text incoming HTTP request ↓ request context allocation ↓ route matching ↓ method validation ↓ middleware chain ↓ handler execution ↓ response writing ↓ panic recovery, if needed ↓ context cleanup ``` *** ## 1. Incoming Request [#1-incoming-request] Every request starts as a standard Go HTTP request: ```go *http.Request ``` The router is compatible with Go’s `net/http` ecosystem and can be used anywhere an `http.Handler` is expected. *** ## 2. Request Context [#2-request-context] Before routing continues, the router prepares a request-scoped `*router.Context`. This context is used for: * route parameters * temporary request values * middleware-to-handler communication ```go func(w http.ResponseWriter, req *http.Request, ctx *router.Context) ``` The context exists only for the lifetime of the request. *** ## 3. Route Matching [#3-route-matching] The router attempts to match the request path against registered routes. It supports: * static routes * parameterized routes * wildcard routes * mounted handlers * grouped routes Example: ```go r.GET("/users/{id}", handler) ``` Request: ```text GET /users/42 ``` Captured parameter: ```text id = 42 ``` *** ## 4. Method Validation [#4-method-validation] After the path is matched, the router validates the HTTP method. Example: ```go r.HandleFunc("/documents", "GET POST", handler) ``` Allowed methods: ```text GET POST ``` If the path exists but the method is not allowed, the router returns: ```text 405 Method Not Allowed ``` *** ## 5. Middleware Execution [#5-middleware-execution] Before the handler runs, middleware is executed. Middleware can be registered globally: ```go r.Use(router.RequestID()) ``` or on route groups: ```go api.Use(AuthMiddleware) ``` Middleware can run logic before and after the next handler: ```go r.Use(func(next router.HandlerFunc) router.HandlerFunc { return func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { // before handler next(w, req, ctx) // after handler } }) ``` *** ## 6. Handler Execution [#6-handler-execution] If the route and method match, the final handler is executed. ```go r.GET("/users/{id}", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { id := ctx.Param("id") w.Write([]byte(id)) }) ``` The handler can: * read request data * access route parameters * read or write context values * write headers * write the response body *** ## 7. Response Writing [#7-response-writing] Handlers write responses using the standard `http.ResponseWriter`. Example: ```go w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok"}`)) ``` Because the router uses standard Go HTTP primitives, response behavior follows normal `net/http` rules. *** ## 8. Panic Recovery [#8-panic-recovery] If a handler panics, the router can recover and return a controlled response. ```go r.Recovery(func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte("Internal Server Error")) }) ``` If no custom recovery handler is configured, the router returns a standard internal server error response. Recovered panics are logged through Go’s `log/slog` ecosystem. *** ## 9. Context Cleanup [#9-context-cleanup] After the request completes, the router releases the request context. The context is reused internally to reduce allocations. Applications should not store `*router.Context` beyond the request lifecycle. Incorrect: ```go saved = ctx ``` Correct: ```go userID := ctx.Get("user_id") ``` Copy the value you need instead of keeping the context itself. *** ## Middleware Order [#middleware-order] Middleware order is important. Middleware is applied in the order it is registered: ```go r.Use(A) r.Use(B) r.Use(C) ``` Execution flow: ```text A before ↓ B before ↓ C before ↓ handler ↓ C after ↓ B after ↓ A after ``` This allows middleware to wrap behavior around later middleware and handlers. *** ## Example Flow [#example-flow] For this route: ```go r.Use(router.RequestID()) r.Use(router.Logger()) r.GET("/users/{id}", handler) ``` Request: ```text GET /users/42 ``` Lifecycle: ```text request received ↓ context prepared ↓ route /users/{id} matched ↓ id parameter extracted ↓ RequestID middleware runs ↓ Logger middleware starts timer ↓ handler executes ↓ Logger middleware writes request log ↓ context released ``` *** ## Notes [#notes] The router keeps the request lifecycle explicit. Routing, middleware, logging, recovery, and response writing remain separate concerns, making application behavior easier to understand, test, and debug. # Colorized Terminal Output The logger supports human-readable colored terminal output designed for local development and debugging. When terminal output is enabled, log entries are printed using ANSI colors based on the log level, making it easier to visually distinguish informational messages, warnings, notices, and errors during runtime. **Enable terminal logging through the `TerminalOutput` configuration option:** ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ TerminalOutput: true, ConsoleMinLevel: slog.LevelDebug, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() slog.Debug("debug message") slog.Info("application started") slog.Warn("warning message") slog.Error("error message") } ``` **Example terminal output:** ```bash 2026-05-11 12:18:13 [DEBUG] debug message 2026-05-11 12:18:13 [INFO] application started 2026-05-11 12:18:13 [WARN] warning message 2026-05-11 12:18:13 [ERROR] error message ``` Terminal logging is typically enabled during development while production environments primarily rely on structured JSON log files. **Colors can be disabled using:** ```go DisableColors: true ``` **Example file output:** ```json {"time":"2026-05-11T12:18:13.367992+02:00","level":"INFO","msg":"application started"} {"time":"2026-05-11T12:18:13.368261+02:00","level":"WARN","msg":"warning message"} {"time":"2026-05-11T12:18:13.36828+02:00","level":"ERROR","msg":"error message"} ``` **This is useful when:** * logs are redirected to files * running inside CI environments * terminal output does not support ANSI colors # Custom Notice Level **`LevelNotice` is positioned between `INFO` and `WARN` and is intended for events such as:** * application startup * graceful shutdown * deployment events * configuration loading * maintenance notifications * important business operations **Example usage:** ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ TerminalOutput: true, ConsoleMinLevel: slog.LevelDebug, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() logger.Notice("server started") logger.Notice("configuration loaded") } ``` **Example terminal output:** ```text 2026-05-11 12:45:43 server started 2026-05-11 12:45:43 configuration loaded ``` LevelNotice is useful when standard informational logs are too noisy but warnings or errors would incorrectly imply a problem state. **Context-aware variants are also supported:** ```go logger.NoticeContext(ctx, "background worker initialized") ``` **Full example:** ```go package main import ( "context" "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ Dir: "./logs", TerminalOutput: true, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() ctx := context.Background() logger.NoticeContext(ctx, "important event", "order_id", 1) } ``` Internally, the notice level integrates directly with the logger’s structured logging pipeline and behaves like any other `slog` level. # Separate Log Levels This allows applications to store stable production logs in files while showing more detailed information in the console during development or debugging sessions. **Configuration is controlled through:** * `MinLevel` * `ConsoleMinLevel` **Example configuration:** ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ TerminalOutput: true, MinLevel: slog.LevelInfo, ConsoleMinLevel: slog.LevelDebug, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() slog.Debug("debug message") slog.Info("application started") slog.Warn("warning message") } ``` **In this configuration:** * log files store: * INFO * WARN * ERROR * terminal output displays: * DEBUG * INFO * WARN * ERROR **Example terminal output:** ```text 2026-05-11 11:42:18 DBG debug message 2026-05-11 11:42:18 INF application started 2026-05-11 11:42:18 WRN warning message ``` **Example file output:** ```json { "time": "2026-05-11T11:42:18.102391+02:00", "level": "INFO", "msg": "application started" } ``` If `ConsoleMinLevel` is not specified, terminal output automatically uses the same level as `MinLevel`. **This separation is especially useful for:** * local debugging * development environments * production observability * reducing noise in persisted logs # Context Logging Context logging is useful for propagating request-scoped or operation-scoped information across HTTP handlers, background workers, database operations, and distributed services. **Supported methods include:** * **InfoContext**: Logs informational messages with context propagation * **WarnContext**: Logs warning messages related to recoverable or unexpected situations * **ErrorContext**: Logs errors and failure-related events with contextual information * **DebugContext**: Logs detailed debugging information useful during development * **NoticeContext**: Logs important operational events intended to remain highly visible **Example usage:** ```go package main import ( "context" "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ TerminalOutput: true, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() ctx := context.Background() slog.InfoContext(ctx, "request started") slog.WarnContext(ctx, "slow database query") logger.NoticeContext(ctx, "background sync completed") } ``` **Example output:** ```json { "time": "2026-05-11T12:50:26.854582+02:00", "level": "INFO", "msg": "request started" } { "time": "2026-05-11T12:50:26.855051+02:00", "level": "WARN", "msg": "slow database query" } ``` **Context-aware logging helps:** * propagate request lifecycle information * correlate logs across operations * integrate with tracing systems * support middleware-driven architectures * improve observability in concurrent applications Because the logger is fully compatible with log/slog, existing slog context-based workflows continue to work without modification. # Source Tracking Source tracking is enabled with the `AddSource` configuration option and is useful during development, debugging, and troubleshooting production issues where the exact log origin matters. **Example configuration:** ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ TerminalOutput: true, AddSource: true, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() slog.Info("source tracking enabled") } ``` **Example file output:** ```json { "time": "2026-05-11T12:53:20.834553+02:00", "level": "INFO", "source": { "function": "main.main", "file": "/examples/test/main.go", "line": 20 }, "msg": "source tracking enabled" } ``` **Source tracking helps with:** * locating where a log entry was created * debugging complex application flows * tracing errors back to specific files * improving development visibility Because source tracking requires runtime caller information, it may add a small amount of overhead and is usually enabled selectively when needed. # Standard Compatible `logger` is built directly on top of Go’s standard `log/slog` package. After initialization, the logger automatically registers itself as the global `slog` logger, allowing the application to continue using the standard logging API without introducing a custom logging interface. This keeps the package fully compatible with existing Go logging patterns while extending `slog` with structured file logging, terminal output, file rotation, and additional helper functionality. ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ Dir: "./logs", TerminalOutput: true, DisableColors: false, MinLevel: slog.LevelInfo, ConsoleMinLevel: slog.LevelDebug, MaxFileSize: 10 * 1024 * 1024, MaxLogFiles: 5, AddSource: true, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() slog.Info("logger initialized") } ``` Once initialized, all standard logging methods such as `slog.Info`, `slog.Warn`, and `slog.Error` automatically use the configured logger instance. **The example above enables:** * structured JSON file logging * colorized terminal output * source location tracking * automatic log rotation * automatic cleanup of old log files while remaining fully compatible with the standard Go logging ecosystem. # Structured Chaining Structured chaining allows applications to attach shared metadata once and automatically include it in all subsequent log entries produced by that logger instance. **This is useful for:** * request-scoped logging * service-specific loggers * worker identifiers * module separation * tenant or user tracking **Example usage:** ```go package main import ( "log/slog" "github.com/netlifeguru/logger" ) func main() { closer, err := logger.Init(logger.Config{ TerminalOutput: true, }) if err != nil { slog.Error(err.Error()) } defer closer.Close() apiLogger := slog.With( slog.String("service", "api"), slog.String("version", "v1"), ) apiLogger.Info("server started") userLogger := apiLogger.With( slog.Int("user_id", 42), ) userLogger.Info("user authenticated") } ``` **Example output:** ```text 2026-05-11 12:51:26 [INFO] server started service=api version=v1 2026-05-11 12:51:26 [INFO] user authenticated service=api version=v1 user_id=42 ``` **Example file output:** ```json { "time": "2026-05-11T12:52:05.952406+02:00", "level": "INFO", "msg": "server started", "service": "api", "version": "v1" } { "time": "2026-05-11T12:52:05.953044+02:00", "level": "INFO", "msg": "user authenticated", "service": "api", "version": "v1", "user_id": 42 } ``` Attributes added through chained loggers are inherited automatically, making it easy to build hierarchical structured logging pipelines without repeating common metadata. # Examples [https://github.com/netlifeguru/logger](https://github.com/netlifeguru/logger) The repository contains standalone examples covering common logging scenarios, structured logging workflows, and advanced logger configuration patterns. **Available Examples** * [Getting started](https://github.com/netlifeguru/examples/logger/getting-started) * [Initialize logger](https://github.com/netlifeguru/examples/logger/init_logger) * [File logging](https://github.com/netlifeguru/examples/logger/file_logging) * [File only](https://github.com/netlifeguru/examples/logger/file_only) * [File and console output](https://github.com/netlifeguru/examples/logger/file_and_console_only) * [Structured logging](https://github.com/netlifeguru/examples/logger/structured) * [Context logging](https://github.com/netlifeguru/examples/logger/context_logging) * [Logger with attributes](https://github.com/netlifeguru/examples/logger/with_basic) * [Chained loggers](https://github.com/netlifeguru/examples/logger/chaining) * [Notice level](https://github.com/netlifeguru/examples/logger/notice) * [Notice with context](https://github.com/netlifeguru/examples/logger/notice_context) * [Add source location](https://github.com/netlifeguru/examples/logger/add_source) * [Detect custom logger level](https://github.com/netlifeguru/examples/logger/detect_custom_logger) * [Server logging](https://github.com/netlifeguru/examples/logger/server_logging) * [Combined usage](https://github.com/netlifeguru/examples/logger/combined) Practical examples are available in the official examples repository: ```text https://github.com/netlifeguru/examples/logger ``` # Project Information ## Documentation [#documentation] Official package documentation, guides, examples, and integration tutorials are available at: * [https://netlife.guru/docs/go/logger](https://netlife.guru/docs/go/logger) API reference is available on pkg.go.dev: * [https://pkg.go.dev/github.com/netlifeguru/logger](https://pkg.go.dev/github.com/netlifeguru/logger) Source code and issue tracking: * [https://github.com/netlifeguru/logger](https://github.com/netlifeguru/logger) *** ## Design Goals [#design-goals] The logger package is designed around a few core principles: * Predictable runtime performance * Minimal heap allocations * Simple architecture with minimal dependencies * Native compatibility with Go’s standard `log/slog` package * Production-ready structured logging * Efficient concurrent logging for backend services The package internally uses reusable pooled objects and optimized file writing strategies to reduce allocation pressure under high log throughput. *** ## Performance [#performance] Benchmarks performed on Apple M2 Max: * **ConsoleHandler**: \~62 ns/op (1 alloc/op) * **FileWriter**: \~1600 ns/op (0 alloc/op) * **FileWriter (Parallel)**: \~2000 ns/op (0 alloc/op) The `FileWriter` achieves `0 allocs/op` during writes through internal buffer reuse and optimized write pipelines. Actual performance depends on: * terminal output usage * filesystem speed * log formatting * JSON serialization overhead * operating system buffering *** ## Versioning [#versioning] This project follows Semantic Versioning. See [`CHANGELOG.md`](https://github.com/netlifeguru/logger/blob/main/CHANGELOG.md) for release history, version updates, and breaking changes. *** ## Contributing [#contributing] Community contributions, discussions, bug reports, and pull requests are welcome. Please read [`CONTRIBUTING.md`](https://github.com/netlifeguru/logger/blob/main/CONTRIBUTING.md) before submitting pull requests or opening issues. *** ## Code of Conduct [#code-of-conduct] This project follows the Contributor Covenant Code of Conduct. Please read [`CODE_OF_CONDUCT.md`](https://github.com/netlifeguru/logger/blob/main/CODE_OF_CONDUCT.md) before participating in discussions or contributing to the project. *** ## Author [#author] Created and maintained by NetLife Guru s.r.o. Resources: * Documentation: [https://netlife.guru/docs](https://netlife.guru/docs) * GitHub: [https://github.com/netlifeguru](https://github.com/netlifeguru) * Contact: [info@netlife.guru](mailto:info@netlife.guru) *** ## License [#license] This project is licensed under the MIT License. See [`LICENSE`](https://github.com/netlifeguru/logger/blob/main/LICENSE) for full license information. # Group Middleware Middleware can be attached directly to a route group. This allows specific middleware to affect only a selected part of the application instead of all routes globally. Group middleware is useful for: * authentication * authorization * API versioning * admin-only routes * rate limiting * request validation * internal services *** ## Registering Middleware on a Group [#registering-middleware-on-a-group] Use `Group.Use(...)` to attach middleware to a route group. ```go api.Use(SimpleAuth) ``` Only routes inside that group are affected. *** ## Example [#example] ```go package main import ( "net/http" "github.com/netlifeguru/router" ) func SimpleAuth(next router.HandlerFunc) router.HandlerFunc { return func(w http.ResponseWriter, r *http.Request, ctx *router.Context) { apiKey := r.Header.Get("X-API-KEY") if apiKey != "secret-key" { http.Error(w, "Unauthorized: Invalid API Key", http.StatusUnauthorized) return } next(w, r, ctx) } } func main() { r := router.New() api := r.Group("/api") api.Use(SimpleAuth) api.GET("/data", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Write([]byte("Secret data accessed successfully!")) }) r.ListenAndServe(":8000") } ``` *** ## Testing the Endpoint [#testing-the-endpoint] Request without the API key: ```bash curl http://localhost:8000/api/data ``` Response: ```text Unauthorized: Invalid API Key ``` Request with the correct API key: ```bash curl -H "X-API-KEY: secret-key" http://localhost:8000/api/data ``` Response: ```text Secret data accessed successfully! ``` *** ## Middleware Scope [#middleware-scope] Middleware registered on a group affects only routes registered inside that group. Example: ```go public := r.Group("/public") api := r.Group("/api") api.Use(SimpleAuth) ``` Result: | Route | Middleware | | ----------- | ------------ | | `/public/*` | none | | `/api/*` | `SimpleAuth` | This allows public and protected routes to coexist cleanly inside the same application. *** ## Nested Groups [#nested-groups] Middleware inheritance is hierarchical. Example: ```go api := r.Group("/api") api.Use(SimpleAuth) admin := api.Group("/admin") ``` Routes inside `/api/admin` automatically inherit the middleware from `/api`. *** ## Notes [#notes] Group middleware is executed after global middleware registered through `r.Use(...)`. This allows applications to combine: * global middleware * group-specific middleware * route-specific behavior in a predictable middleware chain. # Middleware Middleware allows you to run logic before or after a route handler. It is commonly used for: * request logging * request IDs * real client IP detection * CORS * compression * cache headers * content validation * rate limiting * custom application logic Middleware uses the standard router middleware signature: ```go type Middleware func(router.HandlerFunc) router.HandlerFunc ``` *** ## Registering Middleware [#registering-middleware] Register global middleware with `r.Use`. ```go r.Use(router.Logger()) r.Use(router.RequestID()) r.Use(router.RealIP()) ``` Global middleware applies to all routes registered on the router. *** ## Built-in Middleware Overview [#built-in-middleware-overview] | Middleware / Helper | Type | Description | | ------------------------ | -----------: | ------------------------------------------------------------------- | | `Use` | Registration | Registers global middleware on the router | | `With` | Registration | Creates a scoped middleware group for selected routes | | `Group.Use` | Registration | Registers middleware only for a route group | | `UseDefaults` | Preset | Registers `GetHead`, `RequestID`, `RealIP`, and `NoCache` | | `Logger` | Middleware | Logs request method, host, path, query, request ID, and duration | | `RequestID` | Middleware | Adds a request ID to the request context and response header | | `RequestIDWithGenerator` | Middleware | Adds a request ID using a custom ID generator | | `RequestIDFromContext` | Helper | Reads the request ID from `context.Context` | | `RealIP` | Middleware | Resolves the real client IP and stores it in `X-Real-IP` | | `SetTrustedProxies` | Helper | Configures trusted proxy CIDR ranges for real IP detection | | `ClientIP` | Helper | Resolves the client IP from trusted proxy headers or remote address | | `CORS` | Middleware | Handles Cross-Origin Resource Sharing and preflight requests | | `NoCache` | Middleware | Adds headers that prevent browser and proxy caching | | `Compress` | Middleware | Applies gzip compression for selected content types | | `DefaultCompress` | Middleware | Applies gzip compression for common text and JSON content types | | `AllowContentType` | Middleware | Restricts requests based on the `Content-Type` header | | `ContentCharset` | Middleware | Validates accepted request character sets | | `GetHead` | Middleware | Treats `HEAD` requests as `GET` requests for handlers | | `CleanPath` | Middleware | Normalizes the request path before passing it to the handler | | `RateLimit` | Middleware | Limits request frequency per client and route | *** ## Example [#example] ```go package main import ( "compress/gzip" "log/slog" "net/http" "os" "github.com/netlifeguru/router" ) func main() { r := router.New() r.Use(router.Logger()) r.Use(router.RequestID()) if err := router.SetTrustedProxies([]string{"10.0.0.0/8"}); err != nil { slog.Error("failed to set trusted proxies", "error", err) } r.Use(router.RealIP()) r.Use(router.GetHead()) r.Use(router.CleanPath()) r.Use(router.CORS(router.CORSOptions{ AllowedOrigins: []string{"https://*", "http://*"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, ExposedHeaders: []string{"Link"}, AllowCredentials: false, MaxAge: 300, })) r.Use(router.NoCache()) r.Use(router.Compress( gzip.DefaultCompression, "text/html", "text/plain", "text/css", "application/javascript", "text/javascript", "application/json", )) r.Use(router.AllowContentType("application/json", "text/xml")) r.Use(router.ContentCharset("UTF-8", "Latin-1", "")) r.Use(func(next router.HandlerFunc) router.HandlerFunc { return func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { next(w, req, ctx) } }) r.GET("/", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusCreated) w.Write([]byte(`Hello World`)) }) if err := r.ListenAndServe(":8000"); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` *** ## Default Middleware [#default-middleware] Use `UseDefaults` to register a small default middleware set. ```go r.UseDefaults() ``` This registers: * `GetHead` * `RequestID` * `RealIP` * `NoCache` Use this when you want sensible defaults without configuring each middleware manually. *** ## Custom Middleware [#custom-middleware] Custom middleware can wrap any route handler. ```go r.Use(func(next router.HandlerFunc) router.HandlerFunc { return func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { // before handler next(w, req, ctx) // after handler } }) ``` This is useful for authentication, authorization, tracing, request-scoped dependencies, custom headers, and application-specific behavior. *** ## Notes [#notes] Middleware order matters. Middleware is executed in the order it is registered, while handlers are wrapped internally so each middleware can run logic before and after the next handler. For route-group specific middleware, use route groups and `Group.Use`. # Mounting Standard Handlers The router package supports mounting native Go `net/http` handlers directly into the routing tree. This allows seamless integration with: * third-party HTTP services * legacy applications * Prometheus metrics * Swagger UI * pprof * static file servers * external routers * standard `net/http` middleware Mounted handlers use the standard Go handler signature and do not require `*router.Context`. *** ## Mount vs MountFunc [#mount-vs-mountfunc] The router provides two mounting helpers: | Method | Description | | ----------- | ---------------------------------- | | `Mount` | Mounts a standard `http.Handler` | | `MountFunc` | Mounts a standard handler function | *** ## Mount [#mount] Use `Mount` when working with a type implementing `http.Handler`. ```go r.Mount("/service", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Mounted standard service")) })) ``` This is useful for: * external libraries * reusable handlers * middleware stacks * mounted sub-services *** ## MountFunc [#mountfunc] `MountFunc` is a convenience wrapper around `Mount`. ```go r.MountFunc("/serviceFunc", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Mounted standard function") }) ``` This avoids explicitly converting functions into `http.HandlerFunc`. *** ## Example [#example] ```go package main import ( "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/router" ) func main() { r := router.New() r.Mount("/service", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Mounted standard service (http.Handler)")) })) r.MountFunc("/serviceFunc", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Mounted standard function") }) r.Mount("/service/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") fmt.Fprintf(w, "Mounted service with ID: %s", id) })) r.MountFunc("/serviceFunc/{id}/abc", func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") fmt.Fprintf(w, "Mounted func with ID: %s", id) }) r.Mount("/services/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Wildcard captured path: %s", r.URL.Path) })) r.MountFunc("/servicesFunc/*", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Wildcard func captured path: %s", r.URL.Path) }) if err := r.ListenAndServe(":8000"); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` *** ## Route Parameters [#route-parameters] Mounted handlers still support route parameters. Parameters can be accessed using Go’s native `PathValue` API: ```go id := r.PathValue("id") ``` Example route: ```go r.Mount("/service/{id}", handler) ``` Request: ```text /service/42 ``` Result: ```text 42 ``` This keeps mounted handlers fully compatible with the router parameter system while preserving the standard `net/http` interface. *** ## Wildcard Routes [#wildcard-routes] Mounted handlers also support wildcard matching. Example: ```go r.Mount("/services/*", handler) ``` This captures all paths under: ```text /services/ ``` Example requests: ```text /services/api /services/api/v1/users /services/static/file.css ``` This is useful for: * mounting sub-routers * SPA frontends * reverse proxy handlers * static file services * external APIs *** ## Interoperability [#interoperability] Mounted handlers make the router fully compatible with the broader Go `net/http` ecosystem. This allows applications to combine router-native handlers: ```go func(w http.ResponseWriter, req *http.Request, ctx *router.Context) ``` with standard Go handlers: ```go func(w http.ResponseWriter, req *http.Request) ``` inside the same routing tree. ## Common Integrations [#common-integrations] Mounted handlers make it easy to integrate existing Go services and third-party libraries directly into the router. Examples: | Service | Example | | -------------------- | --------------------------------------------------------- | | Prometheus Metrics | `r.Mount("/metrics", promhttp.Handler())` | | GraphQL (gqlgen) | `r.Mount("/graphql/query", srv)` | | GraphQL Playground | `r.Mount("/graphql/playground", playground.Handler(...))` | | pprof Profiling | `r.Mount("/debug/pprof/", http.DefaultServeMux)` | | Swagger UI | `r.Mount("/swagger/", swaggerHandler)` | | Static File Server | `r.Mount("/public/", http.FileServer(...))` | | Reverse Proxy | `r.Mount("/api/", proxyHandler)` | | Legacy net/http Apps | `r.Mount("/legacy/", legacyHandler)` | | WebSocket Services | `r.Mount("/ws", websocketHandler)` | | Custom Admin Panels | `r.Mount("/admin/", adminHandler)` | Example Prometheus integration: ```go import "github.com/prometheus/client_golang/prometheus/promhttp" r.Mount("/metrics", promhttp.Handler()) ``` Example GraphQL integration using gqlgen: ```go g.Mount("/query", srv) g.Mount("/playground", playground.Handler(...)) ``` This allows applications to combine router-native APIs with existing Go ecosystem tooling while keeping the routing layer unified. *** ## Notes [#notes] Mounted handlers intentionally do not receive `*router.Context`. This keeps them fully compatible with standard Go tooling and third-party libraries without requiring adapter layers or custom wrappers. # Parameterized Routes Parameterized routes allow dynamic values to be captured from the URL path. ```go r.GET("/users/{id}", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { id := ctx.Param("id") w.Write([]byte(id)) }) ``` Request: ```text /users/42 ``` Captured parameter: ```text id = 42 ``` Route parameters are accessed through `ctx.Param`. ```go id := ctx.Param("id") ``` *** ## Why Use Route Patterns [#why-use-route-patterns] Route patterns allow the router to validate URL segments before the request reaches the handler. This is useful when a route expects a specific value format, such as: * numeric IDs * UUIDs * slugs * dates * safe filenames * hexadecimal tokens * base64 strings For example, if a route expects a UUID, the router can reject invalid requests before the handler runs. This keeps handlers cleaner and avoids unnecessary application logic for requests that do not match the expected URL shape. *** ## Basic Parameters [#basic-parameters] Use `{name}` to capture any single path segment. ```go r.GET("/users/{id}", handler) r.GET("/posts/{slug}", handler) ``` Examples: | Route | Request | Captured Value | | --------------- | -------------------- | -------------------- | | `/users/{id}` | `/users/42` | `id = 42` | | `/posts/{slug}` | `/posts/hello-world` | `slug = hello-world` | *** ## Prepared Pattern Matchers [#prepared-pattern-matchers] Prepared pattern matchers are named, built-in validators. They are faster and easier to read than custom regular expressions because they are implemented as direct Go functions. Syntax: ```text {name:matcher} ``` Example: ```go r.GET("/users/{id:isDigits}", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { id := ctx.Param("id") w.Write([]byte(id)) }) ``` This route matches: ```text /users/123 ``` but does not match: ```text /users/abc ``` *** ## Supported Pattern Matchers [#supported-pattern-matchers] | Matcher | Pattern | Description | Example | | -------------- | ------------------- | ---------------------------- | -------------------------------------- | | `isLowerAlpha` | `[a-z]+` | Lowercase letters only | `abc` | | `isUpperAlpha` | `[A-Z]+` | Uppercase letters only | `ABC` | | `isAlpha` | `[a-zA-Z]+` | Letters only | `Test` | | `isDigits` | `[0-9]+`, `\d+` | Digits only | `123456` | | `isAlnum` | `[a-zA-Z0-9]+` | Letters and digits | `user42` | | `isWord` | `\w+` | Letters, digits, underscore | `hello_world` | | `isSlugSafe` | `[\w\-]+` | Word characters and hyphen | `post-title` | | `isSlug` | `[a-z0-9\-]+` | Lowercase slug | `my-article` | | `isHex` | `[a-fA-F0-9]+` | Hexadecimal string | `3fA9` | | `isUUID` | UUID format | UUID value | `550e8400-e29b-41d4-a716-446655440000` | | `isSafeText` | `[a-zA-Z0-9 _.-]+` | Safe text value | `File name-1` | | `isUpperAlnum` | `[A-Z0-9]+` | Uppercase letters and digits | `ADMIN99` | | `isBase64` | `a-zA-Z0-9+/=` | Base64-safe string | `SGVsbG8=` | | `isDateYMD` | `\d{4}-\d{2}-\d{2}` | Date in `YYYY-MM-DD` format | `2026-05-12` | | `isSafePath` | `[a-zA-Z0-9/._-]+` | Safe path-like value | `img/uploads/logo.png` | | `any` | `.*` | Always matches | any value | *** ## Regex Parameters [#regex-parameters] For more specific validation, routes can use custom regular expressions. Syntax: ```text {name:regex} ``` Examples: | Route | Description | | -------------------------------------------- | ------------------------------------------------- | | `/user/{id:(\\d+)}` | Only numeric IDs | | `/post/{slug:([a-zA-Z0-9\-_]+)}` | Slug-friendly values with hyphens and underscores | | `/file/{filename:([\\S]+)}/{token:([0-9]+)}` | Multiple validated parameters in one route | Example: ```go r.GET("/user/{id:(\\d+)}", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { id := ctx.Param("id") w.Write([]byte(id)) }) ``` *** ## Named Matcher vs Regex [#named-matcher-vs-regex] These two routes behave similarly: ```go r.GET("/user/{id:isDigits}", handler) ``` ```go r.GET("/user/{id:(\\d+)}", handler) ``` The named matcher is preferred when available because it is: * easier to read * easier to maintain * faster than full regular expression matching * less error-prone Use custom regex only when the built-in matcher is not expressive enough. *** ## Multiple Parameters [#multiple-parameters] Routes can contain multiple parameters. ```go r.GET("/file/{filename:isSafeText}/{token:isDigits}", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { filename := ctx.Param("filename") token := ctx.Param("token") w.Write([]byte(filename + ":" + token)) }) ``` Example request: ```text /file/report.pdf/12345 ``` Captured values: ```text filename = report.pdf token = 12345 ``` *** ## How Matching Works [#how-matching-works] The router checks parameter constraints before executing the handler. If the URL does not match the expected pattern, the handler is not called. This allows route validation to happen at the routing layer instead of inside every handler. The matching priority is: 1. plain parameters, such as `{id}` 2. prepared pattern matchers, such as `{id:isDigits}` 3. custom regex parameters, such as `{id:(\\d+)}` Prepared matchers are optimized for common cases and should be preferred when possible. *** ## When to Use Regex [#when-to-use-regex] Use regex parameters when you need validation rules that are not covered by the prepared matchers. Example: ```go r.GET("/match/{slug:([a-z]+_[0-9]{2})}", handler) ``` This is useful for advanced URL formats. For common values such as IDs, UUIDs, slugs, dates, and tokens, prefer prepared pattern matchers. *** ## Notes [#notes] Route patterns are not a replacement for business validation. They are designed to reject invalid URL shapes early, before the request enters the handler. Handlers should still validate permissions, ownership, database state, request bodies, and application-specific rules. # Request Context The router package provides a lightweight request-scoped context through `*router.Context`. ```go func(w http.ResponseWriter, req *http.Request, ctx *router.Context) ``` The context is designed for: * route parameters * middleware communication * request-scoped storage * authentication data * database connections * tracing metadata * reusable request state The context exists only for the lifetime of the request. *** ## Accessing Route Parameters [#accessing-route-parameters] Route parameters can be accessed using `ctx.Param`. Example route: ```go r.GET("/users/{id}", handler) ``` Handler: ```go func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { id := ctx.Param("id") w.Write([]byte(id)) } ``` Request: ```text /users/42 ``` Captured value: ```text 42 ``` *** ## Storing Request Values [#storing-request-values] Middleware and handlers can store values inside the request context. ```go ctx.Set("user_id", 42) ``` Later in the request lifecycle: ```go userID := ctx.Get("user_id") ``` This allows middleware and handlers to share request-scoped state safely. *** ## Example [#example] ```go package main import ( "net/http" "github.com/netlifeguru/router" ) func main() { r := router.New() r.Use(func(next router.HandlerFunc) router.HandlerFunc { return func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { ctx.Set("request_source", "middleware") next(w, req, ctx) } }) r.GET("/users/{id}", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { id := ctx.Param("id") source := ctx.Get("request_source") w.Write([]byte( "user_id=" + id + ", source=" + source.(string), )) }) r.ListenAndServe(":8000") } ``` Request: ```text /users/42 ``` Response: ```text user_id=42, source=middleware ``` *** ## Middleware Communication [#middleware-communication] One of the primary uses of `router.Context` is communication between middleware and handlers. Example authentication middleware: ```go func Auth(next router.HandlerFunc) router.HandlerFunc { return func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { ctx.Set("user_id", 123) next(w, req, ctx) } } ``` Handler: ```go r.GET("/profile", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { userID := ctx.Get("user_id") w.Write([]byte(fmt.Sprintf("user=%v", userID))) }) ``` This avoids repeatedly parsing authentication state in every handler. *** ## Request-Scoped Storage [#request-scoped-storage] The request context is isolated per request. Values stored inside one request are never shared with other requests. This makes it safe for concurrent workloads and HTTP servers handling many requests simultaneously. Example use cases: | Use Case | Example | | ------------------- | ---------------------- | | Authentication | `user_id`, roles | | Database Connection | request transaction | | Request Tracing | request ID | | Localization | language settings | | Feature Flags | request-specific flags | | Metrics | timing metadata | *** ## Context Lifecycle [#context-lifecycle] The context exists only while the request is being processed. After the request completes, the context is automatically released and reused internally by the router. Applications should never store references to `*router.Context` outside the request lifecycle. Incorrect: ```go globalCtx = ctx ``` Correct: ```go value := ctx.Get("key") ``` and copy only the required values. *** ## Performance [#performance] The request context is internally pooled to reduce allocations during high request throughput. This helps minimize: * heap allocations * GC pressure * per-request overhead The pooling mechanism is fully automatic and transparent to applications. *** ## Context vs context.Context [#context-vs-contextcontext] `router.Context` is separate from Go’s standard: ```go context.Context ``` The router context is optimized for: * route parameters * request-scoped mutable values * middleware communication For cancellation, deadlines, or propagation across services, continue using: ```go req.Context() ``` Both can be used together. Example: ```go ctx.Set("user_id", 42) requestCtx := req.Context() ``` *** ## Notes [#notes] `router.Context` is designed for lightweight request-scoped data sharing inside the HTTP pipeline. It should not replace application services, dependency injection, or persistent storage. # Route Groups Route groups allow routes to share a common URL prefix. They are useful for: * API versioning * modular applications * admin panels * internal services * route organization * shared middleware * separating public and private APIs Instead of repeating the same path prefix on every route, the group automatically prepends it to all child routes. *** ## Creating a Route Group [#creating-a-route-group] Use `Group(...)` to create a grouped router. ```go api := r.Group("/api/v1") ``` All routes registered inside the group automatically inherit the prefix. Example: ```go api.GET("/users", handler) ``` becomes: ```text /api/v1/users ``` *** ## Example [#example] ```go package main import ( "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/router" ) func main() { r := router.New() api := r.Group("/api/v1") { api.HandleFunc("/", "GET POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusCreated) w.Write([]byte(`

/api/v1/

`)) }) api.HandleFunc("/user/{id}", "GET POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { userID := ctx.Param("id") w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusCreated) w.Write([]byte(fmt.Sprintf(`

/api/v1/user:%s

`, userID))) }) } if err := r.ListenAndServe(":8000"); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` *** ## Resulting Routes [#resulting-routes] The example above creates the following routes: | Route | Methods | | ------------------- | ------------- | | `/api/v1/` | `GET`, `POST` | | `/api/v1/user/{id}` | `GET`, `POST` | The group prefix is automatically prepended to all child routes. *** ## API Versioning [#api-versioning] One of the most common uses for route groups is API versioning. Example: ```go v1 := r.Group("/api/v1") v2 := r.Group("/api/v2") ``` This allows multiple API versions to coexist cleanly: ```text /api/v1/users /api/v2/users ``` without duplicating route logic structure. *** ## Nested Groups [#nested-groups] Groups can be nested. Example: ```go api := r.Group("/api") admin := api.Group("/admin") ``` Resulting route: ```text /api/admin ``` Nested groups inherit the full parent prefix automatically. *** ## Group Organization [#group-organization] Using blocks is optional but recommended for readability. Example: ```go api := r.Group("/api/v1") { api.GET("/users", handler) api.GET("/posts", handler) } ``` This keeps grouped routes visually organized in larger applications. *** ## Combining Groups with Middleware [#combining-groups-with-middleware] Groups work especially well together with middleware. Example: ```go api := r.Group("/api") api.Use(AuthMiddleware) ``` All routes inside the group automatically inherit the middleware. This is commonly used for: * authentication * authorization * rate limiting * logging * request validation Group middleware is covered in the dedicated **Group Middleware** section. *** ## Common Use Cases [#common-use-cases] | Use Case | Example | | ----------------- | ----------- | | API Versioning | `/api/v1` | | Admin Dashboard | `/admin` | | Internal Services | `/internal` | | Public API | `/public` | | GraphQL Services | `/graphql` | | Webhooks | `/webhooks` | | Monitoring | `/metrics` | *** ## Notes [#notes] Route groups only affect URL prefixes and inherited middleware. They do not create isolated router instances. For isolated middleware scopes and modular middleware chains, see the dedicated middleware grouping features such as `With(...)`. # Route Handlers Route handlers define how the router responds to incoming HTTP requests. The router supports two styles of handler registration: * generic registration with `HandleFunc` * method-specific shortcuts such as `GET`, `POST`, `PUT`, and `DELETE` All router-native handlers use the same signature: ```go func(w http.ResponseWriter, req *http.Request, ctx *router.Context) ``` The additional `*router.Context` gives handlers access to route parameters and request-scoped storage. *** ## Handler Registration Methods [#handler-registration-methods] | Method | Purpose | | ------------------------------------ | ------------------------------------------------ | | `HandleFunc(path, methods, handler)` | Registers a handler for one or more HTTP methods | | `GET(path, handler)` | Registers a `GET` route | | `POST(path, handler)` | Registers a `POST` route | | `PUT(path, handler)` | Registers a `PUT` route | | `PATCH(path, handler)` | Registers a `PATCH` route | | `DELETE(path, handler)` | Registers a `DELETE` route | | `HEAD(path, handler)` | Registers a `HEAD` route | | `OPTIONS(path, handler)` | Registers an `OPTIONS` route | | `TRACE(path, handler)` | Registers a `TRACE` route | | `CONNECT(path, handler)` | Registers a `CONNECT` route | *** ## Generic Handler Registration [#generic-handler-registration] Use `HandleFunc` when a route should accept multiple HTTP methods. ```go r.HandleFunc("/documents", "GET POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { if req.Method == http.MethodGet { w.Write([]byte(`{"action": "Reading document list"}`)) return } if req.Method == http.MethodPost { w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"action": "Creating a new document"}`)) return } }) ``` Methods are passed as a space-separated string. ```go r.HandleFunc("/documents", "GET POST", handler) ``` Use `ANY` to allow all supported methods. ```go r.HandleFunc("/any", "ANY", handler) ``` *** ## Method Shortcuts [#method-shortcuts] Use method-specific helpers for common REST-style APIs. ```go r.GET("/users/{id}", handler) r.POST("/users", handler) r.PUT("/users/{id}", handler) r.PATCH("/users/{id}", handler) r.DELETE("/users/{id}", handler) ``` These shortcuts make route definitions easier to scan and keep API code explicit. *** ## Example [#example] ```go package main import ( "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/router" ) func main() { r := router.New() r.UseDefaults() r.HandleFunc("/any", "ANY", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"message": "This catches anything!", "used_method": "%s"}`, req.Method) }) r.HandleFunc("/documents", "GET POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "application/json") if req.Method == http.MethodGet { w.Write([]byte(`{"action": "Reading document list"}`)) return } if req.Method == http.MethodPost { w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"action": "Creating a new document"}`)) return } }) r.GET("/users/{id}", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { id := ctx.Param("id") w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"id": "%s", "name": "Alice"}`, id) }) r.POST("/users", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"message": "User successfully created", "id": 3}`)) }) r.PUT("/users/{id:isDigits}", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { userID := ctx.Param("id") w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"message": "User %s has been completely updated"}`, userID) }) r.PATCH("/users/{id:isDigits}", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { userID := ctx.Param("id") w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"message": "User %s has been partially updated"}`, userID) }) r.DELETE("/users/{id:isDigits}", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { userID := ctx.Param("id") w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"message": "User %s has been deleted"}`, userID) }) r.HEAD("/ping", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("X-System-Status", "Online") w.Header().Set("Content-Length", "0") w.WriteHeader(http.StatusOK) }) r.OPTIONS("/api", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Allow", "OPTIONS, GET, POST, PUT, DELETE") w.WriteHeader(http.StatusNoContent) }) r.TRACE("/echo", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "message/http") w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "%s %s %s\n", req.Method, req.URL.RequestURI(), req.Proto) for name, headers := range req.Header { for _, value := range headers { fmt.Fprintf(w, "%s: %s\n", name, value) } } }) r.CONNECT("/tunnel", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.WriteHeader(http.StatusOK) w.Write([]byte("Tunnel connection established")) }) if err := r.ListenAndServe(":8000"); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` *** ## HTTP Method Usage [#http-method-usage] | Method | Common Use | | --------- | --------------------------------------------------- | | `GET` | Retrieve a resource | | `POST` | Create a new resource or submit data | | `PUT` | Replace an existing resource | | `PATCH` | Partially update an existing resource | | `DELETE` | Remove a resource | | `HEAD` | Retrieve headers without a response body | | `OPTIONS` | Discover supported methods or handle CORS preflight | | `TRACE` | Diagnostic request echoing | | `CONNECT` | Establish a tunnel, commonly used by proxies | | `ANY` | Accept all supported HTTP methods | *** ## Route Parameters [#route-parameters] Handlers can access route parameters through `ctx.Param`. ```go r.GET("/users/{id}", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { id := ctx.Param("id") w.Write([]byte(id)) }) ``` Parameters can also be validated using route patterns: ```go r.GET("/users/{id:isDigits}", handler) ``` Pattern-based routing is covered in the dedicated route patterns section. *** ## Notes [#notes] Use `HandleFunc` when one route should support multiple methods. Use method-specific helpers when each HTTP method has its own behavior. For standard `net/http` handlers without `*router.Context`, use `Mount` or `MountFunc`. # Wildcard Routes Wildcard routes capture the remaining unmatched part of the URL path. They are useful for: * static file serving * frontend SPA routing * mounted applications * reverse proxies * catch-all handlers * nested services * asset delivery Wildcard matching allows a route to handle an entire URL branch instead of a single fixed path. *** ## Basic Wildcard Syntax [#basic-wildcard-syntax] Wildcard routes use the `*` syntax. Example: ```go r.GET("/files/*", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Write([]byte(req.URL.Path)) }) ``` Example requests: ```text /files/report.pdf /files/images/logo.png /files/archive/2026/05/data.json ``` All requests beginning with: ```text /files/ ``` are matched by the same handler. *** ## Example [#example] ```go package main import ( "net/http" "github.com/netlifeguru/router" ) func main() { r := router.New() r.GET("/files/*", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Write([]byte(req.URL.Path)) }) r.ListenAndServe(":8000") } ``` Request: ```text /files/images/logo.png ``` Response: ```text /files/images/logo.png ``` *** ## Wildcards with Mount [#wildcards-with-mount] Wildcards are commonly used together with mounted handlers. Example: ```go r.Mount("/assets/*", http.FileServer(http.Dir("./public"))) ``` This forwards all requests under: ```text /assets/ ``` to the mounted file server. Examples: ```text /assets/style.css /assets/js/app.js /assets/images/logo.png ``` This pattern is commonly used for: * static assets * frontend applications * Swagger UI * GraphQL playgrounds * mounted services * reverse proxies *** ## Wildcards with MountFunc [#wildcards-with-mountfunc] Wildcard routes also work with `MountFunc`. ```go r.MountFunc("/services/*", func(w http.ResponseWriter, req *http.Request) { w.Write([]byte(req.URL.Path)) }) ``` This allows standard `net/http` handlers to process entire URL branches. *** ## SPA Frontend Routing [#spa-frontend-routing] Wildcard routes are useful for Single Page Applications. Example: ```go r.Mount("/app/*", http.FileServer(http.Dir("./frontend"))) ``` Requests such as: ```text /app/dashboard /app/settings/profile /app/users/42 ``` can all be handled by the mounted frontend application. *** ## Wildcard Matching Behavior [#wildcard-matching-behavior] Wildcards always match the remaining path after the route prefix. Example route: ```text /files/* ``` Matches: ```text /files/a.txt /files/images/logo.png /files/archive/2026/report.pdf ``` Does not match: ```text /file/test.txt ``` because the prefix differs. *** ## Notes [#notes] Wildcard routes are designed for branch-style routing where an entire subtree of URLs should be handled by the same handler. For single dynamic segments, prefer parameterized routes: ```go /users/{id} ``` instead of: ```go /users/* ``` This keeps route matching more explicit and easier to maintain. # Installation `router` is a high-performance and idiomatic HTTP router for modern Go applications. It is designed for APIs, web services, microservices, and backend platforms that require fast route matching, clean middleware composition, and production-ready HTTP infrastructure. The package provides: * Fast radix-tree based routing * Route groups and middleware pipelines * Regex and parameterized routes * Request-scoped context helpers * Built-in rate limiting and recovery middleware * Static file serving and health check endpoints * Multiserver support and graceful shutdown * Integration with Go’s standard `net/http` ecosystem Add the package to your project using `go get`: ```bash go get github.com/netlifeguru/router ``` Import the package into your application: ```go import "github.com/netlifeguru/router" ``` Once installed, continue with the Quick Start guide to create your first routes, middleware, and HTTP server. # Getting Started ## Quick Start [#quick-start] The example below creates a minimal HTTP server using the `router` package. It demonstrates: * Router initialization * Static route handling * Parameterized routes * Accessing route parameters * Starting an HTTP server ```go package main import ( "log/slog" "net/http" "os" "github.com/netlifeguru/logger" "github.com/netlifeguru/router" ) func main() { r := router.New() closer, err := logger.Init(logger.Config{ Dir: "./logs", TerminalOutput: true, DisableColors: false, MinLevel: slog.LevelInfo, ConsoleMinLevel: slog.LevelDebug, MaxFileSize: 100 * 1024 * 1024, MaxLogFiles: 10, }) if err != nil { slog.Error("failed to initialize logger", "error", err) os.Exit(1) } defer func() { if err := closer.Close(); err != nil { slog.Error("failed to close logger", "error", err) } }() r.Use(router.Logger()) r.HandleFunc("/", "GET POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusCreated) w.Write([]byte(`

Hello World

`)) }) r.HandleFunc("/user/{id}", "GET", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { id := ctx.Param("id") w.Write([]byte(id)) }) if err := r.ListenAndServe(":8000"); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` Run the application: ```bash go run main.go ``` The server starts on: ```text http://localhost:8000 ``` Example requests: ```bash curl http://localhost:8000/ curl http://localhost:8000/user/42 ``` Expected response: ```text 42 ``` The `router.Context` object provides access to route parameters, request-scoped values, and helper methods used throughout the routing system. Continue with the next sections to learn about middleware, route groups, custom matchers, static assets, and advanced server configuration. # Custom 404 Handler The router package allows applications to replace the default `404 page not found` response with a custom handler. This is useful for: * custom inline HTML responses * JSON API error responses * localized messages * lightweight fallback pages * frontend routing fallbacks * application-specific error handling *** ## Registering a Custom Handler [#registering-a-custom-handler] Use `r.NotFound(...)` to override the default 404 behavior. ```go r.NotFound(func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.WriteHeader(http.StatusNotFound) w.Write([]byte("Custom 404")) }) ``` The handler is executed whenever no matching route exists. *** ## Example [#example] ```go package main import ( "log/slog" "net/http" "os" "github.com/netlifeguru/router" ) func main() { r := router.New() r.NotFound(func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusNotFound) w.Write([]byte(`

404

The page you are looking for does not exist.

`)) }) r.GET("/", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "text/html") w.Write([]byte(` Hello World!
Click here to trigger the 404 handler `)) }) if err := r.ListenAndServe(":8000"); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` Run the application: ```bash go run . ``` Open: ```text http://localhost:8000/any-broken-link ``` Expected response: ```html

404

The page you are looking for does not exist.

``` *** ## Returning JSON Responses [#returning-json-responses] The custom handler can also return JSON responses for APIs. Example: ```go r.NotFound(func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) w.Write([]byte(`{ "error": "route_not_found" }`)) }) ``` This is useful for REST APIs and frontend applications expecting structured responses. *** ## Inline vs Template-Based Pages [#inline-vs-template-based-pages] Simple inline responses are useful for lightweight applications: ```go w.Write([]byte("

404

")) ``` For larger applications, template-based pages are usually preferred. See the dedicated **Custom 404 Page** guide for serving full HTML templates, static assets, and branded error pages. *** ## Notes [#notes] The custom 404 handler is a standard route handler and can access: * request context * middleware data * sessions * templates * database connections * localization systems This makes it possible to fully customize how missing routes are handled across the application. # Custom 404 Page The router package allows applications to replace the default `404 page not found` response with a custom handler. Custom 404 pages are useful for: * branded error pages * frontend applications * marketing websites * SPA fallbacks * custom error templates * localized error messages *** ## Registering a Custom 404 Handler [#registering-a-custom-404-handler] Use `r.NotFound(...)` to register a custom handler. ```go r.NotFound(func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.WriteHeader(http.StatusNotFound) w.Write([]byte("Custom 404 Page")) }) ``` This handler is triggered whenever no route matches the incoming request. *** ## Example [#example] Example project structure: ```text custom_error_page/ ├── public/ │ ├── favicon.ico │ └── style.css ├── templates/ │ └── 404.html └── main.go ``` Example application: ```go package main import ( "log/slog" "net/http" "os" "github.com/netlifeguru/router" ) func main() { r := router.New() r.Static("/assets/", "./public") r.NotFound(func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "text/html") htmlContent, err := os.ReadFile("./templates/404.html") if err != nil { w.WriteHeader(http.StatusNotFound) w.Write([]byte("404 - Page Not Found")) return } w.WriteHeader(http.StatusNotFound) w.Write(htmlContent) }) r.GET("/", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "text/html") w.Write([]byte(`

Welcome to netlife.guru

Try visiting a non-existent URL to see the custom 404 page.

`)) }) if err := r.ListenAndServe(":8000"); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` Run the application: ```bash go run . ``` Then open a non-existing route: ```text http://localhost:8000/unknown-page ``` The router automatically renders the custom `404.html` template. *** ## Template Example [#template-example] Example minimal template: ```html 404 - Page Not Found

404 - Page Not Found

The requested page does not exist.

Back to homepage ``` The example above loads static assets from: ```text /assets/ ``` using the router static file system: ```go r.Static("/assets/", "./public") ``` *** ## Fallback Handling [#fallback-handling] If the template file cannot be loaded: ```go os.ReadFile("./templates/404.html") ``` the handler can safely fall back to a simpler response. Example: ```go w.WriteHeader(http.StatusNotFound) w.Write([]byte("404 - Page Not Found")) ``` This prevents broken templates from causing application errors. *** ## Notes [#notes] The custom 404 handler is a normal route handler and can use: * templates * database queries * localization * sessions * middleware context * static assets This makes it possible to build fully customized application-specific error pages. # Error Handling The router package integrates directly with Go’s standard `log/slog` ecosystem. Application errors, startup failures, and recovered panics can be logged using structured logging handlers such as: * `github.com/netlifeguru/logger` * custom `slog.Handler` * JSON loggers * external observability systems This allows production applications to store structured error logs in machine-readable formats for monitoring and debugging. *** ## Startup Errors [#startup-errors] Server startup failures should always be checked. Example: ```go if err := r.ListenAndServe(":8000"); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } ``` This logs startup errors such as: * port conflicts * permission issues * invalid listener configuration * TLS failures *** ## Structured JSON Logging [#structured-json-logging] When used together with `github.com/netlifeguru/logger`, errors are automatically written into rotating JSON log files. Example logger setup: ```go closer, err := logger.Init(logger.Config{ Dir: "./logs", TerminalOutput: true, }) ``` Example log file: ```text ./logs/2026-05-12-0001.log ``` Example structured error log: ```json { "time": "2026-05-12T21:43:02+02:00", "level": "ERROR", "msg": "failed to start server", "error": "listen tcp :8000: bind: address already in use" } ``` Structured logs make it easier to integrate with: * Elasticsearch * Loki * Datadog * Grafana * Splunk * cloud logging systems *** ## Panic Recovery Logging [#panic-recovery-logging] Recovered panics are also logged automatically through `slog`. Example panic: ```go panic("database connection lost") ``` Example log output: ```json { "time": "2026-05-12T21:50:11+02:00", "level": "ERROR", "msg": "panic recovered", "panic": "database connection lost" } ``` This allows applications to capture unexpected runtime failures without crashing the server process. *** ## Terminal vs File Logging [#terminal-vs-file-logging] When terminal output is enabled: ```go TerminalOutput: true ``` errors are shown in the console with colorized formatting. Example: ```text 2026-05-12 21:43:02 [ERROR] failed to start server error="bind: address already in use" ``` At the same time, the same event is safely written into structured JSON log files. *** ## Recommended Production Setup [#recommended-production-setup] Recommended production configuration: ```go logger.Init(logger.Config{ Dir: "./logs", TerminalOutput: false, MinLevel: slog.LevelInfo, ConsoleMinLevel: slog.LevelError, }) ``` This configuration: * stores structured logs in files * reduces terminal noise * keeps error logs centralized * enables long-term log retention *** ## Notes [#notes] The router itself does not force a logging implementation. All logging flows through Go’s standard `log/slog` package, allowing applications to fully control: * formatting * storage * rotation * transports * observability integrations * structured metadata # Health Checks The router package provides helpers for registering common health check endpoints. Health checks are useful for: * container orchestration * load balancers * deployment probes * service monitoring * graceful startup and shutdown flows There are two common types of health checks: * **Liveness**: tells whether the process is running * **Readiness**: tells whether the application is ready to receive traffic *** ## Liveness [#liveness] A liveness endpoint usually returns `200 OK` when the process is alive. ```go r.Liveness("/healthz", func(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("OK")) }) ``` Example request: ```bash curl http://localhost:8080/healthz ``` Expected response: ```text OK ``` *** ## Readiness [#readiness] A readiness endpoint should return `200 OK` only when the application is ready to serve requests. ```go r.Readiness("/readyz", func(w http.ResponseWriter, req *http.Request) { if r.IsReady() { w.WriteHeader(http.StatusOK) w.Write([]byte("READY")) } else { w.WriteHeader(http.StatusServiceUnavailable) w.Write([]byte("NOT_READY")) } }) ``` Example request: ```bash curl http://localhost:8080/readyz ``` Expected response when ready: ```text READY ``` Expected response when not ready: ```text NOT_READY ``` *** ## Readiness State [#readiness-state] The router keeps an internal readiness state. By default, a new router starts in the ready state. ```go r := router.New() ``` You can update the readiness state manually: ```go r.SetReady(false) ``` This is useful during startup, shutdown, migrations, dependency checks, or when the service should temporarily stop receiving traffic. Example: ```go r.SetReady(false) // initialize database connections // warm up cache // load configuration r.SetReady(true) ``` *** ## Default Paths [#default-paths] If an empty path is provided, the router uses default health check paths: ```go r.Liveness("", handler) // /healthz r.Readiness("", handler) // /readyz ``` *** ## Notes [#notes] Health check handlers are regular `net/http` handlers. They are registered through the router’s mounting system, so they can be used alongside normal routes, middleware, static files, and mounted handlers. # Logging The router package uses Go’s standard `log/slog` ecosystem for logging. It does not configure a global logger automatically. This is intentional: applications should decide how logs are formatted, where they are written, and which log levels are enabled. For local development, you can use the `github.com/netlifeguru/logger` package to enable colorized terminal output together with structured file logging. ```go package main import ( "io" "log/slog" "net/http" "os" "github.com/netlifeguru/logger" "github.com/netlifeguru/router" ) func main() { r := router.New() closer, err := logger.Init(logger.Config{ Dir: "./logs", TerminalOutput: true, DisableColors: false, MinLevel: slog.LevelInfo, ConsoleMinLevel: slog.LevelDebug, MaxFileSize: 100 * 1024 * 1024, MaxLogFiles: 10, }) if err != nil { slog.Error("failed to initialize logger", "error", err) os.Exit(1) } defer func() { if err := closer.Close(); err != nil { slog.Error("failed to close logger", "error", err) } }() r.Use(router.Logger()) r.GET("/", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusCreated) w.Write([]byte(`Hello World`)) }) if err := r.ListenAndServe(":8080"); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` Run the application: ```bash go run . ``` With terminal output enabled, logs are written to the console in a human-readable format and structured log files are written to the configured log directory. Example terminal output: ```text 2026-05-12 17:32:47 [INFO] Starting server 2026-05-12 17:32:47 [INFO] System resources cpu_cores=12 2026-05-12 17:32:47 [INFO] web server started server=NetLifeGuru version=v0.0.1 listen_addr=:8080 2026-05-12 17:32:51 [INFO] request processed request_id= method=GET host=localhost:8080 path=/ query= duration=6.917µs ``` *** ## Logger Initialization [#logger-initialization] The logger configuration initializes the global `log/slog` handler used by the application and router middleware. ```go closer, err := logger.Init(logger.Config{ Dir: "./logs", TerminalOutput: true, DisableColors: false, MinLevel: slog.LevelInfo, ConsoleMinLevel: slog.LevelDebug, MaxFileSize: 100 * 1024 * 1024, MaxLogFiles: 10, }) if err != nil { slog.Error("failed to initialize logger", "error", err) os.Exit(1) } defer closer.Close() ``` This configuration enables: * colorized terminal output for local development * structured JSON log files * automatic log rotation * configurable log levels for terminal and file output * graceful shutdown of the logging subsystem The returned `closer` should be closed before application shutdown to ensure the logger releases its resources correctly. If no custom logger is configured, the application falls back to Go’s default `log/slog` behavior. In that case: * terminal output is not colorized * log rotation is not available * structured file logging is not configured automatically *** ## Request Logging Middleware [#request-logging-middleware] HTTP request logging is enabled through middleware registration. ```go r.Use(router.Logger()) ``` The middleware records request-specific information through `slog`, including: * HTTP method * request path * query string * host * request duration * request identifier Request logging is optional and disabled by default. This keeps the router lightweight while allowing applications to enable request logging only when needed. *** ## Logging Philosophy [#logging-philosophy] The router package intentionally does not configure logging automatically. Instead of embedding a custom logging system directly into the router, logging is handled through Go’s standard `log/slog` ecosystem and optional middleware registration. This approach keeps the router lightweight, predictable, and fully compatible with existing logging infrastructure. Applications remain free to: * use any `slog.Handler` * configure custom log formatting * write logs to files, terminals, or external systems * control log levels independently * enable or disable request logging explicitly For development environments, the `github.com/netlifeguru/logger` package can be used to provide colorized terminal output, structured JSON logging, file rotation, and production-ready log management on top of the standard `slog` API. *** ## Production Logging [#production-logging] For production environments, terminal output is often disabled. ```go closer, err := logger.Init(logger.Config{ Dir: "./logs", TerminalOutput: false, MinLevel: slog.LevelInfo, MaxFileSize: 100 * 1024 * 1024, MaxLogFiles: 10, }) ``` When `TerminalOutput` is disabled, logs are written only to structured log files. This is useful when: * running inside containers * forwarding logs through a collector * avoiding ANSI color codes in production output * reducing unnecessary terminal I/O The router middleware remains the same: ```go r.Use(router.Logger()) ``` The middleware records request information through `slog`, while the active logger configuration decides where those log entries are written. *** ## Notes [#notes] The router does not require the NLG Logger package to run. You can use any `slog.Handler`, including the standard library JSON or text handlers. The NLG Logger package is recommended when you want colorized development output, file rotation, daily log files, and structured production logs with minimal setup. # Multi-Server Support The router package can serve multiple HTTP listeners simultaneously using a single router instance. This allows the same routing tree, middleware pipeline, and application state to be shared across multiple ports or network interfaces. Typical use cases include: * public and internal APIs * admin panels * metrics endpoints * multi-port deployments * IPv4 and IPv6 listeners * development and debugging interfaces *** ## Starting Multiple Listeners [#starting-multiple-listeners] ```go package main import ( "fmt" "log/slog" "net/http" "os" "github.com/netlifeguru/logger" "github.com/netlifeguru/router" ) func main() { r := router.New() closer, err := logger.Init(logger.Config{ Dir: "./logs", TerminalOutput: true, DisableColors: false, MinLevel: slog.LevelInfo, ConsoleMinLevel: slog.LevelDebug, MaxFileSize: 100 * 1024 * 1024, MaxLogFiles: 10, }) defer closer.Close() if err != nil { slog.Error("failed to initialize logger", "error", err) os.Exit(1) } r.HandleFunc("/", "GET POST", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(http.StatusOK) serverHost := req.Host response := fmt.Sprintf(`

Hello World

Successfully connected!

Served by listener: %s

`, serverHost) w.Write([]byte(response)) }) listeners := router.Listeners{ {Addr: "localhost:8000"}, {Addr: "localhost:8001"}, } if err := r.MultiListenAndServe(listeners); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` The router starts all configured listeners concurrently while sharing the same application routes and middleware. *** ## Accessing Active Listeners [#accessing-active-listeners] Open multiple endpoints in the browser: ```text http://localhost:8000 http://localhost:8001 ``` The response dynamically shows which listener handled the request. Example: ```html

Hello World

Successfully connected!

Served by listener: localhost:8001

``` *** ## Logging Integration [#logging-integration] When request logging is enabled, each active listener is logged during startup. Example terminal output: ```text 2026-05-12 21:43:02 [INFO] Starting server 2026-05-12 21:43:02 [INFO] System resources cpu_cores=12 2026-05-12 21:43:02 [INFO] web server started server=NetLifeGuru version=v0.0.1 listen_addr=localhost:8000 2026-05-12 21:43:02 [INFO] web server started server=NetLifeGuru version=v0.0.1 listen_addr=localhost:8001 ``` This makes it easy to verify which interfaces and ports are currently active. *** ## Common Deployment Patterns [#common-deployment-patterns] Multi-server setups are useful when applications expose different traffic types on separate listeners. Examples: * public API on `:443` * internal admin API on `127.0.0.1:9000` * metrics endpoint on `:9090` * profiling server on `127.0.0.1:6060` Example: ```go listeners := router.Listeners{ {Addr: ":443"}, {Addr: "127.0.0.1:9000"}, {Addr: ":9090"}, } ``` All listeners share: * the same router instance * middleware stack * route tree * application state *** ## Notes [#notes] Each listener runs in its own HTTP server internally. The router manages listener startup and graceful shutdown automatically while keeping routing behavior consistent across all active listeners. # Panic Recovery The router package can recover from panics that occur inside route handlers. This allows the server to keep running and return a controlled response instead of crashing the application process. Recovery is useful for: * preventing unexpected handler panics from terminating the server * returning custom `500 Internal Server Error` responses * logging panic details * keeping production services available during unexpected failures *** ## Custom Recovery Handler [#custom-recovery-handler] Register a custom recovery handler using `r.Recovery`. ```go r.Recovery(func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("Custom Internal Server Error")) }) ``` This handler is executed whenever a panic occurs inside a route handler. *** ## Example [#example] ```go package main import ( "log/slog" "net/http" "os" "github.com/netlifeguru/router" ) func main() { r := router.New() r.Recovery(func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte("Custom Internal Server Error: Don't worry, we caught the panic!")) }) r.HandleFunc("/", "ANY", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { panic("Something went terribly wrong") }) if err := r.ListenAndServe(":8000"); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` Run the application: ```bash go run . ``` Test the route: ```bash curl http://localhost:8000/ ``` Expected response: ```text Custom Internal Server Error: Don't worry, we caught the panic! ``` *** ## Fail-Safe Recovery [#fail-safe-recovery] The router also protects the recovery handler itself. If the custom recovery handler panics, the router catches that panic as well and returns a standard internal server error response. This prevents a broken recovery handler from crashing or hanging the application. Example: ```go r.Recovery(func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { panic("recovery handler failed") }) ``` In this case, the router falls back to a safe `500 Internal Server Error` response. *** ## Panic Logging [#panic-logging] When a panic occurs, the router logs the panic through Go’s standard `log/slog` package. If a custom logger such as `github.com/netlifeguru/logger` is configured, panic logs follow the active logger configuration. This means panic logs can be written to: * terminal output * structured JSON files * rotated log files * any custom `slog.Handler` *** ## Notes [#notes] Recovery only handles panics that occur during request handling. It does not replace normal error handling for expected application errors. Use explicit error responses for known validation, authorization, or business logic failures. # Profiling The router package can start a dedicated HTTP profiling server using Go’s standard `net/http/pprof` tooling. Profiling is useful for: * performance analysis * memory inspection * CPU profiling * goroutine debugging * allocation tracking * production troubleshooting *** ## Enable Profiling [#enable-profiling] ```go r.EnableProfiling("localhost:6060") ``` This starts a dedicated internal HTTP server exposing standard Go profiling endpoints. Default dashboard: ```text http://localhost:6060/debug/pprof/ ``` Example: ```go package main import ( "net/http" "github.com/netlifeguru/router" ) func main() { r := router.New() r.EnableProfiling("localhost:6060") r.GET("/", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Write([]byte("hello")) }) r.ListenAndServe(":8080") } ``` *** ## Available Endpoints [#available-endpoints] Once enabled, the following endpoints become available: ```text /debug/pprof/ /debug/pprof/heap /debug/pprof/profile /debug/pprof/goroutine /debug/pprof/allocs /debug/pprof/block /debug/pprof/mutex ``` These endpoints are provided by Go’s standard profiling package. *** ## Example CPU Profile [#example-cpu-profile] Capture a 30-second CPU profile: ```bash go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 ``` Open the interactive profiling UI: ```bash go tool pprof -http=:8081 cpu.prof ``` *** ## Security Notes [#security-notes] Profiling endpoints expose internal runtime information and should not be publicly accessible. Recommended practices: * bind profiling to `localhost` * expose profiling only inside trusted networks * disable profiling in public production environments * protect profiling endpoints behind authentication or firewalls Example: ```go r.EnableProfiling("127.0.0.1:6060") ``` *** ## Notes [#notes] The profiling server runs independently from the main HTTP router server. This allows profiling endpoints to remain isolated from public application traffic while still providing full runtime inspection capabilities. # Rate Limiting The router package includes rate limiting middleware for throttling repeated requests from the same client. Rate limiting is useful for: * protecting public endpoints * reducing abuse and accidental traffic spikes * limiting expensive handlers * slowing down brute-force attempts * adding lightweight per-route traffic control The limiter tracks requests by client and route, then rejects requests that arrive too frequently. When a client exceeds the configured limit, the router responds with: ```text 429 Too Many Requests ``` *** ## Basic Usage [#basic-usage] ```go r.Use(router.RateLimit(50 * time.Millisecond)) ``` This allows one request per client and route every `50ms`. That is approximately: ```text 20 requests per second ``` *** ## Example [#example] ```go package main import ( "log/slog" "net/http" "os" "time" "github.com/netlifeguru/router" ) func main() { r := router.New() r.Use(router.RateLimit(50 * time.Millisecond)) r.HandleFunc("/", "GET", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Write([]byte("Success! You are not rate-limited.")) }) if err := r.ListenAndServe(":8000"); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` Run the application: ```bash go run . ``` Test the endpoint: ```bash curl http://localhost:8000/ ``` If requests are sent too quickly, the router returns: ```text 429 Too Many Requests ``` *** ## Custom Configuration [#custom-configuration] The rate limiter can be customized using configuration options. ```go func CustomRateLimitOpt(cfg *router.RateLimitConfig) { cfg.TTL = 5 * time.Minute cfg.CleanupInterval = 1 * time.Minute } ``` Then pass the option to the middleware: ```go r.Use(router.RateLimit(50*time.Millisecond, CustomRateLimitOpt)) ``` Full example: ```go package main import ( "log/slog" "net/http" "os" "time" "github.com/netlifeguru/router" ) func CustomRateLimitOpt(cfg *router.RateLimitConfig) { cfg.TTL = 5 * time.Minute cfg.CleanupInterval = 1 * time.Minute } func main() { r := router.New() r.Use(router.RateLimit(50*time.Millisecond, CustomRateLimitOpt)) r.HandleFunc("/", "GET", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Write([]byte("Success! You are not rate-limited.")) }) if err := r.ListenAndServe(":8000"); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` *** ## Configuration Options [#configuration-options] ### TTL [#ttl] `TTL` defines how long a client tracking record is kept in memory. ```go cfg.TTL = 5 * time.Minute ``` Longer TTL values remember clients for a longer period of time. ### CleanupInterval [#cleanupinterval] `CleanupInterval` defines how often old client tracking records are removed. ```go cfg.CleanupInterval = 1 * time.Minute ``` Shorter cleanup intervals remove expired records more frequently, while longer intervals reduce cleanup overhead. *** ## Notes [#notes] Rate limiting is registered as middleware. When applied globally: ```go r.Use(router.RateLimit(50 * time.Millisecond)) ``` it affects all routes. When applied to a route group, it only affects routes inside that group. The limiter is intended as a lightweight application-level guard. For large distributed systems, combine it with infrastructure-level rate limiting such as reverse proxies, API gateways, or load balancers. # Static Files The router package can serve static assets from a local directory. This is useful for: * CSS files * JavaScript files * images * icons * fonts * public frontend assets Static files are mapped from a real filesystem path to a public URL prefix. For example: ```go r.Static("/assets/", "./public") ``` This means: ```text ./public/style.css → /assets/style.css ./public/favicon.ico → /assets/favicon.ico ./public/images/logo.png → /assets/images/logo.png ``` The local directory name is not exposed in the URL. Only the files inside the directory are served under the configured prefix. *** ## Directory Structure [#directory-structure] Example project structure: ```text static_files/ ├── public/ │ ├── favicon.ico │ └── style.css └── main.go ``` In this example, the real static directory is: ```text ./public ``` and the public URL prefix is: ```text /assets/ ``` *** ## Example [#example] ```go package main import ( "log/slog" "net/http" "os" "github.com/netlifeguru/router" ) func main() { r := router.New() r.Static("/assets/", "./public") r.GET("/", func(w http.ResponseWriter, req *http.Request, ctx *router.Context) { w.Header().Set("Content-Type", "text/html") w.Write([]byte(`

Static Files Example

Static assets are served from the ./public directory.

`)) }) if err := r.ListenAndServe(":4000"); err != nil { slog.Error("failed to start server", "error", err) os.Exit(1) } } ``` Run the application: ```bash go run . ``` Open the page: ```text http://localhost:4000 ``` The CSS file is served from: ```text http://localhost:4000/assets/style.css ``` *** ## Favicon Support [#favicon-support] Browsers commonly request `favicon.ico` from the root path: ```text /favicon.ico ``` When `favicon.ico` exists inside the static directory, the router automatically registers an additional root-level favicon route. For example: ```text ./public/favicon.ico ``` is available at both: ```text /assets/favicon.ico /favicon.ico ``` This prevents unnecessary `404` responses when browsers request the favicon from the root of the domain. *** ## Notes [#notes] Static file serving uses Go’s standard `http.FileServer` internally. The configured URL prefix does not have to match the local directory name: ```go r.Static("/assets/", "./public") ``` In this case, files are loaded from `./public`, but exposed publicly under `/assets/`. # Installation The mapper package requires Go `1.22` or newer. `mapper` is a lightweight standalone package for scanning database rows into Go structs, maps, or custom row handlers. It helps you map database results by column name instead of scan position, while staying independent from any specific database driver. It supports: * struct mapping using `db` and `json` tags * scanning rows into `[]T` * scanning rows into `map[string]any` * filling structs from maps * nullable values and pointer fields * custom row mapping through the `ScanMapper` interface * database-agnostic row scanning through the `mapper.Rows` interface ## Install [#install] Install the package using `go get`: ```bash go get github.com/netlifeguru/mapper ``` After installation, import the package into your application: ```go import "github.com/netlifeguru/mapper" ``` The package is standalone and does not require the NetLifeGuru database layer. You can use it directly with rows from MySQL, PostgreSQL, ScyllaDB, CockroachDB, MariaDB, or any other database driver that can be adapted to the `mapper.Rows` interface. For clarity and consistency, the examples in this documentation use MySQL, but the same mapping concepts apply to other supported database systems. Once installed, continue with the Quick Start guide to scan your first database rows into Go structs. # Quick Start This guide shows the basic mapper workflow: 1. define a Go struct 2. query rows from a database 3. scan the rows into a typed Go slice The examples use MySQL through Go’s standard `database/sql` package, but the same mapping concept applies to any rows compatible with the `mapper.Rows` interface. ## Define a Struct [#define-a-struct] Create a struct that represents one database row. Use `db` tags to match struct fields with database column names. ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` The mapper matches columns by name, not by scan position. For example, the `created_at` column is mapped into the `CreatedAt` field using the `db:"created_at"` tag. ## Query Rows [#query-rows] Query rows using your database driver. ```go rows, err := db.Query(` SELECT * FROM users ORDER BY created_at DESC `) if err != nil { return nil, err } defer rows.Close() ``` The `rows` value must be compatible with the `mapper.Rows` interface. Go’s standard `*sql.Rows` already provides the required behavior. ## Scan Into a Slice [#scan-into-a-slice] Use `ScanStructSlice` to load all returned rows into a typed slice. ```go users, err := mapper.ScanStructSlice[User](rows) if err != nil { return nil, err } ``` The result is a regular Go slice: ```go for _, user := range users { fmt.Println(user.Name) } ``` ## Complete Query Example [#complete-query-example] This example defines the model and a small repository-style function. ```go package main import ( "database/sql" "time" "github.com/netlifeguru/mapper" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } func getUsers(db *sql.DB) ([]User, error) { rows, err := db.Query(` SELECT * FROM users ORDER BY created_at DESC `) if err != nil { return nil, err } defer rows.Close() return mapper.ScanStructSlice[User](rows) } ``` ## Complete Usage Example [#complete-usage-example] This example connects to the database, loads users, and prints the scanned values. ```go package main import ( "fmt" "log" _ "github.com/go-sql-driver/mysql" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } db, err := connectDB() if err != nil { log.Fatal(err) } defer db.Close() users, err := getUsers(db) if err != nil { log.Fatal(err) } for _, user := range users { fmt.Printf( "ID: %d | Name: %s | Email: %s | Active: %t | Created: %s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } } ``` ## Next Steps [#next-steps] Use `ScanStructSlice` for common list queries. Use `ScanStructOne` when the query should return exactly one row. Use `ScanStructRows` when you want to process rows one by one through a callback. Use `ScanMapRows` when the result shape is dynamic and you want map-based rows. ## Related Example [#related-example] A standalone example is available in the examples repository: [ScanStructSlice example](https://github.com/netlifeguru/examples/mapper/api/02_scan_struct_slice) # Examples Practical examples are available in the official examples repository: [https://github.com/netlifeguru/examples/mapper](https://github.com/netlifeguru/examples/mapper) The repository contains standalone examples covering struct scanning, map-based rows, custom mapping, cache keys, field tags, nullable values, edge cases, and error handling. The examples are intentionally broader than this documentation. Use the documentation to understand the core concepts, and use the examples repository when you need to see a specific scenario in code. ## API Examples [#api-examples] * [ScanStructRows](https://github.com/netlifeguru/examples/mapper/api/01_scan_struct_rows) * [ScanStructSlice](https://github.com/netlifeguru/examples/mapper/api/02_scan_struct_slice) * [ScanStructOne](https://github.com/netlifeguru/examples/mapper/api/03_scan_struct_one) * [ScanMapRows](https://github.com/netlifeguru/examples/mapper/api/04_scan_map_rows) * [FillFromMap](https://github.com/netlifeguru/examples/mapper/api/05_fill_from_map) * [Row converters](https://github.com/netlifeguru/examples/mapper/api/06_row_converters) * [Cache key](https://github.com/netlifeguru/examples/mapper/api/07_cache_key) * [Schema version](https://github.com/netlifeguru/examples/mapper/api/08_schema_version) * [Snake case](https://github.com/netlifeguru/examples/mapper/api/09_snake_case) * [Error handling](https://github.com/netlifeguru/examples/mapper/api/10_error_handling) ## Usage Examples [#usage-examples] * [Getting started](https://github.com/netlifeguru/examples/mapper/usage/01_getting_started) * [Select all](https://github.com/netlifeguru/examples/mapper/usage/02_select_all) * [Join](https://github.com/netlifeguru/examples/mapper/usage/03_join) * [Single row](https://github.com/netlifeguru/examples/mapper/usage/04_single_row) * [Nullable values](https://github.com/netlifeguru/examples/mapper/usage/05_nullable) * [Map rows](https://github.com/netlifeguru/examples/mapper/usage/06_map_rows) * [Cache key](https://github.com/netlifeguru/examples/mapper/usage/07_cache_key) * [Many to many](https://github.com/netlifeguru/examples/mapper/usage/08_many_to_many) * [Scan map fast path](https://github.com/netlifeguru/examples/mapper/usage/09_scan_map_fast_path) * [Field tags](https://github.com/netlifeguru/examples/mapper/usage/10_field_tags) * [Ignore extra columns](https://github.com/netlifeguru/examples/mapper/usage/11_ignore_extra_columns) * [Snake case](https://github.com/netlifeguru/examples/mapper/usage/12_snake_case) * [Partial result](https://github.com/netlifeguru/examples/mapper/usage/13_partial_result) * [Error handling](https://github.com/netlifeguru/examples/mapper/usage/14_error_handling) ## Edge Case Examples [#edge-case-examples] * [Null values](https://github.com/netlifeguru/examples/mapper/edge_cases/01_null_values) * [Extra columns](https://github.com/netlifeguru/examples/mapper/edge_cases/02_extra_columns) * [Missing columns](https://github.com/netlifeguru/examples/mapper/edge_cases/03_missing_columns) * [Type conversions](https://github.com/netlifeguru/examples/mapper/edge_cases/04_type_conversions) * [Pointer fields](https://github.com/netlifeguru/examples/mapper/edge_cases/05_pointer_fields) * [JSON fields](https://github.com/netlifeguru/examples/mapper/edge_cases/06_json_fields) * [Empty result](https://github.com/netlifeguru/examples/mapper/edge_cases/07_empty_result) * [Too many rows](https://github.com/netlifeguru/examples/mapper/edge_cases/08_too_many_rows) ## Repository [#repository] ```text https://github.com/netlifeguru/examples/mapper ``` # Project Information ## Documentation [#documentation] Official package documentation, guides, examples, and integration tutorials are available at: * [https://netlife.guru/docs/go/mapper](https://netlife.guru/docs/go/mapper) API reference is available on pkg.go.dev: * [https://pkg.go.dev/github.com/netlifeguru/mapper](https://pkg.go.dev/github.com/netlifeguru/mapper) Source code and issue tracking: * [https://github.com/netlifeguru/mapper](https://github.com/netlifeguru/mapper) Practical examples are available in the official examples repository: * [https://github.com/netlifeguru/examples/mapper](https://github.com/netlifeguru/examples/mapper) *** ## Design Goals [#design-goals] The mapper package is designed around a few core principles: * Simple and predictable row-to-struct mapping * Database-agnostic scanning through a small `Rows` interface * Mapping by column name instead of scan position * Native support for `db` tags, `json` tags, and snake\_case field names * Support for structs, maps, custom row handlers, pointers, nullable values, and JSON fields * Minimal dependencies and straightforward integration with existing Go database code * Efficient repeated scanning through cached struct metadata and scan plans * Clear error handling for empty results, duplicate rows, and unsupported assignments Mapper is intentionally small and focused. It does not manage database connections, transactions, migrations, schemas, or query building. Those responsibilities belong to database drivers or higher-level packages such as `github.com/netlifeguru/db`. *** ## Performance [#performance] The mapper package is designed to keep repeated row scanning efficient. It uses internal caches for struct metadata and scan plans so that repeated scans of the same result shape do not need to rebuild field mappings from scratch. Performance-oriented behavior includes: * cached struct field metadata * cached scan plans for repeated column layouts * optional named cache keys for hot paths * workspace reuse for row scanning * direct scanning for simple Go field types where possible * assignment helpers for more complex values such as pointers, nullable structs, slices, maps, and JSON fields Actual performance depends on: * the database driver * the number of returned rows * the number of returned columns * the target struct shape * whether values can be scanned directly * whether type conversion or JSON decoding is required * whether named cache keys are used for repeated query shapes For most applications, the default scanning functions are sufficient. Use named cache keys only for stable, frequently repeated query shapes where scan-plan reuse is beneficial. *** ## Versioning [#versioning] This project follows Semantic Versioning. See [`CHANGELOG.md`](https://github.com/netlifeguru/mapper/blob/main/CHANGELOG.md) for release history, version updates, and breaking changes. *** ## Contributing [#contributing] Community contributions, discussions, bug reports, and pull requests are welcome. Please read [`CONTRIBUTING.md`](https://github.com/netlifeguru/mapper/blob/main/CONTRIBUTING.md) before submitting pull requests or opening issues. *** ## Code of Conduct [#code-of-conduct] This project follows the Contributor Covenant Code of Conduct. Please read [`CODE_OF_CONDUCT.md`](https://github.com/netlifeguru/mapper/blob/main/CODE_OF_CONDUCT.md) before participating in discussions or contributing to the project. *** ## Author [#author] Created and maintained by NetLife Guru s.r.o. Resources: * Documentation: [https://netlife.guru/docs](https://netlife.guru/docs) * GitHub: [https://github.com/netlifeguru](https://github.com/netlifeguru) * Contact: [info@netlife.guru](mailto:info@netlife.guru) *** ## License [#license] This project is licensed under the MIT License. See [`LICENSE`](https://github.com/netlifeguru/mapper/blob/main/LICENSE) for full license information. # Cache Mapper builds a scan plan when scanning database rows into structs. A scan plan describes how returned database columns map to struct fields. It lets mapper reuse field metadata instead of resolving struct mapping from scratch for every row. For most applications, the default cache used by `ScanStructRows` is enough. For hot paths, you can use a named cache key with `ScanStructRowsWithCacheKey`. ## Overview [#overview] | API | Purpose | | ------------------------------- | ------------------------------------------------------------- | | `ScanStructRowsWithCacheKey` | Scan rows using a named scan-plan cache key | | `SetSchemaVersion` | Set a global schema version used by the named scan-plan cache | | `CurrentSchemaVersion` | Return the current schema version | | `ClearNamedStructScanPlanCache` | Clear all named scan-plan cache entries | ## Default Scan Plan Cache [#default-scan-plan-cache] Regular struct scanning already uses an internal cache. ```go err := mapper.ScanStructRows[User](rows, func(user *User) error { fmt.Println(user.Name) return nil }) ``` This default cache is based on: * the target struct type * the returned column list For most use cases, this is all you need. ## Named Cache Keys [#named-cache-keys] Use `ScanStructRowsWithCacheKey` when the same query shape is scanned repeatedly and you want to provide a stable cache key. ```go func ScanStructRowsWithCacheKey[T any]( rows mapper.Rows, cacheKey string, each func(*T) error, ) error ``` Example: ```go err := mapper.ScanStructRowsWithCacheKey[User]( rows, "users:list", func(user *User) error { fmt.Println(user.Name) return nil }, ) if err != nil { return err } ``` The cache key should describe the query shape, not only the table name. Good cache keys: ```go "users:list" "users:active-list" "users:by-role" "reports:user-summary" ``` Less useful cache keys: ```go "users" "query" "list" ``` ## Complete Example [#complete-example] ```go rows, err := db.Query(` SELECT * FROM users WHERE active = ? ORDER BY created_at DESC `, true) if err != nil { return err } defer rows.Close() err = mapper.ScanStructRowsWithCacheKey[User]( rows, "users:active-list", func(user *User) error { fmt.Println(user.ID, user.Name) return nil }, ) if err != nil { return err } ``` ## Column Shape Matters [#column-shape-matters] The cache key should be used for a stable result shape. This means the returned columns should stay the same for the same cache key. ```sql SELECT id, name, email FROM users ``` and: ```sql SELECT * FROM users ``` should not use the same cache key. Use different cache keys: ```go "users:basic-list" "users:active-list" ``` Mapper also checks the returned column signature internally. If the cached plan does not match the current columns, mapper rebuilds the plan for that key. ## Schema Version [#schema-version] Named cache entries include the current schema version. You can change the schema version when your application schema or query shapes change. ```go mapper.SetSchemaVersion("2026-05-22") ``` You can read the current value with: ```go version := mapper.CurrentSchemaVersion() fmt.Println(version) ``` If an empty schema version is provided, mapper falls back to: ```go "default" ``` ## Clearing the Named Cache [#clearing-the-named-cache] Use `ClearNamedStructScanPlanCache` to remove all named scan-plan cache entries. ```go mapper.ClearNamedStructScanPlanCache() ``` This is useful when: * tests need a clean cache state * query shapes changed dynamically * a long-running process reloads configuration * you want to force scan-plan rebuilding ## When to Use Named Cache Keys [#when-to-use-named-cache-keys] Use `ScanStructRowsWithCacheKey` for repeated hot paths. Good candidates: * high-traffic list queries * repeated background jobs * recurring exports * API endpoints with stable query shapes * frequently used reporting queries For normal application queries, prefer the simpler API: ```go users, err := mapper.ScanStructSlice[User](rows) ``` or: ```go err := mapper.ScanStructRows[User](rows, func(user *User) error { return processUser(user) }) ``` ## When Not to Use Named Cache Keys [#when-not-to-use-named-cache-keys] Avoid named cache keys when the selected columns change frequently. For example: ```go SELECT id, name FROM users ``` and: ```go SELECT id, email, created_at FROM users ``` should not share the same key. Also avoid cache keys for one-off queries where readability is more important than repeated scan-plan reuse. ## Empty Cache Key [#empty-cache-key] If the cache key is empty, mapper falls back to the default scan-plan behavior. ```go err := mapper.ScanStructRowsWithCacheKey[User]( rows, "", func(user *User) error { fmt.Println(user.Name) return nil }, ) ``` This behaves like: ```go err := mapper.ScanStructRows[User](rows, func(user *User) error { fmt.Println(user.Name) return nil }) ``` ## Recommended Usage [#recommended-usage] Use the regular scanning functions first. ```go users, err := mapper.ScanStructSlice[User](rows) ``` Add named cache keys only when a query is repeated often and has a stable result shape. ```go err := mapper.ScanStructRowsWithCacheKey[User]( rows, "users:list", func(user *User) error { return processUser(user) }, ) ``` Keep cache keys descriptive and stable. # Converters Mapper provides small helper functions for converting dynamic values from `map[string]any` rows into common Go types. These helpers are useful when working with: * `ScanMapRows` * `mapper.Row` * custom `ScanMapper` implementations * manually processed database values The converters return the converted value and a boolean result. ```go value, ok := mapper.AsString(v) ``` If conversion succeeds, `ok` is `true`. If the value cannot be converted, `ok` is `false`. ## Overview [#overview] | Function | Returns | Purpose | | ---------- | ------------------- | ------------------------------ | | `AsInt` | `(int, bool)` | Convert a value to `int` | | `AsInt64` | `(int64, bool)` | Convert a value to `int64` | | `AsString` | `(string, bool)` | Convert a value to `string` | | `AsBool` | `(bool, bool)` | Convert a value to `bool` | | `AsTime` | `(time.Time, bool)` | Convert a value to `time.Time` | ## AsInt [#asint] `AsInt` converts common numeric values into `int`. ```go id, ok := mapper.AsInt(row["id"]) if !ok { return errors.New("invalid id") } ``` Supported source values include: * `int` * `int8` * `int16` * `int32` * `int64` * `float32` * `float64` Example: ```go row := map[string]any{ "id": int64(123), } id, ok := mapper.AsInt(row["id"]) if !ok { return errors.New("invalid id") } fmt.Println(id) ``` ## AsInt64 [#asint64] `AsInt64` converts common numeric values into `int64`. ```go id, ok := mapper.AsInt64(row["id"]) if !ok { return errors.New("invalid id") } ``` Supported source values include: * `int` * `int8` * `int16` * `int32` * `int64` * `float32` * `float64` Example: ```go row := map[string]any{ "id": int64(123), } id, ok := mapper.AsInt64(row["id"]) if !ok { return errors.New("invalid id") } fmt.Println(id) ``` ## AsString [#asstring] `AsString` converts string-like values into `string`. ```go name, ok := mapper.AsString(row["name"]) if !ok { return errors.New("invalid name") } ``` Supported source values: * `string` * `[]byte` Example: ```go row := map[string]any{ "name": []byte("Alice"), } name, ok := mapper.AsString(row["name"]) if !ok { return errors.New("invalid name") } fmt.Println(name) ``` If the value is not a string or byte slice, conversion fails. ## AsBool [#asbool] `AsBool` converts boolean-like values into `bool`. ```go active, ok := mapper.AsBool(row["active"]) if !ok { return errors.New("invalid active value") } ``` Supported source values include: * `bool` * signed integer types * unsigned integer types * floating point types * common boolean strings * `[]byte` containing a supported boolean string Numeric values are converted using zero/non-zero behavior. ```go 0 // false 1 // true ``` Supported true strings: * `1` * `true` * `TRUE` * `True` * `yes` * `YES` * `Yes` * `y` * `Y` Supported false strings: * `0` * `false` * `FALSE` * `False` * `no` * `NO` * `No` * `n` * `N` Example: ```go row := map[string]any{ "active": "yes", } active, ok := mapper.AsBool(row["active"]) if !ok { return errors.New("invalid active value") } fmt.Println(active) ``` ## AsTime [#astime] `AsTime` converts time-like values into `time.Time`. ```go createdAt, ok := mapper.AsTime(row["created_at"]) if !ok { return errors.New("invalid created_at value") } ``` Supported source values: * `time.Time` * `string` * `[]byte` String and byte slice values are parsed using common time layouts. Supported layouts include: * `2006-01-02 15:04:05` * `2006-01-02` * `time.RFC3339` * `time.RFC3339Nano` Example: ```go row := map[string]any{ "created_at": "2026-05-22 14:30:00", } createdAt, ok := mapper.AsTime(row["created_at"]) if !ok { return errors.New("invalid created_at value") } fmt.Println(createdAt) ``` ## Row Converter Methods [#row-converter-methods] The same conversions are also available through the `mapper.Row` type. ```go row := mapper.Row{ "id": int64(123), "name": "Alice", "active": true, "created_at": time.Now(), } ``` Use typed methods to access values by key. ```go id, ok := row.Int64("id") if !ok { return errors.New("invalid id") } name, ok := row.String("name") if !ok { return errors.New("invalid name") } active, ok := row.Bool("active") if !ok { return errors.New("invalid active value") } createdAt, ok := row.Time("created_at") if !ok { return errors.New("invalid created_at value") } ``` Available methods: | Method | Uses | Returns | | ----------------- | ---------- | ------------------- | | `row.Int(key)` | `AsInt` | `(int, bool)` | | `row.Int64(key)` | `AsInt64` | `(int64, bool)` | | `row.String(key)` | `AsString` | `(string, bool)` | | `row.Bool(key)` | `AsBool` | `(bool, bool)` | | `row.Time(key)` | `AsTime` | `(time.Time, bool)` | ## Custom Mapping Example [#custom-mapping-example] Converters are especially useful when implementing `ScanMapper`. ```go type User struct { ID int64 Name string Active bool CreatedAt time.Time } func (u *User) ScanMap(row map[string]any) error { id, ok := mapper.AsInt64(row["id"]) if !ok { return errors.New("invalid id") } name, ok := mapper.AsString(row["name"]) if !ok { return errors.New("invalid name") } active, ok := mapper.AsBool(row["active"]) if !ok { return errors.New("invalid active value") } createdAt, ok := mapper.AsTime(row["created_at"]) if !ok { return errors.New("invalid created_at value") } u.ID = id u.Name = name u.Active = active u.CreatedAt = createdAt return nil } ``` ## Conversion Failures [#conversion-failures] Converters do not return errors. They return `ok = false` when conversion is not possible. ```go id, ok := mapper.AsInt64(row["id"]) if !ok { return errors.New("invalid id") } ``` This keeps converter usage simple and predictable. Use explicit error handling in your own code when a value is required. ## Recommended Usage [#recommended-usage] Use converters when working with dynamic row data. ```go err := mapper.ScanMapRows(rows, func(row map[string]any) error { name, ok := mapper.AsString(row["name"]) if !ok { return errors.New("invalid name") } fmt.Println(name) return nil }) ``` Use struct scanning when the result shape is known. ```go users, err := mapper.ScanStructSlice[User](rows) ``` Use converters when you need custom parsing, validation, or dynamic row handling. # Errors Mapper returns errors when row scanning, column loading, field assignment, or single-row expectations fail. Most errors come from one of these sources: * the database rows implementation * invalid scan destinations * unsupported type assignments * callback errors * `ScanStructOne` row count checks ## Overview [#overview] | Error | Meaning | | ---------------- | ------------------------------------------ | | `ErrNoRows` | `ScanStructOne` did not receive any rows | | `ErrTooManyRows` | `ScanStructOne` received more than one row | Use `errors.Is` when checking mapper sentinel errors. ```go if errors.Is(err, mapper.ErrNoRows) { return nil } ``` ## ErrNoRows [#errnorows] `ErrNoRows` is returned by `ScanStructOne` when the result set is empty. ```go user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrNoRows) { return nil } if err != nil { return err } fmt.Println(user.Name) ``` Use this when an empty result is valid and should be handled differently from a real failure. Example: ```go func FindUser(db *sql.DB, id string) (*User, error) { rows, err := db.Query(` SELECT id, name, email FROM users WHERE id = ? LIMIT 1 `, id) if err != nil { return nil, err } defer rows.Close() user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return user, nil } ``` ## ErrTooManyRows [#errtoomanyrows] `ErrTooManyRows` is returned by `ScanStructOne` when the result set contains more than one row. ```go user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrTooManyRows) { return errors.New("expected only one user") } if err != nil { return err } ``` This usually means the query is missing a unique condition, `LIMIT 1`, or the database contains unexpected duplicate data. Example: ```go rows, err := db.Query(` SELECT id, name, email FROM users WHERE email = ? `, email) if err != nil { return nil, err } defer rows.Close() user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrTooManyRows) { return nil, errors.New("email matched multiple users") } if errors.Is(err, mapper.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return user, nil ``` ## Callback Errors [#callback-errors] `ScanStructRows` and `ScanMapRows` stop scanning when the callback returns an error. The same error is returned to the caller. ```go err := mapper.ScanStructRows[User](rows, func(user *User) error { if user.ID == "" { return errors.New("missing user id") } return nil }) if err != nil { return err } ``` This is useful for validation, early stopping, or forwarding downstream processing failures. ```go err := mapper.ScanStructRows[User](rows, func(user *User) error { return indexUser(user) }) ``` If `indexUser` returns an error, scanning stops and that error is returned. ## Row Errors [#row-errors] Mapper checks row-level errors after scanning. If the underlying rows object returns an error from `Rows.Err()`, mapper returns that error. ```go err := mapper.ScanStructRows[User](rows, func(user *User) error { fmt.Println(user.Name) return nil }) if err != nil { return err } ``` These errors usually come from the database driver. Examples include: * connection errors * network interruption * scan errors * cursor errors * driver-specific row iteration errors ## Column Errors [#column-errors] Mapper reads column names using `Rows.Columns()`. If loading column names fails, mapper returns that error. ```go users, err := mapper.ScanStructSlice[User](rows) if err != nil { return err } ``` Column errors are uncommon, but they can happen if the underlying driver cannot expose column metadata. ## Assignment Errors [#assignment-errors] Mapper returns an error when a scanned value cannot be assigned to the destination field. Example: ```go type User struct { CreatedAt time.Time `db:"created_at"` } ``` If the database returns a value that cannot be assigned to `time.Time`, scanning fails. ```go users, err := mapper.ScanStructSlice[User](rows) if err != nil { return err } ``` Assignment errors usually mean that: * the database column type does not match the Go field type * the struct field type is unsupported * a JSON field contains invalid JSON * a numeric value overflows the target type * the destination field cannot be set ## FillFromMap Errors [#fillfrommap-errors] `FillFromMap` returns an error when the destination is invalid or a value cannot be assigned. ```go var user User err := mapper.FillFromMap(&user, row) if err != nil { return err } ``` The destination must be a non-nil pointer to a struct. ```go // Correct err := mapper.FillFromMap(&user, row) ``` Invalid usage: ```go // Wrong: destination is not a pointer. err := mapper.FillFromMap(user, row) ``` ```go var user *User // Wrong: destination is nil. err := mapper.FillFromMap(user, row) ``` ## Converter Failures [#converter-failures] Converter helpers such as `AsString`, `AsInt64`, `AsBool`, and `AsTime` do not return errors. They return `ok = false`. ```go id, ok := mapper.AsInt64(row["id"]) if !ok { return errors.New("invalid id") } ``` This lets you decide how strict your own custom mapping should be. ## Recommended Error Handling [#recommended-error-handling] Use `errors.Is` for mapper sentinel errors. ```go user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrNoRows) { return nil } if errors.Is(err, mapper.ErrTooManyRows) { return errors.New("expected one row") } if err != nil { return err } ``` Return callback errors directly unless you need more context. ```go err := mapper.ScanStructRows[User](rows, func(user *User) error { if err := validateUser(user); err != nil { return fmt.Errorf("validate user %s: %w", user.ID, err) } return nil }) ``` Add context around assignment or query-level failures when useful. ```go users, err := mapper.ScanStructSlice[User](rows) if err != nil { return nil, fmt.Errorf("scan users: %w", err) } ``` ## Common Patterns [#common-patterns] ### Optional Lookup [#optional-lookup] ```go user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return user, nil ``` ### Required Lookup [#required-lookup] ```go user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrNoRows) { return nil, errors.New("user not found") } if err != nil { return nil, err } return user, nil ``` ### Unique Lookup [#unique-lookup] ```go user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrTooManyRows) { return nil, errors.New("expected unique user") } if errors.Is(err, mapper.ErrNoRows) { return nil, errors.New("user not found") } if err != nil { return nil, err } return user, nil ``` # Maps and Rows This page documents the map-based APIs in mapper. Use these APIs when you want dynamic row data, manual mapping, or typed access to values stored in `map[string]any`. ## Overview [#overview] | API | Purpose | | ------------- | ---------------------------------------------------- | | `ScanMapRows` | Scan database rows into `map[string]any` values | | `FillFromMap` | Fill a Go struct from a `map[string]any` | | `Row` | Convenience type with typed accessors for map values | ## ScanMapRows [#scanmaprows] `ScanMapRows` scans each database row into a `map[string]any` and passes it to a callback. ```go func ScanMapRows(rows Rows, each func(row map[string]any) error) error ``` Example: ```go err := mapper.ScanMapRows(rows, func(row map[string]any) error { fmt.Println(row["id"], row["name"]) return nil }) if err != nil { return err } ``` Each map key is the column name returned by the database driver. ```go map[string]any{ "id": "u_123", "name": "Alice", "email": "alice@example.com", "active": true, } ``` `ScanMapRows` calls the callback once for every row. If the callback returns an error, scanning stops and the same error is returned. ## Query Example [#query-example] ```go rows, err := db.Query(` SELECT * FROM users ORDER BY name ASC `) if err != nil { return err } defer rows.Close() err = mapper.ScanMapRows(rows, func(row map[string]any) error { fmt.Println(row["name"]) return nil }) if err != nil { return err } ``` Use `ScanMapRows` when the result shape is dynamic or when you do not want to define a struct for the query. ## Scanned Values [#scanned-values] `ScanMapRows` stores scanned values by column name. Some values are normalized before they are placed into the map: | Source value | Stored value | | ---------------- | -------------- | | `nil` | `nil` | | `[]byte` | `string` | | `map[string]any` | JSON string | | `[]any` | JSON string | | other values | original value | This makes common database values easier to work with in dynamic row maps. ## FillFromMap [#fillfrommap] `FillFromMap` fills a struct from a `map[string]any`. ```go func FillFromMap[T any](dst *T, m map[string]any) error ``` Example: ```go type User struct { ID string `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` } row := map[string]any{ "id": "u_123", "name": "Alice", "email": "alice@example.com", "active": true, } var user User err := mapper.FillFromMap(&user, row) if err != nil { return err } ``` `FillFromMap` uses the same field matching rules as struct scanning: 1. `db` tag 2. `json` tag 3. Go field name 4. snake\_case fallback Unknown map keys are ignored. If a value cannot be assigned to the target field, `FillFromMap` returns an error. ## FillFromMap Destination [#fillfrommap-destination] The destination must be a non-nil pointer. ```go var user User err := mapper.FillFromMap(&user, row) if err != nil { return err } ``` Do not pass a value directly: ```go // Wrong: destination must be a pointer. err := mapper.FillFromMap(user, row) ``` Do not pass a nil pointer: ```go var user *User // Wrong: destination is nil. err := mapper.FillFromMap(user, row) ``` ## Row [#row] `Row` is a convenience type for working with dynamic row maps. ```go type Row map[string]any ``` It provides typed accessors for common values. ```go row := mapper.Row{ "id": int64(123), "name": "Alice", "active": true, } id, ok := row.Int64("id") if !ok { return errors.New("invalid id") } name, ok := row.String("name") if !ok { return errors.New("invalid name") } active, ok := row.Bool("active") if !ok { return errors.New("invalid active value") } ``` ## Row Methods [#row-methods] | Method | Returns | Purpose | | -------- | ------------------- | --------------------------- | | `Int` | `(int, bool)` | Read a value as `int` | | `Int64` | `(int64, bool)` | Read a value as `int64` | | `String` | `(string, bool)` | Read a value as `string` | | `Bool` | `(bool, bool)` | Read a value as `bool` | | `Time` | `(time.Time, bool)` | Read a value as `time.Time` | Each method returns the converted value and a boolean. ```go value, ok := row.String("name") ``` If conversion succeeds, `ok` is `true`. If the key is missing or the value cannot be converted, `ok` is `false`. ## Using Row With ScanMapRows [#using-row-with-scanmaprows] You can convert a scanned map into `mapper.Row` when you want typed accessors. ```go err := mapper.ScanMapRows(rows, func(m map[string]any) error { row := mapper.Row(m) id, ok := row.Int64("id") if !ok { return errors.New("invalid id") } name, ok := row.String("name") if !ok { return errors.New("invalid name") } fmt.Println(id, name) return nil }) ``` ## Choosing the Right API [#choosing-the-right-api] | Need | Use | | ------------------------------------- | --------------------------------------- | | Process rows dynamically | `ScanMapRows` | | Convert an existing map into a struct | `FillFromMap` | | Read typed values from a map | `Row` | | Convert individual values | `AsInt`, `AsString`, `AsBool`, `AsTime` | Individual converters are documented in the Converters API page. # Scanning The scanning API is the main way to convert database rows into typed Go structs. Mapper reads the column names from `Rows.Columns()`, builds a scan plan for the target struct, scans each row, and assigns matching values into struct fields. The main scanning functions are: | Function | Use case | | ---------------------------- | --------------------------------------------- | | `ScanStructRows` | Stream rows one by one through a callback | | `ScanStructSlice` | Scan all rows into a `[]T` slice | | `ScanStructOne` | Scan exactly one row | | `ScanStructRowsWithCacheKey` | Stream rows using a named scan-plan cache key | ## The `Rows` Interface [#the-rows-interface] All scanning functions work with the `mapper.Rows` interface. ```go type Rows interface { Next() bool Scan(dest ...any) error Err() error Close() error Columns() ([]string, error) } ``` Go’s standard `*sql.Rows` already provides these methods, so it can be used directly with mapper. Other database drivers can be supported by adapting their row type to this interface. ## Example Struct [#example-struct] The examples on this page use the following struct: ```go type User struct { ID string `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` The `db` tags tell mapper which database columns should be assigned to each field. ## ScanStructSlice [#scanstructslice] Use `ScanStructSlice` when you want to load all rows into memory as a typed slice. ```go users, err := mapper.ScanStructSlice[User](rows) if err != nil { return err } ``` This returns: ```go []User ``` Example: ```go rows, err := db.Query(` SELECT * FROM users ORDER BY created_at DESC `) if err != nil { return err } defer rows.Close() users, err := mapper.ScanStructSlice[User](rows) if err != nil { return err } for _, user := range users { fmt.Println(user.Name) } ``` Use this when: * the result set is reasonably small * you need all results at once * the caller expects a `[]T` * you want the simplest API When there are no rows, `ScanStructSlice` returns an empty slice. ## ScanStructRows [#scanstructrows] Use `ScanStructRows` when you want to process rows one by one. ```go err := mapper.ScanStructRows[User](rows, func(user *User) error { fmt.Println(user.Name) return nil }) ``` The callback is called once for every row. Example: ```go rows, err := db.Query(` SELECT * FROM users ORDER BY created_at DESC `) if err != nil { return err } defer rows.Close() err = mapper.ScanStructRows[User](rows, func(user *User) error { fmt.Println(user.ID, user.Name) return nil }) if err != nil { return err } ``` Use this when: * the result set may be large * you want to avoid storing all rows in memory * you want to stream rows into another system * you want to stop early by returning an error from the callback If the callback returns an error, scanning stops and that error is returned. ## ScanStructOne [#scanstructone] Use `ScanStructOne` when a query must return exactly one row. ```go user, err := mapper.ScanStructOne[User](rows) if err != nil { return err } ``` `ScanStructOne` returns a pointer: ```go *User ``` Example: ```go rows, err := db.Query(` SELECT * FROM users WHERE id = ? LIMIT 1 `, userID) if err != nil { return err } defer rows.Close() user, err := mapper.ScanStructOne[User](rows) if err != nil { return err } fmt.Println(user.Name) ``` When no rows are returned, `ScanStructOne` returns `mapper.ErrNoRows`. ```go user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrNoRows) { return nil } if err != nil { return err } ``` When more than one row is returned, `ScanStructOne` returns `mapper.ErrTooManyRows`. ```go user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrTooManyRows) { return errors.New("expected only one user") } if err != nil { return err } ``` Use this when: * the query is expected to return one row * you are loading by primary key * you are checking a unique value * multiple rows should be treated as an error ## Do Not Reuse Consumed Rows [#do-not-reuse-consumed-rows] Rows are consumed while scanning. This means you should not scan the same `rows` value twice. ```go users, err := mapper.ScanStructSlice[User](rows) if err != nil { return err } // Do not do this with the same rows value. // The rows have already been consumed. err = mapper.ScanStructRows[User](rows, func(user *User) error { return nil }) ``` Run the query again if you need to scan the result in a different way. ## How Fields Are Assigned [#how-fields-are-assigned] Mapper matches returned column names to exported struct fields. The field matching rules are described in the Mapping guide. In short, mapper supports: * `db` tags * `json` tags * Go field names * snake\_case fallback * direct scanning for simple field types * assignment through `AssignValue` for pointers, nullable structs, JSON fields, and other indirect values Example: ```go type User struct { ID string `db:"id"` CreatedAt time.Time `db:"created_at"` } ``` Column names: ```sql SELECT id, created_at FROM users ``` Result: ```go User{ ID: "u_123", CreatedAt: time.Now(), } ``` ## Direct and Indirect Scanning [#direct-and-indirect-scanning] Mapper uses a scan plan internally. Simple fields can be scanned directly into the struct field. Examples: * `string` * `bool` * integer types * unsigned integer types * floating point types * `time.Time` * `[]byte` More complex fields are scanned into temporary values first and then assigned with `AssignValue`. Examples: * pointer fields * nullable-style structs * slices from JSON * maps from JSON * values requiring conversion You do not normally need to manage this manually. ## ScanStructRowsWithCacheKey [#scanstructrowswithcachekey] `ScanStructRowsWithCacheKey` works like `ScanStructRows`, but uses a named cache key for the scan plan. ```go err := mapper.ScanStructRowsWithCacheKey[User]( rows, "users:list", func(user *User) error { fmt.Println(user.Name) return nil }, ) ``` This is useful for hot paths where the same query shape is scanned repeatedly. Use a stable cache key for a stable result shape. More details are covered in the Cache API page. ## Choosing the Right Function [#choosing-the-right-function] | Need | Use | | ---------------------------------------- | ---------------------------- | | Load all rows into a slice | `ScanStructSlice` | | Process rows one by one | `ScanStructRows` | | Require exactly one row | `ScanStructOne` | | Optimize repeated scans with a named key | `ScanStructRowsWithCacheKey` | ## Recommended Usage [#recommended-usage] For most list queries: ```go users, err := mapper.ScanStructSlice[User](rows) ``` For large exports or streaming workflows: ```go err := mapper.ScanStructRows[User](rows, func(user *User) error { return processUser(user) }) ``` For primary-key lookups: ```go user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrNoRows) { return nil } if err != nil { return err } ``` # FillFromMap Use `FillFromMap` when you already have a `map[string]any` and want to fill a Go struct from it. This is useful when data does not come directly from database rows, or when you want to reuse mapper’s field matching and assignment logic with map-based data. Typical use cases include: * converting dynamic row maps into structs * processing data from `ScanMapRows` * mapping decoded JSON-like values * mapping data from generic pipelines * converting test fixtures into structs * manually preparing row-like data before validation ## Complete Usage Example [#complete-usage-example] This example creates a `map[string]any` manually and fills a `User` struct from it. ```go package main import ( "fmt" "log" "time" "github.com/netlifeguru/mapper" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } func main() { row := map[string]any{ "id": 1, "name": "John Doe", "email": "john@example.com", "active": true, "created_at": time.Date(2026, 5, 7, 11, 15, 21, 0, time.UTC), } var user User err := mapper.FillFromMap(&user, row) if err != nil { log.Fatal(err) } fmt.Printf( "ID: %d | Name: %s | Email: %s | Active: %t | Created: %s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } ``` ## Basic Idea [#basic-idea] `FillFromMap` assigns values from a map into matching struct fields. ```go err := mapper.FillFromMap(&user, row) if err != nil { return err } ``` The destination must be a non-nil pointer to a struct. ## Define a Struct [#define-a-struct] Create a struct that describes the expected data shape. ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` Mapper uses the same field matching rules as struct scanning: 1. `db` tag 2. `json` tag 3. Go field name 4. snake\_case fallback ## Prepare a Map [#prepare-a-map] Create or receive a map with column-like keys. ```go row := map[string]any{ "id": 1, "name": "John Doe", "email": "john@example.com", "active": true, "created_at": time.Date(2026, 5, 7, 11, 15, 21, 0, time.UTC), } ``` The map keys should match the struct tags or field names. ## Fill the Struct [#fill-the-struct] Pass a pointer to the destination struct. ```go var user User err := mapper.FillFromMap(&user, row) if err != nil { return err } ``` After mapping, the struct contains the assigned values. ```go fmt.Println(user.ID, user.Name, user.Email, user.Active) ``` ## Extra Map Keys [#extra-map-keys] Extra keys are ignored when no matching struct field exists. ```go type User struct { ID string `db:"id"` Name string `db:"name"` } ``` ```go row := map[string]any{ "id": "u_123", "name": "Alice", "created_at": "2026-05-22", } ``` The `created_at` key is ignored because the struct does not define a matching field. ## Missing Map Keys [#missing-map-keys] If a map does not contain a key for a struct field, that field keeps its zero value. ```go type User struct { ID string `db:"id"` Name string `db:"name"` Active bool `db:"active"` } ``` ```go row := map[string]any{ "id": "u_123", "name": "Alice", } ``` The `Active` field remains `false`. ## Pointer Fields [#pointer-fields] Pointer fields are supported. ```go type User struct { ID string `db:"id"` Email *string `db:"email"` } ``` ```go row := map[string]any{ "id": "u_123", "email": "alice@example.com", } ``` When the value is present and not `nil`, mapper allocates and assigns the pointer. ```go var user User err := mapper.FillFromMap(&user, row) if err != nil { return err } if user.Email != nil { fmt.Println(*user.Email) } ``` If the map value is `nil`, the pointer remains `nil`. ## JSON Fields [#json-fields] `FillFromMap` can assign JSON strings or byte slices into slice and map fields. ```go type User struct { ID string `db:"id"` Tags []string `db:"tags"` Metadata map[string]string `db:"metadata"` } ``` ```go row := map[string]any{ "id": "u_123", "tags": `["admin","active"]`, "metadata": `{"source":"import","role":"admin"}`, } ``` ```go var user User err := mapper.FillFromMap(&user, row) if err != nil { return err } ``` If the JSON is invalid, mapper returns an error. ## Invalid Destination [#invalid-destination] The destination must be a non-nil pointer to a struct. Correct: ```go var user User err := mapper.FillFromMap(&user, row) ``` Incorrect: ```go // Wrong: destination is not a pointer. err := mapper.FillFromMap(user, row) ``` Incorrect: ```go var user *User // Wrong: destination is nil. err := mapper.FillFromMap(user, row) ``` ## When to Use FillFromMap [#when-to-use-fillfrommap] Use `FillFromMap` when: * you already have `map[string]any` * you want mapper’s struct field matching without scanning rows * you need to convert dynamic data into a typed struct * you want to reuse `db` and `json` tags * you want assignment support for pointers, nullable values, slices, maps, and JSON fields ## When Not to Use It [#when-not-to-use-it] Do not use `FillFromMap` when you already have database rows and simply want structs. Use struct scanning directly. ```go users, err := mapper.ScanStructSlice[User](rows) ``` or: ```go err := mapper.ScanStructRows[User](rows, func(user *User) error { users = append(users, *user) return nil }) ``` ## Related Example [#related-example] A standalone example is available in the examples repository: [FillFromMap example](https://github.com/netlifeguru/examples/mapper/api/05_fill_from_map) # ScanMapRows Use `ScanMapRows` when you want each database row as a `map[string]any`. This is useful when the result shape is dynamic, temporary, or not worth defining as a dedicated struct. Typical use cases include: * admin screens * reports * exports * debugging queries * dynamic dashboards * generic tooling * queries with computed columns * queries where selected columns may change ## Basic Idea [#basic-idea] `ScanMapRows` scans each row into a map and passes it to a callback. ```go err := mapper.ScanMapRows(rows, func(row map[string]any) error { fmt.Println(row["id"], row["type"]) return nil }) ``` The callback is called once for every row. Each map key is the column name returned by the database driver. ## Complete Query Example [#complete-query-example] This example loads event rows as dynamic maps. ```go package main import ( "database/sql" "github.com/netlifeguru/mapper" ) func getEvents(db *sql.DB) ([]map[string]any, error) { rows, err := db.Query(` SELECT id, type, payload, created_at FROM events ORDER BY created_at DESC `) if err != nil { return nil, err } defer rows.Close() var events []map[string]any err = mapper.ScanMapRows(rows, func(row map[string]any) error { events = append(events, row) return nil }) if err != nil { return nil, err } return events, nil } ``` ## Complete Usage Example [#complete-usage-example] This example calls `getEvents` and prints the returned map values. ```go package main import ( "fmt" "log" _ "github.com/go-sql-driver/mysql" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } db, err := connectDB() if err != nil { log.Fatal(err) } defer db.Close() events, err := getEvents(db) if err != nil { log.Fatal(err) } for _, event := range events { fmt.Printf( "ID: %v | Type: %v | Payload: %v | Created: %v\n", event["id"], event["type"], event["payload"], event["created_at"], ) } } ``` ## Returned Row Shape [#returned-row-shape] Each row is represented as a `map[string]any`. For the query above, one row may look like this: ```go map[string]any{ "id": int64(1), "type": "user.created", "payload": `{"name":"Alice"}`, "created_at": time.Now(), } ``` The exact Go types depend on the database driver. ## SQL Aliases [#sql-aliases] Map keys are based on returned column names. Use SQL aliases when selecting computed values or joining tables. ```sql SELECT u.id AS user_id, u.name AS user_name, r.name AS role_name FROM users u JOIN roles r ON r.id = u.role_id ``` The scanned map will contain keys such as: ```go map[string]any{ "user_id": int64(1), "user_name": "Alice", "role_name": "Admin", } ``` ## Typed Access [#typed-access] For typed access, convert the map to `mapper.Row`. ```go row := mapper.Row(event) eventType, ok := row.String("type") if !ok { return errors.New("invalid event type") } ``` `mapper.Row` provides helpers such as: * `Int` * `Int64` * `String` * `Bool` * `Time` For individual values, you can also use standalone converters: ```go eventType, ok := mapper.AsString(event["type"]) ``` ## When to Use ScanMapRows [#when-to-use-scanmaprows] Use `ScanMapRows` when: * you do not have a stable struct shape * you need dynamic columns * you are building reports or exports * you want to inspect raw row values * you want to process data as maps * you do not want to define a struct for the query ## When Not to Use It [#when-not-to-use-it] Do not use `ScanMapRows` when the result shape is known and stable. Use struct scanning instead. ```go users, err := mapper.ScanStructSlice[User](rows) ``` Structs make application code clearer and safer when the result has a known shape. ## Notes [#notes] `ScanMapRows` consumes the rows. Do not scan the same `rows` value again after calling it. If the underlying rows object returns an error, `ScanMapRows` returns that error. If the callback returns an error, scanning stops and returns that error. ## Related Example [#related-example] A standalone example is available in the examples repository: [ScanMapRows example](https://github.com/netlifeguru/examples/mapper/api/04_scan_map_rows) # ScanStructOne Use `ScanStructOne` when a query is expected to return exactly one row. This is useful for lookups where the result should be unique. Typical use cases include: * loading a record by primary key * loading a user by email * checking a unique token * reading one configuration row * fetching a single aggregate result * validating that a query returns only one result ## Basic Idea [#basic-idea] `ScanStructOne` scans one database row into `*T`. ```go user, err := mapper.ScanStructOne[User](rows) if err != nil { return err } ``` The result is a pointer to the scanned struct. ```go fmt.Println(user.Name) ``` If no row is returned, mapper returns `mapper.ErrNoRows`. If more than one row is returned, mapper returns `mapper.ErrTooManyRows`. ## Define a Struct [#define-a-struct] Create a struct that represents the expected result row. ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` ## Query One Row [#query-one-row] Query rows using your database driver. Even when expecting one row, use a rows-based query because mapper works with `mapper.Rows`. ```go rows, err := db.Query(` SELECT * FROM users WHERE id = ? LIMIT 1 `, id) if err != nil { return nil, err } defer rows.Close() return mapper.ScanStructOne[User](rows) ``` ## Complete Query Example [#complete-query-example] This example defines the model and a small repository-style function. ```go package main import ( "database/sql" "time" "github.com/netlifeguru/mapper" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } func getUserByID(db *sql.DB, id int) (*User, error) { rows, err := db.Query(` SELECT * FROM users WHERE id = ? LIMIT 1 `, id) if err != nil { return nil, err } defer rows.Close() return mapper.ScanStructOne[User](rows) } ``` ## Complete Usage Example [#complete-usage-example] This example loads one user and handles the `mapper.ErrNoRows` case separately. ```go package main import ( "errors" "fmt" "log" "github.com/joho/godotenv" _ "github.com/go-sql-driver/mysql" "github.com/netlifeguru/mapper" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } db, err := connectDB() if err != nil { log.Fatal(err) } defer db.Close() user, err := getUserByID(db, 1) if err != nil { if errors.Is(err, mapper.ErrNoRows) { log.Println("user not found") return } log.Fatal(err) } fmt.Printf( "ID: %d | Name: %s | Email: %s | Active: %t | Created: %s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } ``` ## Handling No Rows [#handling-no-rows] Use `errors.Is` to check for `mapper.ErrNoRows`. ```go user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrNoRows) { return nil } if err != nil { return err } fmt.Println(user.Name) ``` This is useful when “not found” is not a system error. For example, an application can log the missing row and return normally. ```go if errors.Is(err, mapper.ErrNoRows) { log.Println("user not found") return } ``` ## Handling Too Many Rows [#handling-too-many-rows] Use `errors.Is` to check for `mapper.ErrTooManyRows`. ```go user, err := mapper.ScanStructOne[User](rows) if errors.Is(err, mapper.ErrTooManyRows) { return errors.New("expected one user, got multiple") } if err != nil { return err } ``` This usually means the query is not unique enough or the database contains unexpected duplicate data. For unique lookups, prefer a unique column or primary key. ```sql SELECT id, name, email FROM users WHERE email = ? ``` If the query should only return one row, adding `LIMIT 1` can protect the query shape, but it also hides duplicate data. Use `LIMIT 1` when you only need the first match. Avoid `LIMIT 1` when duplicates should be detected. ## Required Lookup [#required-lookup] Sometimes a missing row should be treated as an error. ```go func requireUserByID(db *sql.DB, id int) (*User, error) { user, err := getUserByID(db, id) if errors.Is(err, mapper.ErrNoRows) { return nil, errors.New("user not found") } if err != nil { return nil, err } return user, nil } ``` ## Optional Lookup [#optional-lookup] For optional lookups, return `nil` when the row is not found. ```go func optionalUserByID(db *sql.DB, id int) (*User, error) { user, err := getUserByID(db, id) if errors.Is(err, mapper.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return user, nil } ``` ## When to Use ScanStructOne [#when-to-use-scanstructone] Use `ScanStructOne` when: * the query should return exactly one row * zero rows should be handled explicitly * multiple rows should be treated as an error * the caller expects `*T` * you are loading by a unique identifier Good examples: ```sql SELECT id, name, email FROM users WHERE id = ? ``` ```sql SELECT id, name, email FROM users WHERE email = ? ``` ```sql SELECT COUNT(*) AS total FROM users ``` ## When Not to Use It [#when-not-to-use-it] Do not use `ScanStructOne` for normal list queries. Use `ScanStructSlice` instead. ```go users, err := mapper.ScanStructSlice[User](rows) ``` Do not use `ScanStructOne` if you intentionally want only the first row and do not care about duplicates. In that case, make the query explicit with `LIMIT 1`, or handle the behavior at the SQL level. ## Related Example [#related-example] A standalone example is available in the examples repository: [ScanStructOne example](https://github.com/netlifeguru/examples/mapper/api/03_scan_struct_one) # ScanStructRows Use `ScanStructRows` when you want to process database rows one by one. The function scans each row into a Go struct and passes it to a callback. This gives you control over what happens with every scanned row. ## When to Use It [#when-to-use-it] Use `ScanStructRows` when: * you want callback-based row processing * you want to build the result manually * you want to validate each row while scanning * you want to stop scanning by returning an error * you need custom behavior for every scanned row For simple list queries, `ScanStructSlice` is usually shorter. `ScanStructRows` is useful when you want more control. ## Define a Struct [#define-a-struct] Create a struct that represents one row from the query result. ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` The `db` tags tell mapper which database columns should be assigned to each field. ## Query Rows [#query-rows] Query rows using your database driver. ```go rows, err := db.Query(` SELECT * FROM users ORDER BY created_at DESC `) if err != nil { return nil, err } defer rows.Close() ``` The returned `rows` value is passed to mapper. With Go’s standard `database/sql`, `*sql.Rows` already provides the methods required by mapper. ## Scan Rows With a Callback [#scan-rows-with-a-callback] Use `ScanStructRows` to scan rows one by one. ```go var users []User err = mapper.ScanStructRows[User](rows, func(user *User) error { users = append(users, *user) return nil }) if err != nil { return nil, err } return users, nil ``` The callback receives a pointer to the scanned struct. In this example, each scanned `User` is appended to a slice manually. ## Complete Query Example [#complete-query-example] ```go package main import ( "database/sql" "time" "github.com/netlifeguru/mapper" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } func getUsers(db *sql.DB) ([]User, error) { rows, err := db.Query(` SELECT * FROM users ORDER BY created_at DESC `) if err != nil { return nil, err } defer rows.Close() var users []User err = mapper.ScanStructRows[User](rows, func(user *User) error { users = append(users, *user) return nil }) if err != nil { return nil, err } return users, nil } ``` ## Complete Usage Example [#complete-usage-example] ```go package main import ( "fmt" "log" _ "github.com/go-sql-driver/mysql" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } db, err := connectDB() if err != nil { log.Fatal(err) } defer db.Close() users, err := getUsers(db) if err != nil { log.Fatal(err) } for _, user := range users { fmt.Printf( "ID: %d | Name: %s | Email: %s | Active: %t | Created: %s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } } ``` The `connectDB` function is part of the standalone example and is responsible for opening the MySQL database connection. ## Callback Behavior [#callback-behavior] The callback is called once for every row. ```go err = mapper.ScanStructRows[User](rows, func(user *User) error { users = append(users, *user) return nil }) ``` If the callback returns `nil`, scanning continues. If the callback returns an error, scanning stops and that error is returned. ```go err = mapper.ScanStructRows[User](rows, func(user *User) error { if user.ID == 0 { return errors.New("missing user id") } users = append(users, *user) return nil }) ``` This is useful when you want to validate or reject rows during scanning. ## Difference From ScanStructSlice [#difference-from-scanstructslice] This example manually builds a slice: ```go var users []User err = mapper.ScanStructRows[User](rows, func(user *User) error { users = append(users, *user) return nil }) ``` For simple cases, `ScanStructSlice` can do this automatically: ```go users, err := mapper.ScanStructSlice[User](rows) ``` Use `ScanStructRows` when you want callback control. Use `ScanStructSlice` when you simply want all rows returned as `[]User`. ## Rows Are Consumed [#rows-are-consumed] Rows are consumed during scanning. Do not scan the same `rows` value twice. ```go err = mapper.ScanStructRows[User](rows, func(user *User) error { users = append(users, *user) return nil }) // Do not scan the same rows again. // Run the query again if you need another scan. ``` ## Related Example [#related-example] A standalone example is available in the examples repository: [ScanStructRows example](https://github.com/netlifeguru/examples/mapper/api/01_scan_struct_rows) # ScanStructSlice Use `ScanStructSlice` when you want to scan all database rows into a typed Go slice. This is the simplest mapper function for list queries. It is useful when you expect zero or more rows and want the result as `[]T`. ## When to Use It [#when-to-use-it] Use `ScanStructSlice` when: * you are building a list query * you want all rows returned as a slice * the result set can safely fit in memory * you do not need custom per-row callback logic * the caller expects `[]T` For callback-based row processing, use `ScanStructRows`. ## Define a Struct [#define-a-struct] Create a struct that represents one row from the query result. ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` The `db` tags tell mapper which database columns should be assigned to each field. ## Query Rows [#query-rows] Query rows using your database driver. ```go rows, err := db.Query(` SELECT * FROM users ORDER BY created_at DESC `) if err != nil { return nil, err } defer rows.Close() ``` ## Scan Into a Slice [#scan-into-a-slice] Pass the rows into `ScanStructSlice`. ```go return mapper.ScanStructSlice[User](rows) ``` Mapper scans every row into a `User` value and returns a `[]User`. ## Complete Query Example [#complete-query-example] ```go package main import ( "database/sql" "time" "github.com/netlifeguru/mapper" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } func getUsers(db *sql.DB) ([]User, error) { rows, err := db.Query(` SELECT * FROM users ORDER BY created_at DESC `) if err != nil { return nil, err } defer rows.Close() return mapper.ScanStructSlice[User](rows) } ``` ## Complete Usage Example [#complete-usage-example] ```go package main import ( "fmt" "log" _ "github.com/go-sql-driver/mysql" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } db, err := connectDB() if err != nil { log.Fatal(err) } defer db.Close() users, err := getUsers(db) if err != nil { log.Fatal(err) } for _, user := range users { fmt.Printf( "ID: %d | Name: %s | Email: %s | Active: %t | Created: %s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } } ``` The `connectDB` function is part of the standalone example and is responsible for opening the MySQL database connection. ## Empty Result [#empty-result] When the query returns no rows, `ScanStructSlice` returns an empty slice. ```go users, err := mapper.ScanStructSlice[User](rows) if err != nil { return nil, err } fmt.Println(len(users)) // 0 ``` This is useful for API list responses where an empty result is valid. ## Difference From ScanStructRows [#difference-from-scanstructrows] `ScanStructSlice` automatically builds the slice for you. ```go users, err := mapper.ScanStructSlice[User](rows) ``` With `ScanStructRows`, you control what happens for every scanned row. ```go var users []User err = mapper.ScanStructRows[User](rows, func(user *User) error { users = append(users, *user) return nil }) ``` Both approaches are valid. Use `ScanStructSlice` for simple list queries. Use `ScanStructRows` when you need callback control. ## Rows Are Consumed [#rows-are-consumed] Rows are consumed during scanning. Do not scan the same `rows` value twice. ```go users, err := mapper.ScanStructSlice[User](rows) if err != nil { return nil, err } // Do not scan the same rows again. // Run the query again if you need another scan. ``` ## Related Example [#related-example] A standalone example is available in the examples repository: [ScanStructSlice example](https://github.com/netlifeguru/examples/mapper/api/02_scan_struct_slice) # Examples Practical examples are available in the official examples repository: ```text https://github.com/netlifeguru/examples/router ``` The repository contains standalone examples covering routing, middleware composition, request handling, observability integration, recovery workflows, profiling, static assets, and multi-server deployments. ## Core Examples [#core-examples] * [Default router setup](https://github.com/netlifeguru/examples/router/default) * [Handlers](https://github.com/netlifeguru/examples/router/handlers) * [Middleware](https://github.com/netlifeguru/examples/router/middleware) * [Group middleware](https://github.com/netlifeguru/examples/router/group_middleware) * [Route grouping](https://github.com/netlifeguru/examples/router/grouping) * [Mounting handlers](https://github.com/netlifeguru/examples/router/mounting) * [Custom route patterns](https://github.com/netlifeguru/examples/router/patterns) * [Static file serving](https://github.com/netlifeguru/examples/router/static_files) * [Healthcheck endpoints](https://github.com/netlifeguru/examples/router/healthcheck) * [Request logging](https://github.com/netlifeguru/examples/router/logging) * [Recovery middleware](https://github.com/netlifeguru/examples/router/recovery) * [Custom error handler](https://github.com/netlifeguru/examples/router/custom_error_handler) * [Custom error page](https://github.com/netlifeguru/examples/router/custom_error_page) * [Rate limiting](https://github.com/netlifeguru/examples/router/rate_limiting) * [Built-in profiling](https://github.com/netlifeguru/examples/router/profiling) * [Multi-server setup](https://github.com/netlifeguru/examples/router/multi_server) *** ## Observability Examples [#observability-examples] The observability examples demonstrate integration with Prometheus, OpenTelemetry, Jaeger, Grafana, and distributed tracing workflows. These examples require additional third-party dependencies and observability tooling. * [OpenTelemetry integration](https://github.com/netlifeguru/examples/router/observability/otel) * [OpenTelemetry full setup](https://github.com/netlifeguru/examples/router/observability/otel-full) * [Prometheus metrics](https://github.com/netlifeguru/examples/router/observability/prometheus) * [Prometheus + Grafana](https://github.com/netlifeguru/examples/router/observability/prometheus-grafana) * [Prometheus + Grafana + Jaeger](https://github.com/netlifeguru/examples/router/observability/prometheus-grafana-jaeger) * [Full-stack observability](https://github.com/netlifeguru/examples/router/observability/full-stack) The observability examples may require: * Prometheus * Grafana * Jaeger * OpenTelemetry Collector * Additional Go packages from: * `go.opentelemetry.io/otel` * `github.com/prometheus/client_golang` # Project Information ## Documentation [#documentation] Official package documentation, guides, examples, and integration tutorials are available at: * [https://netlife.guru/docs/go/router](https://netlife.guru/docs/go/router) API reference is available on pkg.go.dev: * [https://pkg.go.dev/github.com/netlifeguru/router](https://pkg.go.dev/github.com/netlifeguru/router) Source code and issue tracking: * [https://github.com/netlifeguru/router](https://github.com/netlifeguru/router) *** ## Versioning [#versioning] This project follows Semantic Versioning. See [`CHANGELOG.md`](https://github.com/netlifeguru/router/blob/main/CHANGELOG.md) for release history, version updates, and breaking changes. *** ## Contributing [#contributing] Community contributions, discussions, bug reports, and pull requests are welcome. Please read [`CONTRIBUTING.md`](https://github.com/netlifeguru/router/blob/main/CONTRIBUTING.md) before submitting pull requests or opening issues. *** ## Code of Conduct [#code-of-conduct] This project follows the Contributor Covenant Code of Conduct. Please read [`CODE_OF_CONDUCT.md`](https://github.com/netlifeguru/router/blob/main/CODE_OF_CONDUCT.md) before participating in discussions or contributing to the project. *** ## Author [#author] Created and maintained by NetLife Guru s.r.o. Resources: * Documentation: [https://netlife.guru/docs](https://netlife.guru/docs) * GitHub: [https://github.com/netlifeguru](https://github.com/netlifeguru) * Contact: [info@netlife.guru](mailto:info@netlife.guru) *** ## License [#license] This project is licensed under the MIT License. See [`LICENSE`](https://github.com/netlifeguru/router/blob/main/LICENSE) for full license information. # GetDialect # GetDialect [#getdialect] Use `db.GetDialect` when you want to read zero or one row from a `db.DialectSQL` query. `GetDialect` selects the correct SQL or CQL text for the active driver and scans the returned row into a typed Go value. ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) if err != nil { return User{}, false, err } if !found { return User{}, false, nil } ``` ## Function [#function] ```go func GetDialect[T any](ctx context.Context, c db.DialectQuerier, q db.DialectSQL, args ...any) (T, bool, error) ``` `GetDialect` accepts: * a `context.Context` * a dialect-capable connection * a `db.DialectSQL` value * optional query arguments It returns: ```go T, found, error ``` `found` is `false` when the query returns no rows. ## When to Use GetDialect [#when-to-use-getdialect] Use `db.GetDialect` when: * the query should return one row or no rows * the application may run with different database drivers * SQL syntax differs between drivers * placeholder styles differ between drivers * queries are loaded from SQL model files * a missing row is a normal application case For direct query strings, use `db.Get`. For query objects, use `db.GetQuery`. ## Define a Model [#define-a-model] ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` ## Define Query Model [#define-query-model] A query model stores one logical query with driver-specific SQL. ```go type Queries struct { GetUser db.DialectSQL `json:"GetUser"` } ``` Application code should usually pass the whole `db.DialectSQL` value to `db.GetDialect`. ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` ## SQL File Example [#sql-file-example] For MySQL, `model.sql` may contain: ```sql --GetUser SELECT * FROM users WHERE id = ? LIMIT 1 ``` For PostgreSQL, `model.psql` may contain: ```sql --GetUser SELECT * FROM users WHERE id = $1 LIMIT 1 ``` For Scylla, `model.cql` may contain: ```sql --GetUser SELECT * FROM users_by_id WHERE id = ? LIMIT 1 ``` ## Load Queries [#load-queries] Load the query model with `db.LoadModel`. ```go func LoadQueries(conn db.Conn) (Queries, error) { var queries Queries if err := db.LoadModel(conn, ".", &queries); err != nil { return Queries{}, err } return queries, nil } ``` The active driver decides which model file is loaded: ```text model.sql -> MySQL model.psql -> PostgreSQL model.cql -> Scylla ``` ## Complete Query Example [#complete-query-example] This example loads the query model and uses `db.GetDialect`. ```go package main import ( "context" "time" "github.com/netlifeguru/db" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } type Queries struct { GetUser db.DialectSQL `json:"GetUser"` } func LoadQueries(conn db.Conn) (Queries, error) { var queries Queries if err := db.LoadModel(conn, ".", &queries); err != nil { return Queries{}, err } return queries, nil } func GetUserDialect(ctx context.Context, conn db.Conn, queries Queries, id int64) (User, bool, error) { return db.GetDialect[User](ctx, conn, queries.GetUser, id) } ``` ## Complete Usage Example [#complete-usage-example] This example connects to the database, loads SQL from the active driver model, and reads one user. ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } ctx := context.Background() queries, err := LoadQueries(conn) if err != nil { log.Fatal(err) } user, found, err := GetUserDialect(ctx, conn, queries, 1) if err != nil { log.Fatal(err) } if !found { log.Println("user not found") return } fmt.Printf( "ID: %d | Name: %s | Email: %s | Active: %t | Created: %s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } ``` ## Get Without `found` [#get-without-found] Use `db.GetPtrDialect` when you want to read zero or one row, but you do not want to handle a separate `found` return value. `db.GetDialect` returns three values: ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` `db.GetPtrDialect` returns only the result pointer and error: ```go user, err := db.GetPtrDialect[User](ctx, conn, queries.GetUser, id) ``` When the query returns no rows, `user` is `nil`. That is why the result type is `*User`. ```go func SelectUser(ctx context.Context, conn db.Conn, queries Queries, id int) (*User, error) { return db.GetPtrDialect[User](ctx, conn, queries.GetUser, id) } ``` The caller checks `nil` instead of checking `found`. ```go id := 1 user, err := SelectUser(ctx, conn, queries, id) if err != nil { log.Fatal(err) } if user == nil { log.Println("user not found") return } fmt.Printf("%d | %s | %s | active=%v | created_at=%s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) ``` ## Not Found [#not-found] `db.GetDialect` does not return an error when no row is found. Instead, it returns `found = false`. ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) if err != nil { return User{}, false, err } if !found { return User{}, false, nil } ``` This is useful for optional lookups. ## Too Many Rows [#too-many-rows] `db.GetDialect` expects zero or one row. If the selected query returns more than one row, it returns an error. Use a unique condition or `LIMIT 1` when only one row should be returned. ```sql SELECT * FROM users WHERE id = ? LIMIT 1 ``` Avoid `LIMIT 1` when duplicate rows should be detected by the application. ## Driver Selection [#driver-selection] `GetDialect` selects the query field based on the active driver. | Active driver | Selected query | | ------------- | -------------------------- | | MySQL | `queries.GetUser.Mysql` | | Postgres | `queries.GetUser.Postgres` | | Scylla | `queries.GetUser.Scylla` | If the selected query is empty, `GetDialect` returns an error. ## Arguments [#arguments] Arguments are passed to the selected query. ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` For MySQL, the selected query may use: ```sql WHERE id = ? ``` For PostgreSQL: ```sql WHERE id = $1 ``` For Scylla: ```sql WHERE id = ? ``` The Go call stays the same, but the SQL or CQL text must match the active driver. ## Low-Level Equivalent [#low-level-equivalent] `GetDialect` is a convenience helper. This: ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` is equivalent to: ```go q, err := db.Dialect(conn, queries.GetUser, id) if err != nil { return User{}, false, err } user, found, err := db.GetQuery[User](ctx, conn, q) if err != nil { return User{}, false, err } ``` Use the low-level form when you need access to the selected `db.Query`. ## Related Helpers [#related-helpers] Use `db.GetPtrDialect` when you want a pointer result and `nil` for a missing row instead of a separate `found` boolean. ```go user, err := db.GetPtrDialect[User](ctx, conn, queries.GetUser, id) ``` Use `db.ListDialect` when the dialect query can return multiple rows. ```go users, err := db.ListDialect[User](ctx, conn, queries.ListUsers, 10) ``` Use `db.ValueDialect` when the dialect query returns one scalar value. ```go total, found, err := db.ValueDialect[int64](ctx, conn, queries.CountUsers) ``` Use `db.MapsDialect` when the dialect query returns dynamic map rows. ```go rows, err := db.MapsDialect(ctx, conn, queries.EventsReport, 10) ``` ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL dialect get](https://github.com/netlifeguru/examples/db/mysql/16_dialect_get) * [PostgreSQL dialect get](https://github.com/netlifeguru/examples/db/postgresql/16_dialect_get) * [Scylla dialect get](https://github.com/netlifeguru/examples/db/scylla/16_dialect_get) # ListDialect Use `db.ListDialect` when you want to read multiple rows from a `db.DialectSQL` query. `ListDialect` selects the correct SQL or CQL text for the active driver and scans the returned rows into a typed Go slice. ```go users, err := db.ListDialect[User](ctx, conn, queries.ListUsers, 10) if err != nil { return nil, err } ``` ## Function [#function] ```go func ListDialect[T any](ctx context.Context, c db.DialectQuerier, q db.DialectSQL, args ...any) ([]T, error) ``` `ListDialect` accepts: * a `context.Context` * a dialect-capable connection * a `db.DialectSQL` value * optional query arguments It returns: ```go []T, error ``` ## When to Use ListDialect [#when-to-use-listdialect] Use `db.ListDialect` when: * the query can return zero or more rows * the result should be scanned into `[]T` * the application may run with different database drivers * SQL syntax differs between drivers * placeholder styles differ between drivers * queries are loaded from SQL model files For direct query strings, use `db.List`. For query objects, use `db.ListQuery`. ## Define a Model [#define-a-model] ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` ## Define Query Model [#define-query-model] A query model stores one logical query with driver-specific SQL. ```go type Queries struct { ListUsers db.DialectSQL `json:"ListUsers"` } ``` `ListUsers` can contain different SQL for each driver. ```go queries.ListUsers.Mysql queries.ListUsers.Postgres queries.ListUsers.Scylla ``` Application code usually should not select these fields manually. Pass the whole `db.DialectSQL` value to `db.ListDialect`. ## SQL File Example [#sql-file-example] For MySQL, `model.sql` may contain: ```sql --ListUsers SELECT * FROM users ORDER BY created_at DESC LIMIT ? ``` For PostgreSQL, `model.psql` may contain: ```sql --ListUsers SELECT * FROM users ORDER BY created_at DESC LIMIT $1 ``` For Scylla, `model.cql` may contain: ```sql --ListUsers SELECT * FROM users_by_status WHERE status = ? LIMIT ? ``` ## Load Queries [#load-queries] Load the query model with `db.LoadModel`. ```go func LoadQueries(conn db.Conn) (Queries, error) { var queries Queries if err := db.LoadModel(conn, ".", &queries); err != nil { return Queries{}, err } return queries, nil } ``` The active driver decides which model file is loaded: ```text model.sql -> MySQL model.psql -> PostgreSQL model.cql -> Scylla ``` ## Complete Query Example [#complete-query-example] This example loads the query model and uses `db.ListDialect`. ```go package main import ( "context" "time" "github.com/netlifeguru/db" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } type Queries struct { ListUsers db.DialectSQL `json:"ListUsers"` } func LoadQueries(conn db.Conn) (Queries, error) { var queries Queries if err := db.LoadModel(conn, ".", &queries); err != nil { return Queries{}, err } return queries, nil } func ListUsersDialect(ctx context.Context, conn db.Conn, queries Queries, limit int) ([]User, error) { return db.ListDialect[User](ctx, conn, queries.ListUsers, limit) } ``` ## Complete Usage Example [#complete-usage-example] This example connects to the database, loads SQL from the active driver model, and prints users. ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } ctx := context.Background() queries, err := LoadQueries(conn) if err != nil { log.Fatal(err) } users, err := ListUsersDialect(ctx, conn, queries, 10) if err != nil { log.Fatal(err) } for _, user := range users { fmt.Printf( "ID: %d | Name: %s | Email: %s | Active: %t | Created: %s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } } ``` ## Driver Selection [#driver-selection] `ListDialect` selects the SQL field based on the active driver. | Active driver | Selected query | | ------------- | ---------------------------- | | MySQL | `queries.ListUsers.Mysql` | | Postgres | `queries.ListUsers.Postgres` | | Scylla | `queries.ListUsers.Scylla` | If the selected query is empty, `ListDialect` returns an error. ## Arguments [#arguments] Arguments are passed to the selected query. ```go users, err := db.ListDialect[User](ctx, conn, queries.ListUsers, 10) ``` For MySQL, the selected query may use: ```sql LIMIT ? ``` For PostgreSQL: ```sql LIMIT $1 ``` For Scylla: ```sql WHERE status = ? LIMIT ? ``` The Go call stays the same, but the SQL/CQL text must match the active driver. ## Empty Results [#empty-results] If the query returns no rows, `db.ListDialect` returns an empty slice. ```go users, err := db.ListDialect[User](ctx, conn, queries.ListUsers, 10) if err != nil { return nil, err } fmt.Println(len(users)) // 0 ``` An empty result is not treated as an error. ## Low-Level Equivalent [#low-level-equivalent] `ListDialect` is a convenience helper. This: ```go users, err := db.ListDialect[User](ctx, conn, queries.ListUsers, 10) ``` is equivalent to: ```go q, err := db.Dialect(conn, queries.ListUsers, 10) if err != nil { return nil, err } users, err := db.ListQuery[User](ctx, conn, q) if err != nil { return nil, err } ``` Use the low-level form when you need access to the selected `db.Query`. ## Related Helpers [#related-helpers] Use `db.GetDialect` when the dialect query should return zero or one row. ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` Use `db.ValueDialect` when the dialect query returns one scalar value. ```go total, found, err := db.ValueDialect[int64](ctx, conn, queries.CountUsers) ``` Use `db.MapsDialect` when the dialect query returns dynamic map rows. ```go rows, err := db.MapsDialect(ctx, conn, queries.EventsReport, 10) ``` ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL dialect list](https://github.com/netlifeguru/examples/db/mysql/15_dialect_list) * [PostgreSQL dialect list](https://github.com/netlifeguru/examples/db/postgresql/15_dialect_list) * [Scylla dialect list](https://github.com/netlifeguru/examples/db/scylla/15_dialect_list) # MapsDialect # MapsDialect [#mapsdialect] Use `db.MapsDialect` when you want to read dynamic `map[string]any` rows from a `db.DialectSQL` query. `MapsDialect` selects the correct SQL or CQL text for the active driver and returns the result as a slice of maps. ```go rows, err := db.MapsDialect(ctx, conn, queries.EventsReport, 10) if err != nil { return nil, err } ``` ## Function [#function] ```go func MapsDialect(ctx context.Context, c db.DialectQuerier, q db.DialectSQL, args ...any) ([]map[string]any, error) ``` `MapsDialect` accepts: * a `context.Context` * a dialect-capable connection * a `db.DialectSQL` value * optional query arguments It returns: ```go []map[string]any, error ``` Each returned map represents one database row. ## When to Use MapsDialect [#when-to-use-mapsdialect] Use `db.MapsDialect` when: * the result shape is dynamic * the query may differ between drivers * queries are loaded from SQL model files * you are building reports, exports, admin tools, or generic views * you do not want to define a struct for the result * the application may run with MySQL, PostgreSQL, or Scylla For direct query strings, use `db.Maps`. For query objects, use `db.MapsQuery`. ## Define Query Model [#define-query-model] A query model stores one logical map-based query with driver-specific SQL. ```go type Queries struct { EventsReport db.DialectSQL `json:"EventsReport"` } ``` Application code should usually pass the whole `db.DialectSQL` value to `db.MapsDialect`. ```go events, err := db.MapsDialect(ctx, conn, queries.EventsReport, 10) ``` ## SQL File Example [#sql-file-example] For MySQL, `model.sql` may contain: ```sql --EventsReport SELECT * FROM events ORDER BY created_at DESC LIMIT ? ``` For PostgreSQL, `model.psql` may contain: ```sql --EventsReport SELECT * FROM events ORDER BY created_at DESC LIMIT $1 ``` For Scylla, `model.cql` may contain: ```sql --EventsReport SELECT * FROM events_by_type WHERE type = ? LIMIT ? ``` ## Load Queries [#load-queries] Load the query model with `db.LoadModel`. ```go func LoadQueries(conn db.Conn) (Queries, error) { var queries Queries if err := db.LoadModel(conn, ".", &queries); err != nil { return Queries{}, err } return queries, nil } ``` The active driver decides which model file is loaded: ```text model.sql -> MySQL model.psql -> PostgreSQL model.cql -> Scylla ``` ## Complete Query Example [#complete-query-example] This example loads the query model and uses `db.MapsDialect`. ```go package main import ( "context" "github.com/netlifeguru/db" ) type Queries struct { EventsReport db.DialectSQL `json:"EventsReport"` } func LoadQueries(conn db.Conn) (Queries, error) { var queries Queries if err := db.LoadModel(conn, ".", &queries); err != nil { return Queries{}, err } return queries, nil } func ListEventsDialect(ctx context.Context, conn db.Conn, queries Queries, limit int) ([]map[string]any, error) { return db.MapsDialect(ctx, conn, queries.EventsReport, limit) } ``` ## Complete Usage Example [#complete-usage-example] This example connects to the database, loads SQL from the active driver model, and prints dynamic map values. ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } ctx := context.Background() queries, err := LoadQueries(conn) if err != nil { log.Fatal(err) } events, err := ListEventsDialect(ctx, conn, queries, 10) if err != nil { log.Fatal(err) } for _, event := range events { fmt.Printf( "ID: %v | Type: %v | Payload: %v | Created: %v\n", event["id"], event["type"], event["payload"], event["created_at"], ) } } ``` ## Returned Row Shape [#returned-row-shape] Each row is represented as a `map[string]any`. ```go map[string]any{ "id": int64(1), "type": "user.created", "payload": `{"name":"John Doe"}`, "created_at": time.Now(), } ``` The exact Go value types depend on the active driver and returned column types. ## Driver Selection [#driver-selection] `MapsDialect` selects the query field based on the active driver. | Active driver | Selected query | | ------------- | ------------------------------- | | MySQL | `queries.EventsReport.Mysql` | | Postgres | `queries.EventsReport.Postgres` | | Scylla | `queries.EventsReport.Scylla` | If the selected query is empty, `MapsDialect` returns an error. ## Arguments [#arguments] Arguments are passed to the selected query. ```go events, err := db.MapsDialect(ctx, conn, queries.EventsReport, 10) ``` For MySQL, the selected query may use: ```sql LIMIT ? ``` For PostgreSQL: ```sql LIMIT $1 ``` For Scylla: ```sql WHERE type = ? LIMIT ? ``` The Go call stays the same, but the SQL or CQL text must match the active driver. ## SQL Aliases [#sql-aliases] Map keys are based on returned column names. Use aliases when selecting computed values or joining tables. ```sql SELECT u.id AS user_id, u.email AS user_email, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.email ``` The returned map contains the alias names: ```go map[string]any{ "user_id": int64(1), "user_email": "john@example.com", "order_count": int64(3), } ``` ## Typed Access [#typed-access] For typed access, convert a returned map to `mapper.Row`. ```go row := mapper.Row(events[0]) eventType, ok := row.String("type") if !ok { return errors.New("invalid event type") } ``` Or use standalone mapper converters: ```go eventType, ok := mapper.AsString(events[0]["type"]) if !ok { return errors.New("invalid event type") } ``` Use maps directly for generic output. Use typed access when values are required for business logic. ## Empty Results [#empty-results] If the selected query returns no rows, `db.MapsDialect` returns an empty slice. ```go events, err := db.MapsDialect(ctx, conn, queries.EventsReport, 10) if err != nil { return nil, err } fmt.Println(len(events)) // 0 ``` An empty result is not treated as an error. ## Low-Level Equivalent [#low-level-equivalent] `MapsDialect` is a convenience helper. This: ```go events, err := db.MapsDialect(ctx, conn, queries.EventsReport, 10) ``` is equivalent to: ```go q, err := db.Dialect(conn, queries.EventsReport, 10) if err != nil { return nil, err } events, err := db.MapsQuery(ctx, conn, q) if err != nil { return nil, err } ``` Use the low-level form when you need access to the selected `db.Query`. ## Related Helpers [#related-helpers] Use `db.ListDialect` when the dialect query returns multiple typed rows. ```go users, err := db.ListDialect[User](ctx, conn, queries.ListUsers, 10) ``` Use `db.GetDialect` when the dialect query returns zero or one typed row. ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` Use `db.ValueDialect` when the dialect query returns one scalar value. ```go total, found, err := db.ValueDialect[int64](ctx, conn, queries.CountUsers) ``` ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL dialect map](https://github.com/netlifeguru/examples/db/mysql/18_dialect_map) * [PostgreSQL dialect map](https://github.com/netlifeguru/examples/db/postgresql/19_dialect_map) * [Scylla dialect map](https://github.com/netlifeguru/examples/db/scylla/19_dialect_map) # Dialect SQL Overview Dialect SQL is used when the same application can run with different database drivers, but each driver needs its own SQL or CQL text. This is common when supporting multiple databases such as: * MySQL * Postgres * Scylla The Go application code can stay mostly the same, while the selected driver decides which query text should be executed. ## Basic Idea [#basic-idea] A `db.DialectSQL` value stores query text for each supported driver. ```go type DialectSQL struct { Postgres string `json:"postgres"` Mysql string `json:"mysql"` Scylla string `json:"scylla"` } ``` At runtime, the active connection selects the correct query. ```go q, err := db.Dialect(conn, queries.GetUser, id) if err != nil { return User{}, false, err } return db.GetQuery[User](ctx, conn, q) ``` Or use a direct dialect helper: ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` ## When to Use Dialect SQL [#when-to-use-dialect-sql] Use Dialect SQL when: * your application can run with different database drivers * SQL syntax differs between drivers * placeholder styles differ between drivers * inserts, limits, joins, or functions differ between engines * Scylla uses a different query table than SQL databases * queries are loaded from SQL model files * you want shared Go code with driver-specific query text For single-driver applications, direct helpers such as `db.List`, `db.Get`, `db.Value`, and `db.Maps` are usually simpler. ## Available Helpers [#available-helpers] Dialect helpers mirror the regular select helpers. | Helper | Result | Use case | | -------------------- | ------------------ | ---------------------------------------------------------- | | `db.Dialect` | `db.Query` | Select SQL for the active driver and return a query object | | `db.ListDialect[T]` | `[]T` | Read multiple typed rows from dialect SQL | | `db.GetDialect[T]` | `(T, bool, error)` | Read zero or one typed row from dialect SQL | | `db.ValueDialect[T]` | `(T, bool, error)` | Read one scalar value from dialect SQL | | `db.MapsDialect` | `[]map[string]any` | Read dynamic map rows from dialect SQL | ## Direct Dialect Helpers [#direct-dialect-helpers] Use direct dialect helpers when you want to select and execute the query in one step. ```go users, err := db.ListDialect[User](ctx, conn, queries.ListUsers, 10) ``` ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` ```go total, found, err := db.ValueDialect[int64](ctx, conn, queries.CountUsers) ``` ```go rows, err := db.MapsDialect(ctx, conn, queries.EventsReport, 10) ``` This is usually the most convenient style when the query comes from a loaded model. ## Low-Level Dialect Flow [#low-level-dialect-flow] Use the low-level flow when you want to inspect, pass, or modify the selected query object before execution. ```go q, err := db.Dialect(conn, queries.ListUsers, 10) if err != nil { return nil, err } return db.ListQuery[User](ctx, conn, q) ``` This is equivalent to using: ```go return db.ListDialect[User](ctx, conn, queries.ListUsers, 10) ``` ## Query Model Struct [#query-model-struct] A common pattern is to store queries in a struct. ```go type Queries struct { ListUsers db.DialectSQL `json:"ListUsers"` GetUser db.DialectSQL `json:"GetUser"` CountUsers db.DialectSQL `json:"CountUsers"` } ``` Each field contains query text for multiple drivers. ```go queries.GetUser.Mysql queries.GetUser.Postgres queries.GetUser.Scylla ``` Application code usually should not select those fields manually. Instead, pass the `db.DialectSQL` value to a dialect helper. ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` ## Loading Queries From SQL Files [#loading-queries-from-sql-files] Dialect SQL is commonly used with `db.LoadModel`. ```go func LoadQueries(conn db.Conn) (Queries, error) { var queries Queries if err := db.LoadModel(conn, ".", &queries); err != nil { return Queries{}, err } return queries, nil } ``` The selected driver decides which model file is loaded. ```text model.sql -> MySQL model.psql -> PostgreSQL model.cql -> Scylla ``` The SQL Files guide covers the model format and loading behavior in detail. ## Example SQL Sections [#example-sql-sections] A SQL model file is usually split into named sections. ```sql --ListUsers SELECT * FROM users ORDER BY created_at DESC LIMIT ? ``` ```sql --GetUser SELECT * FROM users WHERE id = ? LIMIT 1 ``` For PostgreSQL, the same logical queries use PostgreSQL syntax. ```sql --ListUsers SELECT * FROM users ORDER BY created_at DESC LIMIT $1 ``` ```sql --GetUser SELECT * FROM users WHERE id = $1 LIMIT 1 ``` For Scylla, the query may target a query table. ```sql --GetUser SELECT * FROM users_by_id WHERE id = ? LIMIT 1 ``` ## Driver Selection [#driver-selection] The active connection chooses the query text. | Active driver | Selected field | | ------------- | --------------------- | | MySQL | `DialectSQL.Mysql` | | Postgres | `DialectSQL.Postgres` | | Scylla | `DialectSQL.Scylla` | If the selected query is empty, dialect helpers return an error. This prevents accidentally executing an undefined query for the active driver. ## Placeholder Differences [#placeholder-differences] Dialect SQL does not rewrite placeholders. Each driver query must use the placeholder syntax required by that driver. | Driver | Placeholder style | | -------- | ----------------- | | MySQL | `?` | | Postgres | `$1`, `$2`, `$3` | | Scylla | `?` | Example: ```go db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` The Go call is the same, but the selected query text is different for each driver. ## Choosing the Right API [#choosing-the-right-api] | Need | Use | | ---------------------------------------- | -------------------------------------------------------------- | | Select SQL and execute list query | `db.ListDialect[T]` | | Select SQL and execute single-row query | `db.GetDialect[T]` | | Select SQL and execute scalar query | `db.ValueDialect[T]` | | Select SQL and execute dynamic map query | `db.MapsDialect` | | Select SQL and get a `db.Query` | `db.Dialect` | | Execute an already selected query object | `db.ListQuery`, `db.GetQuery`, `db.ValueQuery`, `db.MapsQuery` | ## Recommended Usage [#recommended-usage] For most dialect SQL reads, use the direct helpers. ```go users, err := db.ListDialect[User](ctx, conn, queries.ListUsers, 10) ``` ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` Use the low-level `db.Dialect` function when you want to create a `db.Query` first. ```go q, err := db.Dialect(conn, queries.GetUser, id) if err != nil { return User{}, false, err } return db.GetQuery[User](ctx, conn, q) ``` ## Next Step [#next-step] Continue with the dedicated Dialect SQL pages for complete examples of: * `ListDialect` * `GetDialect` * `ValueDialect` * `MapsDialect` # ValueDialect Use `db.ValueDialect` when you want to read one scalar value from a `db.DialectSQL` query. `ValueDialect` selects the correct SQL or CQL text for the active driver and reads one selected column from zero or one row. ```go total, found, err := db.ValueDialect[int64](ctx, conn, queries.CountUsers) if err != nil { return 0, false, err } if !found { return 0, false, nil } ``` ## Function [#function] ```go func ValueDialect[T any](ctx context.Context, c db.DialectQuerier, q db.DialectSQL, args ...any) (T, bool, error) ``` `ValueDialect` accepts: * a `context.Context` * a dialect-capable connection * a `db.DialectSQL` value * optional query arguments It returns: ```go T, found, error ``` `found` is `false` when the query returns no rows. ## When to Use ValueDialect [#when-to-use-valuedialect] Use `db.ValueDialect` when: * the query returns exactly one selected column * you need a scalar result * the application may run with different database drivers * SQL syntax differs between drivers * placeholder styles differ between drivers * queries are loaded from SQL model files * you are reading a count, ID, flag, aggregate, or simple lookup value For direct query strings, use `db.Value`. For query objects, use `db.ValueQuery`. ## Define Query Model [#define-query-model] A query model stores one logical scalar query with driver-specific SQL. ```go type Queries struct { CountUsers db.DialectSQL `json:"CountUsers"` } ``` Application code should usually pass the whole `db.DialectSQL` value to `db.ValueDialect`. ```go total, found, err := db.ValueDialect[int64](ctx, conn, queries.CountUsers) ``` ## SQL File Example [#sql-file-example] For MySQL, `model.sql` may contain: ```sql --CountUsers SELECT COUNT(*) FROM users ``` For PostgreSQL, `model.psql` may contain: ```sql --CountUsers SELECT COUNT(*) FROM users ``` For Scylla, `model.cql` may contain: ```sql --CountUsers SELECT COUNT(*) FROM users_by_status WHERE status = ? ``` ## PostgreSQL Returning Example [#postgresql-returning-example] `ValueDialect` can also be used for PostgreSQL `RETURNING` queries. For PostgreSQL, `model.psql` may contain: ```sql --InsertUser INSERT INTO users (name, email, active) VALUES ($1, $2, $3) RETURNING id ``` The matching query model can include: ```go type Queries struct { InsertUser db.DialectSQL `json:"InsertUser"` } ``` Then call: ```go id, found, err := db.ValueDialect[int64]( ctx, conn, queries.InsertUser, name, email, active, ) if err != nil { return 0, err } if !found { return 0, errors.New("insert did not return id") } return id, nil ``` For MySQL inserts, use `db.Insert` and read `result.LastInsertId()` instead. ## Load Queries [#load-queries] Load the query model with `db.LoadModel`. ```go func LoadQueries(conn db.Conn) (Queries, error) { var queries Queries if err := db.LoadModel(conn, ".", &queries); err != nil { return Queries{}, err } return queries, nil } ``` The active driver decides which model file is loaded: ```text model.sql -> MySQL model.psql -> PostgreSQL model.cql -> Scylla ``` ## Complete Query Example [#complete-query-example] This example loads the query model and uses `db.ValueDialect`. ```go package main import ( "context" "github.com/netlifeguru/db" ) type Queries struct { CountUsers db.DialectSQL `json:"CountUsers"` } func LoadQueries(conn db.Conn) (Queries, error) { var queries Queries if err := db.LoadModel(conn, ".", &queries); err != nil { return Queries{}, err } return queries, nil } func CountUsersDialect(ctx context.Context, conn db.Conn, queries Queries) (int64, bool, error) { return db.ValueDialect[int64](ctx, conn, queries.CountUsers) } ``` ## Complete Usage Example [#complete-usage-example] This example connects to the database, loads SQL from the active driver model, and prints a scalar value. ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } ctx := context.Background() queries, err := LoadQueries(conn) if err != nil { log.Fatal(err) } total, found, err := CountUsersDialect(ctx, conn, queries) if err != nil { log.Fatal(err) } if !found { log.Println("count not found") return } fmt.Printf("users: %d\n", total) } ``` ## Single Column Requirement [#single-column-requirement] `db.ValueDialect` expects the selected query to return exactly one column. Good: ```sql SELECT COUNT(*) FROM users ``` Good: ```sql SELECT active FROM users WHERE id = ? ``` Not suitable: ```sql SELECT id, email FROM users WHERE id = ? ``` For multiple columns, use `db.GetDialect` or `db.ListDialect`. ## Empty Result [#empty-result] If the selected query returns no rows, `db.ValueDialect` returns `found = false`. ```go value, found, err := db.ValueDialect[string](ctx, conn, queries.GetEmail, id) if err != nil { return "", false, err } if !found { return "", false, nil } ``` ## Too Many Rows [#too-many-rows] `db.ValueDialect` expects zero or one row. If the selected query returns more than one row, it returns an error. Use a unique condition, aggregate query, or `LIMIT 1` when appropriate. ## Driver Selection [#driver-selection] `ValueDialect` selects the query field based on the active driver. | Active driver | Selected query | | ------------- | ----------------------------- | | MySQL | `queries.CountUsers.Mysql` | | Postgres | `queries.CountUsers.Postgres` | | Scylla | `queries.CountUsers.Scylla` | If the selected query is empty, `ValueDialect` returns an error. ## Arguments [#arguments] Arguments are passed to the selected query. ```go total, found, err := db.ValueDialect[int64](ctx, conn, queries.CountUsers, status) ``` For MySQL, the selected query may use: ```sql WHERE status = ? ``` For PostgreSQL: ```sql WHERE status = $1 ``` For Scylla: ```sql WHERE status = ? ``` The Go call stays the same, but the SQL or CQL text must match the active driver. ## Low-Level Equivalent [#low-level-equivalent] `ValueDialect` is a convenience helper. This: ```go total, found, err := db.ValueDialect[int64](ctx, conn, queries.CountUsers) ``` is equivalent to: ```go q, err := db.Dialect(conn, queries.CountUsers) if err != nil { return 0, false, err } total, found, err := db.ValueQuery[int64](ctx, conn, q) if err != nil { return 0, false, err } ``` Use the low-level form when you need access to the selected `db.Query`. ## Related Helpers [#related-helpers] Use `db.ListDialect` when the dialect query can return multiple typed rows. ```go users, err := db.ListDialect[User](ctx, conn, queries.ListUsers, 10) ``` Use `db.GetDialect` when the dialect query returns zero or one typed row. ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` Use `db.MapsDialect` when the dialect query returns dynamic map rows. ```go rows, err := db.MapsDialect(ctx, conn, queries.EventsReport, 10) ``` ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL dialect value](https://github.com/netlifeguru/examples/db/mysql/19_dialect_value) * [PostgreSQL dialect value](https://github.com/netlifeguru/examples/db/postgresql/18_dialect_value) * [Scylla dialect value](https://github.com/netlifeguru/examples/db/scylla/18_dialect_value) # GetQuery Use `db.GetQuery` when you already have a `db.Query` value and expect zero or one row. It scans one row into a typed Go value and returns whether a row was found. ```go user, found, err := db.GetQuery[User](ctx, conn, q) if err != nil { return User{}, false, err } if !found { return User{}, false, nil } ``` `GetQuery` is the query-object version of `db.Get`. ## Function [#function] ```go func GetQuery[T any](ctx context.Context, c db.Querier, q db.Query) (T, bool, error) ``` `GetQuery` accepts: * a `context.Context` * a connection or query-capable value * a `db.Query` It returns: ```go T, found, error ``` `found` is `false` when the query returns no rows. ## When to Use GetQuery [#when-to-use-getquery] Use `db.GetQuery` when: * you already created a `db.Query` * the query should return one row or no rows * query creation and query execution are separate steps * the query was created by `db.Raw` or `db.Dialect` * a missing row is a normal application case * the result shape is known For direct query strings, use `db.Get`. ## Define a Model [#define-a-model] ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` ## Create a Query Object [#create-a-query-object] Create a `db.Query` using `db.Raw`. For MySQL and Scylla, use `?` placeholders. ```go const selectUserQuery = ` SELECT * FROM users WHERE id = ? LIMIT 1 ` ``` ```go q, err := db.Raw(selectUserQuery, id) if err != nil { return User{}, false, err } ``` For PostgreSQL, the query uses numbered placeholders. ```go const selectUserQuery = ` SELECT * FROM users WHERE id = $1 LIMIT 1 ` ``` The Go code stays the same: ```go q, err := db.Raw(selectUserQuery, id) if err != nil { return User{}, false, err } ``` ## Execute the Query Object [#execute-the-query-object] Pass the query object into `db.GetQuery`. ```go user, found, err := db.GetQuery[User](ctx, conn, q) if err != nil { return User{}, false, err } if !found { return User{}, false, nil } return user, true, nil ``` ## Get Query Without `found` [#get-query-without-found] Use `db.GetPtrQuery` when you already have a `db.Query`, but you do not want to handle a separate `found` return value. `db.GetQuery` returns the value, `found`, and error: ```go user, found, err := db.GetQuery[User](ctx, conn, q) ``` `db.GetPtrQuery` returns the result pointer and error: ```go user, err := db.GetPtrQuery[User](ctx, conn, q) ``` When the query returns no rows, `user` is `nil`. That is why the helper usually returns `*User`. ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } const selectUserQuery = ` SELECT * FROM users WHERE id = ? LIMIT 1 ` func GetUser(ctx context.Context, conn db.Conn, id int) (*User, error) { q, err := db.Raw(selectUserQuery, id) if err != nil { return nil, err } return db.GetPtrQuery[User](ctx, conn, q) } ``` The caller checks `nil` instead of checking `found`. ```go id := 1 user, err := GetUser(ctx, conn, id) if err != nil { log.Fatal(err) } if user == nil { log.Println("user not found") return } fmt.Printf("%d | %s | %s | active=%v | created_at=%s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) ``` ## Complete Query Example [#complete-query-example] This example creates a `db.Query` and reads one user by ID. ```go package main import ( "context" "time" "github.com/netlifeguru/db" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } const selectUserQuery = ` SELECT * FROM users WHERE id = ? LIMIT 1 ` func GetUserQuery(ctx context.Context, conn db.Conn, id int64) (User, bool, error) { q, err := db.Raw(selectUserQuery, id) if err != nil { return User{}, false, err } return db.GetQuery[User](ctx, conn, q) } ``` ## Complete Usage Example [#complete-usage-example] This example connects to the database, executes the query-object helper, and handles the not-found case. ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } ctx := context.Background() user, found, err := GetUserQuery(ctx, conn, 1) if err != nil { log.Fatal(err) } if !found { log.Println("user not found") return } fmt.Printf( "ID: %d | Name: %s | Email: %s | Active: %t | Created: %s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } ``` ## Not Found [#not-found] `db.GetQuery` does not return an error when no row is found. Instead, it returns `found = false`. ```go user, found, err := db.GetQuery[User](ctx, conn, q) if err != nil { return User{}, false, err } if !found { return User{}, false, nil } ``` This is useful for optional lookups. ## Too Many Rows [#too-many-rows] `db.GetQuery` expects zero or one row. If the query returns more than one row, it returns an error. Use a unique condition or `LIMIT 1` when only one row should be returned. ```sql SELECT * FROM users WHERE id = ? LIMIT 1 ``` Avoid `LIMIT 1` when duplicate rows should be detected by the application. ## Raw Query Validation [#raw-query-validation] `db.Raw` returns an error when the query string is empty. ```go q, err := db.Raw("", id) if err != nil { return User{}, false, err } ``` This prevents empty query objects from being executed accidentally. ## Query Objects From Dialect SQL [#query-objects-from-dialect-sql] `GetQuery` is often used together with `db.Dialect`. ```go q, err := db.Dialect(conn, queries.GetUser, id) if err != nil { return User{}, false, err } return db.GetQuery[User](ctx, conn, q) ``` For direct dialect usage, you can also use `db.GetDialect`. ```go user, found, err := db.GetDialect[User](ctx, conn, queries.GetUser, id) ``` ## Related Helpers [#related-helpers] Use `db.Get` when you want to pass the query string directly. ```go user, found, err := db.Get[User](ctx, conn, selectUserQuery, id) ``` Use `db.GetPtrQuery` when you want `nil` instead of a separate `found` boolean. ```go user, err := db.GetPtrQuery[User](ctx, conn, q) ``` Use `db.ListQuery` when the query object can return multiple rows. ```go users, err := db.ListQuery[User](ctx, conn, q) ``` Use `db.ValueQuery` when the query object returns one scalar value. ```go total, found, err := db.ValueQuery[int64](ctx, conn, q) ``` Use `db.MapsQuery` when the query object returns dynamic map rows. ```go rows, err := db.MapsQuery(ctx, conn, q) ``` ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL query get](https://github.com/netlifeguru/examples/db/mysql/11_query-get) * [PostgreSQL query get](https://github.com/netlifeguru/examples/db/postgresql/11_query-get) * [Scylla query get](https://github.com/netlifeguru/examples/db/scylla/11_query-get) # ListQuery Use `db.ListQuery` when you already have a `db.Query` value and expect multiple rows. It scans all returned rows into a typed Go slice. ```go users, err := db.ListQuery[User](ctx, conn, q) if err != nil { return nil, err } ``` `ListQuery` is the query-object version of `db.List`. ## Function [#function] ```go func ListQuery[T any](ctx context.Context, c db.Querier, q db.Query) ([]T, error) ``` `ListQuery` accepts: * a `context.Context` * a connection or query-capable value * a `db.Query` It returns: ```go []T, error ``` ## When to Use ListQuery [#when-to-use-listquery] Use `db.ListQuery` when: * you already created a `db.Query` * the query can return zero or more rows * the result shape is known * you want a typed `[]T` * query creation and query execution are separate steps * the query was created by `db.Raw` or `db.Dialect` For direct query strings, use `db.List`. ## Define a Model [#define-a-model] ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` ## Create a Query Object [#create-a-query-object] Create a `db.Query` using `db.Raw`. For MySQL and Scylla, use `?` placeholders. ```go const selectUsersQuery = ` SELECT * FROM users ORDER BY created_at DESC LIMIT ? ` ``` ```go q, err := db.Raw(selectUsersQuery, 10) if err != nil { return nil, err } ``` For PostgreSQL, the query uses numbered placeholders. ```go const selectUsersQuery = ` SELECT * FROM users ORDER BY created_at DESC LIMIT $1 ` ``` The Go code stays the same: ```go q, err := db.Raw(selectUsersQuery, 10) if err != nil { return nil, err } ``` ## Execute the Query Object [#execute-the-query-object] Pass the query object into `db.ListQuery`. ```go users, err := db.ListQuery[User](ctx, conn, q) if err != nil { return nil, err } ``` ## Complete Query Example [#complete-query-example] This example creates a `db.Query` and reads users as a typed slice. ```go package main import ( "context" "time" "github.com/netlifeguru/db" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } const selectUsersQuery = ` SELECT * FROM users ORDER BY created_at DESC LIMIT ? ` func ListUsersQuery(ctx context.Context, conn db.Conn, limit int) ([]User, error) { q, err := db.Raw(selectUsersQuery, limit) if err != nil { return nil, err } return db.ListQuery[User](ctx, conn, q) } ``` ## Complete Usage Example [#complete-usage-example] This example connects to the database, executes the query-object helper, and prints the result. ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } ctx := context.Background() users, err := ListUsersQuery(ctx, conn, 10) if err != nil { log.Fatal(err) } for _, user := range users { fmt.Printf( "ID: %d | Name: %s | Email: %s | Active: %t | Created: %s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } } ``` ## Empty Results [#empty-results] If the query returns no rows, `db.ListQuery` returns an empty slice. ```go users, err := db.ListQuery[User](ctx, conn, q) if err != nil { return nil, err } fmt.Println(len(users)) // 0 ``` An empty result is not treated as an error. ## Raw Query Validation [#raw-query-validation] `db.Raw` returns an error when the query string is empty. ```go q, err := db.Raw("", 10) if err != nil { return nil, err } ``` This prevents empty query objects from being executed accidentally. ## Query Objects From Dialect SQL [#query-objects-from-dialect-sql] `ListQuery` is often used together with `db.Dialect`. ```go q, err := db.Dialect(conn, queries.ListUsers, 10) if err != nil { return nil, err } return db.ListQuery[User](ctx, conn, q) ``` For direct dialect usage, you can also use `db.ListDialect`. ```go users, err := db.ListDialect[User](ctx, conn, queries.ListUsers, 10) ``` ## Related Helpers [#related-helpers] Use `db.List` when you want to pass the query string directly. ```go users, err := db.List[User](ctx, conn, selectUsersQuery, 10) ``` Use `db.GetQuery` when the query object should return zero or one row. ```go user, found, err := db.GetQuery[User](ctx, conn, q) ``` Use `db.ValueQuery` when the query object returns one scalar value. ```go total, found, err := db.ValueQuery[int64](ctx, conn, q) ``` Use `db.MapsQuery` when the query object returns dynamic map rows. ```go rows, err := db.MapsQuery(ctx, conn, q) ``` ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL query list](https://github.com/netlifeguru/examples/db/mysql/10_query_list) * [PostgreSQL query list](https://github.com/netlifeguru/examples/db/postgresql/10_query_list) * [Scylla query list](https://github.com/netlifeguru/examples/db/scylla/10_query_list) # MapsQuery Use `db.MapsQuery` when you already have a `db.Query` value and want the result as dynamic `map[string]any` rows. It is the query-object version of `db.Maps`. ```go events, err := db.MapsQuery(ctx, conn, q) if err != nil { return nil, err } ``` ## Function [#function] ```go func MapsQuery(ctx context.Context, c db.Querier, q db.Query) ([]map[string]any, error) ``` `MapsQuery` accepts: * a `context.Context` * a connection or query-capable value * a `db.Query` It returns: ```go []map[string]any, error ``` Each map represents one returned row. ## When to Use MapsQuery [#when-to-use-mapsquery] Use `db.MapsQuery` when: * you already created a `db.Query` * the result shape is dynamic * query creation and execution are separate steps * the query was created by `db.Raw` or `db.Dialect` * you are building reports, exports, admin tools, or generic views * you do not want to define a struct for the result For direct query strings, use `db.Maps`. ## Create a Query Object [#create-a-query-object] Create a `db.Query` using `db.Raw`. For MySQL and Scylla, use `?` placeholders. ```go const selectEventsQuery = ` SELECT * FROM events ORDER BY created_at DESC LIMIT ? ` ``` ```go q, err := db.Raw(selectEventsQuery, 10) if err != nil { return nil, err } ``` For PostgreSQL, the query uses numbered placeholders. ```go const selectEventsQuery = ` SELECT * FROM events ORDER BY created_at DESC LIMIT $1 ` ``` The Go code stays the same: ```go q, err := db.Raw(selectEventsQuery, 10) if err != nil { return nil, err } ``` ## Execute the Query Object [#execute-the-query-object] Pass the query object into `db.MapsQuery`. ```go events, err := db.MapsQuery(ctx, conn, q) if err != nil { return nil, err } return events, nil ``` ## Complete Query Example [#complete-query-example] This example creates a `db.Query` and reads event rows as dynamic maps. ```go package main import ( "context" "github.com/netlifeguru/db" ) const selectEventsQuery = ` SELECT * FROM events ORDER BY created_at DESC LIMIT ? ` func ListEventsQuery(ctx context.Context, conn db.Conn, limit int) ([]map[string]any, error) { q, err := db.Raw(selectEventsQuery, limit) if err != nil { return nil, err } return db.MapsQuery(ctx, conn, q) } ``` ## Complete Usage Example [#complete-usage-example] This example connects to the database, executes the query-object helper, and prints map values. ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } ctx := context.Background() events, err := ListEventsQuery(ctx, conn, 10) if err != nil { log.Fatal(err) } for _, event := range events { fmt.Printf( "ID: %v | Type: %v | Payload: %v | Created: %v\n", event["id"], event["type"], event["payload"], event["created_at"], ) } } ``` ## Returned Row Shape [#returned-row-shape] Each returned row is represented as `map[string]any`. ```go map[string]any{ "id": int64(1), "type": "user.created", "payload": `{"name":"John Doe"}`, "created_at": time.Now(), } ``` The exact Go value types depend on the database driver and returned column types. ## SQL Aliases [#sql-aliases] Map keys are based on returned column names. Use aliases when selecting computed values or joining tables. ```sql SELECT u.id AS user_id, u.email AS user_email, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.email ``` The resulting map will contain the alias names: ```go map[string]any{ "user_id": int64(1), "user_email": "john@example.com", "order_count": int64(3), } ``` ## Typed Access [#typed-access] For typed access, convert a returned map to `mapper.Row`. ```go row := mapper.Row(events[0]) eventType, ok := row.String("type") if !ok { return errors.New("invalid event type") } ``` Or use standalone mapper converters: ```go eventType, ok := mapper.AsString(events[0]["type"]) if !ok { return errors.New("invalid event type") } ``` Use maps directly for generic output. Use typed access when values are required for business logic. ## Empty Results [#empty-results] If the query returns no rows, `db.MapsQuery` returns an empty slice. ```go events, err := db.MapsQuery(ctx, conn, q) if err != nil { return nil, err } fmt.Println(len(events)) // 0 ``` An empty result is not treated as an error. ## Raw Query Validation [#raw-query-validation] `db.Raw` returns an error when the query string is empty. ```go q, err := db.Raw("") if err != nil { return nil, err } ``` This prevents empty query objects from being executed accidentally. ## Query Objects From Dialect SQL [#query-objects-from-dialect-sql] `MapsQuery` is often used together with `db.Dialect`. ```go q, err := db.Dialect(conn, queries.EventsReport, 10) if err != nil { return nil, err } return db.MapsQuery(ctx, conn, q) ``` For direct dialect usage, you can also use `db.MapsDialect`. ```go events, err := db.MapsDialect(ctx, conn, queries.EventsReport, 10) ``` ## Related Helpers [#related-helpers] Use `db.Maps` when you want to pass the query string directly. ```go events, err := db.Maps(ctx, conn, selectEventsQuery, 10) ``` Use `db.ListQuery` when the query object returns multiple typed rows. ```go users, err := db.ListQuery[User](ctx, conn, q) ``` Use `db.GetQuery` when the query object returns zero or one typed row. ```go user, found, err := db.GetQuery[User](ctx, conn, q) ``` Use `db.ValueQuery` when the query object returns one scalar value. ```go total, found, err := db.ValueQuery[int64](ctx, conn, q) ``` ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL query map](https://github.com/netlifeguru/examples/db/mysql/14_query_map) * [PostgreSQL query map](https://github.com/netlifeguru/examples/db/postgresql/14_query_map) * [Scylla query map](https://github.com/netlifeguru/examples/db/scylla/14_query_map) # Query Objects Overview Query object helpers are the lower-level version of the select helpers. Instead of passing a query string and arguments directly into `db.List`, `db.Get`, `db.Value`, or `db.Maps`, you first create a `db.Query` value and then execute it. This is useful when you want to prepare a query once and pass it between functions. ## Available Helpers [#available-helpers] | Helper | Result | Use case | | ------------------ | ------------------ | ------------------------------------------------ | | `db.Raw` | `db.Query` | Create a query object from SQL/CQL and arguments | | `db.ListQuery[T]` | `[]T` | Read multiple rows from a `db.Query` | | `db.GetQuery[T]` | `(T, bool, error)` | Read zero or one row from a `db.Query` | | `db.ValueQuery[T]` | `(T, bool, error)` | Read one scalar value from a `db.Query` | | `db.MapsQuery` | `[]map[string]any` | Read map rows from a `db.Query` | ## Basic Flow [#basic-flow] Create a query object with `db.Raw`. ```go q, err := db.Raw(` SELECT * FROM users WHERE active = ? ORDER BY created_at DESC `, true) if err != nil { return nil, err } ``` Then pass the query object to one of the query helpers. ```go users, err := db.ListQuery[User](ctx, conn, q) if err != nil { return nil, err } ``` This is equivalent to: ```go users, err := db.List[User](ctx, conn, ` SELECT * FROM users WHERE active = ? ORDER BY created_at DESC `, true) ``` ## Why Use Query Objects? [#why-use-query-objects] Use query objects when the query is built or selected before execution. Common use cases include: * passing a query between application layers * building small query helper functions * selecting SQL from another source before execution * using `db.Dialect` before calling `ListQuery`, `GetQuery`, `ValueQuery`, or `MapsQuery` * keeping repository functions focused on execution * reusing the same query object in tests ## Query Object Shape [#query-object-shape] A `db.Query` contains the SQL or CQL string and its arguments. ```go type Query struct { SQL string Args []any Type int } ``` Most application code creates it through `db.Raw`. ```go q, err := db.Raw(query, args...) ``` `db.Raw` validates that the query string is not empty. If the query string is empty, it returns an error. ## ListQuery [#listquery] Use `db.ListQuery` when the query object should return multiple typed rows. ```go users, err := db.ListQuery[User](ctx, conn, q) if err != nil { return nil, err } ``` This is the query-object equivalent of `db.List`. ## GetQuery [#getquery] Use `db.GetQuery` when the query object should return zero or one typed row. ```go user, found, err := db.GetQuery[User](ctx, conn, q) if err != nil { return User{}, false, err } if !found { return User{}, false, nil } ``` This is the query-object equivalent of `db.Get`. ## ValueQuery [#valuequery] Use `db.ValueQuery` when the query object should return one scalar value. ```go total, found, err := db.ValueQuery[int64](ctx, conn, q) if err != nil { return 0, false, err } ``` This is the query-object equivalent of `db.Value`. ## MapsQuery [#mapsquery] Use `db.MapsQuery` when the query object should return dynamic map rows. ```go rows, err := db.MapsQuery(ctx, conn, q) if err != nil { return nil, err } ``` This is the query-object equivalent of `db.Maps`. ## Placeholder Differences [#placeholder-differences] Query objects do not change SQL placeholder syntax. The query string must still match the selected driver. | Driver | Placeholder style | | -------- | ----------------- | | MySQL | `?` | | Postgres | `$1`, `$2`, `$3` | | Scylla | `?` | Example MySQL query object: ```go q, err := db.Raw(` SELECT * FROM users WHERE active = ? `, true) ``` Example PostgreSQL query object: ```go q, err := db.Raw(` SELECT * FROM users WHERE active = $1 `, true) ``` ## Query Objects and Dialect SQL [#query-objects-and-dialect-sql] Query objects are also useful with dialect SQL. `db.Dialect` selects the correct SQL or CQL string from a `db.DialectSQL` value and returns a `db.Query`. ```go q, err := db.Dialect(conn, queries.GetUser, id) if err != nil { return User{}, false, err } return db.GetQuery[User](ctx, conn, q) ``` This is useful when the query comes from SQL model files and your application may run on different drivers. ## Choosing the Right Helper [#choosing-the-right-helper] | Need | Use | | ----------------------------------------- | ------------------ | | Create a query object | `db.Raw` | | Multiple typed rows from a query object | `db.ListQuery[T]` | | Zero or one typed row from a query object | `db.GetQuery[T]` | | One scalar value from a query object | `db.ValueQuery[T]` | | Dynamic map rows from a query object | `db.MapsQuery` | ## Select Helpers vs Query Objects [#select-helpers-vs-query-objects] Use select helpers for direct application code. ```go users, err := db.List[User](ctx, conn, query, args...) ``` Use query objects when you want a separate query creation step. ```go q, err := db.Raw(query, args...) if err != nil { return nil, err } users, err := db.ListQuery[User](ctx, conn, q) ``` ## Next Step [#next-step] Continue with the dedicated query-object pages for complete examples of: * `ListQuery` * `GetQuery` * `ValueQuery` * `MapsQuery` # ValueQuery Use `db.ValueQuery` when you already have a `db.Query` value and expect one scalar value. It reads one selected column from zero or one row. ```go total, found, err := db.ValueQuery[int64](ctx, conn, q) if err != nil { return 0, false, err } if !found { return 0, false, nil } ``` `ValueQuery` is the query-object version of `db.Value`. ## Function [#function] ```go func ValueQuery[T any](ctx context.Context, c db.Querier, q db.Query) (T, bool, error) ``` `ValueQuery` accepts: * a `context.Context` * a connection or query-capable value * a `db.Query` It returns: ```go T, found, error ``` `found` is `false` when the query returns no rows. ## When to Use ValueQuery [#when-to-use-valuequery] Use `db.ValueQuery` when: * you already created a `db.Query` * the query returns exactly one selected column * you need a scalar value * query creation and query execution are separate steps * the query was created by `db.Raw` or `db.Dialect` * you are reading a count, ID, flag, aggregate, or simple lookup value For direct query strings, use `db.Value`. ## Create a Query Object [#create-a-query-object] Create a `db.Query` using `db.Raw`. For MySQL and Scylla, use `?` placeholders. ```go const countUsersQuery = ` SELECT COUNT(*) FROM users WHERE active = ? ` ``` ```go q, err := db.Raw(countUsersQuery, true) if err != nil { return 0, false, err } ``` For PostgreSQL, the query uses numbered placeholders. ```go const countUsersQuery = ` SELECT COUNT(*) FROM users WHERE active = $1 ` ``` The Go code stays the same: ```go q, err := db.Raw(countUsersQuery, true) if err != nil { return 0, false, err } ``` ## Execute the Query Object [#execute-the-query-object] Pass the query object into `db.ValueQuery`. ```go total, found, err := db.ValueQuery[int64](ctx, conn, q) if err != nil { return 0, false, err } if !found { return 0, false, nil } return total, true, nil ``` ## Complete Query Example [#complete-query-example] This example creates a `db.Query` and reads the number of active users. ```go package main import ( "context" "github.com/netlifeguru/db" ) const countActiveUsersQuery = ` SELECT COUNT(*) FROM users WHERE active = ? ` func CountActiveUsersQuery(ctx context.Context, conn db.Conn) (int64, bool, error) { q, err := db.Raw(countActiveUsersQuery, true) if err != nil { return 0, false, err } return db.ValueQuery[int64](ctx, conn, q) } ``` ## Complete Usage Example [#complete-usage-example] This example connects to the database, executes the query-object helper, and prints the result. ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } ctx := context.Background() total, found, err := CountActiveUsersQuery(ctx, conn) if err != nil { log.Fatal(err) } if !found { log.Println("count not found") return } fmt.Printf("active users: %d\n", total) } ``` ## PostgreSQL Returning Example [#postgresql-returning-example] `ValueQuery` can also be used with PostgreSQL `RETURNING`. ```go const insertUserQuery = ` INSERT INTO users (name, email, active) VALUES ($1, $2, $3) RETURNING id ` func InsertUserQuery(ctx context.Context, conn db.Conn, name string, email string, active bool) (int64, error) { q, err := db.Raw(insertUserQuery, name, email, active) if err != nil { return 0, err } id, found, err := db.ValueQuery[int64](ctx, conn, q) if err != nil { return 0, err } if !found { return 0, errors.New("insert did not return id") } return id, nil } ``` ## Single Column Requirement [#single-column-requirement] `db.ValueQuery` expects the query result to contain exactly one selected column. Good: ```sql SELECT COUNT(*) FROM users ``` Good: ```sql SELECT email FROM users WHERE id = ? ``` Not suitable: ```sql SELECT id, email FROM users WHERE id = ? ``` For multiple columns, use `db.GetQuery` or `db.ListQuery`. ## Empty Result [#empty-result] If the query returns no rows, `db.ValueQuery` returns `found = false`. ```go value, found, err := db.ValueQuery[string](ctx, conn, q) if err != nil { return "", false, err } if !found { return "", false, nil } ``` ## Too Many Rows [#too-many-rows] `db.ValueQuery` expects zero or one row. If the query returns more than one row, it returns an error. Use a unique condition, aggregate query, or `LIMIT 1` when appropriate. ## Raw Query Validation [#raw-query-validation] `db.Raw` returns an error when the query string is empty. ```go q, err := db.Raw("") if err != nil { return 0, false, err } ``` This prevents empty query objects from being executed accidentally. ## Query Objects From Dialect SQL [#query-objects-from-dialect-sql] `ValueQuery` is often used together with `db.Dialect`. ```go q, err := db.Dialect(conn, queries.CountUsers) if err != nil { return 0, false, err } return db.ValueQuery[int64](ctx, conn, q) ``` For direct dialect usage, you can also use `db.ValueDialect`. ```go total, found, err := db.ValueDialect[int64](ctx, conn, queries.CountUsers) ``` ## Related Helpers [#related-helpers] Use `db.Value` when you want to pass the query string directly. ```go total, found, err := db.Value[int64](ctx, conn, countUsersQuery, true) ``` Use `db.ListQuery` when the query object can return multiple typed rows. ```go users, err := db.ListQuery[User](ctx, conn, q) ``` Use `db.GetQuery` when the query object returns zero or one typed row. ```go user, found, err := db.GetQuery[User](ctx, conn, q) ``` Use `db.MapsQuery` when the query object returns dynamic map rows. ```go rows, err := db.MapsQuery(ctx, conn, q) ``` ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL query value](https://github.com/netlifeguru/examples/db/mysql/13_query_value) * [PostgreSQL query value](https://github.com/netlifeguru/examples/db/postgresql/13_query_value) * [Scylla query value](https://github.com/netlifeguru/examples/db/scylla/13_query_value) # Get # Get [#get] Use `db.Get` when a query is expected to return zero or one row. It scans a single row into a typed Go value and also tells you whether a row was found. ```go user, found, err := db.Get[User](ctx, conn, selectUserQuery, id) if err != nil { return User{}, false, err } if !found { return User{}, false, nil } ``` ## Function [#function] ```go func Get[T any](ctx context.Context, c db.Conn, query string, args ...any) (T, bool, error) ``` `Get` accepts: * a `context.Context` * a `db.Conn` * a SQL or CQL query string * optional query arguments It returns: ```go T, found, error ``` `found` is `false` when the query returns no rows. ## When to Use Get [#when-to-use-get] Use `db.Get` when: * the query should return one row or no rows * a missing row is a normal application case * the result shape is known * you want a typed Go value * you are loading by ID, email, slug, token, or another lookup key ## Define a Model [#define-a-model] ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` ## Query [#query] For MySQL and Scylla, use `?` placeholders. ```go const selectUserQuery = ` SELECT * FROM users WHERE id = ? LIMIT 1 ` ``` For PostgreSQL, use numbered placeholders. ```go const selectUserQuery = ` SELECT * FROM users WHERE id = $1 LIMIT 1 ` ``` ## Complete Query Example [#complete-query-example] This example returns one user by ID. ```go package main import ( "context" "time" "github.com/netlifeguru/db" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } const selectUserQuery = ` SELECT * FROM users WHERE id = ? LIMIT 1 ` func GetUser(ctx context.Context, conn db.Conn, id int64) (User, bool, error) { return db.Get[User](ctx, conn, selectUserQuery, id) } ``` ## Get Without `found` [#get-without-found] Use `db.GetPtr` when you want to read zero or one row, but you do not want to handle a separate `found` return value. `db.Get` returns the value, `found`, and error: ```go user, found, err := db.Get[User](ctx, conn, selectUserQuery, id) ``` `db.GetPtr` returns the result pointer and error: ```go user, err := db.GetPtr[User](ctx, conn, selectUserQuery, id) ``` When the query returns no rows, `user` is `nil`. That is why the helper usually returns `*User`. ```go type User struct { ID int `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } const selectUserQuery = ` SELECT * FROM users WHERE id = ? LIMIT 1 ` func SelectUser(ctx context.Context, conn db.Conn, id int) (*User, error) { return db.GetPtr[User](ctx, conn, selectUserQuery, id) } ``` The caller checks `nil` instead of checking `found`. ```go id := 1 user, err := SelectUser(ctx, conn, id) if err != nil { log.Fatal(err) } if user == nil { log.Println("user not found") return } fmt.Printf("%d | %s | %s | active=%v | created_at=%s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) ``` ## Complete Usage Example [#complete-usage-example] This example connects to the database, loads one user, and handles the not-found case. ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } ctx := context.Background() user, found, err := GetUser(ctx, conn, 1) if err != nil { log.Fatal(err) } if !found { log.Println("user not found") return } fmt.Printf( "ID: %d | Name: %s | Email: %s | Active: %t | Created: %s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } ``` ## Not Found [#not-found] `db.Get` does not return an error when no row is found. Instead, it returns `found = false`. ```go user, found, err := db.Get[User](ctx, conn, selectUserQuery, id) if err != nil { return User{}, false, err } if !found { return User{}, false, nil } ``` This makes `db.Get` convenient for optional lookups. ## Too Many Rows [#too-many-rows] `db.Get` expects zero or one row. If the query returns more than one row, it returns an error. Use a unique condition or `LIMIT 1` when only one row should be returned. ```sql SELECT * FROM users WHERE id = ? LIMIT 1 ``` Avoid `LIMIT 1` when duplicate rows should be detected by the application. ## Driver Placeholder Differences [#driver-placeholder-differences] The Go call stays the same across drivers. ```go user, found, err := db.Get[User](ctx, conn, selectUserQuery, id) ``` Only the query placeholder syntax changes. | Driver | Placeholder | | -------- | ----------- | | MySQL | `?` | | Postgres | `$1` | | Scylla | `?` | ## Scylla Example [#scylla-example] For Scylla, use a query table designed for the lookup. ```go const selectUserByEmailQuery = ` SELECT * FROM users_by_email WHERE email = ? LIMIT 1 ` func GetUserByEmail(ctx context.Context, conn db.Conn, email string) (User, bool, error) { return db.Get[User](ctx, conn, selectUserByEmailQuery, email) } ``` ## Related Helpers [#related-helpers] Use `db.Get` when you want a separate `found` boolean. ```go user, found, err := db.Get[User](ctx, conn, selectUserQuery, id) ``` Use `db.GetPtr` when you want `nil` instead of a separate `found` boolean. ```go user, err := db.GetPtr[User](ctx, conn, selectUserQuery, id) ``` Use `db.List` when the query can return multiple rows. ```go users, err := db.List[User](ctx, conn, selectUsersQuery, 10) ``` Use `db.Value` when the query returns a single scalar value. ```go total, found, err := db.Value[int64](ctx, conn, countUsersQuery) ``` Use `db.Maps` when the result shape is dynamic. ```go rows, err := db.Maps(ctx, conn, selectRowsQuery) ``` ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL select get](https://github.com/netlifeguru/examples/db/mysql/06_select_get) * [PostgreSQL select get](https://github.com/netlifeguru/examples/db/postgresql/06_select_get) * [Scylla select get](https://github.com/netlifeguru/examples/db/scylla/06_select_get) # List Use `db.List` when a query is expected to return multiple rows. It scans all returned rows into a typed Go slice. ```go users, err := db.List[User](ctx, conn, selectUsersQuery, 10) if err != nil { return nil, err } ``` `db.List` is the most common helper for list endpoints and collection-style reads. ## Function [#function] ```go func List[T any](ctx context.Context, c db.Conn, query string, args ...any) ([]T, error) ``` `List` accepts: * a `context.Context` * a `db.Conn` * a SQL or CQL query string * optional query arguments It returns: ```go []T, error ``` ## When to Use List [#when-to-use-list] Use `db.List` when: * the query can return zero or more rows * the result shape is known * you want a typed `[]T` * the result set can safely fit in memory * you are building list endpoints, reports, or search results For very large result sets, prefer streaming patterns where appropriate. ## Define a Model [#define-a-model] Create a struct that represents one returned row. ```go type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } ``` The struct is mapped using the mapper package. The `db` tags match returned column names. ## Query [#query] The query can be written directly in Go code. For MySQL and Scylla, use `?` placeholders. ```go const selectUsersQuery = ` SELECT * FROM users ORDER BY created_at DESC LIMIT ? ` ``` For PostgreSQL, use numbered placeholders. ```go const selectUsersQuery = ` SELECT * FROM users ORDER BY created_at DESC LIMIT $1 ` ``` ## Complete Query Example [#complete-query-example] This example returns users as a typed slice. ```go package main import ( "context" "database/sql" "time" "github.com/netlifeguru/db" ) type User struct { ID int64 `db:"id"` Name string `db:"name"` Email string `db:"email"` Active bool `db:"active"` CreatedAt time.Time `db:"created_at"` } const selectUsersQuery = ` SELECT * FROM users ORDER BY created_at DESC LIMIT ? ` func ListUsers(ctx context.Context, conn db.Conn, limit int) ([]User, error) { return db.List[User](ctx, conn, selectUsersQuery, limit) } ``` ## Complete Usage Example [#complete-usage-example] This example connects to the database, loads users, and prints the result. ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } ctx := context.Background() users, err := ListUsers(ctx, conn, 10) if err != nil { log.Fatal(err) } for _, user := range users { fmt.Printf( "ID: %d | Name: %s | Email: %s | Active: %t | Created: %s\n", user.ID, user.Name, user.Email, user.Active, user.CreatedAt.Format("2006-01-02 15:04:05"), ) } } ``` ## Empty Results [#empty-results] If the query returns no rows, `db.List` returns an empty slice. ```go users, err := db.List[User](ctx, conn, selectUsersQuery, 10) if err != nil { return nil, err } fmt.Println(len(users)) // 0 ``` An empty result is not treated as an error. This makes `db.List` a good fit for list endpoints and search results. ## Driver Placeholder Differences [#driver-placeholder-differences] The Go call stays the same across drivers. ```go users, err := db.List[User](ctx, conn, selectUsersQuery, 10) ``` Only the query placeholder syntax changes. | Driver | Placeholder | | -------- | ----------- | | MySQL | `?` | | Postgres | `$1` | | Scylla | `?` | ## Scylla Example [#scylla-example] For Scylla, queries are usually based on query tables. ```go const selectUsersByStatusQuery = ` SELECT * FROM users_by_status WHERE status = ? LIMIT ? ` func ListUsersByStatus(ctx context.Context, conn db.Conn, status string, limit int) ([]User, error) { return db.List[User](ctx, conn, selectUsersByStatusQuery, status, limit) } ``` ## Related Helpers [#related-helpers] Use `db.Get` when the query should return zero or one row. ```go user, found, err := db.Get[User](ctx, conn, selectUserQuery, id) ``` Use `db.Value` when the query returns a single scalar value. ```go total, found, err := db.Value[int64](ctx, conn, countUsersQuery) ``` Use `db.Maps` when the result shape is dynamic. ```go rows, err := db.Maps(ctx, conn, selectRowsQuery) ``` ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL select list](https://github.com/netlifeguru/examples/db/mysql/05_select_list) * [PostgreSQL select list](https://github.com/netlifeguru/examples/db/postgresql/05_select_list) * [Scylla select list](https://github.com/netlifeguru/examples/db/scylla/05_select_list) # Maps # Maps [#maps] Use `db.Maps` when a query returns rows that should be handled as dynamic `map[string]any` values. This is useful when the result shape is not stable enough for a struct, or when you want to inspect raw row data. ```go rows, err := db.Maps(ctx, conn, selectEventsQuery, 10) if err != nil { return nil, err } ``` ## Function [#function] ```go func Maps(ctx context.Context, c db.Conn, query string, args ...any) ([]map[string]any, error) ``` `Maps` accepts: * a `context.Context` * a `db.Conn` * a SQL or CQL query string * optional query arguments It returns: ```go []map[string]any, error ``` Each map represents one database row. ## When to Use Maps [#when-to-use-maps] Use `db.Maps` when: * the result shape is dynamic * you do not want to define a struct for the query * you are building reports or exports * you are working with admin tooling * you want to inspect raw database values * the selected columns may change depending on the query For stable application models, prefer `db.List` or `db.Get`. ## Query [#query] For MySQL and Scylla, use `?` placeholders. ```go const selectEventsQuery = ` SELECT * FROM events ORDER BY created_at DESC LIMIT ? ` ``` For PostgreSQL, use numbered placeholders. ```go const selectEventsQuery = ` SELECT * FROM events ORDER BY created_at DESC LIMIT $1 ` ``` ## Complete Query Example [#complete-query-example] This example returns event rows as dynamic maps. ```go package main import ( "context" "github.com/netlifeguru/db" ) const selectEventsQuery = ` SELECT * FROM events ORDER BY created_at DESC LIMIT ? ` func ListEvents(ctx context.Context, conn db.Conn, limit int) ([]map[string]any, error) { return db.Maps(ctx, conn, selectEventsQuery, limit) } ``` ## Complete Usage Example [#complete-usage-example] This example connects to the database, loads events, and prints map values. ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } ctx := context.Background() events, err := ListEvents(ctx, conn, 10) if err != nil { log.Fatal(err) } for _, event := range events { fmt.Printf( "ID: %v | Type: %v | Payload: %v | Created: %v\n", event["id"], event["type"], event["payload"], event["created_at"], ) } } ``` ## Returned Row Shape [#returned-row-shape] Each row is represented as a `map[string]any`. For the query above, a row may look like this: ```go map[string]any{ "id": int64(1), "type": "user.created", "payload": `{"name":"John Doe"}`, "created_at": time.Now(), } ``` The exact Go types depend on the database driver and returned column types. ## SQL Aliases [#sql-aliases] Map keys are based on the returned column names. Use aliases when selecting computed values or joining tables. ```sql SELECT u.id AS user_id, u.email AS user_email, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.email ``` The resulting map will contain the alias names: ```go map[string]any{ "user_id": int64(1), "user_email": "john@example.com", "order_count": int64(3), } ``` ## Typed Access [#typed-access] For typed access, convert a result row to `mapper.Row`. ```go row := mapper.Row(events[0]) eventType, ok := row.String("type") if !ok { return errors.New("invalid event type") } ``` Or use standalone mapper converters: ```go eventType, ok := mapper.AsString(event["type"]) if !ok { return errors.New("invalid event type") } ``` Use map values directly when you only need display or generic processing. Use typed access when values are required for business logic. ## Empty Results [#empty-results] If the query returns no rows, `db.Maps` returns an empty slice. ```go events, err := db.Maps(ctx, conn, selectEventsQuery, 10) if err != nil { return nil, err } fmt.Println(len(events)) // 0 ``` An empty result is not treated as an error. ## Driver Placeholder Differences [#driver-placeholder-differences] The Go call stays the same across drivers. ```go events, err := db.Maps(ctx, conn, selectEventsQuery, 10) ``` Only the query placeholder syntax changes. | Driver | Placeholder | | -------- | ----------- | | MySQL | `?` | | Postgres | `$1` | | Scylla | `?` | ## Scylla Example [#scylla-example] For Scylla, use a query table that matches the lookup pattern. ```go const selectEventsByTypeQuery = ` SELECT * FROM events_by_type WHERE type = ? LIMIT ? ` func ListEventsByType(ctx context.Context, conn db.Conn, eventType string, limit int) ([]map[string]any, error) { return db.Maps(ctx, conn, selectEventsByTypeQuery, eventType, limit) } ``` ## Related Helpers [#related-helpers] Use `db.List` when the query returns multiple typed rows. ```go users, err := db.List[User](ctx, conn, selectUsersQuery, 10) ``` Use `db.Get` when the query returns zero or one typed row. ```go user, found, err := db.Get[User](ctx, conn, selectUserQuery, id) ``` Use `db.Value` when the query returns one scalar value. ```go total, found, err := db.Value[int64](ctx, conn, countUsersQuery) ``` ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL select map](https://github.com/netlifeguru/examples/db/mysql/09_select_map) * [PostgreSQL select map](https://github.com/netlifeguru/examples/db/postgresql/09_select_map) * [Scylla select map](https://github.com/netlifeguru/examples/db/scylla/09_select_map) # Select Overview The select helpers are the most common way to read data with the shared `db` package. They are designed for everyday queries where you already have: * a `context.Context` * a `db.Conn` * a SQL or CQL query string * optional query arguments The select helpers create a `db.Query` internally and execute it through the active driver. ## Available Helpers [#available-helpers] | Helper | Result | Use case | | ------------- | ------------------ | --------------------------------------- | | `db.List[T]` | `[]T` | Read multiple rows into a typed slice | | `db.Get[T]` | `(T, bool, error)` | Read zero or one row into a typed value | | `db.Value[T]` | `(T, bool, error)` | Read one scalar value | | `db.Maps` | `[]map[string]any` | Read rows as dynamic maps | These helpers are the highest-level read API. For lower-level usage with `db.Query`, use the Query Objects helpers. For SQL loaded from model files, use the Dialect SQL helpers. ## List [#list] Use `db.List` when the query returns multiple rows. ```go users, err := db.List[User](ctx, conn, selectUsersQuery, 10) if err != nil { return nil, err } ``` Example query for MySQL: ```go const selectUsersQuery = ` SELECT * FROM users ORDER BY created_at DESC LIMIT ? ` ``` Example query for PostgreSQL: ```go const selectUsersQuery = ` SELECT * FROM users ORDER BY created_at DESC LIMIT $1 ` ``` Example query for Scylla: ```go const selectPostsByUserQuery = ` SELECT * FROM posts_by_user WHERE user_id = ? LIMIT ? ` ``` `db.List` scans all returned rows into `[]T`. ## Get [#get] Use `db.Get` when the query should return zero or one row. ```go user, found, err := db.Get[User](ctx, conn, selectUserQuery, id) if err != nil { return User{}, false, err } if !found { return User{}, false, nil } ``` Example query for MySQL: ```go const selectUserQuery = ` SELECT * FROM users WHERE id = ? LIMIT 1 ` ``` Example query for PostgreSQL: ```go const selectUserQuery = ` SELECT * FROM users WHERE id = $1 LIMIT 1 ` ``` Example query for Scylla: ```go const selectUserByEmailQuery = ` SELECT * FROM users_by_email WHERE email = ? LIMIT 1 ` ``` `db.Get` returns: ```go value, found, err ``` `found` is `false` when the query returns no rows. ## Value [#value] Use `db.Value` when the query returns one scalar value. ```go total, found, err := db.Value[int64](ctx, conn, countUsersQuery) if err != nil { return 0, false, err } if !found { return 0, false, nil } ``` Example query: ```go const countUsersQuery = ` SELECT COUNT(*) FROM users ` ``` Scylla example: ```go const countPostsByUserQuery = ` SELECT COUNT(*) FROM posts_by_user WHERE user_id = ? ` ``` `db.Value` expects exactly one selected column. It is useful for: * counts * IDs returned from `RETURNING` * flags * aggregate values * simple lookups ## Maps [#maps] Use `db.Maps` when you want rows as dynamic `map[string]any` values. ```go rows, err := db.Maps(ctx, conn, selectUserMapsQuery, 10) if err != nil { return nil, err } ``` Example query for MySQL: ```go const selectUserMapsQuery = ` SELECT * FROM users ORDER BY created_at DESC LIMIT ? ` ``` Example query for PostgreSQL: ```go const selectUserMapsQuery = ` SELECT * FROM users ORDER BY created_at DESC LIMIT $1 ` ``` Example query for Scylla: ```go const selectPostMapsByUserQuery = ` SELECT * FROM posts_by_user WHERE user_id = ? LIMIT ? ` ``` `db.Maps` is useful for: * dynamic results * admin screens * reports * debugging * generic tooling For stable application models, prefer `db.List` or `db.Get`. ## Placeholder Differences [#placeholder-differences] The Go helper API is the same across drivers, but SQL placeholders are still driver-specific. | Driver | Placeholder style | | -------- | ----------------- | | MySQL | `?` | | Postgres | `$1`, `$2`, `$3` | | Scylla | `?` | Example MySQL call: ```go users, err := db.List[User](ctx, conn, selectUsersQuery, 10) ``` Example PostgreSQL call: ```go users, err := db.List[User](ctx, conn, selectUsersQuery, 10) ``` The Go code looks the same, but the query string uses the placeholder style required by the selected driver. ## Choosing the Right Helper [#choosing-the-right-helper] | Need | Use | | --------------------- | ------------- | | Multiple typed rows | `db.List[T]` | | Zero or one typed row | `db.Get[T]` | | One scalar value | `db.Value[T]` | | Dynamic map rows | `db.Maps` | ## Select Helpers vs Query Objects [#select-helpers-vs-query-objects] The select helpers accept a raw query string and arguments directly. ```go users, err := db.List[User](ctx, conn, query, args...) ``` Internally, they create a `db.Query` for you. Use Query Objects when you want to build the query first: ```go q, err := db.Raw(query, args...) if err != nil { return nil, err } users, err := db.ListQuery[User](ctx, conn, q) ``` ## Select Helpers vs Dialect SQL [#select-helpers-vs-dialect-sql] Use select helpers when the query is written directly in Go code. ```go users, err := db.List[User](ctx, conn, selectUsersQuery, 10) ``` Use Dialect SQL helpers when the query comes from a loaded SQL model file. ```go users, err := db.ListDialect[User](ctx, conn, queries.ListUsers, 10) ``` Dialect SQL is useful when the same application can run with MySQL, PostgreSQL, or Scylla and each driver needs its own query text. ## Recommended Usage [#recommended-usage] Start with the select helpers. Use: ```go db.List[T] ``` for list endpoints. Use: ```go db.Get[T] ``` for lookup endpoints. Use: ```go db.Value[T] ``` for counts, aggregate values, or returned IDs. Use: ```go db.Maps ``` when the result shape is dynamic. Continue with the dedicated pages for each helper to see complete examples. # Value # Value [#value] Use `db.Value` when a query is expected to return one scalar value. It is commonly used for counts, generated IDs, aggregate values, boolean flags, and simple lookups. ```go total, found, err := db.Value[int64](ctx, conn, countUsersQuery) if err != nil { return 0, false, err } if !found { return 0, false, nil } ``` ## Function [#function] ```go func Value[T any](ctx context.Context, c db.Conn, query string, args ...any) (T, bool, error) ``` `Value` accepts: * a `context.Context` * a `db.Conn` * a SQL or CQL query string * optional query arguments It returns: ```go T, found, error ``` `found` is `false` when the query returns no rows. ## When to Use Value [#when-to-use-value] Use `db.Value` when: * the query returns exactly one column * you need a scalar result * you are reading a count * you are reading a generated ID with PostgreSQL `RETURNING` * you are checking a simple value * you are reading an aggregate result Common examples: ```sql SELECT COUNT(*) FROM users ``` ```sql SELECT active FROM users WHERE id = ? ``` ```sql INSERT INTO users (name, email, active) VALUES ($1, $2, $3) RETURNING id ``` ## Count Example [#count-example] For MySQL and Scylla, use `?` placeholders. ```go const countActiveUsersQuery = ` SELECT COUNT(*) FROM users WHERE active = ? ` ``` For PostgreSQL, use numbered placeholders. ```go const countActiveUsersQuery = ` SELECT COUNT(*) FROM users WHERE active = $1 ` ``` ## Complete Query Example [#complete-query-example] This example returns the number of active users. ```go package main import ( "context" "github.com/netlifeguru/db" ) const countActiveUsersQuery = ` SELECT COUNT(*) FROM users WHERE active = ? ` func CountActiveUsers(ctx context.Context, conn db.Conn) (int64, bool, error) { return db.Value[int64](ctx, conn, countActiveUsersQuery, true) } ``` ## Complete Usage Example [#complete-usage-example] This example connects to the database, reads the count, and prints it. ```go package main import ( "context" "fmt" "log" "github.com/joho/godotenv" ) func main() { err := godotenv.Load() if err != nil { log.Println(".env file not found, I'm using system env variables") } conn, err := connectDB() if err != nil { log.Fatal(err) } ctx := context.Background() total, found, err := CountActiveUsers(ctx, conn) if err != nil { log.Fatal(err) } if !found { log.Println("count not found") return } fmt.Printf("active users: %d\n", total) } ``` ## PostgreSQL Returning Example [#postgresql-returning-example] PostgreSQL commonly uses `RETURNING` when an insert should return a generated ID. ```go const insertUserQuery = ` INSERT INTO users (name, email, active) VALUES ($1, $2, $3) RETURNING id ` func InsertUser(ctx context.Context, conn db.Conn, name string, email string, active bool) (db.Result, error) { return db.Insert(ctx, conn, insertUserQuery, name, email, active) } ``` For MySQL inserts, use `db.Insert` and read `result.LastInsertId()` instead. ## Single Column Requirement [#single-column-requirement] `db.Value` expects the query result to contain exactly one selected column. Good: ```sql SELECT COUNT(*) FROM users ``` Good: ```sql SELECT email FROM users WHERE id = ? ``` Not suitable: ```sql SELECT id, email FROM users WHERE id = ? ``` For multiple columns, use `db.Get` or `db.List`. ## Empty Result [#empty-result] If the query returns no rows, `db.Value` returns `found = false`. ```go value, found, err := db.Value[string](ctx, conn, selectEmailQuery, id) if err != nil { return "", false, err } if !found { return "", false, nil } ``` ## Too Many Rows [#too-many-rows] `db.Value` expects zero or one row. If the query returns more than one row, it returns an error. Use a unique condition, aggregate query, or `LIMIT 1` when appropriate. ```sql SELECT email FROM users WHERE id = ? LIMIT 1 ``` ## Driver Placeholder Differences [#driver-placeholder-differences] The Go call stays the same across drivers. ```go total, found, err := db.Value[int64](ctx, conn, countActiveUsersQuery, true) ``` Only the query placeholder syntax changes. | Driver | Placeholder | | -------- | ----------- | | MySQL | `?` | | Postgres | `$1` | | Scylla | `?` | ## Scylla Example [#scylla-example] Use `db.Value` when a Scylla query returns a single scalar value. ```go const countPostsByUserQuery = ` SELECT COUNT(*) FROM posts_by_user WHERE user_id = ? ` func CountPostsByUser(ctx context.Context, conn db.Conn, userID string) (int64, bool, error) { return db.Value[int64](ctx, conn, countPostsByUserQuery, userID) } ``` ## Related Helpers [#related-helpers] Use `db.List` when the query can return multiple typed rows. ```go users, err := db.List[User](ctx, conn, selectUsersQuery, 10) ``` Use `db.Get` when the query returns zero or one typed row. ```go user, found, err := db.Get[User](ctx, conn, selectUserQuery, id) ``` Use `db.Maps` when the result shape is dynamic. ```go rows, err := db.Maps(ctx, conn, selectRowsQuery) ``` ## Related Examples [#related-examples] Standalone examples are available in the examples repository: * [MySQL select value](https://github.com/netlifeguru/examples/db/mysql/08_select_value) * [PostgreSQL select value](https://github.com/netlifeguru/examples/db/postgresql/08_select_value) * [Scylla select value](https://github.com/netlifeguru/examples/db/scylla/08_select_value)