jackgodb

package module
v1.7.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 19, 2026 License: Unlicense Imports: 23 Imported by: 0

README

jackgodb

A generic ORM + DAO layer for Go, built on top of bun. It adds typed generic data-access objects, per-record dirty tracking, map-based dynamic query building, and a schema-to-model code generator — so your models expose CRUD without writing SQL for the common paths.

Supported backends: MySQL, SQLite, and PostgreSQL (single code path, dialect-aware).

  • Module: github.com/raxisau/jackgodb
  • Requires: go 1.27.1+
  • License: The Unlicense (public domain) — see LICENSE

Installation

go get github.com/raxisau/jackgodb@latest

Core concepts

Type Purpose
DAO Wraps a *bun.DB plus the active dialect (mysql / sqlite / pgsql). Created by the Open*DBConnection helpers.
ModelDAO[T] A typed, generic DAO for one model. Knows the column alias map and how to construct new attached records.
ModelRecord[T] Embedded in your model struct. Provides dirty tracking, CRUD, reflection-based get/set, and the dynamic query builder.

Your model embeds both a bun.BaseModel (table name/alias) and ModelRecord[*T]:

package demo

import (
	"context"
	"log/slog"
	"time"

	"github.com/raxisau/jackgodb"
	"github.com/uptrace/bun"
)

type StatusesDAO struct {
	*jackgodb.ModelDAO[*Statuses]
}

type Statuses struct {
	bun.BaseModel `bun:"table:reg_statuses,alias:RSTAT"`

	jackgodb.ModelRecord[*Statuses] `bun:"-" json:"-"`

	ID                  int64     `bun:"id,pk,autoincrement" json:"id,omitempty"`
	StatusCode          string    `bun:"f_status_code,unique" json:"f_status_code,omitempty"`
	StatusDescription   *string   `bun:"f_status_description" json:"f_status_description,omitempty"`
	StatusReasonDefault *string   `bun:"f_status_reason_default" json:"f_status_reason_default,omitempty"`
	StatusURL           *string   `bun:"f_status_url" json:"f_status_url,omitempty"`
	CreatedAt           time.Time `bun:"f_created_at" json:"f_created_at,omitzero"`
	Comments            *string   `bun:"f_comments" json:"f_comments,omitempty"`
}

var aliasStatuses = map[string]string{
	"id":                  "id",
	"statusCode":          "f_status_code",
	"statusDescription":   "f_status_description",
	"statusReasonDefault": "f_status_reason_default",
	"statusURL":           "f_status_url",
	"createdAt":           "f_created_at",
	"comments":            "f_comments",
}

func NewStatusesDAO(dao *jackgodb.DAO) *StatusesDAO {
	return &StatusesDAO{
		ModelDAO: jackgodb.NewModelDAO(
			dao,
			aliasStatuses,
			func() *Statuses {
				return &Statuses{}
			}),
	}
}

func (dao *StatusesDAO) NewStatuses() *Statuses {
	record := dao.New()
	return record
}

func (m *Statuses) LoadByName(statusCode string) *Statuses {

	record := m.Self
	record.SetID(0)

	if statusCode == "" {
		return nil
	}

	err := m.Dao.Db.NewSelect().
		Model(record).
		Where("f_status_code=?", statusCode).
		Limit(1).
		Scan(context.Background())

	if err != nil {
		slog.Error("1. Statuses.LoadByName", "error", err)
		return nil
	}

	return m
}

The alias map translates the keys you pass to SetValue / GetValue / Dirty / ToWhere into real column names, so application code never has to spell out f_-prefixed column names.

A complete working reference model lives in internal/demo/statuses.go.

Quickstart

package main

import (
	"context"
	"fmt"
	"log/slog"
	"os"

	"github.com/raxisau/jackgodb"
	"github.com/raxisau/jackgodb/internal/demo"
)

