diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py index 45fe26daf12..c08b442ce95 100644 --- a/odoo/addons/base/tests/test_expression.py +++ b/odoo/addons/base/tests/test_expression.py @@ -1229,7 +1229,7 @@ class TestQueries(TransactionCase): SELECT COUNT(*) FROM ( SELECT FROM "res_partner" WHERE (("res_partner"."active" = %s) AND ("res_partner"."name"::text LIKE %s)) - LIMIT 1 + LIMIT %s ) t ''']): Model.search_count([('name', 'like', 'foo')], limit=1) @@ -1294,7 +1294,7 @@ class TestQueries(TransactionCase): OR ("ir_model"."model"::text ILIKE %s) ) ORDER BY "ir_model"."model" - LIMIT 100 + LIMIT %s ''']): Model.name_search('foo') @@ -1306,7 +1306,7 @@ class TestQueries(TransactionCase): AND (("ir_model"."model"::text NOT ILIKE %s) OR "ir_model"."model" IS NULL) ) ORDER BY "ir_model"."model" - LIMIT 100 + LIMIT %s ''']): Model.name_search('foo', operator='not ilike') @@ -1426,7 +1426,7 @@ class TestMany2one(TransactionCase): FROM "res_company" WHERE (("res_company"."active" = %s) AND ("res_company"."name"::text LIKE %s)) ORDER BY "res_company"."id" - LIMIT 1 + LIMIT %s )) ORDER BY "res_partner"."complete_name"asc,"res_partner"."id"desc ''']): diff --git a/odoo/tools/query.py b/odoo/tools/query.py index 7cbc7327700..041efce5289 100644 --- a/odoo/tools/query.py +++ b/odoo/tools/query.py @@ -1,21 +1,34 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -import re +from typing import Union -from odoo.tools.sql import make_identifier - -IDENT_RE = re.compile(r'^[a-z_][a-z0-9_$]*$', re.I) +from odoo.tools.sql import make_identifier, SQL, IDENT_RE -def _from_table(table, alias): - """ Return a FROM clause element from ``table`` and ``alias``. """ - if alias == table: - return f'"{alias}"' - elif IDENT_RE.match(table): - return f'"{table}" AS "{alias}"' - else: - return f'({table}) AS "{alias}"' +def _sql_table(table) -> SQL: + """ Wrap an optional table as an SQL object. """ + if isinstance(table, str): + return SQL.identifier(table) if IDENT_RE.match(table) else SQL(f"({table})") + return table + + +def _sql_from_table(alias, table) -> SQL: + """ Return a FROM clause element from ``alias`` and ``table``. """ + if table is None: + return SQL.identifier(alias) + return SQL("%s AS %s", table, SQL.identifier(alias)) + + +def _sql_from_join(kind, alias, table, condition) -> SQL: + """ Return a FROM clause element for a JOIN. """ + return SQL("%s %s ON (%s)", kind, _sql_from_table(alias, table), condition) + + +_SQL_JOINS = { + "JOIN": SQL("JOIN"), + "LEFT JOIN": SQL("LEFT JOIN"), +} def _generate_table_alias(src_table_alias, link): @@ -45,24 +58,22 @@ class Query(object): :param cr: database cursor (for lazy evaluation) :param alias: name or alias of the table - :param table: if given, a table expression (identifier or query) + :param table: a table expression (``str`` or ``SQL`` object), optional """ - def __init__(self, cr, alias, table=None): + def __init__(self, cr, alias: str, table: Union[str, SQL, None] = None): # database cursor self._cr = cr - # tables {alias: table} - self._tables = {alias: table or alias} + # tables {alias: table(SQL|None)} + self._tables = {alias: _sql_table(table)} - # joins {alias: (kind, table, condition, condition_params)} + # joins {alias: (kind(SQL), table(SQL|None), condition(SQL))} self._joins = {} self._raw_joins = {} # {query: params} - # holds the list of WHERE clause elements (to be joined with 'AND'), and - # the list of parameters + # holds the list of WHERE conditions (to be joined with 'AND') self._where_clauses = [] - self._where_params = [] # order, limit, offset self.order = None @@ -72,16 +83,15 @@ class Query(object): # memoized result self._ids = None - def add_table(self, alias, table=None): + def add_table(self, alias: str, table: Union[str, SQL, None] = None): """ Add a table with a given alias to the from clause. """ - assert alias not in self._tables and alias not in self._joins, "Alias %r already in %s" % (alias, str(self)) - self._tables[alias] = table or alias + assert alias not in self._tables and alias not in self._joins, f"Alias {alias!r} already in {self}" + self._tables[alias] = _sql_table(table) self._ids = None - def add_where(self, where_clause, where_params=()): + def add_where(self, where_clause: Union[str, SQL], where_params=()): """ Add a condition to the where clause. """ - self._where_clauses.append(where_clause) - self._where_params.extend(where_params) + self._where_clauses.append(SQL(where_clause, *where_params)) self._ids = None def join(self, lhs_alias, lhs_column, rhs_table, rhs_column, link, extra=None, extra_params=()): @@ -140,88 +150,101 @@ class Query(object): return self._join('LEFT JOIN', lhs_alias, lhs_column, rhs_table, rhs_column, link, extra, extra_params) def _join(self, kind, lhs_alias, lhs_column, rhs_table, rhs_column, link, extra=None, extra_params=()): + sql_kind = _SQL_JOINS.get(kind.upper()) + assert sql_kind is not None, f"Invalid JOIN type {kind!r}" + assert lhs_alias in self._tables or lhs_alias in self._joins, "Alias %r not in %s" % (lhs_alias, str(self)) rhs_alias = _generate_table_alias(lhs_alias, link) assert rhs_alias not in self._tables, "Alias %r already in %s" % (rhs_alias, str(self)) if rhs_alias not in self._joins: - condition = f'"{lhs_alias}"."{lhs_column}" = "{rhs_alias}"."{rhs_column}"' + condition = SQL("%s = %s", SQL.identifier(lhs_alias, lhs_column), SQL.identifier(rhs_alias, rhs_column)) if extra: - condition = condition + " AND " + extra.format(lhs=lhs_alias, rhs=rhs_alias) - condition_params = list(extra_params) - if kind: - self._joins[rhs_alias] = (kind, rhs_table, condition, condition_params) - else: - self._tables[rhs_alias] = rhs_table - self.add_where(condition, condition_params) + extra = SQL(extra, *extra_params) + extra = SQL(extra.code.format(lhs=lhs_alias, rhs=rhs_alias), *extra.params) + condition = SQL("%s AND %s", condition, extra) + self._joins[rhs_alias] = (sql_kind, _sql_table(rhs_table), condition) self._ids = None return rhs_alias - def select(self, *args): - """ Return the SELECT query as a pair ``(query_string, query_params)``. """ - from_clause, where_clause, params = self.get_sql() - query_str = 'SELECT {} FROM {} WHERE {}{}{}{}'.format( - ", ".join(args or [f'"{next(iter(self._tables))}"."id"']), - from_clause, - where_clause or "TRUE", - (" ORDER BY %s" % self.order) if self.order else "", - (" LIMIT %d" % self.limit) if self.limit else "", - (" OFFSET %d" % self.offset) if self.offset else "", - ) - return query_str, params + @property + def table(self) -> str: + """ Return the query's main table, i.e., the first one in the FROM clause. """ + return next(iter(self._tables)) - def subselect(self, *args): + @property + def from_clause(self) -> SQL: + """ Return the FROM clause of ``self``, without the FROM keyword. """ + tables = SQL(", ").join( + _sql_from_table(alias, table) + for alias, table in self._tables.items() + ) + if not (self._joins or self._raw_joins): + return tables + items = [tables] + for alias, (kind, table, condition) in self._joins.items(): + items.append(_sql_from_join(kind, alias, table, condition)) + for join_query, join_params in self._raw_joins.items(): + items.append(SQL(join_query, *join_params)) + return SQL(" ").join(items) + + @property + def where_clause(self) -> SQL: + """ Return the WHERE condition of ``self``, without the WHERE keyword. """ + return SQL(" AND ").join(self._where_clauses) + + def is_empty(self): + """ Return whether the query is known to return nothing. """ + return self._ids == () + + def select(self, *args: Union[str, SQL]) -> SQL: + """ Return the SELECT query as an ``SQL`` object. """ + sql_args = map(SQL, args) if args else [SQL.identifier(self.table, 'id')] + return SQL( + "%s%s%s%s%s%s", + SQL("SELECT %s", SQL(", ").join(sql_args)), + SQL(" FROM %s", self.from_clause), + SQL(" WHERE %s", self.where_clause) if self._where_clauses else SQL(), + SQL(" ORDER BY %s", SQL(self.order)) if self.order else SQL(), + SQL(" LIMIT %s", self.limit) if self.limit else SQL(), + SQL(" OFFSET %s", self.offset) if self.offset else SQL(), + ) + + def subselect(self, *args: Union[str, SQL]) -> SQL: """ Similar to :meth:`.select`, but for sub-queries. This one avoids the ORDER BY clause when possible, and includes parentheses around the subquery. """ if self._ids is not None and not args: # inject the known result instead of the subquery - return "%s", [self._ids or (None,)] + return SQL("%s", self._ids or (None,)) if self.limit or self.offset: # in this case, the ORDER BY clause is necessary - query_str, params = self.select(*args) - return f"({query_str})", params + return SQL("(%s)", self.select(*args)) - from_clause, where_clause, params = self.get_sql() - query_str = '(SELECT {} FROM {} WHERE {})'.format( - ", ".join(args or [f'"{next(iter(self._tables))}"."id"']), - from_clause, - where_clause or "TRUE", + sql_args = map(SQL, args) if args else [SQL.identifier(self.table, 'id')] + return SQL( + "(%s%s%s)", + SQL("SELECT %s", SQL(", ").join(sql_args)), + SQL(" FROM %s", self.from_clause), + SQL(" WHERE %s", self.where_clause) if self._where_clauses else SQL(), ) - return query_str, params - - def is_empty(self): - """ Return whether the query is known to return nothing. """ - return self._ids == () def get_sql(self): """ Returns (query_from, query_where, query_params). """ - tables = [_from_table(table, alias) for alias, table in self._tables.items()] - joins = [] - params = [] - for alias, (kind, table, condition, condition_params) in self._joins.items(): - joins.append(f'{kind} {_from_table(table, alias)} ON ({condition})') - params.extend(condition_params) - - for join_query, query_params in self._raw_joins.items(): - joins.append(join_query) - params.extend(query_params) - - from_clause = " ".join([", ".join(tables)] + joins) - where_clause = " AND ".join(self._where_clauses) - return from_clause, where_clause, params + self._where_params + from_string, from_params = self.from_clause + where_string, where_params = self.where_clause + return from_string, where_string, from_params + where_params def get_result_ids(self): """ Return the result of ``self.select()`` as a tuple of ids. The result is memoized for future use, which avoids making the same query twice. """ if self._ids is None: - query_str, params = self.select() - self._cr.execute(query_str, params) + self._cr.execute(self.select()) self._ids = tuple(row[0] for row in self._cr.fetchall()) return self._ids @@ -242,20 +265,20 @@ class Query(object): # FROM "stuff" # JOIN (SELECT * FROM unnest(%s) WITH ORDINALITY) AS "stuff__ids" # ON ("stuff"."id" = "stuff__ids"."unnest") - # WHERE TRUE # ORDER BY "stuff__ids"."ordinality" alias = self.join( - next(iter(self._tables)), 'id', - 'SELECT * FROM unnest(%s) WITH ORDINALITY', 'unnest', - 'ids', extra_params=[list(ids)], + self.table, 'id', + SQL('(SELECT * FROM unnest(%s) WITH ORDINALITY)', list(ids)), 'unnest', + 'ids', ) - self.order = f'"{alias}"."ordinality"' + self.order = SQL.identifier(alias, 'ordinality') else: - self.add_where(f'"{next(iter(self._tables))}"."id" IN %s', [ids]) + self.add_where(SQL("%s IN %s", SQL.identifier(self.table, 'id'), ids)) self._ids = ids def __str__(self): - return '' % self.select() + sql = self.select() + return f"" def __bool__(self): return bool(self.get_result_ids()) @@ -264,24 +287,12 @@ class Query(object): if self._ids is None: if self.limit or self.offset: # optimization: generate a SELECT FROM, and then count the rows - query_str, params = self.select('') - query_str = f'SELECT COUNT(*) FROM ({query_str}) t' + sql = SQL("SELECT COUNT(*) FROM (%s) t", self.select("")) else: - query_str, params = self.select('COUNT(*)') - self._cr.execute(query_str, params) + sql = self.select('COUNT(*)') + self._cr.execute(sql) return self._cr.fetchone()[0] return len(self.get_result_ids()) def __iter__(self): return iter(self.get_result_ids()) - - # - # deprecated attributes and methods - # - @property - def where_clause(self): - return tuple(self._where_clauses) - - @property - def where_clause_params(self): - return tuple(self._where_params)