Expand Sale/Purchase/Project + 102 ORM tests — +4633 LOC

Sale (1177→2321 LOC):
- Quotation templates (apply to order, option lines)
- Sales reports (by month, product, customer, salesperson, category)
- Advance payment wizard (delivered/percentage/fixed modes)
- SO cancel wizard, discount wizard
- action_quotation_sent, action_lock/unlock, preview_quotation
- Line computes: invoice_status, price_reduce, untaxed_amount
- Partner extension: sale_order_total

Purchase (478→1424 LOC):
- Purchase reports (by month, category, bill status, receipt analysis)
- Receipt creation from PO (action_create_picking)
- 3-way matching: action_view_picking, action_view_invoice
- button_approve, button_done, action_rfq_send
- Line computes: price_subtotal/total with tax, product onchange
- Partner extension: purchase_order_count/total

Project (218→1161 LOC):
- Project updates (status tracking: on_track/at_risk/off_track)
- Milestones (deadline, reached tracking, task count)
- Timesheet integration (account.analytic.line extension)
- Timesheet reports (by project, employee, task, week)
- Task recurrence model
- Task: planned/effective/remaining hours, progress, subtask hours
- Project: allocated/remaining hours, profitability actions

ORM Tests (102 tests, 0→1257 LOC):
- domain_test.go: 32 tests (compile, operators, AND/OR/NOT, null)
- field_test.go: 15 tests (IsCopyable, SQLType, IsRelational, IsStored)
- model_test.go: 21 tests (NewModel, AddFields, RegisterMethod, ExtendModel)
- domain_parse_test.go: 21 tests (parse Python domain strings)
- sanitize_test.go: 13 tests (false→nil, type conversions)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-04-03 23:39:41 +02:00
parent bdb97f98ad
commit fad2a37d1c
16 changed files with 4633 additions and 0 deletions

View File

