From e36b44e82fd10f87fae2840630ee533478533cf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 7 Dec 2012 12:54:24 +0100 Subject: [PATCH] [IMP] [WIP] order_by: now using aliases in _generate_order_by, _generate_o2m_order_by, _inherits_join_add, ... added some tests. Next comits will clean a bit the code, because currently it is a bit messy. bzr revid: tde@openerp.com-20121207115424-x8gkjcqpi8dz96g2 --- openerp/addons/base/tests/test_expression.py | 1 - openerp/addons/base/tests/test_search.py | 49 ++++-- openerp/osv/expression.py | 22 +-- openerp/osv/orm.py | 78 ++++++--- openerp/osv/query.py | 159 +++++++++++++++++-- openerp/test/test_osv.py | 12 +- 6 files changed, 253 insertions(+), 68 deletions(-) diff --git a/openerp/addons/base/tests/test_expression.py b/openerp/addons/base/tests/test_expression.py index 37fa24f5b1c..c43dda85e9b 100644 --- a/openerp/addons/base/tests/test_expression.py +++ b/openerp/addons/base/tests/test_expression.py @@ -432,7 +432,6 @@ class test_expression(common.TransactionCase): partner_state_id_col._auto_join = False partner_parent_id_col._auto_join = False state_country_id_col._auto_join = False - BaseModel._where_calc = self._base_model_where_calc if __name__ == '__main__': unittest2.main() diff --git a/openerp/addons/base/tests/test_search.py b/openerp/addons/base/tests/test_search.py index dc554408667..a64bfcb3e94 100644 --- a/openerp/addons/base/tests/test_search.py +++ b/openerp/addons/base/tests/test_search.py @@ -2,16 +2,17 @@ import unittest2 import openerp.tests.common as common -class test_expression(common.TransactionCase): - def test_search_order(self): +class test_search(common.TransactionCase): + + def test_00_search_order(self): registry, cr, uid = self.registry, self.cr, self.uid - # Create 6 partners with a given name, and a given creation order to - # ensure the order of their ID. Some are set as unactive to verify they - # are by default excluded from the searches and to provide a second - # `order` argument. + # Create 6 partners with a given name, and a given creation order to + # ensure the order of their ID. Some are set as unactive to verify they + # are by default excluded from the searches and to provide a second + # `order` argument. partners = registry('res.partner') c = partners.create(cr, uid, {'name': 'test_search_order_C'}) @@ -23,9 +24,9 @@ class test_expression(common.TransactionCase): # The tests. - # The basic searches should exclude records that have active = False. - # The order of the returned ids should be given by the `order` - # parameter of search(). + # The basic searches should exclude records that have active = False. + # The order of the returned ids should be given by the `order` + # parameter of search(). name_asc = partners.search(cr, uid, [('name', 'like', 'test_search_order%')], order="name asc") self.assertEqual([a, ab, b, c], name_asc, "Search with 'NAME ASC' order failed.") @@ -36,9 +37,9 @@ class test_expression(common.TransactionCase): id_desc = partners.search(cr, uid, [('name', 'like', 'test_search_order%')], order="id desc") self.assertEqual([ab, b, a, c], id_desc, "Search with 'ID DESC' order failed.") - # The inactive records shouldn't be excluded as soon as a condition on - # that field is present in the domain. The `order` parameter of - # search() should support any legal coma-separated values. + # The inactive records shouldn't be excluded as soon as a condition on + # that field is present in the domain. The `order` parameter of + # search() should support any legal coma-separated values. active_asc_id_asc = partners.search(cr, uid, [('name', 'like', 'test_search_order%'), '|', ('active', '=', True), ('active', '=', False)], order="active asc, id asc") self.assertEqual([d, e, c, a, b, ab], active_asc_id_asc, "Search with 'ACTIVE ASC, ID ASC' order failed.") @@ -57,4 +58,28 @@ class test_expression(common.TransactionCase): id_desc_active_desc = partners.search(cr, uid, [('name', 'like', 'test_search_order%'), '|', ('active', '=', True), ('active', '=', False)], order="id desc, active desc") self.assertEqual([e, ab, b, a, d, c], id_desc_active_desc, "Search with 'ID DESC, ACTIVE DESC' order failed.") + def test_10_m2order(self): + registry, cr, uid = self.registry, self.cr, self.uid + users_obj = registry('res.users') + # Find Employee group + group_employee_ref = self.registry('ir.model.data').get_object_reference(cr, uid, 'base', 'group_user') + group_employee_id = group_employee_ref and group_employee_ref[1] or False + + search_user = users_obj.create(cr, uid, {'name': '__search', 'login': '__search', 'groups_id': [(6, 0, [group_employee_id])]}) + a = users_obj.create(cr, uid, {'name': '__test_A', 'login': '__z_test_A'}) + b = users_obj.create(cr, uid, {'name': '__test_B', 'login': '__a_test_B'}) + + # Do: search on res.users, order on a field on res.partner, then res.users + print '\n\n' + user_ids = users_obj.search(cr, search_user, [], order='name asc, login desc') + self.assertTrue(set([search_user, a, b]).issubset(set(user_ids)), 'cacaprout') + + # DO: order on a many2one + print '\n\n' + user_ids = users_obj.search(cr, search_user, [], order='state_id asc, country_id desc, name asc, login desc') + print user_ids[0:25] + # self.assertTrue(set([search_user, a, b]).issubset(set(user_ids)), 'cacaprout') + +if __name__ == '__main__': + unittest2.main() diff --git a/openerp/osv/expression.py b/openerp/osv/expression.py index 8967d9f254a..526b09ccda2 100644 --- a/openerp/osv/expression.py +++ b/openerp/osv/expression.py @@ -319,16 +319,18 @@ def _quote(to_quote): return to_quote -def generate_table_alias(src_model, joined_tables=[]): +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 model table name + - 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 Examples: - - src_model=res.users, join_tables=[]: - alias = '"res_users"' - - src_model=res.users, join_tables=[(res.partner, 'parent_id')] - alias = '"res_partner" as "res_users__parent_id"' + - 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_model: model source of the alias :param list join_tables: list of tuples @@ -336,12 +338,12 @@ def generate_table_alias(src_model, joined_tables=[]): :return tuple: (table alias, alias statement for from clause with quotes added) """ - alias = src_model._table + alias = src_table_alias if not joined_tables: return ('%s' % alias, '%s' % _quote(alias)) for link in joined_tables: alias += '__' + link[1] - return ('%s' % alias, '%s as %s' % (_quote(joined_tables[-1][0]._table), _quote(alias))) + return ('%s' % alias, '%s as %s' % (_quote(joined_tables[-1][0]), _quote(alias))) def normalize_leaf(element): @@ -499,8 +501,8 @@ class ExtendedLeaf(object): # -------------------------------------------------- def generate_alias(self): - links = [(context[1], context[4]) for context in self.context_stack] - alias, alias_statement = generate_table_alias(self._tables[0], links) + links = [(context[1]._table, context[4]) for context in self.context_stack] + alias, alias_statement = generate_table_alias(self._tables[0]._table, links) return alias def add_join_context(self, table, lhs_col, table_col, link): diff --git a/openerp/osv/orm.py b/openerp/osv/orm.py index a08126d12fc..d2c0a0f5682 100644 --- a/openerp/osv/orm.py +++ b/openerp/osv/orm.py @@ -58,7 +58,6 @@ import types import psycopg2 from lxml import etree -import warnings import fields import openerp @@ -2720,20 +2719,23 @@ class BaseModel(object): return result - def _inherits_join_add(self, current_table, parent_model_name, query): + def _inherits_join_add(self, current_model, parent_model_name, query): """ Add missing table SELECT and JOIN clause to ``query`` for reaching the parent table (no duplicates) - :param current_table: current model object + :param current_model: current model object :param parent_model_name: name of the parent model for which the clauses should be added :param query: query object on which the JOIN should be added """ - inherits_field = current_table._inherits[parent_model_name] + inherits_field = current_model._inherits[parent_model_name] parent_model = self.pool.get(parent_model_name) - parent_table_name = parent_model._table - quoted_parent_table_name = '"%s"' % parent_table_name - if quoted_parent_table_name not in query.tables: - query.tables.append(quoted_parent_table_name) - query.where_clause.append('(%s.%s = %s.id)' % (current_table._table, inherits_field, parent_table_name)) + parent_alias = query.add_implicit_join((current_model, parent_model, inherits_field, 'id', inherits_field)) + return parent_alias + # table_alias = expression.generate_table_alias(current_model, [(parent_model, inherits_field)]) + # print '\t... _inherits_join_add trying to add %s in %s' % (table_alias, query.tables) + # # query.add_table(table_alias) + # if table_alias not in query.tables: + # query.tables.append(table_alias) + # query.where_clause.append('("%s".%s = %s.id)' % (current_model._table, inherits_field, table_alias)) def _inherits_join_calc(self, field, query): """ @@ -2744,13 +2746,15 @@ class BaseModel(object): :param query: query object on which the JOIN should be added :return: qualified name of field, to be used in SELECT clause """ + print '\t--> _inherits_join_calc' current_table = self + parent_alias = current_table._table while field in current_table._inherit_fields and not field in current_table._columns: parent_model_name = current_table._inherit_fields[field][0] parent_table = self.pool.get(parent_model_name) - self._inherits_join_add(current_table, parent_model_name, query) + parent_alias = self._inherits_join_add(current_table, parent_model_name, query) current_table = parent_table - return '"%s".%s' % (current_table._table, field) + return '%s."%s"' % (parent_alias, field) def _parent_store_compute(self, cr): if not self._parent_store: @@ -4659,28 +4663,49 @@ class BaseModel(object): :param query: the current query object """ def apply_rule(added_clause, added_params, added_tables, parent_model=None, child_object=None): + """ :param string parent_model: string of the parent model + :param model child_object: model object, base of the rule application + """ if added_clause: + print '--> _apply_ir_rules.apply_rule,', added_clause, added_params, added_tables, parent_model, child_object if parent_model and child_object: + print '\t... calling inherits-Join_add with parent_model %s, child_object %s' % (parent_model, child_object) # as inherited rules are being applied, we need to add the missing JOIN # to reach the parent table (if it was not JOINed yet in the query) - child_object._inherits_join_add(child_object, parent_model, query) + parent_alias = child_object._inherits_join_add(child_object, parent_model, query) + # inherited rules are applied on the external table -> need to get the alias and replace + parent_table = self.pool.get(parent_model)._table + print parent_table, parent_alias + added_clause = [clause.replace('"%s"' % parent_table, '"%s"' % parent_alias) for clause in added_clause] + # not sure of myself here (in the ORM, this statment is quite cool) + new_tables = [] + for table in added_tables: + if table == '"%s"' % (parent_table): + new_table = '"%s" as "%s"' % (parent_table, parent_alias) + else: + new_table = table.replace('"%s"' % parent_table, '"%s"' % parent_alias) + new_tables.append(new_table) + added_tables = new_tables + print '\t... adding tables %s, clause %s (params %s)' % (added_tables, added_clause, added_params) query.where_clause += added_clause query.where_clause_params += added_params for table in added_tables: - quoted_table_name = '%s' % (table) - if quoted_table_name not in query.tables: - query.tables.append(quoted_table_name) + print '\t... adding table %s in %s' % (table, query.tables) + if table not in query.tables: + query.tables.append(table) return True return False # apply main rules on the object rule_obj = self.pool.get('ir.rule') - apply_rule(*rule_obj.domain_get(cr, uid, self._name, mode, context=context)) + rule_where_clause, rule_where_clause_params, rule_tables = rule_obj.domain_get(cr, uid, self._name, mode, context=context) + apply_rule(rule_where_clause, rule_where_clause_params, rule_tables) # apply ir.rules from the parents (through _inherits) for inherited_model in self._inherits: - kwargs = dict(parent_model=inherited_model, child_object=self) #workaround for python2.5 - apply_rule(*rule_obj.domain_get(cr, uid, inherited_model, mode, context=context), **kwargs) + rule_where_clause, rule_where_clause_params, rule_tables = rule_obj.domain_get(cr, uid, inherited_model, mode, context=context) + apply_rule(rule_where_clause, rule_where_clause_params, rule_tables, + parent_model=inherited_model, child_object=self) def _generate_m2o_order_by(self, order_field, query): """ @@ -4690,6 +4715,7 @@ class BaseModel(object): :return: the qualified field name to use in an ORDER BY clause to sort by ``order_field`` """ + print '_generate_m2o_order_by' if order_field not in self._columns and order_field in self._inherit_fields: # also add missing joins for reaching the table containing the m2o field qualified_field = self._inherits_join_calc(order_field, query) @@ -4715,17 +4741,16 @@ class BaseModel(object): # extract the field names, to be able to qualify them and add desc/asc m2o_order_list = [] for order_part in m2o_order.split(","): - m2o_order_list.append(order_part.strip().split(" ",1)[0].strip()) + m2o_order_list.append(order_part.strip().split(" ", 1)[0].strip()) m2o_order = m2o_order_list # 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 - src_table, src_field = qualified_field.replace('"','').split('.', 1) - query.join((src_table, dest_model._table, src_field, 'id'), outer=True) - qualify = lambda field: '"%s"."%s"' % (dest_model._table, field) + src_table, src_field = qualified_field.replace('"', '').split('.', 1) + dst_alias = query.add_join((src_table, dest_model._table, src_field, 'id'), outer=True) + qualify = lambda field: '"%s"."%s"' % (dst_alias, field) return map(qualify, m2o_order) if isinstance(m2o_order, list) else qualify(m2o_order) - def _generate_order_by(self, order_spec, query): """ Attempt to consruct an appropriate ORDER BY clause based on order_spec, which must be @@ -4742,9 +4767,9 @@ class BaseModel(object): subelems.append(' ') order_list.append('"%s"."%s" %s' % (table, subelems[0], subelems[1])) return order_list - order_by_clause = ','.join(_split_order(self._order, self._table)) if order_spec: + print '-->_generate_order_by beginning' order_by_elements = [] self._check_qorder(order_spec) for order_part in order_spec.split(','): @@ -4761,7 +4786,7 @@ class BaseModel(object): elif order_column._type == 'many2one': inner_clause = self._generate_m2o_order_by(order_field, query) else: - continue # ignore non-readable or "non-joinable" fields + continue # ignore non-readable or "non-joinable" fields elif order_field in self._inherit_fields: parent_obj = self.pool.get(self._inherit_fields[order_field][3]) order_column = parent_obj._columns[order_field] @@ -4770,7 +4795,7 @@ class BaseModel(object): elif order_column._type == 'many2one': inner_clause = self._generate_m2o_order_by(order_field, query) else: - continue # ignore non-readable or "non-joinable" fields + continue # ignore non-readable or "non-joinable" fields if inner_clause: if isinstance(inner_clause, list): for clause in inner_clause: @@ -4779,6 +4804,7 @@ class BaseModel(object): order_by_elements.append("%s %s" % (inner_clause, order_direction)) if order_by_elements: order_by_clause = ",".join(order_by_elements) + print '-->_generate_order_by ending' return order_by_clause and (' ORDER BY %s ' % order_by_clause) or '' diff --git a/openerp/osv/query.py b/openerp/osv/query.py index d32ea73b29d..a07def36a31 100644 --- a/openerp/osv/query.py +++ b/openerp/osv/query.py @@ -21,12 +21,21 @@ #.apidoc title: Query object + def _quote(to_quote): if '"' not in to_quote: return '"%s"' % to_quote return to_quote +def _get_alias_from_statement(string): + if len(string.split(' as ')) > 1: + alias = string.split(' as ')[1].replace('"', '') + else: + alias = string.replace('"', '') + return alias + + class Query(object): """ Dumb implementation of a Query object, using 3 string lists so far @@ -44,6 +53,9 @@ class Query(object): # holds the list of tables joined using default JOIN. # the table names are stored double-quoted (backwards compatibility) self.tables = tables or [] + # holds a mapping of table aliases: + # self._table_alias_mapping = {'alias_1': 'table_name'} + self._table_alias_mapping = {} # holds the list of WHERE clause elements, to be joined with # 'AND' when generating the final query @@ -67,7 +79,110 @@ class Query(object): # LEFT JOIN "table_c" ON ("table_a"."table_a_col2" = "table_c"."table_c_col") self.joins = joins or {} - def join(self, connection, outer=False): + def _add_table_alias(self, table_alias): + pass + + def _get_table_aliases(self): + aliases = [] + for table in self.tables: + if len(table.split(' as ')) > 1: + aliases.append(table.split(' as ')[1].replace('"', '')) + else: + aliases.append(table.replace('"', '')) + # print '--', aliases + return aliases + + def _get_alias_mapping(self): + mapping = {} + aliases = self._get_table_aliases() + for alias in aliases: + for table in self.tables: + if '"%s"' % (alias) in table: + mapping.setdefault(alias, table) + return mapping + + def add_new_join(self, connection, implicit=True, outer=False): + """ 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. + :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) + """ + from openerp.osv.expression import generate_table_alias + (lhs, table, lhs_col, col, link) = connection + alias, alias_statement = generate_table_alias(lhs._table, [(table._table, link)]) + + if implicit: + print '\t\t... Query: trying to add %s in %s (received %s)' % (alias_statement, self.tables, connection) + if alias_statement not in self.tables: + self.tables.append(alias_statement) + condition = '("%s"."%s" = "%s"."%s")' % (lhs._table, lhs_col, alias, col) + print '\t\t... added %s' % (condition) + self.where_clause.append(condition) + return alias + else: + (lhs, table, lhs_col, col) = connection + lhs = _quote(lhs) + table = _quote(table) + print connection + aliases = [] + for table in self.tables: + if len(table.split(' as ')) > 1: + aliases.append(table.split(' as ')[1]) + else: + aliases.append(table) + print '--', aliases + aliases = [table.split(' as ') for table in self.tables] + assert lhs in self.aliases, "Left-hand-side table %s must already be part of the query tables %s!" % (lhs, str(self.tables)) + if table in self.tables: + # already joined, must ignore (promotion to outer and multiple joins not supported yet) + pass + else: + # add JOIN + self.tables.append(table) + self.joins.setdefault(lhs, []).append((table, lhs_col, col, outer and 'LEFT JOIN' or 'JOIN')) + return self + + def add_implicit_join(self, connection): + """ Adds an implicit join. This means that left-hand table is added to the + Query.tables (adding a table in the from clause), and that a join + condition is added in Query.where_clause. + + Implicit joins use expression.generate_table_alias to generate the + alias the the joined table. + + :param connection: a tuple``(lhs, table, lhs_col, col, link)`` Please + refer to expression.py for more details about joins. + """ + from openerp.osv.expression import generate_table_alias + (lhs, table, lhs_col, col, link) = connection + alias, alias_statement = generate_table_alias(lhs._table, [(table._table, link)]) + print '\t\t... Query: trying to add %s in %s (received %s)' % (alias_statement, self.tables, connection) + if alias_statement not in self.tables: + self.tables.append(alias_statement) + condition = '("%s"."%s" = "%s"."%s")' % (lhs._table, lhs_col, alias, col) + print '\t\t... added %s' % (condition) + self.where_clause.append(condition) + return alias + + def add_join(self, connection, outer=False): """Adds the JOIN specified in ``connection``. :param connection: a tuple ``(lhs, table, lhs_col, col)``. @@ -75,6 +190,8 @@ class Query(object): (lhs.lhs_col = table.col) + Note that all connection elements are strings. + :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 @@ -83,36 +200,52 @@ class Query(object): ``_inherits`` or when a domain criterion explicitly adds filtering) """ + from openerp.osv.expression import generate_table_alias (lhs, table, lhs_col, col) = connection - lhs = _quote(lhs) - table = _quote(table) - assert lhs in self.tables, "Left-hand-side table must already be part of the query!" - if table in self.tables: + # lhs = _quote(lhs) + # table = _quote(table) + print '\t\t... Query.add_join(): adding connection %s' % str(connection) + + 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)) + + rhs, rhs_statement = generate_table_alias(lhs, [(connection[1], connection[2])]) + print rhs, rhs_statement + + if rhs_statement in self.tables: # already joined, must ignore (promotion to outer and multiple joins not supported yet) pass else: # add JOIN - self.tables.append(table) - self.joins.setdefault(lhs, []).append((table, lhs_col, col, outer and 'LEFT JOIN' or 'JOIN')) - return self + self.tables.append(rhs_statement) + self.joins.setdefault(lhs, []).append((rhs, lhs_col, col, outer and 'LEFT JOIN' or 'JOIN')) + return rhs def get_sql(self): """Returns (query_from, query_where, query_params)""" query_from = '' tables_to_process = list(self.tables) + alias_mapping = self._get_alias_mapping() + + # print 'tables_to_process %s' % (tables_to_process) + # print 'self.joins %s' % (self.joins) + # print 'alias_mapping %s' % (alias_mapping) + def add_joins_for_table(table, query_from): for (dest_table, lhs_col, col, join) in self.joins.get(table, []): - tables_to_process.remove(dest_table) - query_from += ' %s %s ON (%s."%s" = %s."%s")' % \ - (join, dest_table, table, lhs_col, dest_table, col) + # print dest_table + tables_to_process.remove(alias_mapping[dest_table]) + query_from += ' %s %s ON ("%s"."%s" = "%s"."%s")' % \ + (join, alias_mapping[dest_table], table, lhs_col, dest_table, col) query_from = add_joins_for_table(dest_table, query_from) return query_from for table in tables_to_process: query_from += table - if table in self.joins: - query_from = add_joins_for_table(table, query_from) + if _get_alias_from_statement(table) in self.joins: + query_from = add_joins_for_table(_get_alias_from_statement(table), query_from) query_from += ',' query_from = query_from[:-1] # drop last comma return (query_from, " AND ".join(self.where_clause), self.where_clause_params) diff --git a/openerp/test/test_osv.py b/openerp/test/test_osv.py index d3ff75c03d5..2db9cec1cbd 100644 --- a/openerp/test/test_osv.py +++ b/openerp/test/test_osv.py @@ -28,8 +28,8 @@ class QueryTestCase(unittest.TestCase): query = Query() query.tables.extend(['"product_product"','"product_template"']) query.where_clause.append("product_product.template_id = product_template.id") - query.join(("product_template", "product_category", "categ_id", "id"), outer=False) # add normal join - query.join(("product_product", "res_user", "user_id", "id"), outer=True) # outer join + query.add_join(("product_template", "product_category", "categ_id", "id"), outer=False) # add normal join + query.add_join(("product_product", "res_user", "user_id", "id"), outer=True) # outer join self.assertEquals(query.get_sql()[0].strip(), """"product_product" LEFT JOIN "res_user" ON ("product_product"."user_id" = "res_user"."id"),"product_template" JOIN "product_category" ON ("product_template"."categ_id" = "product_category"."id") """.strip()) self.assertEquals(query.get_sql()[1].strip(), """product_product.template_id = product_template.id""".strip()) @@ -38,8 +38,8 @@ class QueryTestCase(unittest.TestCase): query = Query() query.tables.extend(['"product_product"','"product_template"']) query.where_clause.append("product_product.template_id = product_template.id") - query.join(("product_template", "product_category", "categ_id", "id"), outer=False) # add normal join - query.join(("product_category", "res_user", "user_id", "id"), outer=True) # CHAINED outer join + query.add_join(("product_template", "product_category", "categ_id", "id"), outer=False) # add normal join + query.add_join(("product_category", "res_user", "user_id", "id"), outer=True) # CHAINED outer join self.assertEquals(query.get_sql()[0].strip(), """"product_product","product_template" JOIN "product_category" ON ("product_template"."categ_id" = "product_category"."id") LEFT JOIN "res_user" ON ("product_category"."user_id" = "res_user"."id")""".strip()) self.assertEquals(query.get_sql()[1].strip(), """product_product.template_id = product_template.id""".strip()) @@ -48,8 +48,8 @@ class QueryTestCase(unittest.TestCase): query = Query() query.tables.extend(['"product_product"','"product_template"']) query.where_clause.append("product_product.template_id = product_template.id") - query.join(("product_template", "product_category", "categ_id", "id"), outer=False) # add normal join - query.join(("product_category", "res_user", "user_id", "id"), outer=True) # CHAINED outer join + query.add_join(("product_template", "product_category", "categ_id", "id"), outer=False) # add normal join + query.add_join(("product_category", "res_user", "user_id", "id"), outer=True) # CHAINED outer join query.tables.append('"account.account"') query.where_clause.append("product_category.expense_account_id = account_account.id") # additional implicit join self.assertEquals(query.get_sql()[0].strip(),