Phase 1: read_group/web_read_group with SQL GROUP BY, aggregates (sum/avg/min/max/count/array_agg/sum_currency), date granularity, M2O groupby resolution to [id, display_name]. Phase 2: Record rules with domain_force parsing (Python literal parser), global AND + group OR merging. Domain operators: child_of, parent_of, any, not any compiled to SQL hierarchy/EXISTS queries. Phase 3: Button dispatch via /web/dataset/call_button, method return values interpreted as actions. Payment register wizard (account.payment.register) for sale→invoice→pay flow. Phase 4: ir.filters, ir.default, product fields expanded, SO line product_id onchange, ir_model+ir_model_fields DB seeding. Phase 5: CSV export (/web/export/csv), attachment upload/download via ir.attachment, fields_get with aggregator hints. Admin/System: Session persistence (PostgreSQL-backed), ir.config_parameter with get_param/set_param, ir.cron, ir.logging, res.lang, res.config.settings with company-related fields, Settings form view. Technical menu with Views/Actions/Parameters/Security/Logging sub-menus. User change_password, preferences. Password never exposed in UI/API. Bugfixes: false→nil for varchar/int fields, int32 in toInt64, call_button route with trailing slash, create_invoices returns action, search view always included, get_formview_action, name_create, ir.http stub. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
749 lines
20 KiB
Go
749 lines
20 KiB
Go
package orm
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Domain represents a search filter expression.
|
|
// Mirrors: odoo/orm/domains.py Domain class
|
|
//
|
|
// Odoo uses prefix (Polish) notation:
|
|
//
|
|
// ['&', ('name', 'ilike', 'test'), ('active', '=', True)]
|
|
//
|
|
// Go equivalent:
|
|
//
|
|
// And(Leaf("name", "ilike", "test"), Leaf("active", "=", true))
|
|
type Domain []DomainNode
|
|
|
|
// DomainNode is either an Operator or a Condition (leaf).
|
|
type DomainNode interface {
|
|
isDomainNode()
|
|
}
|
|
|
|
// Operator is a logical operator in a domain expression.
|
|
// Mirrors: odoo/orm/domains.py DOMAIN_OPERATORS
|
|
type Operator string
|
|
|
|
const (
|
|
OpAnd Operator = "&"
|
|
OpOr Operator = "|"
|
|
OpNot Operator = "!"
|
|
)
|
|
|
|
func (o Operator) isDomainNode() {}
|
|
|
|
// Condition is a leaf node in a domain expression.
|
|
// Mirrors: odoo/orm/domains.py DomainLeaf
|
|
//
|
|
// Odoo: ('field_name', 'operator', value)
|
|
type Condition struct {
|
|
Field string // Field name (supports dot notation: "partner_id.name")
|
|
Operator string // Comparison operator
|
|
Value Value // Comparison value
|
|
}
|
|
|
|
func (c Condition) isDomainNode() {}
|
|
|
|
// Valid comparison operators.
|
|
// Mirrors: odoo/orm/domains.py COMPARISON_OPERATORS
|
|
var validOperators = map[string]bool{
|
|
"=": true, "!=": true,
|
|
"<": true, ">": true, "<=": true, ">=": true,
|
|
"in": true, "not in": true,
|
|
"like": true, "not like": true,
|
|
"ilike": true, "not ilike": true,
|
|
"=like": true, "=ilike": true,
|
|
"any": true, "not any": true,
|
|
"child_of": true, "parent_of": true,
|
|
}
|
|
|
|
// Leaf creates a domain condition (leaf node).
|
|
func Leaf(field, operator string, value Value) Condition {
|
|
return Condition{Field: field, Operator: operator, Value: value}
|
|
}
|
|
|
|
// And combines conditions with AND (default in Odoo).
|
|
func And(nodes ...DomainNode) Domain {
|
|
if len(nodes) == 0 {
|
|
return nil
|
|
}
|
|
if len(nodes) == 1 {
|
|
return Domain{nodes[0]}
|
|
}
|
|
result := Domain{}
|
|
for i := 0; i < len(nodes)-1; i++ {
|
|
result = append(result, OpAnd)
|
|
}
|
|
result = append(result, nodes...)
|
|
return result
|
|
}
|
|
|
|
// Or combines conditions with OR.
|
|
func Or(nodes ...DomainNode) Domain {
|
|
if len(nodes) == 0 {
|
|
return nil
|
|
}
|
|
if len(nodes) == 1 {
|
|
return Domain{nodes[0]}
|
|
}
|
|
result := Domain{}
|
|
for i := 0; i < len(nodes)-1; i++ {
|
|
result = append(result, OpOr)
|
|
}
|
|
result = append(result, nodes...)
|
|
return result
|
|
}
|
|
|
|
// Not negates a condition.
|
|
func Not(node DomainNode) Domain {
|
|
return Domain{OpNot, node}
|
|
}
|
|
|
|
// DomainCompiler compiles a Domain to SQL WHERE clause.
|
|
// Mirrors: odoo/orm/domains.py Domain._to_sql()
|
|
type DomainCompiler struct {
|
|
model *Model
|
|
env *Environment // For operators that need DB access (child_of, parent_of, any, not any)
|
|
params []interface{}
|
|
joins []joinClause
|
|
aliasCounter int
|
|
}
|
|
|
|
type joinClause struct {
|
|
table string
|
|
alias string
|
|
on string
|
|
}
|
|
|
|
// CompileResult holds the compiled SQL WHERE clause, JOINs, and parameters.
|
|
type CompileResult struct {
|
|
Where string
|
|
Joins string
|
|
Params []interface{}
|
|
}
|
|
|
|
// Compile converts a domain to a SQL WHERE clause with parameters and JOINs.
|
|
func (dc *DomainCompiler) Compile(domain Domain) (string, []interface{}, error) {
|
|
if len(domain) == 0 {
|
|
return "TRUE", nil, nil
|
|
}
|
|
|
|
dc.params = nil
|
|
dc.joins = nil
|
|
dc.aliasCounter = 0
|
|
sql, err := dc.compileNodes(domain, 0)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return sql, dc.params, nil
|
|
}
|
|
|
|
// JoinSQL returns the SQL JOIN clauses generated during compilation.
|
|
func (dc *DomainCompiler) JoinSQL() string {
|
|
if len(dc.joins) == 0 {
|
|
return ""
|
|
}
|
|
var parts []string
|
|
for _, j := range dc.joins {
|
|
parts = append(parts, fmt.Sprintf("LEFT JOIN %q AS %q ON %s", j.table, j.alias, j.on))
|
|
}
|
|
return " " + strings.Join(parts, " ")
|
|
}
|
|
|
|
func (dc *DomainCompiler) compileNodes(domain Domain, pos int) (string, error) {
|
|
if pos >= len(domain) {
|
|
return "TRUE", nil
|
|
}
|
|
|
|
node := domain[pos]
|
|
|
|
switch n := node.(type) {
|
|
case Operator:
|
|
switch n {
|
|
case OpAnd:
|
|
left, err := dc.compileNodes(domain, pos+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
right, err := dc.compileNodes(domain, pos+2)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("(%s AND %s)", left, right), nil
|
|
|
|
case OpOr:
|
|
left, err := dc.compileNodes(domain, pos+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
right, err := dc.compileNodes(domain, pos+2)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("(%s OR %s)", left, right), nil
|
|
|
|
case OpNot:
|
|
inner, err := dc.compileNodes(domain, pos+1)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("(NOT %s)", inner), nil
|
|
}
|
|
|
|
case Condition:
|
|
return dc.compileCondition(n)
|
|
|
|
case domainGroup:
|
|
// domainGroup wraps a sub-domain as a single node.
|
|
// Compile it recursively as a full domain.
|
|
subSQL, subParams, err := dc.compileDomainGroup(Domain(n))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_ = subParams // params already appended inside compileDomainGroup
|
|
return subSQL, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("unexpected domain node at position %d: %v", pos, node)
|
|
}
|
|
|
|
// compileDomainGroup compiles a sub-domain that was wrapped via domainGroup.
|
|
// It reuses the same DomainCompiler (sharing params and joins) so parameter
|
|
// indices stay consistent with the outer query.
|
|
func (dc *DomainCompiler) compileDomainGroup(sub Domain) (string, []interface{}, error) {
|
|
if len(sub) == 0 {
|
|
return "TRUE", nil, nil
|
|
}
|
|
sql, err := dc.compileNodes(sub, 0)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return sql, nil, nil
|
|
}
|
|
|
|
func (dc *DomainCompiler) compileCondition(c Condition) (string, error) {
|
|
if !validOperators[c.Operator] {
|
|
return "", fmt.Errorf("invalid operator: %q", c.Operator)
|
|
}
|
|
|
|
// Handle dot notation (e.g., "partner_id.name")
|
|
parts := strings.Split(c.Field, ".")
|
|
column := parts[0]
|
|
|
|
// TODO: Handle JOINs for dot notation paths
|
|
// For now, only support direct fields
|
|
if len(parts) > 1 {
|
|
// Placeholder for JOIN resolution
|
|
return dc.compileJoinedCondition(parts, c.Operator, c.Value)
|
|
}
|
|
|
|
return dc.compileSimpleCondition(column, c.Operator, c.Value)
|
|
}
|
|
|
|
func (dc *DomainCompiler) compileSimpleCondition(column, operator string, value Value) (string, error) {
|
|
paramIdx := len(dc.params) + 1
|
|
|
|
switch operator {
|
|
case "=", "!=", "<", ">", "<=", ">=":
|
|
if value == nil || value == false {
|
|
if operator == "=" {
|
|
return fmt.Sprintf("%q IS NULL", column), nil
|
|
}
|
|
return fmt.Sprintf("%q IS NOT NULL", column), nil
|
|
}
|
|
dc.params = append(dc.params, value)
|
|
return fmt.Sprintf("%q %s $%d", column, operator, paramIdx), nil
|
|
|
|
case "in":
|
|
vals := normalizeSlice(value)
|
|
if vals == nil {
|
|
return "", fmt.Errorf("'in' operator requires a slice value")
|
|
}
|
|
if len(vals) == 0 {
|
|
return "FALSE", nil
|
|
}
|
|
placeholders := make([]string, len(vals))
|
|
for i, v := range vals {
|
|
dc.params = append(dc.params, v)
|
|
placeholders[i] = fmt.Sprintf("$%d", paramIdx+i)
|
|
}
|
|
return fmt.Sprintf("%q IN (%s)", column, strings.Join(placeholders, ", ")), nil
|
|
|
|
case "not in":
|
|
vals := normalizeSlice(value)
|
|
if vals == nil {
|
|
return "", fmt.Errorf("'not in' operator requires a slice value")
|
|
}
|
|
if len(vals) == 0 {
|
|
return "TRUE", nil
|
|
}
|
|
placeholders := make([]string, len(vals))
|
|
for i, v := range vals {
|
|
dc.params = append(dc.params, v)
|
|
placeholders[i] = fmt.Sprintf("$%d", paramIdx+i)
|
|
}
|
|
return fmt.Sprintf("%q NOT IN (%s)", column, strings.Join(placeholders, ", ")), nil
|
|
|
|
case "like":
|
|
dc.params = append(dc.params, wrapLikeValue(value))
|
|
return fmt.Sprintf("%q LIKE $%d", column, paramIdx), nil
|
|
|
|
case "not like":
|
|
dc.params = append(dc.params, wrapLikeValue(value))
|
|
return fmt.Sprintf("%q NOT LIKE $%d", column, paramIdx), nil
|
|
|
|
case "ilike":
|
|
dc.params = append(dc.params, wrapLikeValue(value))
|
|
return fmt.Sprintf("%q ILIKE $%d", column, paramIdx), nil
|
|
|
|
case "not ilike":
|
|
dc.params = append(dc.params, wrapLikeValue(value))
|
|
return fmt.Sprintf("%q NOT ILIKE $%d", column, paramIdx), nil
|
|
|
|
case "=like":
|
|
dc.params = append(dc.params, value)
|
|
return fmt.Sprintf("%q LIKE $%d", column, paramIdx), nil
|
|
|
|
case "=ilike":
|
|
dc.params = append(dc.params, value)
|
|
return fmt.Sprintf("%q ILIKE $%d", column, paramIdx), nil
|
|
|
|
case "child_of":
|
|
return dc.compileHierarchyOp(column, value, true)
|
|
|
|
case "parent_of":
|
|
return dc.compileHierarchyOp(column, value, false)
|
|
|
|
case "any":
|
|
return dc.compileAnyOp(column, value, false)
|
|
|
|
case "not any":
|
|
return dc.compileAnyOp(column, value, true)
|
|
|
|
default:
|
|
return "", fmt.Errorf("unhandled operator: %q", operator)
|
|
}
|
|
}
|
|
|
|
// compileJoinedCondition resolves dot-notation paths (e.g., "partner_id.country_id.code")
|
|
// by generating LEFT JOINs through the relational chain.
|
|
func (dc *DomainCompiler) compileJoinedCondition(fieldPath []string, operator string, value Value) (string, error) {
|
|
currentModel := dc.model
|
|
currentAlias := dc.model.Table()
|
|
|
|
// Walk the path: each segment except the last is a Many2one FK to JOIN through
|
|
for i := 0; i < len(fieldPath)-1; i++ {
|
|
fieldName := fieldPath[i]
|
|
f := currentModel.GetField(fieldName)
|
|
if f == nil {
|
|
return "", fmt.Errorf("field %q not found on %s", fieldName, currentModel.Name())
|
|
}
|
|
if f.Type != TypeMany2one {
|
|
return "", fmt.Errorf("field %q on %s is not Many2one, cannot traverse", fieldName, currentModel.Name())
|
|
}
|
|
|
|
comodel := Registry.Get(f.Comodel)
|
|
if comodel == nil {
|
|
return "", fmt.Errorf("comodel %q not found for field %q", f.Comodel, fieldName)
|
|
}
|
|
|
|
// Generate alias and JOIN
|
|
dc.aliasCounter++
|
|
alias := fmt.Sprintf("_j%d", dc.aliasCounter)
|
|
dc.joins = append(dc.joins, joinClause{
|
|
table: comodel.Table(),
|
|
alias: alias,
|
|
on: fmt.Sprintf("%s.%q = %q.\"id\"", currentAlias, f.Column(), alias),
|
|
})
|
|
|
|
currentModel = comodel
|
|
currentAlias = alias
|
|
}
|
|
|
|
// The last segment is the actual field to filter on
|
|
leafField := fieldPath[len(fieldPath)-1]
|
|
qualifiedColumn := fmt.Sprintf("%s.%q", currentAlias, leafField)
|
|
|
|
return dc.compileQualifiedCondition(qualifiedColumn, operator, value)
|
|
}
|
|
|
|
// compileQualifiedCondition compiles a condition with a fully qualified column (alias.column).
|
|
func (dc *DomainCompiler) compileQualifiedCondition(qualifiedColumn, operator string, value Value) (string, error) {
|
|
paramIdx := len(dc.params) + 1
|
|
|
|
switch operator {
|
|
case "=", "!=", "<", ">", "<=", ">=":
|
|
if value == nil || value == false {
|
|
if operator == "=" {
|
|
return fmt.Sprintf("%s IS NULL", qualifiedColumn), nil
|
|
}
|
|
return fmt.Sprintf("%s IS NOT NULL", qualifiedColumn), nil
|
|
}
|
|
dc.params = append(dc.params, value)
|
|
return fmt.Sprintf("%s %s $%d", qualifiedColumn, operator, paramIdx), nil
|
|
|
|
case "in", "not in":
|
|
vals := normalizeSlice(value)
|
|
if vals == nil {
|
|
return "FALSE", nil
|
|
}
|
|
if len(vals) == 0 {
|
|
if operator == "in" {
|
|
return "FALSE", nil
|
|
}
|
|
return "TRUE", nil
|
|
}
|
|
placeholders := make([]string, len(vals))
|
|
for i, v := range vals {
|
|
dc.params = append(dc.params, v)
|
|
placeholders[i] = fmt.Sprintf("$%d", paramIdx+i)
|
|
}
|
|
op := "IN"
|
|
if operator == "not in" {
|
|
op = "NOT IN"
|
|
}
|
|
return fmt.Sprintf("%s %s (%s)", qualifiedColumn, op, strings.Join(placeholders, ", ")), nil
|
|
|
|
case "like", "not like", "ilike", "not ilike":
|
|
dc.params = append(dc.params, wrapLikeValue(value))
|
|
sqlOp := "LIKE"
|
|
switch operator {
|
|
case "not like":
|
|
sqlOp = "NOT LIKE"
|
|
case "ilike":
|
|
sqlOp = "ILIKE"
|
|
case "not ilike":
|
|
sqlOp = "NOT ILIKE"
|
|
}
|
|
return fmt.Sprintf("%s %s $%d", qualifiedColumn, sqlOp, paramIdx), nil
|
|
|
|
case "=like":
|
|
dc.params = append(dc.params, value)
|
|
return fmt.Sprintf("%s LIKE $%d", qualifiedColumn, paramIdx), nil
|
|
|
|
case "=ilike":
|
|
dc.params = append(dc.params, value)
|
|
return fmt.Sprintf("%s ILIKE $%d", qualifiedColumn, paramIdx), nil
|
|
|
|
default:
|
|
dc.params = append(dc.params, value)
|
|
return fmt.Sprintf("%s %s $%d", qualifiedColumn, operator, paramIdx), nil
|
|
}
|
|
}
|
|
|
|
// compileHierarchyOp implements child_of / parent_of by querying the DB for hierarchy IDs.
|
|
// Mirrors: odoo/orm/domains.py _expression._get_hierarchy_ids
|
|
//
|
|
// - child_of: finds all descendants via parent_id traversal, then "id" IN (...)
|
|
// - parent_of: finds all ancestors via parent_id traversal, then "id" IN (...)
|
|
//
|
|
// Requires dc.env to be set for DB access.
|
|
func (dc *DomainCompiler) compileHierarchyOp(column string, value Value, isChildOf bool) (string, error) {
|
|
if dc.env == nil {
|
|
return "", fmt.Errorf("child_of/parent_of requires Environment on DomainCompiler")
|
|
}
|
|
|
|
// Normalize the root ID(s)
|
|
rootIDs := toInt64Slice(value)
|
|
if len(rootIDs) == 0 {
|
|
return "FALSE", nil
|
|
}
|
|
|
|
table := dc.model.Table()
|
|
var allIDs map[int64]bool
|
|
|
|
if isChildOf {
|
|
// child_of: find all descendants (including roots) via parent_id
|
|
allIDs = make(map[int64]bool)
|
|
queue := make([]int64, len(rootIDs))
|
|
copy(queue, rootIDs)
|
|
for _, id := range rootIDs {
|
|
allIDs[id] = true
|
|
}
|
|
|
|
for len(queue) > 0 {
|
|
// Build placeholders for current batch
|
|
placeholders := make([]string, len(queue))
|
|
args := make([]interface{}, len(queue))
|
|
for i, id := range queue {
|
|
args[i] = id
|
|
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
}
|
|
|
|
query := fmt.Sprintf(
|
|
`SELECT "id" FROM %q WHERE "parent_id" IN (%s)`,
|
|
table, strings.Join(placeholders, ", "),
|
|
)
|
|
|
|
rows, err := dc.env.tx.Query(dc.env.ctx, query, args...)
|
|
if err != nil {
|
|
return "", fmt.Errorf("child_of query: %w", err)
|
|
}
|
|
|
|
var nextQueue []int64
|
|
for rows.Next() {
|
|
var childID int64
|
|
if err := rows.Scan(&childID); err != nil {
|
|
rows.Close()
|
|
return "", err
|
|
}
|
|
if !allIDs[childID] {
|
|
allIDs[childID] = true
|
|
nextQueue = append(nextQueue, childID)
|
|
}
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
queue = nextQueue
|
|
}
|
|
} else {
|
|
// parent_of: find all ancestors (including roots) via parent_id
|
|
allIDs = make(map[int64]bool)
|
|
queue := make([]int64, len(rootIDs))
|
|
copy(queue, rootIDs)
|
|
for _, id := range rootIDs {
|
|
allIDs[id] = true
|
|
}
|
|
|
|
for len(queue) > 0 {
|
|
placeholders := make([]string, len(queue))
|
|
args := make([]interface{}, len(queue))
|
|
for i, id := range queue {
|
|
args[i] = id
|
|
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
}
|
|
|
|
query := fmt.Sprintf(
|
|
`SELECT "parent_id" FROM %q WHERE "id" IN (%s) AND "parent_id" IS NOT NULL`,
|
|
table, strings.Join(placeholders, ", "),
|
|
)
|
|
|
|
rows, err := dc.env.tx.Query(dc.env.ctx, query, args...)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parent_of query: %w", err)
|
|
}
|
|
|
|
var nextQueue []int64
|
|
for rows.Next() {
|
|
var parentID int64
|
|
if err := rows.Scan(&parentID); err != nil {
|
|
rows.Close()
|
|
return "", err
|
|
}
|
|
if !allIDs[parentID] {
|
|
allIDs[parentID] = true
|
|
nextQueue = append(nextQueue, parentID)
|
|
}
|
|
}
|
|
rows.Close()
|
|
if err := rows.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
queue = nextQueue
|
|
}
|
|
}
|
|
|
|
if len(allIDs) == 0 {
|
|
return "FALSE", nil
|
|
}
|
|
|
|
// Build "id" IN (1, 2, 3, ...) with parameters
|
|
paramIdx := len(dc.params) + 1
|
|
placeholders := make([]string, 0, len(allIDs))
|
|
for id := range allIDs {
|
|
dc.params = append(dc.params, id)
|
|
placeholders = append(placeholders, fmt.Sprintf("$%d", paramIdx))
|
|
paramIdx++
|
|
}
|
|
|
|
return fmt.Sprintf("%q IN (%s)", column, strings.Join(placeholders, ", ")), nil
|
|
}
|
|
|
|
// compileAnyOp implements 'any' and 'not any' operators.
|
|
// Mirrors: odoo/orm/domains.py for 'any' / 'not any' operators
|
|
//
|
|
// - any: EXISTS (SELECT 1 FROM comodel WHERE comodel.fk = model.id AND <subdomain>)
|
|
// - not any: NOT EXISTS (...)
|
|
//
|
|
// The value must be a Domain (sub-domain) to apply on the comodel.
|
|
func (dc *DomainCompiler) compileAnyOp(column string, value Value, negate bool) (string, error) {
|
|
// Resolve the field to find the comodel
|
|
f := dc.model.GetField(column)
|
|
if f == nil {
|
|
return "", fmt.Errorf("any/not any: field %q not found on %s", column, dc.model.Name())
|
|
}
|
|
|
|
comodel := Registry.Get(f.Comodel)
|
|
if comodel == nil {
|
|
return "", fmt.Errorf("any/not any: comodel %q not found for field %q", f.Comodel, column)
|
|
}
|
|
|
|
// The value should be a Domain (sub-domain for the comodel)
|
|
subDomain, ok := value.(Domain)
|
|
if !ok {
|
|
return "", fmt.Errorf("any/not any: value must be a Domain, got %T", value)
|
|
}
|
|
|
|
// Compile the sub-domain against the comodel
|
|
subCompiler := &DomainCompiler{model: comodel, env: dc.env}
|
|
subWhere, subParams, err := subCompiler.Compile(subDomain)
|
|
if err != nil {
|
|
return "", fmt.Errorf("any/not any: compile subdomain: %w", err)
|
|
}
|
|
|
|
// Rebase parameter indices: shift them by the current param count
|
|
baseIdx := len(dc.params)
|
|
dc.params = append(dc.params, subParams...)
|
|
rebased := subWhere
|
|
// Replace $N with $(N+baseIdx) in the sub-where clause
|
|
for i := len(subParams); i >= 1; i-- {
|
|
old := fmt.Sprintf("$%d", i)
|
|
new := fmt.Sprintf("$%d", i+baseIdx)
|
|
rebased = strings.ReplaceAll(rebased, old, new)
|
|
}
|
|
|
|
// Determine the join condition based on field type
|
|
var joinCond string
|
|
switch f.Type {
|
|
case TypeOne2many:
|
|
// One2many: comodel has a FK pointing back to this model
|
|
inverseField := f.InverseField
|
|
if inverseField == "" {
|
|
return "", fmt.Errorf("any/not any: One2many field %q has no InverseField", column)
|
|
}
|
|
inverseF := comodel.GetField(inverseField)
|
|
if inverseF == nil {
|
|
return "", fmt.Errorf("any/not any: inverse field %q not found on %s", inverseField, comodel.Name())
|
|
}
|
|
joinCond = fmt.Sprintf("%q.%q = %q.\"id\"", comodel.Table(), inverseF.Column(), dc.model.Table())
|
|
|
|
case TypeMany2many:
|
|
// Many2many: use junction table
|
|
relation := f.Relation
|
|
if relation == "" {
|
|
t1, t2 := dc.model.Table(), comodel.Table()
|
|
if t1 > t2 {
|
|
t1, t2 = t2, t1
|
|
}
|
|
relation = fmt.Sprintf("%s_%s_rel", t1, t2)
|
|
}
|
|
col1 := f.Column1
|
|
if col1 == "" {
|
|
col1 = dc.model.Table() + "_id"
|
|
}
|
|
col2 := f.Column2
|
|
if col2 == "" {
|
|
col2 = comodel.Table() + "_id"
|
|
}
|
|
joinCond = fmt.Sprintf(
|
|
"%q.\"id\" IN (SELECT %q FROM %q WHERE %q = %q.\"id\")",
|
|
comodel.Table(), col2, relation, col1, dc.model.Table(),
|
|
)
|
|
|
|
case TypeMany2one:
|
|
// Many2one: this model has the FK
|
|
joinCond = fmt.Sprintf("%q.\"id\" = %q.%q", comodel.Table(), dc.model.Table(), f.Column())
|
|
|
|
default:
|
|
return "", fmt.Errorf("any/not any: field %q is type %s, expected relational", column, f.Type)
|
|
}
|
|
|
|
subJoins := subCompiler.JoinSQL()
|
|
prefix := "EXISTS"
|
|
if negate {
|
|
prefix = "NOT EXISTS"
|
|
}
|
|
|
|
return fmt.Sprintf("%s (SELECT 1 FROM %q%s WHERE %s AND %s)",
|
|
prefix, comodel.Table(), subJoins, joinCond, rebased,
|
|
), nil
|
|
}
|
|
|
|
// toInt64Slice normalizes a value to []int64 for hierarchy operators.
|
|
func toInt64Slice(value Value) []int64 {
|
|
switch v := value.(type) {
|
|
case int64:
|
|
return []int64{v}
|
|
case int:
|
|
return []int64{int64(v)}
|
|
case int32:
|
|
return []int64{int64(v)}
|
|
case float64:
|
|
return []int64{int64(v)}
|
|
case []int64:
|
|
return v
|
|
case []int:
|
|
out := make([]int64, len(v))
|
|
for i, x := range v {
|
|
out[i] = int64(x)
|
|
}
|
|
return out
|
|
case []interface{}:
|
|
out := make([]int64, 0, len(v))
|
|
for _, x := range v {
|
|
switch n := x.(type) {
|
|
case int64:
|
|
out = append(out, n)
|
|
case int:
|
|
out = append(out, int64(n))
|
|
case float64:
|
|
out = append(out, int64(n))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// normalizeSlice converts typed slices to []interface{} for IN/NOT IN operators.
|
|
func normalizeSlice(value Value) []interface{} {
|
|
switch v := value.(type) {
|
|
case []interface{}:
|
|
return v
|
|
case []int64:
|
|
out := make([]interface{}, len(v))
|
|
for i, x := range v {
|
|
out[i] = x
|
|
}
|
|
return out
|
|
case []float64:
|
|
out := make([]interface{}, len(v))
|
|
for i, x := range v {
|
|
out[i] = x
|
|
}
|
|
return out
|
|
case []string:
|
|
out := make([]interface{}, len(v))
|
|
for i, x := range v {
|
|
out[i] = x
|
|
}
|
|
return out
|
|
case []int:
|
|
out := make([]interface{}, len(v))
|
|
for i, x := range v {
|
|
out[i] = x
|
|
}
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// wrapLikeValue wraps a string value with % wildcards for LIKE/ILIKE operators,
|
|
// matching Odoo's behavior where ilike/like auto-wrap the search term.
|
|
// If the value already contains %, it is left as-is.
|
|
// Mirrors: odoo/orm/domains.py _expression._unaccent_wrap (value wrapping)
|
|
func wrapLikeValue(value Value) Value {
|
|
s, ok := value.(string)
|
|
if !ok {
|
|
return value
|
|
}
|
|
if strings.Contains(s, "%") || strings.Contains(s, "_") {
|
|
return value // Already has wildcards, leave as-is
|
|
}
|
|
return "%" + s + "%"
|
|
}
|