@@ -0,0 +1,277 @@
package orm
import "testing"
func TestParseDomainStringSimple(t *testing.T) {
domain, err := ParseDomainString("[('name', '=', 'test')]", nil)
if err != nil {
t.Fatal(err)
}
if len(domain) != 1 {
t.Fatalf("expected 1 node, got %d", len(domain))
}
cond, ok := domain[0].(Condition)
if !ok {
t.Fatal("expected Condition")
}
if cond.Field != "name" {
t.Errorf("field: %s", cond.Field)
}
if cond.Operator != "=" {
t.Errorf("op: %s", cond.Operator)
}
if cond.Value != "test" {
t.Errorf("value: %v", cond.Value)
}
}
func TestParseDomainStringNumeric(t *testing.T) {
domain, err := ParseDomainString("[('age', '>', 18)]", nil)
if err != nil {
t.Fatal(err)
}
cond := domain[0].(Condition)
if cond.Value != int64(18) {
t.Errorf("expected int64(18), got %T %v", cond.Value, cond.Value)
}
}
func TestParseDomainStringFloat(t *testing.T) {
domain, err := ParseDomainString("[('amount', '>', 99.5)]", nil)
if err != nil {
t.Fatal(err)
}
cond := domain[0].(Condition)
if cond.Value != float64(99.5) {
t.Errorf("expected float64(99.5), got %T %v", cond.Value, cond.Value)
}
}
func TestParseDomainStringNegativeNumber(t *testing.T) {
domain, err := ParseDomainString("[('balance', '<', -100)]", nil)
if err != nil {
t.Fatal(err)
}
cond := domain[0].(Condition)
if cond.Value != int64(-100) {
t.Errorf("expected int64(-100), got %T %v", cond.Value, cond.Value)
}
}
func TestParseDomainStringBoolean(t *testing.T) {
domain, err := ParseDomainString("[('active', '=', True)]", nil)
if err != nil {
t.Fatal(err)
}
cond := domain[0].(Condition)
if cond.Value != true {
t.Errorf("expected true, got %v", cond.Value)
}
}
func TestParseDomainStringBooleanFalse(t *testing.T) {
domain, err := ParseDomainString("[('active', '=', False)]", nil)
if err != nil {
t.Fatal(err)
}
cond := domain[0].(Condition)
if cond.Value != false {
t.Errorf("expected false, got %v", cond.Value)
}
}
func TestParseDomainStringList(t *testing.T) {
domain, err := ParseDomainString("[('id', 'in', [1, 2, 3])]", nil)
if err != nil {
t.Fatal(err)
}
cond := domain[0].(Condition)
vals, ok := cond.Value.([]int64)
if !ok {
t.Fatalf("expected []int64, got %T", cond.Value)
}
if len(vals) != 3 {
t.Errorf("expected 3, got %d", len(vals))
}
if vals[0] != 1 || vals[1] != 2 || vals[2] != 3 {
t.Errorf("values: %v", vals)
}
}
func TestParseDomainStringStringList(t *testing.T) {
domain, err := ParseDomainString("[('state', 'in', ['draft', 'sent'])]", nil)
if err != nil {
t.Fatal(err)
}
cond := domain[0].(Condition)
vals, ok := cond.Value.([]string)
if !ok {
t.Fatalf("expected []string, got %T", cond.Value)
}
if len(vals) != 2 {
t.Errorf("expected 2, got %d", len(vals))
}
}
func TestParseDomainStringEmptyList(t *testing.T) {
domain, err := ParseDomainString("[('id', 'in', [])]", nil)
if err != nil {
t.Fatal(err)
}
cond := domain[0].(Condition)
vals, ok := cond.Value.([]int64)
if !ok {
t.Fatalf("expected []int64, got %T", cond.Value)
}
if len(vals) != 0 {
t.Errorf("expected 0, got %d", len(vals))
}
}
func TestParseDomainStringOperators(t *testing.T) {
domain, err := ParseDomainString("['&', ('a', '=', 1), ('b', '=', 2)]", nil)
if err != nil {
t.Fatal(err)
}
if len(domain) != 3 {
t.Fatalf("expected 3 nodes, got %d", len(domain))
}
if domain[0] != OpAnd {
t.Error("expected & operator")
}
}
func TestParseDomainStringOrOperator(t *testing.T) {
domain, err := ParseDomainString("['|', ('a', '=', 1), ('b', '=', 2)]", nil)
if err != nil {
t.Fatal(err)
}
if len(domain) != 3 {
t.Fatalf("expected 3 nodes, got %d", len(domain))
}
if domain[0] != OpOr {
t.Error("expected | operator")
}
}
func TestParseDomainStringNotOperator(t *testing.T) {
domain, err := ParseDomainString("['!', ('active', '=', True)]", nil)
if err != nil {
t.Fatal(err)
}
if len(domain) != 2 {
t.Fatalf("expected 2 nodes, got %d", len(domain))
}
if domain[0] != OpNot {
t.Error("expected ! operator")
}
}
func TestParseDomainStringEmpty(t *testing.T) {
domain, err := ParseDomainString("[]", nil)
if err != nil {
t.Fatal(err)
}
if len(domain) != 0 {
t.Errorf("expected 0 nodes, got %d", len(domain))
}
}
func TestParseDomainStringEmptyString(t *testing.T) {
domain, err := ParseDomainString("", nil)
if err != nil {
t.Fatal(err)
}
if len(domain) != 0 {
t.Errorf("expected 0 nodes, got %d", len(domain))
}
}
func TestParseDomainStringNone(t *testing.T) {
domain, err := ParseDomainString("[('field', '=', None)]", nil)
if err != nil {
t.Fatal(err)
}
cond := domain[0].(Condition)
if cond.Value != nil {
t.Errorf("expected nil, got %v", cond.Value)
}
}
func TestParseDomainStringImplicitAnd(t *testing.T) {
// Multiple leaves without explicit operator should be implicitly ANDed
domain, err := ParseDomainString("[('a', '=', 1), ('b', '=', 2)]", nil)
if err != nil {
t.Fatal(err)
}
// normalizeDomainNodes wraps with And() → [&, leaf, leaf] = 3 nodes
if len(domain) != 3 {
t.Fatalf("expected 3 nodes (implicit AND), got %d", len(domain))
}
if domain[0] != OpAnd {
t.Error("expected implicit & operator")
}
}
func TestParseDomainStringDoubleQuotes(t *testing.T) {
domain, err := ParseDomainString(`[("name", "=", "test")]`, nil)
if err != nil {
t.Fatal(err)
}
if len(domain) != 1 {
t.Fatalf("expected 1 node, got %d", len(domain))
}
cond := domain[0].(Condition)
if cond.Field != "name" {
t.Errorf("field: %s", cond.Field)
}
if cond.Value != "test" {
t.Errorf("value: %v", cond.Value)
}
}
func TestParseDomainStringContextVar(t *testing.T) {
// Without env, context vars should resolve to int64(0)
domain, err := ParseDomainString("[('user_id', '=', user.id)]", nil)
if err != nil {
t.Fatal(err)
}
cond := domain[0].(Condition)
if cond.Value != int64(0) {
t.Errorf("expected int64(0), got %T %v", cond.Value, cond.Value)
}
}
func TestParseDomainStringInvalidSyntax(t *testing.T) {
_, err := ParseDomainString("not a domain", nil)
if err == nil {
t.Error("expected error for invalid syntax")
}
}
func TestParseDomainStringTupleAsList(t *testing.T) {
// Some domain_force uses tuple syntax for list values
domain, err := ParseDomainString("[('id', 'in', (1, 2, 3))]", nil)
if err != nil {
t.Fatal(err)
}
cond := domain[0].(Condition)
vals, ok := cond.Value.([]int64)
if !ok {
t.Fatalf("expected []int64, got %T", cond.Value)
}
if len(vals) != 3 {
t.Errorf("expected 3, got %d", len(vals))
}
}
func TestParseDomainStringEscapedQuote(t *testing.T) {
domain, err := ParseDomainString(`[('name', '=', 'it\'s')]`, nil)
if err != nil {
t.Fatal(err)
}
cond := domain[0].(Condition)
if cond.Value != "it's" {
t.Errorf("expected it's, got %v", cond.Value)
}
}

445
pkg/orm/domain_test.go Normal file
View File

