From 9b0cc92b66189ed5c21e12c830dae89e29e8f51e Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Fri, 2 Mar 2012 16:47:59 +0100 Subject: [PATCH] [ADD] grouping to the Model/Query API, but filtering looks completely broken bzr revid: xmo@openerp.com-20120302154759-8ihi5p1ffygiyhw3 --- addons/web/static/src/js/data.js | 303 ++++++++++++++----------------- doc/source/changelog-6.2.rst | 71 ++++++++ doc/source/rpc.rst | 73 ++++++++ 3 files changed, 277 insertions(+), 170 deletions(-) diff --git a/addons/web/static/src/js/data.js b/addons/web/static/src/js/data.js index 544469c3f9c..cfe5a312c03 100644 --- a/addons/web/static/src/js/data.js +++ b/addons/web/static/src/js/data.js @@ -9,13 +9,13 @@ openerp.web.data = function(openerp) { * @returns {String} SQL-like sorting string (``ORDER BY``) clause */ openerp.web.serialize_sort = function (criterion) { - return _.map(criterion, - function (criteria) { - if (criteria[0] === '-') { - return criteria.slice(1) + ' DESC'; - } - return criteria + ' ASC'; - }).join(', '); + return _.map(criterion, + function (criteria) { + if (criteria[0] === '-') { + return criteria.slice(1) + ' DESC'; + } + return criteria + ' ASC'; + }).join(', '); }; openerp.web.Query = openerp.web.Class.extend({ @@ -103,6 +103,38 @@ openerp.web.Query = openerp.web.Class.extend({ 'search_count', [this._filter], { context: this._model.context(this._context)}); }, + /** + * Performs a groups read according to the provided grouping criterion + * + * @param {String|Array} grouping + * @returns {jQuery.Deferred> | null} + */ + group_by: function (grouping) { + if (grouping === undefined) { + return null; + } + + if (!(grouping instanceof Array)) { + grouping = _.toArray(arguments); + } + if (_.isEmpty(grouping)) { return null; } + + var self = this; + return this._model.call('read_group', { + groupby: grouping, + fields: _.uniq(grouping.concat(this._fields || [])), + domain: this._model.domain(this._filter), + context: this._model.context(this._context), + offset: this._offset, + limit: this._limit, + orderby: openerp.web.serialize_sort(this._order_by) || false + }).pipe(function (results) { + return _(results).map(function (result) { + return new openerp.web.data.Group( + self._model.name, grouping[0], result); + }); + }); + }, /** * Creates a new query with the union of the current query's context and * the new context. @@ -152,9 +184,12 @@ openerp.web.Query = openerp.web.Class.extend({ * @param {String...} fields ordering clauses * @returns {openerp.web.Query} */ - order_by: function () { - if (arguments.length === 0) { return this; } - return this.clone({order_by: _.toArray(arguments)}); + order_by: function (fields) { + if (!fields instanceof Array) { + fields = _.toArray(arguments); + } + if (_.isEmpty(fields)) { return this; } + return this.clone({order_by: fields}); } }); @@ -345,6 +380,64 @@ openerp.web.Traverser = openerp.web.Class.extend(/** @lends openerp.web.Traverse }); +/** + * Utility objects, should never need to be instantiated from outside of this + * module + * + * @namespace + */ +openerp.web.data = { + Group: openerp.web.Class.extend(/** @lends openerp.web.data.Group# */{ + /** + * @constructs openerp.web.data.Group + * @extends openerp.web.Class + */ + init: function (model, grouping_field, read_group_group) { + // In cases where group_by_no_leaf and no group_by, the result of + // read_group has aggregate fields but no __context or __domain. + // Create default (empty) values for those so that things don't break + var fixed_group = _.extend( + {__context: {group_by: []}, __domain: []}, + read_group_group); + + var aggregates = {}; + _(fixed_group).each(function (value, key) { + if (key.indexOf('__') === 0 + || key === grouping_field + || key === grouping_field + '_count') { + return; + } + aggregates[key] = value || 0; + }); + + this.model = new openerp.web.Model( + model, fixed_group.__context, fixed_group.__domain); + + var group_size = fixed_group[grouping_field + '_count'] || fixed_group.__count || 0; + var leaf_group = fixed_group.__context.group_by.length === 0; + this.attributes = { + grouped_on: grouping_field, + // if terminal group (or no group) and group_by_no_leaf => use group.__count + length: group_size, + value: fixed_group[grouping_field], + // A group is open-able if it's not a leaf in group_by_no_leaf mode + has_children: !(leaf_group && fixed_group.__context['group_by_no_leaf']), + + aggregates: aggregates + }; + }, + get: function (key) { + return this.attributes[key]; + }, + subgroups: function () { + return this.model.query().group_by(this.model.context().group_by); + }, + query: function () { + return this.model.query.apply(this.model, arguments); + } + }) +}; + openerp.web.DataGroup = openerp.web.OldWidget.extend( /** @lends openerp.web.DataGroup# */{ /** * Management interface between views and grouped collections of OpenERP @@ -368,181 +461,51 @@ openerp.web.DataGroup = openerp.web.OldWidget.extend( /** @lends openerp.web.Da */ init: function(parent, model, domain, context, group_by, level) { this._super(parent, null); - if (group_by) { - if (group_by.length || context['group_by_no_leaf']) { - return new openerp.web.ContainerDataGroup( this, model, domain, context, group_by, level); - } else { - return new openerp.web.GrouplessDataGroup( this, model, domain, context, level); - } - } - - this.model = model; + this.model = new openerp.web.Model(model, context, domain); + this.group_by = group_by; this.context = context; this.domain = domain; this.level = level || 0; }, - cls: 'DataGroup' -}); -openerp.web.ContainerDataGroup = openerp.web.DataGroup.extend( /** @lends openerp.web.ContainerDataGroup# */ { - /** - * - * @constructs openerp.web.ContainerDataGroup - * @extends openerp.web.DataGroup - * - * @param session - * @param model - * @param domain - * @param context - * @param group_by - * @param level - */ - init: function (parent, model, domain, context, group_by, level) { - this._super(parent, model, domain, context, null, level); - - this.group_by = group_by; - }, - /** - * The format returned by ``read_group`` is absolutely dreadful: - * - * * A ``__context`` key provides future grouping levels - * * A ``__domain`` key provides the domain for the next search - * * The current grouping value is provided through the name of the - * current grouping name e.g. if currently grouping on ``user_id``, then - * the ``user_id`` value for this group will be provided through the - * ``user_id`` key. - * * Similarly, the number of items in the group (not necessarily direct) - * is provided via ``${current_field}_count`` - * * Other aggregate fields are just dumped there - * - * This function slightly improves the grouping records by: - * - * * Adding a ``grouped_on`` property providing the current grouping field - * * Adding a ``value`` and a ``length`` properties which replace the - * ``$current_field`` and ``${current_field}_count`` ones - * * Moving aggregate values into an ``aggregates`` property object - * - * Context and domain keys remain as-is, they should not be used externally - * but in case they're needed... - * - * @param {Object} group ``read_group`` record - */ - transform_group: function (group) { - var field_name = this.group_by[0]; - // In cases where group_by_no_leaf and no group_by, the result of - // read_group has aggregate fields but no __context or __domain. - // Create default (empty) values for those so that things don't break - var fixed_group = _.extend( - {__context: {group_by: []}, __domain: []}, - group); - - var aggregates = {}; - _(fixed_group).each(function (value, key) { - if (key.indexOf('__') === 0 - || key === field_name - || key === field_name + '_count') { - return; - } - aggregates[key] = value || 0; - }); - - var group_size = fixed_group[field_name + '_count'] || fixed_group.__count || 0; - var leaf_group = fixed_group.__context.group_by.length === 0; - return { - __context: fixed_group.__context, - __domain: fixed_group.__domain, - - grouped_on: field_name, - // if terminal group (or no group) and group_by_no_leaf => use group.__count - length: group_size, - value: fixed_group[field_name], - // A group is openable if it's not a leaf in group_by_no_leaf mode - openable: !(leaf_group && this.context['group_by_no_leaf']), - - aggregates: aggregates - }; - }, - fetch: function (fields) { - // internal method - var d = new $.Deferred(); - var self = this; - - this.rpc('/web/group/read', { - model: this.model, - context: this.context, - domain: this.domain, - fields: _.uniq(this.group_by.concat(fields)), - group_by_fields: this.group_by, - sort: openerp.web.serialize_sort(this.sort) - }, function () { }).then(function (response) { - var data_groups = _(response).map( - _.bind(self.transform_group, self)); - self.groups = data_groups; - d.resolveWith(self, [data_groups]); - }, function () { - d.rejectWith.apply(d, [self, arguments]); - }); - return d.promise(); - }, - /** - * The items of a list have the following properties: - * - * ``length`` - * the number of records contained in the group (and all of its - * sub-groups). This does *not* provide the size of the "next level" - * of the group, unless the group is terminal (no more groups within - * it). - * ``grouped_on`` - * the name of the field this level was grouped on, this is mostly - * used for display purposes, in order to know the name of the current - * level of grouping. The ``grouped_on`` should be the same for all - * objects of the list. - * ``value`` - * the value which led to this group (this is the value all contained - * records have for the current ``grouped_on`` field name). - * ``aggregates`` - * a mapping of other aggregation fields provided by ``read_group`` - * - * @param {Array} fields the list of fields to aggregate in each group, can be empty - * @param {Function} ifGroups function executed if any group is found (DataGroup.group_by is non-null and non-empty), called with a (potentially empty) list of groups as parameters. - * @param {Function} ifRecords function executed if there is no grouping left to perform, called with a DataSet instance as parameter - */ list: function (fields, ifGroups, ifRecords) { var self = this; - this.fetch(fields).then(function (group_records) { - ifGroups(_(group_records).map(function (group) { - var child_context = _.extend({}, self.context, group.__context); + $.when(this.model.query(fields) + .order_by(this.sort) + .group_by(this.group_by)).then(function (groups) { + if (!groups) { + console.log(self.domain); + console.log(self.model.domain()); + ifRecords(_.extend( + new openerp.web.DataSetSearch(self, self.model.name), + {domain: self.model.domain(), context: self.model.context(), + _sort: self.sort})); + return; + } + ifGroups(_(groups).map(function (group) { + var child_context = _.extend( + {}, self.model.context(), group.model.context()); return _.extend( new openerp.web.DataGroup( - self, self.model, group.__domain, + self, self.model.name, group.model.domain(), child_context, child_context.group_by, self.level + 1), - group, {sort: self.sort}); + { + __context: child_context, + __domain: group.model.domain(), + grouped_on: group.get('grouped_on'), + length: group.get('length'), + value: group.get('value'), + openable: group.get('has_children'), + aggregates: group.get('aggregates') + }, {sort: self.sort}); })); }); } }); -openerp.web.GrouplessDataGroup = openerp.web.DataGroup.extend( /** @lends openerp.web.GrouplessDataGroup# */ { - /** - * - * @constructs openerp.web.GrouplessDataGroup - * @extends openerp.web.DataGroup - * - * @param session - * @param model - * @param domain - * @param context - * @param level - */ - init: function (parent, model, domain, context, level) { - this._super(parent, model, domain, context, null, level); - }, - list: function (fields, ifGroups, ifRecords) { - ifRecords(_.extend( - new openerp.web.DataSetSearch(this, this.model), - {domain: this.domain, context: this.context, _sort: this.sort})); - } -}); +openerp.web.ContainerDataGroup = openerp.web.DataGroup.extend({ }); +openerp.web.GrouplessDataGroup = openerp.web.DataGroup.extend({ }); + openerp.web.StaticDataGroup = openerp.web.GrouplessDataGroup.extend( /** @lends openerp.web.StaticDataGroup# */ { /** * A specialization of groupless data groups, relying on a single static diff --git a/doc/source/changelog-6.2.rst b/doc/source/changelog-6.2.rst index 5facb47ac14..48d79bf6398 100644 --- a/doc/source/changelog-6.2.rst +++ b/doc/source/changelog-6.2.rst @@ -12,6 +12,51 @@ and removes most stateful behavior of DataSet. Migration guide ~~~~~~~~~~~~~~~ +* Actual arbitrary RPC calls can just be remapped on a + :js:class:`~openerp.web.Model` instance: + + .. code-block:: javascript + + dataset.call(method, args) + + or + + .. code-block:: javascript + + dataset.call_and_eval(method, args) + + can be replaced by calls to :js:func:`openerp.web.Model.call`: + + .. code-block:: javascript + + model.call(method, args) + + If callbacks are passed directly to the older methods, they need to + be added to the new one via ``.then()``. + + .. note:: + + The ``context_index`` and ``domain_index`` features were not + ported, context and domain now need to be passed in "in full", + they won't be automatically filled with the user's current + context. + +* Shorcut methods (``name_get``, ``name_search``, ``unlink``, + ``write``, ...) should be ported to + :js:func:`openerp.web.Model.call`, using the server's original + signature. On the other hand, the non-shortcut equivalents can now + use keyword arguments (see :js:func:`~openerp.web.Model.call`'s + signature for details) + +* ``read_slice``, which allowed a single round-trip to perform a + search and a read, should be reimplemented via + :js:class:`~openerp.web.Query` objects (see: + :js:func:`~openerp.web.Model.query`) for clearer and simpler + code. ``read_index`` should be replaced by a + :js:class:`~openerp.web.Query` as well, combining + :js:func:`~openerp.web.Query.offset` and + :js:func:`~openerp.web.Query.first`. + Rationale ~~~~~~~~~ @@ -35,3 +80,29 @@ API simplification a restricted Python evaluator (in javascript) meaning most of the context and domain parsing & evaluation can be moved to the javascript code and does not require cooperative RPC bridging. + +DataGroup -> also Model +----------------------- + +Alongside the deprecation of ``DataSet`` for +:js:class:`~openerp.web.Model`, OpenERP Web 6.2 also deprecates +``DataGroup`` and its subtypes in favor of a single method on +:js:class:`~openerp.web.Query`: +:js:func:`~openerp.web.Query.group_by`. + +Migration guide +~~~~~~~~~~~~~~~ + +Rationale +~~~~~~~~~ + +While the ``DataGroup`` API worked (mostly), it is quite odd and +alien-looking, a bit too Smalltalk-inspired (behaves like a +self-contained flow-control structure for reasons which may or may not +have been good). + +Because it is heavily related to ``DataSet`` (as it *yields* +``DataSet`` objects), deprecating ``DataSet`` automatically deprecates +``DataGroup`` (if we want to stay consistent), which is a good time to +make the API more imperative and look more like what most developers +are used to. diff --git a/doc/source/rpc.rst b/doc/source/rpc.rst index 5dea140c798..9f8f4424ce6 100644 --- a/doc/source/rpc.rst +++ b/doc/source/rpc.rst @@ -128,6 +128,17 @@ around and use them differently/add new specifications on them. :rtype: Deferred + .. js:function:: openerp.web.Query.group_by(grouping...) + + Fetches the groups for the query, using the first specified + grouping parameter + + :param Array grouping: Lists the levels of grouping + asked of the server. Grouping + can actually be an array or + varargs. + :rtype: Deferred> | null + The second set of methods is the "mutator" methods, they create a **new** :js:class:`~openerp.web.Query` object with the relevant (internal) attribute either augmented or replaced. @@ -170,6 +181,65 @@ around and use them differently/add new specifications on them. (``?`` field) and the inability to "drill down" into relations for sorting. +Aggregation (grouping) +~~~~~~~~~~~~~~~~~~~~~~ + +OpenERP has powerful grouping capacities, but they are kind-of strange +in that they're recursive, and level n+1 relies on data provided +directly by the grouping at level n. As a result, while ``read_group`` +works it's not a very intuitive API. + +OpenERP Web 6.2 eschews direct calls to ``read_group`` in favor of +calling a method of :js:class:`~openerp.web.Query`, `much in the way +it is one in SQLAlchemy +`_ [#]_: + +.. code-block:: javascript + + some_query.group_by(['field1', 'field2']).then(function (groups) { + // do things with the fetched groups + }); + +This method is asynchronous when provided with 1..n fields (to group +on) as argument, but it can also be called without any field (empty +fields collection or nothing at all). In this case, instead of +returning a Deferred object it will return ``null``. + +When grouping criterion come from a third-party and may or may not +list fields (e.g. could be an empty list), this provides two ways to +test the presence of actual subgroups (versus the need to perform a +regular query for records): + +* A check on ``group_by``'s result and two completely separate code + paths + + .. code-block:: javascript + + var groups; + if (groups = some_query.group_by(gby)) { + groups.then(function (gs) { + // groups + }); + } + // no groups + +* Or a more coherent code path using :js:func:`when`'s ability to + coerce values into deferreds: + + .. code-block:: javascript + + $.when(some_query.group_by(gby)).then(function (groups) { + if (!groups) { + // No grouping + } else { + // grouping, even if there are no groups (groups + // itself could be an empty array) + } + }); + +The result of a (successful) :js:func:`~openerp.web.Query.group_by` is +an array of :js:class:`~openerp.web.data.Group`. + Synchronizing views (provisional) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -268,5 +338,8 @@ For instance, to call the ``eval_domain_and_context`` of the // handle result }); +.. [#] with a small twist: SQLAlchemy's ``orm.query.Query.group_by`` + is not terminal, it returns a query which can still be altered. + .. [#] except for ``context``, which is extracted and stored in the request object itself.