diff --git a/.travis.yml b/.travis.yml index 3da33e1..2f1c9ad 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,10 @@ language: python python: + - 3.6 - 3.5 - 3.4 - 2.7 + - 3.7-dev - nightly - pypy - pypy3 diff --git a/rethinkstuff/__init__.py b/rethinkstuff/__init__.py index 66f4ad1..3765b76 100644 --- a/rethinkstuff/__init__.py +++ b/rethinkstuff/__init__.py @@ -2,7 +2,7 @@ rethinkstuff/__init__.py - rethinkdb connection-manager-ish thing and service registry thing -Copyright (C) 2015-2016 Internet Archive +Copyright (C) 2015-2017 Internet Archive Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -215,3 +215,202 @@ class ServiceRegistry(object): except r.ReqlNonExistenceError: return [] +class WatchedDict(dict): + def __init__(self, d, callback, field): + self.callback = callback + self.field = field + for key in d: + dict.__setitem__(self, key, watch( + d[key], callback=self.callback, field=self.field)) + + def __setitem__(self, key, value): + self.callback(self.field) + return dict.__setitem__(self, key, watch( + value, callback=self.callback, field=self.field)) + + def __delitem__(self, key): + self.callback(self.field) + return dict.__delitem__(self, key) + + def clear(self): + self.callback(self.field) + return dict.clear(self) + + def pop(self, *args): + self.callback(self.field) + return dict.pop(self, *args) + + def popitem(self): + self.callback(self.field) + return dict.popitem() + + def setdefault(self, *args): + self.callback(self.field) + if len(args) == 2: + return dict.setdefault(self, args[0], watch( + args[1], callback=self.callback, field=self.field)) + else: + return dict.setdefault(self, *args) + + def update(self, *args, **kwargs): + # looks a little tricky + raise Exception('not implemented') + +class WatchedList(list): + def __init__(self, l, callback, field): + self.callback = callback + self.field = field + for item in l: + list.append(self, watch(item, callback=callback, field=self.field)) + + def __setitem__(self, index, value): + self.callback(self.field) + return list.__setitem__(self, index, watch( + value, callback=self.callback, field=self.field)) + + def __delitem__(self, index): + self.callback(self.field) + return list.__delitem__(self, index) + + def append(self, value): + self.callback(self.field) + return list.append(self, watch( + value, callback=self.callback, field=self.field)) + + def extend(self, value): + self.callback(self.field) + return list.extend(self, watch( + list(value), callback=self.callback, field=self.field)) + + def insert(self, index, value): + self.callback(self.field) + return list.insert(self, index, watch( + value, callback=self.callback, field=self.field)) + + def remove(self, value): + self.callback(self.field) + return list.remove(self, value) + + def pop(self, index=-1): + self.callback(self.field) + return list.pop(self, index) + + def clear(self): + self.callback(self.field) + return list.clear(self) + + def sort(self, key=None, reverse=False): + self.callback(self.field) + return list.sort(self, key, reverse) + + def reverse(self): + self.callback(self.field) + return list.reverse(self) + +def watch(obj, callback, field): + if isinstance(obj, dict): + return WatchedDict(obj, callback, field) + elif isinstance(obj, list): + return WatchedList(obj, callback, field) + else: + return obj + +class Document(dict, object): + ''' + Base class for documents in rethinkdb. + + You should subclass this class for each of your rethinkdb tables. You can + add custom functionality in your subclass if appropriate. + + This class keeps track of changes made to the object and any nested fields. + After you have made some changes, call update() to persist them to the + database. + + Changes in nested fields result in updates to their first-level ancestor + field. For example, if your document starts as {'a': {'b': 'c'}}, then + you run d['a']['x'] = 'y', then the update will replace the whole 'a' + field. Nested field updates get too complicated any other way. + + The primary key must be `id`, the rethinkdb default. (XXX we could find out + what the primary key is from the "table_config" system table.) + ''' + def __init__(self, rethinker, d={}): + dict.__setattr__(self, '_r', rethinker) + for k in d: + dict.__setitem__( + self, k, watch(d[k], callback=self._updated, field=k)) + self._clear_updates() + + def _clear_updates(self): + dict.__setattr__(self, '_updates', {}) + dict.__setattr__(self, '_deletes', set()) + + def __setitem__(self, key, value): + dict.__setitem__( + self, key, watch(value, callback=self._updated, field=key)) + self._updated(key) + + __setattr__ = __setitem__ + __getattr__ = dict.__getitem__ + + def __delitem__(self, key): + dict.__delitem__(self, key) + self._deletes.add(key) + if key in self._updates: + del self._updates[key] + + # XXX do we need the other stuff like in WatchedDict? + + def _updated(self, field): + # callback for all updates + self._updates[field] = self[field] + if field in self._deletes: + self._deletes.remove(field) + + @property + def table(self): + ''' + Name of the rethinkdb table. + + Defaults to the name of the class, lowercased. Can be overridden. + ''' + return self.__class__.__name__.lower() + + def table_create(self): + ''' + Creates the table. + + Subclasses may want to override to do more things, such as creating + indexes. + ''' + self._r.table_create(self.table).run() + + def insert(self): + result = self._r.table(self.table).insert(self).run() + if 'generated_keys' in result: + dict.__setitem__(self, 'id', result['generated_keys'][0]) + self._clear_updates() + + def update(self): + # hmm, masks dict.update() + if self._updates: + # r.literal() to replace, not merge with, nested fields + updates = { + field: r.literal( + self._updates[field]) for field in self._updates} + self._r.table(self.table).get(self.id).update(updates).run() + if self._deletes: + self._r.table(self.table).replace( + r.row.without(self._deletes)).run() + self._clear_updates() + + def refresh(self): + ''' + Refresh from the database. + ''' + d = self._r.table(self.table).get(self.id).run() + for k in d: + dict.__setitem__( + self, k, watch(d[k], callback=self._updated, field=k)) + + diff --git a/setup.py b/setup.py index b882c7e..60b25bc 100644 --- a/setup.py +++ b/setup.py @@ -3,12 +3,13 @@ import codecs setuptools.setup( name='rethinkstuff', - version='0.1.7', + version='0.2.0.dev57', packages=['rethinkstuff'], classifiers=[ 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', ], install_requires=['rethinkdb'], url='https://github.com/nlevitt/rethinkstuff', diff --git a/tests/test_rethinker.py b/tests/test_rethinker.py index 2f93058..78a025e 100644 --- a/tests/test_rethinker.py +++ b/tests/test_rethinker.py @@ -1,7 +1,7 @@ ''' tests_rethinker.py - unit tests for rethinkstuff -Copyright (C) 2015-2016 Internet Archive +Copyright (C) 2015-2017 Internet Archive Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -275,3 +275,89 @@ def test_utcnow(): ## XXX what else can we test without jumping through hoops? +class SomeDoc(rethinkstuff.Document): + pass + +def test_orm(r): + d = SomeDoc(rethinker=r, d={ + 'a': 'b', + 'c': {'d': 'e'}, + 'f': ['g', 'h'], + 'i': ['j', {'k': 'l'}]}) + + d.table_create() + d.insert() + + assert d._updates == {} + d.m = 'n' + assert d._updates == {'m': 'n'} + d['c']['o'] = 'p' + assert d._updates == {'m': 'n', 'c': {'d': 'e', 'o': 'p'}} + d.f[0] = 'q' + assert d._updates == {'m': 'n', 'c': {'d': 'e', 'o': 'p'}, 'f': ['q', 'h']} + d['i'][1]['k'] = 's' + assert d._updates == { + 'm': 'n', + 'c': {'d': 'e', 'o': 'p'}, + 'f': ['q', 'h'], + 'i': ['j', {'k': 's'}]} + + del d['i'] + assert d._deletes == {'i'} + assert d._updates == {'m': 'n', 'c': {'d': 'e', 'o': 'p'}, 'f': ['q', 'h']} + + d.i = 't' + assert d._deletes == set() + assert d._updates == { + 'm': 'n', 'c': {'d': 'e', 'o': 'p'}, 'f': ['q', 'h'], 'i': 't'} + + d.f.append(['sublist']) + assert d._updates == { + 'm': 'n', 'c': {'d': 'e', 'o': 'p'}, + 'f': ['q', 'h', ['sublist']], 'i': 't'} + + ### list.clear not in python 2.7 + # d.f[2].clear() + # assert d._updates == { + # 'm': 'n', 'c': {'d': 'e', 'o': 'p'}, + # 'f': ['q', 'h', []], 'i': 't'} + + result = d.f.pop() + assert result == ['sublist'] + assert d._updates == { + 'm': 'n', 'c': {'d': 'e', 'o': 'p'}, + 'f': ['q', 'h'], 'i': 't'} + + del d.f[0] + assert d._updates == { + 'm': 'n', 'c': {'d': 'e', 'o': 'p'}, + 'f': ['h'], 'i': 't'} + + d.f.insert(0, 'u') + assert d._updates == { + 'm': 'n', 'c': {'d': 'e', 'o': 'p'}, + 'f': ['u', 'h'], 'i': 't'} + + d.f.extend(('v', {'w': 'x'})) + assert d._updates == { + 'm': 'n', 'c': {'d': 'e', 'o': 'p'}, + 'f': ['u', 'h', 'v', {'w': 'x'}], 'i': 't'} + + # check that stuff added by extend() is watched properly + d.f[3]['y'] = 'z' + assert d._updates == { + 'm': 'n', 'c': {'d': 'e', 'o': 'p'}, + 'f': ['u', 'h', 'v', {'w': 'x', 'y': 'z'}], 'i': 't'} + + d.f.remove('h') + assert d._updates == { + 'm': 'n', 'c': {'d': 'e', 'o': 'p'}, + 'f': ['u', 'v', {'w': 'x', 'y': 'z'}], 'i': 't'} + + expected = dict(d) + d.update() + assert d._updates == {} + assert d._deletes == set() + + d.refresh() + assert d == expected