Add free text support to XM.Model.restQueryFormat.
[xtuple] / lib / orm / source / xm / javascript / model.sql
1 select xt.install_js('XM','Model','xtuple', $$
2   /* Copyright (c) 1999-2014 by OpenMFG LLC, d/b/a xTuple.
3      See www.xm.ple.com/CPAL for the full text of the software license. */
4
5   if (!XM.Model) { XM.Model = {}; }
6
7   XM.Model.isDispatchable = true;
8
9   if (!XM.PrivateModel)  { XM.PrivateModel = {}; }
10   XM.PrivateModel.isDispatchable = false;
11
12   /**
13     Pass in a record type and get the next id for that type
14
15     @param {String} record type
16     @returns Number
17   */
18   XM.Model.fetchId = function(recordType) {
19     var nameSpace = recordType.beforeDot(),
20         type = recordType.afterDot(),
21         map = XT.Orm.fetch(nameSpace, type),
22         seq = map.idSequenceName,
23         sql = 'select nextval($1) as result';
24
25     return seq ? plv8.execute(sql, [seq])[0].result : false;
26   }
27
28   /**
29     Pass in a record type and get the next id for that type
30
31     @param {String} record type
32     @returns Number
33   */
34   XM.Model.fetchNumber = function(recordType) {
35     var nameSpace = recordType.beforeDot(),
36         type = recordType.afterDot(),
37         map = XT.Orm.fetch(nameSpace, type),
38         seq = map.orderSequence,
39         sql = 'select fetchNextNumber($1) as result';
40
41     /**  if the order sequence name in orderseq is not found in the ORM
42       throw an error */
43     if (seq) {
44       return plv8.execute(sql, [seq])[0].result;
45     } else {
46       plv8.elog(ERROR, "orderSequence is not defined in the ORM");
47     }
48   },
49
50   /**
51     Obtain a pessemistic record lock. Defaults to timeout of 30 seconds.
52
53     @param {String} Namespace
54     @param {String} Type
55     @param {String|Number} Id
56     @param {String} etag
57     @param {Object} Options: timeout
58   */
59   XM.Model.obtainLock = function (nameSpace, type, id, etag, options) {
60     var orm = XT.Orm.fetch(nameSpace, type),
61        data = Object.create(XT.Data),
62        lockTable = orm.lockTable || orm.table,
63        pkey = XT.Orm.primaryKey(orm),
64        nkey = XT.Orm.naturalKey(orm),
65        rec,
66        pid;
67
68     /* If the model uses a natural key, get the primary key value. */
69     rec = data.retrieveRecord({
70       nameSpace: nameSpace,
71       type: type,
72       id: id,
73       silentError: true
74     });
75
76     if (!rec) { return false; }
77
78     pid = nkey ? data.getId(orm, id) : id;
79     if (!pid) {
80 // TODO - Send not found message back.
81       return false;
82     }
83
84     if (!rec || !rec.data) { throw "Record for requested lock not found." }
85     if (rec.etag !== etag) { return false; }
86
87     return data.tryLock(lockTable, pid);
88   }
89
90   /**
91     Renew a record lock. Defaults to timeout of 30 seconds.
92
93     @param {Number} Key
94     @param {Object} Options: timeout
95   */
96   XM.Model.renewLock = function (key, options) {
97     return XT.Data.renewLock(key, options);
98   }
99
100   /**
101     Release a record lock.
102
103     @param {Number} key
104   */
105   XM.Model.releaseLock = function (key) {
106     return XT.Data.releaseLock(key);
107   }
108
109   /**
110     Release a number back into the sequence pool for a given type.
111
112     @param {String} record type
113     @param {Number} number
114     @returns Boolean
115   */
116   XM.Model.releaseNumber = function(recordType, number) {
117     var nameSpace = recordType.beforeDot(),
118         type = recordType.afterDot(),
119         map = XT.Orm.fetch(nameSpace, type),
120         seq = map.orderSequence,
121         sql = 'select releaseNumber($1, $2) as result';
122
123     return seq ? plv8.execute(sql, [seq, number - 0])[0].result > 0 : false;
124   }
125
126   /**
127     Return a matching record id for a passed user key and value. If none found returns zero.
128
129     @param {String} record type
130     @param {String} user key
131     @param {Number} value
132     @returns Number
133   */
134   XM.Model.findExisting = function(recordType, key, value, id) {
135     var nameSpace = recordType.beforeDot(),
136         type = recordType.afterDot(),
137         map = XT.Orm.fetch(nameSpace, type),
138         table = recordType.decamelize(),
139         okey = XT.Orm.naturalKey(map) || XT.Orm.primaryKey(map),
140         sql = 'select "{key}" as id from {table} where "{userKey}"::text=$1::text'
141               .replace(/{key}/, okey)
142               .replace(/{table}/, table)
143               .replace(/{userKey}/, key),
144         result;
145         if (id) {
146           sql += " and " + okey + " != $2";
147           if (DEBUG) { XT.debug('XM.Model.findExisting sql = ', sql); }
148           result = plv8.execute(sql, [value, id])[0];
149         } else {
150           if (DEBUG) { XT.debug('XM.Model.findExisting sql = ', sql); }
151           result = plv8.execute(sql, [value])[0];
152         }
153
154     return result ? result.id : 0;
155   };
156
157   /**
158    Returns a complex query's results.
159    Sample usage:
160     select xt.post('{
161       "username": "admin",
162       "nameSpace": "XM",
163       "type": "Model",
164       "dispatch":{
165         "functionName":"query",
166         "parameters":[
167           "Address", {"query": {"parameters": [{"attribute": "city","operator": "=","value": "Norfolk"}]}}
168         ]
169       }
170     }');
171
172    @param {String} recordType to query
173    @param {Object} options: query
174    @returns Object
175   */
176   XM.Model.query = function (recordType, options) {
177     options = options || {};
178     var query = {};
179
180     if (recordType && options && options.query) {
181       query.username = XT.username;
182       query.nameSpace = 'XM';
183       query.type = recordType;
184       query.query = options.query;
185     }
186
187     result = XT.Rest.get(query);
188
189     return result;
190   };
191   XM.Model.query.scope = "Model";
192   XM.Model.query.description = "Perform an complex query on a resource. This allows you to use a POST body for the query vs. a long URL.";
193   XM.Model.query.request = {
194     "$ref": "Query"
195   };
196   XM.Model.query.parameterOrder = ["recordType", "options"];
197   // For JSON-Schema deff, see:
198   // https://github.com/fge/json-schema-validator/issues/46#issuecomment-14681103
199   XM.Model.query.schema = {
200     Query: {
201       properties: {
202         attributes: {
203           title: "Service request attributes",
204           description: "An array of attributes needed to perform a complex query.",
205           type: "array",
206           items: [
207             {
208               title: "Resource",
209               description: "The resource to query.",
210               type: "string",
211               required: true
212             },
213             {
214               title: "Options",
215               type: "object",
216               "$ref": "QueryOptions"
217             }
218           ],
219           "minItems": 2,
220           "maxItems": 2,
221           required: true
222         }
223       }
224     },
225     QueryOptions: {
226       properties: {
227         query: {
228           title: "query",
229           description: "The query to perform.",
230           type: "object",
231           "$ref": "QueryOptionsQuery"
232         }
233       }
234     },
235     QueryOptionsQuery: {
236       properties: {
237         parameters: {
238           title: "Parameters",
239           description: "The query parameters.",
240           type: "array",
241           items: [
242             {
243               title: "Attribute",
244               type: "object",
245               "$ref": "QueryOptionsParameters"
246             }
247           ],
248           "minItems": 1
249         },
250         orderBy: {
251           title: "Order By",
252           description: "The query order by.",
253           type: "array",
254           items: [
255             {
256               title: "Attribute",
257               type: "object",
258               "$ref": "QueryOptionsOrderBy"
259             }
260           ]
261         },
262         rowlimit: {
263           title: "Row Limit",
264           description: "The query for paged results.",
265           type: "integer"
266         },
267         maxresults: {
268           title: "Max Results",
269           description: "The query limit for total results.",
270           type: "integer"
271         },
272         pagetoken: {
273           title: "Page Token",
274           description: "The query offset page token.",
275           type: "integer"
276         },
277         count: {
278           title: "Count",
279           description: "Set to true to return only the count of results for this query.",
280           type: "boolean"
281         }
282       }
283     },
284     QueryOptionsParameters: {
285       properties: {
286         attribute: {
287           title: "Attribute",
288           description: "The column name used to construct the query's WHERE clasues.",
289           type: "string",
290           required: true
291         },
292         operator: {
293           title: "Operator",
294           description: "The operator used to construct the query's WHERE clasues.",
295           type: "string"
296         },
297         value: {
298           title: "Value",
299           description: "The value used to construct the query's WHERE clasues.",
300           required: true
301         }
302       }
303     },
304     QueryOptionsOrderBy: {
305       properties: {
306         attribute: {
307           title: "Attribute",
308           description: "The column name used to construct the query's ORDER BY.",
309           type: "string",
310           required: true
311         },
312         descending: {
313           title: "Direction",
314           description: "Set to true so the query's ORDER BY will be DESC.",
315           type: "boolean"
316         }
317       }
318     }
319   };
320
321   /**
322    Format acomplex query's using the REST query structure into an xTuple's query.
323    This is a helper function that reformats the query structure from a
324    rest_query to our XT.Rest structure. This function should be used by reformat
325    any REST API client queriers.
326
327    Sample usage:
328     XM.Model.restQueryFormat("XM.Address", {"query": [{"city":{"EQUALS":"Norfolk"}}], "orderby": [{"ASC": "line1"}, {"DESC": "line2"}]})
329
330    @param {Object} options: query
331    @returns {Object} The formated query
332   */
333   XM.Model.restQueryFormat = function (recordType, options) {
334     options = options || {};
335
336     var order = {},
337         param = {},
338         query = {},
339         mapOperator = function (op) {
340           var operators = {
341                 value: {
342                   ANY:          'ANY',
343                   NOT_ANY:      'NOT ANY',
344                   EQUALS:       '=',
345                   NOT_EQUALS:   '!=',
346                   LESS_THAN:    '<',
347                   AT_MOST:      '<=',
348                   GREATER_THAN: '>',
349                   AT_LEAST:     '>=',
350                   MATCHES:      'MATCHES',
351                   BEGINS_WITH:  'BEGINS_WITH'
352                 }
353               };
354
355           return operators.value[op];
356         };
357
358     /* Convert from rest_query to XM.Model.query structure. */
359     if (options) {
360       if (options.query) {
361         query.parameters = [];
362         for (var i = 0; i < options.query.length; i++) {
363           for (var column in options.query[i]) {
364             for (var op in options.query[i][column]) {
365               param = {};
366               param.attribute = column;
367               param.operator = mapOperator(op);
368               param.value = options.query[i][column][op];
369               query.parameters.push(param);
370             }
371           }
372         }
373       }
374
375       /* Convert free text query. */
376       if (recordType && options.q) {
377         /* Get schema and add string columns to search query. */
378         var data = Object.create(XT.Data),
379           nameSpace = recordType.beforeDot(),
380           type = recordType.afterDot(),
381           orm = data.fetchOrm(nameSpace, type),
382           schema = XT.Session.schema(nameSpace.decamelize(), type.decamelize()),
383           param = {
384             "attribute": []
385           };
386
387         for (var c = 0; c < schema[type].columns.length; c++) {
388           if (schema[type].columns[c].category === 'S') {
389             param.attribute.push(schema[type].columns[c].name);
390           }
391         }
392
393         if (param.attribute.length) {
394           /* Add all string columns to attribute query. */
395           query.parameters = query.parameters || [];
396
397           param.operator = 'MATCHES';
398
399           /* Replace any spaces with regex '.*' so multi-word search works on similar strings. */
400           param.value = options.q.replace(' ', '.*');
401           query.parameters.push(param);
402         }
403       }
404
405       if (options.orderby || options.orderBy) {
406         options.orderBy = options.orderby || options.orderBy;
407         query.orderBy = [];
408         for (var o = 0; o < options.orderBy.length; o++) {
409           for (var column in options.orderBy[o]) {
410             order = {};
411             order.attribute = column;
412             if (options.orderBy[o][column] === 'DESC') {
413               order.descending = true;
414             }
415             query.orderBy.push(order);
416           }
417         }
418       }
419
420       if (options.rowlimit || options.rowLimit) {
421         options.rowLimit = options.rowlimit || options.rowLimit;
422         query.rowLimit = options.rowLimit;
423       }
424
425       if (options.maxresults || options.maxResults) {
426         options.maxResults = options.maxresults || options.maxResults;
427         query.rowLimit = options.maxResults;
428       }
429
430       if (options.pagetoken || options.pageToken) {
431         options.pageToken = options.pagetoken || options.pageToken;
432         if (query.rowLimit) {
433           query.rowOffset = (options.pageToken || 0) * (query.rowLimit);
434         } else {
435           query.rowOffset = (options.pageToken || 0);
436         }
437       }
438
439       if (options.count) {
440         query.count = options.count;
441       }
442     }
443
444     return query;
445   };
446
447   /**
448    Returns a complex query's results using the REST query structure. This is a
449    wrapper for XM.Model.query that reformats the query structure from a
450    rest_query to our XT.Rest structure. This dispatch function can be used by
451    a REST API client to query a resource when the query would be too long to
452    pass to the API as a GET URL query.
453
454    Sample usage:
455     select xt.post('{
456       "username": "admin",
457       "nameSpace": "XM",
458       "type": "Model",
459       "dispatch":{
460         "functionName":"restQuery",
461         "parameters":["Address", {"query": [{"city":{"EQUALS":"Norfolk"}}], "orderby": [{"ASC": "line1"}, {"DESC": "line2"}]}]
462       }
463     }');
464
465    @param {String} recordType to query
466    @param {Object} options: query
467    @returns Object
468   */
469   XM.Model.restQuery = function (recordType, options) {
470     options = options || {};
471     var formattedOptions = {};
472
473     /* Convert from rest_query to XM.Model.query structure. */
474     if (recordType && options) {
475       formattedOptions = {
476         "query": XM.Model.restQueryFormat(recordType, options)
477       };
478     }
479
480     result = XM.Model.query(recordType, formattedOptions);
481
482     return result;
483   };
484   XM.Model.restQuery.description = "Perform an complex query on a resource using the REST query structure. This allows you to use a POST body for the query vs. a long URL.";
485   XM.Model.restQuery.request = {
486     "$ref": "RestQuery"
487   };
488   XM.Model.restQuery.parameterOrder = ["recordType", "options"];
489   // For JSON-Schema deff, see:
490   // https://github.com/fge/json-schema-validator/issues/46#issuecomment-14681103
491   XM.Model.restQuery.schema = {
492     RestQuery: {
493       properties: {
494         attributes: {
495           title: "Service request attributes",
496           description: "An array of attributes needed to perform a complex query.",
497           type: "array",
498           items: [
499             {
500               title: "Resource",
501               description: "The resource to query.",
502               type: "string",
503               required: true
504             },
505             {
506               title: "Options",
507               type: "object",
508               "$ref": "RestQueryOptions"
509             }
510           ],
511           "minItems": 2,
512           "maxItems": 2,
513           required: true
514         }
515       }
516     },
517     RestQueryOptions: {
518       properties: {
519         query: {
520           title: "query",
521           description: "The query to perform.",
522           type: "array",
523           items: [
524             {
525               title: "column",
526               type: "object"
527             }
528           ],
529           "minItems": 1
530         },
531         orderby: {
532           title: "Order By",
533           description: "The query order by.",
534           type: "array",
535           items: [
536             {
537               title: "column",
538               type: "object"
539             }
540           ]
541         },
542         rowlimit: {
543           title: "Row Limit",
544           description: "The query for paged results.",
545           type: "integer"
546         },
547         maxresults: {
548           title: "Max Results",
549           description: "The query limit for total results.",
550           type: "integer"
551         },
552         pagetoken: {
553           title: "Page Token",
554           description: "The query offset page token.",
555           type: "integer"
556         },
557         count: {
558           title: "Count",
559           description: "Set to true to return only the count of results for this query.",
560           type: "boolean"
561         }
562       }
563     }
564   };
565
566   /**
567     Used to determine whether a model is used or not.
568
569     @param {String} Record Type
570     @param {String|Number} Id
571     @param {Array} Array of schema qualified foreign key table names that are exceptions
572     @private
573   */
574   XM.PrivateModel.used = function(recordType, id, exceptions) {
575       exceptions = exceptions || [];
576       var nameSpace = recordType.beforeDot(),
577       type = recordType.afterDot(),
578       map = XT.Orm.fetch(nameSpace, type),
579       data = Object.create(XT.Data),
580       nkey = XT.Orm.naturalKey(map),
581       tableName = map.lockTable || map.table,
582       tableSuffix = tableName.indexOf('.') ? tableName.afterDot() : tableName,
583       sql,
584       fkeys,
585       uses,
586       i,
587       attr,
588       seq,
589       tableName,
590       fkIndex,
591       fkey,
592       propIndex,
593       probObj;
594
595     if (nkey) {
596       id = data.getId(map, id);
597       if (!id) {
598         /* Throw an error here because returning false is a valid use case. */
599         plv8.elog(ERROR, "Can not find primary key.");
600       }
601     }
602
603     /* Determine where this record is used by analyzing foreign key linkages */
604     sql = "select pg_namespace.nspname AS schemaname, " +
605           "con.relname AS tablename, " +
606           "conkey AS seq, " +
607           "conrelid AS class_id " +
608           "from pg_constraint, pg_class f, pg_class con, pg_namespace " +
609           "where confrelid=f.oid " +
610           "and conrelid=con.oid " +
611           "and f.relname = $1 " +
612           "and con.relnamespace=pg_namespace.oid; "
613     fkeys = plv8.execute(sql, [tableSuffix]);
614
615     /* isNested toMany relationships are irrelevant and should not be counted */
616     /* First boil down our list of isNested toManys from the orm */
617     var toMany = map.properties.filter(function (prop) {
618       return prop.toMany && prop.toMany.isNested;
619     }).map(function (prop) {
620       var toManyType = prop.toMany.type,
621         toManyMap = XT.Orm.fetch(nameSpace, toManyType)
622         toManyTable = toManyMap.lockTable || toManyMap.table,
623         toManyPrefix = toManyTable.indexOf('.') < 0 ? "public" : toManyTable.beforeDot(),
624         toManySuffix = toManyTable.afterDot();
625
626       return {nameSpace: toManyPrefix, tableName: toManySuffix};
627     });
628
629     if (DEBUG) { XT.debug('XM.Model.used toMany relations are:', JSON.stringify(toMany)); }
630
631     for (fkIndex = fkeys.length - 1; fkIndex >= 0; fkIndex-=1) {
632       /* loop backwards because we might be deleting elements of the array */
633       fkey = fkeys[fkIndex];
634       toMany.map(function (prop) {
635         if (fkey.schemaname === prop.nameSpace && fkey.tablename === prop.tableName) {
636           fkeys.splice(fkIndex, 1);
637         }
638       });
639     }
640
641     /* Remove exceptions */
642     fkeys = fkeys.filter(function (key) {
643       var name = key.schemaname + '.' + key.tablename;
644       return !exceptions.contains(name);
645     });
646
647     if (DEBUG) { XT.debug('XM.Model.used keys length:', fkeys.length) }
648     if (DEBUG) { XT.debug('XM.Model.used keys:', JSON.stringify(fkeys)) }
649
650     for (i = 0; i < fkeys.length; i++) {
651       /* Validate */
652
653       sql = "select attname " +
654             "from pg_attribute, pg_class " +
655             "where ((attrelid=pg_class.oid) " +
656             " and (pg_class.oid = $1) " +
657             " and (attnum = $2)); ";
658
659       classId = fkeys[i].class_id;
660       seq =  fkeys[i].seq[0];
661       tableName = fkeys[i].schemaname + '.' + fkeys[i].tablename;
662       if (DEBUG) { XT.debug('XM.Model.used vars:', [classId, seq, tableName]) }
663       attr = plv8.execute(sql, [classId, seq])[0].attname;
664
665       /* See if there are dependencies */
666       sql = 'select * from ' + tableName + ' where ' + attr + ' = $1 limit 1;'
667       uses = plv8.execute(sql, [id]);
668       if (uses.length) { return true; }
669     }
670
671     return false
672   }
673
674   /**
675     Return whether a model is referenced by another table.
676   */
677   XM.Model.used = function(recordType, id) {
678     return XM.PrivateModel.used(recordType, id);
679   };
680
681 $$ );