func main() {
	setUpLogging()

	// Empty DSN = in-memory SQLite. See "Connections" for MySQL/PostgreSQL.
	dao := jackgodb.OpenSQLiteDBConnection("")
	defer dao.Close()

	statusDao := demo.NewStatusesDAO(dao)

	// Create the table (bun DDL; in production use migrations).
	ctx := context.Background()
	_, err := dao.Db.NewCreateTable().Model((*demo.Statuses)(nil)).IfNotExists().Exec(ctx)
	if err != nil {
		panic(err)
	}

	// Insert
	rec := statusDao.NewStatuses()
	rec.SetValue("statusCode", "ACTIVE")
	rec.SetValue("statusDescription", "rec.SetValue statusDescription")
	rec.SetValue("statusReasonDefault", "rec.SetValue statusReasonDefault")
	rec.SetValue("statusURL", "http://rec.SetValuestatusURL")
	rec.SetValue("comments", "rec.SetValue comments")
	id, err := rec.SaveE() // returns the new primary key, or the error
	if err != nil {
		panic(err)
	}

	// Load
	loaded, err := statusDao.NewStatuses().LoadE(id)
	if err != nil {
		panic(err)
	}
	fmt.Println(loaded.ToJSONIndent())

	// Update — use SetValue, which marks the column dirty
	loaded.SetValue("statusCode", "RETIRED")
	if _, err := loaded.SaveE(); err != nil {
		panic(err)
	}

	// Delete
	loaded.Delete()
	slog.Info("server stopped")
}

func setUpLogging() *slog.Logger {
	// https://pkg.go.dev/log/slog#example-SetLogLoggerLevel-Log
	opts := &slog.HandlerOptions{Level: slog.LevelDebug}

	log := slog.New(slog.NewJSONHandler(os.Stdout, opts))
	slog.SetDefault(log)
	slog.Info("Starting System")

	return log
}

Connections

dao := jackgodb.OpenMySQLDBConnection("user:pass@tcp(127.0.0.1:3306)/mydb")
dao := jackgodb.OpenSQLiteDBConnection("")                                  // in-memory (shared cache)
dao := jackgodb.OpenSQLiteDBConnection("file:data.db?cache=shared&mode=rwc")
dao := jackgodb.OpenPostgreSQLDBConnection("postgres://user:pass@127.0.0.1:5432/mydb")
  • Pool sizing uses the package constants MaxOpenConns (25), MaxIdleConns (25) and ConnMaxLifetime (MaxLifetime, 5 minutes).
  • Set jackgodb.SQLDebug = true before opening a connection to attach bun's debug query hook (bundebug, verbose) and log every statement.
  • dao.Close() closes the underlying pool. A DAO also exposes Db (the raw *bun.DB) when you need bun directly, and DbIn(start, count) which renders a placeholder list for IN (...) clauses (?,? everywhere; $3,$4,$5-style on PostgreSQL).

CRUD reference

Two sets of CRUD methods are promoted from the embedded ModelRecord[T]:

  • Error-returning (*E): the recommended methods. They return the error, so callers can tell "not found", "no changes", and "failed" apart.
  • Logging (no suffix): kept for compatibility. They log errors via log/slog and return 0 or an empty record on failure. See Known limitations.
Method Behaviour
SaveE() (int64, error) InsertE() when the primary key is zero, otherwise UpdateE().
InsertE() (int64, error) Inserts only the dirty columns (all columns when none are dirty). Returns the new primary key. A duplicate unique value is returned as an error.
UpdateE() (int64, error) Updates only dirty columns by primary key. Returns ErrNoChanges when the record is clean.
LoadE(id int64) (T, error) Loads by primary key. Returns ErrNotFound when no row matches. On error it returns the zero value of T.
Save() int64 Logging wrapper around SaveE. Returns 0 on failure.
Insert() int64 Logging wrapper around InsertE. Returns 0 on failure. Changed: this no longer uses INSERT OR IGNORE, so a duplicate is an error (0), not a silent no-op.
InsertIgnore() int64 INSERT IGNORE / ON CONFLICT DO NOTHING equivalent. Existing rows are left untouched. Use this when a duplicate should be ignored.
Update() int64 Logging wrapper around UpdateE. Returns 0 when clean or on failure.
Delete() int64 Deletes by primary key. Returns rows affected.
Load(id int64) T Logging wrapper around LoadE. On failure the id is reset to 0.

