diff --git a/addons/base_sparse_field/__init__.py b/addons/base_sparse_field/__init__.py index edbaa61c052..567548c78f5 100644 --- a/addons/base_sparse_field/__init__.py +++ b/addons/base_sparse_field/__init__.py @@ -2,11 +2,3 @@ from . import models from . import tests - -def _update_database_schema(cr): - # The reflection of fields is done before updating the model's schema, - # therefore we first update the schema. - cr.execute(""" - ALTER TABLE ir_model_fields - ADD COLUMN serialization_field_id integer REFERENCES ir_model_fields ON DELETE cascade - """) diff --git a/addons/base_sparse_field/__manifest__.py b/addons/base_sparse_field/__manifest__.py index 031a6c179de..ac5a5f6cc36 100644 --- a/addons/base_sparse_field/__manifest__.py +++ b/addons/base_sparse_field/__manifest__.py @@ -14,5 +14,4 @@ 'data': [ 'views/views.xml', ], - 'pre_init_hook': '_update_database_schema', } diff --git a/addons/base_sparse_field/models/models.py b/addons/base_sparse_field/models/models.py index b0ea825b7d3..6d4a5d03401 100644 --- a/addons/base_sparse_field/models/models.py +++ b/addons/base_sparse_field/models/models.py @@ -27,23 +27,23 @@ class IrModelFields(models.Model): return super(IrModelFields, self).write(vals) - def _reflect_values(self, field, existing): - vals = super(IrModelFields, self)._reflect_values(field, existing) + def _reflect_field_params(self, field): + params = super(IrModelFields, self)._reflect_field_params(field) - vals['serialization_field_id'] = None + params['serialization_field_id'] = None if getattr(field, 'sparse', None): model = self.env[field.model_name] serialization_field = model._fields.get(field.sparse) if serialization_field is None: raise UserError(_("Serialization field `%s` not found for sparse field `%s`!") % (field.sparse, field.name)) - serialization_record = self._reflect(serialization_field, existing) - vals['serialization_field_id'] = serialization_record.id + serialization_record = self._reflect_field(serialization_field) + params['serialization_field_id'] = serialization_record.id - return vals + return params def _instanciate_attrs(self, field_data, partial): attrs = super(IrModelFields, self)._instanciate_attrs(field_data, partial) - if field_data['serialization_field_id']: + if field_data.get('serialization_field_id'): serialization_record = self.browse(field_data['serialization_field_id']) attrs['sparse'] = serialization_record.name return attrs diff --git a/addons/mail/models/mail_alias.py b/addons/mail/models/mail_alias.py index 07d6bfb3d58..67233efcd77 100644 --- a/addons/mail/models/mail_alias.py +++ b/addons/mail/models/mail_alias.py @@ -239,6 +239,11 @@ class AliasMixin(models.AbstractModel): if name != 'alias_id': return + # both self and the alias model must be present in 'ir.model' + IM = self.env['ir.model'] + IM._reflect_model(self) + IM._reflect_model(self.env[self.get_alias_model_name({})]) + alias_ctx = { 'alias_model_name': self.get_alias_model_name({}), 'alias_parent_model_name': self._name, diff --git a/addons/website_form/models/models.py b/addons/website_form/models/models.py index f1a80abbb68..7ac2c38d715 100644 --- a/addons/website_form/models/models.py +++ b/addons/website_form/models/models.py @@ -80,7 +80,7 @@ class website_form_model_fields(models.Model): ' SET website_form_blacklisted=true' ' WHERE website_form_blacklisted IS NULL') # add an SQL-level default value on website_form_blacklisted to that - # pure-SQL ir.model.field creations (e.g. in _field_create) generate + # pure-SQL ir.model.field creations (e.g. in _reflect) generate # the right default value for a whitelist (aka fields should be # blacklisted by default) self._cr.execute('ALTER TABLE ir_model_fields ' diff --git a/odoo/addons/base/base.sql b/odoo/addons/base/base.sql index 31b7061cf78..9e3efde69a6 100644 --- a/odoo/addons/base/base.sql +++ b/odoo/addons/base/base.sql @@ -12,49 +12,6 @@ CREATE TABLE ir_act_url (primary key(id)) INHERITS (ir_actions); CREATE TABLE ir_act_server (primary key(id)) INHERITS (ir_actions); CREATE TABLE ir_act_client (primary key(id)) INHERITS (ir_actions); - -CREATE TABLE ir_model ( - id serial, - model varchar NOT NULL, - name varchar, - state varchar, - info text, - transient boolean, - primary key(id) -); - -CREATE TABLE ir_model_fields ( - id serial, - model varchar NOT NULL, - model_id integer references ir_model on delete cascade, - name varchar NOT NULL, - state varchar default 'base', - field_description varchar, - help varchar, - ttype varchar, - relation varchar, - relation_field varchar, - index boolean, - copy boolean, - related varchar, - readonly boolean default False, - required boolean default False, - selectable boolean default False, - translate boolean default False, - relation_table varchar, - column1 varchar, - column2 varchar, - store boolean, - primary key(id) -); - -CREATE TABLE res_lang ( - id serial, - name VARCHAR(64) NOT NULL UNIQUE, - code VARCHAR(16) NOT NULL UNIQUE, - primary key(id) -); - CREATE TABLE res_users ( id serial NOT NULL, active boolean default True, @@ -137,34 +94,6 @@ CREATE TABLE ir_model_data ( primary key(id) ); --- Records foreign keys and constraints installed by a module (so they can be --- removed when the module is uninstalled): --- - for a foreign key: type is 'f', --- - for a constraint: type is 'u' (this is the convention PostgreSQL uses). -CREATE TABLE ir_model_constraint ( - id serial NOT NULL, - date_init timestamp without time zone, - date_update timestamp without time zone, - module integer NOT NULL references ir_module_module on delete restrict, - model integer NOT NULL references ir_model on delete restrict, - type character varying(1) NOT NULL, - definition varchar, - name varchar NOT NULL, - primary key(id) -); - --- Records relation tables (i.e. implementing many2many) installed by a module --- (so they can be removed when the module is uninstalled). -CREATE TABLE ir_model_relation ( - id serial NOT NULL, - date_init timestamp without time zone, - date_update timestamp without time zone, - module integer NOT NULL references ir_module_module on delete restrict, - model integer NOT NULL references ir_model on delete restrict, - name varchar NOT NULL, - primary key(id) -); - CREATE TABLE res_currency ( id serial, name varchar NOT NULL, diff --git a/odoo/addons/base/ir/ir_actions.py b/odoo/addons/base/ir/ir_actions.py index ae5d74a7af6..472fdeafa0c 100644 --- a/odoo/addons/base/ir/ir_actions.py +++ b/odoo/addons/base/ir/ir_actions.py @@ -385,9 +385,8 @@ class IrActionsActWindowView(models.Model): @api.model_cr_context def _auto_init(self): res = super(IrActionsActWindowView, self)._auto_init() - self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = \'act_window_view_unique_mode_per_action\'') - if not self._cr.fetchone(): - self._cr.execute('CREATE UNIQUE INDEX act_window_view_unique_mode_per_action ON ir_act_window_view (act_window_id, view_mode)') + tools.create_unique_index(self._cr, 'act_window_view_unique_mode_per_action', + self._table, ['act_window_id', 'view_mode']) return res diff --git a/odoo/addons/base/ir/ir_attachment.py b/odoo/addons/base/ir/ir_attachment.py index 528df926e5d..0e7e9284f44 100644 --- a/odoo/addons/base/ir/ir_attachment.py +++ b/odoo/addons/base/ir/ir_attachment.py @@ -291,10 +291,8 @@ class IrAttachment(models.Model): @api.model_cr_context def _auto_init(self): res = super(IrAttachment, self)._auto_init() - self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = %s', ('ir_attachment_res_idx',)) - if not self._cr.fetchone(): - self._cr.execute('CREATE INDEX ir_attachment_res_idx ON ir_attachment (res_model, res_id)') - self._cr.commit() + tools.create_index(self._cr, 'ir_attachment_res_idx', + self._table, ['res_model', 'res_id']) return res @api.model diff --git a/odoo/addons/base/ir/ir_filters.py b/odoo/addons/base/ir/ir_filters.py index a25e734c06e..fb6a69f398e 100644 --- a/odoo/addons/base/ir/ir_filters.py +++ b/odoo/addons/base/ir/ir_filters.py @@ -3,7 +3,7 @@ import ast -from odoo import api, fields, models, _ +from odoo import api, fields, models, tools, _ from odoo.exceptions import UserError @@ -150,9 +150,6 @@ class IrFilters(models.Model): def _auto_init(self): result = super(IrFilters, self)._auto_init() # Use unique index to implement unique constraint on the lowercase name (not possible using a constraint) - self._cr.execute("DROP INDEX IF EXISTS ir_filters_name_model_uid_unique_index") # drop old index w/o action - self._cr.execute("SELECT indexname FROM pg_indexes WHERE indexname = 'ir_filters_name_model_uid_unique_action_index'") - if not self._cr.fetchone(): - self._cr.execute("""CREATE UNIQUE INDEX "ir_filters_name_model_uid_unique_action_index" ON ir_filters - (lower(name), model_id, COALESCE(user_id,-1), COALESCE(action_id,-1))""") + tools.create_unique_index(self._cr, 'ir_filters_name_model_uid_unique_action_index', + self._table, ['lower(name)', 'model_id', 'COALESCE(user_id,-1)', 'COALESCE(action_id,-1)']) return result diff --git a/odoo/addons/base/ir/ir_model.py b/odoo/addons/base/ir/ir_model.py index e810be3ab7b..e5acaee9d87 100644 --- a/odoo/addons/base/ir/ir_model.py +++ b/odoo/addons/base/ir/ir_model.py @@ -143,12 +143,11 @@ class IrModel(models.Model): def _drop_table(self): for model in self: table = self.env[model.model]._table - self._cr.execute('select relkind from pg_class where relname=%s', (table,)) - result = self._cr.fetchone() - if result and result[0] == 'v': - self._cr.execute('DROP view %s' % table) - elif result and result[0] == 'r': - self._cr.execute('DROP TABLE %s CASCADE' % table) + kind = tools.table_kind(self._cr, table) + if kind == 'v': + self._cr.execute('DROP VIEW "%s"' % table) + elif kind == 'r': + self._cr.execute('DROP TABLE "%s" CASCADE' % table) return True @api.multi @@ -212,27 +211,30 @@ class IrModel(models.Model): } return self.create(vals).name_get()[0] - def _reflect(self, model): - """ Reflect the given model and its fields in the models 'ir.model' and - 'ir.model.fields'. Also create entries in 'ir.model.data' if the key - 'module' is passed into the context. - """ - cr = self.env.cr - - # create/update the entries in 'ir.model' and 'ir.model.data' - params = { + def _reflect_model_params(self, model): + """ Return the values to write to the database for the given model. """ + return { 'model': model._name, 'name': model._description, 'info': next(cls.__doc__ for cls in type(model).mro() if cls.__doc__), 'state': 'manual' if model._custom else 'base', 'transient': model._transient, } + + def _reflect_model(self, model): + """ Reflect the given model and return the corresponding record. Also + create entries in 'ir.model.data'. + """ + cr = self.env.cr + + # create/update the entries in 'ir.model' and 'ir.model.data' + params = self._reflect_model_params(model) query_update(cr, self._table, params, ['model']) if not cr.rowcount: query_insert(cr, self._table, params) record = self.browse(cr.fetchone()) - self._context['todo'].append((10, record.modified, [['name', 'info', 'transient']])) + self.pool.post_init(record.modified, set(params) - {'model', 'state'}) if model._module == self._context.get('module'): # self._module is the name of the module that last extended self @@ -463,14 +465,9 @@ class IrModelFields(models.Model): if field.name in models.MAGIC_COLUMNS: continue model = self.env[field.model] - self._cr.execute('SELECT relkind FROM pg_class WHERE relname=%s', (model._table,)) - relkind = self._cr.fetchone() - self._cr.execute("""SELECT column_name FROM information_schema.columns - WHERE table_name=%s AND column_name=%s""", - (model._table, field.name)) - column_name = self._cr.fetchone() - if column_name and (relkind and relkind[0] == 'r'): - self._cr.execute('ALTER table "%s" DROP column "%s" cascade' % (model._table, field.name)) + if tools.column_exists(self._cr, model._table, field.name) and \ + tools.table_kind(self._cr, model._table) == 'r': + self._cr.execute('ALTER TABLE "%s" DROP COLUMN "%s" CASCADE' % (model._table, field.name)) if field.state == 'manual' and field.ttype == 'many2many': rel_name = field.relation_table or model._fields[field.name].relation tables_to_drop.add(rel_name) @@ -654,8 +651,15 @@ class IrModelFields(models.Model): res.append((field.id, '%s (%s)' % (field.field_description, field.model))) return res - def _reflect_values(self, field, existing): - """ Return the values corresponding to the given ``field``. """ + @tools.ormcache('model_name') + def _existing_field_data(self, model_name): + """ Return the given model's existing field data. """ + cr = self._cr + cr.execute("SELECT * FROM ir_model_fields WHERE model=%s", [model_name]) + return {row['name']: row for row in cr.dictfetchall()} + + def _reflect_field_params(self, field): + """ Return the values to write to the database for the given field. """ model = self.env['ir.model']._get(field.model_name) return { 'model_id': model.id, @@ -664,6 +668,7 @@ class IrModelFields(models.Model): 'field_description': field.string, 'help': field.help or None, 'ttype': field.type, + 'state': 'manual' if field.manual else 'base', 'relation': field.comodel_name or None, 'index': bool(field.index), 'store': bool(field.store), @@ -679,19 +684,18 @@ class IrModelFields(models.Model): 'column2': field.column2 if field.type == 'many2many' else None, } - def _reflect(self, field, existing): - """ Reflect the given field in the model `ir.model.fields` and return - its corresponding record. - """ - vals = self._reflect_values(field, existing) - existing_data = existing.get(field.name) + def _reflect_field(self, field): + """ Reflect the given field and return its corresponding record. """ + fields_data = self._existing_field_data(field.model_name) + field_data = fields_data.get(field.name) + params = self._reflect_field_params(field) - if existing_data is None: + if field_data is None: cr = self.env.cr # create an entry in this table - query_insert(cr, self._table, vals) + query_insert(cr, self._table, params) record = self.browse(cr.fetchone()) - self._context['todo'].append((20, record.modified, [list(vals)])) + self.pool.post_init(record.modified, list(params)) # create a corresponding xml id module = field._module or self._context.get('module') if module: @@ -703,24 +707,29 @@ class IrModelFields(models.Model): cr.execute(""" INSERT INTO ir_model_data (module, name, model, res_id, date_init, date_update) VALUES (%s, %s, %s, %s, (now() at time zone 'UTC'), (now() at time zone 'UTC')) """, (module, xmlid, record._name, record.id)) - # update existing (for recursive calls) - existing[field.name] = dict(vals, id=record.id) + # update fields_data (for recursive calls) + fields_data[field.name] = dict(params, id=record.id) return record - if not all(existing_data[key] == val for key, val in vals.iteritems()): + diff = {key for key, val in params.items() if field_data[key] != val} + if diff: cr = self.env.cr # update the entry in this table - query_update(cr, self._table, vals, ['model', 'name']) + query_update(cr, self._table, params, ['model', 'name']) record = self.browse(cr.fetchone()) - modified = set(vals) - set(['model', 'name']) - self._context['todo'].append((20, record.modified, [modified])) - # update existing (for recursive calls) - existing_data.update(vals) + self.pool.post_init(record.modified, diff) + # update fields_data (for recursive calls) + field_data.update(params) return record else: # nothing to update, simply return the corresponding record - return self.browse(existing_data['id']) + return self.browse(field_data['id']) + + def _reflect_model(self, model): + """ Reflect the given model's fields. """ + for field in model._fields.itervalues(): + self._reflect_field(field) def _instanciate_attrs(self, field_data, partial): """ Return the parameters for a field instance for ``field_data``. """ @@ -849,6 +858,53 @@ class IrModelConstraint(models.Model): default['name'] = self.name + '_copy' return super(IrModelConstraint, self).copy(default) + def _reflect_constraint(self, model, conname, type, definition, module): + """ Reflect the given constraint, to make it possible to delete it later + when the module is uninstalled. ``type`` is either 'f' or 'u' + depending on the constraint being a foreign key or not. + """ + if not module: + # no need to save constraints for custom models as they're not part + # of any module + return + assert type in ('f', 'u') + cr = self._cr + query = """ SELECT type, definition + FROM ir_model_constraint c, ir_module_module m + WHERE c.module=m.id AND c.name=%s AND m.name=%s """ + cr.execute(query, (conname, module)) + cons = cr.dictfetchone() + if not cons: + query = """ INSERT INTO ir_model_constraint + (name, date_init, date_update, module, model, type, definition) + VALUES (%s, now() AT TIME ZONE 'UTC', now() AT TIME ZONE 'UTC', + (SELECT id FROM ir_module_module WHERE name=%s), + (SELECT id FROM ir_model WHERE model=%s), %s, %s) """ + cr.execute(query, (conname, module, model._name, type, definition)) + elif cons['type'] != type or (definition and cons['definition'] != definition): + query = """ UPDATE ir_model_constraint + SET date_update=now() AT TIME ZONE 'UTC', type=%s, definition=%s + WHERE name=%s AND module=(SELECT id FROM ir_module_module WHERE name=%s) """ + cr.execute(query, (type, definition, conname, module)) + + def _reflect_model(self, model): + """ Reflect the _sql_constraints of the given model. """ + def cons_text(txt): + return txt.lower().replace(', ',',').replace(' (','(') + + # map each constraint on the name of the module where it is defined + constraint_module = { + constraint[0]: cls._module + for cls in reversed(type(model).mro()) + if not getattr(cls, 'pool', None) + for constraint in getattr(cls, '_local_sql_constraints', ()) + } + + for (key, definition, _) in model._sql_constraints: + conname = '%s_%s' % (model._table, key) + module = constraint_module.get(key) + self._reflect_constraint(model, conname, 'u', cons_text(definition), module) + class IrModelRelation(models.Model): """ @@ -884,19 +940,34 @@ class IrModelRelation(models.Model): # as installed modules have defined this element we must not delete it! continue - self._cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name=%s", (name,)) - if self._cr.fetchone(): + if tools.table_exists(self._cr, name): to_drop.add(name) self.unlink() # drop m2m relation tables for table in to_drop: - self._cr.execute('DROP TABLE %s CASCADE' % table,) + self._cr.execute('DROP TABLE "%s" CASCADE' % table,) _logger.info('Dropped table %s', table) self._cr.commit() + def _reflect_relation(self, model, table, module): + """ Reflect the table of a many2many field for the given model, to make + it possible to delete it later when the module is uninstalled. + """ + cr = self._cr + query = """ SELECT 1 FROM ir_model_relation r, ir_module_module m + WHERE r.module=m.id AND r.name=%s AND m.name=%s """ + cr.execute(query, (table, module)) + if not cr.rowcount: + query = """ INSERT INTO ir_model_relation (name, date_init, date_update, module, model) + VALUES (%s, now() AT TIME ZONE 'UTC', now() AT TIME ZONE 'UTC', + (SELECT id FROM ir_module_module WHERE name=%s), + (SELECT id FROM ir_model WHERE model=%s)) """ + cr.execute(query, (table, module, model._name)) + self.invalidate_cache() + class IrModelAccess(models.Model): _name = 'ir.model.access' @@ -1107,12 +1178,10 @@ class IrModelData(models.Model): @api.model_cr_context def _auto_init(self): res = super(IrModelData, self)._auto_init() - self._cr.execute("SELECT indexname FROM pg_indexes WHERE indexname = 'ir_model_data_module_name_uniq_index'") - if not self._cr.fetchone(): - self._cr.execute('CREATE UNIQUE INDEX ir_model_data_module_name_uniq_index ON ir_model_data (module, name)') - self._cr.execute("SELECT indexname FROM pg_indexes WHERE indexname = 'ir_model_data_model_res_id_index'") - if not self._cr.fetchone(): - self._cr.execute('CREATE INDEX ir_model_data_model_res_id_index ON ir_model_data (model, res_id)') + tools.create_unique_index(self._cr, 'ir_model_data_module_name_uniq_index', + self._table, ['module', 'name']) + tools.create_index(self._cr, 'ir_model_data_model_res_id_index', + self._table, ['model', 'res_id']) return res @api.multi diff --git a/odoo/addons/base/ir/ir_translation.py b/odoo/addons/base/ir/ir_translation.py index 5c63e3c7244..4cbd41b013a 100644 --- a/odoo/addons/base/ir/ir_translation.py +++ b/odoo/addons/base/ir/ir_translation.py @@ -255,29 +255,9 @@ class IrTranslation(models.Model): @api.model_cr_context def _auto_init(self): res = super(IrTranslation, self)._auto_init() - cr = self._cr - - cr.execute("SELECT indexname FROM pg_indexes WHERE indexname LIKE 'ir_translation_%'") - indexes = [row[0] for row in cr.fetchall()] - - # Removed because there is a size limit on btree indexed values (problem with column src): - # cr.execute('CREATE INDEX ir_translation_ltns ON ir_translation (name, lang, type, src)') - # cr.execute('CREATE INDEX ir_translation_lts ON ir_translation (lang, type, src)') - # - # Removed because hash indexes are not compatible with postgres streaming replication: - # cr.execute('CREATE INDEX ir_translation_src_hash_idx ON ir_translation USING hash (src)') - if set(indexes) & set(['ir_translation_ltns', 'ir_translation_lts', 'ir_translation_src_hash_idx']): - cr.execute('DROP INDEX IF EXISTS ir_translation_ltns, ir_translation_lts, ir_translation_src_hash_idx') - cr.commit() - # Add separate md5 index on src (no size limit on values, and good performance). - if 'ir_translation_src_md5' not in indexes: - cr.execute('CREATE INDEX ir_translation_src_md5 ON ir_translation (md5(src))') - cr.commit() - - if 'ir_translation_ltn' not in indexes: - cr.execute('CREATE INDEX ir_translation_ltn ON ir_translation (name, lang, type)') - cr.commit() + tools.create_index(self._cr, 'ir_translation_src_md5', self._table, ['md5(src)']) + tools.create_index(self._cr, 'ir_translation_ltn', self._table, ['name', 'lang', 'type']) return res @api.model diff --git a/odoo/addons/base/ir/ir_ui_view.py b/odoo/addons/base/ir/ir_ui_view.py index cd3eb73f96e..9233b08f329 100644 --- a/odoo/addons/base/ir/ir_ui_view.py +++ b/odoo/addons/base/ir/ir_ui_view.py @@ -79,9 +79,8 @@ class ViewCustom(models.Model): @api.model_cr_context def _auto_init(self): res = super(ViewCustom, self)._auto_init() - self._cr.execute("SELECT indexname FROM pg_indexes WHERE indexname = 'ir_ui_view_custom_user_id_ref_id'") - if not self._cr.fetchone(): - self._cr.execute("CREATE INDEX ir_ui_view_custom_user_id_ref_id ON ir_ui_view_custom (user_id, ref_id)") + tools.create_index(self._cr, 'ir_ui_view_custom_user_id_ref_id', + self._table, ['user_id', 'ref_id']) return res @@ -348,9 +347,8 @@ actual arch. @api.model_cr_context def _auto_init(self): res = super(View, self)._auto_init() - self._cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = \'ir_ui_view_model_type_inherit_id\'') - if not self._cr.fetchone(): - self._cr.execute('CREATE INDEX ir_ui_view_model_type_inherit_id ON ir_ui_view (model, inherit_id)') + tools.create_index(self._cr, 'ir_ui_view_model_type_inherit_id', + self._table, ['model', 'inherit_id']) return res def _compute_defaults(self, values): diff --git a/odoo/addons/base/ir/ir_values.py b/odoo/addons/base/ir/ir_values.py index 8614390990f..1134a196d9a 100644 --- a/odoo/addons/base/ir/ir_values.py +++ b/odoo/addons/base/ir/ir_values.py @@ -149,9 +149,8 @@ class IrValues(models.Model): @api.model_cr_context def _auto_init(self): res = super(IrValues, self)._auto_init() - self._cr.execute("SELECT indexname FROM pg_indexes WHERE indexname = 'ir_values_key_model_key2_res_id_user_id_idx'") - if not self._cr.fetchone(): - self._cr.execute("CREATE INDEX ir_values_key_model_key2_res_id_user_id_idx ON ir_values (key, model, key2, res_id, user_id)") + tools.create_index(self._cr, 'ir_values_key_model_key2_res_id_user_id_idx', + self._table, ['key', 'model', 'key2', 'res_id', 'user_id']) return res @api.model diff --git a/odoo/addons/test_new_api/__init__.py b/odoo/addons/test_new_api/__init__.py index 89d26e2f597..361c3498d66 100644 --- a/odoo/addons/test_new_api/__init__.py +++ b/odoo/addons/test_new_api/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- import models +import tests diff --git a/odoo/addons/test_new_api/tests/__init__.py b/odoo/addons/test_new_api/tests/__init__.py index b90ccc96825..b368bc5a45d 100644 --- a/odoo/addons/test_new_api/tests/__init__.py +++ b/odoo/addons/test_new_api/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_attributes from . import test_one2many from . import test_ui from . import test_domain +from . import test_schema diff --git a/odoo/addons/test_new_api/tests/test_schema.py b/odoo/addons/test_new_api/tests/test_schema.py new file mode 100644 index 00000000000..45aea9c0494 --- /dev/null +++ b/odoo/addons/test_new_api/tests/test_schema.py @@ -0,0 +1,534 @@ +# -*- coding: utf-8 -*- +from odoo.models import MetaModel +from odoo.tests import common + + +class TestReflection(common.TransactionCase): + """ Test the reflection into 'ir.model', 'ir.model.fields', etc. """ + + def test_models_fields(self): + """ check that all models and fields are reflected as expected. """ + # retrieve the models defined in this module, and check them + model_names = {cls._name for cls in MetaModel.module_to_models['test_new_api']} + ir_models = self.env['ir.model'].search([('model', 'in', list(model_names))]) + self.assertEqual(len(ir_models), len(model_names)) + for ir_model in ir_models: + model = self.env[ir_model.model] + self.assertEqual(ir_model.name, model._description or False) + self.assertEqual(ir_model.state, 'manual' if model._custom else 'base') + self.assertEqual(ir_model.transient, bool(model._transient)) + self.assertItemsEqual(ir_model.mapped('field_id.name'), list(model._fields)) + for ir_field in ir_model.field_id: + field = model._fields[ir_field.name] + self.assertEqual(ir_field.model, field.model_name) + self.assertEqual(ir_field.field_description, field.string) + self.assertEqual(ir_field.help, field.help or False) + self.assertEqual(ir_field.ttype, field.type) + self.assertEqual(ir_field.state, 'manual' if field.manual else 'base') + self.assertEqual(ir_field.index, bool(field.index)) + self.assertEqual(ir_field.store, bool(field.store)) + self.assertEqual(ir_field.copy, bool(field.copy)) + self.assertEqual(ir_field.related, bool(field.related) and ".".join(field.related)) + self.assertEqual(ir_field.readonly, bool(field.readonly)) + self.assertEqual(ir_field.required, bool(field.required)) + self.assertEqual(ir_field.selectable, bool(field.search or field.store)) + self.assertEqual(ir_field.translate, bool(field.translate)) + if field.relational: + self.assertEqual(ir_field.relation, field.comodel_name) + if field.type == 'one2many' and field.store: + self.assertEqual(ir_field.relation_field, field.inverse_name) + if field.type == 'many2many' and field.store: + self.assertEqual(ir_field.relation_table, field.relation) + self.assertEqual(ir_field.column1, field.column1) + self.assertEqual(ir_field.column2, field.column2) + relation = self.env['ir.model.relation'].search([('name', '=', field.relation)]) + self.assertTrue(relation) + self.assertIn(relation.model.model, [field.model_name, field.comodel_name]) + + +class TestSchema(common.TransactionCase): + + def get_table_data(self, tablename): + query = ''' SELECT table_catalog, table_schema, table_name, table_type, + user_defined_type_catalog, user_defined_type_schema, + user_defined_type_name, is_insertable_into, is_typed + FROM information_schema.tables + WHERE table_name=%s ''' + self.cr.execute(query, [tablename]) + return self.cr.dictfetchone() + + def get_columns_data(self, tablename): + query = ''' SELECT table_catalog, table_schema, table_name, column_name, + column_default, data_type, is_nullable, is_updatable, + character_maximum_length, numeric_precision, + numeric_precision_radix, numeric_scale, + datetime_precision, udt_catalog, udt_schema, udt_name + FROM information_schema.columns + WHERE table_name=%s ''' + self.cr.execute(query, [tablename]) + return {row['column_name']: row for row in self.cr.dictfetchall()} + + def get_foreign_keys(self, tablename): + query = ''' SELECT a.table_name, a.column_name, + b.table_name, b.column_name, c.delete_rule + FROM information_schema.referential_constraints c, + information_schema.key_column_usage a, + information_schema.constraint_column_usage b + WHERE a.constraint_schema=c.constraint_schema + AND a.constraint_name=c.constraint_name + AND b.constraint_schema=c.constraint_schema + AND b.constraint_name=c.constraint_name + AND a.table_name=%s ''' + self.cr.execute(query, [tablename]) + return self.cr.fetchall() + + def test_00_table(self): + """ check the database schema of a model """ + model = self.env['test_new_api.foo'] + self.assertEqual(model._table, 'test_new_api_foo') + + # retrieve schema data about that table + table_data = self.get_table_data('test_new_api_foo') + self.assertEqual(table_data, { + 'is_insertable_into': u'YES', + 'is_typed': u'NO', + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_foo', + 'table_schema': u'public', + 'table_type': u'BASE TABLE', + 'user_defined_type_catalog': None, + 'user_defined_type_name': None, + 'user_defined_type_schema': None, + }) + + # retrieve schema data about the table's columns + columns_data = self.get_columns_data('test_new_api_foo') + self.assertEqual(set(columns_data), + {'id', 'create_date', 'create_uid', 'write_date', + 'write_uid', 'name', 'value1', 'value2'}) + + # retrieve schema data about the table's foreign keys + foreign_keys = self.get_foreign_keys('test_new_api_foo') + self.assertItemsEqual(foreign_keys, [ + ('test_new_api_foo', 'create_uid', 'res_users', 'id', 'SET NULL'), + ('test_new_api_foo', 'write_uid', 'res_users', 'id', 'SET NULL'), + ]) + + def test_10_boolean(self): + """ check the database representation of a boolean field """ + model = self.env['test_new_api.message'] + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['important'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'important', + 'data_type': u'boolean', + 'datetime_precision': None, + 'is_nullable': u'YES', + 'is_updatable': u'YES', + 'numeric_precision': None, + 'numeric_precision_radix': None, + 'numeric_scale': None, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_message', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'bool', + 'udt_schema': u'pg_catalog', + }) + + def test_10_integer(self): + """ check the database representation of an integer field """ + model = self.env['test_new_api.category'] + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['color'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'color', + 'data_type': u'integer', + 'datetime_precision': None, + 'is_nullable': u'YES', + 'is_updatable': u'YES', + 'numeric_precision': 32, + 'numeric_precision_radix': 2, + 'numeric_scale': 0, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_category', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'int4', + 'udt_schema': u'pg_catalog', + }) + + def test_10_float(self): + """ check the database representation of a float field """ + model = self.env['test_new_api.mixed'] + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['number'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'number', + 'data_type': u'numeric', + 'datetime_precision': None, + 'is_nullable': u'YES', + 'is_updatable': u'YES', + 'numeric_precision': None, + 'numeric_precision_radix': 10, + 'numeric_scale': None, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_mixed', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'numeric', + 'udt_schema': u'pg_catalog', + }) + + def test_10_monetary(self): + """ check the database representation of a monetary field """ + model = self.env['test_new_api.mixed'] + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['amount'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'amount', + 'data_type': u'numeric', + 'datetime_precision': None, + 'is_nullable': u'YES', + 'is_updatable': u'YES', + 'numeric_precision': None, + 'numeric_precision_radix': 10, + 'numeric_scale': None, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_mixed', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'numeric', + 'udt_schema': u'pg_catalog', + }) + + def test_10_char(self): + """ check the database representation of a char field """ + model = self.env['res.country'] + self.assertFalse(type(model).code.required) + self.assertEqual(type(model).code.size, 2) + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['code'], { + 'character_maximum_length': 2, + 'column_default': None, + 'column_name': u'code', + 'data_type': u'character varying', + 'datetime_precision': None, + 'is_nullable': u'YES', + 'is_updatable': u'YES', + 'numeric_precision': None, + 'numeric_precision_radix': None, + 'numeric_scale': None, + 'table_catalog': self.cr.dbname, + 'table_name': u'res_country', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'varchar', + 'udt_schema': u'pg_catalog', + }) + + model = self.env['test_new_api.message'] + self.assertFalse(type(model).name.required) + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['name'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'name', + 'data_type': u'character varying', + 'datetime_precision': None, + 'is_nullable': u'YES', + 'is_updatable': u'YES', + 'numeric_precision': None, + 'numeric_precision_radix': None, + 'numeric_scale': None, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_message', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'varchar', + 'udt_schema': u'pg_catalog', + }) + + model = self.env['test_new_api.category'] + self.assertTrue(type(model).name.required) + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['name'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'name', + 'data_type': u'character varying', + 'datetime_precision': None, + 'is_nullable': u'NO', + 'is_updatable': u'YES', + 'numeric_precision': None, + 'numeric_precision_radix': None, + 'numeric_scale': None, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_category', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'varchar', + 'udt_schema': u'pg_catalog', + }) + + def test_10_text(self): + """ check the database representation of a text field """ + model = self.env['test_new_api.message'] + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['body'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'body', + 'data_type': u'text', + 'datetime_precision': None, + 'is_nullable': u'YES', + 'is_updatable': u'YES', + 'numeric_precision': None, + 'numeric_precision_radix': None, + 'numeric_scale': None, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_message', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'text', + 'udt_schema': u'pg_catalog', + }) + + def test_10_html(self): + """ check the database representation of an html field """ + model = self.env['test_new_api.mixed'] + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['comment1'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'comment1', + 'data_type': u'text', + 'datetime_precision': None, + 'is_nullable': u'YES', + 'is_updatable': u'YES', + 'numeric_precision': None, + 'numeric_precision_radix': None, + 'numeric_scale': None, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_mixed', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'text', + 'udt_schema': u'pg_catalog', + }) + + def test_10_date(self): + """ check the database representation of a date field """ + model = self.env['test_new_api.mixed'] + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['date'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'date', + 'data_type': u'date', + 'datetime_precision': 0, + 'is_nullable': u'YES', + 'is_updatable': u'YES', + 'numeric_precision': None, + 'numeric_precision_radix': None, + 'numeric_scale': None, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_mixed', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'date', + 'udt_schema': u'pg_catalog', + }) + + def test_10_datetime(self): + """ check the database representation of a datetime field """ + model = self.env['res.currency.rate'] + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['name'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'name', + 'data_type': u'timestamp without time zone', + 'datetime_precision': 6, + 'is_nullable': u'NO', + 'is_updatable': u'YES', + 'numeric_precision': None, + 'numeric_precision_radix': None, + 'numeric_scale': None, + 'table_catalog': self.cr.dbname, + 'table_name': u'res_currency_rate', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'timestamp', + 'udt_schema': u'pg_catalog', + }) + + model = self.env['test_new_api.mixed'] + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['create_date'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'create_date', + 'data_type': u'timestamp without time zone', + 'datetime_precision': 6, + 'is_nullable': u'YES', + 'is_updatable': u'YES', + 'numeric_precision': None, + 'numeric_precision_radix': None, + 'numeric_scale': None, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_mixed', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'timestamp', + 'udt_schema': u'pg_catalog', + }) + + def test_10_selection(self): + """ check the database representation of a selection field """ + model = self.env['test_new_api.mixed'] + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['lang'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'lang', + 'data_type': u'character varying', + 'datetime_precision': None, + 'is_nullable': u'YES', + 'is_updatable': u'YES', + 'numeric_precision': None, + 'numeric_precision_radix': None, + 'numeric_scale': None, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_mixed', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'varchar', + 'udt_schema': u'pg_catalog', + }) + + def test_10_reference(self): + """ check the database representation of a reference field """ + model = self.env['test_new_api.mixed'] + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['reference'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'reference', + 'data_type': u'character varying', + 'datetime_precision': None, + 'is_nullable': u'YES', + 'is_updatable': u'YES', + 'numeric_precision': None, + 'numeric_precision_radix': None, + 'numeric_scale': None, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_mixed', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'varchar', + 'udt_schema': u'pg_catalog', + }) + + def test_10_many2one(self): + """ check the database representation of a many2one field """ + model = self.env['test_new_api.mixed'] + columns_data = self.get_columns_data(model._table) + self.assertEqual(columns_data['currency_id'], { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'currency_id', + 'data_type': u'integer', + 'datetime_precision': None, + 'is_nullable': u'YES', + 'is_updatable': u'YES', + 'numeric_precision': 32, + 'numeric_precision_radix': 2, + 'numeric_scale': 0, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_mixed', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'int4', + 'udt_schema': u'pg_catalog', + }) + foreign_keys = self.get_foreign_keys(model._table) + self.assertIn( + ('test_new_api_mixed', 'currency_id', 'res_currency', 'id', 'SET NULL'), + foreign_keys, + ) + + def test_10_many2many(self): + """ check the database representation of a many2many field """ + model = self.env['test_new_api.discussion'] + field = type(model).categories + comodel = self.env[field.comodel_name] + self.assertTrue(field.relation) + self.assertTrue(field.column1) + self.assertTrue(field.column2) + + columns_data = self.get_columns_data(model._table) + self.assertNotIn('categories', columns_data) + + table_data = self.get_table_data(field.relation) + self.assertEqual(table_data, { + 'is_insertable_into': u'YES', + 'is_typed': u'NO', + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_discussion_category', + 'table_schema': u'public', + 'table_type': u'BASE TABLE', + 'user_defined_type_catalog': None, + 'user_defined_type_name': None, + 'user_defined_type_schema': None, + }) + + columns_data = self.get_columns_data(field.relation) + self.assertEqual(columns_data, { + field.column1: { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'discussion', + 'data_type': u'integer', + 'datetime_precision': None, + 'is_nullable': u'NO', + 'is_updatable': u'YES', + 'numeric_precision': 32, + 'numeric_precision_radix': 2, + 'numeric_scale': 0, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_discussion_category', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'int4', + 'udt_schema': u'pg_catalog', + }, + field.column2: { + 'character_maximum_length': None, + 'column_default': None, + 'column_name': u'category', + 'data_type': u'integer', + 'datetime_precision': None, + 'is_nullable': u'NO', + 'is_updatable': u'YES', + 'numeric_precision': 32, + 'numeric_precision_radix': 2, + 'numeric_scale': 0, + 'table_catalog': self.cr.dbname, + 'table_name': u'test_new_api_discussion_category', + 'table_schema': u'public', + 'udt_catalog': self.cr.dbname, + 'udt_name': u'int4', + 'udt_schema': u'pg_catalog' + }, + }) + + foreign_keys = self.get_foreign_keys(field.relation) + self.assertItemsEqual(foreign_keys, [ + (field.relation, field.column1, model._table, 'id', 'CASCADE'), + (field.relation, field.column2, comodel._table, 'id', 'CASCADE'), + ]) diff --git a/odoo/fields.py b/odoo/fields.py index f0e3b3baf63..59075c1894f 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -20,6 +20,7 @@ from odoo.tools import float_precision, float_repr, float_round, frozendict, \ from odoo.tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT from odoo.tools.translate import html_translate, _ +import odoo.tools.sql as sql DATE_LENGTH = len(date.today().strftime(DATE_FORMAT)) DATETIME_LENGTH = len(datetime.now().strftime(DATETIME_FORMAT)) @@ -284,6 +285,7 @@ class Field(object): column_type = None # database column type (ident, spec) column_format = '%s' # placeholder for value in queries + column_cast_from = () # column types that may be cast to this _slots = { 'args': EMPTY_DICT, # the parameters given to __init__() @@ -792,14 +794,91 @@ class Field(object): ############################################################################ # - # Read from/write to database + # Update database schema # - def check_schema(self, model): - """ Make sure the database contains everything for this field. Return - ``True`` if the schema was altered and the field must be recomputed. + def update_db(self, model, columns): + """ Update the database schema to implement this field. + + :param model: an instance of the field's model + :param columns: a dict mapping column names to their configuration in database + :return: ``True`` if the field must be recomputed on existing rows """ - pass + if not self.column_type: + return + + column = columns.get(self.name) + if not column and hasattr(self, 'oldname'): + # column not found; check whether it exists under its old name + column = columns.get(self.oldname) + if column: + sql.rename_column(model._cr, model._table, self.oldname, self.name) + + # create/update the column, not null constraint, indexes + self.update_db_column(model, column) + self.update_db_notnull(model, column) + self.update_db_index(model, column) + + return not column + + def update_db_column(self, model, column): + """ Create/update the column corresponding to ``self``. + + :param model: an instance of the field's model + :param column: the column's configuration (dict) if it exists, or ``None`` + """ + if not column: + # the column does not exist, create it + sql.create_column(model._cr, model._table, self.name, self.column_type[1], self.string) + return + if column['udt_name'] == self.column_type[0]: + return + if column['udt_name'] in self.column_cast_from: + sql.convert_column(model._cr, model._table, self.name, self.column_type[1]) + else: + newname = (self.name + '_moved{}').format + i = 0 + while sql.column_exists(model._cr, model._table, newname(i)): + i += 1 + if column['is_nullable'] == 'NO': + sql.drop_not_null(model._cr, model._table, self.name) + sql.rename_column(model._cr, model._table, self.name, newname(i)) + sql.create_column(model._cr, model._table, self.name, self.column_type[1], self.string) + + def update_db_notnull(self, model, column): + """ Add or remove the NOT NULL constraint on ``self``. + + :param model: an instance of the field's model + :param column: the column's configuration (dict) if it exists, or ``None`` + """ + has_notnull = column and column['is_nullable'] == 'NO' + + if not column or (self.required and not has_notnull): + # the column is new or it becomes required; initialize its values + if model._table_has_rows(): + model._init_column(self.name) + + if self.required and not has_notnull: + sql.set_not_null(model._cr, model._table, self.name) + elif not self.required and has_notnull: + sql.drop_not_null(model._cr, model._table, self.name) + + def update_db_index(self, model, column): + """ Add or remove the index corresponding to ``self``. + + :param model: an instance of the field's model + :param column: the column's configuration (dict) if it exists, or ``None`` + """ + indexname = '%s_%s_index' % (model._table, self.name) + if self.index: + sql.create_index(model._cr, indexname, model._table, ['"%s"' % self.name]) + else: + sql.drop_index(model._cr, indexname, model._table) + + ############################################################################ + # + # Read from/write to database + # def read(self, records): """ Read the value of ``self`` on ``records``, and store it in cache. """ @@ -1110,6 +1189,7 @@ class Float(Field): cursor and returning a pair (total, decimal) """ type = 'float' + column_cast_from = ('int4', 'numeric', 'float8') _slots = { '_digits': None, # digits argument passed to class initializer 'group_operator': 'sum', @@ -1170,6 +1250,7 @@ class Monetary(Field): """ type = 'monetary' column_type = ('numeric', 'numeric') + column_cast_from = ('float8',) _slots = { 'currency_field': None, 'group_operator': 'sum', @@ -1285,6 +1366,7 @@ class Char(_String): translation of terms. """ type = 'char' + column_cast_from = ('text',) _slots = { 'size': None, # maximum size of values (deprecated) } @@ -1293,6 +1375,15 @@ class Char(_String): def column_type(self): return ('varchar', pg_varchar(self.size)) + def update_db_column(self, model, column): + if ( + column and column['udt_name'] == 'varchar' and column['character_maximum_length'] and + (self.size is None or column['character_maximum_length'] < self.size) + ): + # the column's varchar size does not match self.size; convert it + sql.convert_column(model._cr, model._table, self.name, self.column_type[1]) + super(Char, self).update_db_column(model, column) + _related_size = property(attrgetter('size')) _description_size = property(attrgetter('size')) @@ -1330,6 +1421,7 @@ class Text(_String): """ type = 'text' column_type = ('text', 'text') + column_cast_from = ('varchar',) def convert_to_cache(self, value, record, validate=True): if value is None or value is False: @@ -1399,6 +1491,7 @@ class Html(_String): class Date(Field): type = 'date' column_type = ('date', 'date') + column_cast_from = ('timestamp',) @staticmethod def today(*args): @@ -1462,6 +1555,7 @@ class Date(Field): class Datetime(Field): type = 'datetime' column_type = ('timestamp', 'timestamp') + column_cast_from = ('date',) @staticmethod def now(*args): @@ -1841,6 +1935,33 @@ class Many2one(_Relational): if not self.delegate: self.delegate = name in model._inherits.values() + def update_db(self, model, columns): + 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] + # 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``. This is used to reflect the assignment ``value[name] = records``, where @@ -2099,7 +2220,7 @@ class One2many(_RelationalMulti): fnames.discard(self.inverse_name) return super(One2many, self).convert_to_onchange(value, record, fnames) - def check_schema(self, model): + def update_db(self, model, columns): if self.comodel_name in model.env: comodel = model.env[self.comodel_name] if self.inverse_name not in comodel._fields: @@ -2254,35 +2375,42 @@ class Many2many(_RelationalMulti): # add self in m2m, so that its inverse field can find it m2m[(self.relation, self.column1, self.column2)] = self - def check_schema(self, model): + 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_'): - model._save_relation_table(rel, self._module) - cr.execute("SELECT relname FROM pg_class WHERE relkind IN ('r','v') AND relname=%s", (rel,)) - if not cr.dictfetchall(): - 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 - cr.execute("SELECT relkind FROM pg_class WHERE relkind IN ('v') AND relname=%s", (comodel._table,)) - if not cr.fetchall(): - model._m2o_add_foreign_key_unchecked(rel, id2, comodel, 'cascade', self._module) - cr.execute("SELECT relkind FROM pg_class WHERE relkind IN ('v') AND relname=%s", (model._table,)) - if not cr.fetchall(): - 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)) - cr.commit() - _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] @@ -2373,6 +2501,9 @@ class Id(Field): 'readonly': True, } + def update_db(self, model, columns): + pass # this column is created with the table + def __get__(self, record, owner): if record is None: return self # the field is accessed through the class owner diff --git a/odoo/models.py b/odoo/models.py index a2be00d2134..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) @@ -266,23 +258,14 @@ class BaseModel(object): pass @api.model_cr_context - def _field_create(self): + def _reflect(self): """ Reflect the model and its fields in the models 'ir.model' and 'ir.model.fields'. Also create entries in 'ir.model.data' if the key 'module' is passed to the context. """ - # reflect the model in 'ir.model' - self.env['ir.model']._reflect(self) - - # reflect the fields in 'ir.model.fields' - cr = self.env.cr - cr.execute("SELECT * FROM ir_model_fields WHERE model=%s", (self._name,)) - existing = {row['name']: row for row in cr.dictfetchall()} - - reflect = self.env['ir.model.fields']._reflect - for field in self._fields.itervalues(): - reflect(field, existing) - + self.env['ir.model']._reflect_model(self) + self.env['ir.model.fields']._reflect_model(self) + self.env['ir.model.constraint']._reflect_model(self) self.invalidate_cache() @api.model @@ -553,6 +536,8 @@ class BaseModel(object): @api.model def _add_manual_fields(self, partial): + if not self.pool._init_modules: + return IrModelFields = self.env['ir.model.fields'] manual_fields = self.pool.get_manual_fields(self._cr, self._name) for name, field_data in manual_fields.iteritems(): @@ -622,12 +607,7 @@ class BaseModel(object): @api.model @ormcache() def _is_an_ordinary_table(self): - self.env.cr.execute("""\ - SELECT 1 - FROM pg_class - WHERE relname = %s - AND relkind = %s""", [self._table, 'r']) - return bool(self.env.cr.fetchone()) + return tools.table_kind(self.env.cr, self._table) == 'r' def __export_xml_id(self): """ Return a valid xml_id for the record ``self``. """ @@ -2060,132 +2040,7 @@ class BaseModel(object): _logger.debug("column %s is in the table %s but not in the corresponding object %s", row['attname'], self._table, self._name) if row['attnotnull']: - cr.execute('ALTER TABLE "%s" ALTER COLUMN "%s" DROP NOT NULL' % (self._table, row['attname'])) - _schema.debug("Table '%s': column '%s': dropped NOT NULL constraint", - self._table, row['attname']) - - @api.model_cr - def _save_constraint(self, constraint_name, type, definition, module): - """ - Record the creation of a constraint for this model, to make it possible - to delete it later when the module is uninstalled. Type can be either - 'f' or 'u' depending on the constraint being a foreign key or not. - """ - if not module: - # no need to save constraints for custom models as they're not part - # of any module - return - assert type in ('f', 'u') - cr = self._cr - cr.execute(""" - SELECT type, definition FROM ir_model_constraint, ir_module_module - WHERE ir_model_constraint.module=ir_module_module.id - AND ir_model_constraint.name=%s - AND ir_module_module.name=%s - """, (constraint_name, module)) - constraints = cr.dictfetchone() - if not constraints: - cr.execute(""" - INSERT INTO ir_model_constraint - (name, date_init, date_update, module, model, type, definition) - VALUES (%s, now() AT TIME ZONE 'UTC', now() AT TIME ZONE 'UTC', - (SELECT id FROM ir_module_module WHERE name=%s), - (SELECT id FROM ir_model WHERE model=%s), %s, %s)""", - (constraint_name, module, self._name, type, definition)) - elif constraints['type'] != type or (definition and constraints['definition'] != definition): - cr.execute(""" - UPDATE ir_model_constraint - SET date_update=now() AT TIME ZONE 'UTC', type=%s, definition=%s - WHERE name=%s AND module = (SELECT id FROM ir_module_module WHERE name=%s)""", - (type, definition, constraint_name, module)) - - @api.model_cr - def _drop_constraint(self, source_table, constraint_name): - self._cr.execute("ALTER TABLE %s DROP CONSTRAINT %s" % (source_table, constraint_name)) - - @api.model_cr - def _save_relation_table(self, relation_table, module): - """ - Record the creation of a many2many for this model, to make it possible - to delete it later when the module is uninstalled. - """ - cr = self._cr - cr.execute(""" - SELECT 1 FROM ir_model_relation, ir_module_module - WHERE ir_model_relation.module=ir_module_module.id - AND ir_model_relation.name=%s - AND ir_module_module.name=%s - """, (relation_table, module)) - if not cr.rowcount: - cr.execute("""INSERT INTO ir_model_relation (name, date_init, date_update, module, model) - VALUES (%s, now() AT TIME ZONE 'UTC', now() AT TIME ZONE 'UTC', - (SELECT id FROM ir_module_module WHERE name=%s), - (SELECT id FROM ir_model WHERE model=%s))""", - (relation_table, module, self._name)) - self.invalidate_cache() - - # 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): - fk_def = (source_table, source_field, dest_model._table, ondelete or 'set null', module) - self._foreign_keys.add(fk_def) - _schema.debug("Table '%s': added foreign key '%s' with definition=REFERENCES \"%s\" ON DELETE %s", *fk_def[:-1]) - - @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 - _schema.debug("Table '%s': dropping obsolete FK constraint: '%s'", - source_table, cons['constraint_name']) - self._drop_constraint(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: - _schema.debug("Table '%s': dropping duplicate FK constraints: '%s'", - source_table, cons['constraint_name']) - self._drop_constraint(source_table, cons['constraint_name']) - - # (re-)create the FK - self._m2o_add_foreign_key_checked(source_field, dest_model, ondelete) + tools.drop_not_null(self._table, row['attname']) @api.model_cr_context def _init_column(self, column_name): @@ -2210,30 +2065,33 @@ class BaseModel(object): query = 'UPDATE "%s" SET "%s"=%s WHERE "%s" IS NULL' % ( self._table, column_name, field.column_format, column_name) self._cr.execute(query, (value,)) - # this is a disgrace - self._cr.commit() + + @ormcache() + def _table_has_rows(self): + """ Return whether the model's table has rows. This method should only + be used when updating the database schema (:meth:`~._auto_init`). + """ + self.env.cr.execute('SELECT 1 FROM "%s" LIMIT 1' % self._table) + return self.env.cr.rowcount @api.model_cr_context def _auto_init(self): - """ Reflect the model and initialize the database schema of ``self``: - - create the corresponding table in database, - - add the parent columns in database, - - add the '_log_access' columns if required, - - report on database columns no more existing in ``self._fields``, - - remove no more existing not null constraints, - - alter existing database columns to match ``self._fields``, - - create database tables to match ``self._fields``, - - add database indices to match ``self._fields``, - - save in self._foreign_keys a list a foreign keys to create (see - _auto_end). + """ Initialize the database schema of ``self``: + - create the corresponding table, + - create/update the necessary columns/tables for fields, + - initialize new columns on existing rows, + - add the SQL constraints given on the model, + - add the indexes on indexed fields, - Note: you should not override this method. Instead, you can modify the - model's database schema by overriding method :meth:`~.init`, which is - called right after this one. + Also prepare post-init stuff to: + - add foreign key constraints, + - reflect models, fields, relations and constraints, + - mark fields to recompute on existing records. + + Note: you should not override this method. Instead, you can modify + the model's database schema by overriding method :meth:`~.init`, + which is called right after this one. """ - assert 'todo' in self._context, "Context not passed correctly to method _auto_init()." - - type(self)._foreign_keys = set() raise_on_invalid_object_name(self._name) # This prevents anything called by this method (in particular default @@ -2241,259 +2099,51 @@ class BaseModel(object): # has not been added in database yet! self = self.with_context(prefetch_fields=False) + self.pool.post_init(self._reflect) + cr = self._cr parent_store_compute = False - stored_fields = [] # new-style stored fields with compute - todo_end = self._context['todo'] update_custom_fields = self._context.get('update_custom_fields', False) - self._field_create() - create = not self._table_exist() + must_create_table = not tools.table_exists(cr, self._table) if self._auto: + if must_create_table: + tools.create_model_table(cr, self._table, self._description) - if create: - self._create_table() - has_rows = False - else: - cr.execute('SELECT 1 FROM "%s" LIMIT 1' % self._table) - has_rows = cr.rowcount - - cr.commit() if self._parent_store: - if not self._parent_columns_exist(): + if not tools.column_exists(cr, self._table, 'parent_left'): self._create_parent_columns() parent_store_compute = True self._check_removed_columns(log=False) - # retrieve existing database columns - column_data = self._select_column_data() + # update the database schema for fields + columns = tools.table_columns(cr, self._table) - for name, field in self._fields.iteritems(): - if name == 'id': - continue + def recompute(field): + _logger.info("Storing computed values of %s", field) + recs = self.with_context(active_test=False).search([]) + recs._recompute_todo(field) + for field in self._fields.itervalues(): if not field.store: continue if field.manual and not update_custom_fields: - # Don't update custom (also called manual) fields - continue + continue # don't update custom fields - if not field.column_type: - # the field is not stored as a column - if field.check_schema(self) and field.compute: - stored_fields.append(field) - - else: - res = column_data.get(name) - - # The column is not found as-is in database. Check whether - # it exists with an old name, and rename it. - if not res and hasattr(field, 'oldname'): - res = column_data.get(field.oldname) - if res: - cr.execute('ALTER TABLE "%s" RENAME "%s" TO "%s"' % (self._table, field.oldname, name)) - res['attname'] = name - _schema.debug("Table '%s': renamed column '%s' to '%s'", self._table, field.oldname, name) - - # The column already exists in database. Possibly change its - # type, rename it, drop it or change its constraints. - if res: - f_pg_type = res['typname'] - f_pg_size = res['size'] - f_pg_notnull = res['attnotnull'] - column_type = field.column_type - - if column_type: - converted = False - casts = [ - ('text', 'char', column_type[1], '::' + column_type[1]), - ('varchar', 'text', 'TEXT', ''), - ('int4', 'float', column_type[1], '::' + column_type[1]), - ('date', 'datetime', 'TIMESTAMP', '::TIMESTAMP'), - ('timestamp', 'date', 'date', '::date'), - ('numeric', 'float', column_type[1], '::' + column_type[1]), - ('float8', 'float', column_type[1], '::' + column_type[1]), - ('float8', 'monetary', column_type[1], '::' + column_type[1]), - ] - if f_pg_type == 'varchar' and field.type == 'char' and f_pg_size and (field.size is None or f_pg_size < field.size): - try: - with cr.savepoint(): - cr.execute('ALTER TABLE "%s" ALTER COLUMN "%s" TYPE %s' % (self._table, name, column_type[1]), log_exceptions=False) - except psycopg2.NotSupportedError: - # In place alter table cannot be done because a view is depending of this field. - # Do a manual copy. This will drop the view (that will be recreated later) - cr.execute('ALTER TABLE "%s" RENAME COLUMN "%s" TO temp_change_size' % (self._table, name)) - cr.execute('ALTER TABLE "%s" ADD COLUMN "%s" %s' % (self._table, name, column_type[1])) - cr.execute('UPDATE "%s" SET "%s"=temp_change_size::%s' % (self._table, name, column_type[1])) - cr.execute('ALTER TABLE "%s" DROP COLUMN temp_change_size CASCADE' % (self._table,)) - cr.commit() - _schema.debug("Table '%s': column '%s' (type varchar) changed size from %s to %s", - self._table, name, f_pg_size or 'unlimited', field.size or 'unlimited') - for c in casts: - if (f_pg_type == c[0]) and (field.type == c[1]): - if f_pg_type != column_type[0]: - converted = True - try: - with cr.savepoint(): - cr.execute('ALTER TABLE "%s" ALTER COLUMN "%s" TYPE %s' % (self._table, name, c[2]), log_exceptions=False) - except psycopg2.NotSupportedError: - # can't do inplace change -> use a casted temp column - cr.execute('ALTER TABLE "%s" RENAME COLUMN "%s" TO __temp_type_cast' % (self._table, name)) - cr.execute('ALTER TABLE "%s" ADD COLUMN "%s" %s' % (self._table, name, c[2])) - cr.execute('UPDATE "%s" SET "%s"= __temp_type_cast%s' % (self._table, name, c[3])) - cr.execute('ALTER TABLE "%s" DROP COLUMN __temp_type_cast CASCADE' % (self._table,)) - cr.commit() - _schema.debug("Table '%s': column '%s' changed type from %s to %s", - self._table, name, c[0], c[1]) - break - - if f_pg_type != column_type[0]: - if not converted: - i = 0 - while True: - newname = name + '_moved' + str(i) - cr.execute("SELECT count(1) FROM pg_class c,pg_attribute a " \ - "WHERE c.relname=%s " \ - "AND a.attname=%s " \ - "AND c.oid=a.attrelid ", (self._table, newname)) - if not cr.fetchone()[0]: - break - i += 1 - if f_pg_notnull: - cr.execute('ALTER TABLE "%s" ALTER COLUMN "%s" DROP NOT NULL' % (self._table, name)) - cr.execute('ALTER TABLE "%s" RENAME COLUMN "%s" TO "%s"' % (self._table, name, newname)) - cr.execute('ALTER TABLE "%s" ADD COLUMN "%s" %s' % (self._table, name, column_type[1])) - cr.execute("COMMENT ON COLUMN %s.\"%s\" IS %%s" % (self._table, name), (field.string,)) - _schema.warning("Table `%s`: column `%s` has changed type (DB=%s, def=%s), data moved to column `%s`", - self._table, name, f_pg_type, field.type, newname) - - # if the field is required and hasn't got a NOT NULL constraint - if field.required and f_pg_notnull == 0: - if has_rows: - self._init_column(name) - # add the NOT NULL constraint - try: - cr.commit() - cr.execute('ALTER TABLE "%s" ALTER COLUMN "%s" SET NOT NULL' % (self._table, name), log_exceptions=False) - _schema.debug("Table '%s': column '%s': added NOT NULL constraint", - self._table, name) - except Exception: - msg = "Table '%s': unable to set a NOT NULL constraint on column '%s' !\n"\ - "If you want to have it, you should update the records and execute manually:\n"\ - "ALTER TABLE %s ALTER COLUMN %s SET NOT NULL" - _schema.warning(msg, self._table, name, self._table, name) - cr.commit() - elif not field.required and f_pg_notnull == 1: - cr.execute('ALTER TABLE "%s" ALTER COLUMN "%s" DROP NOT NULL' % (self._table, name)) - cr.commit() - _schema.debug("Table '%s': column '%s': dropped NOT NULL constraint", - self._table, name) - # Verify index - indexname = '%s_%s_index' % (self._table, name) - cr.execute("SELECT indexname FROM pg_indexes WHERE indexname = %s and tablename = %s", (indexname, self._table)) - res2 = cr.dictfetchall() - if not res2 and field.index: - cr.execute('CREATE INDEX "%s_%s_index" ON "%s" ("%s")' % (self._table, name, self._table, name)) - cr.commit() - if field.type == 'text': - # FIXME: for fields.text columns we should try creating GIN indexes instead (seems most suitable for an ERP context) - msg = "Table '%s': Adding (b-tree) index for %s column '%s'."\ - "This is probably useless (does not work for fulltext search) and prevents INSERTs of long texts"\ - " because there is a length limit for indexable btree values!\n"\ - "Use a search view instead if you simply want to make the field searchable." - _schema.warning(msg, self._table, field.type, name) - if res2 and not field.index: - cr.execute('DROP INDEX "%s_%s_index"' % (self._table, name)) - cr.commit() - msg = "Table '%s': dropping index for column '%s' of type '%s' as it is not required anymore" - _schema.debug(msg, self._table, name, field.type) - - if field.type == 'many2one': - comodel = self.env[field.comodel_name] - if comodel._auto and comodel._table != 'ir_actions': - self._m2o_fix_foreign_key(self._table, name, comodel, field.ondelete) - - else: - # the column doesn't exist in database, create it - cr.execute('ALTER TABLE "%s" ADD COLUMN "%s" %s' % (self._table, name, field.column_type[1])) - cr.execute("COMMENT ON COLUMN %s.\"%s\" IS %%s" % (self._table, name), (field.string,)) - _schema.debug("Table '%s': added column '%s' with definition=%s", - self._table, name, field.column_type[1]) - - # initialize it - if has_rows: - self._init_column(name) - - # remember new-style stored fields with compute method - if field.compute: - stored_fields.append(field) - - # and add constraints if needed - if field.type == 'many2one' and field.store: - if field.comodel_name not in self.env: - raise ValueError(_('There is no reference available for %s') % (field.comodel_name,)) - comodel = self.env[field.comodel_name] - # ir_actions is inherited so foreign key doesn't work on it - if comodel._auto and comodel._table != 'ir_actions': - self._m2o_add_foreign_key_checked(name, comodel, field.ondelete) - if field.index: - cr.execute('CREATE INDEX "%s_%s_index" ON "%s" ("%s")' % (self._table, name, self._table, name)) - if field.required: - try: - cr.commit() - cr.execute('ALTER TABLE "%s" ALTER COLUMN "%s" SET NOT NULL' % (self._table, name)) - _schema.debug("Table '%s': column '%s': added a NOT NULL constraint", - self._table, name) - except Exception: - msg = "WARNING: unable to set column %s of table %s not null !\n"\ - "Try to re-run: openerp-server --update=module\n"\ - "If it doesn't work, update records and execute manually:\n"\ - "ALTER TABLE %s ALTER COLUMN %s SET NOT NULL" - _logger.warning(msg, name, self._table, self._table, name, exc_info=True) - cr.commit() - - else: - cr.execute("SELECT relname FROM pg_class WHERE relkind IN ('r','v') AND relname=%s", (self._table,)) - create = not bool(cr.fetchone()) - - cr.commit() # start a new transaction + new = field.update_db(self, columns) + if new and field.compute: + self.pool.post_init(recompute, field) if self._auto: self._add_sql_constraints() - if create: + if must_create_table: self._execute_sql() if parent_store_compute: self._parent_store_compute() - cr.commit() - - if stored_fields: - # trigger computation of new-style stored fields with a compute - def func(): - fnames = [f.name for f in stored_fields] - _logger.info("Storing computed values of %s fields %s", - self._name, ', '.join(sorted(fnames))) - recs = self.with_context(active_test=False).search([]) - if recs: - recs.invalidate_cache(fnames, recs.ids) - for f in stored_fields: - recs._recompute_todo(f) - - todo_end.append((1000, func, ())) - - @api.model_cr_context - def _auto_end(self): - """ Create the foreign keys recorded by _auto_init. """ - cr = self._cr - query = 'ALTER TABLE "%s" ADD FOREIGN KEY ("%s") REFERENCES "%s" ON DELETE %s' - for table1, column, table2, ondelete, module in self._foreign_keys: - cr.execute(query % (table1, column, table2, ondelete)) - self._save_constraint("%s_%s_fkey" % (table1, column), 'f', False, module) - cr.commit() - del type(self)._foreign_keys @api.model_cr def init(self): @@ -2502,54 +2152,20 @@ class BaseModel(object): """ pass - @api.model_cr - def _table_exist(self): - query = "SELECT relname FROM pg_class WHERE relkind IN ('r','v') AND relname=%s" - self._cr.execute(query, (self._table,)) - return self._cr.rowcount - - @api.model_cr - def _create_table(self): - self._cr.execute('CREATE TABLE "%s" (id SERIAL NOT NULL, PRIMARY KEY(id))' % (self._table,)) - self._cr.execute("COMMENT ON TABLE \"%s\" IS %%s" % self._table, (self._description,)) - _schema.debug("Table '%s': created", self._table) - - @api.model_cr - def _parent_columns_exist(self): - query = """ SELECT c.relname FROM pg_class c, pg_attribute a - WHERE c.relname=%s AND a.attname=%s AND c.oid=a.attrelid """ - self._cr.execute(query, (self._table, 'parent_left')) - return self._cr.rowcount - @api.model_cr def _create_parent_columns(self): - self._cr.execute('ALTER TABLE "%s" ADD COLUMN "parent_left" INTEGER' % (self._table,)) - self._cr.execute('ALTER TABLE "%s" ADD COLUMN "parent_right" INTEGER' % (self._table,)) + tools.create_column(self._cr, self._table, 'parent_left', 'INTEGER') + tools.create_column(self._cr, self._table, 'parent_right', 'INTEGER') if 'parent_left' not in self._fields: _logger.error("add a field parent_left on model %s: parent_left = fields.Integer('Left Parent', index=True)", self._name) - _schema.debug("Table '%s': added column '%s' with definition=%s", self._table, 'parent_left', 'INTEGER') elif not self._fields['parent_left'].index: _logger.error('parent_left field on model %s must be indexed! Add index=True to the field definition)', self._name) if 'parent_right' not in self._fields: _logger.error("add a field parent_right on model %s: parent_right = fields.Integer('Left Parent', index=True)", self._name) - _schema.debug("Table '%s': added column '%s' with definition=%s", self._table, 'parent_right', 'INTEGER') elif not self._fields['parent_right'].index: _logger.error("parent_right field on model %s must be indexed! Add index=True to the field definition)", self._name) if self._fields[self._parent_name].ondelete not in ('cascade', 'restrict'): _logger.error("The field %s on model %s must be set as ondelete='cascade' or 'restrict'", self._parent_name, self._name) - self._cr.commit() - - @api.model_cr - def _select_column_data(self): - # attlen is the number of bytes necessary to represent the type when - # the type has a fixed size. If the type has a varying size attlen is - # -1 and atttypmod is the size limit + 4, or -1 if there is no limit. - query = """ SELECT c.relname, a.attname, a.attlen, a.atttypmod, a.attnotnull, a.atthasdef, t.typname, - CASE WHEN a.attlen=-1 THEN (CASE WHEN a.atttypmod=-1 THEN 0 ELSE a.atttypmod-4 END) ELSE a.attlen END as size - FROM pg_class c, pg_attribute a, pg_type t - WHERE c.relname=%s AND c.oid=a.attrelid AND a.atttypid=t.oid """ - self._cr.execute(query, (self._table,)) - return {row['attname']: row for row in self._cr.dictfetchall()} @api.model_cr def _add_sql_constraints(self): @@ -2560,70 +2176,33 @@ class BaseModel(object): """ cr = self._cr + foreign_key_re = re.compile(r'\s*foreign\s+key\b.*', re.I) - def unify_cons_text(txt): + def cons_text(txt): return txt.lower().replace(', ',',').replace(' (','(') - def drop(name, definition, old_definition): - try: - cr.execute('ALTER TABLE "%s" DROP CONSTRAINT "%s"' % (self._table, name)) - cr.commit() - _schema.debug("Table '%s': dropped constraint '%s'. Reason: its definition changed from '%s' to '%s'", - self._table, name, old_definition, definition) - except Exception: - _schema.warning("Table '%s': unable to drop constraint '%s'!", self._table, definition) - cr.rollback() - - def add(name, definition): - query = 'ALTER TABLE "%s" ADD CONSTRAINT "%s" %s' % (self._table, name, definition) - try: - cr.execute(query) - cr.commit() - _schema.debug("Table '%s': added constraint '%s' with definition=%s", - self._table, name, definition) - except Exception: - _schema.warning("Table '%s': unable to add constraint '%s'!\n" - "If you want to have it, you should update the records and execute manually:\n%s", - self._table, definition, query) - cr.rollback() - - # map each constraint on the name of the module where it is defined - constraint_module = { - constraint[0]: cls._module - for cls in reversed(type(self).mro()) - if not getattr(cls, 'pool', None) - for constraint in getattr(cls, '_local_sql_constraints', ()) - } + def process(key, definition): + conname = '%s_%s' % (self._table, key) + has_definition = tools.constraint_definition(cr, conname) + if not has_definition: + # constraint does not exists + tools.add_constraint(cr, self._table, conname, definition) + elif cons_text(definition) != cons_text(has_definition): + # constraint exists but its definition may have changed + tools.drop_constraint(cr, self._table, conname) + tools.add_constraint(cr, self._table, conname, definition) for (key, definition, _) in self._sql_constraints: - conname = '%s_%s' % (self._table, key) - - # using 1 to get result if no imc but one pgc - cr.execute("""SELECT definition, 1 - FROM ir_model_constraint imc - RIGHT JOIN pg_constraint pgc - ON (pgc.conname = imc.name) - WHERE pgc.conname=%s - """, (conname, )) - existing = cr.dictfetchone() - if not existing: - # constraint does not exists - add(conname, definition) - elif unify_cons_text(definition) != existing['definition']: - # constraint exists but its definition has changed - drop(conname, definition, existing['definition'] or '') - add(conname, definition) - - # we need to add the constraint: - module = constraint_module.get(key) - self._save_constraint(conname, 'u', unify_cons_text(definition), module) + if foreign_key_re.match(definition): + self.pool.post_init(process, key, definition) + else: + process(key, definition) @api.model_cr def _execute_sql(self): """ Execute the SQL code from the _sql attribute (if any).""" if hasattr(self, "_sql"): self._cr.execute(self._sql) - self._cr.commit() # # Update objects that uses this one to update their _inherits fields diff --git a/odoo/modules/db.py b/odoo/modules/db.py index f2592f947e2..7106ff82b69 100644 --- a/odoo/modules/db.py +++ b/odoo/modules/db.py @@ -12,8 +12,7 @@ def is_initialized(cr): The database can be initialized with the 'initialize' function below. """ - cr.execute("SELECT relname FROM pg_class WHERE relkind='r' AND relname='ir_module_module'") - return len(cr.fetchall()) > 0 + return odoo.tools.table_exists(cr, 'ir_module_module') def initialize(cr): """ Initialize a database with for the ORM. diff --git a/odoo/modules/registry.py b/odoo/modules/registry.py index 7bfacb700fb..a6ab4ead1b4 100644 --- a/odoo/modules/registry.py +++ b/odoo/modules/registry.py @@ -6,6 +6,7 @@ """ from collections import Mapping, defaultdict, deque from contextlib import closing +from functools import partial from operator import attrgetter import logging import os @@ -14,7 +15,7 @@ import threading import odoo from .. import SUPERUSER_ID from odoo.tools import (assertion_report, lazy_classproperty, config, - lazy_property, topological_sort, OrderedSet) + lazy_property, table_exists, topological_sort, OrderedSet) from odoo.tools.lru import LRU _logger = logging.getLogger(__name__) @@ -106,6 +107,7 @@ class Registry(Mapping): self._init_parent = {} self._assertion_report = assertion_report.assertion_report() self._fields_by_model = None + self._post_init_queue = deque() # modules fully loaded (maintained during init phase by `loading` module) self._init_modules = set() @@ -273,12 +275,13 @@ class Registry(Mapping): lazy_property.reset_all(self) env = odoo.api.Environment(cr, SUPERUSER_ID, {}) - # load custom models - ir_model = env['ir.model'] - cr.execute('SELECT * FROM ir_model WHERE state=%s', ('manual',)) - for model_data in cr.dictfetchall(): - model_class = ir_model._instanciate(model_data) - model_class._build_model(self, cr) + # load custom models (except when loading 'base') + if self._init_modules: + ir_model = env['ir.model'] + cr.execute('SELECT * FROM ir_model WHERE state=%s', ('manual',)) + for model_data in cr.dictfetchall(): + model_class = ir_model._instanciate(model_data) + model_class._build_model(self, cr) # prepare the setup on all models models = env.values() @@ -296,10 +299,14 @@ class Registry(Mapping): for model in models: model._setup_complete() + def post_init(self, func, *args, **kwargs): + """ Register a function to call at the end of :meth:`~.init_models`. """ + self._post_init_queue.append(partial(func, *args, **kwargs)) + def init_models(self, cr, model_names, context): """ Initialize a list of models (given by their name). Call methods - ``_auto_init``, ``init``, and ``_auto_end`` on each model to create - or update the database tables supporting the models. + ``_auto_init`` and ``init`` on each model to create or update the + database tables supporting the models. The ``context`` may contain the following items: - ``module``: the name of the module being installed/updated, if any; @@ -308,21 +315,16 @@ class Registry(Mapping): if 'module' in context: _logger.info('module %s: creating or updating database tables', context['module']) - context = dict(context, todo=[]) env = odoo.api.Environment(cr, SUPERUSER_ID, context) models = [env[model_name] for model_name in model_names] for model in models: model._auto_init() model.init() - cr.commit() - for model in models: - model._auto_end() - cr.commit() - - for _, func, args in sorted(context['todo']): - func(*args) + while self._post_init_queue: + func = self._post_init_queue.popleft() + func() if models: models[0].recompute() @@ -331,7 +333,7 @@ class Registry(Mapping): # make sure all tables are present missing = [name for name, model in env.items() - if not model._abstract and not model._table_exist()] + if not model._abstract and not table_exists(cr, model._table)] if missing: _logger.warning("Models have no table: %s.", ", ".join(missing)) # recreate missing tables following model dependencies @@ -340,10 +342,10 @@ class Registry(Mapping): if name in missing: _logger.info("Recreate table of model %s.", name) env[name].init() - cr.commit() + cr.commit() # check again, and log errors if tables are still missing for name, model in env.items(): - if not model._abstract and not model._table_exist(): + if not model._abstract and not table_exists(cr, model._table): _logger.error("Model %s has no table.", name) def clear_caches(self): diff --git a/odoo/service/db.py b/odoo/service/db.py index 5e74c6d38b3..a9968963fb8 100644 --- a/odoo/service/db.py +++ b/odoo/service/db.py @@ -354,8 +354,7 @@ def list_db_incompatible(databases): server_version = '.'.join(map(str, version_info[:2])) for database_name in databases: with closing(db_connect(database_name).cursor()) as cr: - cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name='ir_module_module'") - if cr.fetchone(): + if odoo.tools.table_exists(cr, 'ir_module_module'): cr.execute("SELECT latest_version FROM ir_module_module WHERE name=%s", ('base',)) base_version = cr.fetchone() if not base_version or not base_version[0]: diff --git a/odoo/tools/sql.py b/odoo/tools/sql.py index 0c67657db8d..34d1a84cd98 100644 --- a/odoo/tools/sql.py +++ b/odoo/tools/sql.py @@ -1,6 +1,190 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging +import psycopg2 + +_schema = logging.getLogger('odoo.schema') + +_TABLE_KIND = { + 'BASE TABLE': 'r', + 'VIEW': 'v', + 'FOREIGN TABLE': 'f', + 'LOCAL TEMPORARY': 't', +} + +_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 information_schema.tables WHERE table_name=%s" + cr.execute(query, (tablename,)) + return cr.rowcount + +def table_kind(cr, tablename): + """ Return the kind of a table: ``'r'`` (regular table), ``'v'`` (view), + ``'f'`` (foreign table), ``'t'`` (temporary table), or ``None``. + """ + query = "SELECT table_type FROM information_schema.tables WHERE table_name=%s" + cr.execute(query, (tablename,)) + return _TABLE_KIND[cr.fetchone()[0]] if cr.rowcount else None + +def create_model_table(cr, tablename, comment=None): + """ Create the table for a model. """ + cr.execute('CREATE TABLE "{}" (id SERIAL NOT NULL, PRIMARY KEY(id))'.format(tablename)) + if comment: + cr.execute('COMMENT ON TABLE "{}" IS %s'.format(tablename), (comment,)) + _schema.debug("Table %r: created", tablename) + +def table_columns(cr, tablename): + """ Return a dict mapping column names to their configuration. The latter is + a dict with the data from the table ``information_schema.columns``. + """ + query = 'SELECT * FROM information_schema.columns WHERE table_name=%s' + cr.execute(query, (tablename,)) + return {row['column_name']: row for row in cr.dictfetchall()} + +def column_exists(cr, tablename, columnname): + """ Return whether the given column exists. """ + query = """ SELECT 1 FROM information_schema.columns + WHERE table_name=%s AND column_name=%s """ + cr.execute(query, (tablename, columnname)) + return cr.rowcount + +def create_column(cr, tablename, columnname, columntype, comment=None): + """ Create a column with the given type. """ + cr.execute('ALTER TABLE "{}" ADD COLUMN "{}" {}'.format(tablename, columnname, columntype)) + if comment: + cr.execute('COMMENT ON COLUMN "{}"."{}" IS %s'.format(tablename, columnname), (comment,)) + _schema.debug("Table %r: added column %r of type %s", tablename, columnname, columntype) + +def rename_column(cr, tablename, columnname1, columnname2): + """ Rename the given column. """ + cr.execute('ALTER TABLE "{}" RENAME COLUMN "{}" TO "{}"'.format(tablename, columnname1, columnname2)) + _schema.debug("Table %r: renamed column %r to %r", tablename, columnname1, columnname2) + +def convert_column(cr, tablename, columnname, columntype): + """ Convert the column to the given type. """ + try: + with cr.savepoint(): + cr.execute('ALTER TABLE "{}" ALTER COLUMN "{}" TYPE {}'.format(tablename, columnname, columntype), + log_exceptions=False) + except psycopg2.NotSupportedError: + # can't do inplace change -> use a casted temp column + query = 'ALTER TABLE "{0}" RENAME COLUMN "{1}" TO __temp_type_cast; ' \ + 'ALTER TABLE "{0}" ADD COLUMN "{1}" {2}; ' \ + 'UPDATE "{0}" SET "{1}"= __temp_type_cast::{2}' \ + 'ALTER TABLE "{0}" DROP COLUMN __temp_type_cast CASCADE' + cr.execute(query.format(tablename, columntype, columntype)) + _schema.debug("Table %r: column %r changed to type %s", tablename, columntype, columntype) + +def set_not_null(cr, tablename, columnname): + """ Add a NOT NULL constraint on the given column. """ + query = 'ALTER TABLE "{}" ALTER COLUMN "{}" SET NOT NULL'.format(tablename, columnname) + try: + with cr.savepoint(): + cr.execute(query) + _schema.debug("Table %r: column %r: added constraint NOT NULL", tablename, columnname) + except Exception: + msg = "Table %r: unable to set NOT NULL on column %r!\n" \ + "If you want to have it, you should update the records and execute manually:\n%s" + _schema.warning(msg, tablename, columnname, query, exc_info=True) + +def drop_not_null(cr, tablename, columnname): + """ Drop the NOT NULL constraint on the given column. """ + cr.execute('ALTER TABLE "{}" ALTER COLUMN "{}" DROP NOT NULL'.format(tablename, columnname)) + _schema.debug("Table %r: column %r: dropped constraint NOT NULL", tablename, columnname) + +def constraint_definition(cr, constraintname): + """ Return the given constraint's definition. """ + cr.execute("SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname=%s", (constraintname,)) + return cr.fetchone()[0] if cr.rowcount else None + +def add_constraint(cr, tablename, constraintname, definition): + """ Add a constraint on the given table. """ + query = 'ALTER TABLE "{}" ADD CONSTRAINT "{}" {}'.format(tablename, constraintname, definition) + try: + with cr.savepoint(): + cr.execute(query) + _schema.debug("Table %r: added constraint %r as %s", tablename, constraintname, definition) + except Exception: + msg = "Table %r: unable to add constraint %r!\n" \ + "If you want to have it, you should update the records and execute manually:\n%s" + _schema.warning(msg, tablename, constraintname, query, exc_info=True) + +def drop_constraint(cr, tablename, constraintname): + """ drop the given constraint. """ + try: + with cr.savepoint(): + cr.execute('ALTER TABLE "{}" DROP CONSTRAINT "{}"'.format(tablename, constraintname)) + _schema.debug("Table %r: dropped constraint %r", 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. + """ + # Do not use 'information_schema' here, as those views are awfully slow! + 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,)) + return cr.rowcount + +def create_index(cr, indexname, tablename, expressions): + """ Create the given index unless it exists. """ + if index_exists(cr, indexname): + return + args = ', '.join(expressions) + cr.execute('CREATE INDEX "{}" ON "{}" ({})'.format(indexname, tablename, args)) + _schema.debug("Table %r: created index %r (%s)", tablename, indexname, args) + +def create_unique_index(cr, indexname, tablename, expressions): + """ Create the given index unless it exists. """ + if index_exists(cr, indexname): + return + args = ', '.join(expressions) + cr.execute('CREATE UNIQUE INDEX "{}" ON "{}" ({})'.format(indexname, tablename, args)) + _schema.debug("Table %r: created index %r (%s)", tablename, indexname, args) + +def drop_index(cr, indexname, tablename): + """ Drop the given index if it exists. """ + cr.execute('DROP INDEX IF EXISTS "{}"'.format(indexname)) + _schema.debug("Table %r: dropped index %r", tablename, indexname) + def drop_view_if_exists(cr, viewname): cr.execute("DROP view IF EXISTS %s CASCADE" % (viewname,)) cr.commit()