chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
This commit is contained in:
+163
@@ -0,0 +1,163 @@
|
||||
# Model Package
|
||||
|
||||
The `model` package provides a structured data storage interface with CRUD operations, query filtering, and multiple database backends.
|
||||
|
||||
Unlike the `store` package (which is a raw KV abstraction), `model` provides structured data access with schema awareness, WHERE queries, ordering, pagination, and indexes.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"go-micro.dev/v5/model"
|
||||
)
|
||||
|
||||
// Define your model with struct tags
|
||||
type User struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Name string `json:"name" model:"index"`
|
||||
Email string `json:"email"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
|
||||
// Create a model and register your type
|
||||
db := model.NewModel()
|
||||
db.Register(&User{})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create
|
||||
db.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30})
|
||||
|
||||
// Read
|
||||
user := &User{}
|
||||
db.Read(ctx, "1", user)
|
||||
fmt.Println(user.Name) // "Alice"
|
||||
|
||||
// Update
|
||||
user.Name = "Alice Smith"
|
||||
db.Update(ctx, user)
|
||||
|
||||
// Delete
|
||||
db.Delete(ctx, "1", &User{})
|
||||
```
|
||||
|
||||
## Struct Tags
|
||||
|
||||
| Tag | Description | Example |
|
||||
|-----|-------------|---------|
|
||||
| `model:"key"` | Primary key field | `ID string \`model:"key"\`` |
|
||||
| `model:"index"` | Create an index on this field | `Name string \`model:"index"\`` |
|
||||
| `json:"name"` | Column name in the database | `Name string \`json:"name"\`` |
|
||||
|
||||
If no `model:"key"` tag is found, the package defaults to a field with `json:"id"` or column name `id`.
|
||||
|
||||
## Querying
|
||||
|
||||
```go
|
||||
// Filter by field value
|
||||
var users []*User
|
||||
db.List(ctx, &users, model.Where("name", "Alice"))
|
||||
|
||||
// Comparison operators
|
||||
db.List(ctx, &users, model.WhereOp("age", ">", 25))
|
||||
db.List(ctx, &users, model.WhereOp("name", "LIKE", "Ali%"))
|
||||
|
||||
// Ordering
|
||||
db.List(ctx, &users, model.OrderAsc("name"))
|
||||
db.List(ctx, &users, model.OrderDesc("age"))
|
||||
|
||||
// Pagination
|
||||
db.List(ctx, &users, model.Limit(10), model.Offset(20))
|
||||
|
||||
// Combine
|
||||
db.List(ctx, &users,
|
||||
model.Where("status", "active"),
|
||||
model.WhereOp("age", ">=", 18),
|
||||
model.OrderDesc("created_at"),
|
||||
model.Limit(25),
|
||||
)
|
||||
|
||||
// Count
|
||||
total, _ := db.Count(ctx, &User{})
|
||||
active, _ := db.Count(ctx, &User{}, model.Where("status", "active"))
|
||||
```
|
||||
|
||||
## Backends
|
||||
|
||||
### Memory (Development & Testing)
|
||||
|
||||
```go
|
||||
import "go-micro.dev/v5/model"
|
||||
|
||||
db := model.NewModel()
|
||||
```
|
||||
|
||||
In-memory storage. No persistence. Fast. Good for tests and prototyping.
|
||||
|
||||
### SQLite (Development & Single-Node Production)
|
||||
|
||||
```go
|
||||
import "go-micro.dev/v5/model/sqlite"
|
||||
|
||||
db := sqlite.New("app.db") // File-based
|
||||
db := sqlite.New(":memory:") // In-memory (testing)
|
||||
```
|
||||
|
||||
Embedded SQL database. Zero external dependencies. Supports WHERE, indexes, ordering natively.
|
||||
|
||||
### Postgres (Production)
|
||||
|
||||
```go
|
||||
import "go-micro.dev/v5/model/postgres"
|
||||
|
||||
db := postgres.New("postgres://user:pass@localhost/mydb?sslmode=disable")
|
||||
```
|
||||
|
||||
Full PostgreSQL support. Best for production with rich query capabilities.
|
||||
|
||||
## Table Names
|
||||
|
||||
By default, the table name is the lowercase struct name + "s" (e.g., `User` → `users`). Override with `model.WithTable`:
|
||||
|
||||
```go
|
||||
db.Register(&User{}, model.WithTable("app_users"))
|
||||
```
|
||||
|
||||
## Model Interface
|
||||
|
||||
All backends implement the `model.Model` interface:
|
||||
|
||||
```go
|
||||
type Model interface {
|
||||
Init(...Option) error
|
||||
Register(v interface{}, opts ...RegisterOption) error
|
||||
Create(ctx context.Context, v interface{}) error
|
||||
Read(ctx context.Context, key string, v interface{}) error
|
||||
Update(ctx context.Context, v interface{}) error
|
||||
Delete(ctx context.Context, key string, v interface{}) error
|
||||
List(ctx context.Context, result interface{}, opts ...QueryOption) error
|
||||
Count(ctx context.Context, v interface{}, opts ...QueryOption) (int64, error)
|
||||
Close() error
|
||||
String() string
|
||||
}
|
||||
```
|
||||
|
||||
## Model vs Store
|
||||
|
||||
| Feature | `store` | `model` |
|
||||
|---------|---------|---------|
|
||||
| Data format | Raw `[]byte` | Go structs |
|
||||
| Queries | Key prefix/suffix only | WHERE, operators, LIKE |
|
||||
| Ordering | None | ORDER BY field ASC/DESC |
|
||||
| Pagination | Limit/Offset on keys | Limit/Offset on results |
|
||||
| Indexes | None | Via `model:"index"` tag |
|
||||
| Schema | None (schemaless KV) | Auto-created from struct |
|
||||
| Backends | Memory, File, MySQL, Postgres, NATS | Memory, SQLite, Postgres |
|
||||
| Use case | Config, sessions, cache | Application data, entities |
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go test ./model/...
|
||||
```
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type memoryModel struct {
|
||||
mu sync.RWMutex
|
||||
schemas map[string]*Schema
|
||||
types map[reflect.Type]*Schema
|
||||
tables map[string]map[string]map[string]any // table -> key -> fields
|
||||
}
|
||||
|
||||
func newMemoryModel(opts ...Option) Model {
|
||||
return &memoryModel{
|
||||
schemas: make(map[string]*Schema),
|
||||
types: make(map[reflect.Type]*Schema),
|
||||
tables: make(map[string]map[string]map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *memoryModel) Init(opts ...Option) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryModel) Register(v interface{}, opts ...RegisterOption) error {
|
||||
schema := BuildSchema(v, opts...)
|
||||
t := ResolveType(v)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.schemas[schema.Table] = schema
|
||||
m.types[t] = schema
|
||||
if _, ok := m.tables[schema.Table]; !ok {
|
||||
m.tables[schema.Table] = make(map[string]map[string]any)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryModel) schema(v interface{}) (*Schema, error) {
|
||||
t := ResolveType(v)
|
||||
m.mu.RLock()
|
||||
s, ok := m.types[t]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, ErrNotRegistered
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (m *memoryModel) Create(ctx context.Context, v interface{}) error {
|
||||
schema, err := m.schema(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := StructToMap(schema, v)
|
||||
key := KeyValue(schema, v)
|
||||
if key == "" {
|
||||
return fmt.Errorf("model: key field %q not set", schema.Key)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
tbl := m.tables[schema.Table]
|
||||
if _, exists := tbl[key]; exists {
|
||||
return ErrDuplicateKey
|
||||
}
|
||||
row := make(map[string]any, len(fields))
|
||||
for k, v := range fields {
|
||||
row[k] = v
|
||||
}
|
||||
tbl[key] = row
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryModel) Read(ctx context.Context, key string, v interface{}) error {
|
||||
schema, err := m.schema(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
tbl := m.tables[schema.Table]
|
||||
row, ok := tbl[key]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
MapToStruct(schema, row, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryModel) Update(ctx context.Context, v interface{}) error {
|
||||
schema, err := m.schema(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := StructToMap(schema, v)
|
||||
key := KeyValue(schema, v)
|
||||
if key == "" {
|
||||
return fmt.Errorf("model: key field %q not set", schema.Key)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
tbl := m.tables[schema.Table]
|
||||
if _, ok := tbl[key]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
row := make(map[string]any, len(fields))
|
||||
for k, v := range fields {
|
||||
row[k] = v
|
||||
}
|
||||
tbl[key] = row
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryModel) Delete(ctx context.Context, key string, v interface{}) error {
|
||||
schema, err := m.schema(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
tbl := m.tables[schema.Table]
|
||||
if _, ok := tbl[key]; !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(tbl, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryModel) List(ctx context.Context, result interface{}, opts ...QueryOption) error {
|
||||
// result must be *[]*T
|
||||
rv := reflect.ValueOf(result)
|
||||
if rv.Kind() != reflect.Pointer || rv.Elem().Kind() != reflect.Slice {
|
||||
return fmt.Errorf("model: result must be a pointer to a slice")
|
||||
}
|
||||
sliceVal := rv.Elem()
|
||||
elemType := sliceVal.Type().Elem() // *T
|
||||
structType := elemType
|
||||
if structType.Kind() == reflect.Pointer {
|
||||
structType = structType.Elem()
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
s, ok := m.types[structType]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return ErrNotRegistered
|
||||
}
|
||||
|
||||
q := ApplyQueryOptions(opts...)
|
||||
|
||||
m.mu.RLock()
|
||||
tbl := m.tables[s.Table]
|
||||
|
||||
var rows []map[string]any
|
||||
for _, row := range tbl {
|
||||
if matchFilters(row, q.Filters) {
|
||||
cp := make(map[string]any, len(row))
|
||||
for k, v := range row {
|
||||
cp[k] = v
|
||||
}
|
||||
rows = append(rows, cp)
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
if q.OrderBy != "" {
|
||||
sortRows(rows, q.OrderBy, q.Desc)
|
||||
}
|
||||
if q.Offset > 0 && uint(len(rows)) > q.Offset {
|
||||
rows = rows[q.Offset:]
|
||||
} else if q.Offset > 0 {
|
||||
rows = nil
|
||||
}
|
||||
if q.Limit > 0 && uint(len(rows)) > q.Limit {
|
||||
rows = rows[:q.Limit]
|
||||
}
|
||||
|
||||
results := reflect.MakeSlice(sliceVal.Type(), len(rows), len(rows))
|
||||
for i, row := range rows {
|
||||
vp := reflect.New(structType)
|
||||
MapToStruct(s, row, vp.Interface())
|
||||
if elemType.Kind() == reflect.Pointer {
|
||||
results.Index(i).Set(vp)
|
||||
} else {
|
||||
results.Index(i).Set(vp.Elem())
|
||||
}
|
||||
}
|
||||
sliceVal.Set(results)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryModel) Count(ctx context.Context, v interface{}, opts ...QueryOption) (int64, error) {
|
||||
schema, err := m.schema(v)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
q := ApplyQueryOptions(opts...)
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
tbl := m.tables[schema.Table]
|
||||
var count int64
|
||||
for _, row := range tbl {
|
||||
if matchFilters(row, q.Filters) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (m *memoryModel) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryModel) String() string {
|
||||
return "memory"
|
||||
}
|
||||
|
||||
// matchFilters returns true if the row satisfies all filters.
|
||||
func matchFilters(row map[string]any, filters []Filter) bool {
|
||||
for _, f := range filters {
|
||||
val, ok := row[f.Field]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !compareValues(val, f.Op, f.Value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// compareValues compares two values with the given operator.
|
||||
func compareValues(a any, op string, b any) bool {
|
||||
switch op {
|
||||
case "=":
|
||||
return fmt.Sprint(a) == fmt.Sprint(b)
|
||||
case "!=":
|
||||
return fmt.Sprint(a) != fmt.Sprint(b)
|
||||
case "LIKE":
|
||||
pattern := fmt.Sprint(b)
|
||||
val := fmt.Sprint(a)
|
||||
if strings.HasPrefix(pattern, "%") && strings.HasSuffix(pattern, "%") {
|
||||
return strings.Contains(val, pattern[1:len(pattern)-1])
|
||||
}
|
||||
if strings.HasPrefix(pattern, "%") {
|
||||
return strings.HasSuffix(val, pattern[1:])
|
||||
}
|
||||
if strings.HasSuffix(pattern, "%") {
|
||||
return strings.HasPrefix(val, pattern[:len(pattern)-1])
|
||||
}
|
||||
return val == pattern
|
||||
case "<", ">", "<=", ">=":
|
||||
return compareNumeric(a, op, b)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func compareNumeric(a any, op string, b any) bool {
|
||||
af, aOk := toFloat64(a)
|
||||
bf, bOk := toFloat64(b)
|
||||
if !aOk || !bOk {
|
||||
as, bs := fmt.Sprint(a), fmt.Sprint(b)
|
||||
switch op {
|
||||
case "<":
|
||||
return as < bs
|
||||
case ">":
|
||||
return as > bs
|
||||
case "<=":
|
||||
return as <= bs
|
||||
case ">=":
|
||||
return as >= bs
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch op {
|
||||
case "<":
|
||||
return af < bf
|
||||
case ">":
|
||||
return af > bf
|
||||
case "<=":
|
||||
return af <= bf
|
||||
case ">=":
|
||||
return af >= bf
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func toFloat64(v any) (float64, bool) {
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return float64(rv.Int()), true
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return float64(rv.Uint()), true
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return rv.Float(), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func sortRows(rows []map[string]any, field string, desc bool) {
|
||||
for i := 1; i < len(rows); i++ {
|
||||
for j := i; j > 0; j-- {
|
||||
a := fmt.Sprint(rows[j-1][field])
|
||||
b := fmt.Sprint(rows[j][field])
|
||||
shouldSwap := a > b
|
||||
if desc {
|
||||
shouldSwap = a < b
|
||||
}
|
||||
if shouldSwap {
|
||||
rows[j-1], rows[j] = rows[j], rows[j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Package memory provides an in-memory model.Model implementation.
|
||||
// This is the same as model.NewModel() but importable as a standalone package.
|
||||
package memory
|
||||
|
||||
import (
|
||||
"go-micro.dev/v6/model"
|
||||
)
|
||||
|
||||
// New creates a new in-memory model.
|
||||
func New(opts ...model.Option) model.Model {
|
||||
return model.NewModel(opts...)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/model"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Name string `json:"name" model:"index"`
|
||||
Email string `json:"email"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
|
||||
func setup(t *testing.T) model.Model {
|
||||
t.Helper()
|
||||
db := New()
|
||||
if err := db.Register(&User{}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestCRUD(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create
|
||||
err := db.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@test.com", Age: 30})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Read
|
||||
u := &User{}
|
||||
err = db.Read(ctx, "1", u)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if u.Name != "Alice" {
|
||||
t.Errorf("expected Alice, got %s", u.Name)
|
||||
}
|
||||
if u.Age != 30 {
|
||||
t.Errorf("expected age 30, got %d", u.Age)
|
||||
}
|
||||
|
||||
// Update
|
||||
u.Name = "Alice Updated"
|
||||
u.Age = 31
|
||||
err = db.Update(ctx, u)
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
|
||||
u2 := &User{}
|
||||
db.Read(ctx, "1", u2)
|
||||
if u2.Name != "Alice Updated" {
|
||||
t.Errorf("expected 'Alice Updated', got %s", u2.Name)
|
||||
}
|
||||
if u2.Age != 31 {
|
||||
t.Errorf("expected age 31, got %d", u2.Age)
|
||||
}
|
||||
|
||||
// Delete
|
||||
err = db.Delete(ctx, "1", &User{})
|
||||
if err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
err = db.Read(ctx, "1", &User{})
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateKey(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Create(ctx, &User{ID: "1", Name: "Alice"})
|
||||
err := db.Create(ctx, &User{ID: "1", Name: "Bob"})
|
||||
if err != model.ErrDuplicateKey {
|
||||
t.Errorf("expected ErrDuplicateKey, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := db.Read(ctx, "nonexistent", &User{})
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
err = db.Update(ctx, &User{ID: "nonexistent"})
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound on update, got %v", err)
|
||||
}
|
||||
|
||||
err = db.Delete(ctx, "nonexistent", &User{})
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound on delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
db.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35})
|
||||
|
||||
var all []*User
|
||||
err := db.List(ctx, &all)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Errorf("expected 3, got %d", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithFilter(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
db.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35})
|
||||
|
||||
var results []*User
|
||||
err := db.List(ctx, &results, model.Where("name", "Alice"))
|
||||
if err != nil {
|
||||
t.Fatalf("list with filter: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Errorf("expected 2 Alices, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithLimitOffset(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Create(ctx, &User{ID: "1", Name: "A", Age: 1})
|
||||
db.Create(ctx, &User{ID: "2", Name: "B", Age: 2})
|
||||
db.Create(ctx, &User{ID: "3", Name: "C", Age: 3})
|
||||
db.Create(ctx, &User{ID: "4", Name: "D", Age: 4})
|
||||
|
||||
var results []*User
|
||||
err := db.List(ctx, &results,
|
||||
model.OrderAsc("name"),
|
||||
model.Limit(2),
|
||||
model.Offset(1),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(results))
|
||||
}
|
||||
if results[0].Name != "B" {
|
||||
t.Errorf("expected B, got %s", results[0].Name)
|
||||
}
|
||||
if results[1].Name != "C" {
|
||||
t.Errorf("expected C, got %s", results[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCount(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
db.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35})
|
||||
|
||||
count, err := db.Count(ctx, &User{})
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Errorf("expected 3, got %d", count)
|
||||
}
|
||||
|
||||
count, err = db.Count(ctx, &User{}, model.Where("name", "Alice"))
|
||||
if err != nil {
|
||||
t.Fatalf("count with filter: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhereOp(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
db.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35})
|
||||
|
||||
var results []*User
|
||||
err := db.List(ctx, &results, model.WhereOp("age", ">", 28))
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Errorf("expected 2 (age > 28), got %d", len(results))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package model is an interface for structured data storage with schema awareness.
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotFound is returned when a record doesn't exist.
|
||||
ErrNotFound = errors.New("not found")
|
||||
// ErrDuplicateKey is returned when a record with the same key already exists.
|
||||
ErrDuplicateKey = errors.New("duplicate key")
|
||||
// ErrNotRegistered is returned when a table has not been registered.
|
||||
ErrNotRegistered = errors.New("table not registered")
|
||||
// DefaultModel is the default model.
|
||||
DefaultModel Model = NewModel()
|
||||
)
|
||||
|
||||
// Model is a structured data storage interface.
|
||||
type Model interface {
|
||||
// Init initializes the model.
|
||||
Init(...Option) error
|
||||
// Register registers a struct type as a table.
|
||||
Register(v interface{}, opts ...RegisterOption) error
|
||||
// Create inserts a new record. Returns ErrDuplicateKey if key exists.
|
||||
Create(ctx context.Context, v interface{}) error
|
||||
// Read retrieves a record by key into v. Returns ErrNotFound if missing.
|
||||
Read(ctx context.Context, key string, v interface{}) error
|
||||
// Update modifies an existing record. Returns ErrNotFound if missing.
|
||||
Update(ctx context.Context, v interface{}) error
|
||||
// Delete removes a record by key. v is a pointer to the struct type.
|
||||
Delete(ctx context.Context, key string, v interface{}) error
|
||||
// List retrieves records matching the query. result must be a pointer to a slice of struct pointers.
|
||||
List(ctx context.Context, result interface{}, opts ...QueryOption) error
|
||||
// Count returns the number of matching records. v is a pointer to the struct type.
|
||||
Count(ctx context.Context, v interface{}, opts ...QueryOption) (int64, error)
|
||||
// Close closes the model.
|
||||
Close() error
|
||||
// String returns the name of the implementation.
|
||||
String() string
|
||||
}
|
||||
|
||||
type Option func(*Options)
|
||||
|
||||
type RegisterOption func(*Schema)
|
||||
|
||||
// NewModel returns the default in-memory model.
|
||||
func NewModel(opts ...Option) Model {
|
||||
return newMemoryModel(opts...)
|
||||
}
|
||||
|
||||
// Register registers a struct type with the default model.
|
||||
func Register(v interface{}, opts ...RegisterOption) error {
|
||||
return DefaultModel.Register(v, opts...)
|
||||
}
|
||||
|
||||
// Create inserts a new record using the default model.
|
||||
func Create(ctx context.Context, v interface{}) error {
|
||||
return DefaultModel.Create(ctx, v)
|
||||
}
|
||||
|
||||
// Read retrieves a record by key using the default model.
|
||||
func Read(ctx context.Context, key string, v interface{}) error {
|
||||
return DefaultModel.Read(ctx, key, v)
|
||||
}
|
||||
|
||||
// Update modifies an existing record using the default model.
|
||||
func Update(ctx context.Context, v interface{}) error {
|
||||
return DefaultModel.Update(ctx, v)
|
||||
}
|
||||
|
||||
// Delete removes a record by key using the default model.
|
||||
func Delete(ctx context.Context, key string, v interface{}) error {
|
||||
return DefaultModel.Delete(ctx, key, v)
|
||||
}
|
||||
|
||||
// List retrieves records matching the query using the default model.
|
||||
func List(ctx context.Context, result interface{}, opts ...QueryOption) error {
|
||||
return DefaultModel.List(ctx, result, opts...)
|
||||
}
|
||||
|
||||
// Count returns the number of matching records using the default model.
|
||||
func Count(ctx context.Context, v interface{}, opts ...QueryOption) (int64, error) {
|
||||
return DefaultModel.Count(ctx, v, opts...)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
type TestUser struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Name string `json:"name" model:"index"`
|
||||
Email string `json:"email"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
|
||||
func TestBuildSchema(t *testing.T) {
|
||||
schema := BuildSchema(TestUser{})
|
||||
|
||||
if schema.Table != "testusers" {
|
||||
t.Errorf("expected table 'testusers', got %q", schema.Table)
|
||||
}
|
||||
if schema.Key != "id" {
|
||||
t.Errorf("expected key 'id', got %q", schema.Key)
|
||||
}
|
||||
if len(schema.Fields) != 4 {
|
||||
t.Fatalf("expected 4 fields, got %d", len(schema.Fields))
|
||||
}
|
||||
|
||||
var keyField Field
|
||||
var indexField Field
|
||||
for _, f := range schema.Fields {
|
||||
if f.IsKey {
|
||||
keyField = f
|
||||
}
|
||||
if f.Index {
|
||||
indexField = f
|
||||
}
|
||||
}
|
||||
if keyField.Column != "id" {
|
||||
t.Errorf("expected key column 'id', got %q", keyField.Column)
|
||||
}
|
||||
if indexField.Column != "name" {
|
||||
t.Errorf("expected index column 'name', got %q", indexField.Column)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSchema_DefaultKey(t *testing.T) {
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
schema := BuildSchema(Item{})
|
||||
if schema.Key != "id" {
|
||||
t.Errorf("expected default key 'id', got %q", schema.Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSchema_WithTable(t *testing.T) {
|
||||
schema := BuildSchema(TestUser{}, WithTable("my_users"))
|
||||
|
||||
if schema.Table != "my_users" {
|
||||
t.Errorf("expected table 'my_users', got %q", schema.Table)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructToMap(t *testing.T) {
|
||||
schema := BuildSchema(TestUser{})
|
||||
u := &TestUser{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30}
|
||||
|
||||
m := StructToMap(schema, u)
|
||||
|
||||
if m["id"] != "1" {
|
||||
t.Errorf("expected id '1', got %v", m["id"])
|
||||
}
|
||||
if m["name"] != "Alice" {
|
||||
t.Errorf("expected name 'Alice', got %v", m["name"])
|
||||
}
|
||||
if m["email"] != "alice@example.com" {
|
||||
t.Errorf("expected email 'alice@example.com', got %v", m["email"])
|
||||
}
|
||||
if m["age"] != 30 {
|
||||
t.Errorf("expected age 30, got %v", m["age"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapToStruct(t *testing.T) {
|
||||
schema := BuildSchema(TestUser{})
|
||||
m := map[string]any{
|
||||
"id": "1",
|
||||
"name": "Bob",
|
||||
"email": "bob@example.com",
|
||||
"age": 25,
|
||||
}
|
||||
|
||||
u := &TestUser{}
|
||||
MapToStruct(schema, m, u)
|
||||
|
||||
if u.ID != "1" {
|
||||
t.Errorf("expected ID '1', got %q", u.ID)
|
||||
}
|
||||
if u.Name != "Bob" {
|
||||
t.Errorf("expected Name 'Bob', got %q", u.Name)
|
||||
}
|
||||
if u.Email != "bob@example.com" {
|
||||
t.Errorf("expected Email 'bob@example.com', got %q", u.Email)
|
||||
}
|
||||
if u.Age != 25 {
|
||||
t.Errorf("expected Age 25, got %d", u.Age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyQueryOptions(t *testing.T) {
|
||||
q := ApplyQueryOptions(
|
||||
Where("name", "Alice"),
|
||||
WhereOp("age", ">", 20),
|
||||
OrderDesc("name"),
|
||||
Limit(10),
|
||||
Offset(5),
|
||||
)
|
||||
|
||||
if len(q.Filters) != 2 {
|
||||
t.Fatalf("expected 2 filters, got %d", len(q.Filters))
|
||||
}
|
||||
if q.Filters[0].Field != "name" || q.Filters[0].Op != "=" || q.Filters[0].Value != "Alice" {
|
||||
t.Errorf("unexpected filter 0: %+v", q.Filters[0])
|
||||
}
|
||||
if q.Filters[1].Field != "age" || q.Filters[1].Op != ">" {
|
||||
t.Errorf("unexpected filter 1: %+v", q.Filters[1])
|
||||
}
|
||||
if q.OrderBy != "name" || !q.Desc {
|
||||
t.Errorf("expected order by name desc, got %q desc=%v", q.OrderBy, q.Desc)
|
||||
}
|
||||
if q.Limit != 10 {
|
||||
t.Errorf("expected limit 10, got %d", q.Limit)
|
||||
}
|
||||
if q.Offset != 5 {
|
||||
t.Errorf("expected offset 5, got %d", q.Offset)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
// Options holds configuration for a Model.
|
||||
type Options struct {
|
||||
// DSN is the data source name / connection string.
|
||||
DSN string
|
||||
}
|
||||
|
||||
// WithDSN sets the data source name.
|
||||
func WithDSN(dsn string) Option {
|
||||
return func(o *Options) {
|
||||
o.DSN = dsn
|
||||
}
|
||||
}
|
||||
|
||||
// NewOptions creates Options with defaults applied.
|
||||
func NewOptions(opts ...Option) Options {
|
||||
o := Options{}
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// WithTable overrides the auto-derived table name.
|
||||
func WithTable(name string) RegisterOption {
|
||||
return func(s *Schema) {
|
||||
s.Table = name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
// Package postgres provides a PostgreSQL model.Model implementation.
|
||||
// Uses lib/pq driver. Best for production deployments with rich query support.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"go-micro.dev/v6/model"
|
||||
)
|
||||
|
||||
type postgresModel struct {
|
||||
db *sql.DB
|
||||
mu sync.RWMutex
|
||||
schemas map[string]*model.Schema
|
||||
types map[reflect.Type]*model.Schema
|
||||
}
|
||||
|
||||
// New creates a new Postgres model. DSN is a connection string
|
||||
// (e.g., "postgres://user:pass@localhost/dbname?sslmode=disable").
|
||||
func New(dsn string) model.Model {
|
||||
db, err := sql.Open("postgres", dsn)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("model/postgres: failed to open: %v", err))
|
||||
}
|
||||
return &postgresModel{
|
||||
db: db,
|
||||
schemas: make(map[string]*model.Schema),
|
||||
types: make(map[reflect.Type]*model.Schema),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *postgresModel) Init(opts ...model.Option) error {
|
||||
return d.db.Ping()
|
||||
}
|
||||
|
||||
func (d *postgresModel) Register(v interface{}, opts ...model.RegisterOption) error {
|
||||
schema := model.BuildSchema(v, opts...)
|
||||
t := model.ResolveType(v)
|
||||
|
||||
d.mu.Lock()
|
||||
d.schemas[schema.Table] = schema
|
||||
d.types[t] = schema
|
||||
d.mu.Unlock()
|
||||
|
||||
var cols []string
|
||||
for _, f := range schema.Fields {
|
||||
colType := goTypeToPostgres(f.Type)
|
||||
col := fmt.Sprintf("%s %s", quoteIdent(f.Column), colType)
|
||||
if f.IsKey {
|
||||
col += " PRIMARY KEY"
|
||||
}
|
||||
cols = append(cols, col)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (%s)", quoteIdent(schema.Table), strings.Join(cols, ", "))
|
||||
if _, err := d.db.Exec(query); err != nil {
|
||||
return fmt.Errorf("model/postgres: create table: %w", err)
|
||||
}
|
||||
|
||||
for _, f := range schema.Fields {
|
||||
if f.Index && !f.IsKey {
|
||||
idx := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)",
|
||||
quoteIdent("idx_"+schema.Table+"_"+f.Column),
|
||||
quoteIdent(schema.Table),
|
||||
quoteIdent(f.Column))
|
||||
if _, err := d.db.Exec(idx); err != nil {
|
||||
return fmt.Errorf("model/postgres: create index: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *postgresModel) schema(v interface{}) (*model.Schema, error) {
|
||||
t := model.ResolveType(v)
|
||||
d.mu.RLock()
|
||||
s, ok := d.types[t]
|
||||
d.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, model.ErrNotRegistered
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (d *postgresModel) Create(ctx context.Context, v interface{}) error {
|
||||
schema, err := d.schema(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := model.StructToMap(schema, v)
|
||||
cols, placeholders, values := buildInsert(schema, fields)
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", quoteIdent(schema.Table), cols, placeholders)
|
||||
_, err = d.db.ExecContext(ctx, query, values...)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "unique constraint") {
|
||||
return model.ErrDuplicateKey
|
||||
}
|
||||
return fmt.Errorf("model/postgres: create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *postgresModel) Read(ctx context.Context, key string, v interface{}) error {
|
||||
schema, err := d.schema(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cols := columnList(schema)
|
||||
query := fmt.Sprintf("SELECT %s FROM %s WHERE %s = $1", cols, quoteIdent(schema.Table), quoteIdent(schema.Key))
|
||||
row := d.db.QueryRowContext(ctx, query, key)
|
||||
fields, err := scanRow(schema, row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
model.MapToStruct(schema, fields, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *postgresModel) Update(ctx context.Context, v interface{}) error {
|
||||
schema, err := d.schema(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := model.StructToMap(schema, v)
|
||||
key := model.KeyValue(schema, v)
|
||||
setClauses, values := buildUpdate(schema, fields)
|
||||
values = append(values, key)
|
||||
paramIdx := len(values)
|
||||
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s = $%d",
|
||||
quoteIdent(schema.Table), setClauses, quoteIdent(schema.Key), paramIdx)
|
||||
result, err := d.db.ExecContext(ctx, query, values...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model/postgres: update: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *postgresModel) Delete(ctx context.Context, key string, v interface{}) error {
|
||||
schema, err := d.schema(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE %s = $1", quoteIdent(schema.Table), quoteIdent(schema.Key))
|
||||
result, err := d.db.ExecContext(ctx, query, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model/postgres: delete: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *postgresModel) List(ctx context.Context, result interface{}, opts ...model.QueryOption) error {
|
||||
rv := reflect.ValueOf(result)
|
||||
if rv.Kind() != reflect.Pointer || rv.Elem().Kind() != reflect.Slice {
|
||||
return fmt.Errorf("model/postgres: result must be a pointer to a slice")
|
||||
}
|
||||
sliceVal := rv.Elem()
|
||||
elemType := sliceVal.Type().Elem()
|
||||
structType := elemType
|
||||
if structType.Kind() == reflect.Pointer {
|
||||
structType = structType.Elem()
|
||||
}
|
||||
|
||||
d.mu.RLock()
|
||||
schema, ok := d.types[structType]
|
||||
d.mu.RUnlock()
|
||||
if !ok {
|
||||
return model.ErrNotRegistered
|
||||
}
|
||||
|
||||
q := model.ApplyQueryOptions(opts...)
|
||||
cols := columnList(schema)
|
||||
|
||||
query := fmt.Sprintf("SELECT %s FROM %s", cols, quoteIdent(schema.Table))
|
||||
var args []any
|
||||
paramN := 1
|
||||
|
||||
if len(q.Filters) > 0 {
|
||||
where, fArgs, _ := buildWhere(q.Filters, paramN)
|
||||
query += " WHERE " + where
|
||||
args = append(args, fArgs...)
|
||||
}
|
||||
|
||||
if q.OrderBy != "" {
|
||||
dir := "ASC"
|
||||
if q.Desc {
|
||||
dir = "DESC"
|
||||
}
|
||||
query += fmt.Sprintf(" ORDER BY %s %s", quoteIdent(q.OrderBy), dir)
|
||||
}
|
||||
|
||||
if q.Limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", q.Limit)
|
||||
}
|
||||
if q.Offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", q.Offset)
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model/postgres: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fieldMaps, err := scanRows(schema, rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := reflect.MakeSlice(sliceVal.Type(), len(fieldMaps), len(fieldMaps))
|
||||
for i, fields := range fieldMaps {
|
||||
vp := reflect.New(structType)
|
||||
model.MapToStruct(schema, fields, vp.Interface())
|
||||
if elemType.Kind() == reflect.Pointer {
|
||||
results.Index(i).Set(vp)
|
||||
} else {
|
||||
results.Index(i).Set(vp.Elem())
|
||||
}
|
||||
}
|
||||
sliceVal.Set(results)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *postgresModel) Count(ctx context.Context, v interface{}, opts ...model.QueryOption) (int64, error) {
|
||||
schema, err := d.schema(v)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
q := model.ApplyQueryOptions(opts...)
|
||||
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM %s", quoteIdent(schema.Table))
|
||||
var args []any
|
||||
paramN := 1
|
||||
|
||||
if len(q.Filters) > 0 {
|
||||
where, fArgs, _ := buildWhere(q.Filters, paramN)
|
||||
query += " WHERE " + where
|
||||
args = append(args, fArgs...)
|
||||
}
|
||||
|
||||
var count int64
|
||||
err = d.db.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("model/postgres: count: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (d *postgresModel) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
func (d *postgresModel) String() string {
|
||||
return "postgres"
|
||||
}
|
||||
|
||||
// SQL helpers
|
||||
|
||||
func quoteIdent(s string) string {
|
||||
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||||
}
|
||||
|
||||
func goTypeToPostgres(t reflect.Type) string {
|
||||
switch t.Kind() {
|
||||
case reflect.Int, reflect.Int64:
|
||||
return "BIGINT"
|
||||
case reflect.Int8, reflect.Int16, reflect.Int32:
|
||||
return "INTEGER"
|
||||
case reflect.Uint, reflect.Uint64:
|
||||
return "BIGINT"
|
||||
case reflect.Uint8, reflect.Uint16, reflect.Uint32:
|
||||
return "INTEGER"
|
||||
case reflect.Float32:
|
||||
return "REAL"
|
||||
case reflect.Float64:
|
||||
return "DOUBLE PRECISION"
|
||||
case reflect.Bool:
|
||||
return "BOOLEAN"
|
||||
default:
|
||||
return "TEXT"
|
||||
}
|
||||
}
|
||||
|
||||
func buildInsert(schema *model.Schema, fields map[string]any) (string, string, []any) {
|
||||
var cols []string
|
||||
var placeholders []string
|
||||
var values []any
|
||||
i := 1
|
||||
for _, f := range schema.Fields {
|
||||
if v, ok := fields[f.Column]; ok {
|
||||
cols = append(cols, quoteIdent(f.Column))
|
||||
placeholders = append(placeholders, fmt.Sprintf("$%d", i))
|
||||
values = append(values, v)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return strings.Join(cols, ", "), strings.Join(placeholders, ", "), values
|
||||
}
|
||||
|
||||
func buildUpdate(schema *model.Schema, fields map[string]any) (string, []any) {
|
||||
var setClauses []string
|
||||
var values []any
|
||||
i := 1
|
||||
for _, f := range schema.Fields {
|
||||
if f.IsKey {
|
||||
continue
|
||||
}
|
||||
if v, ok := fields[f.Column]; ok {
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", quoteIdent(f.Column), i))
|
||||
values = append(values, v)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return strings.Join(setClauses, ", "), values
|
||||
}
|
||||
|
||||
func buildWhere(filters []model.Filter, startParam int) (string, []any, int) {
|
||||
var clauses []string
|
||||
var args []any
|
||||
n := startParam
|
||||
for _, f := range filters {
|
||||
clauses = append(clauses, fmt.Sprintf("%s %s $%d", quoteIdent(f.Field), f.Op, n))
|
||||
args = append(args, f.Value)
|
||||
n++
|
||||
}
|
||||
return strings.Join(clauses, " AND "), args, n
|
||||
}
|
||||
|
||||
func columnList(schema *model.Schema) string {
|
||||
var cols []string
|
||||
for _, f := range schema.Fields {
|
||||
cols = append(cols, quoteIdent(f.Column))
|
||||
}
|
||||
return strings.Join(cols, ", ")
|
||||
}
|
||||
|
||||
func scanRow(schema *model.Schema, row *sql.Row) (map[string]any, error) {
|
||||
ptrs := make([]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
ptrs[i] = newScanPtr(f.Type)
|
||||
}
|
||||
if err := row.Scan(ptrs...); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("model/postgres: scan: %w", err)
|
||||
}
|
||||
result := make(map[string]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
result[f.Column] = derefScanPtr(ptrs[i], f.Type)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanRows(schema *model.Schema, rows *sql.Rows) ([]map[string]any, error) {
|
||||
var results []map[string]any
|
||||
for rows.Next() {
|
||||
ptrs := make([]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
ptrs[i] = newScanPtr(f.Type)
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, fmt.Errorf("model/postgres: scan row: %w", err)
|
||||
}
|
||||
row := make(map[string]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
row[f.Column] = derefScanPtr(ptrs[i], f.Type)
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
func newScanPtr(t reflect.Type) any {
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
return new(string)
|
||||
case reflect.Int, reflect.Int64:
|
||||
return new(int64)
|
||||
case reflect.Int32:
|
||||
return new(int32)
|
||||
case reflect.Float64:
|
||||
return new(float64)
|
||||
case reflect.Float32:
|
||||
return new(float32)
|
||||
case reflect.Bool:
|
||||
return new(bool)
|
||||
default:
|
||||
return new(string)
|
||||
}
|
||||
}
|
||||
|
||||
func derefScanPtr(ptr any, t reflect.Type) any {
|
||||
rv := reflect.ValueOf(ptr).Elem()
|
||||
if rv.Type().ConvertibleTo(t) {
|
||||
return rv.Convert(t).Interface()
|
||||
}
|
||||
return rv.Interface()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package model
|
||||
|
||||
// QueryOptions configures a List or Count operation.
|
||||
type QueryOptions struct {
|
||||
Filters []Filter
|
||||
OrderBy string
|
||||
Desc bool
|
||||
Limit uint
|
||||
Offset uint
|
||||
}
|
||||
|
||||
// Filter represents a field-level query condition.
|
||||
type Filter struct {
|
||||
Field string // Column name
|
||||
Op string // Operator: =, !=, <, >, <=, >=, LIKE
|
||||
Value any // Comparison value
|
||||
}
|
||||
|
||||
// QueryOption sets values in QueryOptions.
|
||||
type QueryOption func(*QueryOptions)
|
||||
|
||||
// ApplyQueryOptions applies a set of QueryOptions and returns the result.
|
||||
func ApplyQueryOptions(opts ...QueryOption) QueryOptions {
|
||||
q := QueryOptions{}
|
||||
for _, o := range opts {
|
||||
o(&q)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// Where adds an equality filter: field = value.
|
||||
func Where(field string, value any) QueryOption {
|
||||
return func(q *QueryOptions) {
|
||||
q.Filters = append(q.Filters, Filter{Field: field, Op: "=", Value: value})
|
||||
}
|
||||
}
|
||||
|
||||
// WhereOp adds a filter with a custom operator (=, !=, <, >, <=, >=, LIKE).
|
||||
func WhereOp(field, op string, value any) QueryOption {
|
||||
return func(q *QueryOptions) {
|
||||
q.Filters = append(q.Filters, Filter{Field: field, Op: op, Value: value})
|
||||
}
|
||||
}
|
||||
|
||||
// OrderAsc orders results by field ascending.
|
||||
func OrderAsc(field string) QueryOption {
|
||||
return func(q *QueryOptions) {
|
||||
q.OrderBy = field
|
||||
q.Desc = false
|
||||
}
|
||||
}
|
||||
|
||||
// OrderDesc orders results by field descending.
|
||||
func OrderDesc(field string) QueryOption {
|
||||
return func(q *QueryOptions) {
|
||||
q.OrderBy = field
|
||||
q.Desc = true
|
||||
}
|
||||
}
|
||||
|
||||
// Limit limits the number of returned records.
|
||||
func Limit(n uint) QueryOption {
|
||||
return func(q *QueryOptions) {
|
||||
q.Limit = n
|
||||
}
|
||||
}
|
||||
|
||||
// Offset skips the first n records (for pagination).
|
||||
func Offset(n uint) QueryOption {
|
||||
return func(q *QueryOptions) {
|
||||
q.Offset = n
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Schema describes a model's storage layout, derived from struct tags.
|
||||
type Schema struct {
|
||||
// Table name in the database.
|
||||
Table string
|
||||
// Key is the name of the primary key field.
|
||||
Key string
|
||||
// Fields maps Go field names to their column metadata.
|
||||
Fields []Field
|
||||
}
|
||||
|
||||
// Field describes a single field in the schema.
|
||||
type Field struct {
|
||||
// Name is the Go struct field name.
|
||||
Name string
|
||||
// Column is the database column name (from json tag or lowercased name).
|
||||
Column string
|
||||
// Type is the Go reflect type.
|
||||
Type reflect.Type
|
||||
// IsKey indicates this is the primary key field.
|
||||
IsKey bool
|
||||
// Index indicates this field should be indexed.
|
||||
Index bool
|
||||
}
|
||||
|
||||
// BuildSchema extracts a Schema from a struct type using reflection.
|
||||
func BuildSchema(v interface{}, opts ...RegisterOption) *Schema {
|
||||
t := reflect.TypeOf(v)
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
|
||||
schema := &Schema{
|
||||
Table: strings.ToLower(t.Name()) + "s",
|
||||
}
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
field := Field{
|
||||
Name: f.Name,
|
||||
Type: f.Type,
|
||||
}
|
||||
|
||||
// Column name: use json tag if present, else lowercase field name
|
||||
if tag := f.Tag.Get("json"); tag != "" {
|
||||
parts := strings.Split(tag, ",")
|
||||
if parts[0] != "" && parts[0] != "-" {
|
||||
field.Column = parts[0]
|
||||
}
|
||||
}
|
||||
if field.Column == "" {
|
||||
field.Column = strings.ToLower(f.Name)
|
||||
}
|
||||
|
||||
// Check model tag
|
||||
if tag := f.Tag.Get("model"); tag != "" {
|
||||
for _, opt := range strings.Split(tag, ",") {
|
||||
switch opt {
|
||||
case "key":
|
||||
field.IsKey = true
|
||||
schema.Key = field.Column
|
||||
case "index":
|
||||
field.Index = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
schema.Fields = append(schema.Fields, field)
|
||||
}
|
||||
|
||||
if schema.Key == "" {
|
||||
// Default to "id" if no key tag found
|
||||
for i := range schema.Fields {
|
||||
if schema.Fields[i].Column == "id" {
|
||||
schema.Fields[i].IsKey = true
|
||||
schema.Key = "id"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, o := range opts {
|
||||
o(schema)
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
// StructToMap converts a struct pointer to a map of column name → value.
|
||||
func StructToMap(schema *Schema, v interface{}) map[string]any {
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() == reflect.Pointer {
|
||||
rv = rv.Elem()
|
||||
}
|
||||
fields := make(map[string]any, len(schema.Fields))
|
||||
for _, f := range schema.Fields {
|
||||
fv := rv.FieldByName(f.Name)
|
||||
if fv.IsValid() {
|
||||
fields[f.Column] = fv.Interface()
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// MapToStruct fills a struct pointer from a map of column name → value.
|
||||
func MapToStruct(schema *Schema, fields map[string]any, v interface{}) {
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() == reflect.Pointer {
|
||||
rv = rv.Elem()
|
||||
}
|
||||
for _, f := range schema.Fields {
|
||||
val, ok := fields[f.Column]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fv := rv.FieldByName(f.Name)
|
||||
if !fv.IsValid() || !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
rval := reflect.ValueOf(val)
|
||||
if rval.Type().AssignableTo(fv.Type()) {
|
||||
fv.Set(rval)
|
||||
} else if rval.Type().ConvertibleTo(fv.Type()) {
|
||||
fv.Set(rval.Convert(fv.Type()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NewFromSchema creates a new zero-value struct pointer for the given schema's original type.
|
||||
func NewFromSchema(schema *Schema, rtype reflect.Type) interface{} {
|
||||
return reflect.New(rtype).Interface()
|
||||
}
|
||||
|
||||
// KeyValue extracts the key value from a struct using the schema.
|
||||
func KeyValue(schema *Schema, v interface{}) string {
|
||||
fields := StructToMap(schema, v)
|
||||
key, ok := fields[schema.Key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(key)
|
||||
}
|
||||
|
||||
// ResolveType returns the struct reflect.Type from a value (handles pointers and slices).
|
||||
func ResolveType(v interface{}) reflect.Type {
|
||||
t := reflect.TypeOf(v)
|
||||
for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice {
|
||||
t = t.Elem()
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
// Package sqlite provides a SQLite model.Model implementation.
|
||||
// Uses mattn/go-sqlite3 for broad compatibility.
|
||||
// Good for development, testing, and single-node production.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"go-micro.dev/v6/model"
|
||||
)
|
||||
|
||||
type sqliteModel struct {
|
||||
db *sql.DB
|
||||
mu sync.RWMutex
|
||||
schemas map[string]*model.Schema
|
||||
types map[reflect.Type]*model.Schema
|
||||
}
|
||||
|
||||
// New creates a new SQLite model. DSN is the file path (e.g., "data.db" or ":memory:").
|
||||
func New(dsn string) model.Model {
|
||||
if dsn == "" {
|
||||
dsn = ":memory:"
|
||||
}
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("model/sqlite: failed to open %q: %v", dsn, err))
|
||||
}
|
||||
_, _ = db.Exec("PRAGMA journal_mode=WAL")
|
||||
return &sqliteModel{
|
||||
db: db,
|
||||
schemas: make(map[string]*model.Schema),
|
||||
types: make(map[reflect.Type]*model.Schema),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *sqliteModel) Init(opts ...model.Option) error {
|
||||
return d.db.Ping()
|
||||
}
|
||||
|
||||
func (d *sqliteModel) Register(v interface{}, opts ...model.RegisterOption) error {
|
||||
schema := model.BuildSchema(v, opts...)
|
||||
t := model.ResolveType(v)
|
||||
|
||||
d.mu.Lock()
|
||||
d.schemas[schema.Table] = schema
|
||||
d.types[t] = schema
|
||||
d.mu.Unlock()
|
||||
|
||||
var cols []string
|
||||
for _, f := range schema.Fields {
|
||||
colType := goTypeToSQLite(f.Type)
|
||||
col := fmt.Sprintf("%q %s", f.Column, colType)
|
||||
if f.IsKey {
|
||||
col += " PRIMARY KEY"
|
||||
}
|
||||
cols = append(cols, col)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %q (%s)", schema.Table, strings.Join(cols, ", "))
|
||||
if _, err := d.db.Exec(query); err != nil {
|
||||
return fmt.Errorf("model/sqlite: create table: %w", err)
|
||||
}
|
||||
|
||||
for _, f := range schema.Fields {
|
||||
if f.Index && !f.IsKey {
|
||||
idx := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %q ON %q (%q)",
|
||||
"idx_"+schema.Table+"_"+f.Column, schema.Table, f.Column)
|
||||
if _, err := d.db.Exec(idx); err != nil {
|
||||
return fmt.Errorf("model/sqlite: create index: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *sqliteModel) schema(v interface{}) (*model.Schema, error) {
|
||||
t := model.ResolveType(v)
|
||||
d.mu.RLock()
|
||||
s, ok := d.types[t]
|
||||
d.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, model.ErrNotRegistered
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (d *sqliteModel) Create(ctx context.Context, v interface{}) error {
|
||||
schema, err := d.schema(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := model.StructToMap(schema, v)
|
||||
cols, placeholders, values := buildInsert(schema, fields)
|
||||
query := fmt.Sprintf("INSERT INTO %q (%s) VALUES (%s)", schema.Table, cols, placeholders)
|
||||
_, err = d.db.ExecContext(ctx, query, values...)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint") || strings.Contains(err.Error(), "PRIMARY KEY") {
|
||||
return model.ErrDuplicateKey
|
||||
}
|
||||
return fmt.Errorf("model/sqlite: create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *sqliteModel) Read(ctx context.Context, key string, v interface{}) error {
|
||||
schema, err := d.schema(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cols := columnList(schema)
|
||||
query := fmt.Sprintf("SELECT %s FROM %q WHERE %q = ?", cols, schema.Table, schema.Key)
|
||||
row := d.db.QueryRowContext(ctx, query, key)
|
||||
fields, err := scanRow(schema, row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
model.MapToStruct(schema, fields, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *sqliteModel) Update(ctx context.Context, v interface{}) error {
|
||||
schema, err := d.schema(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := model.StructToMap(schema, v)
|
||||
key := model.KeyValue(schema, v)
|
||||
setClauses, values := buildUpdate(schema, fields)
|
||||
values = append(values, key)
|
||||
query := fmt.Sprintf("UPDATE %q SET %s WHERE %q = ?", schema.Table, setClauses, schema.Key)
|
||||
result, err := d.db.ExecContext(ctx, query, values...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model/sqlite: update: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *sqliteModel) Delete(ctx context.Context, key string, v interface{}) error {
|
||||
schema, err := d.schema(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := fmt.Sprintf("DELETE FROM %q WHERE %q = ?", schema.Table, schema.Key)
|
||||
result, err := d.db.ExecContext(ctx, query, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model/sqlite: delete: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *sqliteModel) List(ctx context.Context, result interface{}, opts ...model.QueryOption) error {
|
||||
// result must be *[]*T
|
||||
rv := reflect.ValueOf(result)
|
||||
if rv.Kind() != reflect.Pointer || rv.Elem().Kind() != reflect.Slice {
|
||||
return fmt.Errorf("model/sqlite: result must be a pointer to a slice")
|
||||
}
|
||||
sliceVal := rv.Elem()
|
||||
elemType := sliceVal.Type().Elem()
|
||||
structType := elemType
|
||||
if structType.Kind() == reflect.Pointer {
|
||||
structType = structType.Elem()
|
||||
}
|
||||
|
||||
d.mu.RLock()
|
||||
schema, ok := d.types[structType]
|
||||
d.mu.RUnlock()
|
||||
if !ok {
|
||||
return model.ErrNotRegistered
|
||||
}
|
||||
|
||||
q := model.ApplyQueryOptions(opts...)
|
||||
cols := columnList(schema)
|
||||
|
||||
query := fmt.Sprintf("SELECT %s FROM %q", cols, schema.Table)
|
||||
var args []any
|
||||
|
||||
if len(q.Filters) > 0 {
|
||||
where, fArgs := buildWhere(q.Filters)
|
||||
query += " WHERE " + where
|
||||
args = append(args, fArgs...)
|
||||
}
|
||||
|
||||
if q.OrderBy != "" {
|
||||
dir := "ASC"
|
||||
if q.Desc {
|
||||
dir = "DESC"
|
||||
}
|
||||
query += fmt.Sprintf(" ORDER BY %q %s", q.OrderBy, dir)
|
||||
}
|
||||
|
||||
if q.Limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", q.Limit)
|
||||
}
|
||||
if q.Offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", q.Offset)
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model/sqlite: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fieldMaps, err := scanRows(schema, rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results := reflect.MakeSlice(sliceVal.Type(), len(fieldMaps), len(fieldMaps))
|
||||
for i, fields := range fieldMaps {
|
||||
vp := reflect.New(structType)
|
||||
model.MapToStruct(schema, fields, vp.Interface())
|
||||
if elemType.Kind() == reflect.Pointer {
|
||||
results.Index(i).Set(vp)
|
||||
} else {
|
||||
results.Index(i).Set(vp.Elem())
|
||||
}
|
||||
}
|
||||
sliceVal.Set(results)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *sqliteModel) Count(ctx context.Context, v interface{}, opts ...model.QueryOption) (int64, error) {
|
||||
schema, err := d.schema(v)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
q := model.ApplyQueryOptions(opts...)
|
||||
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM %q", schema.Table)
|
||||
var args []any
|
||||
|
||||
if len(q.Filters) > 0 {
|
||||
where, fArgs := buildWhere(q.Filters)
|
||||
query += " WHERE " + where
|
||||
args = append(args, fArgs...)
|
||||
}
|
||||
|
||||
var count int64
|
||||
err = d.db.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("model/sqlite: count: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (d *sqliteModel) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
func (d *sqliteModel) String() string {
|
||||
return "sqlite"
|
||||
}
|
||||
|
||||
// SQL helpers
|
||||
|
||||
func goTypeToSQLite(t reflect.Type) string {
|
||||
switch t.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return "INTEGER"
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return "REAL"
|
||||
case reflect.Bool:
|
||||
return "INTEGER"
|
||||
default:
|
||||
return "TEXT"
|
||||
}
|
||||
}
|
||||
|
||||
func buildInsert(schema *model.Schema, fields map[string]any) (string, string, []any) {
|
||||
var cols []string
|
||||
var placeholders []string
|
||||
var values []any
|
||||
for _, f := range schema.Fields {
|
||||
if v, ok := fields[f.Column]; ok {
|
||||
cols = append(cols, fmt.Sprintf("%q", f.Column))
|
||||
placeholders = append(placeholders, "?")
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return strings.Join(cols, ", "), strings.Join(placeholders, ", "), values
|
||||
}
|
||||
|
||||
func buildUpdate(schema *model.Schema, fields map[string]any) (string, []any) {
|
||||
var setClauses []string
|
||||
var values []any
|
||||
for _, f := range schema.Fields {
|
||||
if f.IsKey {
|
||||
continue
|
||||
}
|
||||
if v, ok := fields[f.Column]; ok {
|
||||
setClauses = append(setClauses, fmt.Sprintf("%q = ?", f.Column))
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return strings.Join(setClauses, ", "), values
|
||||
}
|
||||
|
||||
func buildWhere(filters []model.Filter) (string, []any) {
|
||||
var clauses []string
|
||||
var args []any
|
||||
for _, f := range filters {
|
||||
clauses = append(clauses, fmt.Sprintf("%q %s ?", f.Field, f.Op))
|
||||
args = append(args, f.Value)
|
||||
}
|
||||
return strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
func columnList(schema *model.Schema) string {
|
||||
var cols []string
|
||||
for _, f := range schema.Fields {
|
||||
cols = append(cols, fmt.Sprintf("%q", f.Column))
|
||||
}
|
||||
return strings.Join(cols, ", ")
|
||||
}
|
||||
|
||||
func scanRow(schema *model.Schema, row *sql.Row) (map[string]any, error) {
|
||||
ptrs := make([]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
ptrs[i] = newScanPtr(f.Type)
|
||||
}
|
||||
if err := row.Scan(ptrs...); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("model/sqlite: scan: %w", err)
|
||||
}
|
||||
result := make(map[string]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
result[f.Column] = derefScanPtr(ptrs[i], f.Type)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanRows(schema *model.Schema, rows *sql.Rows) ([]map[string]any, error) {
|
||||
var results []map[string]any
|
||||
for rows.Next() {
|
||||
ptrs := make([]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
ptrs[i] = newScanPtr(f.Type)
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return nil, fmt.Errorf("model/sqlite: scan row: %w", err)
|
||||
}
|
||||
row := make(map[string]any, len(schema.Fields))
|
||||
for i, f := range schema.Fields {
|
||||
row[f.Column] = derefScanPtr(ptrs[i], f.Type)
|
||||
}
|
||||
results = append(results, row)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
func newScanPtr(t reflect.Type) any {
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
return new(string)
|
||||
case reflect.Int, reflect.Int64:
|
||||
return new(int64)
|
||||
case reflect.Int32:
|
||||
return new(int32)
|
||||
case reflect.Float64:
|
||||
return new(float64)
|
||||
case reflect.Float32:
|
||||
return new(float32)
|
||||
case reflect.Bool:
|
||||
return new(bool)
|
||||
default:
|
||||
return new(string)
|
||||
}
|
||||
}
|
||||
|
||||
func derefScanPtr(ptr any, t reflect.Type) any {
|
||||
rv := reflect.ValueOf(ptr).Elem()
|
||||
if rv.Type().ConvertibleTo(t) {
|
||||
return rv.Convert(t).Interface()
|
||||
}
|
||||
return rv.Interface()
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-micro.dev/v6/model"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id" model:"key"`
|
||||
Name string `json:"name" model:"index"`
|
||||
Email string `json:"email"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
|
||||
func setup(t *testing.T) model.Model {
|
||||
t.Helper()
|
||||
db := New(":memory:")
|
||||
if err := db.Register(&User{}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestCRUD(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create
|
||||
err := db.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@test.com", Age: 30})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
// Read
|
||||
u := &User{}
|
||||
err = db.Read(ctx, "1", u)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if u.Name != "Alice" {
|
||||
t.Errorf("expected Alice, got %s", u.Name)
|
||||
}
|
||||
if u.Age != 30 {
|
||||
t.Errorf("expected age 30, got %d", u.Age)
|
||||
}
|
||||
|
||||
// Update
|
||||
u.Name = "Alice Updated"
|
||||
u.Age = 31
|
||||
err = db.Update(ctx, u)
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
|
||||
u2 := &User{}
|
||||
db.Read(ctx, "1", u2)
|
||||
if u2.Name != "Alice Updated" {
|
||||
t.Errorf("expected 'Alice Updated', got %s", u2.Name)
|
||||
}
|
||||
if u2.Age != 31 {
|
||||
t.Errorf("expected age 31, got %d", u2.Age)
|
||||
}
|
||||
|
||||
// Delete
|
||||
err = db.Delete(ctx, "1", &User{})
|
||||
if err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
|
||||
err = db.Read(ctx, "1", &User{})
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateKey(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Create(ctx, &User{ID: "1", Name: "Alice"})
|
||||
err := db.Create(ctx, &User{ID: "1", Name: "Bob"})
|
||||
if err != model.ErrDuplicateKey {
|
||||
t.Errorf("expected ErrDuplicateKey, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFound(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := db.Read(ctx, "nonexistent", &User{})
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
err = db.Update(ctx, &User{ID: "nonexistent"})
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound on update, got %v", err)
|
||||
}
|
||||
|
||||
err = db.Delete(ctx, "nonexistent", &User{})
|
||||
if err != model.ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound on delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithFilter(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
db.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35})
|
||||
|
||||
var results []*User
|
||||
err := db.List(ctx, &results, model.Where("name", "Alice"))
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Errorf("expected 2 Alices, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithOrder(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Create(ctx, &User{ID: "1", Name: "Charlie", Age: 35})
|
||||
db.Create(ctx, &User{ID: "2", Name: "Alice", Age: 30})
|
||||
db.Create(ctx, &User{ID: "3", Name: "Bob", Age: 25})
|
||||
|
||||
var results []*User
|
||||
err := db.List(ctx, &results, model.OrderAsc("name"))
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(results))
|
||||
}
|
||||
if results[0].Name != "Alice" {
|
||||
t.Errorf("expected Alice first, got %s", results[0].Name)
|
||||
}
|
||||
if results[2].Name != "Charlie" {
|
||||
t.Errorf("expected Charlie last, got %s", results[2].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWithLimitOffset(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Create(ctx, &User{ID: "1", Name: "A", Age: 1})
|
||||
db.Create(ctx, &User{ID: "2", Name: "B", Age: 2})
|
||||
db.Create(ctx, &User{ID: "3", Name: "C", Age: 3})
|
||||
db.Create(ctx, &User{ID: "4", Name: "D", Age: 4})
|
||||
|
||||
var results []*User
|
||||
err := db.List(ctx, &results,
|
||||
model.OrderAsc("name"),
|
||||
model.Limit(2),
|
||||
model.Offset(1),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(results))
|
||||
}
|
||||
if results[0].Name != "B" {
|
||||
t.Errorf("expected B, got %s", results[0].Name)
|
||||
}
|
||||
if results[1].Name != "C" {
|
||||
t.Errorf("expected C, got %s", results[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCount(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
db.Create(ctx, &User{ID: "3", Name: "Alice", Age: 35})
|
||||
|
||||
count, err := db.Count(ctx, &User{})
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Errorf("expected 3, got %d", count)
|
||||
}
|
||||
|
||||
count, err = db.Count(ctx, &User{}, model.Where("name", "Alice"))
|
||||
if err != nil {
|
||||
t.Fatalf("count with filter: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("expected 2, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhereOp(t *testing.T) {
|
||||
db := setup(t)
|
||||
ctx := context.Background()
|
||||
|
||||
db.Create(ctx, &User{ID: "1", Name: "Alice", Age: 30})
|
||||
db.Create(ctx, &User{ID: "2", Name: "Bob", Age: 25})
|
||||
db.Create(ctx, &User{ID: "3", Name: "Charlie", Age: 35})
|
||||
|
||||
var results []*User
|
||||
err := db.List(ctx, &results, model.WhereOp("age", ">", 28))
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Errorf("expected 2 (age > 28), got %d", len(results))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user