From dc82ff59accf0733229f39b57fc030782600530e Mon Sep 17 00:00:00 2001 From: Raphael Collet Date: Tue, 7 Feb 2017 10:20:22 +0100 Subject: [PATCH] [REF] models: in `_auto_init`, delegate field schema updates to fields The fields objects are responsible for creating their column/table and indexes. The methods that update the schema are overridden to handle cases specific to each type of field. --- odoo/fields.py | 135 +++++++++++++++++++++++++++++++++++++++++---- odoo/models.py | 146 +++++++------------------------------------------ 2 files changed, 142 insertions(+), 139 deletions(-) diff --git a/odoo/fields.py b/odoo/fields.py index 6aff8c14c0c..6d8c85a3a63 100644 --- a/odoo/fields.py +++ b/odoo/fields.py @@ -16,11 +16,11 @@ import psycopg2 from odoo.sql_db import LazyCursor from odoo.tools import float_precision, float_repr, float_round, frozendict, \ - html_sanitize, human_size, pg_varchar, table_exists, \ - table_kind, ustr, OrderedSet + html_sanitize, human_size, pg_varchar, ustr, OrderedSet 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)) @@ -285,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__() @@ -793,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['typname'] == self.column_type[0]: + return + if column['typname'] 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['attnotnull']: + 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['attnotnull'] + + 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. """ @@ -1111,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', @@ -1171,6 +1250,7 @@ class Monetary(Field): """ type = 'monetary' column_type = ('numeric', 'numeric') + column_cast_from = ('float8',) _slots = { 'currency_field': None, 'group_operator': 'sum', @@ -1286,6 +1366,7 @@ class Char(_String): translation of terms. """ type = 'char' + column_cast_from = ('text',) _slots = { 'size': None, # maximum size of values (deprecated) } @@ -1294,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['typname'] == 'varchar' and column['size'] and + (self.size is None or column['size'] < 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')) @@ -1331,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: @@ -1400,6 +1491,7 @@ class Html(_String): class Date(Field): type = 'date' column_type = ('date', 'date') + column_cast_from = ('timestamp',) @staticmethod def today(*args): @@ -1463,6 +1555,7 @@ class Date(Field): class Datetime(Field): type = 'datetime' column_type = ('timestamp', 'timestamp') + column_cast_from = ('date',) @staticmethod def now(*args): @@ -1842,6 +1935,21 @@ class Many2one(_Relational): if not self.delegate: self.delegate = name in model._inherits.values() + def update_db(self, model, columns): + if self.comodel_name not in model.env: + raise ValueError(_('There is no reference available for %s') % (self.comodel_name,)) + return super(Many2one, self).update_db(model, columns) + + def update_db_column(self, model, column): + super(Many2one, self).update_db_column(model, column) + comodel = model.env[self.comodel_name] + # Note: ir_actions is inherited, so foreign key doesn't work on it + if comodel._auto and comodel._table != 'ir_actions': + if not column: + model.pool.post_init(model._m2o_add_foreign_key_checked, self.name, comodel, self.ondelete) + else: + model.pool.post_init(model._m2o_fix_foreign_key, model._table, self.name, comodel, self.ondelete) + 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 @@ -2100,7 +2208,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: @@ -2255,7 +2363,7 @@ 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 @@ -2264,15 +2372,15 @@ class Many2many(_RelationalMulti): if not rel.startswith('x_'): IMR = model.env['ir.model.relation'] model.pool.post_init(IMR._reflect_relation, model, rel, self._module) - if not table_exists(cr, rel): + if not sql.table_exists(cr, rel): if self.comodel_name not in model.env: raise UserError(_('Many2many comodel does not exist: %r') % (self.comodel_name,)) comodel = model.env[self.comodel_name] cr.execute('CREATE TABLE "%s" ("%s" INTEGER NOT NULL, "%s" INTEGER NOT NULL, UNIQUE("%s","%s"))' % (rel, id1, id2, id1, id2)) # create foreign key references with ondelete=cascade, unless the targets are SQL views - if table_kind(cr, comodel._table) != 'v': + if sql.table_kind(cr, comodel._table) != 'v': model.pool.post_init(model._m2o_add_foreign_key_unchecked, rel, id2, comodel, 'cascade', self._module) - if table_kind(cr, model._table) != 'v': + if sql.table_kind(cr, model._table) != 'v': model.pool.post_init(model._m2o_add_foreign_key_unchecked, rel, id1, model, 'cascade', self._module) cr.execute('CREATE INDEX ON "%s" ("%s")' % (rel, id1)) @@ -2371,6 +2479,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 d0ebaa8a82b..9cc5cc63509 100644 --- a/odoo/models.py +++ b/odoo/models.py @@ -2137,6 +2137,14 @@ class BaseModel(object): self._table, column_name, field.column_format, column_name) self._cr.execute(query, (value,)) + @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): """ Initialize the database schema of ``self``: @@ -2162,11 +2170,6 @@ class BaseModel(object): # has not been added in database yet! self = self.with_context(prefetch_fields=False) - def recompute(field): - _logger.info("Storing computed values of %s", field) - recs = self.with_context(active_test=False).search([]) - recs._recompute_todo(field) - self.pool.post_init(self._reflect) cr = self._cr @@ -2175,13 +2178,8 @@ class BaseModel(object): 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) - has_rows = False - else: - cr.execute('SELECT 1 FROM "%s" LIMIT 1' % self._table) - has_rows = cr.rowcount if self._parent_store: if not tools.column_exists(cr, self._table, 'parent_left'): @@ -2190,130 +2188,24 @@ class BaseModel(object): self._check_removed_columns(log=False) - # retrieve existing database columns - column_data = tools.table_columns(cr, self._table) + # 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: - self.pool.post_init(recompute, 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: - tools.rename_column(cr, 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): - tools.convert_column(cr, self._table, name, column_type[1]) - for c in casts: - if (f_pg_type == c[0]) and (field.type == c[1]): - if f_pg_type != column_type[0]: - converted = True - tools.convert_column(cr, self._table, name, column_type[1]) - break - - if f_pg_type != column_type[0]: - if not converted: - i = 0 - while True: - newname = name + '_moved' + str(i) - if not tools.column_exists(cr, self._table, newname): - break - i += 1 - if f_pg_notnull: - tools.drop_not_null(cr, self._table, name) - tools.rename_column(cr, self._table, name, newname) - tools.create_column(cr, self._table, name, column_type[1], field.string) - - # 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 - tools.set_not_null(cr, self._table, name) - elif not field.required and f_pg_notnull == 1: - tools.drop_not_null(cr, self._table, name) - # Verify index - indexname = '%s_%s_index' % (self._table, name) - has_index = tools.index_exists(cr, indexname) - if not has_index and field.index: - tools.create_index(cr, indexname, self._table, [name]) - 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 has_index and not field.index: - tools.drop_index(cr, indexname, self._table) - - if field.type == 'many2one': - comodel = self.env[field.comodel_name] - if comodel._auto and comodel._table != 'ir_actions': - self.pool.post_init(self._m2o_fix_foreign_key, self._table, name, comodel, field.ondelete) - - else: - # the column doesn't exist in database, create it - tools.create_column(cr, self._table, name, field.column_type[1], field.string) - - # initialize it - if has_rows: - self._init_column(name) - - # remember new-style stored fields with compute method - if field.compute: - self.pool.post_init(recompute, 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.pool.post_init(self._m2o_add_foreign_key_checked, name, comodel, field.ondelete) - if field.index: - indexname = '%s_%s_index' % (self._table, name) - tools.create_index(cr, indexname, self._table, [name]) - if field.required: - tools.set_not_null(cr, self._table, name) + new = field.update_db(self, columns) + if new and field.compute: + self.pool.post_init(recompute, field) if self._auto: self._add_sql_constraints()