From f8e573db2350b025afec8407eeb5849c7a31afa4 Mon Sep 17 00:00:00 2001 From: Raphael Collet Date: Tue, 7 Feb 2017 10:21:15 +0100 Subject: [PATCH] [REF] models: refactoring of foreign keys --- odoo/fields.py | 80 ++++++++++++++++++++++++++++++----------------- odoo/models.py | 71 ----------------------------------------- odoo/tools/sql.py | 39 +++++++++++++++++++++++ 3 files changed, 90 insertions(+), 100 deletions(-) diff --git a/odoo/fields.py b/odoo/fields.py index 6d8c85a3a63..ad39822f63e 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -1936,19 +1936,31 @@ class Many2one(_Relational): self.delegate = name in model._inherits.values() def update_db(self, model, columns): - if self.comodel_name not in model.env: - raise ValueError(_('There is no reference available for %s') % (self.comodel_name,)) + comodel = model.env[self.comodel_name] + if not model.is_transient() and comodel.is_transient(): + raise ValueError('Many2one %s from Model to TransientModel is forbidden' % self) + if model.is_transient() and not comodel.is_transient(): + # Many2one relations from TransientModel Model are annoying because + # they can block deletion due to foreign keys. So unless stated + # otherwise, we default them to ondelete='cascade'. + self.ondelete = self.ondelete or 'cascade' return super(Many2one, self).update_db(model, columns) def update_db_column(self, model, column): super(Many2one, self).update_db_column(model, column) + model.pool.post_init(self.update_db_foreign_key, model, column) + + def update_db_foreign_key(self, model, column): comodel = model.env[self.comodel_name] - # Note: ir_actions is inherited, so foreign key doesn't work on it - if comodel._auto and comodel._table != 'ir_actions': - if not column: - model.pool.post_init(model._m2o_add_foreign_key_checked, self.name, comodel, self.ondelete) - else: - model.pool.post_init(model._m2o_fix_foreign_key, model._table, self.name, comodel, self.ondelete) + # ir_actions is inherited, so foreign key doesn't work on it + if not comodel._auto or comodel._table == 'ir_actions': + return + # create/update the foreign key, and reflect it in 'ir.model.constraint' + process = sql.fix_foreign_key if column else sql.add_foreign_key + new = process(model._cr, model._table, self.name, comodel._table, 'id', self.ondelete or 'set null') + if new: + conname = '%s_%s_fkey' % (model._table, self.name) + model.env['ir.model.constraint']._reflect_constraint(model, conname, 'f', None, self._module) def _update(self, records, value): """ Update the cached value of ``self`` for ``records`` with ``value``. @@ -2365,30 +2377,40 @@ class Many2many(_RelationalMulti): def update_db(self, model, columns): cr = model._cr - rel, id1, id2 = self.relation, self.column1, self.column2 - # do not create relations for custom fields as they do not belong to a module - # they will be automatically removed when dropping the corresponding ir.model.field - # table name for custom relation all starts with x_, see __init__ - if not rel.startswith('x_'): - IMR = model.env['ir.model.relation'] - model.pool.post_init(IMR._reflect_relation, model, rel, self._module) - if not sql.table_exists(cr, rel): - if self.comodel_name not in model.env: - raise UserError(_('Many2many comodel does not exist: %r') % (self.comodel_name,)) + # Do not reflect relations for custom fields, as they do not belong to a + # module. They are automatically removed when dropping the corresponding + # 'ir.model.field'. + if not self.manual: + model.pool.post_init(model.env['ir.model.relation']._reflect_relation, + model, self.relation, self._module) + if not sql.table_exists(cr, self.relation): comodel = model.env[self.comodel_name] - cr.execute('CREATE TABLE "%s" ("%s" INTEGER NOT NULL, "%s" INTEGER NOT NULL, UNIQUE("%s","%s"))' % (rel, id1, id2, id1, id2)) - # create foreign key references with ondelete=cascade, unless the targets are SQL views - if sql.table_kind(cr, comodel._table) != 'v': - model.pool.post_init(model._m2o_add_foreign_key_unchecked, rel, id2, comodel, 'cascade', self._module) - if sql.table_kind(cr, model._table) != 'v': - model.pool.post_init(model._m2o_add_foreign_key_unchecked, rel, id1, model, 'cascade', self._module) - - cr.execute('CREATE INDEX ON "%s" ("%s")' % (rel, id1)) - cr.execute('CREATE INDEX ON "%s" ("%s")' % (rel, id2)) - cr.execute("COMMENT ON TABLE \"%s\" IS 'RELATION BETWEEN %s AND %s'" % (rel, model._table, comodel._table)) - _schema.debug("Create table '%s': m2m relation between '%s' and '%s'", rel, model._table, comodel._table) + query = """ + CREATE TABLE "{rel}" ("{id1}" INTEGER NOT NULL, + "{id2}" INTEGER NOT NULL, + UNIQUE("{id1}","{id2}")); + COMMENT ON TABLE "{rel}" IS %s; + CREATE INDEX ON "{rel}" ("{id1}"); + CREATE INDEX ON "{rel}" ("{id2}") + """.format(rel=self.relation, id1=self.column1, id2=self.column2) + cr.execute(query, ['RELATION BETWEEN %s AND %s' % (model._table, comodel._table)]) + _schema.debug("Create table %r: m2m relation between %r and %r", self.relation, model._table, comodel._table) + model.pool.post_init(self.update_db_foreign_keys, model) return True + def update_db_foreign_keys(self, model): + """ Add the foreign keys corresponding to the field's relation table. """ + cr = model._cr + comodel = model.env[self.comodel_name] + reflect = model.env['ir.model.constraint']._reflect_constraint + # create foreign key references with ondelete=cascade, unless the targets are SQL views + if sql.table_kind(cr, model._table) != 'v': + sql.add_foreign_key(cr, self.relation, self.column1, model._table, 'id', 'cascade') + reflect(model, '%s_%s_fkey' % (self.relation, self.column1), 'f', None, self._module) + if sql.table_kind(cr, comodel._table) != 'v': + sql.add_foreign_key(cr, self.relation, self.column2, comodel._table, 'id', 'cascade') + reflect(model, '%s_%s_fkey' % (self.relation, self.column2), 'f', None, self._module) + def read(self, records): comodel = records.env[self.comodel_name] diff --git a/odoo/models.py b/odoo/models.py index 9cc5cc63509..ea5f49056fa 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -106,14 +106,6 @@ def check_method_name(name): if regex_private.match(name): raise AccessError(_('Private methods (such as %s) cannot be called remotely.') % (name,)) -POSTGRES_CONFDELTYPES = { - 'RESTRICT': 'r', - 'NO ACTION': 'a', - 'CASCADE': 'c', - 'SET NULL': 'n', - 'SET DEFAULT': 'd', -} - def intersect(la, lb): return filter(lambda x: x in lb, la) @@ -2050,69 +2042,6 @@ class BaseModel(object): if row['attnotnull']: tools.drop_not_null(self._table, row['attname']) - # checked version: for direct m2o starting from ``self`` - def _m2o_add_foreign_key_checked(self, source_field, dest_model, ondelete): - assert self.is_transient() or not dest_model.is_transient(), \ - 'Many2One relationships from non-transient Model to TransientModel are forbidden' - if self.is_transient() and not dest_model.is_transient(): - # TransientModel relationships to regular Models are annoying - # usually because they could block deletion due to the FKs. - # So unless stated otherwise we default them to ondelete=cascade. - ondelete = ondelete or 'cascade' - field = self._fields[source_field] - self._m2o_add_foreign_key_unchecked(self._table, source_field, dest_model, ondelete, field._module) - - # unchecked version: for custom cases, such as m2m relationships - def _m2o_add_foreign_key_unchecked(self, source_table, source_field, dest_model, ondelete, module): - cr = self._cr - query = 'ALTER TABLE "%s" ADD FOREIGN KEY ("%s") REFERENCES "%s" ON DELETE %s' - cr.execute(query % (source_table, source_field, dest_model._table, ondelete or 'set null')) - _schema.debug("Table '%s': added foreign key '%s' with definition=REFERENCES \"%s\" ON DELETE %s", - source_table, source_field, dest_model._table, ondelete or 'set null') - conname = "%s_%s_fkey" % (source_table, source_field) - self.env['ir.model.constraint']._reflect_constraint(self, conname, 'f', None, module) - - @api.model_cr - def _m2o_fix_foreign_key(self, source_table, source_field, dest_model, ondelete): - # Find FK constraint(s) currently established for the m2o field, - # and see whether they are stale or not - query = """ SELECT confdeltype as ondelete_rule, conname as constraint_name, - cl2.relname as foreign_table - FROM pg_constraint as con, pg_class as cl1, pg_class as cl2, - pg_attribute as att1, pg_attribute as att2 - WHERE con.conrelid = cl1.oid - AND cl1.relname = %s - AND con.confrelid = cl2.oid - AND array_lower(con.conkey, 1) = 1 - AND con.conkey[1] = att1.attnum - AND att1.attrelid = cl1.oid - AND att1.attname = %s - AND array_lower(con.confkey, 1) = 1 - AND con.confkey[1] = att2.attnum - AND att2.attrelid = cl2.oid - AND att2.attname = %s - AND con.contype = 'f' """ - self._cr.execute(query, (source_table, source_field, 'id')) - constraints = self._cr.dictfetchall() - if constraints: - if len(constraints) == 1: - # Is it the right constraint? - cons, = constraints - if cons['ondelete_rule'] != POSTGRES_CONFDELTYPES.get((ondelete or 'set null').upper(), 'a')\ - or cons['foreign_table'] != dest_model._table: - # Wrong FK: drop it and recreate - tools.drop_constraint(self._cr, source_table, cons['constraint_name']) - else: - # it's all good, nothing to do! - return - else: - # Multiple FKs found for the same field, drop them all, and re-create - for cons in constraints: - tools.drop_constraint(self._cr, source_table, cons['constraint_name']) - - # (re-)create the FK - self._m2o_add_foreign_key_checked(source_field, dest_model, ondelete) - @api.model_cr_context def _init_column(self, column_name): """ Initialize the value of the given column for existing rows. """ diff --git a/odoo/tools/sql.py b/odoo/tools/sql.py index 6bdcbb05303..0b3851df651 100644 --- a/odoo/tools/sql.py +++ b/odoo/tools/sql.py @@ -6,6 +6,14 @@ import psycopg2 _schema = logging.getLogger('odoo.schema') +_CONFDELTYPES = { + 'RESTRICT': 'r', + 'NO ACTION': 'a', + 'CASCADE': 'c', + 'SET NULL': 'n', + 'SET DEFAULT': 'd', +} + def table_exists(cr, tablename): """ Return whether the given table exists. """ query = "SELECT 1 FROM pg_class WHERE relkind IN ('r','v') AND relname=%s" @@ -121,6 +129,37 @@ def drop_constraint(cr, tablename, constraintname): except Exception: _schema.warning("Table %r: unable to drop constraint %r!", tablename, constraintname) +def add_foreign_key(cr, tablename1, columnname1, tablename2, columnname2, ondelete): + """ Create the given foreign key, and return ``True``. """ + query = 'ALTER TABLE "{}" ADD FOREIGN KEY ("{}") REFERENCES "{}"("{}") ON DELETE {}' + cr.execute(query.format(tablename1, columnname1, tablename2, columnname2, ondelete)) + _schema.debug("Table %r: added foreign key %r references %r(%r) ON DELETE %s", + tablename1, columnname1, tablename2, columnname2, ondelete) + return True + +def fix_foreign_key(cr, tablename1, columnname1, tablename2, columnname2, ondelete): + """ Update the foreign keys between tables to match the given one, and + return ``True`` if the given foreign key has been recreated. + """ + deltype = _CONFDELTYPES.get(ondelete.upper(), 'a') + query = """ SELECT con.conname, c2.relname, a2.attnum, con.confdeltype as deltype + FROM pg_constraint as con, pg_class as c1, pg_class as c2, + pg_attribute as a1, pg_attribute as a2 + WHERE con.contype='f' AND con.conrelid=c1.oid AND con.confrelid=c2.oid + AND array_lower(con.conkey, 1)=1 AND con.conkey[1]=a1.attnum + AND array_lower(con.confkey, 1)=1 AND con.confkey[1]=a2.attnum + AND a1.attrelid=c1.oid AND a2.attrelid=c2.oid + AND c1.relname=%s AND a1.attname=%s """ + cr.execute(query, (tablename1, columnname1)) + found = False + for fk in cr.fetchall(): + if not found and fk[1:] == (tablename2, columnname2, deltype): + found = True + else: + drop_constraint(cr, tablename1, fk[0]) + if not found: + return add_foreign_key(cr, tablename1, columnname1, tablename2, columnname2, ondelete) + def index_exists(cr, indexname): """ Return whether the given index exists. """ cr.execute("SELECT 1 FROM pg_indexes WHERE indexname=%s", (indexname,))