]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/web/js/dojo/openils/PermaCrud/Store.js
fetchItemByIdentity now returns null immediately upon null identity
[working/Evergreen.git] / Open-ILS / web / js / dojo / openils / PermaCrud / Store.js
1 if (!dojo._hasResource["openils.PermaCrud.Store"]) {
2     dojo._hasResource["openils.PermaCrud.Store"] = true;
3     dojo.provide("openils.PermaCrud.Store");
4     dojo.require("openils.PermaCrud");
5
6     /* an exception class specific to openils.PermaCrud.Store */
7     function PCSError(message) { this.message = message; }
8     PCSError.prototype.toString = function() {
9         return "openils.PermaCrud.Store: " + this.message;
10     };
11
12     /* PCSQueryCache is a here to prevent openils.PermaCrud.Store from asking
13      * openils.PermaCrud redundant questions within short time frames.
14      */
15     function PCSQueryCache() {
16         var self = this;
17
18         this._init = function(max_age) {
19             if (typeof (this.max_age = max_age) == "undefined")
20                 throw new PCSError("PCSQueryCache requires max_age parameter");
21             this._cached_items = {};
22         };
23
24         this._is_left_anchored = function(key) {
25             return key.slice(-1) == "%";
26         };
27
28         /* Find any reasonably close matches for key  */
29         this._similar_key = function(key) {
30             var key_is_left_anchored = this._is_left_anchored(key);
31
32             for (var candidate in this._cached_items) {
33                 if (key == candidate) {
34                     return candidate;
35                 } else if (!key_is_left_anchored &&
36                     this._is_left_anchored(candidate)) {
37                     if (candidate.slice(0, -1) == key)
38                         return candidate;
39                 }
40             }
41
42             return null;
43         };
44
45         this._get_if_fresh = function(key) {
46             /* XXX This is passive cache aging. Make it active w/ setTimeout? */
47             var age = new Date().getTime() - this._cached_items[key].when;
48             if (age > this.max_age) {
49                 delete this._cached_items[key];
50                 return [];
51             } else {
52                 return this._cached_items[key].data;
53             }
54         };
55
56         this.put = function(key, data) {
57             this._cached_items[key] = {
58                 "when": new Date().getTime(), "data": data
59             };
60         };
61
62         this.get = function(key) {
63             if (similar = this._similar_key(key)) { /* assignment */
64                 var results = this._get_if_fresh(similar);
65                 if (results.length)
66                     console.log("cache hit: " + key);
67                 return results;
68             } else {
69                 return [];
70             }
71         };
72
73         this.clear = function(key) {
74             this.put(key, []);
75         };
76
77         this.add = function(key, datum) {
78             this._cached_items[key].data.push(datum);
79             this._cached_items[key].when = new Date().getTime();
80         };
81
82         this._init.apply(this, arguments);
83     }
84
85     dojo.declare(
86         "openils.PermaCrud.Store", null, {
87         //  summary:
88         //      This is a data store implementing the Read and Identity APIs,
89         //      making it possible to lazy-load fieldmapper objects via the
90         //      PermaCrud service.
91         //  description:
92         //      Two "levels" of laziness are possible. You get one
93         //      level of laziness by default: no retrieve-all queries are
94         //      honored, and fetch() only retrieves objects matching
95         //      substantive queries. This is great for autocompleting dijits.
96         //      The second level of laziness is invoked by using stubby mode.
97         //      In stubby mode, fetch() only retrieves IDs and returns place-
98         //      holder objects, while getValue() or anything like it will
99         //      actually retrieve the full object.  This may be more useful for
100         //      grids.  In any event, huge datasets don't have to be retrieved
101         //      just to provide a widget whereby a user can select a single item.
102         //
103         //      Later it is hoped that we will also implement the Notification
104         //      and Write APIs here, which will enable vastly simpler interfaces
105         //      to be developed (and existing interfaces to be vastly simplified)
106         //      in Evergreen. Think no more keeping track at the interface layer
107         //      of dirty objects, nor manually updating one dijit's store when a
108         //      value in another changes.
109         //
110         //      Note that the methods of this class may throw exceptions in cases
111         //      where such behavior is prescribed by the dojo data API from
112         //      which said methods originate.  These might not be documented in
113         //      the method summaries below.
114         //
115         //      The Thought behind all this came from Mike Rylander, who has a
116         //      pretty clear vision of what this needs to be and how it needs
117         //      to get there. The actual typing, testing, and gradually dawning
118         //      understanding is brought to you by Lebbeous Fogle-Weekley.
119
120         "constructor": function(/* object */ args) {
121             //  summary:
122             //      Insantiates the store.
123             //  description:
124             //      Requires the object argument *args*.
125             //  args:
126             //      An object with these properties:
127             //      {
128             //          fmclass:            (string required),
129             //          fetch_limit:        (int default 50),
130             //          max_query_cache_age:(int default 10000 ms),
131             //          stubby:             (bool default false),
132             //          honor_retrieve_all: (bool default value of *stubby*),
133             //          label_attributes:   (optional array of attribute names)
134             //          label_separator:    (string default " ")
135             //          base_filter:        (optional object pcrud search filter)
136             //          pcrud:              (optional openils.PermaCrud object),
137             //          authtoken:          (optional string authtoken)
138             //      }
139             //
140             //  The *fmclass* parameter.
141             //      This is required, and should be a class hint from the IDL.
142             //      In this way you specify the class that the store will deal
143             //      with.
144             //
145             //  The *fetch_limit* parameter.
146             //      The maximum number of items the store will fetch at a time.
147             //
148             //  The *max_query_cache_age* parameter.
149             //      An internal cache is used to avoid re-issuing the same query
150             //      repeatedly to PermaCrud. This is necessary because some
151             //      dijits (dijit.form.FilteringSelect, for example) get pretty
152             //      talky with the fetch() method.  With this parameter you're
153             //      specifying the maximum age of entry in this cache. After
154             //      this length of time, a fresh call to fetch(), even with
155             //      the same query as it was issued in a previous call, will
156             //      result in a call to PermaCrud.
157             //
158             //  The *stubby* parameter.
159             //      In stubby mode, fetch() only retrieves IDs and returns
160             //      place-holder objects, while getValue or anything like it
161             //      will /then/ actually retrieve the full object.
162             //
163             //  The *honor_retrive_all* parameter.
164             //      This is normally set to whatever the value of *stubby* is,
165             //      meaning that queries from dijits of the form
166             //      {query: {key: ""}} and {query: {key: "*"}} are ignored by
167             //      default in non-stubby mode, and translated to pcrud
168             //      search filters of {id: {"!=": null}} in stubby mode (where
169             //      id is the primary key for the class in question).  Set this
170             //      boolean parameter to override the default behavior.
171             //
172             //  The *label_attributes* parameter.
173             //      getLabelAttributes() will figure out what to return based
174             //      on 1) fields with a selector attribute for our class in the
175             //      IDL and, failing that, 2) the Identity field for our class
176             //      _unless_ you want to override that by providing an array
177             //      (single element is fine) of field names to use as label
178             //      attributes here.
179             //
180             //  The *label_separator* parameter.
181             //      In the event of dealing with a class that has more than one
182             //      attribute contributing to the label, this string, which
183             //      defaults to " " defines the token that is placed between
184             //      the value of each field as the label string is built.
185             //
186             //  The *base_filter* parameter.
187             //      This optional object will be mixed in with any search queries
188             //      produced for pcrud, giving the user a way to limit the result
189             //      set beyond the query that will be issued by the dijit. For
190             //      example, you can provide an autocompleting widget against
191             //      the acqpl class, set base_filter to
192             //      {"owner": openils.User.user.id()}
193             //      and have the dijit query against name, so that as you type
194             //      the store issues queries like
195             //      {"owner": 1, "name": {"ilike": "new boo%"}}
196             //
197             //  The *pcrud* paramter.
198             //      Optionally pass in your own openils.PermaCrud object, if
199             //      you already have one.
200             //
201             //  The *authtoken* parameter.
202             //      Optionally pass in your authtoken string.  If you're in
203             //      certain parts of the Evergreen environment, we may be able
204             //      to get this automagically from openils.User anyway, so that's
205             //      why this parameter is optional.
206             if (typeof(this.fmclass = args.fmclass) != "string")
207                 throw new PCSError("Must have fmclass");
208
209             this.pkey = fieldmapper.IDL.fmclasses[this.fmclass].pkey;
210             this.fetch_limit = args.fetch_limit || 50;
211             this.max_query_cache_age = args.max_query_cache_age || 10000; /*ms*/
212             this.stubby = args.stubby || false;
213
214             if (typeof args.honor_retrieve_all != undefined)
215                 this.honor_retrieve_all = args.honor_retrieve_all;
216             else
217                 this.honor_retrieve_all = args.stubby;
218
219             this.label_attributes = args.label_attributes || null;
220             this.label_separator = args.label_separator || " ";
221
222             this.base_filter = args.base_filter || {};
223             this.pcrud = args.pcrud || new openils.PermaCrud(
224                 args.authtoken ? {"authtoken": args.authtoken} : null
225             );
226
227             this._stored_items = {};
228             this._query_cache = new PCSQueryCache(this.max_query_cache_age);
229         },
230
231         "_dojo_query_to_pcrud": function(/* request-object */ req) {
232             //  summary:
233             //      Internal method to convery queries from dijits into pcrud
234             //      search filters. Messy. Called by fetch().
235             var qkeys = openils.Util.objectProperties(req.query);
236             if (qkeys.length < 1)
237                 throw new PCSError("Not enough meat on that query");
238
239             for (var qkey in req.query) {
240                 var value = req.query[qkey];
241                 var type = typeof value;
242                 if (
243                     type == "number" ||
244                     type == "string" ||
245                     (type == "object" && dojo.isArray(value))
246                 ) continue;
247                 throw new PCSError(
248                     "Can't deal with query key " + qkey + " (" + type + ")"
249                 );
250             }
251
252             var pcrud_query = {};
253             var hashparts = [];
254
255             for (var i = 0; i < qkeys.length; i++) {
256                 var key = qkeys[i];
257                 var term = req.query[key];
258                 var op;
259                 /* TODO: break this down into smaller separate methods:
260                  *  key & term munging
261                  *  offset & limit
262                  *  sort -> order_by
263                  */
264
265                 if (term == "" || term == "*") {
266                     if (qkeys.length != 1) {
267                         continue;   /* query: {name: "bar", id: "*"}
268                                        makes no sense; we could just leave
269                                        out the id: part */
270                     } else if (!this.honor_retrieve_all) {
271                         return req; /* totally bail */
272                     } else {
273                         key = this.pkey; /*ignore given key: may not be unique*/
274                         pcrud_query[key] = {"!=": null};
275                         hashparts[i] = key + ":%";
276                     }
277                 } else {
278                     term = term.replace("%", "%%");
279                     term = term.replace(/\*$/, "%");
280
281                     if (dojo.indexOf(term, "%") != -1) op = "like";
282                     if (req.queryOptions && req.queryOptions.ignoreCase)
283                         op = "ilike";
284
285                     if (op) {
286                         pcrud_query[key] = {};
287                         pcrud_query[key][op] = term;
288                         hashparts[i] = key + ":" + op + ":" + term;
289                     } else {
290                         pcrud_query[key] = term;
291                         hashparts[i] = key + ":" + term;
292                     }
293                 }
294             }
295
296             var hashkey = hashparts.join(":");
297             var opts = {};
298
299             opts.offset = req.start || 0;
300             hashkey = "offset:" + opts.offset + ":" + hashkey;
301
302             opts.limit = (req.count && req.count != Infinity) ?
303                 req.count : this.fetch_limit;
304             hashkey = "limit:" + opts.limit + ":" + hashkey;
305
306             if (dojo.isArray(req.sort)) {
307                 opts.order_by = {};
308                 opts.order_by[this.fmclass] = dojo.map(
309                     req.sort, function(key) {
310                         return (key.attribute + " ") + (
311                             key.descending ? "DESC" : "ASC"
312                         );
313                     }
314                 ).join(",");
315                 /* XXX not sure whether multiple columns will work as such. */
316                 hashkey = "order_by:" + opts.order_by[this.fmclass] + ":" +
317                     hashkey;
318             }
319
320             opts.id_list = this.stubby;
321
322             return [dojo.mixin(this.base_filter, pcrud_query), opts, hashkey];
323         },
324
325         /* *** Begin dojo.data.api.Read methods *** */
326
327         "getValue": function(
328             /* object */ item,
329             /* string */ attribute,
330             /* anything */ defaultValue) {
331             //  summary:
332             //      Given an *item* and the name of an *attribute* on that item,
333             //      return that attribute's value.  Load the item first if
334             //      it's not actually loaded yet (stubby mode).
335             if (!this.isItem(item))
336                 throw new PCSError("getValue(): bad item: " + item);
337             else if (typeof attribute != "string")
338                 throw new PCSError("getValue(): bad attribute");
339
340             var value;
341             try {
342                 if (this.isItemLoaded(item)) {
343                     value = item[attribute]();
344                 } else {
345                     value = this.loadItem({"item": item})[attribute]();
346                 }
347             } catch (E) {
348                 console.log(E);
349                 return undefined;
350             }
351
352             /* XXX This method by proscription can't return an array, but what
353              * the heck is it supposed to do if the value of the field
354              * indicated IS an array? */
355             return (typeof value == "undefined") ? defaultValue : value;
356         },
357
358         "getValues": function(/* object */ item, /* string */ attribute) {
359             //  summary:
360             //      Same as getValue(), except the result is always an array
361             //      and there is no way to specify a default value.
362             if (!this.isItem(item) || typeof attribute != "string")
363                 throw new PCSError("bad arguments");
364
365             var result = this.getValue(item, attribute, []);
366             return dojo.isArray(result) ? result : [result];
367         },
368
369         "getAttributes": function(/* object */ item) {
370             //  summary:
371             //      Return an array of all of the given *item*'s *attribute*s.
372             //      This is done by consulting fieldmapper.
373             if (!this.isItem(item) || typeof attribute != "string")
374                 throw new PCSError("getAttributes(): bad arguments");
375             else
376                 return fieldmapper.IDL.fmclasses[item.classname].fields;
377         },
378
379         "hasAttribute": function(/* object */ item, /* string */ attribute) {
380             //  summary:
381             //      Return true or false based on whether *item* has an
382             //      attribute by the name specified in *attribute*.
383             if (!this.isItem(item) || typeof attribute != "string") {
384                 throw new PCSError("hasAttribute(): bad arguments");
385             } else {
386                 /* tested as autovivification-safe */
387                 return (
388                     typeof fieldmapper.IDL.fmclasses[item.classname].
389                         fields[attribute] != "undefined"
390                 );
391             }
392         },
393
394         "containsValue": function(
395             /* object */ item,
396             /* string */ attribute,
397             /* anything */ value) {
398             //  summary:
399             //      Return true or false based on whether *item* has any value
400             //      matching *value* for *attribute*.
401             if (!this.isItem(item) || typeof attribute != "string")
402                 throw new PCSError("bad data");
403             else
404                 return (
405                     dojo.indexOf(this.getValues(item, attribute), value) != -1
406                 );
407         },
408
409         "isItem": function(/* anything */ something) {
410             //  summary:
411             //      Return true if *something* is an item (loaded or not), else
412             //      false.
413             /* XXX Shouldn't this really check to see whether the item came from
414              * our store? Checking type (fieldmapper class) may suffice. */
415             return (
416                 typeof something == "object" && something !== null &&
417                 something._isfieldmapper && something.classname == this.fmclass
418             );
419         },
420
421         "isItemLoaded": function(/* anything */ something) {
422             //  summary:
423             //      Return true if *something* is an item and is loaded.
424             //      In stubby mode, something that is an item but isn't yet
425             //      loaded is possible.
426             return this.isItem(something) && something._loaded;
427         },
428
429         "close": function(/* object */ request) {
430             //  summary:
431             //      This is a no-op.
432             return;
433         },
434
435         "getLabel": function(/* object */ item) {
436             //  summary:
437             //      Return the name of the attribute that should serve as the
438             //      label for objects of the same class as *item*.  This is
439             //      done by consulting fieldmapper and looking for the field
440             //      with "selector" set to true.
441             var self = this;
442
443             return dojo.map(
444                 this.getLabelAttributes(),
445                 function(o) { self.getValue(item, o); }
446             ).join(this.label_separator);
447         },
448
449         "getLabelAttributes": function(/* object */ item) {
450             //  summary:
451             //      This is simply a deeper method supporting getLabel().
452             if (dojo.isArray(this.label_attributes)) {
453                 return this.label_attributes;
454             }
455
456             var fmclass = fieldmapper.IDL.fmclasses[item];
457             var sels = dojo.filter(
458                 fmclass.fields,
459                 function(c) { return Boolean(c.selector); }
460             );
461             if (sels.length) return sels;
462             else return [fmclass.pkey];
463         },
464
465         "loadItem": function(/* object */ keywordArgs) {
466             //  summary:
467             //      Fully load the item specified in the *item* property of
468             //      *keywordArgs* by retrieving it from PermaCrud.
469             //
470             //  description:
471             //      In non-stubby mode (default) this ultimately just returns the
472             //      same object it's given.  In stubby mode, the object might
473             //      not really be fully loaded, so we go to PermaCrud for it.
474             //
475             //      This method (part of the Read API) is dependent on
476             //      fetchItemByIdentity() (part of the Identity API), so don't
477             //      split the two up unless you know what you're doing.
478             if (!this.isItem(keywordArgs.item))
479                 throw new PCSError("that's not an item; can't load it");
480
481             keywordArgs.identity = keywordArgs.item[this.pkey]();
482             return this.fetchItemByIdentity(keywordArgs);
483         },
484
485         "fetch": function(/* request-object */ req) {
486             //  summary:
487             //      Basically, fetch objects matching the *query* property of
488             //      the *req* parameter.
489             //
490             //  description:
491             //      In non-stubby mode (default) this means translaating the
492             //      *query* in to a pcrud search filter and storing all the
493             //      objects that result from that search, up to fetch_limit
494             //      (a property of the store itself, set via the constructor).
495             //
496             //      In stubby mode, this means the same as above except that
497             //      we only ask pcrud for an ID list, and what we store are
498             //      "fake" objects with only the identifier field set.
499             //
500             //      In both modes, we also respect the following properties
501             //      of the *req* object (all optional):
502             //
503             //          sort     an object that gets translated to order_by
504             //          count    an int that gets translated to limit
505             //          start    an int that gets translated to offset
506             //          onBegin  a callback that takes the number of items
507             //                      that this call to fetch() will return, but
508             //                      we always give it -1 (i.e. unknown)
509             //          onItem   a callback that takes each item as we get it
510             //          onComplete  a callback that takes the list of items
511             //                          after they're all fetched
512             //
513             //      The onError callback is ignored. I've never seen PermaCrud
514             //      actually execute its own onerror callback, so this remains
515             //      to be figured out.
516             //
517             //      The Read API also charges this method with adding an abort
518             //      callback to the *req* object for the caller's use, but
519             //      the one we provide does nothing but issue an alert().
520             var parts = this._dojo_query_to_pcrud(req);
521             var filter = parts[0];
522             var opts = parts[1];
523             var hashkey = parts[2];
524
525             if (!filter) return req; /* nothing to do */
526
527             /* set up some closures... */
528             var self = this;
529             var fetch_results = [];
530             var callback_scope = req.scope || dojo.global;
531
532             var process_fetch = function(r) {
533                 if (r = openils.Util.readResponse(r)) {
534                     if (self.stubby) {
535                         var id = r;
536                         r = new fieldmapper[self.fmclass]();
537                         r[self.pkey](id);
538                         r._loaded = false;
539                     } else {
540                         r._loaded = true;
541                     }
542                     if (typeof req.onItem == "function")
543                         req.onItem.call(callback_scope, r, req);
544
545                     self._stored_items[r[self.pkey]()] = r;
546                     fetch_results.push(r);
547                     self._query_cache.add(hashkey, r);
548                 }
549             };
550             req.abort = function() {
551                 alert("The 'abort' operation is not supported");
552             };
553
554             /* ... and proceed. */
555
556             if (typeof req.onBegin == "function")
557                 req.onBegin.call(callback_scope, -1, req);
558
559             fetch_results = this._query_cache.get(hashkey);
560             if (!fetch_results.length) {
561                 this._query_cache.clear(hashkey);
562                 this.pcrud.search(
563                     this.fmclass, filter, dojo.mixin(opts, {
564                         "streaming": true,
565                         "timeout": 10,  /* important: streaming but sync */
566                         "onresponse": process_fetch
567                     })
568                 );
569             }
570
571             /* XXX at the moment, I don't believe we need either to call
572              * onItem nor to add to our internal "_stored_items" those items
573              * that we just got from cache. */
574
575             /* as for onError: I don't believe openils.PermaCrud supports any
576              * onerror-like callback in an actually working way at this time */
577
578             if (typeof req.onComplete == "function")
579                 req.onComplete.call(callback_scope, fetch_results, req);
580
581             return req;
582         },
583
584         /* *** Begin dojo.data.api.Identity methods *** */
585
586         "getIdentity": function(/* object */ item) {
587             //  summary:
588             //      Given an *item* return its unique identifier (the value
589             //      of its primary key).
590             if (!this.isItem(item)) throw new PCSError("not an item");
591             if (this._stored_items[item[this.pkey]()] == item)
592                 return item[this.pkey]();
593             else
594                 return null;
595         },
596
597         "getIdentityAttributes": function(/* object */ item) {
598             //  summary:
599             //      Given an *item* return the list of the name of the fields
600             //      that constitute the item's unique identifier.  Since we
601             //      deal with fieldmapper objects, that's always a list of one.
602             return [this.pkey];
603         },
604
605         "fetchItemByIdentity": function(/* object */ keywordArgs) {
606             //  summary:
607             //      Given an *identity* property in the *keywordArgs* object,
608             //      retrieve an item, unless we already have the fully loaded
609             //      item in the store's internal memory.
610             //
611             //  description:
612             //      Once we've have the item we want one way or another, issue
613             //      the *onItem* callback from the *keywordArgs* object.  If we
614             //      tried to retrieve the item with pcrud but didn't get an item
615             //      back, issue the *onError* callback.
616            if (keywordArgs.identity == undefined)
617                 return null; // Identity API spec unclear whether error callback
618                              // would need to be run, so we won't.  Matters
619                              // because in some cases pcrud times out when attempting
620                              // to retrieve by a null PK value
621             var callback_scope = keywordArgs.scope || dojo.global;
622             var test_item = this._stored_items[keywordArgs.identity];
623
624             if (test_item && this.isItemLoaded(test_item)) {
625                 console.log(
626                     "fetchItemByIdentity(): already have " +
627                     keywordArgs.identity
628                 );
629                 if (typeof keywordArgs.onItem == "function")
630                     keywordArgs.onItem.call(callback_scope, test_item);
631
632                 return test_item;
633             } else {
634                 console.log(
635                     "fetchItemByIdentity(): going to pcrud for " +
636                     keywordArgs.identity
637                 );
638                 try {
639                     var item =
640                         this.pcrud.retrieve(this.fmclass, keywordArgs.identity);
641
642                     if (!item)
643                         throw new PCSError(
644                             "No item of class " + this.fmclass +
645                             " with identity " + keywordArgs.identity +
646                             " could be retrieved."
647                         );
648
649                     item._loaded = true;
650                     this._stored_items[item[this.pkey]()] = item;
651
652                     if (typeof keywordArgs.onItem == "function")
653                         keywordArgs.onItem.call(callback_scope, item);
654
655                     return item;
656                 } catch (E) {
657                     if (typeof keywordArgs.onError == "function")
658                         keywordArgs.onError.call(callback_scope, E);
659
660                     return null;
661                 }
662             }
663         },
664
665         /* *** This last method is for classes implementing any dojo APIs *** */
666
667         "getFeatures": function() {
668             return {
669                 "dojo.data.api.Read": true,
670                 "dojo.data.api.Identity": true
671             };
672         }
673     });
674 }