Exported errors: ErrNoChanges, ErrNotFound. Use errors.Is to check them.

Dirty tracking:

Method Behaviour
Dirty(column string) Mark a column (by alias or column name) as modified.
IsClean() / IsDirty() bool Whether any column is dirty.
Clean() Reset all columns to clean (done automatically after attach and update).

Direct struct field assignment is not tracked. To update a record, change values via SetValue (which marks the column dirty) or call Dirty yourself.

Value API

ok := rec.SetValue("statusCode", "ACTIVE") // alias-resolved, type-coerced, marks dirty
val := rec.GetValue("statusCode")          // any

id := rec.GetID()   // reads the pk column as int64
rec.SetID(id)       // writes the pk column

SetValue coerces the incoming value to the field's type via ConvertTo and only marks the column dirty when the value actually changed. Supported targets: string, int, int64, float64, time.Time, and their nullable (*) forms; anything else falls back through AnyToString / AnyToInt64 helpers.

API bridging

// Struct -> map[string]any, keyed by json tags.
asMap := rec.ToAPI()

// map[string]any -> struct. If the payload contains the pk column the record is loaded
// from the database first, so it works as an upsert-style PATCH endpoint.
rec.FromAPI(map[string]any{
	"id":         3,
	"statusCode": "ACTIVE",
})

Dynamic queries with ToWhere

ToWhere(query, args) builds bun SELECT clauses from plain maps/lists — useful when the request shape comes from JSON:

var rows []*Statuses
query := statusDao.Db.NewSelect().Model(&rows)
rec.ToWhere(query, map[string]any{
	"where": map[string]any{
		"statusCode":           "ACTIVE",               // parameterized: WHERE f_status_code = ?
		"statusUrl != ?":       "https://spam.example", // keys with "?" bind values as params
		"comments IS NOT NULL": nil,                    // value-less keys run as raw SQL
	},
	"order": map[string]any{"createdAt": "DESC"},
	"limits": []any{0, 50},             // offset, limit
})
err := query.Scan(context.Background())
statusDao.AttachList(rows) // attach loaded rows so they get full record behaviour
Key Accepted shapes
limits int (limit only) · []any{offset, limit} · map[string]any{"<offset>": limit}
order "column" · map[string]any{"column": "ASC"/"DESC"} (anything but DESC = ASC) · []any of columns
where "column" (raw) · map[string]any{"column": value} or {"sql with ?": value} · []any of raw clauses

Keys are resolved through the alias map and converted to real column names; values are bound as query parameters, but string/list keys are interpolated as SQL — never pass untrusted input there (see Known limitations).

Code generation with modelgen

cmd/modelgen turns a MySQL-flavoured CREATE TABLE statement into a ready-to-use model file (bun tags, alias map, DAO constructor):

# From a file, print to stdout
go run ./cmd/modelgen -file stdout schema.sql

# Or pipe it in
cat schema.sql | go run ./cmd/modelgen -file infer   # writes <snake_case_name>.go
go run ./cmd/modelgen -file model/statuses.go schema.sql

Conventions the generator understands:

  • Table names prefixed tbl and column names prefixed fld / f_ are stripped before Pascal-casing (tblUser -> User, f_status_code -> StatusCode).
  • Types: int* -> int64, tinyint(1) -> bool, decimal/float/double -> float64, date/datetime/timestamp -> time.Time, text/varchar/json -> string; nullable columns become pointer types.
  • PRIMARY KEY and UNIQUE KEY lines are detected; auto_increment produces the autoincrement bun tag.
  • A column named f_updated_at (or fldLastUpdated) is forced to time.Time and tagged nullzero,notnull,default:current_timestamp.

Model metadata rules

