Merge lp:~openerp-dev/openobject-server/trunk-view-dependencies-xmo into lp:openobject-server

Proposed by Xavier (Open ERP)
Status: Needs review
Proposed branch: lp:~openerp-dev/openobject-server/trunk-view-dependencies-xmo
Merge into: lp:openobject-server
Diff against target: 307 lines (+153/-59)
3 files modified
openerp/modules/registry.py (+2/-1)
openerp/osv/orm.py (+147/-58)
openerp/tools/sql.py (+4/-0)
To merge this branch: bzr merge lp:~openerp-dev/openobject-server/trunk-view-dependencies-xmo
Reviewer Review Type Date Requested Status
Olivier Dony (Odoo) Pending
OpenERP Core Team Pending
Review via email: mp+205765@code.launchpad.net

Description of the change

Fixes conflict between inheriting from a view and depending on one:

* A module A defines a view a
* A module B defines a view b, which depends on (uses, e.g. via a join) a
* A module C inherits from (and alters) a

There is no interdependency between B and C. If C is loaded after B it will cause a new call to a.init(), which will DROP VIEW a CASCADE, which will drop b. The system ends up with view b missing even though the corresponding module (and model) are installed. There are cases of this happening in standard OpenERP modules e.g. the view crm_partner_report_assign joins on account_invoice_report, sale_crm overrides account_invoice_report to add a new field.

If sale_crm is installed after crm_partner_report_assign (which it is on e.g. -all runbots, or if one installs sale and crm_partner_report_assign), the crm_partner_report_assign view disappears and the Partnership Analysis reporting view is broken. An other example is event_sale destroying the Events Analysis.

Rejected fix method:
A consideration was to use Postgres's dependency analysis after view creation to infer view dependencies and correctly handle their reloading, this was deemed to magical and overly complex (getting dependencies between views without false positives is not trivial).

Fix method: explicit declaration of dependencies and a special view-creation method which attempts to explicitly drop and re-init dependents on the fly. The default path will use a non-cascading DROP VIEW and in case of error fall back on a cascading drop view with an error message logged.

To post a comment you must log in.
Revision history for this message
Xavier (Open ERP) (xmo-deactivatedaccount) wrote :

Initial strategy of a method call turns out to not work right (I think) for more than 1 level of dependency, there may be views init-ed multiple times and ordering can be completely broken.

Replaced the whole concept by a specific Model subclass, the developer provides the view's query (as a method or a class-level attribute) and the class can then cleanly handle destructions and recreations in case of reinit.

Revision history for this message
Xavier (Open ERP) (xmo-deactivatedaccount) wrote :

Unmerged revisions

5071. By Xavier (Open ERP)

[FIX] correctly handle drop failure (by forcing)

Drop failure can be caused by unregistered dependent views, or some other type
of dependent. If the case is encountered, log it and fallback on the old
behavior of forcing everything to drop using CASCADE.

Looked into diag object (>9.3) for possibly more informative error message,
but nothing is available outside of the exception's base info messages.

5070. By Xavier (Open ERP)

[ADD] View class for dependency handling

5069. By Xavier (Open ERP)

[IMP] move _name validation to create_instance

5068. By Xavier (Open ERP)

[IMP] minor style improvements

5067. By Xavier (Open ERP)

[REF] extract special attributes handling from create_instance

