odoo.define('web.data', function (require) { "use strict"; var Class = require('web.Class'); var mixins = require('web.mixins'); var Model = require('web.Model'); var session = require('web.session'); var translation = require('web.translation'); var pyeval = require('web.pyeval'); var utils = require('web.utils'); var _t = translation._t; /** * Serializes the sort criterion array of a dataset into a form which can be * consumed by OpenERP's RPC APIs. * * @param {Array} criterion array of fields, from first to last criteria, prefixed with '-' for reverse sorting * @returns {String} SQL-like sorting string (``ORDER BY``) clause */ function serialize_sort(criterion) { return _.map(criterion, function (criteria) { if (criteria[0] === '-') { return criteria.slice(1) + ' DESC'; } return criteria + ' ASC'; }).join(', '); } /** * Reverse of the serialize_sort function: convert an array of SQL-like sort * descriptors into a list of fields prefixed with '-' if necessary. */ function deserialize_sort(criterion) { return _.map(criterion, function (criteria) { var split = _.without(criteria.split(' '), ''); return (split[1] && split[1].toLowerCase() === 'desc' ? '-' : '') + split[0]; }); } var Query = Class.extend({ init: function (model, fields) { this._model = model; this._fields = fields; this._filter = []; this._context = {}; this._lazy = true; this._limit = false; this._offset = 0; this._order_by = []; }, clone: function (to_set) { to_set = to_set || {}; var q = new Query(this._model, this._fields); q._context = this._context; q._filter = this._filter; q._lazy = this._lazy; q._limit = this._limit; q._offset = this._offset; q._order_by = this._order_by; for(var key in to_set) { if (!to_set.hasOwnProperty(key)) { continue; } switch(key) { case 'filter': q._filter = new CompoundDomain( q._filter, to_set.filter); break; case 'context': q._context = new CompoundContext( q._context, to_set.context); break; case 'lazy': case 'limit': case 'offset': case 'order_by': q['_' + key] = to_set[key]; } } return q; }, _execute: function (options) { var self = this; options = options || {}; return session.rpc('/web/dataset/search_read', { model: this._model.name, fields: this._fields || false, domain: pyeval.eval('domains', [this._model.domain(this._filter)]), context: pyeval.eval('contexts', [this._model.context(this._context)]), offset: this._offset, limit: this._limit, sort: serialize_sort(this._order_by) }, options).then(function (results) { self._count = results.length; return results.records; }, null); }, /** * Fetches the first record matching the query, or null * * @param {Object} [options] additional options for the rpc() method * @returns {jQuery.Deferred} */ first: function (options) { var self = this; return this.clone({limit: 1})._execute(options).then(function (records) { delete self._count; if (records.length) { return records[0]; } return null; }); }, /** * Fetches all records matching the query * * @param {Object} [options] additional options for the rpc() method * @returns {jQuery.Deferred>} */ all: function (options) { return this._execute(options); }, /** * Fetches the number of records matching the query in the database * * @returns {jQuery.Deferred} */ count: function () { if (this._count !== undefined) { return $.when(this._count); } return this._model.call( '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) { var ctx = pyeval.eval( 'context', this._model.context(this._context)); // undefined passed in explicitly (!) if (_.isUndefined(grouping)) { grouping = []; } if (!(grouping instanceof Array)) { grouping = _.toArray(arguments); } if (_.isEmpty(grouping) && !ctx.group_by_no_leaf) { return null; } var raw_fields = _.map(grouping.concat(this._fields || []), function (field) { return field.split(':')[0]; }); var self = this; return this._model.call('read_group', { groupby: grouping, fields: _.uniq(raw_fields), domain: this._model.domain(this._filter), context: ctx, offset: this._offset, lazy: this._lazy, limit: this._limit, orderby: serialize_sort(this._order_by) || false }).then(function (results) { return _(results).map(function (result) { // FIX: querygroup initialization result.__context = result.__context || {}; result.__context.group_by = result.__context.group_by || []; _.defaults(result.__context, ctx); var grouping_fields = self._lazy ? [grouping[0]] : grouping; return new QueryGroup( self._model.name, grouping_fields, result); }); }); }, /** * Creates a new query with the union of the current query's context and * the new context. * * @param context context data to add to the query * @returns {openerp.web.Query} */ context: function (context) { if (!context) { return this; } return this.clone({context: context}); }, /** * Creates a new query with the union of the current query's filter and * the new domain. * * @param domain domain data to AND with the current query filter * @returns {openerp.web.Query} */ filter: function (domain) { if (!domain) { return this; } return this.clone({filter: domain}); }, /** * Creates a new query with the provided parameter lazy replacing the current * query's own. * * @param {Boolean} lazy indicates if the read_group should return only the * first level of groupby records, or should return the records grouped by * all levels at once (so, it makes only 1 db request). * @returns {openerp.web.Query} */ lazy: function (lazy) { return this.clone({lazy: lazy}); }, /** * Creates a new query with the provided limit replacing the current * query's own limit * * @param {Number} limit maximum number of records the query should retrieve * @returns {openerp.web.Query} */ limit: function (limit) { return this.clone({limit: limit}); }, /** * Creates a new query with the provided offset replacing the current * query's own offset * * @param {Number} offset number of records the query should skip before starting its retrieval * @returns {openerp.web.Query} */ offset: function (offset) { return this.clone({offset: offset}); }, /** * Creates a new query with the provided ordering parameters replacing * those of the current query * * @param {String...} fields ordering clauses * @returns {openerp.web.Query} */ order_by: function (fields) { if (fields === undefined) { return this; } if (!(fields instanceof Array)) { fields = _.toArray(arguments); } if (_.isEmpty(fields)) { return this; } return this.clone({order_by: fields}); } }); var QueryGroup = Class.extend({ init: function (model, grouping_fields, 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 count_key = (grouping_fields[0] && grouping_fields[0].split(':')[0]) + '_count'; var aggregates = {}; for (var key in fixed_group) { if (fixed_group.hasOwnProperty(key)) { if (!(key.indexOf('__') === 0 || _.contains(grouping_fields, key) || (key === count_key))) { aggregates[key] = fixed_group[key] || 0; } } } this.model = new Model( model, fixed_group.__context, fixed_group.__domain); var group_size = fixed_group[count_key] || fixed_group.__count || 0; var leaf_group = fixed_group.__context.group_by.length === 0; var value = (grouping_fields.length === 1) ? fixed_group[grouping_fields[0]] : _.map(grouping_fields, function (field) { return fixed_group[field]; }); var grouped_on = (grouping_fields.length === 1) ? grouping_fields[0] : grouping_fields; this.attributes = { folded: !!(fixed_group.__fold), grouped_on: grouped_on, // if terminal group (or no group) and group_by_no_leaf => use group.__count length: group_size, value: value, // 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); } }); var DataSet = Class.extend(mixins.PropertiesMixin, { /** * Collection of OpenERP records, used to share records and the current selection between views. * * @constructs instance.web.DataSet * * @param {String} model the OpenERP model this dataset will manage */ init: function(parent, model, context) { mixins.PropertiesMixin.init.call(this); this.model = model; this.context = context || {}; this.index = null; this._sort = []; this._model = new Model(model, context); this.orderer = new utils.DropMisordered(); }, previous: function () { this.index -= 1; if (!this.ids.length) { this.index = null; } else if (this.index < 0) { this.index = this.ids.length - 1; } return this; }, next: function () { this.index += 1; if (!this.ids.length) { this.index = null; } else if (this.index >= this.ids.length) { this.index = 0; } return this; }, select_id: function(id) { var idx = this.get_id_index(id); if (idx === null) { return false; } else { this.index = idx; return true; } }, get_id_index: function(id) { for (var i=0, ii=this.ids.length; i= ids.length - 1) { this.index = ids.length - 1; } }, unlink: function(ids) { this.set_ids(_.without.apply(null, [this.ids].concat(ids))); this.trigger('unlink', ids); return $.Deferred().resolve({result: true}); }, }); var DataSetSearch = DataSet.extend({ /** * @constructs instance.web.DataSetSearch * @extends instance.web.DataSet * * @param {Object} parent * @param {String} model * @param {Object} context * @param {Array} domain */ init: function(parent, model, context, domain) { this._super(parent, model, context); this.domain = domain || []; this._length = null; this.ids = []; this._model = new Model(model, context, domain); }, /** * Read a slice of the records represented by this DataSet, based on its * domain and context. * * @params {Object} options * @param {Array} [options.fields] fields to read and return, by default all fields are returned * @param {Object} [options.context] context data to add to the request payload, on top of the DataSet's own context * @param {Array} [options.domain] domain data to add to the request payload, ANDed with the dataset's domain * @param {Number} [options.offset=0] The index from which selected records should be returned * @param {Number} [options.limit=null] The maximum number of records to return * @returns {$.Deferred} */ read_slice: function (fields, options) { options = options || {}; var self = this; var q = this._model.query(fields || false) .filter(options.domain) .context(options.context) .offset(options.offset || 0) .limit(options.limit || false); q = q.order_by.apply(q, this._sort); return this.orderer.add(q.all()).done(function (records) { // FIXME: not sure about that one, *could* have discarded count q.count().done(function (count) { self._length = count; }); self.ids = _(records).pluck('id'); }); }, get_domain: function (other_domain) { return this._model.domain(other_domain); }, alter_ids: function (ids) { this._super(ids); if (this.index !== null && this.index >= this.ids.length) { this.index = this.ids.length > 0 ? this.ids.length - 1 : 0; } }, remove_ids: function (ids) { var before = this.ids.length; this._super(ids); if (this._length) { this._length -= (before - this.ids.length); } }, add_ids: function(ids, at) { var before = this.ids.length; this._super(ids, at); if(this._length){ this._length += (this.ids.length - before); } }, unlink: function(ids, callback, error_callback) { var self = this; return this._super(ids).done(function() { self.remove_ids( ids); self.trigger("dataset_changed", ids, callback, error_callback); }); }, size: function () { if (this._length !== null) { return this._length; } return this._super(); } }); var BufferedDataSet = DataSetStatic.extend({ virtual_id_prefix: "one2many_v_id_", debug_mode: true, init: function() { this._super.apply(this, arguments); this.reset_ids([]); this.last_default_get = {}; this.running_reads = []; this.mutex = new utils.Mutex(); }, default_get: function(fields, options) { var self = this; return this._super(fields, options).done(function(res) { self.last_default_get = _.clone(res); }); }, get_cache: function (id) { if (!this.cache[id]) { this.cache[id] = { 'id': id, 'values': {}, 'from_read': {}, 'changes': {}, 'readonly_fields': {}, 'to_create': false, 'to_delete': false}; } return this.cache[id]; }, _update_cache: function (id, options) { // One should call this method after modifying this.from_read, // this.to_create or this.change. It updates this.cache and // this.readonly_fields. var cached = this.get_cache(id); if (options) { _.extend(cached.from_read, options.from_read); _.extend(cached.changes, options.changes); _.extend(cached.readonly_fields, options.readonly_fields); if (options.to_create !== undefined) cached.to_create = options.to_create; if (options.to_delete !== undefined) cached.to_delete = options.to_delete; } cached.values = _.extend({'id': id}, cached.from_read, cached.changes, cached.readonly_fields); return cached; }, create: function(data, options) { var changes = _.extend({}, this.last_default_get, data); var cached = this._update_cache(_.uniqueId(this.virtual_id_prefix), _.extend({'changes': changes, 'to_create': true}, options)); this.trigger("dataset_changed", data, options); return $.Deferred().resolve(cached.id); }, write: function (id, data, options) { var self = this; var cached = this.get_cache(id); // if update after a remove, it's like an add before updating cached.to_delete = false; // apply change var def = $.Deferred(); this.mutex.exec(function () { var dirty = false; // _.each is broken if a field "length" is present for (var k in data) { if (!_.isEqual(data[k], cached.values[k])) { dirty = true; if (_.isEqual(data[k], cached.from_read[k])) { // clean changes delete cached.changes[k]; } else { cached.changes[k] = data[k]; } } else { delete data[k]; } } self._update_cache(id, options); if (dirty) { self.trigger("dataset_changed", id, data, options); } return def.resolve(data).promise(); }); return def; }, unlink: function(ids, callback, error_callback) { var self = this; _.each(ids, function (id) { self.get_cache(id).to_delete = true; }); this.set_ids(_.difference(this.ids, _.pluck(_.filter(this.cache, function (c) {return c.to_delete;}), 'id'))); this.trigger("dataset_changed", ids, callback, error_callback); return utils.async_when({result: true}).done(callback); }, reset_ids: function(ids, options) { var self = this; this.set_ids(ids); if (!options || !options.keep_read_data) { this.cache = {}; } else { _.each(this.cache, function (cache) { self._update_cache(cache.id, {'changes': {}, 'to_delete': false}); }); } this.delete_all = false; this.cancel_read(); }, cancel_read: function () { _.invoke(_.clone(this.running_reads), 'reject'); }, read_ids: function (ids, fields, options) { // read what is necessary from the server to have ids and the given // fields in this.from_read var self = this; var to_get = _.filter(ids, function(id) { var cache = self.get_cache(id); return !cache.to_create && _.any(fields, function(x) {return cache.from_read[x] === undefined;}); }); options = options || {}; var return_records = function() { var records = _.map(ids, function(id) { return _.clone(self.get_cache(id).values); }); if (self.debug_mode) { if (_.include(records, undefined)) { throw "Record not correctly loaded"; } } var sort_fields = self._sort, compare = function (v1, v2) { return (v1 < v2) ? -1 : (v1 > v2) ? 1 : 0; }; // Array.sort is not necessarily stable. We must be careful with this because // sorting an array where all items are considered equal is a worst-case that // will randomize the array with an unstable sort! Therefore we must avoid // sorting if there are no sort_fields (i.e. all items are considered equal) // See also: http://ecma262-5.com/ELS5_Section_15.htm#Section_15.4.4.11 // http://code.google.com/p/v8/issues/detail?id=90 if (sort_fields.length) { records.sort(function (a, b) { return _.reduce(sort_fields, function (acc, field) { if (acc) { return acc; } var sign = 1; if (field[0] === '-') { sign = -1; field = field.slice(1); } if(!a[field] && a[field] !== 0){ return sign; } if(!b[field] && b[field] !== 0){ return (sign == -1) ? 1 : -1; } //m2o should be searched based on value[1] not based whole value(i.e. [id, value]) if(_.isArray(a[field]) && a[field].length == 2 && _.isString(a[field][1])){ return sign * compare(a[field][1], b[field][1]); } return sign * compare(a[field], b[field]); }, 0); }); } return $.when(records); }; if(to_get.length > 0) { var def = $.Deferred(); self.running_reads.push(def); def.always(function() { self.running_reads = _.without(self.running_reads, def); }); var _super = this._super; this.mutex.exec(function () { if (def.state() !== "pending") return; _super.call(self, to_get, fields, options).then(_.bind(def.resolve, def), _.bind(def.reject, def)); return def; }); return def.then(function(records) { _.each(records, function(record, index) { // add information into from_read var id = to_get[index]; self._update_cache(id, _.extend(options, {'from_read': record})); }); return return_records(); }); } else { return return_records(); } }, /** * Invalidates caching of a record in the dataset to ensure the next read * of that record will hit the server. * * Of use when an action is going to remote-alter a record which will then * need to be reloaded, e.g. action button. * * @param {Object} id record to remove from the BDS's cache */ evict_record: function (id) { // Don't evict records which haven't yet been saved: there is no more // recent data on the server (and there potentially isn't any data), // and this breaks the assumptions of other methods (that the data // for new and altered records is both in the cache and in the change // or to_create collection) this.get_cache(id).from_read = {}; this._update_cache(id); }, call_button: function (method, args) { this.evict_record(args[0][0]); return this._super(method, args); }, alter_ids: function(n_ids, options) { var dirty = !_.isEqual(this.ids, n_ids); this._super(n_ids, options); if (dirty) { this.trigger("dataset_changed", n_ids, options); } }, }); BufferedDataSet.virtual_id_regex = /^one2many_v_id_.*$/; var ProxyDataSet = DataSetSearch.extend({ init: function() { this._super.apply(this, arguments); this.create_function = null; this.write_function = null; this.read_function = null; this.default_get_function = null; this.unlink_function = null; }, read_ids: function (ids, fields, options) { if (this.read_function) { return this.read_function(ids, fields, options, this._super); } else { return this._super.apply(this, arguments); } }, default_get: function(fields, options) { if (this.default_get_function) { return this.default_get_function(fields, options, this._super); } else { return this._super.apply(this, arguments); } }, create: function(data, options) { if (this.create_function) { return this.create_function(data, options, this._super); } else { return this._super.apply(this, arguments); } }, write: function (id, data, options) { if (this.write_function) { return this.write_function(id, data, options, this._super); } else { return this._super.apply(this, arguments); } }, unlink: function(ids) { if (this.unlink_function) { return this.unlink_function(ids, this._super); } else { return this._super.apply(this, arguments); } }, }); var CompoundContext = Class.extend({ init: function () { this.__ref = "compound_context"; this.__contexts = []; this.__eval_context = null; var self = this; _.each(arguments, function(x) { self.add(x); }); }, add: function (context) { this.__contexts.push(context); return this; }, set_eval_context: function (eval_context) { this.__eval_context = eval_context; return this; }, get_eval_context: function () { return this.__eval_context; }, eval: function() { return pyeval.eval('context', this, undefined, {no_user_context: true}); }, }); var CompoundDomain = Class.extend({ init: function () { this.__ref = "compound_domain"; this.__domains = []; this.__eval_context = null; var self = this; _.each(arguments, function(x) { self.add(x); }); }, add: function(domain) { this.__domains.push(domain); return this; }, set_eval_context: function(eval_context) { this.__eval_context = eval_context; return this; }, get_eval_context: function() { return this.__eval_context; }, eval: function() { return pyeval.eval('domain', this); }, }); function compute_domain (expr, fields) { if (! (expr instanceof Array)) return !! expr; var stack = []; for (var i = expr.length - 1; i >= 0; i--) { var ex = expr[i]; if (ex.length == 1) { var top = stack.pop(); switch (ex) { case '|': stack.push(stack.pop() || top); continue; case '&': stack.push(stack.pop() && top); continue; case '!': stack.push(!top); continue; default: throw new Error(_.str.sprintf( _t("Unknown operator %s in domain %s"), ex, JSON.stringify(expr))); } } var field = fields[ex[0]]; if (!field) { throw new Error(_.str.sprintf( _t("Unknown field %s in domain %s"), ex[0], JSON.stringify(expr))); } var field_value = field.get_value ? field.get_value() : field.value; var op = ex[1]; var val = ex[2]; switch (op.toLowerCase()) { case '=': case '==': stack.push(_.isEqual(field_value, val)); break; case '!=': case '<>': stack.push(!_.isEqual(field_value, val)); break; case '<': stack.push(field_value < val); break; case '>': stack.push(field_value > val); break; case '<=': stack.push(field_value <= val); break; case '>=': stack.push(field_value >= val); break; case 'in': if (!_.isArray(val)) val = [val]; stack.push(_(val).contains(field_value)); break; case 'not in': if (!_.isArray(val)) val = [val]; stack.push(!_(val).contains(field_value)); break; default: console.warn( _t("Unsupported operator %s in domain %s"), op, JSON.stringify(expr)); } } return _.all(stack, _.identity); } return { Query: Query, DataSet: DataSet, DataSetStatic: DataSetStatic, DataSetSearch: DataSetSearch, BufferedDataSet: BufferedDataSet, ProxyDataSet: ProxyDataSet, CompoundContext: CompoundContext, CompoundDomain: CompoundDomain, compute_domain: compute_domain, noDisplayContent: "" + _t("Unnamed") + "", }; });