ModelRecord caches metadata per concrete type (table name, primary key, column list, column -> field index) by reading your bun tags:

  • Fields need a bun tag to participate; bun:"-" fields are ignored (as embedded ModelRecord is).
  • The model must declare a table (table:... on any field's bun tag, normally the bun.BaseModel) and a column tagged pk. If either is missing, metadata is nil and value/ CRUD calls log "model metadata is nil".
  • Tag options: pk marks the primary key (kept out of dirty tracking); scanonly columns are readable but never written.
  • A column named fldLastUpdated tagged default:current_timestamp is treated as database-managed and excluded from inserts/updates.

Package API summary

// db.go
func OpenMySQLDBConnection(dsn string) *DAO
func OpenSQLiteDBConnection(dsn string) *DAO
func OpenPostgreSQLDBConnection(dsn string) *DAO
const MaxOpenConns, MaxIdleConns = 25, 25
const MaxLifetime = 5 * time.Minute
var SQLDebug bool

// dao.go
func NewDAO(db *bun.DB) *DAO
func (dao *DAO) Close()
func (dao *DAO) DbIn(count int) string
func NewModelDAO[T DBModel](dao *DAO, alias map[string]string, newRecord func() T) *ModelDAO[T]
func (dao *ModelDAO[T]) New() T
func (dao *ModelDAO[T]) Attach(record T)
func (dao *ModelDAO[T]) AttachList(records []T)

// orm.go — promoted onto your model via ModelRecord[T]
func (m) SaveE() (int64, error) / InsertE() (int64, error) / UpdateE() (int64, error)
func (m) LoadE(id int64) (T, error)
func (m) Save() / Insert() / InsertIgnore() / Update() / Delete() int64
func (m) Load(id int64) T
var ErrNoChanges, ErrNotFound error
func (m) SetValue(column string, value any) bool
func (m) GetValue(column string) any
func (m) GetID() int64 / SetID(id int64)
func (m) Dirty(column string) / IsClean() / IsDirty() bool / Clean()
func (m) ToAPI() map[string]any
func (m) ToJSON() string
func (m) ToJSONIndent() string
func (m) FromAPI(data map[string]any) T
func (m) ToWhere(query *bun.SelectQuery, args map[string]any) *bun.SelectQuery

// converter.go
func ConvertTo(value any, targetType reflect.Type) (any, error)
func ParseUTCDateTime(value string) (time.Time, error)
func AnyToTime(value any) time.Time
func AnyToInt(value any) int
func AnyToInt64(value any) int64
func AnyToString(value any) string
func StringValue(*string) string

Known limitations

  • Logging methods hide errors. The methods without a suffix write failures to log/slog and return 0 / empty records. Use the *E methods, which return the error.
  • Raw SQL surface in ToWhere. String/list where and order keys are interpolated as SQL. Parameterize everything user-controlled or validate against the alias map first.
  • No context parameter. Queries run on context.Background(); there is no cancellation or timeout plumbing yet.
  • UpdateE() returns ErrNoChanges on clean records, so field writes must go through SetValue/Dirty.
  • modelgen targets MySQL-style DDL (backtick-quoted identifiers, auto_increment, inline PRIMARY KEY/UNIQUE KEY lines).

Project layout

cmd/jackgodb/    Demo binary (in-memory SQLite; creates the demo table)
cmd/modelgen/    CREATE TABLE -> Go model code generator
internal/demo/   Reference model (Statuses) showing the expected shape
db.go            Connection openers for MySQL / SQLite / PostgreSQL
dao.go           DAO and generic ModelDAO[T]
orm.go           ModelRecord[T]: metadata, dirty tracking, CRUD, ToWhere
converter.go     Type coercion and date parsing helpers

Development

make jgdb   # Run the demo
make test   # Run the tests
make cover  # check the coverage from tests

Documentation

Index

Constants

View Source
const (
	DialectMySQL      = "mysql"
	DialectSQLite     = "sqlite"
	DialectPostgreSQL = "pgsql"
)
View Source
const (
	MaxOpenConns = 25
	MaxIdleConns = 25
	MaxLifetime  = 5 * time.Minute
)

Variables

View Source
var (
	StringType = reflect.TypeFor[string]()
	TimeType   = reflect.TypeFor[time.Time]()
)
View Source
var ErrBadArgs = errors.New("jackgodb: bad query argument")

ErrBadArgs is returned by ToWhereE when an argument has the wrong shape.

View Source
var ErrNoChanges = errors.New("jackgodb: no changes to update")

ErrNoChanges is returned by UpdateE when the record has no dirty columns.

View Source
var ErrNotFound = errors.New("jackgodb: record not found")

ErrNotFound is returned by LoadE when no row matches the id.

View Source
var ErrUnknownKey = errors.New("jackgodb: unknown column key")

ErrUnknownKey is returned by ToWhereE when a where or order key is not an alias or column.

View Source
var (
	SQLDebug = false
)

Functions

func AnyToInt

func AnyToInt(v any) int

func AnyToInt64

func AnyToInt64(v any) int64

func AnyToString

func AnyToString(value any) string

func AnyToTime

func AnyToTime(value any) time.Time

func ConvertTo

func ConvertTo(value any, targetType reflect.Type) (any, error)

func DbIn added in v1.7.1

func DbIn(count int) string

func ParseUTCDateTime

func ParseUTCDateTime(value string) (time.Time, error)

func StringValue

func StringValue(value *string) string

Types

type DAO

type DAO struct {
	Db *bun.DB
	// contains filtered or unexported fields
}

func NewDAO

func NewDAO(db *bun.DB) *DAO

func OpenMySQLDBConnection

func OpenMySQLDBConnection(dsn string) *DAO

func OpenPostgreSQLDBConnection added in v1.2.0

func OpenPostgreSQLDBConnection(dsn string) *DAO

func OpenSQLiteDBConnection

func OpenSQLiteDBConnection(dsn string) *DAO

func (*DAO) Close

func (dao *DAO) Close()

type DBModel

type DBModel interface {
	GetID() int64
	SetID(int64)
}

type ModelDAO

type ModelDAO[T DBModel] struct {
	Db *bun.DB
	// contains filtered or unexported fields
}

func NewModelDAO

func NewModelDAO[T DBModel](dao *DAO, alias map[string]string, newRecord func() T) *ModelDAO[T]

func (*ModelDAO[T]) Attach

func (dao *ModelDAO[T]) Attach(record T)

func (*ModelDAO[T]) AttachList

func (dao *ModelDAO[T]) AttachList(recordList []T)

func (*ModelDAO[T]) New

func (dao *ModelDAO[T]) New() T

type ModelDAOAttacher

type ModelDAOAttacher[T DBModel] interface {
	AttachModelDAO(*ModelDAO[T], T)
}

type ModelMetadata

type ModelMetadata struct {
	TableName     string         `json:"TableName,omitempty"`
	PKField       string         `json:"PKField,omitempty"`
	PKColumn      string         `json:"PKColumn,omitempty"`
	Columns       []string       `json:"Columns,omitempty"`
	FieldByColumn map[string]int `json:"FieldByColumn,omitempty"`
}

type ModelRecord

type ModelRecord[T DBModel] struct {
	Dao *ModelDAO[T]

	Self T
	// contains filtered or unexported fields
}

func (*ModelRecord[T]) AttachModelDAO

func (m *ModelRecord[T]) AttachModelDAO(dao *ModelDAO[T], self T)

func (*ModelRecord[T]) Clean

func (m *ModelRecord[T]) Clean()

func (*ModelRecord[T]) Delete

func (m *ModelRecord[T]) Delete() int64

func (*ModelRecord[T]) Dirty

func (m *ModelRecord[T]) Dirty(column string)

func (*ModelRecord[T]) DirtyAll added in v1.5.0

func (m *ModelRecord[T]) DirtyAll()

DirtyAll marks every non-primary-key column dirty, so the next Update or Save writes the whole row. Use it when you have changed a record in ways SetValue did not track. Do not use it to save a partial change: any column you did not load will be overwritten with its zero value.

func (*ModelRecord[T]) FromAPI

func (m *ModelRecord[T]) FromAPI(apiData map[string]any) T

func (*ModelRecord[T]) GetID

func (m *ModelRecord[T]) GetID() int64

func (*ModelRecord[T]) GetModelMetadata

func (m *ModelRecord[T]) GetModelMetadata() *ModelMetadata

func (*ModelRecord[T]) GetValue

func (m *ModelRecord[T]) GetValue(columnName string) any

func (*ModelRecord[T]) Insert

func (m *ModelRecord[T]) Insert() int64

Insert is the logging wrapper around InsertE. It returns 0 on failure. Use InsertE to see why.

func (*ModelRecord[T]) InsertE added in v1.6.0

func (m *ModelRecord[T]) InsertE() (int64, error)

InsertE inserts the record and returns its new ID. A unique-constraint violation is returned as an error. Unlike Insert it does not use INSERT OR IGNORE.

func (*ModelRecord[T]) InsertIgnore

func (m *ModelRecord[T]) InsertIgnore() int64

func (*ModelRecord[T]) IsClean

func (m *ModelRecord[T]) IsClean() bool

func (*ModelRecord[T]) IsDirty

func (m *ModelRecord[T]) IsDirty() bool

func (*ModelRecord[T]) Load

func (m *ModelRecord[T]) Load(id int64) T

func (*ModelRecord[T]) LoadE added in v1.6.0

func (m *ModelRecord[T]) LoadE(id int64) (T, error)

LoadE loads the record by id. It returns ErrNotFound when no row matches. On any error it returns the zero value of T, so callers should check err before using the result.

func (*ModelRecord[T]) Save

func (m *ModelRecord[T]) Save() int64

func (*ModelRecord[T]) SaveE added in v1.6.0

func (m *ModelRecord[T]) SaveE() (int64, error)

SaveE inserts when the ID is zero, otherwise updates. Unlike Save it returns the error.

func (*ModelRecord[T]) SetID

func (m *ModelRecord[T]) SetID(id int64)

func (*ModelRecord[T]) SetValue

func (m *ModelRecord[T]) SetValue(columnName string, value any) bool

func (*ModelRecord[T]) ToAPI

func (m *ModelRecord[T]) ToAPI() map[string]any

func (*ModelRecord[T]) ToJSON added in v1.3.0

func (m *ModelRecord[T]) ToJSON() string

func (*ModelRecord[T]) ToJSONIndent added in v1.3.0

func (m *ModelRecord[T]) ToJSONIndent() string

func (*ModelRecord[T]) ToWhere

func (m *ModelRecord[T]) ToWhere(query *bun.SelectQuery, args map[string]any) *bun.SelectQuery

ToWhere is the logging wrapper around ToWhereE. On error it logs and returns the query with the error's clauses skipped, so callers that ignore errors do not get an unfiltered query. Use ToWhereE to see the error.

func (*ModelRecord[T]) ToWhereE added in v1.6.0

func (m *ModelRecord[T]) ToWhereE(query *bun.SelectQuery, args map[string]any) (*bun.SelectQuery, error)

ToWhereE applies limits, order and where from args to query. Keys in "where" and "order" must be an alias or a real column name. Anything else returns ErrUnknownKey, so no raw SQL is accepted from keys.

Supported args:

"limits": int, or [offset, limit]
"order":  {"column": "asc"|"desc"}, or ["column", ...]
"where":  {"column": value}  (equality), or ["column", ...] for a column-only key
          (not supported: use query.Where directly for expressions)

func (*ModelRecord[T]) Update

func (m *ModelRecord[T]) Update() int64

func (*ModelRecord[T]) UpdateE added in v1.6.0

func (m *ModelRecord[T]) UpdateE() (int64, error)

UpdateE updates the dirty columns and returns the rows affected. It returns ErrNoChanges when nothing is dirty, so "no changes" is no longer confused with failure.

Directories

Path Synopsis
cmd
jackgodb command
modelgen command
internal

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL
⌂ 换网址