A reflection-based, adapter-driven ORM and query builder for Go, plus Studio — an embeddable web admin UI for browsing and editing your tables.
Repository: github.com/nonamecat19/go-orm · License: MIT
Looking for a working example? See the demo application — a full-stack shopping list app built on this library.
Supported databases: PostgreSQL, MySQL, SQLite, Microsoft SQL Server.
- Overview
- Repository layout
- Installation
- Quick start
- Defining entities
- Query builder API
- Relations and preloading
- Architecture
- Studio (web admin UI)
- Demo application
- Development
- Known issues and limitations
go-orm is built around three ideas:
- Entities are plain structs. Table and column mapping comes from an embedded
entities.Modelstruct plusdb:"..."struct tags. Table name comes from anInfo() stringmethod. - A fluent query builder.
CreateQueryBuilder(client).Where(...).OrderBy(...).Limit(...).FindMany(&out)— chainable methods accumulate state, terminal methods (FindMany,InsertOne,UpdateMany,DeleteMany, …) build and execute SQL. - Dialects live behind an adapter interface. Every SQL fragment (
WHERE,LIMIT,SET,INSERT, placeholder syntax) is produced by aDbAdapter. A sharedadapter-basepackage holds the ANSI-ish default implementation; each dialect adapter delegates to it and overrides only what differs.
Result binding is done with reflect and database/sql's Rows.Scan, writing directly into pointers to your struct fields.
This is a Go workspace (go.work) containing eight modules:
| Path | Module | Purpose |
|---|---|---|
core/ |
.../core |
Shared contracts: DbAdapter interface, ORMConfig, entities.Model/Entity, JoinClause, and reflection/string utilities. No third-party DB deps. |
adapter-base/ |
.../adapter-base |
Default SQL fragment generators shared by all dialects (free functions, not methods). |
adapter-postgres/ |
.../adapter-postgres |
AdapterPostgres — lib/pq, $N placeholders. |
adapter-mysql/ |
.../adapter-mysql |
AdapterMySQL — go-sql-driver/mysql, ? placeholders. |
adapter-sqlite/ |
.../adapter-sqlite |
AdapterSQLite — mattn/go-sqlite3, $N placeholders. |
adapter-mssql/ |
.../adapter-mssql |
AdapterMSSQL — denisenkom/go-mssqldb, @pN named params, OFFSET/FETCH NEXT. |
orm/ |
.../orm |
The user-facing API: client (connection) and querybuilder (fluent builder). Also hosts integration tests. |
studio/ |
.../studio |
Fiber + templ + htmx + Tailwind/daisyUI admin UI, mountable into any Fiber app. |
Adapter modules depend on core and adapter-base; orm depends on core; studio depends on core, orm, and adapter-postgres.
┌──────────┐
│ core │ DbAdapter, ORMConfig, Model, utils
└────┬─────┘
┌───────────┼────────────┐
│ │ │
┌──────▼─────┐ ┌───▼───┐ ┌─────▼──────┐
│adapter-base│ │ orm │ │ studio │
└──────┬─────┘ └───▲───┘ └─────┬──────┘
│ │ │
┌──────▼───────────┴──┐ │
│ adapter-{pg,mysql, ├─────────┘
│ sqlite,mssql} │
└─────────────────────┘
Each module is independently importable. Install the ORM plus the adapter(s) you need:
go get github.com/nonamecat19/go-orm/orm
go get github.com/nonamecat19/go-orm/core
go get github.com/nonamecat19/go-orm/adapter-postgresYou must also import the matching driver for its database/sql side effect:
| Adapter | Driver import | Driver name |
|---|---|---|
| Postgres | _ "github.com/lib/pq" |
postgres |
| MySQL | _ "github.com/go-sql-driver/mysql" |
mysql |
| SQLite | _ "github.com/mattn/go-sqlite3" |
sqlite3 |
| MSSQL | _ "github.com/denisenkom/go-mssqldb" |
sqlserver |
package main
import (
"fmt"
_ "github.com/lib/pq"
adapterpostgres "github.com/nonamecat19/go-orm/adapter-postgres/lib"
"github.com/nonamecat19/go-orm/core/lib/config"
"github.com/nonamecat19/go-orm/orm/lib/client"
"github.com/nonamecat19/go-orm/orm/lib/querybuilder"
)
func main() {
db := client.CreateClient(config.ORMConfig{
Host: "127.0.0.1",
Port: 15432,
User: "postgres",
Password: "root",
DbName: "orm",
SSLMode: false,
}, adapterpostgres.AdapterPostgres{})
var users []User
err := querybuilder.CreateQueryBuilder(db).
Where("id > ?", 12).
AndWhere("id < ?", 18).
Preload("orders").
Preload("role").
OrderBy("id DESC").
Limit(8).
Offset(2).
FindMany(&users)
if err != nil {
panic(err)
}
fmt.Println(len(users))
}type ORMConfig struct {
Host string
Port int
User string
Password string
DbName string
SSLMode bool
Path string // SQLite only: file path, used verbatim as the DSN
}Connection strings per adapter:
- Postgres:
host=%s port=%d user=%s password=%s dbname=%s sslmode=disable - MySQL:
%s:%s@tcp(%s:%d)/%s?parseTime=true - MSSQL:
sqlserver://%s:%s@%s:%d?database=%s - SQLite:
config.Path
Note:
SSLModeis currently present in the struct but not honoured — the Postgres DSN hardcodessslmode=disable.
client.CreateClient(cfg, adapter) DbClient opens the pool via sql.Open (it calls log.Fatal on error rather than returning it). Accessors: GetDb() *sql.DB, GetConfig(), GetAdapter().
An entity is a struct that:
- embeds
entities.Modelas its first field, - tags each column with
db:"column_name", - implements
Info() stringreturning the table name.
import "github.com/nonamecat19/go-orm/core/lib/entities"
type Role struct {
entities.Model
Name string `db:"name" json:"name,omitempty"`
Users []User `db:"users" json:"users,omitempty" relation:"role_id"`
}
func (r Role) Info() string { return "roles" }
type User struct {
entities.Model
Name string `db:"name" json:"name,omitempty"`
Email string `db:"email" json:"email,omitempty"`
Gender string `db:"gender" json:"gender,omitempty"`
Orders []Order `db:"orders" json:"orders,omitempty" relation:"user_id"`
RoleId *int64 `db:"role_id" json:"roleId,omitempty"`
Role *Role `db:"role" relation:"role_id" json:"role,omitempty"`
}
func (u User) Info() string { return "users" }
type Order struct {
entities.Model
Count int64 `db:"count" json:"count,omitempty"`
UserId *int64 `db:"user_id" json:"userId,omitempty"`
User *User `db:"user" relation:"user_id" json:"user,omitempty"`
OrderDate time.Time `db:"order_date" json:"orderDate,omitempty"`
}
func (o Order) Info() string { return "orders" }entities.Model supplies the system columns present on every table:
type Model struct {
ID int64 `db:"id" json:"id"`
CreatedAt *time.Time `db:"created_at" json:"created_at"`
UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
DeletedAt *time.Time `db:"deleted_at" json:"deleted_at"`
}| Tag | Meaning |
|---|---|
db:"col" without relation |
A real column. Included in SELECT/INSERT/UPDATE field lists. |
db:"name" with relation:"fk" |
A relation field. Excluded from column lists; loaded only via Preload("name"). |
relation names the foreign-key column:
- Many-to-one (field is
*T):relationis the FK column on this entity (User.Role→relation:"role_id", matchingUser.RoleId). - One-to-many (field is
[]T):relationis the FK column on the related table (User.Orders→relation:"user_id", a column oforders).
querybuilder.CreateQueryBuilder(client) *QueryBuilder starts a builder with limit: -1, offset: -1 (meaning "unset"). Chainable methods return *QueryBuilder; terminal methods return error (or (*sql.Rows, error)).
| Chainable | Effect |
|---|---|
Select(cols ...string) |
Restrict selected columns. Names are fully qualified ("users.id"). |
Where(cond string, args ...any) |
Sets the WHERE clause to (cond). |
AndWhere(cond string, args ...any) |
Appends AND (cond). |
OrWhere(cond string, args ...any) |
Appends OR (cond). |
OrderBy(fields ...string) |
Appends to ORDER BY (pass e.g. "id DESC"). |
Limit(n int) |
Row limit. |
Offset(n int) |
Row offset. |
LeftJoinAndSelect(table, condition string, selectFields ...string) |
Adds a LEFT JOIN. |
Preload(cols ...string) |
Eager-load relation fields by their db tag. |
SetValues(map[string]any) |
Column→value map for UpdateMany. |
Debug() |
Print the final SQL and args. |
| Terminal | Signature |
|---|---|
FindMany |
FindMany(out any) error — out must be *[]Entity |
FindOne |
currently a no-op stub |
InsertOne |
InsertOne(entity any) error — value, not pointer |
InsertMany |
InsertMany(entities any) error — a slice |
InsertMap |
InsertMap(entity any, fields map[string]any) error |
UpdateMany |
UpdateMany(entity any) error — pointer; requires Where + SetValues |
UpdateMap |
UpdateMap(entity any, fields map[string]any) error — requires Where |
DeleteMany |
DeleteMany(entity any) error — pointer; requires Where |
ExecuteRaw |
ExecuteRaw(sql string, args ...any) (*sql.Rows, error) |
ExecuteQuery |
ExecuteQuery(query string, args ...any) (*sql.Rows, error) |
var users []User
err := querybuilder.CreateQueryBuilder(db).
Select("users.id", "users.name", "users.gender", "users.email").
Where("email = ?", "test@gmail.com").
OrderBy("id ASC").
Offset(0).
Limit(1).
FindMany(&users)FindMany requires a pointer to a slice. It:
- reflects the element type to get the table name and column list,
- builds the query (see pipeline),
- scans each row into a fresh struct via field-address pointers,
- runs any
Preloads.
Select intersects with the full column list, so unknown/unqualified names silently drop columns — always use table.column form.
// Single struct (note: value, not pointer)
err := querybuilder.CreateQueryBuilder(db).
InsertOne(User{Name: "testName", Email: "test@gmail.com", Gender: "non_binary", RoleId: &roleId})
// Bulk — one multi-row INSERT statement
err = querybuilder.CreateQueryBuilder(db).InsertMany([]User{u1, u2, u3})
// Partial insert from a map; keys are validated against the entity's db tags
err = querybuilder.CreateQueryBuilder(db).
InsertMap(User{}, map[string]any{"name": "Ann", "email": "ann@example.com"})Generated form: INSERT INTO users (name, email, ...) VALUES (?, ?, ...), (?, ?, ...); with placeholders normalized per dialect. *T fields serialize as their pointee or nil.
Note that only non-relation, non-system columns are written — id/created_at/updated_at/deleted_at are left to the database.
Both update paths refuse to run without a Where, guarding against a full-table update.
err := querybuilder.CreateQueryBuilder(db).
Where("role_id = 3").
SetValues(map[string]any{"name": "testName"}).
UpdateMany(&User{})
// or, with map-field validation against the entity:
err = querybuilder.CreateQueryBuilder(db).
Where("id = ?", 42).
UpdateMap(User{}, map[string]any{"name": "testName"})Also requires a Where.
err := querybuilder.CreateQueryBuilder(db).
Where("role_id = ?", 1).
OrWhere("gender <> 'female'").
DeleteMany(&User{})rows, err := querybuilder.CreateQueryBuilder(db).
ExecuteRaw("SELECT id, name FROM users WHERE id > ?", 10)ExecuteRaw runs the SQL through the adapter's placeholder normalizer first, so ? works across dialects. Scanning is up to you.
.Debug() prints the fully prepared statement (green, lightly pretty-printed with newlines before SELECT/FROM/JOIN/WHERE/…) and the argument slice (yellow) to stdout before execution.
Preload avoids N+1 by issuing one extra query per relation, then stitching results in memory.
var users []User
err := querybuilder.CreateQueryBuilder(db).
Preload("orders"). // one-to-many: []Order
Preload("role"). // many-to-one: *Role
FindMany(&users)The argument is the relation field's db tag. Dispatch is by field kind:
*T(many-to-one) —preloadRelationPointer: collects the distinct FK values from the parent rows (relationcolumn on the parent), runsSELECT * FROM <table> WHERE id IN (...), maps byid, and assigns pointers back.[]T(one-to-many) —preloadRelationSlice: collects distinct parentids, runsSELECT <cols> FROM <table> WHERE <relation> IN (...), groups rows by the FK value, and assigns each parent its slice.
Anything else returns "<name> must be relation field".
Both paths currently assume the key type is int64 or *int64; other key types return an error.
LeftJoinAndSelect is a separate, lower-level mechanism: it appends a real LEFT JOIN to the statement and reserves scan slots for the joined columns — but those values are scanned into throwaway new(any) placeholders, so joins currently only affect the SQL and row multiplicity, not the materialized structs. Use Preload for populating relations.
core defines the contract; adapter-base implements the default SQL; each dialect adapter is a thin struct whose methods forward to adapter-base except where the dialect differs; orm composes a client + builder against the DbAdapter interface only, so it never imports a dialect package.
Defined in core/lib/adapter/adapter.go — every dialect implements all of it:
type DbAdapter interface {
// connection
GetConnString(config config.ORMConfig) string
GetDbDriver() string
// statement heads
DeleteFromTable(tableName string) string
Update(tableName string) string
Insert(tableName string, fieldNames []string, values []any, args []any) (string, []any)
// clause builders
PrepareOrderBy(query string, orderBy []string) string
PrepareWhere(query string, where string) string
PrepareLimit(query string, limit int) string
PrepareOffset(query string, offset int) string
PrepareSet(query string, set map[string]any, args []any) (string, []any)
PrepareJoins(query string, joins []query.JoinClause) string
// where composition
Where(condition string, args []any) string
AndWhere(condition string, where string, args []any) string
OrWhere(condition string, where string, args []any) string
// select construction
GetReadQuery(tableName string, fields []string, fromSubquery string) string
GetFromSubquery(tableName string, where string, orderBy []string, limit int, offset int) string
GetSelectQuery(selectValue string, fromValue string) string
GetSelectWhereIn(tableName string, selectValue string, fieldName string, fieldValues []string) string
// formatting
JoinFields(fields []string) string
JoinFieldsStrictly(fields []string) string
NormalizeSqlWithArgs(sql string, args []any) string
PrepareQueryAndArgs(query string, args []any) (string, []any)
}A FindMany builds a subquery-wrapped SELECT so that WHERE/ORDER BY/LIMIT/OFFSET apply to the base table before joins fan rows out:
GetFromSubquery→SELECT * FROM users WHERE (...) ORDER BY id DESC LIMIT 8 OFFSET 2GetReadQuerywraps it and aliases every column strictly:SELECT users.id AS "users.id", users.name AS "users.name", … FROM (<subquery>) AS usersPrepareJoinsappends anyLEFT JOIN … ON ….
JoinFieldsStrictly is what produces the col AS "col" aliasing — it keeps qualified names stable through the subquery wrap and makes the column order deterministic for Rows.Scan. JoinFields is the plain , -join (also mapping the literal string "<nil>" to NULL).
Because scanning is positional, the field-pointer list is assembled in the same order as the SELECT list: system fields first (id, created_at, updated_at, deleted_at), then entity fields, then one throwaway pointer per joined select column.
The builder always writes ? internally. NormalizeSqlWithArgs(sql, args) rewrites them, starting the counter at len(args)+1 so that fragments appended later (a second AndWhere, a SET clause) continue the numbering:
| Adapter | Placeholder | Final arg handling |
|---|---|---|
| Postgres | $1, $2, … |
args passed through unchanged |
| SQLite | $1, $2, … |
args passed through unchanged |
| MySQL | ? (no rewrite) |
args passed through unchanged |
| MSSQL | @p1, @p2, … |
PrepareQueryAndArgs wraps each arg in sql.Named("pN", v) |
Everything except the following forwards to adapter-base:
- MySQL —
NormalizeSqlWithArgsis the identity function (MySQL uses?natively);Insert,Where/AndWhere/OrWhere, andPrepareSetare reimplemented as methods so they call the MySQL normalizer rather than the base one. - MSSQL —
@pNplaceholders;PrepareLimitemitsFETCH NEXT n ROWS ONLYandPrepareOffsetemitsOFFSET n ROWS;GetFromSubqueryreorders clause application toWHERE → ORDER BY → OFFSET → LIMIT(T-SQL requiresOFFSETbeforeFETCH, and both requireORDER BY); args becomesql.Namedvalues. - Postgres / SQLite — pure delegation to
adapter-base; they differ only inGetConnStringandGetDbDriver.
| Function | Purpose |
|---|---|
ExtractTableAndFields(entity, prefix) |
Returns (tableName, entityFields, systemFields, error); prefix=true qualifies names as table.col. Errors unless the entity is a pointer to a struct implementing Info() string. |
ExtractTableAndFieldsFromType(t, prefix) |
Same, from a reflect.Type. |
ExtractFields(t) |
All db tags without a relation tag. |
GetSystemFields() |
The four entities.Model columns. |
GetFieldNameByTagValue(t, tag) |
Struct field name for a given db tag value. |
GetModelFields(model) |
map[dbTag]*field. |
StringifyReflectValue(v) |
Human-readable rendering used by Studio (time.Time → 02/01/2006 15:04:05, nil → NULL). |
ToSnakeCase / ToHumanCase |
Naming helpers. |
If, Map, MapWithIndex, Chunk, Contains, StringsIntersection, AddPrefix, GenerateParamsSlice |
Generic slice/string helpers. |
Studio is a database admin panel rendered server-side with templ, driven by htmx, styled with Tailwind + daisyUI, and served by Fiber. CSS and JS are inlined into the base template at build time, so the binary ships without external asset requests.
import (
"github.com/gofiber/fiber/v2"
coreEntities "github.com/nonamecat19/go-orm/core/lib/entities"
"github.com/nonamecat19/go-orm/studio/lib/app"
)
server := fiber.New()
tables := []coreEntities.Entity{ Order{}, Role{}, User{} }
app.AddStudioFiberGroup(server, tables, dbClient, "/studio")
server.Listen(":8080")AddStudioFiberGroup(app, tables, client, prefix) registers a Fiber group and stashes {tableMap, tables, client, prefix} in c.Locals("data") for handlers. A middleware rewrites emitted URLs (href="/…", hx-get="/tables/…", form actions, …) to be prefix-aware by string replacement on the response body.
| Method | Path (relative to prefix) | Handler |
|---|---|---|
| GET | / |
Table list |
| GET | /tables/:id |
Table detail — sortable grid, add/edit/delete controls |
| GET | /settings |
Settings (daisyUI theme picker) |
| POST | /api/tables/:table_id/records |
Create record |
| PUT | /api/tables/:table_id/records/:record_id |
Update record |
| DELETE | /api/tables/:table_id/records/:record_id |
Delete record |
| GET | /assets/* |
Static assets (packr box) |
Detail pages accept ?sort=<column>&dir=<asc\|desc> and support htmx partial rendering: an HX-Request: true with HX-Target: table-container re-renders only the grid. Mutating endpoints respond with an HX-Redirect back to the table.
Column metadata (name, Go type, nullability inferred from a leading *) is derived reflectively via buildFieldInfo, and cell values are rendered through StringifyReflectValue.
studio/
├── cmd/main.go # standalone demo server (hardcoded Postgres config)
├── cmd/embed_assets.go # asset-inlining CLI (also package main — see Known issues)
├── lib/app/app.go # AddStudioFiberGroup — the public entry point
├── internal/config/ # StudioConfig (ServerAddr, default ":8080")
├── internal/handlers/ # tablesGroup/*, settingsGroup/*
├── internal/model/ # FieldInfo view model
├── internal/utils/ # SharedData accessors, templ Render helper
├── internal/view/ # .templ sources + generated *_templ.go
│ ├── layout/ components/ recordForm/ tables/ settings/ icons/
├── web/ # Tailwind + esbuild sources
│ ├── src/main.css src/main.js tailwind.config.js package.json
│ └── public/assets/ # built main.css / main.js
├── scripts/embed_assets*.sh # inline built CSS/JS into base.templ / base_templ.go
└── Makefile
cd studio
make install-deps # npm install + go mod download
make build # css → js → embed → templ generate → go build -o ./bin/myapp
make watch # air + templ --watch + tailwind/esbuild watchers (parallel)
make fmt # templ fmt internal/viewRequires Node/npm and the templ CLI. make watch proxies through templ's dev proxy on top of the app at localhost:8080.
go-orm-test-app is a full-stack shopping list application included in this repository. Its backend consumes this library as a published dependency. It is the reference for how the pieces fit together in a real project:
| Concern | Where to look |
|---|---|
Client setup from env vars (ORMConfig, AdapterPostgres) |
backend/database/database.go |
Entity definitions with db: tags and a one-to-many relation: |
backend/entities/ |
| Query builder usage — reads, inserts, updates, deletes, preloading | backend/services/ |
Mounting Studio into a Fiber app (AddStudioFiberGroup) |
backend/main.go |
The app pairs a Go/Fiber REST API with a React + Vite frontend and Postgres, and runs end to end via Docker Compose.
docker-compose.yml brings up all three server databases used by the integration tests (SQLite is a local file):
docker compose up -d| Service | Image | Host port | Credentials | Database |
|---|---|---|---|---|
postgres_db |
postgres:15-alpine |
15432 | postgres / root |
orm |
mysql_db |
mysql:8.0 |
3306 | admin / root |
orm |
mssql_db |
mssql/server:2022-latest |
1433 | sa / 1StrongPwd!! |
master |
The compose file also defines an mssql_configurator one-shot container that runs ./init/init.sql against SQL Server once it is healthy. That init/ directory is not committed — create it (or drop the service) if you need MSSQL bootstrapping beyond what the test dumps do.
Integration tests live in orm/tests/db/ — one file per dialect, each exercising the same five scenarios defined in orm/tests/test-utils/queries.go:
FindUsersWithOrdersAndRole, FindOrdersWithUsers, DeleteUsers, InsertUser, UpdateUsers.
Each test first calls PrepareDB, which reads orm/tests/dumps/<driver>.sql (postgres.sql, mysql.sql, sqlite3.sql, sqlserver.sql — named after GetDbDriver()), splits it on ;, and executes every statement. The dumps drop and recreate roles/users/orders and seed 4 roles, 20 users, and a set of orders.
Results are serialized to JSON and compared against golden files in orm/tests/outputs/ with assert.JSONEq.
cd orm
go test ./tests/db/... # all dialects (needs docker compose up)
go test ./tests/db/ -run TestPostgres # one dialect
go test ./tests/db/ -run TestSQLite # no containers neededThese are true integration tests against live databases — there is no unit-test suite for query generation.
Four GitHub Actions workflows (.github/workflows/), all on push/PR to main, all running go work sync first and then iterating the module list adapter-base adapter-mysql adapter-mssql adapter-postgres adapter-sqlite core orm:
| Workflow | What it runs |
|---|---|
build.yml |
go build -v ./... per module |
code-formatting.yml |
gofmt -l over every .go file, fails if any output |
linting.yml |
golangci-lint run --timeout=5m ./... per module |
static-analysis.yml |
staticcheck ./... per module |
Note the module list excludes studio, so Studio is not built or linted in CI.
These are current, verifiable characteristics of the codebase — worth knowing before you build on it.
Build / structure
studio/cmd/fails to build: bothmain.goandembed_assets.godeclarepackage mainwith afunc main, givingmain redeclared in this block.embed_assets.gois a build-time helper and needs its own directory or a build tag. Thestudio/lib/apppackage itself builds fine, so mounting Studio into your own app works; only the bundled demo binary is broken.orm/tests/test-utils/queries.goimports entities from the publishedgithub.com/nonamecat19/go-orm/app/entitiesmodule (pinned atv0.0.0-20250517165903-…), not from the localorm/tests/entities/package — which is therefore dead code, and the two can drift.studio/cmd/main.goimports the same remote package. Tests resolve it only becausestudio'sgo.modpulls it into the workspace module graph.
API
FindOne()is an empty stub — it takes no arguments and does nothing. UseFindManywithLimit(1).DbClient.Query(query, args...)passesargsas a single slice argument todb.Queryrather than expanding it, so it will not bind parameters correctly.QueryBuilder.ExecuteQueryis the working path.LeftJoinAndSelectscans joined columns into discardednew(any)values; joined data never reaches your structs.config.SSLModeis ignored — the Postgres DSN hardcodessslmode=disable.CreateClientcallslog.Fatalonsql.Openfailure instead of returning the error.- There is no transaction API, no context/cancellation support (
QueryContextis never used), no migrations, no schema introspection, no soft-delete handling (DeletedAtis stored and read but never filtered on), and no connection-pool configuration. Wherereplaces rather than appends: callingWheretwice discards the first condition. UseAndWhere/OrWherefor composition.- Preload key handling assumes
int64/*int64primary and foreign keys. PrepareSetiterates amap[string]anytwice — once to buildcol = ?clauses and once to append values. Go map iteration order is randomized per range, so the SET clause order and the argument order can disagree, binding values to the wrong columns. Single-key updates (the tested path) are unaffected.
Safety
Where/OrderBy/Selectfragments and join conditions are interpolated into SQL as raw strings; only?arguments are bound as parameters. Never build those fragments from untrusted input.- Studio's
getTableRecordsinterpolates thesortanddirquery parameters straight intoORDER BYwith no allowlist — that is a SQL injection vector on any exposed Studio instance. Studio also ships no authentication or authorization of any kind; mount it behind your own auth middleware and never expose it publicly. - Studio writes all form values as strings and relies on the database to coerce types.
- Studio handlers call
.Debug()unconditionally, printing every generated statement and its arguments to stdout.