from __future__ import annotations import typing from odoo.tools import sql if typing.TYPE_CHECKING: from collections.abc import Callable import psycopg2.extensions from .environments import Environment from .models import BaseModel from .registry import Registry ConstraintMessageType = ( str | Callable[[Environment, psycopg2.extensions.Diagnostics | None], str] ) IndexDefinitionType = ( str | Callable[[Registry], str] ) class TableObject: """ Declares a SQL object related to the model. The identifier of the SQL object will be "{model._table}_{name}". """ name: str message: ConstraintMessageType = '' _module: str = '' def __init__(self): """Abstract SQL object""" # to avoid confusion: name is unique inside the model, full_name is in the database self.name = '' def __set_name__(self, owner, name): # database objects should be private member fo the class: # first of all, you should not need to access them from any model # and this avoid having them in the middle of the fields when listing members assert name.startswith('_'), "Names of SQL objects in a model must start with '_'" assert not name.startswith(f"_{owner.__name__}__"), "Names of SQL objects must not be mangled" self.name = name[1:] if getattr(owner, 'pool', None) is None: # models.is_model_definition(owner) # only for fields on definition classes, not registry classes self._module = owner._module owner._table_object_definitions.append(self) def get_definition(self, registry: Registry) -> str: raise NotImplementedError def full_name(self, model: BaseModel) -> str: assert self.name, f"The table object is not named ({self.definition})" name = f"{model._table}_{self.name}" return sql.make_identifier(name) def get_error_message(self, model: BaseModel, diagnostics=None) -> str: """Build an error message for the object/constraint. :param model: Optional model on which the constraint is defined :param diagnostics: Optional diagnostics from the raised exception :return: Translated error for the user """ message = self.message if callable(message): return message(model.env, diagnostics) return message def apply_to_database(self, model: BaseModel): raise NotImplementedError def __str__(self) -> str: return f"({self.name!r}={self.definition!r}, {self.message!r})" class Constraint(TableObject): """ SQL table constraint. The definition of the constraint is used to `ADD CONSTRAINT` on the table. """ def __init__( self, definition: str, message: ConstraintMessageType = '', ) -> None: """ SQL table containt. The definition is the SQL that will be used to add the constraint. If the constraint is violated, we will show the message to the user or an empty string to get a default message. Examples of constraint definitions: - CHECK (x > 0) - FOREIGN KEY (abc) REFERENCES some_table(id) - UNIQUE (user_id) """ super().__init__() self._definition = definition if message: self.message = message def get_definition(self, registry: Registry): return self._definition def apply_to_database(self, model: BaseModel): cr = model.env.cr conname = self.full_name(model) definition = self.get_definition(model.pool) current_definition = sql.constraint_definition(cr, model._table, conname) if current_definition == definition: return if current_definition: # constraint exists but its definition may have changed sql.drop_constraint(cr, model._table, conname) model.pool.post_constraint( cr, lambda cr: sql.add_constraint(cr, model._table, conname, definition), conname) class Index(TableObject): """ Index on the table. ``CREATE INDEX ... ON model_table ``. """ unique: bool = False def __init__(self, definition: IndexDefinitionType): """ Index in SQL. The name of the SQL object will be "{model._table}_{key}". The definition is the SQL that will be used to create the constraint. Example of definition: - (group_id, active) WHERE active IS TRUE - USING btree (group_id, user_id) """ super().__init__() self._index_definition = definition def get_definition(self, registry: Registry): if callable(self._index_definition): definition = self._index_definition(registry) else: definition = self._index_definition if not definition: return '' return f"{'UNIQUE ' if self.unique else ''}INDEX {definition}" def apply_to_database(self, model: BaseModel): cr = model.env.cr conname = self.full_name(model) definition = self.get_definition(model.pool) db_definition, db_comment = sql.index_definition(cr, conname) if db_comment == definition or (not db_comment and db_definition): # keep when the definition matches the comment in the database # or if we have an index without a comment (this is used by support to tweak indexes) return if db_definition: # constraint exists but its definition may have changed sql.drop_index(cr, conname, model._table) if callable(self._index_definition): definition_clause = self._index_definition(model.pool) else: definition_clause = self._index_definition if not definition_clause: # Don't create index with an empty definition return model.pool.post_constraint(cr, lambda cr: sql.add_index( cr, conname, model._table, comment=definition, definition=definition_clause, unique=self.unique, ), conname) class UniqueIndex(Index): """ Unique index on the table. ``CREATE UNIQUE INDEX ... ON model_table ``. """ unique = True def __init__(self, definition: IndexDefinitionType, message: ConstraintMessageType = ''): """ Unique index in SQL. The name of the SQL object will be "{model._table}_{key}". The definition is the SQL that will be used to create the constraint. You can also specify a message to be used when constraint is violated. Example of definition: - (group_id, active) WHERE active IS TRUE - USING btree (group_id, user_id) """ super().__init__(definition) if message: self.message = message