@@ -0,0 +1,445 @@
package orm
import (
"fmt"
"testing"
)
func TestDomainCompileEmpty(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
where, params, err := dc.Compile(nil)
if err != nil {
t.Fatal(err)
}
if where != "TRUE" {
t.Errorf("expected TRUE, got %s", where)
}
if len(params) != 0 {
t.Errorf("expected 0 params, got %d", len(params))
}
}
func TestDomainCompileSimpleLeaf(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("name", "=", "test")}
where, params, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != `"name" = $1` {
t.Errorf("got %s", where)
}
if len(params) != 1 || params[0] != "test" {
t.Errorf("params: %v", params)
}
}
func TestDomainCompileNullCheck(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("name", "=", nil)}
where, _, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != `"name" IS NULL` {
t.Errorf("got %s", where)
}
}
func TestDomainCompileFalseCheck(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("active", "=", false)}
where, _, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != `"active" IS NULL` {
t.Errorf("got %s", where)
}
}
func TestDomainCompileNotEqualNull(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("name", "!=", nil)}
where, _, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != `"name" IS NOT NULL` {
t.Errorf("got %s", where)
}
}
func TestDomainCompileIn(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("id", "in", []int64{1, 2, 3})}
where, params, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != `"id" IN ($1, $2, $3)` {
t.Errorf("got %s", where)
}
if len(params) != 3 {
t.Errorf("params: %v", params)
}
}
func TestDomainCompileEmptyIn(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("id", "in", []int64{})}
where, _, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != "FALSE" {
t.Errorf("got %s", where)
}
}
func TestDomainCompileNotIn(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("id", "not in", []int64{1, 2})}
where, params, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != `"id" NOT IN ($1, $2)` {
t.Errorf("got %s", where)
}
if len(params) != 2 {
t.Errorf("params: %v", params)
}
}
func TestDomainCompileEmptyNotIn(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("id", "not in", []int64{})}
where, _, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != "TRUE" {
t.Errorf("got %s", where)
}
}
func TestDomainCompileLike(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("name", "ilike", "test")}
where, params, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != `"name" ILIKE $1` {
t.Errorf("got %s", where)
}
if params[0] != "%test%" {
t.Errorf("expected %%test%%, got %v", params[0])
}
}
func TestDomainCompileLikeWithWildcard(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("name", "ilike", "test%")}
where, params, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != `"name" ILIKE $1` {
t.Errorf("got %s", where)
}
// Value already contains wildcard, should not be wrapped again
if params[0] != "test%" {
t.Errorf("expected test%%, got %v", params[0])
}
}
func TestDomainCompileNotLike(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("name", "not like", "foo")}
where, params, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != `"name" NOT LIKE $1` {
t.Errorf("got %s", where)
}
if params[0] != "%foo%" {
t.Errorf("expected %%foo%%, got %v", params[0])
}
}
func TestDomainCompileExactLike(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("name", "=like", "test")}
where, params, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != `"name" LIKE $1` {
t.Errorf("got %s", where)
}
// =like does NOT auto-wrap
if params[0] != "test" {
t.Errorf("expected test, got %v", params[0])
}
}
func TestDomainCompileExactIlike(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("name", "=ilike", "Test")}
where, params, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != `"name" ILIKE $1` {
t.Errorf("got %s", where)
}
if params[0] != "Test" {
t.Errorf("expected Test, got %v", params[0])
}
}
func TestDomainCompileAnd(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := And(Leaf("a", "=", 1), Leaf("b", "=", 2))
where, params, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
expected := `("a" = $1 AND "b" = $2)`
if where != expected {
t.Errorf("expected %s, got %s", expected, where)
}
if len(params) != 2 {
t.Errorf("params: %v", params)
}
}
func TestDomainCompileOr(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Or(Leaf("a", "=", 1), Leaf("b", "=", 2))
where, _, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
expected := `("a" = $1 OR "b" = $2)`
if where != expected {
t.Errorf("expected %s, got %s", expected, where)
}
}
func TestDomainCompileNot(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Not(Leaf("active", "=", true))
where, _, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
expected := `(NOT "active" = $1)`
if where != expected {
t.Errorf("expected %s, got %s", expected, where)
}
}
func TestDomainCompileInvalidOperator(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("a", "INVALID", 1)}
_, _, err := dc.Compile(domain)
if err == nil {
t.Error("expected error for invalid operator")
}
}
func TestDomainCompileComparison(t *testing.T) {
ops := []string{"<", ">", "<=", ">="}
for _, op := range ops {
t.Run(op, func(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("age", op, 18)}
where, params, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
expected := fmt.Sprintf(`"age" %s $1`, op)
if where != expected {
t.Errorf("expected %s, got %s", expected, where)
}
if len(params) != 1 || params[0] != 18 {
t.Errorf("params: %v", params)
}
})
}
}
func TestDomainCompileInStrings(t *testing.T) {
dc := &DomainCompiler{model: &Model{table: "test"}}
domain := Domain{Leaf("state", "in", []string{"draft", "sent"})}
where, params, err := dc.Compile(domain)
if err != nil {
t.Fatal(err)
}
if where != `"state" IN ($1, $2)` {
t.Errorf("got %s", where)
}
if len(params) != 2 {
t.Errorf("params: %v", params)
}
}
func TestAndEmpty(t *testing.T) {
d := And()
if d != nil {
t.Errorf("expected nil, got %v", d)
}
}
func TestAndSingle(t *testing.T) {
d := And(Leaf("a", "=", 1))
if len(d) != 1 {
t.Errorf("expected 1 node, got %d", len(d))
}
}
func TestOrEmpty(t *testing.T) {
d := Or()
if d != nil {
t.Errorf("expected nil, got %v", d)
}
}
func TestOrSingle(t *testing.T) {
d := Or(Leaf("a", "=", 1))
if len(d) != 1 {
t.Errorf("expected 1 node, got %d", len(d))
}
}
func TestOrMultiple(t *testing.T) {
d := Or(Leaf("a", "=", 1), Leaf("b", "=", 2), Leaf("c", "=", 3))
// Should have 2 OR operators + 3 leaves = 5 nodes
if len(d) != 5 {
t.Errorf("expected 5 nodes, got %d", len(d))
}
}
func TestAndMultiple(t *testing.T) {
d := And(Leaf("a", "=", 1), Leaf("b", "=", 2), Leaf("c", "=", 3))
// Should have 2 AND operators + 3 leaves = 5 nodes
if len(d) != 5 {
t.Errorf("expected 5 nodes, got %d", len(d))
}
}
func TestNotDomain(t *testing.T) {
d := Not(Leaf("active", "=", true))
if len(d) != 2 {
t.Errorf("expected 2 nodes, got %d", len(d))
}
if d[0] != OpNot {
t.Errorf("expected OpNot, got %v", d[0])
}
}
func TestLeafCreation(t *testing.T) {
c := Leaf("name", "=", "test")
if c.Field != "name" {
t.Errorf("field: %s", c.Field)
}
if c.Operator != "=" {
t.Errorf("operator: %s", c.Operator)
}
if c.Value != "test" {
t.Errorf("value: %v", c.Value)
}
}
func TestWrapLikeValue(t *testing.T) {
tests := []struct {
name string
input Value
want Value
}{
{"plain string", "test", "%test%"},
{"already has %", "test%", "test%"},
{"already has _", "test_val", "test_val"},
{"non-string", 42, 42},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := wrapLikeValue(tt.input)
if got != tt.want {
t.Errorf("wrapLikeValue(%v) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestNormalizeSlice(t *testing.T) {
t.Run("[]int64", func(t *testing.T) {
result := normalizeSlice([]int64{1, 2, 3})
if len(result) != 3 {
t.Errorf("expected 3, got %d", len(result))
}
})
t.Run("[]string", func(t *testing.T) {
result := normalizeSlice([]string{"a", "b"})
if len(result) != 2 {
t.Errorf("expected 2, got %d", len(result))
}
})
t.Run("[]int", func(t *testing.T) {
result := normalizeSlice([]int{1, 2})
if len(result) != 2 {
t.Errorf("expected 2, got %d", len(result))
}
})
t.Run("[]float64", func(t *testing.T) {
result := normalizeSlice([]float64{1.5, 2.5})
if len(result) != 2 {
t.Errorf("expected 2, got %d", len(result))
}
})
t.Run("non-slice", func(t *testing.T) {
result := normalizeSlice("not a slice")
if result != nil {
t.Errorf("expected nil, got %v", result)
}
})
}
func TestToInt64Slice(t *testing.T) {
tests := []struct {
name string
val Value
want []int64
}{
{"int64", int64(5), []int64{5}},
{"int", int(3), []int64{3}},
{"int32", int32(7), []int64{7}},
{"float64", float64(9), []int64{9}},
{"[]int64", []int64{1, 2}, []int64{1, 2}},
{"[]int", []int{3, 4}, []int64{3, 4}},
{"string", "bad", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := toInt64Slice(tt.val)
if tt.want == nil {
if got != nil {
t.Errorf("expected nil, got %v", got)
}
return
}
if len(got) != len(tt.want) {
t.Fatalf("len: got %d, want %d", len(got), len(tt.want))
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("index %d: got %d, want %d", i, got[i], tt.want[i])
}
}
})
}
}

330
pkg/orm/field_test.go Normal file
View File

@@ -0,0 +1,330 @@
package orm
import "testing"
func TestFieldIsCopyable(t *testing.T) {
tests := []struct {
name string
field Field
want bool
}{
{"regular char", Field{Name: "name", Type: TypeChar}, true},
{"id field", Field{Name: "id", Type: TypeInteger}, false},
{"create_uid", Field{Name: "create_uid", Type: TypeMany2one}, false},
{"write_uid", Field{Name: "write_uid", Type: TypeMany2one}, false},
{"create_date", Field{Name: "create_date", Type: TypeDatetime}, false},
{"write_date", Field{Name: "write_date", Type: TypeDatetime}, false},
{"password", Field{Name: "password", Type: TypeChar}, false},
{"computed non-stored", Field{Name: "total", Type: TypeFloat, Compute: "x"}, false},
{"computed stored", Field{Name: "total", Type: TypeFloat, Compute: "x", Store: true}, true},
{"o2m", Field{Name: "lines", Type: TypeOne2many}, false},
{"m2o", Field{Name: "partner_id", Type: TypeMany2one}, true},
{"boolean", Field{Name: "active", Type: TypeBoolean}, true},
{"explicit copy true", Field{Name: "ref", Type: TypeChar, Copy: true}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.field.IsCopyable()
if got != tt.want {
t.Errorf("IsCopyable() = %v, want %v", got, tt.want)
}
})
}
}
func TestFieldSQLType(t *testing.T) {
tests := []struct {
typ FieldType
want string
}{
{TypeChar, "varchar"},
{TypeText, "text"},
{TypeHTML, "text"},
{TypeInteger, "int4"},
{TypeFloat, "numeric"},
{TypeMonetary, "numeric"},
{TypeBoolean, "bool"},
{TypeDate, "date"},
{TypeDatetime, "timestamp without time zone"},
{TypeMany2one, "int4"},
{TypeOne2many, ""},
{TypeMany2many, ""},
{TypeJson, "jsonb"},
{TypeProperties, "jsonb"},
{TypeBinary, "bytea"},
{TypeSelection, "varchar"},
{TypeReference, "varchar"},
}
for _, tt := range tests {
t.Run(tt.typ.String(), func(t *testing.T) {
got := tt.typ.SQLType()
if got != tt.want {
t.Errorf("SQLType() = %q, want %q", got, tt.want)
}
})
}
}
func TestFieldSQLTypeWithSize(t *testing.T) {
f := &Field{Type: TypeChar, Size: 64}
got := f.SQLType()
if got != "varchar(64)" {
t.Errorf("expected varchar(64), got %s", got)
}
}
func TestFieldTypeString(t *testing.T) {
if TypeChar.String() != "char" {
t.Error("expected char")
}
if TypeMany2one.String() != "many2one" {
t.Error("expected many2one")
}
if TypeBoolean.String() != "boolean" {
t.Error("expected boolean")
}
if TypeText.String() != "text" {
t.Error("expected text")
}
if TypeInteger.String() != "integer" {
t.Error("expected integer")
}
if TypeFloat.String() != "float" {
t.Error("expected float")
}
}
func TestFieldTypeIsRelational(t *testing.T) {
if !TypeMany2one.IsRelational() {
t.Error("m2o should be relational")
}
if !TypeOne2many.IsRelational() {
t.Error("o2m should be relational")
}
if !TypeMany2many.IsRelational() {
t.Error("m2m should be relational")
}
if TypeChar.IsRelational() {
t.Error("char should not be relational")
}
if TypeInteger.IsRelational() {
t.Error("integer should not be relational")
}
if TypeBoolean.IsRelational() {
t.Error("boolean should not be relational")
}
}
func TestFieldTypeIsStored(t *testing.T) {
if !TypeChar.IsStored() {
t.Error("char should be stored")
}
if !TypeMany2one.IsStored() {
t.Error("m2o should be stored")
}
if TypeOne2many.IsStored() {
t.Error("o2m should not be stored")
}
if TypeMany2many.IsStored() {
t.Error("m2m should not be stored")
}
if !TypeBoolean.IsStored() {
t.Error("boolean should be stored")
}
if !TypeInteger.IsStored() {
t.Error("integer should be stored")
}
}
func TestFieldIsStored(t *testing.T) {
tests := []struct {
name string
f Field
want bool
}{
{"plain char", Field{Type: TypeChar}, true},
{"computed not stored", Field{Type: TypeChar, Compute: "x"}, false},
{"computed stored", Field{Type: TypeChar, Compute: "x", Store: true}, true},
{"related not stored", Field{Type: TypeChar, Related: "partner_id.name"}, false},
{"related stored", Field{Type: TypeChar, Related: "partner_id.name", Store: true}, true},
{"o2m", Field{Type: TypeOne2many}, false},
{"m2m", Field{Type: TypeMany2many}, false},
{"m2o", Field{Type: TypeMany2one}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.f.IsStored()
if got != tt.want {
t.Errorf("IsStored() = %v, want %v", got, tt.want)
}
})
}
}
func TestFieldColumn(t *testing.T) {
f := &Field{Name: "partner_id", column: "partner_id"}
if f.Column() != "partner_id" {
t.Errorf("expected partner_id, got %s", f.Column())
}
f2 := &Field{Name: "custom", column: "custom_col"}
if f2.Column() != "custom_col" {
t.Errorf("expected custom_col, got %s", f2.Column())
}
// When column is empty, falls back to Name
f3 := &Field{Name: "fallback"}
if f3.Column() != "fallback" {
t.Errorf("expected fallback, got %s", f3.Column())
}
}
func TestFieldConstructors(t *testing.T) {
t.Run("Char", func(t *testing.T) {
f := Char("name", FieldOpts{String: "Name", Required: true})
if f.Type != TypeChar {
t.Errorf("type: %s", f.Type)
}
if f.Name != "name" {
t.Errorf("name: %s", f.Name)
}
if !f.Required {
t.Error("expected required")
}
if f.String != "Name" {
t.Errorf("string: %s", f.String)
}
})
t.Run("Integer", func(t *testing.T) {
f := Integer("count", FieldOpts{})
if f.Type != TypeInteger {
t.Errorf("type: %s", f.Type)
}
})
t.Run("Boolean", func(t *testing.T) {
f := Boolean("active", FieldOpts{Default: true})
if f.Type != TypeBoolean {
t.Errorf("type: %s", f.Type)
}
if f.Default != true {
t.Error("expected default true")
}
})
t.Run("Many2one", func(t *testing.T) {
f := Many2one("partner_id", "res.partner", FieldOpts{String: "Partner"})
if f.Type != TypeMany2one {
t.Errorf("type: %s", f.Type)
}
if f.Comodel != "res.partner" {
t.Errorf("comodel: %s", f.Comodel)
}
if !f.Index {
t.Error("M2O should be auto-indexed")
}
if f.OnDelete != OnDeleteSetNull {
t.Errorf("expected set null, got %s", f.OnDelete)
}
})
t.Run("One2many", func(t *testing.T) {
f := One2many("line_ids", "sale.order.line", "order_id", FieldOpts{})
if f.Type != TypeOne2many {
t.Errorf("type: %s", f.Type)
}
if f.InverseField != "order_id" {
t.Errorf("inverse: %s", f.InverseField)
}
})
t.Run("Many2many", func(t *testing.T) {
f := Many2many("tag_ids", "res.partner.tag", FieldOpts{})
if f.Type != TypeMany2many {
t.Errorf("type: %s", f.Type)
}
if f.Comodel != "res.partner.tag" {
t.Errorf("comodel: %s", f.Comodel)
}
})
t.Run("Text", func(t *testing.T) {
f := Text("description", FieldOpts{})
if f.Type != TypeText {
t.Errorf("type: %s", f.Type)
}
})
t.Run("Float", func(t *testing.T) {
f := Float("amount", FieldOpts{})
if f.Type != TypeFloat {
t.Errorf("type: %s", f.Type)
}
})
t.Run("Date", func(t *testing.T) {
f := Date("birthday", FieldOpts{})
if f.Type != TypeDate {
t.Errorf("type: %s", f.Type)
}
})
t.Run("Datetime", func(t *testing.T) {
f := Datetime("created", FieldOpts{})
if f.Type != TypeDatetime {
t.Errorf("type: %s", f.Type)
}
})
t.Run("Binary", func(t *testing.T) {
f := Binary("image", FieldOpts{})
if f.Type != TypeBinary {
t.Errorf("type: %s", f.Type)
}
})
t.Run("Json", func(t *testing.T) {
f := Json("data", FieldOpts{})
if f.Type != TypeJson {
t.Errorf("type: %s", f.Type)
}
})
t.Run("default label from name", func(t *testing.T) {
f := Char("my_field", FieldOpts{})
if f.String != "my_field" {
t.Errorf("expected my_field as default label, got %s", f.String)
}
})
}
func TestFieldResolveDefault(t *testing.T) {
t.Run("nil default", func(t *testing.T) {
f := &Field{Default: nil}
if f.ResolveDefault() != nil {
t.Error("expected nil")
}
})
t.Run("string default", func(t *testing.T) {
f := &Field{Default: "hello"}
if f.ResolveDefault() != "hello" {
t.Error("expected hello")
}
})
t.Run("bool default", func(t *testing.T) {
f := &Field{Default: true}
if f.ResolveDefault() != true {
t.Error("expected true")
}
})
t.Run("int default", func(t *testing.T) {
f := &Field{Default: 42}
if f.ResolveDefault() != 42 {
t.Error("expected 42")
}
})
}