not sure this should be a module-level global thing though

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'openerp/modules/registry.py'
2--- openerp/modules/registry.py 2014-02-09 00:40:05 +0000
3+++ openerp/modules/registry.py 2014-02-12 16:56:55 +0000
4@@ -22,7 +22,7 @@
5 """ Models registries.
6
7 """
8-from collections import Mapping
9+from collections import Mapping, defaultdict
10 from contextlib import contextmanager
11 import logging
12 import threading
13@@ -49,6 +49,7 @@
14 self.models = {} # model name/model instance mapping
15 self._sql_error = {}
16 self._store_function = {}
17+ self._model_dependents = defaultdict(set)
18 self._init = True
19 self._init_parent = {}
20 self._assertion_report = assertion_report.assertion_report()
21
22=== modified file 'openerp/osv/orm.py'
23--- openerp/osv/orm.py 2014-02-06 11:04:23 +0000
24+++ openerp/osv/orm.py 2014-02-12 16:56:55 +0000
25@@ -723,6 +723,9 @@
26 # 2. uses column_info instead of a triple.
27 _all_columns = {}
28
29+ # list of model names on which this one depends
30+ _model_dependencies = set()
31+
32 _table = None
33 _invalids = set()
34 _log_create = False
35@@ -852,88 +855,78 @@
36 cr.commit()
37
38 #
39- # Goal: try to apply inheritance at the instanciation level and
40+ # Goal: try to apply inheritance at the instantiation level and
41 # put objects in the pool var
42 #
43 @classmethod
44 def create_instance(cls, pool, cr):
45- """ Instanciate a given model.
46+ """ Instantiate a given model.
47
48- This class method instanciates the class of some model (i.e. a class
49+ This class method instantiates the class of some model (i.e. a class
50 deriving from osv or osv_memory). The class might be the class passed
51 in argument or, if it inherits from another class, a class constructed
52 by combining the two classes.
53
54- The ``attributes`` argument specifies which parent class attributes
55- have to be combined.
56-
57 TODO: the creation of the combined class is repeated at each call of
58 this method. This is probably unnecessary.
59
60 """
61- attributes = ['_columns', '_defaults', '_inherits', '_constraints',
62- '_sql_constraints']
63-
64 parent_names = getattr(cls, '_inherit', None)
65 if parent_names:
66- if isinstance(parent_names, (str, unicode)):
67- name = cls._name or parent_names
68+ name = cls._name
69+ if isinstance(parent_names, basestring):
70+ name = name or parent_names
71 parent_names = [parent_names]
72- else:
73- name = cls._name
74+
75 if not name:
76 raise TypeError('_name is mandatory in case of multiple inheritance')
77
78- for parent_name in ((type(parent_names)==list) and parent_names or [parent_names]):
79- if parent_name not in pool:
80- raise TypeError('The model "%s" specifies an unexisting parent class "%s"\n'
81- 'You may need to add a dependency on the parent class\' module.' % (name, parent_name))
82- parent_model = pool[parent_name]
83+ assert isinstance(parent_names, list)
84+ for parent_name in parent_names:
85+ parent_model = pool.get(parent_name)
86+ if parent_model is None:
87+ raise TypeError(
88+ "The model \"%s\" specifies a non-existent parent "
89+ "class \"%s\"\n. You may need to add a dependency on "
90+ "the parent class's module." % (name, parent_name))
91+
92 if not getattr(cls, '_original_module', None) and name == parent_model._name:
93 cls._original_module = parent_model._original_module
94- parent_class = parent_model.__class__
95+ parent_class = type(parent_model)
96 nattr = {}
97- for s in attributes:
98- new = copy.copy(getattr(parent_model, s, {}))
99- if s == '_columns':
100- # Don't _inherit custom fields.
101- for c in new.keys():
102- if new[c].manual:
103- del new[c]
104- if hasattr(new, 'update'):
105- new.update(cls.__dict__.get(s, {}))
106- elif s=='_constraints':
107- for c in cls.__dict__.get(s, []):
108- exist = False
109- for c2 in range(len(new)):
110- #For _constraints, we should check field and methods as well
111- if new[c2][2]==c[2] and (new[c2][0] == c[0] \
112- or getattr(new[c2][0],'__name__', True) == \
113- getattr(c[0],'__name__', False)):
114- # If new class defines a constraint with
115- # same function name, we let it override
116- # the old one.
117-
118- new[c2] = c
119- exist = True
120- break
121- if not exist:
122- new.append(c)
123- else:
124- new.extend(cls.__dict__.get(s, []))
125- nattr[s] = new
126+ new_data = cls.__dict__
127+ for attr in model_attributes:
128+ parent_item = getattr(parent_model, attr.name, attr.empty())
129+ new = attr.copy(parent_item)
130+
131+ new_item = new_data.get(attr.name, attr.empty())
132+ attr.update(new, new_item)
133+
134+ nattr[attr.name] = new
135
136 # Keep links to non-inherited constraints, e.g. useful when exporting translations
137- nattr['_local_constraints'] = cls.__dict__.get('_constraints', [])
138- nattr['_local_sql_constraints'] = cls.__dict__.get('_sql_constraints', [])
139+ nattr['_local_constraints'] = new_data.get('_constraints', [])
140+ nattr['_local_sql_constraints'] = new_data.get('_sql_constraints', [])
141+ nattr['_register'] = False
142
143- cls = type(name, (cls, parent_class), dict(nattr, _register=False))
144+ cls = type(name, (cls, parent_class), nattr)
145 else:
146 cls._local_constraints = getattr(cls, '_constraints', [])
147 cls._local_sql_constraints = getattr(cls, '_sql_constraints', [])
148
149 if not getattr(cls, '_original_module', None):
150 cls._original_module = cls._module
151+
152+ if not cls._name:
153+ msg = "The class %s must have a _name attribute" % cls.__name__
154+ _logger.error(msg)
155+ raise except_orm('ValueError', msg)
156+
157+ # convert cls -> dependencies mapping to dependencies -> self, so a
158+ # model can easily know who its dependents are
159+ for dependency in getattr(cls, '_model_dependencies', ()):
160+ pool._model_dependents[dependency].add(cls._name)
161+
162 obj = object.__new__(cls)
163
164 if hasattr(obj, '_columns'):
165@@ -983,13 +976,6 @@
166 pool.add(self._name, self)
167 self.pool = pool
168
169- if not self._name and not hasattr(self, '_inherit'):
170- name = type(self).__name__.split('.')[0]
171- msg = "The class %s has to have a _name attribute" % name
172-
173- _logger.error(msg)
174- raise except_orm('ValueError', msg)
175-
176 if not self._description:
177 self._description = self._name
178 if not self._table:
179@@ -5093,6 +5079,77 @@
180 _register = False # not visible in ORM registry, meant to be python-inherited only
181 _transient = False
182
183+class View(Model):
184+ """ Dedicated :class:`Model` subtype for database views (reporting).
185+
186+ Automatically generates or drops the view named through ``_table``
187+ (automatically inferred from ``_name`` if not specified, as with normal
188+ :class:`Model` instances), the view query can be specified either via
189+ the :attr:`._query` class attribute or by overriding the :meth:`.query`
190+ method (for dynamic/overridable views).
191+
192+ Other views onto which this one depends should be declared via
193+ :attr:`._model_dependencies`, so that views overriding can be handled
194+ correc
195+
196+ :class:`View` provides its own ``init`` method.
197+ """
198+ _auto = False
199+ _register = False
200+
201+ _query = None
202+
203+ def query(self, cr):
204+ """ Returns the SQL query used to generate a view. By default, returns
205+ the value of the _query argument but can be overridden for dynamically
206+ generated or overridable view queries
207+ """
208+ return self._query
209+
210+ def _get_dependents(self):
211+ # Instead of recursive, maybe build flat list of dependents in topo
212+ # order?
213+ return [self.pool[name] for name in self.pool._model_dependents[self._name]]
214+
215+ def _drop_view(self, cr, dropped):
216+ if self._name in dropped: return
217+
218+ for model in self._get_dependents():
219+ model._drop_view(cr, dropped)
220+
221+ try:
222+ cr.execute('SAVEPOINT before_drop_attempt')
223+ cr.execute('DROP VIEW IF EXISTS %s' % self._table)
224+ except psycopg2.Error:
225+ _logger.exception(
226+ "Attempted to drop view '%s' during reload of model '%s', "
227+ "trying again with CASCADE, dependents will be silently "
228+ "discarded.\n"
229+ "\n"
230+ "If dependents are views, see orm.View and "
231+ "_model_dependencies in order to correctly handle their "
232+ "upgrade.",
233+ self._table, self._name)
234+ cr.execute('ROLLBACK TO SAVEPOINT before_drop_attempt')
235+ cr.execute('DROP VIEW %s CASCADE' % self._table)
236+ else:
237+ cr.execute('RELEASE SAVEPOINT before_drop_attempt')
238+ dropped.add(self._name)
239+
240+ def _create_view(self, cr, created):
241+ if self._name in created: return
242+
243+ created.add(self._name)
244+ cr.execute('CREATE VIEW %s AS (%s)' % (self._table, self.query(cr)))
245+
246+ # avoid double-init? Avoid redrop?
247+ for model in self._get_dependents():
248+ model._create_view(cr, created)
249+
250+ def init(self, cr):
251+ self._drop_view(cr, set())
252+ self._create_view(cr, set())
253+
254 def itemgetter_tuple(items):
255 """ Fixes itemgetter inconsistency (useful in some cases) of not returning
256 a tuple if len(items) == 1: always returns an n-tuple where n = len(items)
257@@ -5148,4 +5205,36 @@
258 # unique constraint error
259 '23505': convert_pgerror_23505,
260 })
261+
262+def _merge_constraints(copied, new_constraints):
263+ """
264+ If new class defines a constraint with the name fields and function
265+ (~name) as its ancestors, override existing constraint. Otherwise add
266+ new constraint to list
267+ """
268+ for constraint in new_constraints:
269+ for idx in range(len(copied)):
270+ if _matching_constraints(copied[idx], constraint):
271+ copied[idx] = constraint
272+ break
273+ else:
274+ copied.append(constraint)
275+def _matching_constraints(c1, c2):
276+ # match fields list, then functions
277+ return c1[2] == c2[2] and _matching_functions(c1[0], c2[0])
278+def _matching_functions(v1, v2, ph1=object(), ph2=object()):
279+ # same function or different function with same name
280+ return v1 == v2 or getattr(v1,'__name__', ph1) == getattr(v2,'__name__', ph2)
281+
282+ModelMerge = collections.namedtuple('ModelMerge', 'name empty copy update')
283+model_attributes = [
284+ ModelMerge('_columns', dict,
285+ lambda it: dict((k, v) for k, v in it.iteritems() if not v.manual),
286+ dict.update),
287+ ModelMerge('_defaults', dict, copy.copy, dict.update),
288+ ModelMerge('_inherits', dict, copy.copy, dict.update),
289+ ModelMerge('_constraints', list, copy.copy, _merge_constraints),
290+ ModelMerge('_sql_constraints', list, copy.copy, list.extend),
291+ ModelMerge('_model_dependencies', set, copy.copy, set.update)
292+]
293 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
294
295=== modified file 'openerp/tools/sql.py'
296--- openerp/tools/sql.py 2012-02-27 16:57:37 +0000
297+++ openerp/tools/sql.py 2014-02-12 16:56:55 +0000
298@@ -20,6 +20,10 @@
299 ##############################################################################
300
301 def drop_view_if_exists(cr, viewname):
302+ """
303+ .. deprecated:: 8.0
304+ use :class:`~openerp.osv.orm.View` instead
305+ """
306 cr.execute("DROP view IF EXISTS %s CASCADE" % (viewname,))
307 cr.commit()
308