Files
zeekay 48e0c71a1d feat: add xorm-compatible engine layer, SQL driver, and naming conventions
Add engine/ package with xorm-compatible Session/Engine API:
- Fluent Where/And/Or/Get/Find/Insert/Update/Delete/Count/Exist
- Composite primary key support (PK type)
- Schema sync via Sync2 (struct → DDL with xorm tag parsing)
- SQL query builder (In/NotIn/Between/Like/Expr)
- Column selection (Cols/AllCols/Omit)
- Transaction support
- Raw SQL execution
- JSON type conversion via standard json.Marshal (fixes xorm whitespace bug)

Add db/sql.go with abstract SQL driver (pgx/v5 backend):
- Full db.DB interface implementation
- Connection pooling, transactions, multi-tenant support
- JSONB entity storage with parameterized $N queries

Add names/ package with naming convention mappers:
- SnakeMapper, SameMapper, PrefixMapper, GonicMapper

75 new tests covering all engine, builder, schema, and naming operations.
2026-02-27 20:30:42 -08:00

179 lines
4.4 KiB
Go

// Package names provides naming convention mappers for converting between
// Go struct field names and database column/table names.
// Compatible with xorm's naming interfaces.
package names
import (
"strings"
"unicode"
)
// Mapper defines the interface for name conversion between Go and SQL.
type Mapper interface {
Obj2Table(string) string
Table2Obj(string) string
}
// SnakeMapper converts CamelCase to snake_case and back.
type SnakeMapper struct{}
func (SnakeMapper) Obj2Table(name string) string {
return camelToSnake(name)
}
func (SnakeMapper) Table2Obj(name string) string {
return snakeToCamel(name)
}
// SameMapper performs no conversion — names pass through unchanged.
type SameMapper struct{}
func (SameMapper) Obj2Table(name string) string { return name }
func (SameMapper) Table2Obj(name string) string { return name }
// PrefixMapper adds a prefix to table names.
type PrefixMapper struct {
Mapper Mapper
Prefix string
}
func (m PrefixMapper) Obj2Table(name string) string {
return m.Prefix + m.Mapper.Obj2Table(name)
}
func (m PrefixMapper) Table2Obj(name string) string {
name = strings.TrimPrefix(name, m.Prefix)
return m.Mapper.Table2Obj(name)
}
// GonicMapper is like SnakeMapper but treats common initialisms as single
// words (e.g. "ID" → "id", "HTTP" → "http", "URL" → "url").
type GonicMapper struct {
Acronyms map[string]bool
}
// NewGonicMapper returns a GonicMapper with common Go initialisms.
func NewGonicMapper() GonicMapper {
return GonicMapper{
Acronyms: map[string]bool{
"ID": true, "HTTP": true, "URL": true, "API": true,
"SQL": true, "SSH": true, "JSON": true, "XML": true,
"HTML": true, "CSS": true, "DNS": true, "RPC": true,
"TCP": true, "UDP": true, "IP": true, "TLS": true,
"SSL": true, "UUID": true, "URI": true, "EOF": true,
},
}
}
func (m GonicMapper) Obj2Table(name string) string {
return gonicCamelToSnake(name, m.Acronyms)
}
func (m GonicMapper) Table2Obj(name string) string {
return snakeToCamel(name)
}
// camelToSnake converts CamelCase to snake_case.
func camelToSnake(s string) string {
var result strings.Builder
result.Grow(len(s) + 4)
for i, r := range s {
if unicode.IsUpper(r) {
if i > 0 {
prev := rune(s[i-1])
if unicode.IsLower(prev) || unicode.IsDigit(prev) {
result.WriteByte('_')
} else if unicode.IsUpper(prev) && i+1 < len(s) && unicode.IsLower(rune(s[i+1])) {
result.WriteByte('_')
}
}
result.WriteRune(unicode.ToLower(r))
} else {
result.WriteRune(r)
}
}
return result.String()
}
// gonicCamelToSnake converts CamelCase to snake_case, treating known
// acronyms as single tokens.
func gonicCamelToSnake(s string, acronyms map[string]bool) string {
var result strings.Builder
result.Grow(len(s) + 4)
runes := []rune(s)
for i := 0; i < len(runes); i++ {
// Check for known acronym at this position
if unicode.IsUpper(runes[i]) {
found := false
for acr := range acronyms {
acrRunes := []rune(acr)
if i+len(acrRunes) <= len(runes) {
match := true
for j, ar := range acrRunes {
if runes[i+j] != ar {
match = false
break
}
}
if match {
// Check that the acronym isn't followed by a lowercase letter
// (which would mean it's not a standalone acronym)
atEnd := i+len(acrRunes) >= len(runes)
nextIsUpper := !atEnd && unicode.IsUpper(runes[i+len(acrRunes)])
nextIsNonAlpha := !atEnd && !unicode.IsLetter(runes[i+len(acrRunes)])
if atEnd || nextIsUpper || nextIsNonAlpha {
if i > 0 {
result.WriteByte('_')
}
result.WriteString(strings.ToLower(acr))
i += len(acrRunes) - 1
found = true
break
}
}
}
}
if found {
continue
}
}
if unicode.IsUpper(runes[i]) {
if i > 0 {
prev := runes[i-1]
if unicode.IsLower(prev) || unicode.IsDigit(prev) {
result.WriteByte('_')
} else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) {
result.WriteByte('_')
}
}
result.WriteRune(unicode.ToLower(runes[i]))
} else {
result.WriteRune(runes[i])
}
}
return result.String()
}
// snakeToCamel converts snake_case to CamelCase.
func snakeToCamel(s string) string {
parts := strings.Split(s, "_")
var result strings.Builder
result.Grow(len(s))
for _, p := range parts {
if p == "" {
continue
}
runes := []rune(p)
runes[0] = unicode.ToUpper(runes[0])
result.WriteString(string(runes))
}
return result.String()
}