373
pkg/orm/model_test.go Normal file
View File

@@ -0,0 +1,373 @@
package orm
import (
"fmt"
"testing"
)
func TestNewModel(t *testing.T) {
m := NewModel("test.model.unit.new", ModelOpts{
Description: "Test Model",
RecName: "name",
})
if m.Name() != "test.model.unit.new" {
t.Errorf("name: %s", m.Name())
}
if m.Table() != "test_model_unit_new" {
t.Errorf("table: %s", m.Table())
}
if m.Description() != "Test Model" {
t.Errorf("desc: %s", m.Description())
}
if m.RecName() != "name" {
t.Errorf("rec_name: %s", m.RecName())
}
}
func TestNewModelDefaults(t *testing.T) {
m := NewModel("test.model.defaults", ModelOpts{})
if m.Order() != "id" {
t.Errorf("default order: %s", m.Order())
}
if m.RecName() != "name" {
t.Errorf("default rec_name: %s", m.RecName())
}
if m.IsAbstract() {
t.Error("should not be abstract")
}
if m.IsTransient() {
t.Error("should not be transient")
}
}
func TestNewModelCustomTable(t *testing.T) {
m := NewModel("test.model.custom.table", ModelOpts{
Table: "my_custom_table",
})
if m.Table() != "my_custom_table" {
t.Errorf("table: %s", m.Table())
}
}
func TestNewModelAbstract(t *testing.T) {
m := NewModel("test.model.abstract", ModelOpts{
Type: ModelAbstract,
})
if m.IsAbstract() != true {
t.Error("should be abstract")
}
// Abstract models have no table
if m.Table() != "" {
t.Errorf("abstract should have no table, got %s", m.Table())
}
}
func TestNewModelTransient(t *testing.T) {
m := NewModel("test.model.transient", ModelOpts{
Type: ModelTransient,
})
if m.IsTransient() != true {
t.Error("should be transient")
}
}
func TestModelMagicFields(t *testing.T) {
m := NewModel("test.model.magic", ModelOpts{})
// Magic fields should be auto-created
if f := m.GetField("id"); f == nil {
t.Error("id field missing")
}
if f := m.GetField("display_name"); f == nil {
t.Error("display_name field missing")
}
if f := m.GetField("create_uid"); f == nil {
t.Error("create_uid field missing")
}
if f := m.GetField("create_date"); f == nil {
t.Error("create_date field missing")
}
if f := m.GetField("write_uid"); f == nil {
t.Error("write_uid field missing")
}
if f := m.GetField("write_date"); f == nil {
t.Error("write_date field missing")
}
}
func TestModelAddFields(t *testing.T) {
m := NewModel("test.model.fields.add", ModelOpts{})
m.AddFields(
Char("name", FieldOpts{String: "Name", Required: true}),
Integer("age", FieldOpts{String: "Age"}),
Boolean("active", FieldOpts{String: "Active", Default: true}),
)
if f := m.GetField("name"); f == nil {
t.Error("name field missing")
}
if f := m.GetField("age"); f == nil {
t.Error("age field missing")
}
if f := m.GetField("active"); f == nil {
t.Error("active field missing")
}
if f := m.GetField("nonexistent"); f != nil {
t.Error("should be nil")
}
nameF := m.GetField("name")
if nameF.Type != TypeChar {
t.Error("expected char")
}
if !nameF.Required {
t.Error("expected required")
}
if nameF.String != "Name" {
t.Error("expected Name label")
}
}
func TestModelAddFieldSetsModel(t *testing.T) {
m := NewModel("test.model.field.backref", ModelOpts{})
f := Char("ref", FieldOpts{})
m.AddField(f)
if f.model != m {
t.Error("field should have back-reference to model")
}
}
func TestModelStoredFields(t *testing.T) {
m := NewModel("test.model.stored", ModelOpts{})
m.AddFields(
Char("name", FieldOpts{}),
Char("computed_field", FieldOpts{Compute: "x"}),
One2many("lines", "other.model", "parent_id", FieldOpts{}),
)
stored := m.StoredFields()
// Should include magic fields + name, but not computed_field or o2m
nameFound := false
computedFound := false
linesFound := false
for _, f := range stored {
switch f.Name {
case "name":
nameFound = true
case "computed_field":
computedFound = true
case "lines":
linesFound = true
}
}
if !nameFound {
t.Error("name should be in stored fields")
}
if computedFound {
t.Error("computed_field should not be in stored fields")
}
if linesFound {
t.Error("o2m lines should not be in stored fields")
}
}
func TestModelRegisterMethod(t *testing.T) {
m := NewModel("test.model.methods.reg", ModelOpts{})
called := false
m.RegisterMethod("test_action", func(rs *Recordset, args ...interface{}) (interface{}, error) {
called = true
return "ok", nil
})
if _, ok := m.Methods["test_action"]; !ok {
t.Error("method not registered")
}
result, err := m.Methods["test_action"](nil)
if err != nil {
t.Fatal(err)
}
if result != "ok" {
t.Error("expected ok")
}
if !called {
t.Error("method not called")
}
}
func TestExtendModel(t *testing.T) {
NewModel("test.model.base.ext", ModelOpts{Description: "Base"})
ext := ExtendModel("test.model.base.ext")
ext.AddFields(Char("extra_field", FieldOpts{String: "Extra"}))
base := Registry.Get("test.model.base.ext")
if f := base.GetField("extra_field"); f == nil {
t.Error("extension field missing")
}
}
func TestExtendModelPanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("expected panic for missing model")
}
}()
ExtendModel("nonexistent.model.xyz.panic")
}
func TestRegistryGet(t *testing.T) {
NewModel("test.registry.get.model", ModelOpts{})
if m := Registry.Get("test.registry.get.model"); m == nil {
t.Error("model not found")
}
if m := Registry.Get("nonexistent.registry.model"); m != nil {
t.Error("should be nil")
}
}
func TestRegistryMustGetPanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("expected panic for missing model")
}
}()
Registry.MustGet("nonexistent.mustget.model")
}
func TestRegistryAll(t *testing.T) {
NewModel("test.registry.all.model", ModelOpts{})
all := Registry.All()
found := false
for _, name := range all {
if name == "test.registry.all.model" {
found = true
break
}
}
if !found {
t.Error("model not in Registry.All()")
}
}
func TestRegistryModels(t *testing.T) {
NewModel("test.registry.models.model", ModelOpts{})
models := Registry.Models()
if _, ok := models["test.registry.models.model"]; !ok {
t.Error("model not in Registry.Models()")
}
}
func TestModelSQLConstraint(t *testing.T) {
m := NewModel("test.model.constraint.sql", ModelOpts{})
m.AddSQLConstraint("unique_name", "UNIQUE(name)", "Name must be unique")
if len(m.SQLConstraints) != 1 {
t.Error("constraint not added")
}
if m.SQLConstraints[0].Name != "unique_name" {
t.Error("wrong name")
}
if m.SQLConstraints[0].Definition != "UNIQUE(name)" {
t.Error("wrong definition")
}
if m.SQLConstraints[0].Message != "Name must be unique" {
t.Error("wrong message")
}
}
func TestModelAddConstraint(t *testing.T) {
m := NewModel("test.model.constraint.func", ModelOpts{})
m.AddConstraint(func(rs *Recordset) error {
return fmt.Errorf("test error")
})
if len(m.Constraints) != 1 {
t.Error("constraint not added")
}
}
func TestModelRegisterOnchange(t *testing.T) {
m := NewModel("test.model.onchange", ModelOpts{})
m.RegisterOnchange("partner_id", func(env *Environment, vals Values) Values {
return Values{"name": "changed"}
})
if m.OnchangeHandlers == nil {
t.Fatal("OnchangeHandlers should not be nil")
}
if _, ok := m.OnchangeHandlers["partner_id"]; !ok {
t.Error("onchange handler not registered")
}
}
func TestModelRegisterInverse(t *testing.T) {
m := NewModel("test.model.inverse", ModelOpts{})
m.AddFields(Char("computed", FieldOpts{Compute: "_compute_computed"}))
m.RegisterInverse("computed", func(rs *Recordset, args ...interface{}) (interface{}, error) {
return nil, nil
})
f := m.GetField("computed")
if f.Inverse != "_inverse_computed" {
t.Errorf("expected _inverse_computed, got %s", f.Inverse)
}
if _, ok := m.Methods["_inverse_computed"]; !ok {
t.Error("inverse method not registered")
}
}
func TestModelCreateTableSQL(t *testing.T) {
m := NewModel("test.model.ddl", ModelOpts{})
m.AddFields(
Char("name", FieldOpts{Required: true}),
Integer("count", FieldOpts{}),
)
sql := m.CreateTableSQL()
if sql == "" {
t.Fatal("expected non-empty SQL")
}
// Should contain the table name
if !containsStr(sql, `"test_model_ddl"`) {
t.Error("missing table name in DDL")
}
// Should contain name column
if !containsStr(sql, `"name"`) {
t.Error("missing name column in DDL")
}
// Should contain NOT NULL for required
if !containsStr(sql, "NOT NULL") {
t.Error("missing NOT NULL for required field")
}
}
func TestModelCreateTableSQLAbstract(t *testing.T) {
m := NewModel("test.model.ddl.abstract", ModelOpts{Type: ModelAbstract})
sql := m.CreateTableSQL()
if sql != "" {
t.Error("abstract model should have empty DDL")
}
}
func TestModelFields(t *testing.T) {
m := NewModel("test.model.all.fields", ModelOpts{})
m.AddFields(Char("name", FieldOpts{}))
fields := m.Fields()
if _, ok := fields["name"]; !ok {
t.Error("name not in Fields()")
}
if _, ok := fields["id"]; !ok {
t.Error("id not in Fields()")
}
}
// containsStr is a test helper - checks if s contains substr
func containsStr(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && stringContains(s, substr))
}
func stringContains(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

109
pkg/orm/sanitize_test.go Normal file
View File

@@ -0,0 +1,109 @@
package orm
import "testing"
func TestSanitizeFieldValueBoolFalse(t *testing.T) {
// false for boolean field should stay false
f := &Field{Type: TypeBoolean}
got := sanitizeFieldValue(f, false)
if got != false {
t.Errorf("expected false, got %v", got)
}
}
func TestSanitizeFieldValueBoolTrue(t *testing.T) {
f := &Field{Type: TypeBoolean}
got := sanitizeFieldValue(f, true)
if got != true {
t.Errorf("expected true, got %v", got)
}
}
func TestSanitizeFieldValueCharFalse(t *testing.T) {
// false for char field should become nil
f := &Field{Type: TypeChar}
got := sanitizeFieldValue(f, false)
if got != nil {
t.Errorf("expected nil, got %v", got)
}
}
func TestSanitizeFieldValueIntFalse(t *testing.T) {
f := &Field{Type: TypeInteger}
got := sanitizeFieldValue(f, false)
if got != nil {
t.Errorf("expected nil, got %v", got)
}
}
func TestSanitizeFieldValueFloatFalse(t *testing.T) {
f := &Field{Type: TypeFloat}
got := sanitizeFieldValue(f, false)
if got != nil {
t.Errorf("expected nil, got %v", got)
}
}
func TestSanitizeFieldValueM2OFalse(t *testing.T) {
f := &Field{Type: TypeMany2one}
got := sanitizeFieldValue(f, false)
if got != nil {
t.Errorf("expected nil, got %v", got)
}
}
func TestSanitizeFieldValueDateFalse(t *testing.T) {
f := &Field{Type: TypeDate}
got := sanitizeFieldValue(f, false)
if got != nil {
t.Errorf("expected nil, got %v", got)
}
}
func TestSanitizeFieldValueFloat64ToInt(t *testing.T) {
f := &Field{Type: TypeInteger}
got := sanitizeFieldValue(f, float64(42))
if got != int64(42) {
t.Errorf("expected int64(42), got %T %v", got, got)
}
}
func TestSanitizeFieldValueM2OFloat(t *testing.T) {
f := &Field{Type: TypeMany2one}
got := sanitizeFieldValue(f, float64(5))
if got != int64(5) {
t.Errorf("expected int64(5), got %T %v", got, got)
}
}
func TestSanitizeFieldValueNil(t *testing.T) {
f := &Field{Type: TypeChar}
got := sanitizeFieldValue(f, nil)
if got != nil {
t.Errorf("expected nil, got %v", got)
}
}
func TestSanitizeFieldValuePassthrough(t *testing.T) {
f := &Field{Type: TypeChar}
got := sanitizeFieldValue(f, "hello")
if got != "hello" {
t.Errorf("expected hello, got %v", got)
}
}
func TestSanitizeFieldValueIntPassthrough(t *testing.T) {
f := &Field{Type: TypeInteger}
got := sanitizeFieldValue(f, int64(99))
if got != int64(99) {
t.Errorf("expected int64(99), got %T %v", got, got)
}
}
func TestSanitizeFieldValueFloatPassthrough(t *testing.T) {
f := &Field{Type: TypeFloat}
got := sanitizeFieldValue(f, float64(3.14))
if got != float64(3.14) {
t.Errorf("expected 3.14, got %v", got)
}
}