From 0f7f163d2e2bb9cc38f2b037dae6df2eae9466a4 Mon Sep 17 00:00:00 2001 From: Raphael Collet Date: Wed, 17 Jun 2020 16:48:00 +0000 Subject: [PATCH] [REF] core: improve the API and implementation of Query Introduce better methods for introducing tables, join clauses and where clauses to the Query object, and other methods to generate a complete SELECT query. The Query object can also behave as a tuple of ids, where the query is made on demand, and the result is memoized. --- odoo/addons/base/tests/test_expression.py | 36 ++- odoo/addons/base/tests/test_osv.py | 92 +++++-- odoo/models.py | 56 ++-- odoo/osv/expression.py | 70 +---- odoo/osv/query.py | 307 +++++++++++----------- 5 files changed, 280 insertions(+), 281 deletions(-) diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py index 88f86fbcb38..4e1c6197b10 100644 --- a/odoo/addons/base/tests/test_expression.py +++ b/odoo/addons/base/tests/test_expression.py @@ -1014,6 +1014,37 @@ class TestQueries(TransactionCase): ''']): Model.search(domain) + def test_order(self): + Model = self.env['res.partner'] + Model.search([('name', 'like', 'foo')]) + + with self.assertQueries([''' + SELECT "res_partner".id + FROM "res_partner" + WHERE (("res_partner"."active" = %s) AND ("res_partner"."name"::text LIKE %s)) + ORDER BY "res_partner"."display_name" + ''']): + Model.search([('name', 'like', 'foo')]) + + with self.assertQueries([''' + SELECT "res_partner".id + FROM "res_partner" + WHERE (("res_partner"."active" = %s) AND ("res_partner"."name"::text LIKE %s)) + ORDER BY "res_partner"."id" + ''']): + Model.search([('name', 'like', 'foo')], order='id') + + def test_count(self): + Model = self.env['res.partner'] + Model.search([('name', 'like', 'foo')]) + + with self.assertQueries([''' + SELECT count(1) + FROM "res_partner" + WHERE (("res_partner"."active" = %s) AND ("res_partner"."name"::text LIKE %s)) + ''']): + Model.search_count([('name', 'like', 'foo')]) + def test_translated_field(self): self.env['res.lang']._activate_lang('fr_FR') Model = self.env['res.partner.title'].with_context(lang='fr_FR') @@ -1057,10 +1088,11 @@ class TestQueries(TransactionCase): with self.assertQueries([''' SELECT "res_users".id - FROM "res_users", "res_partner" AS "res_users__partner_id" + FROM "res_users" + LEFT JOIN "res_partner" AS "res_users__partner_id" ON + ("res_users"."partner_id" = "res_users__partner_id"."id") WHERE ("res_users"."active" = %s) AND ("res_users"."id" = %s) - AND ("res_users"."partner_id" = "res_users__partner_id"."id") AND ("res_users__partner_id"."id" = %s) ORDER BY "res_users__partner_id"."name", "res_users"."login" ''']): diff --git a/odoo/addons/base/tests/test_osv.py b/odoo/addons/base/tests/test_osv.py index ffa0de33174..6dc11d8fd98 100644 --- a/odoo/addons/base/tests/test_osv.py +++ b/odoo/addons/base/tests/test_osv.py @@ -8,41 +8,75 @@ from odoo.tests.common import BaseCase class QueryTestCase(BaseCase): def test_basic_query(self): - query = Query() - query.add_table('product_product') + query = Query(None, 'product_product') query.add_table('product_template') - query.where_clause.append("product_product.template_id = product_template.id") - query.add_join(("product_template", "product_category", "categ_id", "id", "categ_id"), implicit=False, outer=False) # add normal join - query.add_join(("product_product", "res_user", "user_id", "id", "user_id"), implicit=False, outer=True) # outer join - self.assertEqual(query.get_sql()[0].strip(), - """"product_product" LEFT JOIN "res_user" as "product_product__user_id" ON ("product_product"."user_id" = "product_product__user_id"."id"),"product_template" JOIN "product_category" as "product_template__categ_id" ON ("product_template"."categ_id" = "product_template__categ_id"."id") """.strip()) - self.assertEqual(query.get_sql()[1].strip(), """product_product.template_id = product_template.id""".strip()) + query.add_where("product_product.template_id = product_template.id") + # add inner join + alias = query.join("product_template", "categ_id", "product_category", "id", "categ_id") + self.assertEqual(alias, 'product_template__categ_id') + # add left join + alias = query.left_join("product_product", "user_id", "res_user", "id", "user_id") + self.assertEqual(alias, 'product_product__user_id') + + from_clause, where_clause, where_params = query.get_sql() + self.assertEqual(from_clause, + '"product_product", "product_template" JOIN "product_category" AS "product_template__categ_id" ON ("product_template"."categ_id" = "product_template__categ_id"."id") LEFT JOIN "res_user" AS "product_product__user_id" ON ("product_product"."user_id" = "product_product__user_id"."id")') + self.assertEqual(where_clause, "product_product.template_id = product_template.id") def test_query_chained_explicit_joins(self): - query = Query() - query.add_table('product_product') + query = Query(None, 'product_product') query.add_table('product_template') - query.where_clause.append("product_product.template_id = product_template.id") - query.add_join(("product_template", "product_category", "categ_id", "id", "categ_id"), implicit=False, outer=False) # add normal join - query.add_join(("product_template__categ_id", "res_user", "user_id", "id", "user_id"), implicit=False, outer=True) # CHAINED outer join - self.assertEqual(query.get_sql()[0].strip(), - """"product_product","product_template" JOIN "product_category" as "product_template__categ_id" ON ("product_template"."categ_id" = "product_template__categ_id"."id") LEFT JOIN "res_user" as "product_template__categ_id__user_id" ON ("product_template__categ_id"."user_id" = "product_template__categ_id__user_id"."id")""".strip()) - self.assertEqual(query.get_sql()[1].strip(), """product_product.template_id = product_template.id""".strip()) + query.add_where("product_product.template_id = product_template.id") + # add inner join + alias = query.join("product_template", "categ_id", "product_category", "id", "categ_id") + self.assertEqual(alias, 'product_template__categ_id') + # add CHAINED left join + alias = query.left_join("product_template__categ_id", "user_id", "res_user", "id", "user_id") + self.assertEqual(alias, 'product_template__categ_id__user_id') + + from_clause, where_clause, where_params = query.get_sql() + self.assertEqual(from_clause, + '"product_product", "product_template" JOIN "product_category" AS "product_template__categ_id" ON ("product_template"."categ_id" = "product_template__categ_id"."id") LEFT JOIN "res_user" AS "product_template__categ_id__user_id" ON ("product_template__categ_id"."user_id" = "product_template__categ_id__user_id"."id")') + self.assertEqual(where_clause, "product_product.template_id = product_template.id") def test_mixed_query_chained_explicit_implicit_joins(self): - query = Query() - query.add_table('product_product') + query = Query(None, 'product_product') query.add_table('product_template') - query.where_clause.append("product_product.template_id = product_template.id") - query.add_join(("product_template", "product_category", "categ_id", "id", "categ_id"), implicit=False, outer=False) # add normal join - query.add_join(("product_template__categ_id", "res_user", "user_id", "id", "user_id"), implicit=False, outer=True) # CHAINED outer join - query.add_table('account_account') - query.where_clause.append("product_category.expense_account_id = account_account.id") # additional implicit join - self.assertEqual(query.get_sql()[0].strip(), - """"product_product","product_template" JOIN "product_category" as "product_template__categ_id" ON ("product_template"."categ_id" = "product_template__categ_id"."id") LEFT JOIN "res_user" as "product_template__categ_id__user_id" ON ("product_template__categ_id"."user_id" = "product_template__categ_id__user_id"."id"),"account_account" """.strip()) - self.assertEqual(query.get_sql()[1].strip(), """product_product.template_id = product_template.id AND product_category.expense_account_id = account_account.id""".strip()) + query.add_where("product_product.template_id = product_template.id") + # add inner join + alias = query.join("product_template", "categ_id", "product_category", "id", "categ_id") + self.assertEqual(alias, 'product_template__categ_id') + # add CHAINED left join + alias = query.left_join("product_template__categ_id", "user_id", "res_user", "id", "user_id") + self.assertEqual(alias, 'product_template__categ_id__user_id') + # additional implicit join + query.add_table('account.account') + query.add_where("product_category.expense_account_id = account_account.id") + + from_clause, where_clause, where_params = query.get_sql() + self.assertEqual(from_clause, + '"product_product", "product_template", "account.account" JOIN "product_category" AS "product_template__categ_id" ON ("product_template"."categ_id" = "product_template__categ_id"."id") LEFT JOIN "res_user" AS "product_template__categ_id__user_id" ON ("product_template__categ_id"."user_id" = "product_template__categ_id__user_id"."id")') + self.assertEqual(where_clause, "product_product.template_id = product_template.id AND product_category.expense_account_id = account_account.id") def test_raise_missing_lhs(self): - query = Query() - query.add_table('product_product') - self.assertRaises(AssertionError, query.add_join, ("product_template", "product_category", "categ_id", "id", "categ_id"), implicit=False, outer=False) + query = Query(None, 'product_product') + with self.assertRaises(AssertionError): + query.join("product_template", "categ_id", "product_category", "id", "categ_id") + + def test_long_aliases(self): + query = Query(None, 'product_product') + tmp = query.join('product_product', 'product_tmpl_id', 'product_template', 'id', 'product_tmpl_id') + self.assertEqual(tmp, 'product_product__product_tmpl_id') + # no hashing + tmp_cat = query.join(tmp, 'product_category_id', 'product_category', 'id', 'product_category_id') + self.assertEqual(tmp_cat, 'product_product__product_tmpl_id__product_category_id') + # hashing to limit identifier length + tmp_cat_cmp = query.join(tmp_cat, 'company_id', 'res_company', 'id', 'company_id') + self.assertEqual(tmp_cat_cmp, 'product_product__product_tmpl_id__product_category_id__9f0ddff7') + tmp_cat_stm = query.join(tmp_cat, 'salesteam_id', 'res_company', 'id', 'salesteam_id') + self.assertEqual(tmp_cat_stm, 'product_product__product_tmpl_id__product_category_id__953a466f') + # extend hashed identifiers + tmp_cat_cmp_par = query.join(tmp_cat_cmp, 'partner_id', 'res_partner', 'id', 'partner_id') + self.assertEqual(tmp_cat_cmp_par, 'product_product__product_tmpl_id__product_category_id__56d55687') + tmp_cat_stm_par = query.join(tmp_cat_stm, 'partner_id', 'res_partner', 'id', 'partner_id') + self.assertEqual(tmp_cat_stm_par, 'product_product__product_tmpl_id__product_category_id__9_363fdd') diff --git a/odoo/models.py b/odoo/models.py index 99486832526..5eb58b7aad4 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -2362,11 +2362,13 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): """ inherits_field = current_model._inherits[parent_model_name] parent_model = self.env[parent_model_name] - parent_alias, parent_alias_statement = query.add_join((current_model._table, parent_model._table, inherits_field, 'id', inherits_field), implicit=True) + parent_alias = query.left_join( + current_model._table, inherits_field, parent_model._table, 'id', inherits_field, + ) return parent_alias @api.model - def _inherits_join_calc(self, alias, fname, query, implicit=True, outer=False): + def _inherits_join_calc(self, alias, fname, query): """ Adds missing table select and join clause(s) to ``query`` for reaching the field coming from an '_inherits' parent table (no duplicates). @@ -2383,9 +2385,8 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})): parent_model = self.env[field.related_field.model_name] parent_fname = field.related[0] # JOIN parent_model._table AS parent_alias ON alias.parent_fname = parent_alias.id - parent_alias, _ = query.add_join( - (alias, parent_model._table, parent_fname, 'id', parent_fname), - implicit=implicit, outer=outer, + parent_alias = query.left_join( + alias, parent_fname, parent_model._table, 'id', parent_fname, ) model, alias, field = parent_model, parent_alias, field.related_field # handle the case where the field is translated @@ -3057,8 +3058,7 @@ Fields: cr, user, context, su = env.args # make a query object for selecting ids, and apply security rules to it - param_ids = object() - query = Query(['"%s"' % self._table], ['"%s".id IN %%s' % self._table], [param_ids]) + query = Query(self.env.cr, self._table) self._apply_ir_rules(query, 'read') # the query may involve several tables: we need fully-qualified names @@ -3073,17 +3073,13 @@ Fields: # selected fields are: 'id' followed by fields_pre qual_names = [qualify(name) for name in [self._fields['id']] + fields_pre] - # determine the actual query to execute - from_clause, where_clause, params = query.get_sql() - query_str = "SELECT %s FROM %s WHERE %s" % (",".join(qual_names), from_clause, where_clause) - - # fetch one list of record values per field - param_pos = params.index(param_ids) + # determine the actual query to execute (last parameter is added below) + query.add_where('"%s".id IN %%s' % self._table) + query_str, params = query.select(*qual_names) result = [] for sub_ids in cr.split_for_in_conditions(self.ids): - params[param_pos] = tuple(sub_ids) - cr.execute(query_str, params) + cr.execute(query_str, params + [sub_ids]) result += cr.fetchall() else: self.check_access_rule('read') @@ -3325,21 +3321,18 @@ Fields: if not self._ids: return self - quoted_table = '"%s"' % self._table - query = Query([quoted_table]) + query = Query(self._cr, self._table) self._apply_ir_rules(query, operation) if not query.where_clause: return self # detemine ids in database that satisfy ir.rules valid_ids = set() - from_clause, where_clause, where_params = query.get_sql() - query_str = "SELECT {}.id FROM {} WHERE {} AND {}.id IN %s".format( - quoted_table, from_clause, where_clause, quoted_table, - ) + query.add_where(f'"{self._table}".id IN %s') + query_str, params = query.select() self._flush_search([]) for sub_ids in self._cr.split_for_in_conditions(self.ids): - self._cr.execute(query_str, where_params + [sub_ids]) + self._cr.execute(query_str, params + [sub_ids]) valid_ids.update(row[0] for row in self._cr.fetchall()) # return new ids without origin and ids with origin in valid_ids @@ -4160,7 +4153,7 @@ Fields: if domain: return expression.expression(domain, self).query else: - return Query(['"%s"' % self._table]) + return Query(self.env.cr, self._table) def _check_qorder(self, word): if not regex_order.match(word): @@ -4203,10 +4196,8 @@ Fields: :return: the qualified field name (or expression) to use for ``field`` """ if self.env.lang: - alias, alias_statement = query.add_join( - (table_alias, 'ir_translation', 'id', 'res_id', field), - implicit=False, - outer=True, + alias = query.left_join( + table_alias, 'id', 'ir_translation', 'res_id', field, extra='"{rhs}"."type" = \'model\' AND "{rhs}"."name" = %s AND "{rhs}"."lang" = %s AND "{rhs}"."value" != %s', extra_params=["%s,%s" % (self._name, field), self.env.lang, ""], ) @@ -4246,8 +4237,7 @@ Fields: # Join the dest m2o table if it's not joined yet. We use [LEFT] OUTER join here # as we don't want to exclude results that have NULL values for the m2o - join = (alias, dest_model._table, order_field, 'id', order_field) - dest_alias, _ = query.add_join(join, implicit=False, outer=True) + dest_alias = query.left_join(alias, order_field, dest_model._table, 'id', order_field) return dest_model._generate_order_by_inner(dest_alias, m2o_order, query, reverse_direction, seen) @@ -4281,7 +4271,7 @@ Fields: seen.add(key) order_by_elements += self._generate_m2o_order_by(alias, order_field, query, do_reverse, seen) elif field.store and field.column_type: - qualifield_name = self._inherits_join_calc(alias, order_field, query, implicit=False, outer=True) + qualifield_name = self._inherits_join_calc(alias, order_field, query) if field.type == 'boolean': qualifield_name = "COALESCE(%s, false)" % qualifield_name order_by_elements.append("%s %s" % (qualifield_name, order_direction)) @@ -4402,10 +4392,8 @@ Fields: if count: # Ignore order, limit and offset when just counting, they don't make sense and could # hurt performance - from_clause, where_clause, where_clause_params = query.get_sql() - where_str = where_clause and (" WHERE %s" % where_clause) or '' - query_str = 'SELECT count(1) FROM ' + from_clause + where_str - self._cr.execute(query_str, where_clause_params) + query_str, params = query.select("count(1)") + self._cr.execute(query_str, params) res = self._cr.fetchone() return res[0] diff --git a/odoo/osv/expression.py b/odoo/osv/expression.py index 90dd394537c..e5733164567 100644 --- a/odoo/osv/expression.py +++ b/odoo/osv/expression.py @@ -119,7 +119,6 @@ import warnings import logging import traceback from functools import partial -from zlib import crc32 from datetime import date, datetime, time import odoo.modules @@ -339,55 +338,6 @@ def _quote(to_quote): return to_quote -def generate_table_alias(src_table_alias, joined_tables=[]): - """ Generate a standard table alias name. An alias is generated as following: - - the base is the source table name (that can already be an alias) - - then, each joined table is added in the alias using a 'link field name' - that is used to render unique aliases for a given path - - returns a tuple composed of the alias, and the full table alias to be - added in a from condition with quoting done - Examples: - - src_table_alias='res_users', join_tables=[]: - alias = ('res_users','"res_users"') - - src_model='res_users', join_tables=[(res.partner, 'parent_id')] - alias = ('res_users__parent_id', '"res_partner" as "res_users__parent_id"') - - :param model src_table_alias: model source of the alias - :param list joined_tables: list of tuples - (dst_model, link_field) - - :return tuple: (table_alias, alias statement for from clause with quotes added) - """ - alias = src_table_alias - if not joined_tables: - return '%s' % alias, '%s' % _quote(alias) - for link in joined_tables: - alias += '__' + link[1] - # Use an alternate alias scheme if length exceeds the PostgreSQL limit - # of 63 characters. - if len(alias) >= 64: - # We have to fit a crc32 hash and one underscore - # into a 63 character alias. The remaining space we can use to add - # a human readable prefix. - alias_hash = hex(crc32(alias.encode('utf-8')))[2:] - ALIAS_PREFIX_LENGTH = 63 - len(alias_hash) - 1 - alias = "%s_%s" % ( - alias[:ALIAS_PREFIX_LENGTH], alias_hash) - return '%s' % alias, '%s as %s' % (_quote(joined_tables[-1][0]), _quote(alias)) - - -def get_alias_from_query(from_query): - """ :param string from_query: is something like : - - '"res_partner"' OR - - '"res_partner" as "res_users__partner_id"'' - """ - from_splitted = from_query.split(' as ') - if len(from_splitted) > 1: - return from_splitted[0].replace('"', ''), from_splitted[1].replace('"', '') - else: - return from_splitted[0].replace('"', ''), from_splitted[0].replace('"', '') - - def normalize_leaf(element): """ Change a term's operator to some canonical form, simplifying later processing. """ @@ -482,7 +432,7 @@ class expression(object): self.expression = distribute_not(normalize_domain(domain)) # this object handles all the joins - self.query = Query(['"%s"' % model._table]) if query is None else query + self.query = Query(model.env.cr, model._table) if query is None else query # parse the domain expression self.parse() @@ -692,9 +642,8 @@ class expression(object): elif field.inherited: parent_model = model.env[field.related_field.model_name] parent_fname = model._inherits[parent_model._name] - parent_alias, _ = self.query.add_join( - (alias, parent_model._table, parent_fname, 'id', parent_fname), - implicit=False, outer=True, + parent_alias = self.query.left_join( + alias, parent_fname, parent_model._table, 'id', parent_fname, ) push(leaf, parent_model, parent_alias) @@ -719,9 +668,8 @@ class expression(object): elif len(path) > 1 and field.store and field.type == 'many2one' and field.auto_join: # res_partner.state_id = res_partner__state_id.id - coalias, _ = self.query.add_join( - (alias, comodel._table, path[0], 'id', path[0]), - implicit=False, outer=True, + coalias = self.query.left_join( + alias, path[0], comodel._table, 'id', path[0], ) push((path[1], operator, right), comodel, coalias) @@ -729,10 +677,7 @@ class expression(object): # use a subquery bypassing access rules and business logic domain = [(path[1], operator, right)] + field.get_domain_list(model) query = comodel.with_context(**field.context)._where_calc(domain) - subfrom, subwhere, subparams = query.get_sql() - subquery = 'SELECT "{}"."{}" FROM {} WHERE {}'.format( - comodel._table, field.inverse_name, subfrom, subwhere, - ) + subquery, subparams = query.select('"%s"."%s"' % (comodel._table, field.inverse_name)) push(('id', 'inselect', (subquery, subparams)), model, alias, internal=True) elif len(path) > 1 and field.store and field.auto_join: @@ -968,8 +913,7 @@ class expression(object): [self.result] = result_stack where_clause, where_params = self.result - self.query.where_clause.append(where_clause) - self.query.where_clause_params.extend(where_params) + self.query.add_where(where_clause, where_params) def __leaf_to_sql(self, leaf, model, alias): left, operator, right = leaf diff --git a/odoo/osv/query.py b/odoo/osv/query.py index 79b7a8424ff..18302fa1d70 100644 --- a/odoo/osv/query.py +++ b/odoo/osv/query.py @@ -2,6 +2,9 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import warnings +from zlib import crc32 + +from odoo.tools import lazy_property def _quote(to_quote): @@ -10,177 +13,175 @@ def _quote(to_quote): return to_quote +def _generate_table_alias(src_table_alias, link): + """ Generate a standard table alias name. An alias is generated as following: + - the base is the source table name (that can already be an alias) + - then, the joined table is added in the alias using a 'link field name' + that is used to render unique aliases for a given path + - the name is shortcut if it goes beyond PostgreSQL's identifier limits + + Examples: + - src_table_alias='res_users', link='parent_id' + alias = 'res_users__parent_id' + + :param str src_table_alias: alias of the source table + :param str link: field name + :return str: alias + """ + alias = "%s__%s" % (src_table_alias, link) + # Use an alternate alias scheme if length exceeds the PostgreSQL limit + # of 63 characters. + if len(alias) >= 64: + # We have to fit a crc32 hash and one underscore into a 63 character + # alias. The remaining space we can use to add a human readable prefix. + alias_hash = hex(crc32(alias.encode('utf-8')))[2:] + ALIAS_PREFIX_LENGTH = 63 - len(alias_hash) - 1 + alias = "%s_%s" % (alias[:ALIAS_PREFIX_LENGTH], alias_hash) + return alias + + class Query(object): - """ - Dumb implementation of a Query object, using 3 string lists so far - for backwards compatibility with the (table, where_clause, where_params) previously used. - - TODO: To be improved after v6.0 to rewrite part of the ORM and add support for: - - auto-generated multiple table aliases - - multiple joins to the same table with different conditions - - dynamic right-hand-side values in domains (e.g. a.name = a.description) - - etc. + """ Simple implementation of a query object, managing tables with aliases, + join clauses (with aliases, condition and parameters), where clauses (with + parameters), order, limit and offset. """ - def __init__(self, tables=None, where_clause=None, where_clause_params=None, joins=None, extras=None): + def __init__(self, cr, table, where_clause=None, where_params=()): + # database cursor + self._cr = cr - # holds the list of tables joined using default JOIN. - # the table names are stored double-quoted (backwards compatibility) - self._tables = tables or [] + # tables {alias: table} + self._tables = {table: table} + + # joins {alias: (kind, table, condition, condition_params)} + self._joins = {} # holds the list of WHERE clause elements, to be joined with # 'AND' when generating the final query - self.where_clause = where_clause or [] + self._where_clauses = [where_clause] if where_clause else [] # holds the parameters for the formatting of `where_clause`, to be # passed to psycopg's execute method. - self.where_clause_params = where_clause_params or [] + self._where_params = list(where_params) - # holds table joins done explicitly, supporting outer joins. The JOIN - # condition should not be in `where_clause`. The dict is used as follows: - # self.joins = { - # 'table_a': [ - # ('table_b', 'table_a_col1', 'table_b_col', 'LEFT JOIN'), - # ('table_c', 'table_a_col2', 'table_c_col', 'LEFT JOIN'), - # ('table_d', 'table_a_col3', 'table_d_col', 'JOIN'), - # ] - # } - # which should lead to the following SQL: - # SELECT ... FROM "table_a" LEFT JOIN "table_b" ON ("table_a"."table_a_col1" = "table_b"."table_b_col") - # LEFT JOIN "table_c" ON ("table_a"."table_a_col2" = "table_c"."table_c_col") - self.joins = joins or {} + # order, limit, offset + self.order = None + self.limit = None + self.offset = None - # holds extra conditions for table joins that should not be in the where - # clause but in the join condition itself. The dict is used as follows: - # - # self.extras = { - # ('table_a', ('table_b', 'table_a_col1', 'table_b_col', 'LEFT JOIN')): - # ('"table_b"."table_b_col3" = %s', [42]) - # } - # - # which should lead to the following SQL: - # - # SELECT ... FROM "table_a" - # LEFT JOIN "table_b" ON ("table_a"."table_a_col1" = "table_b"."table_b_col" AND "table_b"."table_b_col3" = 42) - # ... - self.extras = extras or {} + def add_table(self, table, alias=None): + """ Add a table with a given alias to the from clause. """ + alias = alias or table + assert alias not in self._tables and alias not in self._joins, "Alias %r already in %s" % (alias, str(self)) + self._tables[alias] = table - def add_table(self, table): - table = _quote(table) - assert table not in self._tables, '%s already in %s' % (table, self) - self._tables.append(table) + def add_where(self, where_clause, where_params=()): + """ Add a condition to the where clause. """ + self._where_clauses.append(where_clause) + self._where_params.extend(where_params) + def join(self, lhs_alias, lhs_column, rhs_table, rhs_column, link, extra=None, extra_params=()): + """ Add an INNER JOIN to the current table (if necessary), and return + the alias corresponding to ``rhs_table``. + """ + return self._join('JOIN', lhs_alias, lhs_column, rhs_table, rhs_column, link, extra, extra_params) + + def left_join(self, lhs_alias, lhs_column, rhs_table, rhs_column, link, extra=None, extra_params=()): + """ Add a LEFT JOIN to the current table (if necessary), and return the + alias corresponding to ``rhs_table``. + """ + 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=()): + 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_params = [] + 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) + + 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 [_quote(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 + + def get_sql(self): + """ Returns (query_from, query_where, query_params). """ + tables = [] + joins = [] + params = [] + for alias, table in self._tables.items(): + tables.append(f'"{table}"' if alias == table else f'"{table}" AS "{alias}"') + for alias, (kind, table, condition, condition_params) in self._joins.items(): + joins.append(f'{kind} "{table}" AS "{alias}" ON ({condition})') + params.extend(condition_params) + + from_clause = " ".join([", ".join(tables)] + joins) + where_clause = " AND ".join(self._where_clauses) + return from_clause, where_clause, params + self._where_params + + @lazy_property + def _result(self): + query_str, params = self.select() + self._cr.execute(query_str, params) + return [row[0] for row in self._cr.fetchall()] + + def __str__(self): + return '' % self.select() + + def __bool__(self): + return bool(self._result) + + def __len__(self): + return len(self._result) + + def __iter__(self): + return iter(self._result) + + # + # deprecated attributes and methods + # @property def tables(self): warnings.warn("deprecated Query.tables, use Query.get_sql() instead", DeprecationWarning) - return self._tables + return tuple( + f'"{table}"' if alias == table else f'"{table}" AS "{alias}"' + for alias, table in self._tables + ) - def _get_table_aliases(self): - from odoo.osv.expression import get_alias_from_query - return [get_alias_from_query(from_statement)[1] for from_statement in self._tables] + @property + def where_clause(self): + return tuple(self._where_clauses) - def _get_alias_mapping(self): - from odoo.osv.expression import get_alias_from_query - mapping = {} - for table in self._tables: - alias, statement = get_alias_from_query(table) - mapping[statement] = table - return mapping + @property + def where_clause_params(self): + return tuple(self._where_params) - def add_join(self, connection, implicit=True, outer=False, extra=None, extra_params=[]): - """ Join a destination table to the current table. - - :param implicit: False if the join is an explicit join. This allows - to fall back on the previous implementation of ``join`` before - OpenERP 7.0. It therefore adds the JOIN specified in ``connection`` - If True, the join is done implicitely, by adding the table alias - in the from clause and the join condition in the where clause - of the query. Implicit joins do not handle outer, extra, extra_params parameters. - :param connection: a tuple ``(lhs, table, lhs_col, col, link)``. - The join corresponds to the SQL equivalent of:: - - (lhs.lhs_col = table.col) - - Note that all connection elements are strings. Please refer to expression.py for more details about joins. - - :param outer: True if a LEFT OUTER JOIN should be used, if possible - (no promotion to OUTER JOIN is supported in case the JOIN - was already present in the query, as for the moment - implicit INNER JOINs are only connected from NON-NULL - columns so it would not be correct (e.g. for - ``_inherits`` or when a domain criterion explicitly - adds filtering) - - :param extra: A string with the extra join condition (SQL), or None. - This is used to provide an additional condition to the join - clause that cannot be added in the where clause (e.g., for LEFT - JOIN concerns). The condition string should refer to the table - aliases as "{lhs}" and "{rhs}". - - :param extra_params: a list of parameters for the `extra` condition. - """ - from odoo.osv.expression import generate_table_alias - (lhs, table, lhs_col, col, link) = connection - alias, alias_statement = generate_table_alias(lhs, [(table, link)]) - - if implicit: - if alias_statement not in self._tables: - self._tables.append(alias_statement) - condition = '("%s"."%s" = "%s"."%s")' % (lhs, lhs_col, alias, col) - self.where_clause.append(condition) - else: - # already joined - pass - return alias, alias_statement - else: - aliases = self._get_table_aliases() - assert lhs in aliases, "Left-hand-side table %s must already be part of the query tables %s!" % (lhs, str(self._tables)) - if alias_statement in self._tables: - # already joined, must ignore (promotion to outer and multiple joins not supported yet) - pass - else: - # add JOIN - self._tables.append(alias_statement) - join_tuple = (alias, lhs_col, col, outer and 'LEFT JOIN' or 'JOIN') - self.joins.setdefault(lhs, []).append(join_tuple) - if extra or extra_params: - extra = (extra or '').format(lhs=lhs, rhs=alias) - self.extras[(lhs, join_tuple)] = (extra, extra_params) - return alias, alias_statement - - def get_sql(self): - """ Returns (query_from, query_where, query_params). """ - from odoo.osv.expression import get_alias_from_query - tables_to_process = list(self._tables) - alias_mapping = self._get_alias_mapping() - from_clause = [] - from_params = [] - - def add_joins_for_table(lhs): - for (rhs, lhs_col, rhs_col, join) in self.joins.get(lhs, []): - tables_to_process.remove(alias_mapping[rhs]) - from_clause.append(' %s %s ON ("%s"."%s" = "%s"."%s"' % \ - (join, alias_mapping[rhs], lhs, lhs_col, rhs, rhs_col)) - extra = self.extras.get((lhs, (rhs, lhs_col, rhs_col, join))) - if extra: - if extra[0]: - from_clause.append(' AND ') - from_clause.append(extra[0]) - if extra[1]: - from_params.extend(extra[1]) - from_clause.append(')') - add_joins_for_table(rhs) - - for pos, table in enumerate(tables_to_process): - if pos > 0: - from_clause.append(',') - from_clause.append(table) - table_alias = get_alias_from_query(table)[1] - if table_alias in self.joins: - add_joins_for_table(table_alias) - - return "".join(from_clause), " AND ".join(self.where_clause), from_params + self.where_clause_params - - def __str__(self): - return '' % self.get_sql() + def add_join(self, connection, implicit=True, outer=False, extra=None, extra_params=()): + warnings.warn("deprecated Query.add_join, use Query.join() or Query.left_join() instead", + DeprecationWarning) + lhs_alias, rhs_table, lhs_column, rhs_column, link = connection + kind = '' if implicit else ('LEFT JOIN' if outer else 'JOIN') + rhs_alias = self._join(kind, lhs_alias, lhs_column, rhs_table, rhs_column, link, extra, extra_params) + return rhs_alias, f'"{rhs_table}" AS "{rhs_alias}"'