1 """GNUmed database object business class.
2
3 Overview
4 --------
5 This class wraps a source relation (table, view) which
6 represents an entity that makes immediate business sense
7 such as a vaccination or a medical document. In many if
8 not most cases this source relation is a denormalizing
9 view. The data in that view will in most cases, however,
10 originate from several normalized tables. One instance
11 of this class represents one row of said source relation.
12
13 Note, however, that this class does not *always* simply
14 wrap a single table or view. It can also encompass several
15 relations (views, tables, sequences etc) that taken together
16 form an object meaningful to *business* logic.
17
18 Initialization
19 --------------
20 There are two ways to initialize an instance with values.
21 One way is to pass a "primary key equivalent" object into
22 __init__(). Refetch_payload() will then pull the data from
23 the backend. Another way would be to fetch the data outside
24 the instance and pass it in via the <row> argument. In that
25 case the instance will not initially connect to the databse
26 which may offer a great boost to performance.
27
28 Values API
29 ----------
30 Field values are cached for later access. They can be accessed
31 by a dictionary API, eg:
32
33 old_value = object['field']
34 object['field'] = new_value
35
36 The field names correspond to the respective column names
37 in the "main" source relation. Accessing non-existant field
38 names will raise an error, so does trying to set fields not
39 listed in self.__class__._updatable_fields. To actually
40 store updated values in the database one must explicitly
41 call save_payload().
42
43 The class will in many cases be enhanced by accessors to
44 related data that is not directly part of the business
45 object itself but are closely related, such as codes
46 linked to a clinical narrative entry (eg a diagnosis). Such
47 accessors in most cases start with get_*. Related setters
48 start with set_*. The values can be accessed via the
49 object['field'] syntax, too, but they will be cached
50 independantly.
51
52 Concurrency handling
53 --------------------
54 GNUmed connections always run transactions in isolation level
55 "serializable". This prevents transactions happening at the
56 *very same time* to overwrite each other's data. All but one
57 of them will abort with a concurrency error (eg if a
58 transaction runs a select-for-update later than another one
59 it will hang until the first transaction ends. Then it will
60 succeed or fail depending on what the first transaction
61 did). This is standard transactional behaviour.
62
63 However, another transaction may have updated our row
64 between the time we first fetched the data and the time we
65 start the update transaction. This is noticed by getting the
66 XMIN system column for the row when initially fetching the
67 data and using that value as a where condition value when
68 updating the row later. If the row had been updated (xmin
69 changed) or deleted (primary key disappeared) in the
70 meantime the update will touch zero rows (as no row with
71 both PK and XMIN matching is found) even if the query itself
72 syntactically succeeds.
73
74 When detecting a change in a row due to XMIN being different
75 one needs to be careful how to represent that to the user.
76 The row may simply have changed but it also might have been
77 deleted and a completely new and unrelated row which happens
78 to have the same primary key might have been created ! This
79 row might relate to a totally different context (eg. patient,
80 episode, encounter).
81
82 One can offer all the data to the user:
83
84 self.original_payload
85 - contains the data at the last successful refetch
86
87 self.modified_payload
88 - contains the modified payload just before the last
89 failure of save_payload() - IOW what is currently
90 in the database
91
92 self._payload
93 - contains the currently active payload which may or
94 may not contain changes
95
96 For discussion on this see the thread starting at:
97
98 http://archives.postgresql.org/pgsql-general/2004-10/msg01352.php
99
100 and here
101
102 http://groups.google.com/group/pgsql.general/browse_thread/thread/e3566ba76173d0bf/6cf3c243a86d9233
103 (google for "XMIN semantic at peril")
104
105 Problem cases with XMIN:
106
107 1) not unlikely
108 - a very old row is read with XMIN
109 - vacuum comes along and sets XMIN to FrozenTransactionId
110 - now XMIN changed but the row actually didn't !
111 - an update with "... where xmin = old_xmin ..." fails
112 although there is no need to fail
113
114 2) quite unlikely
115 - a row is read with XMIN
116 - a long time passes
117 - the original XMIN gets frozen to FrozenTransactionId
118 - another writer comes along and changes the row
119 - incidentally the exact same old row gets the old XMIN *again*
120 - now XMIN is (again) the same but the data changed !
121 - a later update fails to detect the concurrent change !!
122
123 TODO:
124 The solution is to use our own column for optimistic locking
125 which gets updated by an AFTER UPDATE trigger.
126 """
127
128 __author__ = "K.Hilbert <Karsten.Hilbert@gmx.net>"
129 __license__ = "GPL v2 or later"
130
131
132 import sys
133 import types
134 import inspect
135 import logging
136 import datetime
137
138
139 if __name__ == '__main__':
140 sys.path.insert(0, '../../')
141 from Gnumed.pycommon import gmExceptions
142 from Gnumed.pycommon import gmPG2
143 from Gnumed.pycommon.gmDateTime import pydt_strftime
144 from Gnumed.pycommon.gmTools import tex_escape_string
145
146
147 _log = logging.getLogger('gm.db')
148
150 """Represents business objects in the database.
151
152 Rules:
153 - instances ARE ASSUMED TO EXIST in the database
154 - PK construction (aPK_obj): DOES verify its existence on instantiation
155 (fetching data fails)
156 - Row construction (row): allowed by using a dict of pairs
157 field name: field value (PERFORMANCE improvement)
158 - does NOT verify FK target existence
159 - does NOT create new entries in the database
160 - does NOT lazy-fetch fields on access
161
162 Class scope SQL commands and variables:
163
164 <_cmd_fetch_payload>
165 - must return exactly one row
166 - where clause argument values are expected
167 in self.pk_obj (taken from __init__(aPK_obj))
168 - must return xmin of all rows that _cmds_store_payload
169 will be updating, so views must support the xmin columns
170 of their underlying tables
171
172 <_cmds_store_payload>
173 - one or multiple "update ... set ... where xmin_* = ... and pk* = ..."
174 statements which actually update the database from the data in self._payload,
175 - the last query must refetch at least the XMIN values needed to detect
176 concurrent updates, their field names had better be the same as
177 in _cmd_fetch_payload,
178 - the last query CAN return other fields which is particularly
179 useful when those other fields are computed in the backend
180 and may thus change upon save but will not have been set by
181 the client code explicitely - this is only really of concern
182 if the saved subclass is to be reused after saving rather
183 than re-instantiated
184 - when subclasses tend to live a while after save_payload() was
185 called and they support computed fields (say, _(some_column)
186 you need to return *all* columns (see cEncounter)
187
188 <_updatable_fields>
189 - a list of fields available for update via object['field']
190
191
192 A template for new child classes:
193
194 *********** start of template ***********
195
196 #------------------------------------------------------------
197 from Gnumed.pycommon import gmBusinessDBObject
198 from Gnumed.pycommon import gmPG2
199
200 #============================================================
201 # short description
202 #------------------------------------------------------------
203 # use plural form, search-replace get_XXX
204 _SQL_get_XXX = u" ""
205 SELECT *, (xmin AS xmin_XXX)
206 FROM XXX.v_XXX
207 WHERE %s
208 "" "
209
210 class cXxxXxx(gmBusinessDBObject.cBusinessDBObject):
211 " ""Represents ..."" "
212
213 _cmd_fetch_payload = _SQL_get_XXX % u"pk_XXX = %s"
214 _cmds_store_payload = [
215 u" ""
216 -- typically the underlying table name
217 UPDATE xxx.xxx SET
218 -- typically "table_col = %(view_col)s"
219 xxx = %(xxx)s,
220 xxx = gm.nullify_empty_string(%(xxx)s)
221 WHERE
222 pk = %(pk_XXX)s
223 AND
224 xmin = %(xmin_XXX)s
225 RETURNING
226 xmin as xmin_XXX
227 --, ...
228 --, ...
229 " ""
230 ]
231 # view columns that can be updated:
232 _updatable_fields = [
233 u'xxx',
234 u'xxx'
235 ]
236 #--------------------------------------------------------
237 def format(self):
238 return u'%s' % self
239
240 #------------------------------------------------------------
241 def get_XXX(order_by=None):
242 if order_by is None:
243 order_by = u'true'
244 else:
245 order_by = u'true ORDER BY %s' % order_by
246
247 cmd = _SQL_get_XXX % order_by
248 rows, idx = gmPG2.run_ro_queries(queries = [{'cmd': cmd}], get_col_idx = True)
249 return [ cXxxXxx(row = {'data': r, 'idx': idx, 'pk_field': 'xxx'}) for r in rows ]
250 #------------------------------------------------------------
251 def create_xxx(xxx=None, xxx=None):
252
253 args = {
254 u'xxx': xxx,
255 u'xxx': xxx
256 }
257 cmd = u" ""
258 INSERT INTO xxx.xxx (
259 xxx,
260 xxx,
261 xxx
262 ) VALUES (
263 %(xxx)s,
264 %(xxx)s,
265 gm.nullify_empty_string(%(xxx)s)
266 )
267 RETURNING pk
268 --RETURNING *
269 " ""
270 rows, idx = gmPG2.run_rw_queries(queries = [{'cmd': cmd, 'args': args}], return_data = True, get_col_idx = False)
271 #rows, idx = gmPG2.run_rw_queries(queries = [{'cmd': cmd, 'args': args}], return_data = True, get_col_idx = True)
272
273 return cXxxXxx(aPK_obj = rows[0]['pk'])
274 #return cXxxXxx(row = {'data': r, 'idx': idx, 'pk_field': 'xxx'})
275 #------------------------------------------------------------
276 def delete_xxx(xxx=None):
277 args = {'pk': xxx}
278 cmd = u"DELETE FROM xxx.xxx WHERE pk = %(pk)s"
279 gmPG2.run_rw_queries(queries = [{'cmd': cmd, 'args': args}])
280 return True
281 #------------------------------------------------------------
282
283 *********** end of template ***********
284
285 """
286
287 - def __init__(self, aPK_obj=None, row=None):
288 """Init business object.
289
290 Call from child classes:
291
292 super(cChildClass, self).__init__(aPK_obj = aPK_obj, row = row)
293 """
294
295
296
297 self.pk_obj = '<uninitialized>'
298 self._idx = {}
299 self._payload = []
300 self._ext_cache = {}
301 self._is_modified = False
302
303
304 self.__class__._cmd_fetch_payload
305 self.__class__._cmds_store_payload
306 self.__class__._updatable_fields
307
308 if aPK_obj is not None:
309 self.__init_from_pk(aPK_obj=aPK_obj)
310 else:
311 self._init_from_row_data(row=row)
312
313 self._is_modified = False
314
316 """Creates a new clinical item instance by its PK.
317
318 aPK_obj can be:
319 - a simple value
320 * the primary key WHERE condition must be
321 a simple column
322 - a dictionary of values
323 * the primary key where condition must be a
324 subselect consuming the dict and producing
325 the single-value primary key
326 """
327 self.pk_obj = aPK_obj
328 result = self.refetch_payload()
329 if result is True:
330 self.original_payload = {}
331 for field in self._idx.keys():
332 self.original_payload[field] = self._payload[self._idx[field]]
333 return True
334
335 if result is False:
336 raise gmExceptions.ConstructorError, "[%s:%s]: error loading instance" % (self.__class__.__name__, self.pk_obj)
337
339 """Creates a new clinical item instance given its fields.
340
341 row must be a dict with the fields:
342 - pk_field: the name of the primary key field
343 - idx: a dict mapping field names to position
344 - data: the field values in a list (as returned by
345 cursor.fetchone() in the DB-API)
346
347 row = {'data': row, 'idx': idx, 'pk_field': 'the PK column name'}
348
349 rows, idx = gmPG2.run_ro_queries(queries = [{'cmd': cmd, 'args': args}], get_col_idx = True)
350 objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column name'}) for r in rows ]
351 """
352 try:
353 self._idx = row['idx']
354 self._payload = row['data']
355 self.pk_obj = self._payload[self._idx[row['pk_field']]]
356 except:
357 _log.exception('faulty <row> argument structure: %s' % row)
358 raise gmExceptions.ConstructorError, "[%s:??]: error loading instance from row data" % self.__class__.__name__
359
360 if len(self._idx.keys()) != len(self._payload):
361 _log.critical('field index vs. payload length mismatch: %s field names vs. %s fields' % (len(self._idx.keys()), len(self._payload)))
362 _log.critical('faulty <row> argument structure: %s' % row)
363 raise gmExceptions.ConstructorError, "[%s:??]: error loading instance from row data" % self.__class__.__name__
364
365 self.original_payload = {}
366 for field in self._idx.keys():
367 self.original_payload[field] = self._payload[self._idx[field]]
368
370 if self.__dict__.has_key('_is_modified'):
371 if self._is_modified:
372 _log.critical('[%s:%s]: loosing payload changes' % (self.__class__.__name__, self.pk_obj))
373 _log.debug('original: %s' % self.original_payload)
374 _log.debug('modified: %s' % self._payload)
375
377 tmp = []
378 try:
379 for attr in self._idx.keys():
380 if self._payload[self._idx[attr]] is None:
381 tmp.append(u'%s: NULL' % attr)
382 else:
383 tmp.append('%s: >>%s<<' % (attr, self._payload[self._idx[attr]]))
384 return '[%s:%s]: %s' % (self.__class__.__name__, self.pk_obj, str(tmp))
385 except:
386 return 'nascent [%s @ %s], cannot show payload and primary key' %(self.__class__.__name__, id(self))
387
389
390
391
392 try:
393 return self._payload[self._idx[attribute]]
394 except KeyError:
395 pass
396
397
398 getter = getattr(self, 'get_%s' % attribute, None)
399 if not callable(getter):
400 _log.warning('[%s]: no attribute [%s]' % (self.__class__.__name__, attribute))
401 _log.warning('[%s]: valid attributes: %s' % (self.__class__.__name__, str(self._idx.keys())))
402 _log.warning('[%s]: no getter method [get_%s]' % (self.__class__.__name__, attribute))
403 methods = filter(lambda x: x[0].startswith('get_'), inspect.getmembers(self, inspect.ismethod))
404 _log.warning('[%s]: valid getter methods: %s' % (self.__class__.__name__, str(methods)))
405 raise KeyError('[%s]: cannot read from key [%s]' % (self.__class__.__name__, attribute))
406
407 self._ext_cache[attribute] = getter()
408 return self._ext_cache[attribute]
409
411
412
413 if attribute in self.__class__._updatable_fields:
414 try:
415 if self._payload[self._idx[attribute]] != value:
416 self._payload[self._idx[attribute]] = value
417 self._is_modified = True
418 return
419 except KeyError:
420 _log.warning('[%s]: cannot set attribute <%s> despite marked settable' % (self.__class__.__name__, attribute))
421 _log.warning('[%s]: supposedly settable attributes: %s' % (self.__class__.__name__, str(self.__class__._updatable_fields)))
422 raise KeyError('[%s]: cannot write to key [%s]' % (self.__class__.__name__, attribute))
423
424
425 if hasattr(self, 'set_%s' % attribute):
426 setter = getattr(self, "set_%s" % attribute)
427 if not callable(setter):
428 raise AttributeError('[%s] setter [set_%s] not callable' % (self.__class__.__name__, attribute))
429 try:
430 del self._ext_cache[attribute]
431 except KeyError:
432 pass
433 if type(value) is types.TupleType:
434 if setter(*value):
435 self._is_modified = True
436 return
437 raise AttributeError('[%s]: setter [%s] failed for [%s]' % (self.__class__.__name__, setter, value))
438 if setter(value):
439 self._is_modified = True
440 return
441
442
443 _log.error('[%s]: cannot find attribute <%s> or setter method [set_%s]' % (self.__class__.__name__, attribute, attribute))
444 _log.warning('[%s]: settable attributes: %s' % (self.__class__.__name__, str(self.__class__._updatable_fields)))
445 methods = filter(lambda x: x[0].startswith('set_'), inspect.getmembers(self, inspect.ismethod))
446 _log.warning('[%s]: valid setter methods: %s' % (self.__class__.__name__, str(methods)))
447 raise AttributeError('[%s]: cannot set [%s]' % (self.__class__.__name__, attribute))
448
449
450
452 raise NotImplementedError('comparison between [%s] and [%s] not implemented' % (self, another_object))
453
455 return self._is_modified
456
458 try:
459 return self._idx.keys()
460 except AttributeError:
461 return 'nascent [%s @ %s], cannot return keys' %(self.__class__.__name__, id(self))
462
465
466 - def fields_as_dict(self, date_format='%Y %b %d %H:%M', none_string=u'', escape_style=None, bool_strings=None):
467 if bool_strings is None:
468 bools = {True: u'true', False: u'false'}
469 else:
470 bools = {True: bool_strings[0], False: bool_strings[1]}
471 data = {}
472 for field in self._idx.keys():
473
474
475
476
477 val = self._payload[self._idx[field]]
478 if val is None:
479 data[field] = none_string
480 continue
481 if isinstance(val, bool):
482 data[field] = bools[val]
483 continue
484
485 if isinstance(val, datetime.datetime):
486 data[field] = pydt_strftime(val, format = date_format, encoding = 'utf8')
487 if escape_style in [u'latex', u'tex']:
488 data[field] = tex_escape_string(data[field])
489 continue
490
491 try:
492 data[field] = unicode(val, encoding = 'utf8', errors = 'replace')
493 except TypeError:
494 try:
495 data[field] = unicode(val)
496 except (UnicodeDecodeError, TypeError):
497 val = '%s' % str(val)
498 data[field] = val.decode('utf8', 'replace')
499 if escape_style in [u'latex', u'tex']:
500 data[field] = tex_escape_string(data[field])
501
502 return data
503
505 _log.error('[%s:%s]: forgot to override get_patient()' % (self.__class__.__name__, self.pk_obj))
506 return None
507
510
512 """Fetch field values from backend.
513 """
514 if self._is_modified:
515 if ignore_changes:
516 _log.critical('[%s:%s]: loosing payload changes' % (self.__class__.__name__, self.pk_obj))
517 _log.debug('original: %s' % self.original_payload)
518 _log.debug('modified: %s' % self._payload)
519 else:
520 _log.critical('[%s:%s]: cannot reload, payload changed' % (self.__class__.__name__, self.pk_obj))
521 return False
522
523 if type(self.pk_obj) == types.DictType:
524 arg = self.pk_obj
525 else:
526 arg = [self.pk_obj]
527 rows, self._idx = gmPG2.run_ro_queries (
528 queries = [{'cmd': self.__class__._cmd_fetch_payload, 'args': arg}],
529 get_col_idx = True
530 )
531 if len(rows) == 0:
532 _log.error('[%s:%s]: no such instance' % (self.__class__.__name__, self.pk_obj))
533 return False
534 self._payload = rows[0]
535 return True
536
539
540 - def save(self, conn=None):
542
544 """Store updated values (if any) in database.
545
546 Optionally accepts a pre-existing connection
547 - returns a tuple (<True|False>, <data>)
548 - True: success
549 - False: an error occurred
550 * data is (error, message)
551 * for error meanings see gmPG2.run_rw_queries()
552 """
553 if not self._is_modified:
554 return (True, None)
555
556 args = {}
557 for field in self._idx.keys():
558 args[field] = self._payload[self._idx[field]]
559 self.modified_payload = args
560
561 close_conn = self.__noop
562 if conn is None:
563 conn = gmPG2.get_connection(readonly=False)
564 close_conn = conn.close
565
566 queries = []
567 for query in self.__class__._cmds_store_payload:
568 queries.append({'cmd': query, 'args': args})
569 rows, idx = gmPG2.run_rw_queries (
570 link_obj = conn,
571 queries = queries,
572 return_data = True,
573 get_col_idx = True
574 )
575
576
577
578
579
580
581 if len(rows) == 0:
582 return (False, (u'cannot update row', _('[%s:%s]: row not updated (nothing returned), row in use ?') % (self.__class__.__name__, self.pk_obj)))
583
584
585
586
587
588 row = rows[0]
589 for key in idx:
590 try:
591 self._payload[self._idx[key]] = row[idx[key]]
592 except KeyError:
593 conn.rollback()
594 close_conn()
595 _log.error('[%s:%s]: cannot update instance, XMIN refetch key mismatch on [%s]' % (self.__class__.__name__, self.pk_obj, key))
596 _log.error('payload keys: %s' % str(self._idx))
597 _log.error('XMIN refetch keys: %s' % str(idx))
598 _log.error(args)
599 raise
600
601 conn.commit()
602 close_conn()
603
604 self._is_modified = False
605
606 self.original_payload = {}
607 for field in self._idx.keys():
608 self.original_payload[field] = self._payload[self._idx[field]]
609
610 return (True, None)
611
612
614
615 """ turn the data into a list of dicts, adding "class hints".
616 all objects get turned into dictionaries which the other end
617 will interpret as "object", via the __jsonclass__ hint,
618 as specified by the JSONRPC protocol standard.
619 """
620 if isinstance(obj, list):
621 return map(jsonclasshintify, obj)
622 elif isinstance(obj, gmPG2.dbapi.tz.FixedOffsetTimezone):
623
624
625 res = {'__jsonclass__': ["jsonobjproxy.FixedOffsetTimezone"]}
626 res['name'] = obj._name
627 res['offset'] = jsonclasshintify(obj._offset)
628 return res
629 elif isinstance(obj, datetime.timedelta):
630
631
632 res = {'__jsonclass__': ["jsonobjproxy.TimeDelta"]}
633 res['days'] = obj.days
634 res['seconds'] = obj.seconds
635 res['microseconds'] = obj.microseconds
636 return res
637 elif isinstance(obj, datetime.time):
638
639
640 res = {'__jsonclass__': ["jsonobjproxy.Time"]}
641 res['hour'] = obj.hour
642 res['minute'] = obj.minute
643 res['second'] = obj.second
644 res['microsecond'] = obj.microsecond
645 res['tzinfo'] = jsonclasshintify(obj.tzinfo)
646 return res
647 elif isinstance(obj, datetime.datetime):
648
649
650 res = {'__jsonclass__': ["jsonobjproxy.DateTime"]}
651 res['year'] = obj.year
652 res['month'] = obj.month
653 res['day'] = obj.day
654 res['hour'] = obj.hour
655 res['minute'] = obj.minute
656 res['second'] = obj.second
657 res['microsecond'] = obj.microsecond
658 res['tzinfo'] = jsonclasshintify(obj.tzinfo)
659 return res
660 elif isinstance(obj, cBusinessDBObject):
661
662
663 res = {'__jsonclass__': ["jsonobjproxy.%s" % obj.__class__.__name__]}
664 for k in obj.get_fields():
665 t = jsonclasshintify(obj[k])
666 res[k] = t
667 print "props", res, dir(obj)
668 for attribute in dir(obj):
669 if not attribute.startswith("get_"):
670 continue
671 k = attribute[4:]
672 if res.has_key(k):
673 continue
674 getter = getattr(obj, attribute, None)
675 if callable(getter):
676 res[k] = jsonclasshintify(getter())
677 return res
678 return obj
679
680
681 if __name__ == '__main__':
682
683 if len(sys.argv) < 2:
684 sys.exit()
685
686 if sys.argv[1] != u'test':
687 sys.exit()
688
689
700
701 from Gnumed.pycommon import gmI18N
702 gmI18N.activate_locale()
703 gmI18N.install_domain()
704
705 data = {
706 'pk_field': 'bogus_pk',
707 'idx': {'bogus_pk': 0, 'bogus_field': 1, 'bogus_date': 2},
708 'data': [-1, 'bogus_data', datetime.datetime.now()]
709 }
710 obj = cTestObj(row=data)
711
712
713
714 print obj.fields_as_dict()
715
716
717