├── .github └── workflows │ ├── pypi.yaml │ └── pytest.yaml ├── LICENSE ├── README.md ├── example.py ├── minidb.py ├── setup.cfg ├── setup.py └── test └── test_minidb.py /.github/workflows/pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Release on PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - '**' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: python -m pip install --upgrade pip 14 | - run: python -m pip install flake8 pytest build 15 | - run: python -m pytest -v 16 | - run: flake8 . 17 | - run: python -m build --sdist 18 | - uses: pypa/gh-action-pypi-publish@release/v1 19 | with: 20 | user: __token__ 21 | password: ${{ secrets.PYPI_API_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yaml: -------------------------------------------------------------------------------- 1 | name: PyTest 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | pytest: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | python-version: 17 | - '3.8' 18 | - '3.9' 19 | - '3.10' 20 | - '3.11' 21 | - '3.12' 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v4 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Display Python version 29 | run: python -c "import sys; print(sys.version)" 30 | - run: python -m pip install --upgrade pip 31 | - run: python -m pip install flake8 pytest 32 | - run: python -m pytest -v 33 | - run: flake8 . 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # _ _ _ _ 2 | # _ __ (_)_ _ (_)__| | |__ 3 | # | ' \| | ' \| / _` | '_ \ 4 | # |_|_|_|_|_||_|_\__,_|_.__/ 5 | # simple python object store 6 | # 7 | # Copyright 2009-2010, 2014-2022, 2024 Thomas Perl . All rights reserved. 8 | # 9 | # Permission to use, copy, modify, and/or distribute this software for any 10 | # purpose with or without fee is hereby granted, provided that the above 11 | # copyright notice and this permission notice appear in all copies. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 14 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 15 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 16 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 17 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 18 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 19 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | minidb: simple python object store 2 | ================================== 3 | 4 | [![PyTest](https://github.com/thp/minidb/actions/workflows/pytest.yaml/badge.svg)](https://github.com/thp/minidb/actions/workflows/pytest.yaml) 5 | 6 | Store Python objects in SQLite 3. Concise, pythonic API. Fun to use. 7 | 8 | 9 | Tutorial 10 | -------- 11 | 12 | Let's start by importing the minidb module in Python 3: 13 | 14 | ``` 15 | >>> import minidb 16 | ``` 17 | 18 | To create a store in memory, we simply instantiate a minidb.Store, optionally 19 | telling it to output SQL statements as debug output: 20 | 21 | ``` 22 | >>> db = minidb.Store(debug=True) 23 | ``` 24 | 25 | If you want to persist into a file, simply pass in a filename as the first 26 | parameter when creating the minidb.Store: 27 | 28 | ``` 29 | >>> db = minidb.Store('filename.db', debug=True) 30 | ``` 31 | 32 | Note that for persisting data into the file, you actually need to call 33 | db.close() to flush the changes to disk, and optionally db.commit() if you 34 | want to save the changes to disk without closing the database. 35 | 36 | By default, `minidb` executes `VACUUM` on the SQLite database on close. You 37 | can opt-out of this behaviour by passing `vacuum_on_close=False` to the 38 | `minidb.Store` constructor. You can manually execute a `VACUUM` by calling 39 | `.vacuum()` on the `minidb.Store` object, this helps reduce the file size 40 | in case you delete many objects at once. See the 41 | [SQLite VACUUM docs](https://www.sqlite.org/lang_vacuum.html) for details. 42 | 43 | To actually store objects, we need to subclass from minidb.Model (which takes 44 | care of all the behind-the-scenes magic for making your class persistable, and 45 | adds methods for working with the database): 46 | 47 | ``` 48 | >>> class Person(minidb.Model): 49 | ... name = str 50 | ... email = str 51 | ... age = int 52 | ``` 53 | 54 | Every subclass of minidb.Model will also have a "id" attribute that is None if 55 | an instance is not stored in the database, or an automatically assigned value 56 | if it is in the database. This uniquely identifies an object in the database. 57 | 58 | Now it's time to register our minidb.Model subclass with the store: 59 | 60 | ``` 61 | >>> db.register(Person) 62 | ``` 63 | 64 | This will check if the table exists, and create the necessary structure (this 65 | output appears only when debug=True is passed to minidb.Store's constructor): 66 | 67 | ``` 68 | : PRAGMA table_info(Person) 69 | : CREATE TABLE Person (id INTEGER PRIMARY KEY, 70 | name TEXT, 71 | email TEXT, 72 | age INTEGER) 73 | ``` 74 | 75 | Now you can create instances of your minidb.Model subclass, optionally passing 76 | keyword arguments that will be used to initialize the fields: 77 | 78 | ``` 79 | >>> p = Person(name='Hello World', email='minidb@example.com', age=99) 80 | >>> p 81 | 82 | ``` 83 | 84 | To store this object in the database, use .save() on the instance with the 85 | store as sole argument: 86 | 87 | ``` 88 | >>> p.save(db) 89 | ``` 90 | 91 | In debug mode, we will see how it stores the object in the database: 92 | 93 | ``` 94 | : INSERT INTO Person (name, email, age) VALUES (?, ?, ?) 95 | ['Hello World', 'minidb@example.com', '99'] 96 | ``` 97 | 98 | Also, it will now have its "id" attribute assigned: 99 | 100 | ``` 101 | >>> p 102 | 103 | ``` 104 | 105 | The instance will remember the last minidb.Store object it was saved into or 106 | the minidb.Store object from which it was loaded, so you can leave it out the 107 | next time you want to save the object: 108 | 109 | ``` 110 | >>> p.name = 'Hello Again' 111 | >>> p.save() 112 | ``` 113 | 114 | Again, the store will figure out what needs to be done: 115 | 116 | ``` 117 | : UPDATE Person SET name=?, email=?, age=? WHERE id=? 118 | ['Hello Again', 'minidb@example.com', '99', 1] 119 | ``` 120 | 121 | Now, let's insert some more data, just for fun: 122 | 123 | ``` 124 | >>> for i in range(10): 125 | ... Person(name='Hello', email='x@example.org', age=10+i*3).save(db) 126 | ``` 127 | 128 | Now that we have some objects in the database, let's query all elements, and 129 | also let's output if any of those loaded objects is the same object as p: 130 | 131 | ``` 132 | >>> for person in Person.load(db): 133 | ... print(person, person is p) 134 | ``` 135 | 136 | The SQL query that is executed by Person.load() is: 137 | 138 | ``` 139 | : SELECT id, name, email, age FROM Person 140 | [] 141 | ``` 142 | 143 | The output of the load looks like this: 144 | 145 | ``` 146 | True 147 | False 148 | False 149 | False 150 | False 151 | False 152 | False 153 | False 154 | False 155 | False 156 | False 157 | ``` 158 | 159 | Note that the first object retrieved is actually the object p (there's no new 160 | object created, it's the same). minidb caches objects as long as you have a 161 | reference to them around, and will be able to retrieve those objects instead. 162 | This makes sure that all objects stay in sync, let's try modifying an object 163 | returned by Person.get(), a function that retrieves exactly one object: 164 | 165 | ``` 166 | >>> print(p.name) 167 | Hello Again 168 | >>> Person.get(db, id=1).name = 'Hello' 169 | >>> print(p.name) 170 | Hello 171 | ``` 172 | 173 | Now, let's try some more fancy queries. The minidb.Model subclass has a class 174 | attribute called "c" that can be used to reference to the columns/attributes: 175 | 176 | ``` 177 | >>> Person.c 178 | 179 | ``` 180 | 181 | For example, we can query all objects for which age is between 16 and 50 182 | 183 | ``` 184 | >>> Person.load(db, (Person.c.age >= 16) & (Person.c.age <= 50)) 185 | ``` 186 | 187 | This will run the following SQL query: 188 | 189 | ``` 190 | : SELECT id, name, email, age FROM Person WHERE ( age >= ? ) AND ( age <= ? ) 191 | [16, 50] 192 | ``` 193 | 194 | Instead of querying for full objects, you can also query for columns, for 195 | example, we can find out the minimum and maximum age value in the table: 196 | 197 | ``` 198 | >>> next(Person.query(db, Person.c.age.min // Person.c.age.max)) 199 | (10, 99) 200 | ``` 201 | 202 | The corresponding query looks like this: 203 | 204 | ``` 205 | : SELECT min(age), max(age) FROM Person 206 | [] 207 | ``` 208 | 209 | Note that column1 // column2 is syntactic sugar for the more verbose syntax of 210 | minidb.columns(column1, column2). The .query() method returns a generator of 211 | rows, you can get a single row via the Python built-in next(). Each row can be 212 | accessed in different ways: 213 | 214 | 1. As tuple (this is also the default representation when printing a row) 215 | 2. As dictionary 216 | 3. As object with attributes 217 | 218 | For example, as a dictionary: 219 | 220 | ``` 221 | >>> dict(next(Person.query(db, Person.c.age.min))) 222 | {'min(age)': 10} 223 | ``` 224 | 225 | If you want to have nicer names, you can give your result columns names: 226 | 227 | ``` 228 | >>> dict(next(Person.query(db, Person.c.age.min('minimum_age')))) 229 | {'minimum_age': 10} 230 | ``` 231 | 232 | The generated SQL query for renaming looks like this: 233 | 234 | ``` 235 | : SELECT min(age) AS minimum_age FROM Person 236 | [] 237 | ``` 238 | 239 | And of course, you can access the column using attribute access: 240 | 241 | ``` 242 | >>> next(Person.query(db, Person.c.age.min('minimum_age'))).minimum_age 243 | 10 244 | ``` 245 | 246 | There is also support for SQL's ORDER BY, GROUP_BY and LIMIT, as optional 247 | keyword arguments to .query(): 248 | 249 | ``` 250 | >>> list(Person.query(db, Person.c.name // Person.c.age, 251 | ... order_by=Person.c.age.desc, limit=5)) 252 | ``` 253 | 254 | To save typing, you can do: 255 | 256 | ``` 257 | >>> Person.c.name.query(db) 258 | 259 | >>> (Person.c.name // Person.c.email).query(db) 260 | 261 | >>> (Person.c.name // Person.c.age).query(db, order_by=lamdba c: c.age.desc) 262 | 263 | >>> Person.query(db, lambda c: c.name // c.email) 264 | ``` 265 | 266 | See [`example.py`](example.py) for more examples. 267 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | import minidb 2 | import datetime 3 | 4 | 5 | class Person(minidb.Model): 6 | # Constants have to be all-uppercase 7 | THIS_IS_A_CONSTANT = 123 8 | 9 | # Database columns have to be lowercase 10 | username = str 11 | mail = str 12 | foo = int 13 | 14 | # Not persisted (runtime-only) class attributes start with underscore 15 | _not_persisted = float 16 | _foo = object 17 | 18 | # Custom, non-constant class attributes with dunder 19 | __custom_class_attribute__ = [] 20 | 21 | # This is the custom constructor that will be called by minidb. 22 | # The attributes from the db will already be set when this is called. 23 | def __init__(self, foo): 24 | print('here we go now:', foo, self) 25 | self._not_persisted = 42.23 26 | self._foo = foo 27 | 28 | @classmethod 29 | def cm_foo(cls): 30 | print('called classmethod', cls) 31 | 32 | @staticmethod 33 | def sm_bar(): 34 | print('called static method') 35 | 36 | def send_email(self): 37 | print('Would send e-mail to {self.username} at {self.mail}'.format(self=self)) 38 | print('and we have _foo as', self._foo) 39 | 40 | @property 41 | def a_property(self): 42 | return self.username.upper() 43 | 44 | @property 45 | def read_write_property(self): 46 | return 'old value' + str(self.THIS_IS_A_CONSTANT) 47 | 48 | @read_write_property.setter 49 | def read_write_property(self, new): 50 | print('new value:', new) 51 | 52 | 53 | class AdvancedPerson(Person): 54 | advanced_x = float 55 | advanced_y = float 56 | 57 | 58 | class WithoutConstructor(minidb.Model): 59 | name = str 60 | age = int 61 | height = float 62 | 63 | 64 | class WithPayload(minidb.Model): 65 | payload = minidb.JSON 66 | 67 | 68 | class DateTimeTypes(minidb.Model): 69 | just_date = datetime.date 70 | just_time = datetime.time 71 | date_and_time = datetime.datetime 72 | 73 | 74 | Person.__custom_class_attribute__.append(333) 75 | print(Person.__custom_class_attribute__) 76 | Person.cm_foo() 77 | Person.sm_bar() 78 | 79 | 80 | class FooObject(object): 81 | pass 82 | 83 | 84 | with minidb.Store(debug=True) as db: 85 | db.register(Person) 86 | db.register(WithoutConstructor) 87 | db.register(AdvancedPerson) 88 | db.register(WithPayload) 89 | db.register(DateTimeTypes) 90 | 91 | AdvancedPerson(username='advanced', mail='a@example.net').save(db) 92 | 93 | for aperson in AdvancedPerson.load(db): 94 | print(aperson) 95 | 96 | for i in range(5): 97 | w = WithoutConstructor(name='x', age=10 + 3 * i) 98 | w.height = w.age * 3.33 99 | w.save(db) 100 | print(w) 101 | w2 = WithoutConstructor() 102 | w2.name = 'xx' 103 | w2.age = 100 + w.age 104 | w2.height = w2.age * 23.33 105 | w2.save(db) 106 | print(w2) 107 | 108 | for i in range(3): 109 | p = Person(FooObject(), username='foo' * i) 110 | print(p) 111 | p.save(db) 112 | print(p) 113 | p.username *= 3 114 | p.save() 115 | pp = Person(FooObject()) 116 | pp.username = 'bar' * i 117 | print(pp) 118 | pp.save(db) 119 | print(pp) 120 | 121 | print('loader is:', Person.load(db)) 122 | 123 | print('query') 124 | # for person in db.load(Person, FooObject()): 125 | for person in Person.load(db)(FooObject()): 126 | print(person) 127 | if person.username == '': 128 | print('delete') 129 | person.delete() 130 | print('id after delete:', person.id) 131 | continue 132 | person.mail = person.username + '@example.com' 133 | person.save() 134 | print(person) 135 | 136 | print('query without') 137 | for w in WithoutConstructor.load(db): 138 | print(w) 139 | 140 | print('get without') 141 | w = WithoutConstructor.get(db, age=13) 142 | print('got:', w) 143 | 144 | print('requery') 145 | print({p.id: p for p in Person.load(db)(FooObject())}) 146 | person = Person.get(db, id=3)(FooObject()) 147 | # person = db.get(Person, FooObject(), id=2) 148 | print(person) 149 | person.send_email() 150 | print('a_property:', person.a_property) 151 | print('rw property:', person.read_write_property) 152 | person.read_write_property = 'hello' 153 | print('get not persisted:', person._not_persisted) 154 | person._not_persisted = 47.11 155 | print(person._not_persisted) 156 | person.save() 157 | 158 | print('RowProxy') 159 | for row in Person.query(db, Person.c.username // Person.c.foo): 160 | print('Repr:', row) 161 | print('Attribute access:', row.username, row.foo) 162 | print('Key access:', row['username'], row['foo']) 163 | print('Index access:', row[0], row[1]) 164 | print('As dict:', dict(row)) 165 | 166 | print('select with query builder') 167 | print('columns:', Person.c) 168 | query = (Person.c.id < 1000) & Person.c.username.like('%foo%') & (Person.c.username != None) 169 | # Person.load(db, Person.id < 1000 & Person.username.like('%foo%')) 170 | print('query:', query) 171 | print({p.id: p for p in Person.load(db, query)(FooObject())}) 172 | print('deleting all persons with a short username') 173 | print(Person.delete_where(db, Person.c.username.length <= 3)) 174 | 175 | print('what is left') 176 | for p in Person.load(db)(FooObject()): 177 | uu = next(Person.query(db, minidb.columns(Person.c.username.upper('up'), 178 | Person.c.username.lower('down'), 179 | Person.c.foo('foox'), 180 | Person.c.foo), 181 | where=(Person.c.id == p.id), 182 | order_by=minidb.columns(Person.c.id.desc, 183 | Person.c.username.length.asc), 184 | limit=1)) 185 | print(p.id, p.username, p.mail, uu) 186 | 187 | print('=' * 30) 188 | print('queries') 189 | print('=' * 30) 190 | 191 | highest_id = next(Person.query(db, Person.c.id.max('max'))).max 192 | print('highest id:', highest_id) 193 | 194 | average_age = next(WithoutConstructor.query(db, WithoutConstructor.c.age.avg('average'))).average 195 | print('average age:', average_age) 196 | 197 | all_ages = list(WithoutConstructor.c.age.query(db, order_by=WithoutConstructor.c.age.desc)) 198 | print('all ages:', all_ages) 199 | 200 | average_age = next(WithoutConstructor.c.age.avg('average').query(db, limit=1)).average 201 | print('average age (direct query):', average_age) 202 | 203 | print('multi-column query:') 204 | for row in WithoutConstructor.query(db, minidb.columns(WithoutConstructor.c.age, 205 | WithoutConstructor.c.height), 206 | order_by=WithoutConstructor.c.age.desc, 207 | limit=50): 208 | print('got:', dict(row)) 209 | 210 | print('multi-column query (direct)') 211 | print([dict(x) for x in minidb.columns(WithoutConstructor.c.age, 212 | WithoutConstructor.c.height).query( 213 | db, order_by=WithoutConstructor.c.height.desc)]) 214 | 215 | print('order by multiple with then') 216 | print(list(WithoutConstructor.c.age.query(db, order_by=(WithoutConstructor.c.height.asc // 217 | WithoutConstructor.c.age.desc)))) 218 | 219 | print('order by shortcut with late-binding column lambda as dictionary') 220 | print(list(WithoutConstructor.c.age.query(db, order_by=lambda c: c.height.asc // c.age.desc))) 221 | 222 | print('multiple columns with // and as tuple') 223 | for age, height in (WithoutConstructor.c.age // WithoutConstructor.c.height).query(db): 224 | print(age, height) 225 | 226 | print('simple query for age') 227 | for (age,) in WithoutConstructor.c.age.query(db): 228 | print(age) 229 | 230 | print('late-binding column lambda') 231 | for name, age, height, random in WithoutConstructor.query(db, lambda c: (c.name // c.age // 232 | c.height // minidb.func.random()), 233 | order_by=lambda c: (c.height.desc // 234 | minidb.func.random().asc)): 235 | print('got:', name, age, height, random) 236 | 237 | print(minidb.func.max(1, Person.c.username, 3, minidb.func.random()).tosql()) 238 | print(minidb.func.max(Person.c.username.lower, person.c.foo.lower, 6)('maximal').tosql()) 239 | 240 | print('...') 241 | print(Person.load(db, Person.c.username.like('%'))(FooObject())) 242 | 243 | print('Select Star') 244 | print(list(Person.query(db, minidb.literal('*')))) 245 | 246 | print('Count items') 247 | print(db.count_rows(Person)) 248 | 249 | print('Group By') 250 | print(list(Person.query(db, Person.c.username // Person.c.id.count, group_by=Person.c.username))) 251 | 252 | print('Pretty-Printing') 253 | minidb.pprint(Person.query(db, minidb.literal('*'))) 254 | 255 | print('Pretty-formatting in color') 256 | print(repr(minidb.pformat(Person.query(db, Person.c.id), color=True))) 257 | 258 | print('Pretty-Printing, in color') 259 | minidb.pprint(WithoutConstructor.query(db, minidb.literal('*')), color=True) 260 | 261 | print('Pretty-Querying with default star-select') 262 | Person.pquery(db) 263 | 264 | print('Delete all items') 265 | db.delete_all(Person) 266 | 267 | print('Count again after delete') 268 | print(db.count_rows(Person)) 269 | 270 | print('With payload (JSON)') 271 | WithPayload(payload={'a': [1] * 3}).save(db) 272 | for payload in WithPayload.load(db): 273 | print('foo', payload) 274 | 275 | print(next(WithPayload.c.payload.query(db))) 276 | 277 | print('Date and time types') 278 | item = DateTimeTypes(just_date=datetime.date.today(), 279 | just_time=datetime.datetime.now().time(), 280 | date_and_time=datetime.datetime.now()) 281 | print('saving:', item) 282 | item.save(db) 283 | for dtt in DateTimeTypes.load(db): 284 | print('loading:', dtt) 285 | 286 | 287 | def cached_person_main(with_delete=None): 288 | if with_delete is None: 289 | for i in range(2): 290 | cached_person_main(i) 291 | print('=' * 77) 292 | return 293 | print('=' * 20, 'Cached Person Main, with_delete =', with_delete, '=' * 20) 294 | 295 | debug_object_cache, minidb.DEBUG_OBJECT_CACHE = minidb.DEBUG_OBJECT_CACHE, True 296 | 297 | class CachedPerson(minidb.Model): 298 | name = str 299 | age = int 300 | _inst = object 301 | 302 | with minidb.Store(debug=True) as db: 303 | db.register(CachedPerson) 304 | p = CachedPerson(name='foo', age=12) 305 | p._inst = 123 306 | p.save(db) 307 | p_id = p.id 308 | if with_delete: 309 | del p 310 | p = CachedPerson.get(db, id=p_id) 311 | print('p._inst =', repr(p._inst)) 312 | minidb.DEBUG_OBJECT_CACHE = debug_object_cache 313 | 314 | 315 | cached_person_main() 316 | -------------------------------------------------------------------------------- /minidb.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # _ _ _ _ 3 | # _ __ (_)_ _ (_)__| | |__ 4 | # | ' \| | ' \| / _` | '_ \ 5 | # |_|_|_|_|_||_|_\__,_|_.__/ 6 | # simple python object store 7 | # 8 | # Copyright 2009-2010, 2014-2022, 2024 Thomas Perl . All rights reserved. 9 | # 10 | # Permission to use, copy, modify, and/or distribute this software for any 11 | # purpose with or without fee is hereby granted, provided that the above 12 | # copyright notice and this permission notice appear in all copies. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 15 | # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 16 | # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 17 | # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 18 | # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 19 | # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 20 | # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 21 | # 22 | 23 | """A simple SQLite3-based store for Python objects""" 24 | 25 | import sqlite3 26 | import threading 27 | import inspect 28 | import functools 29 | import types 30 | import collections 31 | import weakref 32 | import sys 33 | import json 34 | import datetime 35 | import logging 36 | 37 | 38 | __author__ = 'Thomas Perl ' 39 | __version__ = '2.0.8' 40 | __url__ = 'http://thp.io/2010/minidb/' 41 | __license__ = 'ISC' 42 | 43 | 44 | __all__ = [ 45 | # Main classes 46 | 'Store', 'Model', 'JSON', 47 | 48 | # Exceptions 49 | 'UnknownClass', 50 | 51 | # Utility functions 52 | 'columns', 'func', 'literal', 53 | 54 | # Decorator for registering converters 55 | 'converter_for', 56 | 57 | # Debugging utilities 58 | 'pprint', 'pformat', 59 | ] 60 | 61 | 62 | DEBUG_OBJECT_CACHE = False 63 | CONVERTERS = {} 64 | 65 | 66 | logger = logging.getLogger(__name__) 67 | 68 | 69 | class UnknownClass(TypeError): 70 | ... 71 | 72 | 73 | def converter_for(type_): 74 | def decorator(f): 75 | CONVERTERS[type_] = f 76 | return f 77 | 78 | return decorator 79 | 80 | 81 | def _get_all_slots(class_, include_private=False): 82 | for clazz in reversed(inspect.getmro(class_)): 83 | if hasattr(clazz, '__minidb_slots__'): 84 | for name, type_ in clazz.__minidb_slots__.items(): 85 | if include_private or not name.startswith('_'): 86 | yield (name, type_) 87 | 88 | 89 | def _set_attribute(o, slot, cls, value): 90 | if value is None and hasattr(o.__class__, '__minidb_defaults__'): 91 | value = getattr(o.__class__.__minidb_defaults__, slot, None) 92 | if isinstance(value, types.FunctionType): 93 | # Late-binding of default lambda (taking o as argument) 94 | value = value(o) 95 | if value is not None and cls not in CONVERTERS: 96 | value = cls(value) 97 | setattr(o, slot, value) 98 | 99 | 100 | class RowProxy(object): 101 | def __init__(self, row, keys): 102 | self._row = row 103 | self._keys = keys 104 | 105 | def __getitem__(self, key): 106 | if isinstance(key, str): 107 | try: 108 | index = self._keys.index(key) 109 | except ValueError: 110 | raise KeyError(key) 111 | 112 | return self._row[index] 113 | 114 | return self._row[key] 115 | 116 | def __getattr__(self, attr): 117 | if attr not in self._keys: 118 | raise AttributeError(attr) 119 | 120 | return self[attr] 121 | 122 | def __repr__(self): 123 | return repr(self._row) 124 | 125 | def keys(self): 126 | return self._keys 127 | 128 | 129 | class Store(object): 130 | PRIMARY_KEY = ('id', int) 131 | MINIDB_ATTR = '_minidb' 132 | 133 | def __init__(self, filename=':memory:', debug=False, smartupdate=False, vacuum_on_close=True): 134 | self.db = sqlite3.connect(filename, check_same_thread=False) 135 | self.debug = debug 136 | self.smartupdate = smartupdate 137 | self.vacuum_on_close = vacuum_on_close 138 | self.registered = {} 139 | self.lock = threading.RLock() 140 | 141 | def __enter__(self): 142 | return self 143 | 144 | def __exit__(self, exc_type, exc_value, traceback): 145 | if exc_type is exc_value is traceback is None: 146 | self.commit() 147 | 148 | self.close() 149 | 150 | def _execute(self, sql, args=None): 151 | if args is None: 152 | if self.debug: 153 | logger.debug('%s', sql) 154 | return self.db.execute(sql) 155 | else: 156 | if self.debug: 157 | logger.debug('%s %r', sql, args) 158 | return self.db.execute(sql, args) 159 | 160 | def _schema(self, class_): 161 | if class_ not in self.registered.values(): 162 | raise UnknownClass('{} was never registered'.format(class_)) 163 | return (class_.__name__, list(_get_all_slots(class_))) 164 | 165 | def commit(self): 166 | with self.lock: 167 | self.db.commit() 168 | 169 | def vacuum(self): 170 | with self.lock: 171 | self._execute('VACUUM') 172 | 173 | def close(self): 174 | with self.lock: 175 | self.db.isolation_level = None 176 | if self.vacuum_on_close: 177 | self._execute('VACUUM') 178 | self.db.close() 179 | 180 | def _ensure_schema(self, table, slots): 181 | with self.lock: 182 | cur = self._execute('PRAGMA table_info(%s)' % table) 183 | available = cur.fetchall() 184 | 185 | def column(name, type_, primary=True): 186 | if (name, type_) == self.PRIMARY_KEY and primary: 187 | return 'INTEGER PRIMARY KEY' 188 | elif type_ in (int, bool): 189 | return 'INTEGER' 190 | elif type_ in (float,): 191 | return 'REAL' 192 | elif type_ in (bytes,): 193 | return 'BLOB' 194 | else: 195 | return 'TEXT' 196 | 197 | if available: 198 | available = [(row[1], row[2]) for row in available] 199 | 200 | modify_slots = [(name, type_) for name, type_ in slots if name in (name for name, _ in available) and 201 | (name, column(name, type_, False)) not in available] 202 | for name, type_ in modify_slots: 203 | raise TypeError('Column {} is {}, but expected {}'.format(name, next(dbtype for n, dbtype in 204 | available if n == name), 205 | column(name, type_))) 206 | 207 | # TODO: What to do with extraneous columns? 208 | 209 | missing_slots = [(name, type_) for name, type_ in slots if name not in (n for n, _ in available)] 210 | for name, type_ in missing_slots: 211 | self._execute('ALTER TABLE %s ADD COLUMN %s %s' % (table, name, column(name, type_))) 212 | else: 213 | self._execute('CREATE TABLE %s (%s)' % (table, ', '.join('{} {}'.format(name, column(name, type_)) 214 | for name, type_ in slots))) 215 | 216 | def register(self, class_, upgrade=False): 217 | if not issubclass(class_, Model): 218 | raise TypeError('{} is not a subclass of minidb.Model'.format(class_.__name__)) 219 | 220 | if class_ in self.registered.values(): 221 | raise TypeError('{} is already registered'.format(class_.__name__)) 222 | elif class_.__name__ in self.registered and not upgrade: 223 | raise TypeError('{} is already registered {}'.format(class_.__name__, self.registered[class_.__name__])) 224 | 225 | with self.lock: 226 | self.registered[class_.__name__] = class_ 227 | table, slots = self._schema(class_) 228 | self._ensure_schema(table, slots) 229 | 230 | return class_ 231 | 232 | def serialize(self, v, t): 233 | if v is None: 234 | return None 235 | elif t in CONVERTERS: 236 | return CONVERTERS[t](v, True) 237 | elif isinstance(v, bool): 238 | return int(v) 239 | elif isinstance(v, (int, float, bytes)): 240 | return v 241 | 242 | return str(v) 243 | 244 | def deserialize(self, v, t): 245 | if v is None: 246 | return None 247 | elif t in CONVERTERS: 248 | return CONVERTERS[t](v, False) 249 | elif isinstance(v, t): 250 | return v 251 | 252 | return t(v) 253 | 254 | def save_or_update(self, o): 255 | if o.id is None: 256 | o.id = self.save(o) 257 | else: 258 | self._update(o) 259 | 260 | def delete_by_pk(self, o): 261 | with self.lock: 262 | table, slots = self._schema(o.__class__) 263 | 264 | assert self.PRIMARY_KEY in slots 265 | pk_name, pk_type = self.PRIMARY_KEY 266 | pk = getattr(o, pk_name) 267 | assert pk is not None 268 | 269 | self._execute('DELETE FROM %s WHERE %s = ?' % (table, pk_name), [pk]) 270 | setattr(o, pk_name, None) 271 | 272 | def _update(self, o): 273 | with self.lock: 274 | table, slots = self._schema(o.__class__) 275 | 276 | # Update requires a primary key 277 | assert self.PRIMARY_KEY in slots 278 | pk_name, pk_type = self.PRIMARY_KEY 279 | 280 | if self.smartupdate: 281 | existing = dict(next(self.query(o.__class__, where=lambda c: 282 | getattr(c, pk_name) == getattr(o, pk_name)))) 283 | else: 284 | existing = {} 285 | 286 | values = [(name, type_, getattr(o, name, None)) 287 | for name, type_ in slots if (name, type_) != self.PRIMARY_KEY and 288 | (name not in existing or getattr(o, name, None) != existing[name])] 289 | 290 | if self.smartupdate and self.debug: 291 | for name, type_, to_value in values: 292 | logger.debug('%s %s', '{}(id={})'.format(table, o.id), 293 | '{}: {} -> {}'.format(name, existing[name], to_value)) 294 | 295 | if not values: 296 | # No values have changed - nothing to update 297 | return 298 | 299 | def gen_keys(): 300 | for name, type_, value in values: 301 | if value is not None: 302 | yield '{name}=?'.format(name=name) 303 | else: 304 | yield '{name}=NULL'.format(name=name) 305 | 306 | def gen_values(): 307 | for name, type_, value in values: 308 | if value is not None: 309 | yield self.serialize(value, type_) 310 | 311 | yield getattr(o, pk_name) 312 | 313 | self._execute('UPDATE %s SET %s WHERE %s = ?' % (table, ', '.join(gen_keys()), pk_name), list(gen_values())) 314 | 315 | def save(self, o): 316 | with self.lock: 317 | table, slots = self._schema(o.__class__) 318 | 319 | # Save all values except for the primary key 320 | slots = [(name, type_) for name, type_ in slots if (name, type_) != self.PRIMARY_KEY] 321 | 322 | values = [self.serialize(getattr(o, name), type_) for name, type_ in slots] 323 | return self._execute('INSERT INTO %s (%s) VALUES (%s)' % (table, ', '.join(name for name, type_ in slots), 324 | ', '.join('?' * len(slots))), values).lastrowid 325 | 326 | def delete_where(self, class_, where): 327 | with self.lock: 328 | table, slots = self._schema(class_) 329 | 330 | if isinstance(where, types.FunctionType): 331 | # Late-binding of where 332 | where = where(class_.c) 333 | 334 | ssql, args = where.tosql() 335 | sql = 'DELETE FROM %s WHERE %s' % (table, ssql) 336 | return self._execute(sql, args).rowcount 337 | 338 | def delete_all(self, class_): 339 | self.delete_where(class_, literal('1')) 340 | 341 | def count_rows(self, class_): 342 | return next(self.query(class_, func.count(literal('*'))))[0] 343 | 344 | def query(self, class_, select=None, where=None, order_by=None, group_by=None, limit=None): 345 | with self.lock: 346 | table, slots = self._schema(class_) 347 | attr_to_type = dict(slots) 348 | 349 | sql = [] 350 | args = [] 351 | 352 | if select is None: 353 | select = literal('*') 354 | 355 | if isinstance(select, types.FunctionType): 356 | # Late-binding of columns 357 | select = select(class_.c) 358 | 359 | # Select can always be a sequence 360 | if not isinstance(select, Sequence): 361 | select = Sequence([select]) 362 | 363 | # Look for RenameOperation operations in the SELECT sequence and 364 | # remember the column types, so we can decode values properly later 365 | for arg in select.args: 366 | if isinstance(arg, Operation): 367 | if isinstance(arg.a, RenameOperation): 368 | if isinstance(arg.a.column, Column): 369 | attr_to_type[arg.a.name] = arg.a.column.type_ 370 | 371 | ssql, sargs = select.tosql() 372 | sql.append('SELECT %s FROM %s' % (ssql, table)) 373 | args.extend(sargs) 374 | 375 | if where is not None: 376 | if isinstance(where, types.FunctionType): 377 | # Late-binding of columns 378 | where = where(class_.c) 379 | wsql, wargs = where.tosql() 380 | sql.append('WHERE %s' % (wsql,)) 381 | args.extend(wargs) 382 | 383 | if order_by is not None: 384 | if isinstance(order_by, types.FunctionType): 385 | # Late-binding of columns 386 | order_by = order_by(class_.c) 387 | 388 | osql, oargs = order_by.tosql() 389 | sql.append('ORDER BY %s' % (osql,)) 390 | args.extend(oargs) 391 | 392 | if group_by is not None: 393 | if isinstance(group_by, types.FunctionType): 394 | # Late-binding of columns 395 | group_by = group_by(class_.c) 396 | 397 | gsql, gargs = group_by.tosql() 398 | sql.append('GROUP BY %s' % (gsql,)) 399 | args.extend(gargs) 400 | 401 | if limit is not None: 402 | sql.append('LIMIT ?') 403 | args.append(limit) 404 | 405 | sql = ' '.join(sql) 406 | 407 | result = self._execute(sql, args) 408 | columns = [d[0] for d in result.description] 409 | 410 | def _decode(row, columns): 411 | for name, value in zip(columns, row): 412 | type_ = attr_to_type.get(name, None) 413 | yield (self.deserialize(value, type_) if type_ is not None else value) 414 | 415 | return (RowProxy(tuple(_decode(row, columns)), columns) for row in list(result)) 416 | 417 | def load(self, class_, *args, **kwargs): 418 | with self.lock: 419 | query = kwargs.get('__query__', None) 420 | if '__query__' in kwargs: 421 | del kwargs['__query__'] 422 | 423 | table, slots = self._schema(class_) 424 | sql = 'SELECT %s FROM %s' % (', '.join(name for name, type_ in slots), table) 425 | if query: 426 | if isinstance(query, types.FunctionType): 427 | # Late-binding of query 428 | query = query(class_.c) 429 | 430 | ssql, aargs = query.tosql() 431 | sql += ' WHERE %s' % ssql 432 | sql_args = aargs 433 | elif kwargs: 434 | sql += ' WHERE %s' % (' AND '.join('%s = ?' % k for k in kwargs)) 435 | sql_args = list(kwargs.values()) 436 | else: 437 | sql_args = [] 438 | cur = self._execute(sql, sql_args) 439 | 440 | def apply(row): 441 | row = zip(slots, row) 442 | kwargs = {name: self.deserialize(v, type_) for (name, type_), v in row if v is not None} 443 | o = class_(*args, **kwargs) 444 | setattr(o, self.MINIDB_ATTR, self) 445 | return o 446 | 447 | return (x for x in (apply(row) for row in cur) if x is not None) 448 | 449 | def get(self, class_, *args, **kwargs): 450 | it = self.load(class_, *args, **kwargs) 451 | result = next(it, None) 452 | 453 | try: 454 | next(it) 455 | except StopIteration: 456 | return result 457 | 458 | raise ValueError('More than one row returned') 459 | 460 | 461 | class Operation(object): 462 | def __init__(self, a, op=None, b=None, brackets=False): 463 | self.a = a 464 | self.op = op 465 | self.b = b 466 | self.brackets = brackets 467 | 468 | def _get_class(self, a): 469 | if isinstance(a, Column): 470 | return a.class_ 471 | elif isinstance(a, RenameOperation): 472 | return self._get_class(a.column) 473 | elif isinstance(a, Function): 474 | return a.args[0].class_ 475 | elif isinstance(a, Sequence): 476 | return a.args[0].class_ 477 | 478 | raise ValueError('Cannot determine class for query') 479 | 480 | def query(self, db, where=None, order_by=None, group_by=None, limit=None): 481 | return self._get_class(self.a).query(db, self, where=where, order_by=order_by, group_by=group_by, limit=limit) 482 | 483 | def __floordiv__(self, other): 484 | if self.b is not None: 485 | raise ValueError('Cannot sequence columns') 486 | return Sequence([self, other]) 487 | 488 | def argtosql(self, arg): 489 | if isinstance(arg, Operation): 490 | return arg.tosql(self.brackets) 491 | elif isinstance(arg, Column): 492 | return (arg.name, []) 493 | elif isinstance(arg, RenameOperation): 494 | columnname, args = arg.column.tosql() 495 | return ('%s AS %s' % (columnname, arg.name), args) 496 | elif isinstance(arg, Function): 497 | sqls = [] 498 | argss = [] 499 | for farg in arg.args: 500 | sql, args = self.argtosql(farg) 501 | sqls.append(sql) 502 | argss.extend(args) 503 | return ['%s(%s)' % (arg.name, ', '.join(sqls)), argss] 504 | elif isinstance(arg, Sequence): 505 | sqls = [] 506 | argss = [] 507 | for farg in arg.args: 508 | sql, args = self.argtosql(farg) 509 | sqls.append(sql) 510 | argss.extend(args) 511 | return ['%s' % ', '.join(sqls), argss] 512 | elif isinstance(arg, Literal): 513 | return [arg.name, []] 514 | if type(arg) in CONVERTERS: 515 | return ('?', [CONVERTERS[type(arg)](arg, True)]) 516 | 517 | return ('?', [arg]) 518 | 519 | def tosql(self, brackets=False): 520 | sql = [] 521 | args = [] 522 | 523 | ssql, aargs = self.argtosql(self.a) 524 | sql.append(ssql) 525 | args.extend(aargs) 526 | 527 | if self.op is not None: 528 | sql.append(self.op) 529 | 530 | if self.b is not None: 531 | ssql, aargs = self.argtosql(self.b) 532 | sql.append(ssql) 533 | args.extend(aargs) 534 | 535 | if brackets: 536 | sql.insert(0, '(') 537 | sql.append(')') 538 | 539 | return (' '.join(sql), args) 540 | 541 | def __and__(self, other): 542 | return Operation(self, 'AND', other, True) 543 | 544 | def __or__(self, other): 545 | return Operation(self, 'OR', other, True) 546 | 547 | def __repr__(self): 548 | if self.b is None: 549 | if self.op is None: 550 | return '{self.a!r}'.format(self=self) 551 | return '{self.a!r} {self.op}'.format(self=self) 552 | return '{self.a!r} {self.op} {self.b!r}'.format(self=self) 553 | 554 | 555 | class Sequence(object): 556 | def __init__(self, args): 557 | self.args = args 558 | 559 | def __repr__(self): 560 | return ', '.join(repr(arg) for arg in self.args) 561 | 562 | def tosql(self): 563 | return Operation(self).tosql() 564 | 565 | def query(self, db, order_by=None, group_by=None, limit=None): 566 | return Operation(self).query(db, order_by=order_by, group_by=group_by, limit=limit) 567 | 568 | def __floordiv__(self, other): 569 | self.args.append(other) 570 | return self 571 | 572 | 573 | def columns(*args): 574 | """columns(a, b, c) -> a // b // c 575 | 576 | Query multiple columns, like the // column sequence operator. 577 | """ 578 | return Sequence(args) 579 | 580 | 581 | class func(object): 582 | max = staticmethod(lambda *args: Function('max', *args)) 583 | min = staticmethod(lambda *args: Function('min', *args)) 584 | sum = staticmethod(lambda *args: Function('sum', *args)) 585 | distinct = staticmethod(lambda *args: Function('distinct', *args)) 586 | random = staticmethod(lambda: Function('random')) 587 | 588 | abs = staticmethod(lambda a: Function('abs', a)) 589 | length = staticmethod(lambda a: Function('length', a)) 590 | lower = staticmethod(lambda a: Function('lower', a)) 591 | upper = staticmethod(lambda a: Function('upper', a)) 592 | ltrim = staticmethod(lambda a: Function('ltrim', a)) 593 | rtrim = staticmethod(lambda a: Function('rtrim', a)) 594 | trim = staticmethod(lambda a: Function('trim', a)) 595 | 596 | count = staticmethod(lambda a: Function('count', a)) 597 | __call__ = lambda a, name: RenameOperation(a, name) 598 | 599 | 600 | class OperatorMixin(object): 601 | __lt__ = lambda a, b: Operation(a, '<', b) 602 | __le__ = lambda a, b: Operation(a, '<=', b) 603 | __eq__ = lambda a, b: Operation(a, '=', b) if b is not None else Operation(a, 'IS NULL') 604 | __ne__ = lambda a, b: Operation(a, '!=', b) if b is not None else Operation(a, 'IS NOT NULL') 605 | __gt__ = lambda a, b: Operation(a, '>', b) 606 | __ge__ = lambda a, b: Operation(a, '>=', b) 607 | 608 | __call__ = lambda a, name: RenameOperation(a, name) 609 | tosql = lambda a: Operation(a).tosql() 610 | query = lambda a, db, where=None, order_by=None, group_by=None, limit=None: Operation(a).query(db, where=where, 611 | order_by=order_by, 612 | group_by=group_by, 613 | limit=limit) 614 | __floordiv__ = lambda a, b: Sequence([a, b]) 615 | 616 | like = lambda a, b: Operation(a, 'LIKE', b) 617 | 618 | avg = property(lambda a: Function('avg', a)) 619 | max = property(lambda a: Function('max', a)) 620 | min = property(lambda a: Function('min', a)) 621 | sum = property(lambda a: Function('sum', a)) 622 | distinct = property(lambda a: Function('distinct', a)) 623 | 624 | asc = property(lambda a: Operation(a, 'ASC')) 625 | desc = property(lambda a: Operation(a, 'DESC')) 626 | 627 | abs = property(lambda a: Function('abs', a)) 628 | length = property(lambda a: Function('length', a)) 629 | lower = property(lambda a: Function('lower', a)) 630 | upper = property(lambda a: Function('upper', a)) 631 | ltrim = property(lambda a: Function('ltrim', a)) 632 | rtrim = property(lambda a: Function('rtrim', a)) 633 | trim = property(lambda a: Function('trim', a)) 634 | count = property(lambda a: Function('count', a)) 635 | 636 | 637 | class RenameOperation(OperatorMixin): 638 | def __init__(self, column, name): 639 | self.column = column 640 | self.name = name 641 | 642 | def __repr__(self): 643 | return '%r AS %s' % (self.column, self.name) 644 | 645 | 646 | class Literal(OperatorMixin): 647 | def __init__(self, name): 648 | self.name = name 649 | 650 | def __repr__(self): 651 | return self.name 652 | 653 | 654 | def literal(name): 655 | """Insert a literal as-is into a SQL query 656 | 657 | >>> func.count(literal('*')) 658 | count(*) 659 | """ 660 | return Literal(name) 661 | 662 | 663 | class Function(OperatorMixin): 664 | def __init__(self, name, *args): 665 | self.name = name 666 | self.args = args 667 | 668 | def __repr__(self): 669 | return '%s(%s)' % (self.name, ', '.join(repr(arg) for arg in self.args)) 670 | 671 | 672 | class Column(OperatorMixin): 673 | def __init__(self, class_, name, type_): 674 | self.class_ = class_ 675 | self.name = name 676 | self.type_ = type_ 677 | 678 | def __repr__(self): 679 | return '.'.join((self.class_.__name__, self.name)) 680 | 681 | 682 | class Columns(object): 683 | def __init__(self, name, slots): 684 | self._class = None 685 | self._name = name 686 | self._slots = slots 687 | 688 | def __repr__(self): 689 | return '<{} for {} ({})>'.format(self.__class__.__name__, self._name, ', '.join(self._slots)) 690 | 691 | def __getattr__(self, name): 692 | d = {k: v for k, v in _get_all_slots(self._class, include_private=True)} 693 | if name not in d: 694 | raise AttributeError(name) 695 | 696 | return Column(self._class, name, d[name]) 697 | 698 | 699 | def model_init(self, *args, **kwargs): 700 | slots = list(_get_all_slots(self.__class__, include_private=True)) 701 | unmatched_kwargs = set(kwargs.keys()).difference(set(key for key, type_ in slots)) 702 | if unmatched_kwargs: 703 | raise KeyError('Invalid keyword argument(s): %r' % unmatched_kwargs) 704 | 705 | for key, type_ in slots: 706 | _set_attribute(self, key, type_, kwargs.get(key, None)) 707 | 708 | # Call redirected constructor 709 | if '__minidb_init__' in self.__class__.__dict__: 710 | # Any keyword arguments that are not the primary key ("id") or any of the slots 711 | # will be passed to the __init__() function of the class, all other attributes 712 | # will have already been initialized/set by the time __init__() is called. 713 | kwargs = {k: v for k, v in kwargs.items() 714 | if k != Store.PRIMARY_KEY[0] and k not in self.__class__.__minidb_slots__} 715 | getattr(self, '__minidb_init__')(*args, **kwargs) 716 | 717 | 718 | class MetaModel(type): 719 | @classmethod 720 | def __prepare__(metacls, name, bases): 721 | return collections.OrderedDict() 722 | 723 | def __new__(mcs, name, bases, d): 724 | # Redirect __init__() to __minidb_init__() 725 | if '__init__' in d: 726 | d['__minidb_init__'] = d['__init__'] 727 | d['__init__'] = model_init 728 | 729 | # Caching of live objects 730 | d['__minidb_cache__'] = weakref.WeakValueDictionary() 731 | 732 | slots = collections.OrderedDict((k, v) for k, v in d.items() 733 | if k.lower() == k and 734 | not k.startswith('__') and 735 | not isinstance(v, types.FunctionType) and 736 | not isinstance(v, property) and 737 | not isinstance(v, staticmethod) and 738 | not isinstance(v, classmethod)) 739 | 740 | keep = collections.OrderedDict((k, v) for k, v in d.items() if k not in slots) 741 | keep['__minidb_slots__'] = slots 742 | 743 | keep['__slots__'] = tuple(slots.keys()) 744 | if not bases: 745 | # Add weakref slot to Model (for caching) 746 | keep['__slots__'] += ('__weakref__',) 747 | 748 | columns = Columns(name, slots) 749 | keep['c'] = columns 750 | 751 | result = type.__new__(mcs, name, bases, keep) 752 | columns._class = result 753 | return result 754 | 755 | 756 | def pformat(result, color=False): 757 | def incolor(color_id, s): 758 | return '\033[9%dm%s\033[0m' % (color_id, s) if sys.stdout.isatty() and color else s 759 | 760 | inred, ingreen, inyellow, inblue = (functools.partial(incolor, x) for x in range(1, 5)) 761 | 762 | rows = list(result) 763 | if not rows: 764 | return '(no rows)' 765 | 766 | def colorvalue(formatted, value): 767 | if value is None: 768 | return inred(formatted) 769 | if isinstance(value, bool): 770 | return ingreen(formatted) 771 | 772 | return formatted 773 | 774 | s = [] 775 | keys = rows[0].keys() 776 | lengths = tuple(max(x) for x in zip(*[[len(str(column)) for column in row] for row in [keys] + rows])) 777 | s.append(' | '.join(inyellow('%-{}s'.format(length) % key) for key, length in zip(keys, lengths))) 778 | s.append('-+-'.join('-' * length for length in lengths)) 779 | for row in rows: 780 | s.append(' | '.join(colorvalue('%-{}s'.format(length) % col, col) for col, length in zip(row, lengths))) 781 | s.append('({} row(s))'.format(len(rows))) 782 | return ('\n'.join(s)) 783 | 784 | 785 | def pprint(result, color=False): 786 | print(pformat(result, color)) 787 | 788 | 789 | class JSON(object): 790 | ... 791 | 792 | 793 | @converter_for(JSON) 794 | def convert_json(v, serialize): 795 | return json.dumps(v) if serialize else json.loads(v) 796 | 797 | 798 | @converter_for(datetime.datetime) 799 | def convert_datetime_datetime(v, serialize): 800 | """ 801 | >>> convert_datetime_datetime(datetime.datetime(2014, 12, 13, 14, 15), True) 802 | '2014-12-13T14:15:00' 803 | >>> convert_datetime_datetime('2014-12-13T14:15:16', False) 804 | datetime.datetime(2014, 12, 13, 14, 15, 16) 805 | """ 806 | if serialize: 807 | return v.isoformat() 808 | else: 809 | isoformat, microseconds = (v.rsplit('.', 1) if '.' in v else (v, 0)) 810 | return (datetime.datetime.strptime(isoformat, '%Y-%m-%dT%H:%M:%S') + 811 | datetime.timedelta(microseconds=int(microseconds))) 812 | 813 | 814 | @converter_for(datetime.date) 815 | def convert_datetime_date(v, serialize): 816 | """ 817 | >>> convert_datetime_date(datetime.date(2014, 12, 13), True) 818 | '2014-12-13' 819 | >>> convert_datetime_date('2014-12-13', False) 820 | datetime.date(2014, 12, 13) 821 | """ 822 | if serialize: 823 | return v.isoformat() 824 | else: 825 | return datetime.datetime.strptime(v, '%Y-%m-%d').date() 826 | 827 | 828 | @converter_for(datetime.time) 829 | def convert_datetime_time(v, serialize): 830 | """ 831 | >>> convert_datetime_time(datetime.time(14, 15, 16), True) 832 | '14:15:16' 833 | >>> convert_datetime_time('14:15:16', False) 834 | datetime.time(14, 15, 16) 835 | """ 836 | if serialize: 837 | return v.isoformat() 838 | else: 839 | isoformat, microseconds = (v.rsplit('.', 1) if '.' in v else (v, 0)) 840 | return (datetime.datetime.strptime(isoformat, '%H:%M:%S') + 841 | datetime.timedelta(microseconds=int(microseconds))).time() 842 | 843 | 844 | class Model(metaclass=MetaModel): 845 | id = int 846 | _minidb = Store 847 | 848 | @classmethod 849 | def _finalize(cls, id): 850 | if DEBUG_OBJECT_CACHE: 851 | logger.debug('Finalizing {} id={}'.format(cls.__name__, id)) 852 | 853 | def __repr__(self): 854 | def get_attrs(): 855 | for key, type_ in _get_all_slots(self.__class__): 856 | yield key, getattr(self, key, None) 857 | 858 | attrs = ['{key}={value!r}'.format(key=key, value=value) for key, value in get_attrs()] 859 | return '<%(cls)s(%(attrs)s)>' % { 860 | 'cls': self.__class__.__name__, 861 | 'attrs': ', '.join(attrs), 862 | } 863 | 864 | @classmethod 865 | def __lookup_single(cls, o): 866 | if o is None: 867 | return None 868 | 869 | cache = cls.__minidb_cache__ 870 | if o.id not in cache: 871 | if DEBUG_OBJECT_CACHE: 872 | logger.debug('Storing id={} in cache {}'.format(o.id, o)) 873 | weakref.finalize(o, cls._finalize, o.id) 874 | cache[o.id] = o 875 | else: 876 | if DEBUG_OBJECT_CACHE: 877 | logger.debug('Getting id={} from cache'.format(o.id)) 878 | return cache[o.id] 879 | 880 | @classmethod 881 | def __lookup_cache(cls, objects): 882 | for o in objects: 883 | yield cls.__lookup_single(o) 884 | 885 | @classmethod 886 | def load(cls, db, query=None, **kwargs): 887 | if query is not None: 888 | kwargs['__query__'] = query 889 | if '__minidb_init__' in cls.__dict__: 890 | @functools.wraps(cls.__minidb_init__) 891 | def init_wrapper(*args): 892 | return cls.__lookup_cache(db.load(cls, *args, **kwargs)) 893 | return init_wrapper 894 | else: 895 | return cls.__lookup_cache(db.load(cls, **kwargs)) 896 | 897 | @classmethod 898 | def get(cls, db, query=None, **kwargs): 899 | if query is not None: 900 | kwargs['__query__'] = query 901 | if '__minidb_init__' in cls.__dict__: 902 | @functools.wraps(cls.__minidb_init__) 903 | def init_wrapper(*args): 904 | return cls.__lookup_single(db.get(cls, *args, **kwargs)) 905 | return init_wrapper 906 | else: 907 | return cls.__lookup_single(db.get(cls, **kwargs)) 908 | 909 | def save(self, db=None): 910 | if getattr(self, Store.MINIDB_ATTR, None) is None: 911 | if db is None: 912 | raise ValueError('Needs a db object') 913 | setattr(self, Store.MINIDB_ATTR, db) 914 | 915 | getattr(self, Store.MINIDB_ATTR).save_or_update(self) 916 | 917 | if DEBUG_OBJECT_CACHE: 918 | logger.debug('Storing id={} in cache {}'.format(self.id, self)) 919 | weakref.finalize(self, self.__class__._finalize, self.id) 920 | self.__class__.__minidb_cache__[self.id] = self 921 | 922 | return self 923 | 924 | def delete(self): 925 | if getattr(self, Store.MINIDB_ATTR) is None: 926 | raise ValueError('Needs a db object') 927 | elif self.id is None: 928 | raise KeyError('id is None (not stored in db?)') 929 | 930 | # drop from cache 931 | cache = self.__class__.__minidb_cache__ 932 | if self.id in cache: 933 | if DEBUG_OBJECT_CACHE: 934 | logger.debug('Dropping id={} from cache {}'.format(self.id, self)) 935 | del cache[self.id] 936 | 937 | getattr(self, Store.MINIDB_ATTR).delete_by_pk(self) 938 | 939 | @classmethod 940 | def delete_where(cls, db, query): 941 | return db.delete_where(cls, query) 942 | 943 | @classmethod 944 | def query(cls, db, select=None, where=None, order_by=None, group_by=None, limit=None): 945 | return db.query(cls, select=select, where=where, order_by=order_by, group_by=group_by, limit=limit) 946 | 947 | @classmethod 948 | def pquery(cls, db, select=None, where=None, order_by=None, group_by=None, limit=None, color=True): 949 | pprint(db.query(cls, select=select, where=where, order_by=order_by, group_by=group_by, limit=limit), color) 950 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | with-doctest = true 3 | cover-erase = true 4 | with-coverage = true 5 | cover-package = minidb 6 | 7 | [flake8] 8 | max-line-length = 120 9 | # We use "!= None" for operator-overloaded SQL, cannot use "is not None" 10 | ignore = E711,W504,E731 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Setup script for 3 | 'minidb' 4 | # by Thomas Perl 5 | 6 | import os 7 | import re 8 | from setuptools import setup 9 | 10 | dirname = os.path.dirname(os.path.abspath(__file__)) 11 | src = open(os.path.join(dirname, '{}.py'.format(__doc__))).read() 12 | docstrings = re.findall('"""(.*)"""', src) 13 | m = dict(re.findall(r"__([a-z_]+)__\s*=\s*'([^']+)'", src)) 14 | m['name'] = __doc__ 15 | m['author'], m['author_email'] = re.match(r'(.*) <(.*)>', m['author']).groups() 16 | m['description'] = docstrings[0] 17 | m['py_modules'] = (m['name'],) 18 | m['download_url'] = '{m[url]}{m[name]}-{m[version]}.tar.gz'.format(m=m) 19 | 20 | setup(**m) 21 | -------------------------------------------------------------------------------- /test/test_minidb.py: -------------------------------------------------------------------------------- 1 | import concurrent.futures 2 | import minidb 3 | import pytest 4 | import datetime 5 | 6 | 7 | class FieldTest(minidb.Model): 8 | CONSTANT = 123 9 | 10 | # Persisted 11 | column1 = str 12 | column2 = int 13 | column3 = float 14 | column4 = bool 15 | 16 | # Not persisted per-instance attribute 17 | _private1 = object 18 | _private2 = str 19 | _private3 = int 20 | _private4 = object 21 | 22 | # Class attributes 23 | __class_attribute1__ = 'Hello' 24 | __class_attribute2__ = ['World'] 25 | 26 | def __init__(self, constructor_arg): 27 | self._private1 = constructor_arg 28 | self._private2 = 'private' 29 | self._private3 = self.CONSTANT 30 | self._private4 = None 31 | 32 | @classmethod 33 | def a_classmethod(cls): 34 | return 'classmethod' 35 | 36 | @staticmethod 37 | def a_staticmethod(): 38 | return 'staticmethod' 39 | 40 | def a_membermethod(self): 41 | return self._private1 42 | 43 | @property 44 | def a_read_only_property(self): 45 | return self._private2.upper() 46 | 47 | @property 48 | def a_read_write_property(self): 49 | return self._private3 50 | 51 | @a_read_write_property.setter 52 | def read_write_property(self, new_value): 53 | self._private3 = new_value 54 | 55 | 56 | class FieldConversion(minidb.Model): 57 | integer = int 58 | floating = float 59 | boolean = bool 60 | string = str 61 | jsoninteger = minidb.JSON 62 | jsonfloating = minidb.JSON 63 | jsonboolean = minidb.JSON 64 | jsonstring = minidb.JSON 65 | jsonlist = minidb.JSON 66 | jsondict = minidb.JSON 67 | jsonnone = minidb.JSON 68 | 69 | @classmethod 70 | def create(cls): 71 | return cls(integer=1, floating=1.1, boolean=True, string='test', 72 | jsonlist=[1, 2], jsondict={'a': 1}, jsonnone=None, 73 | jsoninteger=1, jsonfloating=1.1, jsonboolean=True, 74 | jsonstring='test') 75 | 76 | 77 | def test_instantiate_fieldtest_from_code(): 78 | field_test = FieldTest(999) 79 | assert field_test.id is None 80 | assert field_test.column1 is None 81 | assert field_test.column2 is None 82 | assert field_test.column3 is None 83 | assert field_test.column4 is None 84 | assert field_test._private1 == 999 85 | assert field_test._private2 is not None 86 | assert field_test._private3 is not None 87 | assert field_test._private4 is None 88 | 89 | 90 | def test_saving_object_stores_id(): 91 | with minidb.Store(debug=True) as db: 92 | db.register(FieldTest) 93 | field_test = FieldTest(998) 94 | assert field_test.id is None 95 | field_test.save(db) 96 | assert field_test.id is not None 97 | 98 | 99 | def test_loading_object_returns_cached_object(): 100 | with minidb.Store(debug=True) as db: 101 | db.register(FieldTest) 102 | field_test = FieldTest(9999) 103 | field_test._private1 = 4711 104 | assert field_test.id is None 105 | field_test.save(db) 106 | assert field_test.id is not None 107 | field_test_loaded = FieldTest.get(db, id=field_test.id)(9999) 108 | assert field_test_loaded._private1 == 4711 109 | assert field_test_loaded is field_test 110 | 111 | 112 | def test_loading_object_returns_new_object_after_reference_drop(): 113 | with minidb.Store(debug=True) as db: 114 | db.register(FieldTest) 115 | field_test = FieldTest(9999) 116 | field_test._private1 = 4711 117 | assert field_test.id is None 118 | field_test.save(db) 119 | assert field_test.id is not None 120 | field_test_id = field_test.id 121 | del field_test 122 | 123 | field_test_loaded = FieldTest.get(db, id=field_test_id)(9999) 124 | assert field_test_loaded._private1 == 9999 125 | 126 | 127 | def test_loading_objects(): 128 | with minidb.Store(debug=True) as db: 129 | db.register(FieldTest) 130 | for i in range(100): 131 | FieldTest(i).save(db) 132 | 133 | assert next(FieldTest.c.id.count('count').query(db)).count == 100 134 | 135 | for field_test in FieldTest.load(db)(997): 136 | assert field_test.id is not None 137 | assert field_test._private1 == 997 138 | 139 | 140 | def test_saving_without_registration_fails(): 141 | with pytest.raises(minidb.UnknownClass): 142 | with minidb.Store(debug=True) as db: 143 | FieldTest(9).save(db) 144 | 145 | 146 | def test_registering_non_subclass_of_model_fails(): 147 | # This cannot be registered, as it's not a subclass of minidb.Model 148 | with pytest.raises(TypeError): 149 | class Something(object): 150 | column = str 151 | 152 | with minidb.Store(debug=True) as db: 153 | db.register(Something) 154 | db.register(Something) 155 | 156 | 157 | def test_invalid_keyword_arguments_fails(): 158 | with pytest.raises(KeyError): 159 | with minidb.Store(debug=True) as db: 160 | db.register(FieldTest) 161 | FieldTest(9, this_is_not_an_attribute=123).save(db) 162 | 163 | 164 | def test_invalid_column_raises_attribute_error(): 165 | with pytest.raises(AttributeError): 166 | class HasOnlyColumnX(minidb.Model): 167 | x = int 168 | 169 | with minidb.Store(debug=True) as db: 170 | db.register(HasOnlyColumnX) 171 | HasOnlyColumnX.c.y 172 | 173 | 174 | def test_json_serialization(): 175 | class WithJsonField(minidb.Model): 176 | foo = str 177 | bar = minidb.JSON 178 | 179 | with minidb.Store(debug=True) as db: 180 | db.register(WithJsonField) 181 | d = {'a': 1, 'b': [1, 2, 3], 'c': [True, 4.0, {'d': 'e'}]} 182 | WithJsonField(bar=d).save(db) 183 | assert WithJsonField.get(db, id=1).bar == d 184 | 185 | 186 | def test_json_field_query(): 187 | class WithJsonField(minidb.Model): 188 | bar = minidb.JSON 189 | 190 | with minidb.Store(debug=True) as db: 191 | db.register(WithJsonField) 192 | d = {'a': [1, True, 3.9]} 193 | WithJsonField(bar=d).save(db) 194 | assert next(WithJsonField.c.bar.query(db)).bar == d 195 | 196 | 197 | def test_json_field_renamed_query(): 198 | class WithJsonField(minidb.Model): 199 | bar = minidb.JSON 200 | 201 | with minidb.Store(debug=True) as db: 202 | db.register(WithJsonField) 203 | d = {'a': [1, True, 3.9]} 204 | WithJsonField(bar=d).save(db) 205 | assert next(WithJsonField.c.bar('renamed').query(db)).renamed == d 206 | 207 | 208 | def test_field_conversion_get_object(): 209 | with minidb.Store(debug=True) as db: 210 | db.register(FieldConversion) 211 | FieldConversion.create().save(db) 212 | result = FieldConversion.get(db, id=1) 213 | assert isinstance(result.integer, int) 214 | assert isinstance(result.floating, float) 215 | assert isinstance(result.boolean, bool) 216 | assert isinstance(result.string, str) 217 | assert isinstance(result.jsoninteger, int) 218 | assert isinstance(result.jsonfloating, float) 219 | assert isinstance(result.jsonboolean, bool) 220 | assert isinstance(result.jsonstring, str) 221 | assert isinstance(result.jsonlist, list) 222 | assert isinstance(result.jsondict, dict) 223 | assert result.jsonnone is None 224 | 225 | 226 | def test_field_conversion_query_select_star(): 227 | with minidb.Store(debug=True) as db: 228 | db.register(FieldConversion) 229 | FieldConversion.create().save(db) 230 | result = next(FieldConversion.query(db, minidb.literal('*'))) 231 | assert isinstance(result.integer, int) 232 | assert isinstance(result.floating, float) 233 | assert isinstance(result.boolean, bool) 234 | assert isinstance(result.string, str) 235 | assert isinstance(result.jsoninteger, int) 236 | assert isinstance(result.jsonfloating, float) 237 | assert isinstance(result.jsonboolean, bool) 238 | assert isinstance(result.jsonstring, str) 239 | assert isinstance(result.jsonlist, list) 240 | assert isinstance(result.jsondict, dict) 241 | assert result.jsonnone is None 242 | 243 | 244 | def test_storing_and_retrieving_booleans(): 245 | class BooleanModel(minidb.Model): 246 | value = bool 247 | 248 | with minidb.Store(debug=True) as db: 249 | db.register(BooleanModel) 250 | true_id = BooleanModel(value=True).save(db).id 251 | false_id = BooleanModel(value=False).save(db).id 252 | assert BooleanModel.get(db, id=true_id).value is True 253 | assert BooleanModel.get(db, BooleanModel.c.id == true_id).value is True 254 | assert BooleanModel.get(db, lambda c: c.id == true_id).value is True 255 | assert BooleanModel.get(db, id=false_id).value is False 256 | assert next(BooleanModel.c.value.query(db, where=lambda c: c.id == true_id)).value is True 257 | assert next(BooleanModel.c.value.query(db, where=lambda c: c.id == false_id)).value is False 258 | 259 | 260 | def test_storing_and_retrieving_floats(): 261 | class FloatModel(minidb.Model): 262 | value = float 263 | 264 | with minidb.Store(debug=True) as db: 265 | db.register(FloatModel) 266 | float_id = FloatModel(value=3.1415).save(db).id 267 | get_value = FloatModel.get(db, id=float_id).value 268 | assert isinstance(get_value, float) 269 | assert get_value == 3.1415 270 | query_value = next(FloatModel.c.value.query(db, where=lambda c: c.id == float_id)).value 271 | assert isinstance(query_value, float) 272 | assert query_value == 3.1415 273 | 274 | 275 | def test_storing_and_retrieving_bytes(): 276 | # http://probablyprogramming.com/2009/03/15/the-tiniest-gif-ever 277 | BLOB = (b'GIF89a\x01\x00\x01\x00\x80\x01\x00\xff\xff\xff\x00\x00\x00' + 278 | b'!\xf9\x04\x01\n\x00\x01\x00,\x00\x00\x00\x00\x01\x00\x01' + 279 | b'\x00\x00\x02\x02L\x01\x00;') 280 | 281 | class BytesModel(minidb.Model): 282 | value = bytes 283 | 284 | with minidb.Store(debug=True) as db: 285 | db.register(BytesModel) 286 | bytes_id = BytesModel(value=BLOB).save(db).id 287 | get_value = BytesModel.get(db, id=bytes_id).value 288 | assert isinstance(get_value, bytes) 289 | assert get_value == BLOB 290 | query_value = next(BytesModel.c.value.query(db, where=lambda c: c.id == bytes_id)).value 291 | assert isinstance(query_value, bytes) 292 | assert query_value == BLOB 293 | 294 | 295 | def test_get_with_multiple_value_raises_exception(): 296 | with pytest.raises(ValueError): 297 | class Mod(minidb.Model): 298 | mod = str 299 | 300 | with minidb.Store(debug=True) as db: 301 | db.register(Mod) 302 | Mod(mod='foo').save(db) 303 | Mod(mod='foo').save(db) 304 | Mod.get(db, mod='foo') 305 | 306 | 307 | def test_get_with_no_value_returns_none(): 308 | class Mod(minidb.Model): 309 | mod = str 310 | 311 | with minidb.Store(debug=True) as db: 312 | db.register(Mod) 313 | assert Mod.get(db, mod='foo') is None 314 | 315 | 316 | def test_delete_where(): 317 | class DeleteWhere(minidb.Model): 318 | v = int 319 | 320 | with minidb.Store(debug=True) as db: 321 | db.register(DeleteWhere) 322 | 323 | for i in range(10): 324 | DeleteWhere(v=i).save(db) 325 | 326 | assert DeleteWhere.delete_where(db, lambda c: c.v < 2) == len({0, 1}) 327 | assert DeleteWhere.delete_where(db, DeleteWhere.c.v > 5) == len({6, 7, 8, 9}) 328 | assert {2, 3, 4, 5} == {v for (v,) in DeleteWhere.c.v.query(db)} 329 | 330 | 331 | def test_invalid_rowproxy_access_by_attribute(): 332 | with pytest.raises(AttributeError): 333 | class Foo(minidb.Model): 334 | bar = str 335 | 336 | with minidb.Store(debug=True) as db: 337 | db.register(Foo) 338 | Foo(bar='baz').save(db) 339 | next(Foo.query(db, Foo.c.bar)).baz 340 | 341 | 342 | def test_invalid_rowproxy_access_by_key(): 343 | with pytest.raises(KeyError): 344 | class Foo(minidb.Model): 345 | bar = str 346 | 347 | with minidb.Store(debug=True) as db: 348 | db.register(Foo) 349 | Foo(bar='baz').save(db) 350 | next(Foo.query(db, Foo.c.bar))['baz'] 351 | 352 | 353 | def test_use_schema_without_registration_raises_typeerror(): 354 | with pytest.raises(TypeError): 355 | with minidb.Store(debug=True) as db: 356 | class Foo(minidb.Model): 357 | bar = str 358 | Foo.query(db) 359 | 360 | 361 | def test_use_schema_with_nonidentity_class_raises_typeerror(): 362 | with pytest.raises(TypeError): 363 | with minidb.Store(debug=True) as db: 364 | class Foo(minidb.Model): 365 | bar = str 366 | db.register(Foo) 367 | 368 | class Foo(minidb.Model): 369 | bar = str 370 | 371 | Foo.query(db) 372 | 373 | 374 | def test_upgrade_schema_without_upgrade_raises_typeerror(): 375 | with pytest.raises(TypeError): 376 | with minidb.Store(debug=True) as db: 377 | class Foo(minidb.Model): 378 | bar = str 379 | 380 | db.register(Foo) 381 | 382 | class Foo(minidb.Model): 383 | bar = str 384 | baz = int 385 | 386 | db.register(Foo) 387 | 388 | 389 | def test_reregistering_class_raises_typeerror(): 390 | with pytest.raises(TypeError): 391 | class Foo(minidb.Model): 392 | bar = int 393 | 394 | with minidb.Store(debug=True) as db: 395 | db.register(Foo) 396 | db.register(Foo) 397 | 398 | 399 | def test_upgrade_schema_with_upgrade_succeeds(): 400 | with minidb.Store(debug=True) as db: 401 | class Foo(minidb.Model): 402 | bar = str 403 | 404 | db.register(Foo) 405 | 406 | class Foo(minidb.Model): 407 | bar = str 408 | baz = int 409 | 410 | db.register(Foo, upgrade=True) 411 | 412 | 413 | def test_upgrade_schema_with_different_type_raises_typeerror(): 414 | with pytest.raises(TypeError): 415 | with minidb.Store(debug=True) as db: 416 | class Foo(minidb.Model): 417 | bar = str 418 | 419 | db.register(Foo) 420 | 421 | class Foo(minidb.Model): 422 | bar = int 423 | 424 | db.register(Foo, upgrade=True) 425 | 426 | 427 | def test_update_object(): 428 | class Foo(minidb.Model): 429 | bar = str 430 | 431 | with minidb.Store(debug=True) as db: 432 | db.register(Foo) 433 | a = Foo(bar='a').save(db) 434 | b = Foo(bar='b').save(db) 435 | 436 | a.bar = 'c' 437 | a.save() 438 | 439 | b.bar = 'd' 440 | b.save() 441 | 442 | assert {'c', 'd'} == {bar for (bar,) in Foo.c.bar.query(db)} 443 | 444 | 445 | def test_delete_object(): 446 | class Foo(minidb.Model): 447 | bar = int 448 | 449 | with minidb.Store(debug=True) as db: 450 | db.register(Foo) 451 | for i in range(3): 452 | Foo(bar=i).save(db) 453 | 454 | Foo.get(db, bar=2).delete() 455 | 456 | assert {0, 1} == {bar for (bar,) in Foo.c.bar.query(db)} 457 | 458 | 459 | def test_distinct(): 460 | class Foo(minidb.Model): 461 | bar = str 462 | baz = int 463 | 464 | with minidb.Store(debug=True) as db: 465 | db.register(Foo) 466 | 467 | for i in range(2): 468 | Foo(bar='hi', baz=i).save(db) 469 | 470 | Foo(bar='ho', baz=7).save(db) 471 | 472 | expected = {('hi',), ('ho',)} 473 | 474 | # minidb.func.distinct(COLUMN)(NAME) 475 | result = {tuple(x) for x in Foo.query(db, lambda c: minidb.func.distinct(c.bar)('foo'))} 476 | assert result == expected 477 | 478 | # COLUMN.distinct(NAME) 479 | result = {tuple(x) for x in Foo.query(db, Foo.c.bar.distinct('foo'))} 480 | assert result == expected 481 | 482 | 483 | def test_group_by_with_sum(): 484 | class Foo(minidb.Model): 485 | bar = str 486 | baz = int 487 | 488 | with minidb.Store(debug=True) as db: 489 | db.register(Foo) 490 | 491 | for i in range(5): 492 | Foo(bar='hi', baz=i).save(db) 493 | 494 | for i in range(6): 495 | Foo(bar='ho', baz=i).save(db) 496 | 497 | expected = {('hi', sum(range(5))), ('ho', sum(range(6)))} 498 | 499 | # minidb.func.sum(COLUMN)(NAME) 500 | result = {tuple(x) for x in Foo.query(db, lambda c: c.bar // 501 | minidb.func.sum(c.baz)('sum'), group_by=lambda c: c.bar)} 502 | assert result == expected 503 | 504 | # COLUMN.sum(NAME) 505 | result = {tuple(x) for x in Foo.query(db, lambda c: c.bar // 506 | c.baz.sum('sum'), group_by=lambda c: c.bar)} 507 | assert result == expected 508 | 509 | 510 | def test_save_without_db_raises_valueerror(): 511 | with pytest.raises(ValueError): 512 | class Foo(minidb.Model): 513 | bar = int 514 | 515 | Foo(bar=99).save() 516 | 517 | 518 | def test_delete_without_db_raises_valueerror(): 519 | with pytest.raises(ValueError): 520 | class Foo(minidb.Model): 521 | bar = int 522 | 523 | Foo(bar=99).delete() 524 | 525 | 526 | def test_double_delete_without_id_raises_valueerror(): 527 | with pytest.raises(KeyError): 528 | class Foo(minidb.Model): 529 | bar = str 530 | 531 | with minidb.Store(debug=True) as db: 532 | db.register(Foo) 533 | a = Foo(bar='hello') 534 | a.save(db) 535 | assert a.id is not None 536 | a.delete() 537 | assert a.id is None 538 | a.delete() 539 | 540 | 541 | def test_default_values_are_set_if_none(): 542 | class Foo(minidb.Model): 543 | name = str 544 | 545 | class __minidb_defaults__: 546 | name = 'Bob' 547 | 548 | f = Foo() 549 | assert f.name == 'Bob' 550 | 551 | f = Foo(name='John') 552 | assert f.name == 'John' 553 | 554 | 555 | def test_default_values_with_callable(): 556 | class Foo(minidb.Model): 557 | name = str 558 | email = str 559 | 560 | # Defaults are applied in order of slots of the Model 561 | # subclass, so if e.g. email depends on name to be 562 | # set, make sure email appears *after* name in the model 563 | class __minidb_defaults__: 564 | name = lambda o: 'Bob' 565 | email = lambda o: o.name + '@example.com' 566 | 567 | f = Foo() 568 | assert f.name == 'Bob' 569 | assert f.email == 'Bob@example.com' 570 | 571 | f = Foo(name='John') 572 | assert f.name == 'John' 573 | assert f.email == 'John@example.com' 574 | 575 | f = Foo(name='Joe', email='joe@example.net') 576 | assert f.name == 'Joe' 577 | assert f.email == 'joe@example.net' 578 | 579 | 580 | def test_storing_and_retrieving_datetime(): 581 | DT_NOW = datetime.datetime.now() 582 | D_TODAY = datetime.date.today() 583 | T_NOW = datetime.datetime.now().time() 584 | 585 | class DateTimeModel(minidb.Model): 586 | dt = datetime.datetime 587 | da = datetime.date 588 | tm = datetime.time 589 | 590 | with minidb.Store(debug=True) as db: 591 | db.register(DateTimeModel) 592 | datetime_id = DateTimeModel(dt=DT_NOW, da=D_TODAY, tm=T_NOW).save(db).id 593 | get_value = DateTimeModel.get(db, id=datetime_id) 594 | assert isinstance(get_value.dt, datetime.datetime) 595 | assert get_value.dt == DT_NOW 596 | assert isinstance(get_value.da, datetime.date) 597 | assert get_value.da == D_TODAY 598 | assert isinstance(get_value.tm, datetime.time) 599 | assert get_value.tm == T_NOW 600 | query_value = next(DateTimeModel.query(db, lambda c: c.dt // c.da // c.tm, 601 | where=lambda c: c.id == datetime_id)) 602 | assert isinstance(query_value.dt, datetime.datetime) 603 | assert query_value.dt == DT_NOW 604 | assert isinstance(query_value.da, datetime.date) 605 | assert query_value.da == D_TODAY 606 | assert isinstance(query_value.tm, datetime.time) 607 | assert query_value.tm == T_NOW 608 | 609 | 610 | def test_query_with_datetime(): 611 | DT_NOW = datetime.datetime.now() 612 | 613 | class DateTimeModel(minidb.Model): 614 | dt = datetime.datetime 615 | 616 | with minidb.Store(debug=True) as db: 617 | db.register(DateTimeModel) 618 | datetime_id = DateTimeModel(dt=DT_NOW).save(db).id 619 | assert DateTimeModel.get(db, lambda c: c.dt == DT_NOW).id == datetime_id 620 | 621 | 622 | def test_custom_converter(): 623 | class Point(object): 624 | def __init__(self, x, y): 625 | self.x = x 626 | self.y = y 627 | 628 | @minidb.converter_for(Point) 629 | def convert_point(v, serialize): 630 | if serialize: 631 | return ','.join(str(x) for x in (v.x, v.y)) 632 | else: 633 | return Point(*(float(x) for x in v.split(','))) 634 | 635 | class Player(minidb.Model): 636 | name = str 637 | position = Point 638 | 639 | with minidb.Store(debug=True) as db: 640 | db.register(Player) 641 | p = Point(1.12, 5.99) 642 | player_id = Player(name='Foo', position=p).save(db).id 643 | get_value = Player.get(db, id=player_id) 644 | assert isinstance(get_value.position, Point) 645 | assert (get_value.position.x, get_value.position.y) == (p.x, p.y) 646 | query_value = next(Player.query(db, lambda c: c.position, 647 | where=lambda c: c.id == player_id)) 648 | assert isinstance(query_value.position, Point) 649 | assert (query_value.position.x, query_value.position.y) == (p.x, p.y) 650 | 651 | 652 | def test_delete_all(): 653 | class Thing(minidb.Model): 654 | bla = str 655 | blubb = int 656 | 657 | with minidb.Store(debug=True) as db: 658 | db.register(Thing) 659 | 660 | db.save(Thing(bla='a', blubb=123)) 661 | db.save(Thing(bla='c', blubb=456)) 662 | assert db.count_rows(Thing) == 2 663 | 664 | db.delete_all(Thing) 665 | assert db.count_rows(Thing) == 0 666 | 667 | 668 | def test_threaded_query(): 669 | class Thing(minidb.Model): 670 | s = str 671 | i = int 672 | 673 | with minidb.Store(debug=True, vacuum_on_close=False) as db: 674 | db.register(Thing) 675 | 676 | for i in range(100): 677 | db.save(Thing(s=str(i), i=i)) 678 | 679 | def query(i): 680 | things = list(Thing.query(db, Thing.c.s // Thing.c.i, where=Thing.c.i == i)) 681 | assert len(things) == 1 682 | 683 | thing = things[0] 684 | assert thing is not None 685 | assert thing.s == str(i) 686 | assert thing.i == i 687 | 688 | return i 689 | 690 | executor = concurrent.futures.ThreadPoolExecutor(max_workers=10) 691 | # Wrap in list to resolve all the futures 692 | list(executor.map(query, range(100))) 693 | --------------------------------------------------------------------------------