pax_global_header00006660000000000000000000000064146122532510014513gustar00rootroot0000000000000052 comment=b18571efe558491f692d9a8413a91772c920435e minidb-2.0.8/000077500000000000000000000000001461225325100127645ustar00rootroot00000000000000minidb-2.0.8/.github/000077500000000000000000000000001461225325100143245ustar00rootroot00000000000000minidb-2.0.8/.github/workflows/000077500000000000000000000000001461225325100163615ustar00rootroot00000000000000minidb-2.0.8/.github/workflows/pypi.yaml000066400000000000000000000007511461225325100202310ustar00rootroot00000000000000name: Release on PyPI on: push: tags: - '**' jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - run: python -m pip install --upgrade pip - run: python -m pip install flake8 pytest build - run: python -m pytest -v - run: flake8 . - run: python -m build --sdist - uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} minidb-2.0.8/.github/workflows/pytest.yaml000066400000000000000000000013761461225325100206040ustar00rootroot00000000000000name: PyTest on: push: branches: - master pull_request: branches: - master jobs: pytest: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: - '3.8' - '3.9' - '3.10' - '3.11' - '3.12' steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Display Python version run: python -c "import sys; print(sys.version)" - run: python -m pip install --upgrade pip - run: python -m pip install flake8 pytest - run: python -m pytest -v - run: flake8 . minidb-2.0.8/LICENSE000066400000000000000000000016771461225325100140040ustar00rootroot00000000000000# _ _ _ _ # _ __ (_)_ _ (_)__| | |__ # | ' \| | ' \| / _` | '_ \ # |_|_|_|_|_||_|_\__,_|_.__/ # simple python object store # # Copyright 2009-2010, 2014-2022, 2024 Thomas Perl . All rights reserved. # # Permission to use, copy, modify, and/or distribute this software for any # purpose with or without fee is hereby granted, provided that the above # copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. minidb-2.0.8/README.md000066400000000000000000000166231461225325100142530ustar00rootroot00000000000000minidb: simple python object store ================================== [![PyTest](https://github.com/thp/minidb/actions/workflows/pytest.yaml/badge.svg)](https://github.com/thp/minidb/actions/workflows/pytest.yaml) Store Python objects in SQLite 3. Concise, pythonic API. Fun to use. Tutorial -------- Let's start by importing the minidb module in Python 3: ``` >>> import minidb ``` To create a store in memory, we simply instantiate a minidb.Store, optionally telling it to output SQL statements as debug output: ``` >>> db = minidb.Store(debug=True) ``` If you want to persist into a file, simply pass in a filename as the first parameter when creating the minidb.Store: ``` >>> db = minidb.Store('filename.db', debug=True) ``` Note that for persisting data into the file, you actually need to call db.close() to flush the changes to disk, and optionally db.commit() if you want to save the changes to disk without closing the database. By default, `minidb` executes `VACUUM` on the SQLite database on close. You can opt-out of this behaviour by passing `vacuum_on_close=False` to the `minidb.Store` constructor. You can manually execute a `VACUUM` by calling `.vacuum()` on the `minidb.Store` object, this helps reduce the file size in case you delete many objects at once. See the [SQLite VACUUM docs](https://www.sqlite.org/lang_vacuum.html) for details. To actually store objects, we need to subclass from minidb.Model (which takes care of all the behind-the-scenes magic for making your class persistable, and adds methods for working with the database): ``` >>> class Person(minidb.Model): ... name = str ... email = str ... age = int ``` Every subclass of minidb.Model will also have a "id" attribute that is None if an instance is not stored in the database, or an automatically assigned value if it is in the database. This uniquely identifies an object in the database. Now it's time to register our minidb.Model subclass with the store: ``` >>> db.register(Person) ``` This will check if the table exists, and create the necessary structure (this output appears only when debug=True is passed to minidb.Store's constructor): ``` : PRAGMA table_info(Person) : CREATE TABLE Person (id INTEGER PRIMARY KEY, name TEXT, email TEXT, age INTEGER) ``` Now you can create instances of your minidb.Model subclass, optionally passing keyword arguments that will be used to initialize the fields: ``` >>> p = Person(name='Hello World', email='minidb@example.com', age=99) >>> p ``` To store this object in the database, use .save() on the instance with the store as sole argument: ``` >>> p.save(db) ``` In debug mode, we will see how it stores the object in the database: ``` : INSERT INTO Person (name, email, age) VALUES (?, ?, ?) ['Hello World', 'minidb@example.com', '99'] ``` Also, it will now have its "id" attribute assigned: ``` >>> p ``` The instance will remember the last minidb.Store object it was saved into or the minidb.Store object from which it was loaded, so you can leave it out the next time you want to save the object: ``` >>> p.name = 'Hello Again' >>> p.save() ``` Again, the store will figure out what needs to be done: ``` : UPDATE Person SET name=?, email=?, age=? WHERE id=? ['Hello Again', 'minidb@example.com', '99', 1] ``` Now, let's insert some more data, just for fun: ``` >>> for i in range(10): ... Person(name='Hello', email='x@example.org', age=10+i*3).save(db) ``` Now that we have some objects in the database, let's query all elements, and also let's output if any of those loaded objects is the same object as p: ``` >>> for person in Person.load(db): ... print(person, person is p) ``` The SQL query that is executed by Person.load() is: ``` : SELECT id, name, email, age FROM Person [] ``` The output of the load looks like this: ``` True False False False False False False False False False False ``` Note that the first object retrieved is actually the object p (there's no new object created, it's the same). minidb caches objects as long as you have a reference to them around, and will be able to retrieve those objects instead. This makes sure that all objects stay in sync, let's try modifying an object returned by Person.get(), a function that retrieves exactly one object: ``` >>> print(p.name) Hello Again >>> Person.get(db, id=1).name = 'Hello' >>> print(p.name) Hello ``` Now, let's try some more fancy queries. The minidb.Model subclass has a class attribute called "c" that can be used to reference to the columns/attributes: ``` >>> Person.c ``` For example, we can query all objects for which age is between 16 and 50 ``` >>> Person.load(db, (Person.c.age >= 16) & (Person.c.age <= 50)) ``` This will run the following SQL query: ``` : SELECT id, name, email, age FROM Person WHERE ( age >= ? ) AND ( age <= ? ) [16, 50] ``` Instead of querying for full objects, you can also query for columns, for example, we can find out the minimum and maximum age value in the table: ``` >>> next(Person.query(db, Person.c.age.min // Person.c.age.max)) (10, 99) ``` The corresponding query looks like this: ``` : SELECT min(age), max(age) FROM Person [] ``` Note that column1 // column2 is syntactic sugar for the more verbose syntax of minidb.columns(column1, column2). The .query() method returns a generator of rows, you can get a single row via the Python built-in next(). Each row can be accessed in different ways: 1. As tuple (this is also the default representation when printing a row) 2. As dictionary 3. As object with attributes For example, as a dictionary: ``` >>> dict(next(Person.query(db, Person.c.age.min))) {'min(age)': 10} ``` If you want to have nicer names, you can give your result columns names: ``` >>> dict(next(Person.query(db, Person.c.age.min('minimum_age')))) {'minimum_age': 10} ``` The generated SQL query for renaming looks like this: ``` : SELECT min(age) AS minimum_age FROM Person [] ``` And of course, you can access the column using attribute access: ``` >>> next(Person.query(db, Person.c.age.min('minimum_age'))).minimum_age 10 ``` There is also support for SQL's ORDER BY, GROUP_BY and LIMIT, as optional keyword arguments to .query(): ``` >>> list(Person.query(db, Person.c.name // Person.c.age, ... order_by=Person.c.age.desc, limit=5)) ``` To save typing, you can do: ``` >>> Person.c.name.query(db) >>> (Person.c.name // Person.c.email).query(db) >>> (Person.c.name // Person.c.age).query(db, order_by=lamdba c: c.age.desc) >>> Person.query(db, lambda c: c.name // c.email) ``` See [`example.py`](example.py) for more examples. minidb-2.0.8/example.py000066400000000000000000000237231461225325100150000ustar00rootroot00000000000000import minidb import datetime class Person(minidb.Model): # Constants have to be all-uppercase THIS_IS_A_CONSTANT = 123 # Database columns have to be lowercase username = str mail = str foo = int # Not persisted (runtime-only) class attributes start with underscore _not_persisted = float _foo = object # Custom, non-constant class attributes with dunder __custom_class_attribute__ = [] # This is the custom constructor that will be called by minidb. # The attributes from the db will already be set when this is called. def __init__(self, foo): print('here we go now:', foo, self) self._not_persisted = 42.23 self._foo = foo @classmethod def cm_foo(cls): print('called classmethod', cls) @staticmethod def sm_bar(): print('called static method') def send_email(self): print('Would send e-mail to {self.username} at {self.mail}'.format(self=self)) print('and we have _foo as', self._foo) @property def a_property(self): return self.username.upper() @property def read_write_property(self): return 'old value' + str(self.THIS_IS_A_CONSTANT) @read_write_property.setter def read_write_property(self, new): print('new value:', new) class AdvancedPerson(Person): advanced_x = float advanced_y = float class WithoutConstructor(minidb.Model): name = str age = int height = float class WithPayload(minidb.Model): payload = minidb.JSON class DateTimeTypes(minidb.Model): just_date = datetime.date just_time = datetime.time date_and_time = datetime.datetime Person.__custom_class_attribute__.append(333) print(Person.__custom_class_attribute__) Person.cm_foo() Person.sm_bar() class FooObject(object): pass with minidb.Store(debug=True) as db: db.register(Person) db.register(WithoutConstructor) db.register(AdvancedPerson) db.register(WithPayload) db.register(DateTimeTypes) AdvancedPerson(username='advanced', mail='a@example.net').save(db) for aperson in AdvancedPerson.load(db): print(aperson) for i in range(5): w = WithoutConstructor(name='x', age=10 + 3 * i) w.height = w.age * 3.33 w.save(db) print(w) w2 = WithoutConstructor() w2.name = 'xx' w2.age = 100 + w.age w2.height = w2.age * 23.33 w2.save(db) print(w2) for i in range(3): p = Person(FooObject(), username='foo' * i) print(p) p.save(db) print(p) p.username *= 3 p.save() pp = Person(FooObject()) pp.username = 'bar' * i print(pp) pp.save(db) print(pp) print('loader is:', Person.load(db)) print('query') # for person in db.load(Person, FooObject()): for person in Person.load(db)(FooObject()): print(person) if person.username == '': print('delete') person.delete() print('id after delete:', person.id) continue person.mail = person.username + '@example.com' person.save() print(person) print('query without') for w in WithoutConstructor.load(db): print(w) print('get without') w = WithoutConstructor.get(db, age=13) print('got:', w) print('requery') print({p.id: p for p in Person.load(db)(FooObject())}) person = Person.get(db, id=3)(FooObject()) # person = db.get(Person, FooObject(), id=2) print(person) person.send_email() print('a_property:', person.a_property) print('rw property:', person.read_write_property) person.read_write_property = 'hello' print('get not persisted:', person._not_persisted) person._not_persisted = 47.11 print(person._not_persisted) person.save() print('RowProxy') for row in Person.query(db, Person.c.username // Person.c.foo): print('Repr:', row) print('Attribute access:', row.username, row.foo) print('Key access:', row['username'], row['foo']) print('Index access:', row[0], row[1]) print('As dict:', dict(row)) print('select with query builder') print('columns:', Person.c) query = (Person.c.id < 1000) & Person.c.username.like('%foo%') & (Person.c.username != None) # Person.load(db, Person.id < 1000 & Person.username.like('%foo%')) print('query:', query) print({p.id: p for p in Person.load(db, query)(FooObject())}) print('deleting all persons with a short username') print(Person.delete_where(db, Person.c.username.length <= 3)) print('what is left') for p in Person.load(db)(FooObject()): uu = next(Person.query(db, minidb.columns(Person.c.username.upper('up'), Person.c.username.lower('down'), Person.c.foo('foox'), Person.c.foo), where=(Person.c.id == p.id), order_by=minidb.columns(Person.c.id.desc, Person.c.username.length.asc), limit=1)) print(p.id, p.username, p.mail, uu) print('=' * 30) print('queries') print('=' * 30) highest_id = next(Person.query(db, Person.c.id.max('max'))).max print('highest id:', highest_id) average_age = next(WithoutConstructor.query(db, WithoutConstructor.c.age.avg('average'))).average print('average age:', average_age) all_ages = list(WithoutConstructor.c.age.query(db, order_by=WithoutConstructor.c.age.desc)) print('all ages:', all_ages) average_age = next(WithoutConstructor.c.age.avg('average').query(db, limit=1)).average print('average age (direct query):', average_age) print('multi-column query:') for row in WithoutConstructor.query(db, minidb.columns(WithoutConstructor.c.age, WithoutConstructor.c.height), order_by=WithoutConstructor.c.age.desc, limit=50): print('got:', dict(row)) print('multi-column query (direct)') print([dict(x) for x in minidb.columns(WithoutConstructor.c.age, WithoutConstructor.c.height).query( db, order_by=WithoutConstructor.c.height.desc)]) print('order by multiple with then') print(list(WithoutConstructor.c.age.query(db, order_by=(WithoutConstructor.c.height.asc // WithoutConstructor.c.age.desc)))) print('order by shortcut with late-binding column lambda as dictionary') print(list(WithoutConstructor.c.age.query(db, order_by=lambda c: c.height.asc // c.age.desc))) print('multiple columns with // and as tuple') for age, height in (WithoutConstructor.c.age // WithoutConstructor.c.height).query(db): print(age, height) print('simple query for age') for (age,) in WithoutConstructor.c.age.query(db): print(age) print('late-binding column lambda') for name, age, height, random in WithoutConstructor.query(db, lambda c: (c.name // c.age // c.height // minidb.func.random()), order_by=lambda c: (c.height.desc // minidb.func.random().asc)): print('got:', name, age, height, random) print(minidb.func.max(1, Person.c.username, 3, minidb.func.random()).tosql()) print(minidb.func.max(Person.c.username.lower, person.c.foo.lower, 6)('maximal').tosql()) print('...') print(Person.load(db, Person.c.username.like('%'))(FooObject())) print('Select Star') print(list(Person.query(db, minidb.literal('*')))) print('Count items') print(db.count_rows(Person)) print('Group By') print(list(Person.query(db, Person.c.username // Person.c.id.count, group_by=Person.c.username))) print('Pretty-Printing') minidb.pprint(Person.query(db, minidb.literal('*'))) print('Pretty-formatting in color') print(repr(minidb.pformat(Person.query(db, Person.c.id), color=True))) print('Pretty-Printing, in color') minidb.pprint(WithoutConstructor.query(db, minidb.literal('*')), color=True) print('Pretty-Querying with default star-select') Person.pquery(db) print('Delete all items') db.delete_all(Person) print('Count again after delete') print(db.count_rows(Person)) print('With payload (JSON)') WithPayload(payload={'a': [1] * 3}).save(db) for payload in WithPayload.load(db): print('foo', payload) print(next(WithPayload.c.payload.query(db))) print('Date and time types') item = DateTimeTypes(just_date=datetime.date.today(), just_time=datetime.datetime.now().time(), date_and_time=datetime.datetime.now()) print('saving:', item) item.save(db) for dtt in DateTimeTypes.load(db): print('loading:', dtt) def cached_person_main(with_delete=None): if with_delete is None: for i in range(2): cached_person_main(i) print('=' * 77) return print('=' * 20, 'Cached Person Main, with_delete =', with_delete, '=' * 20) debug_object_cache, minidb.DEBUG_OBJECT_CACHE = minidb.DEBUG_OBJECT_CACHE, True class CachedPerson(minidb.Model): name = str age = int _inst = object with minidb.Store(debug=True) as db: db.register(CachedPerson) p = CachedPerson(name='foo', age=12) p._inst = 123 p.save(db) p_id = p.id if with_delete: del p p = CachedPerson.get(db, id=p_id) print('p._inst =', repr(p._inst)) minidb.DEBUG_OBJECT_CACHE = debug_object_cache cached_person_main() minidb-2.0.8/minidb.py000066400000000000000000000765521461225325100146170ustar00rootroot00000000000000# -*- coding: utf-8 -*- # _ _ _ _ # _ __ (_)_ _ (_)__| | |__ # | ' \| | ' \| / _` | '_ \ # |_|_|_|_|_||_|_\__,_|_.__/ # simple python object store # # Copyright 2009-2010, 2014-2022, 2024 Thomas Perl . All rights reserved. # # Permission to use, copy, modify, and/or distribute this software for any # purpose with or without fee is hereby granted, provided that the above # copyright notice and this permission notice appear in all copies. # # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. # """A simple SQLite3-based store for Python objects""" import sqlite3 import threading import inspect import functools import types import collections import weakref import sys import json import datetime import logging __author__ = 'Thomas Perl ' __version__ = '2.0.8' __url__ = 'http://thp.io/2010/minidb/' __license__ = 'ISC' __all__ = [ # Main classes 'Store', 'Model', 'JSON', # Exceptions 'UnknownClass', # Utility functions 'columns', 'func', 'literal', # Decorator for registering converters 'converter_for', # Debugging utilities 'pprint', 'pformat', ] DEBUG_OBJECT_CACHE = False CONVERTERS = {} logger = logging.getLogger(__name__) class UnknownClass(TypeError): ... def converter_for(type_): def decorator(f): CONVERTERS[type_] = f return f return decorator def _get_all_slots(class_, include_private=False): for clazz in reversed(inspect.getmro(class_)): if hasattr(clazz, '__minidb_slots__'): for name, type_ in clazz.__minidb_slots__.items(): if include_private or not name.startswith('_'): yield (name, type_) def _set_attribute(o, slot, cls, value): if value is None and hasattr(o.__class__, '__minidb_defaults__'): value = getattr(o.__class__.__minidb_defaults__, slot, None) if isinstance(value, types.FunctionType): # Late-binding of default lambda (taking o as argument) value = value(o) if value is not None and cls not in CONVERTERS: value = cls(value) setattr(o, slot, value) class RowProxy(object): def __init__(self, row, keys): self._row = row self._keys = keys def __getitem__(self, key): if isinstance(key, str): try: index = self._keys.index(key) except ValueError: raise KeyError(key) return self._row[index] return self._row[key] def __getattr__(self, attr): if attr not in self._keys: raise AttributeError(attr) return self[attr] def __repr__(self): return repr(self._row) def keys(self): return self._keys class Store(object): PRIMARY_KEY = ('id', int) MINIDB_ATTR = '_minidb' def __init__(self, filename=':memory:', debug=False, smartupdate=False, vacuum_on_close=True): self.db = sqlite3.connect(filename, check_same_thread=False) self.debug = debug self.smartupdate = smartupdate self.vacuum_on_close = vacuum_on_close self.registered = {} self.lock = threading.RLock() def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): if exc_type is exc_value is traceback is None: self.commit() self.close() def _execute(self, sql, args=None): if args is None: if self.debug: logger.debug('%s', sql) return self.db.execute(sql) else: if self.debug: logger.debug('%s %r', sql, args) return self.db.execute(sql, args) def _schema(self, class_): if class_ not in self.registered.values(): raise UnknownClass('{} was never registered'.format(class_)) return (class_.__name__, list(_get_all_slots(class_))) def commit(self): with self.lock: self.db.commit() def vacuum(self): with self.lock: self._execute('VACUUM') def close(self): with self.lock: self.db.isolation_level = None if self.vacuum_on_close: self._execute('VACUUM') self.db.close() def _ensure_schema(self, table, slots): with self.lock: cur = self._execute('PRAGMA table_info(%s)' % table) available = cur.fetchall() def column(name, type_, primary=True): if (name, type_) == self.PRIMARY_KEY and primary: return 'INTEGER PRIMARY KEY' elif type_ in (int, bool): return 'INTEGER' elif type_ in (float,): return 'REAL' elif type_ in (bytes,): return 'BLOB' else: return 'TEXT' if available: available = [(row[1], row[2]) for row in available] modify_slots = [(name, type_) for name, type_ in slots if name in (name for name, _ in available) and (name, column(name, type_, False)) not in available] for name, type_ in modify_slots: raise TypeError('Column {} is {}, but expected {}'.format(name, next(dbtype for n, dbtype in available if n == name), column(name, type_))) # TODO: What to do with extraneous columns? missing_slots = [(name, type_) for name, type_ in slots if name not in (n for n, _ in available)] for name, type_ in missing_slots: self._execute('ALTER TABLE %s ADD COLUMN %s %s' % (table, name, column(name, type_))) else: self._execute('CREATE TABLE %s (%s)' % (table, ', '.join('{} {}'.format(name, column(name, type_)) for name, type_ in slots))) def register(self, class_, upgrade=False): if not issubclass(class_, Model): raise TypeError('{} is not a subclass of minidb.Model'.format(class_.__name__)) if class_ in self.registered.values(): raise TypeError('{} is already registered'.format(class_.__name__)) elif class_.__name__ in self.registered and not upgrade: raise TypeError('{} is already registered {}'.format(class_.__name__, self.registered[class_.__name__])) with self.lock: self.registered[class_.__name__] = class_ table, slots = self._schema(class_) self._ensure_schema(table, slots) return class_ def serialize(self, v, t): if v is None: return None elif t in CONVERTERS: return CONVERTERS[t](v, True) elif isinstance(v, bool): return int(v) elif isinstance(v, (int, float, bytes)): return v return str(v) def deserialize(self, v, t): if v is None: return None elif t in CONVERTERS: return CONVERTERS[t](v, False) elif isinstance(v, t): return v return t(v) def save_or_update(self, o): if o.id is None: o.id = self.save(o) else: self._update(o) def delete_by_pk(self, o): with self.lock: table, slots = self._schema(o.__class__) assert self.PRIMARY_KEY in slots pk_name, pk_type = self.PRIMARY_KEY pk = getattr(o, pk_name) assert pk is not None self._execute('DELETE FROM %s WHERE %s = ?' % (table, pk_name), [pk]) setattr(o, pk_name, None) def _update(self, o): with self.lock: table, slots = self._schema(o.__class__) # Update requires a primary key assert self.PRIMARY_KEY in slots pk_name, pk_type = self.PRIMARY_KEY if self.smartupdate: existing = dict(next(self.query(o.__class__, where=lambda c: getattr(c, pk_name) == getattr(o, pk_name)))) else: existing = {} values = [(name, type_, getattr(o, name, None)) for name, type_ in slots if (name, type_) != self.PRIMARY_KEY and (name not in existing or getattr(o, name, None) != existing[name])] if self.smartupdate and self.debug: for name, type_, to_value in values: logger.debug('%s %s', '{}(id={})'.format(table, o.id), '{}: {} -> {}'.format(name, existing[name], to_value)) if not values: # No values have changed - nothing to update return def gen_keys(): for name, type_, value in values: if value is not None: yield '{name}=?'.format(name=name) else: yield '{name}=NULL'.format(name=name) def gen_values(): for name, type_, value in values: if value is not None: yield self.serialize(value, type_) yield getattr(o, pk_name) self._execute('UPDATE %s SET %s WHERE %s = ?' % (table, ', '.join(gen_keys()), pk_name), list(gen_values())) def save(self, o): with self.lock: table, slots = self._schema(o.__class__) # Save all values except for the primary key slots = [(name, type_) for name, type_ in slots if (name, type_) != self.PRIMARY_KEY] values = [self.serialize(getattr(o, name), type_) for name, type_ in slots] return self._execute('INSERT INTO %s (%s) VALUES (%s)' % (table, ', '.join(name for name, type_ in slots), ', '.join('?' * len(slots))), values).lastrowid def delete_where(self, class_, where): with self.lock: table, slots = self._schema(class_) if isinstance(where, types.FunctionType): # Late-binding of where where = where(class_.c) ssql, args = where.tosql() sql = 'DELETE FROM %s WHERE %s' % (table, ssql) return self._execute(sql, args).rowcount def delete_all(self, class_): self.delete_where(class_, literal('1')) def count_rows(self, class_): return next(self.query(class_, func.count(literal('*'))))[0] def query(self, class_, select=None, where=None, order_by=None, group_by=None, limit=None): with self.lock: table, slots = self._schema(class_) attr_to_type = dict(slots) sql = [] args = [] if select is None: select = literal('*') if isinstance(select, types.FunctionType): # Late-binding of columns select = select(class_.c) # Select can always be a sequence if not isinstance(select, Sequence): select = Sequence([select]) # Look for RenameOperation operations in the SELECT sequence and # remember the column types, so we can decode values properly later for arg in select.args: if isinstance(arg, Operation): if isinstance(arg.a, RenameOperation): if isinstance(arg.a.column, Column): attr_to_type[arg.a.name] = arg.a.column.type_ ssql, sargs = select.tosql() sql.append('SELECT %s FROM %s' % (ssql, table)) args.extend(sargs) if where is not None: if isinstance(where, types.FunctionType): # Late-binding of columns where = where(class_.c) wsql, wargs = where.tosql() sql.append('WHERE %s' % (wsql,)) args.extend(wargs) if order_by is not None: if isinstance(order_by, types.FunctionType): # Late-binding of columns order_by = order_by(class_.c) osql, oargs = order_by.tosql() sql.append('ORDER BY %s' % (osql,)) args.extend(oargs) if group_by is not None: if isinstance(group_by, types.FunctionType): # Late-binding of columns group_by = group_by(class_.c) gsql, gargs = group_by.tosql() sql.append('GROUP BY %s' % (gsql,)) args.extend(gargs) if limit is not None: sql.append('LIMIT ?') args.append(limit) sql = ' '.join(sql) result = self._execute(sql, args) columns = [d[0] for d in result.description] def _decode(row, columns): for name, value in zip(columns, row): type_ = attr_to_type.get(name, None) yield (self.deserialize(value, type_) if type_ is not None else value) return (RowProxy(tuple(_decode(row, columns)), columns) for row in list(result)) def load(self, class_, *args, **kwargs): with self.lock: query = kwargs.get('__query__', None) if '__query__' in kwargs: del kwargs['__query__'] table, slots = self._schema(class_) sql = 'SELECT %s FROM %s' % (', '.join(name for name, type_ in slots), table) if query: if isinstance(query, types.FunctionType): # Late-binding of query query = query(class_.c) ssql, aargs = query.tosql() sql += ' WHERE %s' % ssql sql_args = aargs elif kwargs: sql += ' WHERE %s' % (' AND '.join('%s = ?' % k for k in kwargs)) sql_args = list(kwargs.values()) else: sql_args = [] cur = self._execute(sql, sql_args) def apply(row): row = zip(slots, row) kwargs = {name: self.deserialize(v, type_) for (name, type_), v in row if v is not None} o = class_(*args, **kwargs) setattr(o, self.MINIDB_ATTR, self) return o return (x for x in (apply(row) for row in cur) if x is not None) def get(self, class_, *args, **kwargs): it = self.load(class_, *args, **kwargs) result = next(it, None) try: next(it) except StopIteration: return result raise ValueError('More than one row returned') class Operation(object): def __init__(self, a, op=None, b=None, brackets=False): self.a = a self.op = op self.b = b self.brackets = brackets def _get_class(self, a): if isinstance(a, Column): return a.class_ elif isinstance(a, RenameOperation): return self._get_class(a.column) elif isinstance(a, Function): return a.args[0].class_ elif isinstance(a, Sequence): return a.args[0].class_ raise ValueError('Cannot determine class for query') def query(self, db, where=None, order_by=None, group_by=None, limit=None): return self._get_class(self.a).query(db, self, where=where, order_by=order_by, group_by=group_by, limit=limit) def __floordiv__(self, other): if self.b is not None: raise ValueError('Cannot sequence columns') return Sequence([self, other]) def argtosql(self, arg): if isinstance(arg, Operation): return arg.tosql(self.brackets) elif isinstance(arg, Column): return (arg.name, []) elif isinstance(arg, RenameOperation): columnname, args = arg.column.tosql() return ('%s AS %s' % (columnname, arg.name), args) elif isinstance(arg, Function): sqls = [] argss = [] for farg in arg.args: sql, args = self.argtosql(farg) sqls.append(sql) argss.extend(args) return ['%s(%s)' % (arg.name, ', '.join(sqls)), argss] elif isinstance(arg, Sequence): sqls = [] argss = [] for farg in arg.args: sql, args = self.argtosql(farg) sqls.append(sql) argss.extend(args) return ['%s' % ', '.join(sqls), argss] elif isinstance(arg, Literal): return [arg.name, []] if type(arg) in CONVERTERS: return ('?', [CONVERTERS[type(arg)](arg, True)]) return ('?', [arg]) def tosql(self, brackets=False): sql = [] args = [] ssql, aargs = self.argtosql(self.a) sql.append(ssql) args.extend(aargs) if self.op is not None: sql.append(self.op) if self.b is not None: ssql, aargs = self.argtosql(self.b) sql.append(ssql) args.extend(aargs) if brackets: sql.insert(0, '(') sql.append(')') return (' '.join(sql), args) def __and__(self, other): return Operation(self, 'AND', other, True) def __or__(self, other): return Operation(self, 'OR', other, True) def __repr__(self): if self.b is None: if self.op is None: return '{self.a!r}'.format(self=self) return '{self.a!r} {self.op}'.format(self=self) return '{self.a!r} {self.op} {self.b!r}'.format(self=self) class Sequence(object): def __init__(self, args): self.args = args def __repr__(self): return ', '.join(repr(arg) for arg in self.args) def tosql(self): return Operation(self).tosql() def query(self, db, order_by=None, group_by=None, limit=None): return Operation(self).query(db, order_by=order_by, group_by=group_by, limit=limit) def __floordiv__(self, other): self.args.append(other) return self def columns(*args): """columns(a, b, c) -> a // b // c Query multiple columns, like the // column sequence operator. """ return Sequence(args) class func(object): max = staticmethod(lambda *args: Function('max', *args)) min = staticmethod(lambda *args: Function('min', *args)) sum = staticmethod(lambda *args: Function('sum', *args)) distinct = staticmethod(lambda *args: Function('distinct', *args)) random = staticmethod(lambda: Function('random')) abs = staticmethod(lambda a: Function('abs', a)) length = staticmethod(lambda a: Function('length', a)) lower = staticmethod(lambda a: Function('lower', a)) upper = staticmethod(lambda a: Function('upper', a)) ltrim = staticmethod(lambda a: Function('ltrim', a)) rtrim = staticmethod(lambda a: Function('rtrim', a)) trim = staticmethod(lambda a: Function('trim', a)) count = staticmethod(lambda a: Function('count', a)) __call__ = lambda a, name: RenameOperation(a, name) class OperatorMixin(object): __lt__ = lambda a, b: Operation(a, '<', b) __le__ = lambda a, b: Operation(a, '<=', b) __eq__ = lambda a, b: Operation(a, '=', b) if b is not None else Operation(a, 'IS NULL') __ne__ = lambda a, b: Operation(a, '!=', b) if b is not None else Operation(a, 'IS NOT NULL') __gt__ = lambda a, b: Operation(a, '>', b) __ge__ = lambda a, b: Operation(a, '>=', b) __call__ = lambda a, name: RenameOperation(a, name) tosql = lambda a: Operation(a).tosql() query = lambda a, db, where=None, order_by=None, group_by=None, limit=None: Operation(a).query(db, where=where, order_by=order_by, group_by=group_by, limit=limit) __floordiv__ = lambda a, b: Sequence([a, b]) like = lambda a, b: Operation(a, 'LIKE', b) avg = property(lambda a: Function('avg', a)) max = property(lambda a: Function('max', a)) min = property(lambda a: Function('min', a)) sum = property(lambda a: Function('sum', a)) distinct = property(lambda a: Function('distinct', a)) asc = property(lambda a: Operation(a, 'ASC')) desc = property(lambda a: Operation(a, 'DESC')) abs = property(lambda a: Function('abs', a)) length = property(lambda a: Function('length', a)) lower = property(lambda a: Function('lower', a)) upper = property(lambda a: Function('upper', a)) ltrim = property(lambda a: Function('ltrim', a)) rtrim = property(lambda a: Function('rtrim', a)) trim = property(lambda a: Function('trim', a)) count = property(lambda a: Function('count', a)) class RenameOperation(OperatorMixin): def __init__(self, column, name): self.column = column self.name = name def __repr__(self): return '%r AS %s' % (self.column, self.name) class Literal(OperatorMixin): def __init__(self, name): self.name = name def __repr__(self): return self.name def literal(name): """Insert a literal as-is into a SQL query >>> func.count(literal('*')) count(*) """ return Literal(name) class Function(OperatorMixin): def __init__(self, name, *args): self.name = name self.args = args def __repr__(self): return '%s(%s)' % (self.name, ', '.join(repr(arg) for arg in self.args)) class Column(OperatorMixin): def __init__(self, class_, name, type_): self.class_ = class_ self.name = name self.type_ = type_ def __repr__(self): return '.'.join((self.class_.__name__, self.name)) class Columns(object): def __init__(self, name, slots): self._class = None self._name = name self._slots = slots def __repr__(self): return '<{} for {} ({})>'.format(self.__class__.__name__, self._name, ', '.join(self._slots)) def __getattr__(self, name): d = {k: v for k, v in _get_all_slots(self._class, include_private=True)} if name not in d: raise AttributeError(name) return Column(self._class, name, d[name]) def model_init(self, *args, **kwargs): slots = list(_get_all_slots(self.__class__, include_private=True)) unmatched_kwargs = set(kwargs.keys()).difference(set(key for key, type_ in slots)) if unmatched_kwargs: raise KeyError('Invalid keyword argument(s): %r' % unmatched_kwargs) for key, type_ in slots: _set_attribute(self, key, type_, kwargs.get(key, None)) # Call redirected constructor if '__minidb_init__' in self.__class__.__dict__: # Any keyword arguments that are not the primary key ("id") or any of the slots # will be passed to the __init__() function of the class, all other attributes # will have already been initialized/set by the time __init__() is called. kwargs = {k: v for k, v in kwargs.items() if k != Store.PRIMARY_KEY[0] and k not in self.__class__.__minidb_slots__} getattr(self, '__minidb_init__')(*args, **kwargs) class MetaModel(type): @classmethod def __prepare__(metacls, name, bases): return collections.OrderedDict() def __new__(mcs, name, bases, d): # Redirect __init__() to __minidb_init__() if '__init__' in d: d['__minidb_init__'] = d['__init__'] d['__init__'] = model_init # Caching of live objects d['__minidb_cache__'] = weakref.WeakValueDictionary() slots = collections.OrderedDict((k, v) for k, v in d.items() if k.lower() == k and not k.startswith('__') and not isinstance(v, types.FunctionType) and not isinstance(v, property) and not isinstance(v, staticmethod) and not isinstance(v, classmethod)) keep = collections.OrderedDict((k, v) for k, v in d.items() if k not in slots) keep['__minidb_slots__'] = slots keep['__slots__'] = tuple(slots.keys()) if not bases: # Add weakref slot to Model (for caching) keep['__slots__'] += ('__weakref__',) columns = Columns(name, slots) keep['c'] = columns result = type.__new__(mcs, name, bases, keep) columns._class = result return result def pformat(result, color=False): def incolor(color_id, s): return '\033[9%dm%s\033[0m' % (color_id, s) if sys.stdout.isatty() and color else s inred, ingreen, inyellow, inblue = (functools.partial(incolor, x) for x in range(1, 5)) rows = list(result) if not rows: return '(no rows)' def colorvalue(formatted, value): if value is None: return inred(formatted) if isinstance(value, bool): return ingreen(formatted) return formatted s = [] keys = rows[0].keys() lengths = tuple(max(x) for x in zip(*[[len(str(column)) for column in row] for row in [keys] + rows])) s.append(' | '.join(inyellow('%-{}s'.format(length) % key) for key, length in zip(keys, lengths))) s.append('-+-'.join('-' * length for length in lengths)) for row in rows: s.append(' | '.join(colorvalue('%-{}s'.format(length) % col, col) for col, length in zip(row, lengths))) s.append('({} row(s))'.format(len(rows))) return ('\n'.join(s)) def pprint(result, color=False): print(pformat(result, color)) class JSON(object): ... @converter_for(JSON) def convert_json(v, serialize): return json.dumps(v) if serialize else json.loads(v) @converter_for(datetime.datetime) def convert_datetime_datetime(v, serialize): """ >>> convert_datetime_datetime(datetime.datetime(2014, 12, 13, 14, 15), True) '2014-12-13T14:15:00' >>> convert_datetime_datetime('2014-12-13T14:15:16', False) datetime.datetime(2014, 12, 13, 14, 15, 16) """ if serialize: return v.isoformat() else: isoformat, microseconds = (v.rsplit('.', 1) if '.' in v else (v, 0)) return (datetime.datetime.strptime(isoformat, '%Y-%m-%dT%H:%M:%S') + datetime.timedelta(microseconds=int(microseconds))) @converter_for(datetime.date) def convert_datetime_date(v, serialize): """ >>> convert_datetime_date(datetime.date(2014, 12, 13), True) '2014-12-13' >>> convert_datetime_date('2014-12-13', False) datetime.date(2014, 12, 13) """ if serialize: return v.isoformat() else: return datetime.datetime.strptime(v, '%Y-%m-%d').date() @converter_for(datetime.time) def convert_datetime_time(v, serialize): """ >>> convert_datetime_time(datetime.time(14, 15, 16), True) '14:15:16' >>> convert_datetime_time('14:15:16', False) datetime.time(14, 15, 16) """ if serialize: return v.isoformat() else: isoformat, microseconds = (v.rsplit('.', 1) if '.' in v else (v, 0)) return (datetime.datetime.strptime(isoformat, '%H:%M:%S') + datetime.timedelta(microseconds=int(microseconds))).time() class Model(metaclass=MetaModel): id = int _minidb = Store @classmethod def _finalize(cls, id): if DEBUG_OBJECT_CACHE: logger.debug('Finalizing {} id={}'.format(cls.__name__, id)) def __repr__(self): def get_attrs(): for key, type_ in _get_all_slots(self.__class__): yield key, getattr(self, key, None) attrs = ['{key}={value!r}'.format(key=key, value=value) for key, value in get_attrs()] return '<%(cls)s(%(attrs)s)>' % { 'cls': self.__class__.__name__, 'attrs': ', '.join(attrs), } @classmethod def __lookup_single(cls, o): if o is None: return None cache = cls.__minidb_cache__ if o.id not in cache: if DEBUG_OBJECT_CACHE: logger.debug('Storing id={} in cache {}'.format(o.id, o)) weakref.finalize(o, cls._finalize, o.id) cache[o.id] = o else: if DEBUG_OBJECT_CACHE: logger.debug('Getting id={} from cache'.format(o.id)) return cache[o.id] @classmethod def __lookup_cache(cls, objects): for o in objects: yield cls.__lookup_single(o) @classmethod def load(cls, db, query=None, **kwargs): if query is not None: kwargs['__query__'] = query if '__minidb_init__' in cls.__dict__: @functools.wraps(cls.__minidb_init__) def init_wrapper(*args): return cls.__lookup_cache(db.load(cls, *args, **kwargs)) return init_wrapper else: return cls.__lookup_cache(db.load(cls, **kwargs)) @classmethod def get(cls, db, query=None, **kwargs): if query is not None: kwargs['__query__'] = query if '__minidb_init__' in cls.__dict__: @functools.wraps(cls.__minidb_init__) def init_wrapper(*args): return cls.__lookup_single(db.get(cls, *args, **kwargs)) return init_wrapper else: return cls.__lookup_single(db.get(cls, **kwargs)) def save(self, db=None): if getattr(self, Store.MINIDB_ATTR, None) is None: if db is None: raise ValueError('Needs a db object') setattr(self, Store.MINIDB_ATTR, db) getattr(self, Store.MINIDB_ATTR).save_or_update(self) if DEBUG_OBJECT_CACHE: logger.debug('Storing id={} in cache {}'.format(self.id, self)) weakref.finalize(self, self.__class__._finalize, self.id) self.__class__.__minidb_cache__[self.id] = self return self def delete(self): if getattr(self, Store.MINIDB_ATTR) is None: raise ValueError('Needs a db object') elif self.id is None: raise KeyError('id is None (not stored in db?)') # drop from cache cache = self.__class__.__minidb_cache__ if self.id in cache: if DEBUG_OBJECT_CACHE: logger.debug('Dropping id={} from cache {}'.format(self.id, self)) del cache[self.id] getattr(self, Store.MINIDB_ATTR).delete_by_pk(self) @classmethod def delete_where(cls, db, query): return db.delete_where(cls, query) @classmethod def query(cls, db, select=None, where=None, order_by=None, group_by=None, limit=None): return db.query(cls, select=select, where=where, order_by=order_by, group_by=group_by, limit=limit) @classmethod def pquery(cls, db, select=None, where=None, order_by=None, group_by=None, limit=None, color=True): pprint(db.query(cls, select=select, where=where, order_by=order_by, group_by=group_by, limit=limit), color) minidb-2.0.8/setup.cfg000066400000000000000000000003401461225325100146020ustar00rootroot00000000000000[nosetests] with-doctest = true cover-erase = true with-coverage = true cover-package = minidb [flake8] max-line-length = 120 # We use "!= None" for operator-overloaded SQL, cannot use "is not None" ignore = E711,W504,E731 minidb-2.0.8/setup.py000066400000000000000000000011261461225325100144760ustar00rootroot00000000000000#!/usr/bin/env python3 # Setup script for 'minidb' # by Thomas Perl import os import re from setuptools import setup dirname = os.path.dirname(os.path.abspath(__file__)) src = open(os.path.join(dirname, '{}.py'.format(__doc__))).read() docstrings = re.findall('"""(.*)"""', src) m = dict(re.findall(r"__([a-z_]+)__\s*=\s*'([^']+)'", src)) m['name'] = __doc__ m['author'], m['author_email'] = re.match(r'(.*) <(.*)>', m['author']).groups() m['description'] = docstrings[0] m['py_modules'] = (m['name'],) m['download_url'] = '{m[url]}{m[name]}-{m[version]}.tar.gz'.format(m=m) setup(**m) minidb-2.0.8/test/000077500000000000000000000000001461225325100137435ustar00rootroot00000000000000minidb-2.0.8/test/test_minidb.py000066400000000000000000000475301461225325100166270ustar00rootroot00000000000000import concurrent.futures import minidb import pytest import datetime class FieldTest(minidb.Model): CONSTANT = 123 # Persisted column1 = str column2 = int column3 = float column4 = bool # Not persisted per-instance attribute _private1 = object _private2 = str _private3 = int _private4 = object # Class attributes __class_attribute1__ = 'Hello' __class_attribute2__ = ['World'] def __init__(self, constructor_arg): self._private1 = constructor_arg self._private2 = 'private' self._private3 = self.CONSTANT self._private4 = None @classmethod def a_classmethod(cls): return 'classmethod' @staticmethod def a_staticmethod(): return 'staticmethod' def a_membermethod(self): return self._private1 @property def a_read_only_property(self): return self._private2.upper() @property def a_read_write_property(self): return self._private3 @a_read_write_property.setter def read_write_property(self, new_value): self._private3 = new_value class FieldConversion(minidb.Model): integer = int floating = float boolean = bool string = str jsoninteger = minidb.JSON jsonfloating = minidb.JSON jsonboolean = minidb.JSON jsonstring = minidb.JSON jsonlist = minidb.JSON jsondict = minidb.JSON jsonnone = minidb.JSON @classmethod def create(cls): return cls(integer=1, floating=1.1, boolean=True, string='test', jsonlist=[1, 2], jsondict={'a': 1}, jsonnone=None, jsoninteger=1, jsonfloating=1.1, jsonboolean=True, jsonstring='test') def test_instantiate_fieldtest_from_code(): field_test = FieldTest(999) assert field_test.id is None assert field_test.column1 is None assert field_test.column2 is None assert field_test.column3 is None assert field_test.column4 is None assert field_test._private1 == 999 assert field_test._private2 is not None assert field_test._private3 is not None assert field_test._private4 is None def test_saving_object_stores_id(): with minidb.Store(debug=True) as db: db.register(FieldTest) field_test = FieldTest(998) assert field_test.id is None field_test.save(db) assert field_test.id is not None def test_loading_object_returns_cached_object(): with minidb.Store(debug=True) as db: db.register(FieldTest) field_test = FieldTest(9999) field_test._private1 = 4711 assert field_test.id is None field_test.save(db) assert field_test.id is not None field_test_loaded = FieldTest.get(db, id=field_test.id)(9999) assert field_test_loaded._private1 == 4711 assert field_test_loaded is field_test def test_loading_object_returns_new_object_after_reference_drop(): with minidb.Store(debug=True) as db: db.register(FieldTest) field_test = FieldTest(9999) field_test._private1 = 4711 assert field_test.id is None field_test.save(db) assert field_test.id is not None field_test_id = field_test.id del field_test field_test_loaded = FieldTest.get(db, id=field_test_id)(9999) assert field_test_loaded._private1 == 9999 def test_loading_objects(): with minidb.Store(debug=True) as db: db.register(FieldTest) for i in range(100): FieldTest(i).save(db) assert next(FieldTest.c.id.count('count').query(db)).count == 100 for field_test in FieldTest.load(db)(997): assert field_test.id is not None assert field_test._private1 == 997 def test_saving_without_registration_fails(): with pytest.raises(minidb.UnknownClass): with minidb.Store(debug=True) as db: FieldTest(9).save(db) def test_registering_non_subclass_of_model_fails(): # This cannot be registered, as it's not a subclass of minidb.Model with pytest.raises(TypeError): class Something(object): column = str with minidb.Store(debug=True) as db: db.register(Something) db.register(Something) def test_invalid_keyword_arguments_fails(): with pytest.raises(KeyError): with minidb.Store(debug=True) as db: db.register(FieldTest) FieldTest(9, this_is_not_an_attribute=123).save(db) def test_invalid_column_raises_attribute_error(): with pytest.raises(AttributeError): class HasOnlyColumnX(minidb.Model): x = int with minidb.Store(debug=True) as db: db.register(HasOnlyColumnX) HasOnlyColumnX.c.y def test_json_serialization(): class WithJsonField(minidb.Model): foo = str bar = minidb.JSON with minidb.Store(debug=True) as db: db.register(WithJsonField) d = {'a': 1, 'b': [1, 2, 3], 'c': [True, 4.0, {'d': 'e'}]} WithJsonField(bar=d).save(db) assert WithJsonField.get(db, id=1).bar == d def test_json_field_query(): class WithJsonField(minidb.Model): bar = minidb.JSON with minidb.Store(debug=True) as db: db.register(WithJsonField) d = {'a': [1, True, 3.9]} WithJsonField(bar=d).save(db) assert next(WithJsonField.c.bar.query(db)).bar == d def test_json_field_renamed_query(): class WithJsonField(minidb.Model): bar = minidb.JSON with minidb.Store(debug=True) as db: db.register(WithJsonField) d = {'a': [1, True, 3.9]} WithJsonField(bar=d).save(db) assert next(WithJsonField.c.bar('renamed').query(db)).renamed == d def test_field_conversion_get_object(): with minidb.Store(debug=True) as db: db.register(FieldConversion) FieldConversion.create().save(db) result = FieldConversion.get(db, id=1) assert isinstance(result.integer, int) assert isinstance(result.floating, float) assert isinstance(result.boolean, bool) assert isinstance(result.string, str) assert isinstance(result.jsoninteger, int) assert isinstance(result.jsonfloating, float) assert isinstance(result.jsonboolean, bool) assert isinstance(result.jsonstring, str) assert isinstance(result.jsonlist, list) assert isinstance(result.jsondict, dict) assert result.jsonnone is None def test_field_conversion_query_select_star(): with minidb.Store(debug=True) as db: db.register(FieldConversion) FieldConversion.create().save(db) result = next(FieldConversion.query(db, minidb.literal('*'))) assert isinstance(result.integer, int) assert isinstance(result.floating, float) assert isinstance(result.boolean, bool) assert isinstance(result.string, str) assert isinstance(result.jsoninteger, int) assert isinstance(result.jsonfloating, float) assert isinstance(result.jsonboolean, bool) assert isinstance(result.jsonstring, str) assert isinstance(result.jsonlist, list) assert isinstance(result.jsondict, dict) assert result.jsonnone is None def test_storing_and_retrieving_booleans(): class BooleanModel(minidb.Model): value = bool with minidb.Store(debug=True) as db: db.register(BooleanModel) true_id = BooleanModel(value=True).save(db).id false_id = BooleanModel(value=False).save(db).id assert BooleanModel.get(db, id=true_id).value is True assert BooleanModel.get(db, BooleanModel.c.id == true_id).value is True assert BooleanModel.get(db, lambda c: c.id == true_id).value is True assert BooleanModel.get(db, id=false_id).value is False assert next(BooleanModel.c.value.query(db, where=lambda c: c.id == true_id)).value is True assert next(BooleanModel.c.value.query(db, where=lambda c: c.id == false_id)).value is False def test_storing_and_retrieving_floats(): class FloatModel(minidb.Model): value = float with minidb.Store(debug=True) as db: db.register(FloatModel) float_id = FloatModel(value=3.1415).save(db).id get_value = FloatModel.get(db, id=float_id).value assert isinstance(get_value, float) assert get_value == 3.1415 query_value = next(FloatModel.c.value.query(db, where=lambda c: c.id == float_id)).value assert isinstance(query_value, float) assert query_value == 3.1415 def test_storing_and_retrieving_bytes(): # http://probablyprogramming.com/2009/03/15/the-tiniest-gif-ever BLOB = (b'GIF89a\x01\x00\x01\x00\x80\x01\x00\xff\xff\xff\x00\x00\x00' + b'!\xf9\x04\x01\n\x00\x01\x00,\x00\x00\x00\x00\x01\x00\x01' + b'\x00\x00\x02\x02L\x01\x00;') class BytesModel(minidb.Model): value = bytes with minidb.Store(debug=True) as db: db.register(BytesModel) bytes_id = BytesModel(value=BLOB).save(db).id get_value = BytesModel.get(db, id=bytes_id).value assert isinstance(get_value, bytes) assert get_value == BLOB query_value = next(BytesModel.c.value.query(db, where=lambda c: c.id == bytes_id)).value assert isinstance(query_value, bytes) assert query_value == BLOB def test_get_with_multiple_value_raises_exception(): with pytest.raises(ValueError): class Mod(minidb.Model): mod = str with minidb.Store(debug=True) as db: db.register(Mod) Mod(mod='foo').save(db) Mod(mod='foo').save(db) Mod.get(db, mod='foo') def test_get_with_no_value_returns_none(): class Mod(minidb.Model): mod = str with minidb.Store(debug=True) as db: db.register(Mod) assert Mod.get(db, mod='foo') is None def test_delete_where(): class DeleteWhere(minidb.Model): v = int with minidb.Store(debug=True) as db: db.register(DeleteWhere) for i in range(10): DeleteWhere(v=i).save(db) assert DeleteWhere.delete_where(db, lambda c: c.v < 2) == len({0, 1}) assert DeleteWhere.delete_where(db, DeleteWhere.c.v > 5) == len({6, 7, 8, 9}) assert {2, 3, 4, 5} == {v for (v,) in DeleteWhere.c.v.query(db)} def test_invalid_rowproxy_access_by_attribute(): with pytest.raises(AttributeError): class Foo(minidb.Model): bar = str with minidb.Store(debug=True) as db: db.register(Foo) Foo(bar='baz').save(db) next(Foo.query(db, Foo.c.bar)).baz def test_invalid_rowproxy_access_by_key(): with pytest.raises(KeyError): class Foo(minidb.Model): bar = str with minidb.Store(debug=True) as db: db.register(Foo) Foo(bar='baz').save(db) next(Foo.query(db, Foo.c.bar))['baz'] def test_use_schema_without_registration_raises_typeerror(): with pytest.raises(TypeError): with minidb.Store(debug=True) as db: class Foo(minidb.Model): bar = str Foo.query(db) def test_use_schema_with_nonidentity_class_raises_typeerror(): with pytest.raises(TypeError): with minidb.Store(debug=True) as db: class Foo(minidb.Model): bar = str db.register(Foo) class Foo(minidb.Model): bar = str Foo.query(db) def test_upgrade_schema_without_upgrade_raises_typeerror(): with pytest.raises(TypeError): with minidb.Store(debug=True) as db: class Foo(minidb.Model): bar = str db.register(Foo) class Foo(minidb.Model): bar = str baz = int db.register(Foo) def test_reregistering_class_raises_typeerror(): with pytest.raises(TypeError): class Foo(minidb.Model): bar = int with minidb.Store(debug=True) as db: db.register(Foo) db.register(Foo) def test_upgrade_schema_with_upgrade_succeeds(): with minidb.Store(debug=True) as db: class Foo(minidb.Model): bar = str db.register(Foo) class Foo(minidb.Model): bar = str baz = int db.register(Foo, upgrade=True) def test_upgrade_schema_with_different_type_raises_typeerror(): with pytest.raises(TypeError): with minidb.Store(debug=True) as db: class Foo(minidb.Model): bar = str db.register(Foo) class Foo(minidb.Model): bar = int db.register(Foo, upgrade=True) def test_update_object(): class Foo(minidb.Model): bar = str with minidb.Store(debug=True) as db: db.register(Foo) a = Foo(bar='a').save(db) b = Foo(bar='b').save(db) a.bar = 'c' a.save() b.bar = 'd' b.save() assert {'c', 'd'} == {bar for (bar,) in Foo.c.bar.query(db)} def test_delete_object(): class Foo(minidb.Model): bar = int with minidb.Store(debug=True) as db: db.register(Foo) for i in range(3): Foo(bar=i).save(db) Foo.get(db, bar=2).delete() assert {0, 1} == {bar for (bar,) in Foo.c.bar.query(db)} def test_distinct(): class Foo(minidb.Model): bar = str baz = int with minidb.Store(debug=True) as db: db.register(Foo) for i in range(2): Foo(bar='hi', baz=i).save(db) Foo(bar='ho', baz=7).save(db) expected = {('hi',), ('ho',)} # minidb.func.distinct(COLUMN)(NAME) result = {tuple(x) for x in Foo.query(db, lambda c: minidb.func.distinct(c.bar)('foo'))} assert result == expected # COLUMN.distinct(NAME) result = {tuple(x) for x in Foo.query(db, Foo.c.bar.distinct('foo'))} assert result == expected def test_group_by_with_sum(): class Foo(minidb.Model): bar = str baz = int with minidb.Store(debug=True) as db: db.register(Foo) for i in range(5): Foo(bar='hi', baz=i).save(db) for i in range(6): Foo(bar='ho', baz=i).save(db) expected = {('hi', sum(range(5))), ('ho', sum(range(6)))} # minidb.func.sum(COLUMN)(NAME) result = {tuple(x) for x in Foo.query(db, lambda c: c.bar // minidb.func.sum(c.baz)('sum'), group_by=lambda c: c.bar)} assert result == expected # COLUMN.sum(NAME) result = {tuple(x) for x in Foo.query(db, lambda c: c.bar // c.baz.sum('sum'), group_by=lambda c: c.bar)} assert result == expected def test_save_without_db_raises_valueerror(): with pytest.raises(ValueError): class Foo(minidb.Model): bar = int Foo(bar=99).save() def test_delete_without_db_raises_valueerror(): with pytest.raises(ValueError): class Foo(minidb.Model): bar = int Foo(bar=99).delete() def test_double_delete_without_id_raises_valueerror(): with pytest.raises(KeyError): class Foo(minidb.Model): bar = str with minidb.Store(debug=True) as db: db.register(Foo) a = Foo(bar='hello') a.save(db) assert a.id is not None a.delete() assert a.id is None a.delete() def test_default_values_are_set_if_none(): class Foo(minidb.Model): name = str class __minidb_defaults__: name = 'Bob' f = Foo() assert f.name == 'Bob' f = Foo(name='John') assert f.name == 'John' def test_default_values_with_callable(): class Foo(minidb.Model): name = str email = str # Defaults are applied in order of slots of the Model # subclass, so if e.g. email depends on name to be # set, make sure email appears *after* name in the model class __minidb_defaults__: name = lambda o: 'Bob' email = lambda o: o.name + '@example.com' f = Foo() assert f.name == 'Bob' assert f.email == 'Bob@example.com' f = Foo(name='John') assert f.name == 'John' assert f.email == 'John@example.com' f = Foo(name='Joe', email='joe@example.net') assert f.name == 'Joe' assert f.email == 'joe@example.net' def test_storing_and_retrieving_datetime(): DT_NOW = datetime.datetime.now() D_TODAY = datetime.date.today() T_NOW = datetime.datetime.now().time() class DateTimeModel(minidb.Model): dt = datetime.datetime da = datetime.date tm = datetime.time with minidb.Store(debug=True) as db: db.register(DateTimeModel) datetime_id = DateTimeModel(dt=DT_NOW, da=D_TODAY, tm=T_NOW).save(db).id get_value = DateTimeModel.get(db, id=datetime_id) assert isinstance(get_value.dt, datetime.datetime) assert get_value.dt == DT_NOW assert isinstance(get_value.da, datetime.date) assert get_value.da == D_TODAY assert isinstance(get_value.tm, datetime.time) assert get_value.tm == T_NOW query_value = next(DateTimeModel.query(db, lambda c: c.dt // c.da // c.tm, where=lambda c: c.id == datetime_id)) assert isinstance(query_value.dt, datetime.datetime) assert query_value.dt == DT_NOW assert isinstance(query_value.da, datetime.date) assert query_value.da == D_TODAY assert isinstance(query_value.tm, datetime.time) assert query_value.tm == T_NOW def test_query_with_datetime(): DT_NOW = datetime.datetime.now() class DateTimeModel(minidb.Model): dt = datetime.datetime with minidb.Store(debug=True) as db: db.register(DateTimeModel) datetime_id = DateTimeModel(dt=DT_NOW).save(db).id assert DateTimeModel.get(db, lambda c: c.dt == DT_NOW).id == datetime_id def test_custom_converter(): class Point(object): def __init__(self, x, y): self.x = x self.y = y @minidb.converter_for(Point) def convert_point(v, serialize): if serialize: return ','.join(str(x) for x in (v.x, v.y)) else: return Point(*(float(x) for x in v.split(','))) class Player(minidb.Model): name = str position = Point with minidb.Store(debug=True) as db: db.register(Player) p = Point(1.12, 5.99) player_id = Player(name='Foo', position=p).save(db).id get_value = Player.get(db, id=player_id) assert isinstance(get_value.position, Point) assert (get_value.position.x, get_value.position.y) == (p.x, p.y) query_value = next(Player.query(db, lambda c: c.position, where=lambda c: c.id == player_id)) assert isinstance(query_value.position, Point) assert (query_value.position.x, query_value.position.y) == (p.x, p.y) def test_delete_all(): class Thing(minidb.Model): bla = str blubb = int with minidb.Store(debug=True) as db: db.register(Thing) db.save(Thing(bla='a', blubb=123)) db.save(Thing(bla='c', blubb=456)) assert db.count_rows(Thing) == 2 db.delete_all(Thing) assert db.count_rows(Thing) == 0 def test_threaded_query(): class Thing(minidb.Model): s = str i = int with minidb.Store(debug=True, vacuum_on_close=False) as db: db.register(Thing) for i in range(100): db.save(Thing(s=str(i), i=i)) def query(i): things = list(Thing.query(db, Thing.c.s // Thing.c.i, where=Thing.c.i == i)) assert len(things) == 1 thing = things[0] assert thing is not None assert thing.s == str(i) assert thing.i == i return i executor = concurrent.futures.ThreadPoolExecutor(max_workers=10) # Wrap in list to resolve all the futures list(executor.map(query, range(100)))