├── .editorconfig ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── docker-compose.yml ├── docs ├── Makefile └── source │ ├── _templates │ └── github-corners.html │ ├── conf.py │ ├── index.rst │ └── reference.rst ├── img ├── numberly.png └── refty.png ├── mongo_thingy ├── __init__.py ├── camelcase.py ├── cursor.py └── versioned.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── camelcase.py ├── conftest.py ├── cursor.py └── versioned.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | indent_style = space 8 | 9 | [*.py] 10 | max_line_length = 89 11 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "pypy-3.7", "pypy-3.8", "pypy-3.9"] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-python@v2 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - run: pip install coveralls && cat requirements.txt | xargs -n 1 pip install || true 21 | - run: docker compose up -d 22 | - run: pytest 23 | - run: coveralls --service=github 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | COVERALLS_FLAG_NAME: ${{ matrix.python-version }} 27 | COVERALLS_PARALLEL: true 28 | 29 | finalize: 30 | needs: test 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: coverallsapp/github-action@master 34 | with: 35 | github-token: ${{ secrets.GITHUB_TOKEN }} 36 | parallel-finished: true 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | # C extensions 3 | *.so 4 | 5 | # Packages 6 | *.egg 7 | *.egg-info 8 | dist 9 | build 10 | eggs 11 | parts 12 | bin 13 | var 14 | sdist 15 | develop-eggs 16 | .installed.cfg 17 | lib 18 | lib64 19 | __pycache__ 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | pytestdebug.log 29 | .cache/ 30 | htmlcov/ 31 | 32 | # Translations 33 | *.mo 34 | 35 | # Mr Developer 36 | .mr.developer.cfg 37 | .project 38 | .pydevproject 39 | .ropeproject/ 40 | 41 | # Vim / Emacs 42 | *.s[a-w][a-z] 43 | *.un~ 44 | Session.vim 45 | .netrwhist 46 | *~ 47 | *\#* 48 | 49 | # SublimeText 50 | *.sublime-workspace 51 | 52 | # PyCharm 53 | .idea 54 | .iml 55 | 56 | # virtualenv 57 | .venv* 58 | src/ 59 | 60 | # MontyDB 61 | .monty.storage 62 | monty.storage.cfg 63 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 22.3.0 4 | hooks: 5 | - id: black 6 | - repo: https://github.com/pycqa/isort 7 | rev: 5.12.0 8 | hooks: 9 | - id: isort 10 | args: ["--profile", "black", "--filter-files"] 11 | - repo: https://github.com/pycqa/flake8 12 | rev: 3.9.2 13 | hooks: 14 | - id: flake8 15 | - repo: https://github.com/editorconfig-checker/editorconfig-checker.python 16 | rev: 2.3.5 17 | hooks: 18 | - id: editorconfig-checker 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2019 Numberly 4 | Copyright (c) 2022 Refty 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [pymongo]: https://github.com/mongodb/mongo-python-driver 2 | [thingy]: https://github.com/Refty/thingy 3 | [mongomock]: https://github.com/mongomock/mongomock 4 | [montydb]: https://github.com/davidlatwe/montydb 5 | [motor]: https://github.com/mongodb/motor 6 | [mongomock-motor]: https://github.com/michaelkryukov/mongomock_motor 7 | 8 |  9 | 10 |
21 | 22 | **_Mongo-Thingy_ is the most idiomatic and friendly-yet-powerful way to use 23 | MongoDB with Python.** 24 | 25 | It is an _"Object-Document Mapper"_ that gives you full advantage of MongoDB 26 | schema-less design by **not** asking you to define schemas in your code. 27 | 28 | What you'll get: 29 | 30 | - a simple and robust pure-Python code base, with 100% coverage and few 31 | dependencies; 32 | - [PyMongo][pymongo] query language - no need to learn yet another one; 33 | - both sync and async support! choose what suits you best; 34 | - [Thingy][thingy] views - control what to show, and create fields based on 35 | other fields; 36 | - swappable backend - wanna use SQLite behind the scenes? well, you can; 37 | - versioning *(optional)* - rollback to any point in any thingy history; 38 | - and more! 39 | 40 | # Compatibility 41 | 42 | We support all Python and MongoDB versions supported by [PyMongo][pymongo], 43 | namely: 44 | 45 | - CPython 3.7+ and PyPy3.7+ 46 | - MongoDB 3.6, 4.0, 4.2, 4.4, and 5.0. 47 | 48 | As a backend, Mongo-Thingy supports the following libraries: 49 | 50 | - Synchronous: 51 | 52 | * [PyMongo][pymongo] (default) 53 | * [Mongomock][mongomock] 54 | * [MontyDB][montydb] 55 | 56 | - Asynchronous: 57 | 58 | * [Motor][motor] (default when Motor is installed) 59 | * [Motor][motor] with Tornado (default when Motor and Tornado are installed) 60 | * [Mongomock-Motor][mongomock-motor] 61 | 62 | # Install 63 | 64 | ```sh 65 | pip install mongo-thingy 66 | ``` 67 | 68 | # Examples 69 | 70 | ## First steps 71 | 72 | ### Connect, insert and find thingies 73 | 74 | ```python 75 | >>> from mongo_thingy import connect, Thingy 76 | >>> connect("mongodb://localhost/test") 77 | 78 | >>> class User(Thingy): 79 | ... pass 80 | 81 | >>> user = User({"name": "Mr. Foo", "age": 42}).save() 82 | >>> User.count_documents() 83 | 1 84 | >>> User.find_one({"age": 42}) 85 | User({'_id': ObjectId(...), 'name': 'Mr. Foo', 'age': 42}) 86 | ``` 87 | 88 | In an AsyncIO (or Tornado) environment, use the asynchronous class instead: 89 | 90 | ```python 91 | >>> from mongo_thingy import connect, AsyncThingy 92 | >>> connect("mongodb://localhost/test") 93 | 94 | >>> class User(AsyncThingy): 95 | ... pass 96 | 97 | >>> user = await User({"name": "Mr. Foo", "age": 42}).save() 98 | >>> await User.count_documents() 99 | 1 100 | >>> await User.find_one({"age": 42}) 101 | User({'_id': ObjectId(...), 'name': 'Mr. Foo', 'age': 42}) 102 | ``` 103 | 104 | To use another backend than the default ones, just pass its client class with 105 | ``client_cls``: 106 | 107 | ```python 108 | >>> import mongomock 109 | >>> connect(client_cls=mongomock.MongoClient) 110 | ``` 111 | 112 | ### Update a thingy 113 | 114 | ```python 115 | >>> user.age 116 | 42 117 | >>> user.age = 1337 118 | >>> user.save() 119 | User({'_id': ObjectId(...), 'name': 'Mr. Foo', 'age': 1337}) 120 | ``` 121 | 122 | ## Thingy views power 123 | 124 | ### Complete information with properties 125 | 126 | ```python 127 | >>> class User(Thingy): 128 | ... @property 129 | ... def username(self): 130 | ... return "".join(char for char in self.name if char.isalpha()) 131 | 132 | >>> User.add_view(name="everything", defaults=True, include="username") 133 | >>> user = User.find_one() 134 | >>> user.view("everything") 135 | {'_id': ObjectId(...), 'name': 'Mr. Foo', 'age': 1337, 'username': 'MrFoo'} 136 | ``` 137 | 138 | ### Hide sensitive stuff 139 | 140 | ```python 141 | >>> User.add_view(name="public", defaults=True, exclude="password") 142 | >>> user.password = "t0ps3cr3t" 143 | >>> user.view() 144 | {'_id': ObjectId(...), 'name': 'Mr. Foo', 'age': 1337, 'password': 't0ps3cr3t'} 145 | >>> user.view("public") 146 | {'_id': ObjectId(...), 'name': 'Mr. Foo', 'age': 1337} 147 | ``` 148 | 149 | ### Only use certain fields/properties 150 | 151 | ```python 152 | >>> User.add_view(name="credentials", include=["username", "password"]) 153 | >>> user.view("credentials") 154 | {'username': 'MrFoo', 'password': 't0ps3cr3t'} 155 | ``` 156 | 157 | ### Apply views on cursors 158 | 159 | ```python 160 | >>> cursor = User.find() 161 | >>> for credentials in cursor.view("credentials"): 162 | ... print(credentials) 163 | {'username': 'MrFoo', 'password': 't0ps3cr3t'} 164 | {'username': 'MrsBar', 'password': '123456789'} 165 | ... 166 | ``` 167 | 168 | And if your cursor is already exhausted, you can still apply a view! 169 | 170 | ```python 171 | >>> users = User.find().to_list(None) 172 | >>> for credentials in users.view("credentials"): 173 | ... print(credentials) 174 | {'username': 'MrFoo', 'password': 't0ps3cr3t'} 175 | {'username': 'MrsBar', 'password': '123456789'} 176 | ... 177 | ``` 178 | 179 | ## Versioning 180 | 181 | ```python 182 | >>> from mongo_thingy.versioned import Versioned 183 | 184 | >>> class Article(Versioned, Thingy): 185 | ... pass 186 | 187 | >>> article = Article(content="Cogito ergo sum") 188 | >>> article.version 189 | 0 190 | 191 | >>> article.save() 192 | Article({'_id': ObjectId('...'), 'content': 'Cogito ergo sum'}) 193 | >>> article.version 194 | 1 195 | 196 | >>> article.content = "Sum ergo cogito" 197 | >>> article.save() 198 | Article({'_id': ObjectId('...'), 'content': 'Sum ergo cogito'}) 199 | >>> article.version 200 | 2 201 | 202 | >>> article.revert() 203 | Article({'_id': ObjectId('...'), 'content': 'Cogito ergo sum'}) 204 | >>> article.version 205 | 3 206 | ``` 207 | 208 | ## Database/collection "discovery" 209 | 210 | ### Default behaviour 211 | 212 | ```python 213 | >>> class AuthenticationGroup(Thingy): 214 | ... pass 215 | 216 | >>> connect("mongodb://localhost/") 217 | >>> AuthenticationGroup.collection 218 | Collection(Database(MongoClient(host=['localhost:27017'], ...), 'authentication'), 'group') 219 | ``` 220 | 221 | ### Use mismatching names for Thingy class and database collection 222 | 223 | You can either specify the collection name: 224 | 225 | ```python 226 | >>> class Foo(Thingy): 227 | ... collection_name = "bar" 228 | ``` 229 | 230 | or the collection directly: 231 | 232 | ```python 233 | >>> class Foo(Thingy): 234 | ... collection = db.bar 235 | ``` 236 | 237 | You can then check what collection is being used with: 238 | 239 | ```python 240 | >>> Foo.collection 241 | Collection(Database(MongoClient('localhost', 27017), 'database'), 'bar') 242 | ``` 243 | 244 | ## Indexes 245 | 246 | ### Create an index 247 | 248 | ```python 249 | >>> User.create_index("email", sparse=True, unique=True) 250 | ``` 251 | 252 | ### Add one or more indexes, create later 253 | 254 | ```python 255 | >>> User.add_index("email", sparse=True, unique=True) 256 | >>> User.add_index("username") 257 | 258 | >>> User.create_indexes() 259 | ``` 260 | 261 | ### Create all indexes of all thingies at once 262 | 263 | ```python 264 | >>> from mongo_thingy import create_indexes 265 | >>> create_indexes() 266 | ``` 267 | 268 | ## Dealing with camelCase data 269 | 270 | ```python 271 | >>> from mongo_thingy.camelcase import CamelCase 272 | 273 | >>> class SystemUser(CamelCase, Thingy): 274 | ... collection_name = "systemUsers" 275 | 276 | >>> user = SystemUser.find_one() 277 | >>> user.view() 278 | {'_id': ObjectId(...), 'firstName': 'John', 'lastName': 'Doe'} 279 | 280 | >>> user.first_name 281 | 'John' 282 | >>> user.first_name = "Jonny" 283 | >>> user.save() 284 | SystemUser({'_id': ObjectId(...), firstName: 'Jonny', lastName: 'Doe'}) 285 | ``` 286 | 287 | # Tests 288 | 289 | To run the tests suite: 290 | 291 | - make sure you have a MongoDB database running on `localhost:27017` (you can 292 | spawn one with `docker compose up -d`); 293 | - install developers requirements with `pip install -r requirements.txt`; 294 | - run `pytest`. 295 | 296 | # Sponsors 297 | 298 | 306 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mongodb: 3 | image: bitnami/mongodb:latest 4 | ports: 5 | - "27017:27017" 6 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = Mongo-Thingy 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/_templates/github-corners.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | 5 | # If extensions (or modules to document with autodoc) are in another directory, 6 | # add these directories to sys.path here. If the directory is relative to the 7 | # documentation root, use os.path.abspath to make it absolute, like shown here. 8 | # 9 | import os 10 | import sys 11 | 12 | sys.path.insert(0, os.path.abspath("../..")) 13 | 14 | 15 | # -- General configuration ------------------------------------------------ 16 | 17 | # Add any Sphinx extension module names here, as strings. They can be 18 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 19 | # ones. 20 | extensions = [ 21 | "sphinx.ext.autodoc", 22 | "sphinx.ext.viewcode", 23 | "m2r2", 24 | ] 25 | 26 | source_suffix = [".rst", ".md"] 27 | 28 | # The master toctree document. 29 | master_doc = "index" 30 | 31 | # General information about the project. 32 | project = "Mongo-Thingy" 33 | copyright = "Refty" 34 | author = "Refty" 35 | 36 | 37 | # -- Options for HTML output ---------------------------------------------- 38 | 39 | # The theme to use for HTML and HTML Help pages. See the documentation for 40 | # a list of builtin themes. 41 | # 42 | html_theme = "alabaster" 43 | 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ["_templates"] 46 | 47 | html_sidebars = {"**": ["github-corners.html"]} 48 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Mongo-Thingy documentation master file, created by 2 | sphinx-quickstart on Thu Mar 29 15:46:32 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | .. mdinclude:: ../../README.md 7 | 8 | Documentation 9 | ============= 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | reference 15 | -------------------------------------------------------------------------------- /docs/source/reference.rst: -------------------------------------------------------------------------------- 1 | API reference 2 | ############# 3 | 4 | .. automodule:: mongo_thingy 5 | :members: 6 | :undoc-members: 7 | 8 | Cursor 9 | ====== 10 | 11 | .. automodule:: mongo_thingy.cursor 12 | :members: 13 | :undoc-members: 14 | 15 | Versioned 16 | ========= 17 | 18 | .. automodule:: mongo_thingy.versioned 19 | :members: 20 | :undoc-members: 21 | -------------------------------------------------------------------------------- /img/numberly.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Refty/mongo-thingy/d0e80da623ab7ea6326bb01a6d84f7b0f45a3c4c/img/numberly.png -------------------------------------------------------------------------------- /img/refty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Refty/mongo-thingy/d0e80da623ab7ea6326bb01a6d84f7b0f45a3c4c/img/refty.png -------------------------------------------------------------------------------- /mongo_thingy/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import warnings 3 | from collections.abc import Mapping 4 | 5 | from pymongo import MongoClient, ReturnDocument 6 | from pymongo.errors import ConfigurationError 7 | from thingy import DatabaseThingy, classproperty, registry 8 | 9 | from mongo_thingy.cursor import AsyncCursor, Cursor 10 | 11 | try: 12 | from motor.motor_tornado import MotorClient 13 | except ImportError: # pragma: no cover 14 | MotorClient = None 15 | 16 | try: 17 | from motor.motor_asyncio import AsyncIOMotorClient 18 | except ImportError: # pragma: no cover 19 | AsyncIOMotorClient = None 20 | 21 | 22 | class ThingyList(list): 23 | def distinct(self, key): 24 | def __get_value(item): 25 | if isinstance(item, BaseThingy): 26 | item = item.view() 27 | return item.get(key) 28 | 29 | values = [] 30 | 31 | def __append_value(value): 32 | if value not in values: 33 | values.append(value) 34 | 35 | for item in self: 36 | value = __get_value(item) 37 | if isinstance(value, list): 38 | for v in value: 39 | __append_value(v) 40 | else: 41 | __append_value(value) 42 | return list(values) 43 | 44 | def view(self, name="defaults"): 45 | def __view(item): 46 | if not isinstance(item, BaseThingy): 47 | raise TypeError(f"Can't view type {type(item)}.") 48 | return item.view(name) 49 | 50 | return [__view(item) for item in self] 51 | 52 | 53 | class BaseThingy(DatabaseThingy): 54 | """Represents a document in a collection""" 55 | 56 | _client = None 57 | _client_cls = None 58 | _collection = None 59 | _collection_name = None 60 | _cursor_cls = None 61 | _result_cls = ThingyList 62 | 63 | @classproperty 64 | def _table(cls): 65 | return cls._collection 66 | 67 | @classproperty 68 | def _table_name(cls): 69 | return cls._collection_name 70 | 71 | @classproperty 72 | def table_name(cls): 73 | return cls.collection_name 74 | 75 | @classproperty 76 | def collection(cls): 77 | return cls.get_collection() 78 | 79 | @classproperty 80 | def collection_name(cls): 81 | return cls.get_table_name() 82 | 83 | @classproperty 84 | def client(cls): 85 | return cls.get_client() 86 | 87 | @classmethod 88 | def _get_client(cls, database): 89 | return database.client 90 | 91 | @classmethod 92 | def _get_database(cls, collection, name): 93 | if collection is not None: 94 | return collection.database 95 | if cls._client and name: 96 | return cls._client[name] 97 | raise AttributeError("Undefined database.") 98 | 99 | @classmethod 100 | def _get_table(cls, database, table_name): 101 | return database[table_name] 102 | 103 | @classmethod 104 | def _get_database_name(cls, database): 105 | return database.name 106 | 107 | @classmethod 108 | def _get_table_name(cls, table): 109 | return table.name 110 | 111 | @classmethod 112 | def get_client(cls): 113 | if cls._client: 114 | return cls._client 115 | return cls._get_client(cls.database) 116 | 117 | @classmethod 118 | def get_collection(cls): 119 | return cls.get_table() 120 | 121 | @classmethod 122 | def add_index(cls, keys, **kwargs): 123 | kwargs.setdefault("background", True) 124 | if not hasattr(cls, "_indexes"): 125 | cls._indexes = [] 126 | cls._indexes.append((keys, kwargs)) 127 | 128 | @classmethod 129 | def count_documents(cls, filter=None, *args, **kwargs): 130 | if filter is None: 131 | filter = {} 132 | return cls.collection.count_documents(filter, *args, **kwargs) 133 | 134 | @classmethod 135 | def count(cls, filter=None, *args, **kwargs): 136 | warnings.warn( 137 | "count is deprecated. Use count_documents instead.", DeprecationWarning 138 | ) 139 | return cls.count_documents(filter=filter, *args, **kwargs) 140 | 141 | @classmethod 142 | def connect(cls, *args, client_cls=None, database_name=None, **kwargs): 143 | if not client_cls: 144 | client_cls = cls._client_cls 145 | 146 | cls._client = client_cls(*args, **kwargs) 147 | try: 148 | cls._database = cls._client.get_database(database_name) 149 | except (ConfigurationError, TypeError): 150 | cls._database = cls._client["test"] 151 | 152 | @classmethod 153 | def disconnect(cls, *args, **kwargs): 154 | if cls._client: 155 | cls._client.close() 156 | cls._client = None 157 | cls._database = None 158 | 159 | @classmethod 160 | def distinct(cls, *args, **kwargs): 161 | return cls.collection.distinct(*args, **kwargs) 162 | 163 | @classmethod 164 | def find(cls, *args, view=None, **kwargs): 165 | delegate = cls.collection.find(*args, **kwargs) 166 | return cls._cursor_cls(delegate, thingy_cls=cls, view=view) 167 | 168 | @classmethod 169 | def find_one(cls, filter=None, *args, **kwargs): 170 | if filter is not None and not isinstance(filter, Mapping): 171 | filter = {"_id": filter} 172 | 173 | cursor = cls.find(filter, *args, **kwargs) 174 | return cursor.first() 175 | 176 | @classmethod 177 | def delete_many(cls, filter=None, *args, **kwargs): 178 | return cls.collection.delete_many(filter, *args, **kwargs) 179 | 180 | @classmethod 181 | def delete_one(cls, filter=None, *args, **kwargs): 182 | if filter is not None and not isinstance(filter, Mapping): 183 | filter = {"_id": filter} 184 | 185 | return cls.collection.delete_one(filter, *args, **kwargs) 186 | 187 | @classmethod 188 | def update_many(cls, filter, update, *args, **kwargs): 189 | return cls.collection.update_many(filter, update, *args, **kwargs) 190 | 191 | @classmethod 192 | def update_one(cls, filter, update, *args, **kwargs): 193 | if filter is not None and not isinstance(filter, Mapping): 194 | filter = {"_id": filter} 195 | 196 | return cls.collection.update_one(filter, update, *args, **kwargs) 197 | 198 | @property 199 | def id(self): 200 | return self.__dict__.get("id") or self._id 201 | 202 | @id.setter 203 | def id(self, value): 204 | if "id" in self.__dict__: 205 | self.__dict__["id"] = value 206 | else: 207 | self._id = value 208 | 209 | def delete(self): 210 | return self.get_collection().delete_one({"_id": self.id}) 211 | 212 | 213 | class Thingy(BaseThingy): 214 | _client_cls = MongoClient 215 | _cursor_cls = Cursor 216 | 217 | @classmethod 218 | def create_index(cls, keys, **kwargs): 219 | cls.collection.create_index(keys, **kwargs) 220 | 221 | @classmethod 222 | def create_indexes(cls): 223 | if hasattr(cls, "_indexes"): 224 | for keys, kwargs in cls._indexes: 225 | cls.create_index(keys, **kwargs) 226 | 227 | @classmethod 228 | def find_one_and_replace(cls, filter, replacement, *args, **kwargs): 229 | if filter is not None and not isinstance(filter, Mapping): 230 | filter = {"_id": filter} 231 | 232 | kwargs.setdefault("return_document", ReturnDocument.AFTER) 233 | result = cls.collection.find_one_and_replace( 234 | filter, replacement, *args, **kwargs 235 | ) 236 | if result is not None: 237 | return cls(result) 238 | 239 | @classmethod 240 | def find_one_and_update(cls, filter, update, *args, **kwargs): 241 | if filter is not None and not isinstance(filter, Mapping): 242 | filter = {"_id": filter} 243 | 244 | kwargs.setdefault("return_document", ReturnDocument.AFTER) 245 | result = cls.collection.find_one_and_update(filter, update, *args, **kwargs) 246 | if result is not None: 247 | return cls(result) 248 | 249 | def save(self, force_insert=False, refresh=False): 250 | data = self.__dict__ 251 | collection = self.get_collection() 252 | 253 | if self.id is not None and not force_insert: 254 | filter = {"_id": self.id} 255 | collection.replace_one(filter, data, upsert=True) 256 | else: 257 | collection.insert_one(data) 258 | 259 | if refresh: 260 | self.__dict__ = collection.find_one(self.id) 261 | return self 262 | 263 | 264 | class AsyncThingy(BaseThingy): 265 | _client_cls = MotorClient or AsyncIOMotorClient 266 | _cursor_cls = AsyncCursor 267 | 268 | @classmethod 269 | async def create_index(cls, keys, **kwargs): 270 | await cls.collection.create_index(keys, **kwargs) 271 | 272 | @classmethod 273 | async def create_indexes(cls): 274 | if hasattr(cls, "_indexes"): 275 | for keys, kwargs in cls._indexes: 276 | await cls.create_index(keys, **kwargs) 277 | 278 | @classmethod 279 | async def find_one_and_replace(cls, filter, replacement, *args, **kwargs): 280 | if filter is not None and not isinstance(filter, Mapping): 281 | filter = {"_id": filter} 282 | 283 | kwargs.setdefault("return_document", ReturnDocument.AFTER) 284 | result = await cls.collection.find_one_and_replace( 285 | filter, replacement, *args, **kwargs 286 | ) 287 | if result is not None: 288 | return cls(result) 289 | 290 | @classmethod 291 | async def find_one_and_update(cls, filter, update, *args, **kwargs): 292 | if filter is not None and not isinstance(filter, Mapping): 293 | filter = {"_id": filter} 294 | 295 | kwargs.setdefault("return_document", ReturnDocument.AFTER) 296 | result = await cls.collection.find_one_and_update( 297 | filter, update, *args, **kwargs 298 | ) 299 | if result is not None: 300 | return cls(result) 301 | 302 | async def save(self, force_insert=False, refresh=False): 303 | data = self.__dict__ 304 | collection = self.get_collection() 305 | 306 | if self.id is not None and not force_insert: 307 | filter = {"_id": self.id} 308 | await collection.replace_one(filter, data, upsert=True) 309 | else: 310 | await collection.insert_one(data) 311 | 312 | if refresh: 313 | self.__dict__ = await collection.find_one(self.id) 314 | return self 315 | 316 | 317 | def connect(*args, **kwargs): 318 | if AsyncThingy._client_cls is not None: 319 | AsyncThingy.connect(*args, **kwargs) 320 | Thingy.connect(*args, **kwargs) 321 | 322 | 323 | def disconnect(*args, **kwargs): 324 | Thingy.disconnect(*args, **kwargs) 325 | AsyncThingy.disconnect(*args, **kwargs) 326 | 327 | 328 | def create_indexes(): 329 | """Create indexes registered on all :class:`Thingy`""" 330 | tasks = [] 331 | 332 | for cls in registry: 333 | if issubclass(cls, Thingy): 334 | cls.create_indexes() 335 | if issubclass(cls, AsyncThingy): 336 | coroutine = cls.create_indexes() 337 | task = asyncio.create_task(coroutine) 338 | tasks.append(task) 339 | 340 | if tasks: 341 | return asyncio.wait(tasks) 342 | 343 | 344 | __all__ = ["AsyncThingy", "Thingy", "connect", "create_indexes"] 345 | -------------------------------------------------------------------------------- /mongo_thingy/camelcase.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from mongo_thingy import BaseThingy 4 | 5 | CAMELIZE_RE = re.compile(r"(?!^)_([a-zA-Z])") 6 | UNCAMELIZE_RE = re.compile(r"(?", string).lower() 19 | 20 | 21 | class CamelCase: 22 | def __setattr__(self, attr, value): 23 | return BaseThingy.__setattr__(self, camelize(attr), value) 24 | 25 | def __getattribute__(self, attr): 26 | try: 27 | return object.__getattribute__(self, camelize(attr)) 28 | except AttributeError: 29 | return BaseThingy.__getattribute__(self, uncamelize(attr)) 30 | -------------------------------------------------------------------------------- /mongo_thingy/cursor.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | 4 | class _Proxy: 5 | def __init__(self, name): 6 | self.name = name 7 | 8 | def __call__(self, cursor): 9 | return getattr(cursor.delegate, self.name) 10 | 11 | 12 | class _ChainingProxy(_Proxy): 13 | def __call__(self, cursor): 14 | method = getattr(cursor.delegate, self.name) 15 | 16 | @functools.wraps(method) 17 | def wrapper(*args, **kwargs): 18 | method(*args, **kwargs) 19 | return cursor 20 | 21 | return wrapper 22 | 23 | 24 | class _BindingProxy(_Proxy): 25 | def __call__(self, cursor): 26 | method = getattr(cursor.delegate, self.name) 27 | 28 | @functools.wraps(method) 29 | def wrapper(*args, **kwargs): 30 | result = method(*args, **kwargs) 31 | return cursor.bind(result) 32 | 33 | return wrapper 34 | 35 | 36 | class _AsyncBindingProxy(_Proxy): 37 | def __call__(self, cursor): 38 | method = getattr(cursor.delegate, self.name) 39 | 40 | @functools.wraps(method) 41 | async def wrapper(*args, **kwargs): 42 | result = await method(*args, **kwargs) 43 | if isinstance(result, list): 44 | return cursor.result_cls(cursor.bind(r) for r in result) 45 | return cursor.bind(result) 46 | 47 | return wrapper 48 | 49 | 50 | class BaseCursor: 51 | distinct = _Proxy("distinct") 52 | explain = _Proxy("explain") 53 | limit = _ChainingProxy("limit") 54 | skip = _ChainingProxy("skip") 55 | sort = _ChainingProxy("sort") 56 | 57 | def __init__(self, delegate, thingy_cls=None, view=None): 58 | self.delegate = delegate 59 | self.thingy_cls = thingy_cls 60 | self.result_cls = getattr(thingy_cls, "_result_cls", list) 61 | 62 | if isinstance(view, str): 63 | view = self.get_view(view) 64 | 65 | self.thingy_view = view 66 | 67 | def __getattribute__(self, name): 68 | attribute = object.__getattribute__(self, name) 69 | if isinstance(attribute, _Proxy): 70 | return attribute(self) 71 | return attribute 72 | 73 | def bind(self, document): 74 | if not self.thingy_cls: 75 | return document 76 | thingy = self.thingy_cls(document) 77 | if self.thingy_view is not None: 78 | return self.thingy_view(thingy) 79 | return thingy 80 | 81 | def clone(self): 82 | delegate = self.delegate.clone() 83 | return self.__class__( 84 | delegate, thingy_cls=self.thingy_cls, view=self.thingy_view 85 | ) 86 | 87 | def get_view(self, name): 88 | return self.thingy_cls._views[name] 89 | 90 | def view(self, name="defaults"): 91 | self.thingy_view = self.get_view(name) 92 | return self 93 | 94 | 95 | class Cursor(BaseCursor): 96 | next = __next__ = _BindingProxy("__next__") 97 | 98 | def __getitem__(self, index): 99 | document = self.delegate.__getitem__(index) 100 | return self.bind(document) 101 | 102 | def to_list(self, length): 103 | if length is not None: 104 | self.limit(length) 105 | return self.result_cls(self) 106 | 107 | def delete(self): 108 | ids = self.distinct("_id") 109 | return self.thingy_cls.collection.delete_many({"_id": {"$in": ids}}) 110 | 111 | def first(self): 112 | try: 113 | document = self.delegate.clone().limit(-1).__next__() 114 | except StopIteration: 115 | return None 116 | return self.bind(document) 117 | 118 | 119 | class AsyncCursor(BaseCursor): 120 | to_list = _AsyncBindingProxy("to_list") 121 | next = __anext__ = _AsyncBindingProxy("__anext__") 122 | 123 | async def __aiter__(self): 124 | async for document in self.delegate: 125 | yield self.bind(document) 126 | 127 | async def delete(self): 128 | ids = await self.distinct("_id") 129 | return await self.thingy_cls.collection.delete_many({"_id": {"$in": ids}}) 130 | 131 | async def first(self): 132 | try: 133 | document = await self.delegate.clone().limit(-1).__anext__() 134 | except StopAsyncIteration: 135 | return None 136 | return self.bind(document) 137 | 138 | 139 | __all__ = ["AsyncCursor", "Cursor"] 140 | -------------------------------------------------------------------------------- /mongo_thingy/versioned.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from datetime import datetime 3 | 4 | from pymongo import ASCENDING, DESCENDING 5 | 6 | from mongo_thingy import AsyncThingy, BaseThingy, Thingy 7 | from mongo_thingy.cursor import AsyncCursor, BaseCursor, Cursor 8 | 9 | 10 | class BaseRevisionCursor(BaseCursor): 11 | def __init__(self, *args, **kwargs): 12 | super(BaseRevisionCursor, self).__init__(*args, **kwargs) 13 | 14 | 15 | class RevisionCursor(Cursor, BaseRevisionCursor): 16 | def __getitem__(self, index): 17 | if index < 0 and self.thingy: 18 | version = self.thingy.count_revisions() 19 | index = version + index 20 | return super(RevisionCursor, self).__getitem__(index) 21 | 22 | 23 | class AsyncRevisionCursor(AsyncCursor, BaseRevisionCursor): 24 | pass 25 | 26 | 27 | class BaseRevision(BaseThingy): 28 | """Revision of a document""" 29 | 30 | _collection_name = "revision" 31 | _cursor_cls = None 32 | 33 | @classmethod 34 | def from_thingy(cls, thingy, author=None, operation="update"): 35 | version = cls( 36 | document_id=thingy.id, 37 | document_type=type(thingy).__name__, 38 | operation=operation, 39 | ) 40 | if operation != "delete": 41 | version.document = thingy.__dict__ 42 | if author: 43 | version.author = author 44 | return version 45 | 46 | def _prev(self): 47 | return self.find_one( 48 | {"document_id": self.document_id, "document_type": self.document_type} 49 | ) 50 | 51 | 52 | BaseRevision.add_index([("document_id", DESCENDING), ("document_type", DESCENDING)]) 53 | 54 | 55 | class Revision(Thingy, BaseRevision): 56 | _cursor_cls = RevisionCursor 57 | 58 | def save(self): 59 | self.creation_date = datetime.utcnow() 60 | if not self._prev(): 61 | self.operation = "create" 62 | return super(Revision, self).save() 63 | 64 | 65 | class AsyncRevision(AsyncThingy, BaseRevision): 66 | _cursor_cls = AsyncRevisionCursor 67 | 68 | async def save(self): 69 | self.creation_date = datetime.utcnow() 70 | if not await self._prev(): 71 | self.operation = "create" 72 | return await super(AsyncRevision, self).save() 73 | 74 | 75 | class BaseVersioned: 76 | """Mixin to versionate changes in a collection""" 77 | 78 | _revisions_cls = None 79 | 80 | def get_revisions(self, **kwargs): 81 | filter = {"document_id": self.id, "document_type": type(self).__name__} 82 | filter.update(kwargs) 83 | 84 | cursor = self._revision_cls.find(filter) 85 | cursor.thingy = self 86 | return cursor.sort("_id", ASCENDING) 87 | 88 | def count_revisions(self, **kwargs): 89 | filter = {"document_id": self.id, "document_type": type(self).__name__} 90 | return self._revision_cls.count_documents(filter, **kwargs) 91 | 92 | 93 | class Versioned(BaseVersioned): 94 | def is_versioned(self): 95 | return bool(self.count_revisions(limit=1)) 96 | 97 | @property 98 | def version(self): 99 | warnings.warn( 100 | "version is deprecated. Use count_revisions() instead.", DeprecationWarning 101 | ) 102 | return self.count_revisions() 103 | 104 | @property 105 | def versioned(self): 106 | warnings.warn( 107 | "versioned is deprecated. Use is_versioned() instead.", DeprecationWarning 108 | ) 109 | return bool(self.count_revisions(limit=1)) 110 | 111 | @property 112 | def revisions(self): 113 | warnings.warn( 114 | "revisions is deprecated. Use get_revisions() instead.", DeprecationWarning 115 | ) 116 | return self.get_revisions() 117 | 118 | def revert(self): 119 | revisions = list(self.get_revisions().limit(3)) 120 | try: 121 | self.__dict__ = revisions[-2].document 122 | except IndexError: 123 | self.__dict__ = {"_id": self.id} 124 | return self.save() 125 | 126 | def save(self, author=None, **kwargs): 127 | result = super(Versioned, self).save(**kwargs) 128 | version = self._revision_cls.from_thingy(self, author=author) 129 | version.save() 130 | return result 131 | 132 | def delete(self, author=None): 133 | result = super(Versioned, self).delete() 134 | version = self._revision_cls.from_thingy( 135 | self, author=author, operation="delete" 136 | ) 137 | version.save() 138 | return result 139 | 140 | 141 | class AsyncVersioned(BaseVersioned): 142 | async def is_versioned(self): 143 | return bool(await self.count_revisions(limit=1)) 144 | 145 | async def revert(self): 146 | revisions = await self.get_revisions().limit(3).to_list(length=3) 147 | try: 148 | self.__dict__ = revisions[-2].document 149 | except IndexError: 150 | self.__dict__ = {"_id": self.id} 151 | return await self.save() 152 | 153 | async def save(self, author=None, **kwargs): 154 | result = await super(AsyncVersioned, self).save(**kwargs) 155 | version = self._revision_cls.from_thingy(self, author=author) 156 | await version.save() 157 | return result 158 | 159 | async def delete(self, author=None): 160 | result = await super(AsyncVersioned, self).delete() 161 | version = self._revision_cls.from_thingy( 162 | self, author=author, operation="delete" 163 | ) 164 | await version.save() 165 | return result 166 | 167 | 168 | __all__ = ["AsyncRevision", "AsyncVersioned", "Revision", "Versioned"] 169 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | thingy==0.10.0 2 | pymongo==4.6.3 3 | mongomock==4.0.0 4 | mongomock-motor==0.0.14 5 | montydb==2.4.0 6 | tornado==6.4.2 7 | motor==3.0.0 8 | pytest==7.0.1 9 | pytest-cov==3.0.0 10 | pytest-asyncio==0.18.3 11 | sphinx==4.4.0 12 | m2r2==0.3.2 13 | pre-commit==2.19.0 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | python_files = *.py 4 | addopts = -vv --showlocals --cov mongo_thingy --cov-report term-missing --cov-fail-under 100 5 | markers = 6 | all_backends: mark a test as testable against all backends 7 | only_backends: mark a test as testable only against these backends 8 | ignore_backends: mark a test as not testable against these backends 9 | asyncio_mode = auto 10 | 11 | [flake8] 12 | max-line-length = 89 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | 4 | def get_description(): 5 | with open("README.md") as file: 6 | return file.read() 7 | 8 | 9 | setup( 10 | name="Mongo-Thingy", 11 | version="0.17.2", 12 | url="https://github.com/Refty/mongo-thingy", 13 | license="MIT", 14 | author="Guillaume Gelin", 15 | author_email="guillaume@refty.co", 16 | description=( 17 | "The most idiomatic and friendly-yet-powerful way to use " "MongoDB with Python" 18 | ), 19 | long_description=get_description(), 20 | long_description_content_type="text/markdown", 21 | packages=["mongo_thingy"], 22 | include_package_data=True, 23 | zip_safe=False, 24 | platforms="any", 25 | install_requires=["thingy>=0.10.0", "pymongo>=4.6.3"], 26 | classifiers=[ 27 | "Programming Language :: Python :: 3.7", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: Implementation :: CPython", 33 | "Programming Language :: Python :: Implementation :: PyPy", 34 | "Intended Audience :: Developers", 35 | "Operating System :: OS Independent", 36 | "Programming Language :: Python", 37 | "Topic :: Database", 38 | "Topic :: Software Development :: Libraries :: Python Modules", 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import datetime, timezone 3 | 4 | import pytest 5 | from bson import ObjectId 6 | 7 | from mongo_thingy import ( 8 | Thingy, 9 | ThingyList, 10 | connect, 11 | create_indexes, 12 | disconnect, 13 | registry, 14 | ) 15 | 16 | 17 | async def test_thingy_list_distinct_thingies(): 18 | foos = ThingyList() 19 | foos.append(Thingy()) 20 | foos.append(Thingy()) 21 | foos.append(Thingy(bar="baz")) 22 | foos.append(Thingy(bar="qux")) 23 | 24 | distinct = foos.distinct("bar") 25 | assert distinct.count(None) == 1 26 | assert distinct == [None, "baz", "qux"] 27 | 28 | 29 | async def test_thingy_list_distinct_dicts(): 30 | foos = ThingyList() 31 | foos.append({}) 32 | foos.append({}) 33 | foos.append({"bar": "baz"}) 34 | foos.append({"bar": "qux"}) 35 | 36 | distinct = foos.distinct("bar") 37 | assert distinct.count(None) == 1 38 | assert distinct == [None, "baz", "qux"] 39 | 40 | 41 | # https://mongodb.com/docs/manual/reference/method/db.collection.distinct/#array-fields 42 | async def test_thingy_list_distinct_array_fields(): 43 | foos = ThingyList() 44 | foos.append(Thingy()) 45 | foos.append(Thingy()) 46 | foos.append(Thingy(bar=[1, 2])) 47 | foos.append(Thingy(bar=[2, 3, [3]])) 48 | 49 | distinct = foos.distinct("bar") 50 | assert distinct.count(None) == 1 51 | assert distinct == [None, 1, 2, 3, [3]] 52 | 53 | 54 | async def test_thingy_list_view(): 55 | class Foo(Thingy): 56 | pass 57 | 58 | Foo.add_view("empty") 59 | foos = ThingyList() 60 | foos.append(Foo(bar="baz")) 61 | foos.append(Foo(bar="qux")) 62 | 63 | for foo in foos.view("empty"): 64 | assert foo == {} 65 | 66 | foos.append({}) 67 | with pytest.raises(TypeError): 68 | foos.view("empty") 69 | 70 | 71 | @pytest.mark.all_backends 72 | async def test_base_thingy_database(TestThingy, database): 73 | assert TestThingy.database == database 74 | 75 | 76 | @pytest.mark.all_backends 77 | async def test_base_thingy_client(TestThingy, client): 78 | assert TestThingy.client == client 79 | 80 | 81 | @pytest.mark.all_backends 82 | async def test_base_thingy_collection(TestThingy, collection): 83 | assert TestThingy.collection == collection 84 | 85 | 86 | @pytest.mark.all_backends 87 | async def test_base_thingy_names(thingy_cls, client): 88 | class FooBar(thingy_cls): 89 | pass 90 | 91 | with pytest.raises(AttributeError): 92 | FooBar.database 93 | 94 | FooBar._client = client 95 | assert FooBar.database == FooBar.client.foo 96 | assert FooBar.collection == FooBar.client.foo.bar 97 | assert FooBar.database_name == "foo" 98 | assert FooBar.collection_name == "bar" 99 | 100 | 101 | @pytest.mark.all_backends 102 | async def test_base_thingy_database_name(thingy_cls, client, database): 103 | class FooBar(thingy_cls): 104 | _client = client 105 | _database_name = "fuu" 106 | 107 | assert FooBar.database_name == "fuu" 108 | 109 | class FooBar(thingy_cls): 110 | _database = database 111 | 112 | assert FooBar.database_name == database.name 113 | 114 | 115 | @pytest.mark.all_backends 116 | async def test_base_thingy_collection_name(thingy_cls, client, collection): 117 | class FooBar(thingy_cls): 118 | _client = client 119 | _collection_name = "baz" 120 | 121 | assert FooBar.collection_name == "baz" 122 | 123 | class FooBar(thingy_cls): 124 | _collection = collection 125 | 126 | assert FooBar.collection_name == collection.name 127 | 128 | 129 | @pytest.mark.all_backends 130 | async def test_base_thingy_database_from_client(thingy_cls, client): 131 | class FooBar(thingy_cls): 132 | _client = client 133 | 134 | assert FooBar.database == client.foo 135 | 136 | 137 | @pytest.mark.all_backends 138 | async def test_base_thingy_database_from_collection(thingy_cls, collection): 139 | class Foo(thingy_cls): 140 | _collection = collection 141 | 142 | assert Foo.database == collection.database 143 | 144 | 145 | @pytest.mark.all_backends 146 | async def test_base_thingy_client_from_database(thingy_cls, database): 147 | class Foo(thingy_cls): 148 | _database = database 149 | 150 | assert Foo.client == database.client 151 | 152 | 153 | @pytest.mark.all_backends 154 | async def test_base_thingy_collection_from_database(thingy_cls, database): 155 | class Foo(thingy_cls): 156 | _database = database 157 | 158 | assert Foo.collection == database.foo 159 | 160 | 161 | @pytest.mark.all_backends 162 | async def test_base_thingy_database_from_name(thingy_cls, client): 163 | class FooBar(thingy_cls): 164 | _client = client 165 | 166 | assert FooBar.database == client.foo 167 | 168 | 169 | @pytest.mark.all_backends 170 | async def test_base_thingy_collection_from_name(thingy_cls, database): 171 | class Bar(thingy_cls): 172 | _database = database 173 | 174 | assert Bar.collection == database.bar 175 | 176 | 177 | @pytest.mark.all_backends 178 | async def test_base_thingy_add_index(thingy_cls, collection): 179 | class Foo(thingy_cls): 180 | _collection = collection 181 | 182 | Foo.add_index("foo", unique=True) 183 | assert Foo._indexes == [("foo", {"unique": True, "background": True})] 184 | 185 | Foo._indexes = [] 186 | Foo.add_index("foo", unique=True, background=False) 187 | assert Foo._indexes == [("foo", {"unique": True, "background": False})] 188 | 189 | 190 | def test_thingy_count_documents(TestThingy, collection): 191 | collection.insert_one({"bar": "baz"}) 192 | collection.insert_one({"foo": "bar"}) 193 | 194 | with pytest.deprecated_call(): 195 | TestThingy.count() 196 | 197 | assert TestThingy.count_documents() == 2 198 | assert TestThingy.count_documents({"foo": "bar"}) == 1 199 | 200 | 201 | async def test_async_thingy_count_documents(TestThingy, collection): 202 | await collection.insert_one({"bar": "baz"}) 203 | await collection.insert_one({"foo": "bar"}) 204 | 205 | with pytest.deprecated_call(): 206 | await TestThingy.count() 207 | 208 | assert await TestThingy.count_documents() == 2 209 | assert await TestThingy.count_documents({"foo": "bar"}) == 1 210 | 211 | 212 | @pytest.mark.ignore_backends("montydb") 213 | def test_connect_disconnect(thingy_cls, client_cls): 214 | connect(client_cls=client_cls) 215 | assert isinstance(thingy_cls.client, client_cls) 216 | assert thingy_cls._database.name == "test" 217 | disconnect() 218 | 219 | connect(client_cls=client_cls, database_name="database") 220 | assert isinstance(thingy_cls.client, client_cls) 221 | assert thingy_cls._database.name == "database" 222 | disconnect() 223 | 224 | thingy_cls._client_cls = client_cls 225 | connect() 226 | assert isinstance(thingy_cls.client, client_cls) 227 | disconnect() 228 | 229 | connect("mongodb://hostname/database") 230 | assert thingy_cls.database is not None 231 | assert thingy_cls.database.name == "database" 232 | disconnect() 233 | 234 | assert thingy_cls._client is None 235 | with pytest.raises(AttributeError): 236 | thingy_cls.client 237 | 238 | assert thingy_cls._database is None 239 | with pytest.raises(AttributeError): 240 | thingy_cls.database 241 | 242 | 243 | @pytest.mark.ignore_backends("montydb") 244 | def test_thingy_create_index(TestThingy, collection): 245 | TestThingy.create_index("foo", unique=True) 246 | 247 | indexes = collection.index_information() 248 | assert "_id_" in indexes 249 | assert "foo_1" in indexes 250 | assert len(indexes) == 2 251 | 252 | 253 | async def test_async_thingy_create_index(TestThingy, collection): 254 | await TestThingy.create_index("foo", unique=True) 255 | 256 | indexes = await collection.index_information() 257 | assert "_id_" in indexes 258 | assert "foo_1" in indexes 259 | assert len(indexes) == 2 260 | 261 | 262 | @pytest.mark.ignore_backends("montydb") 263 | def test_thingy_create_indexes(TestThingy, collection): 264 | class Foo(TestThingy): 265 | _indexes = [("foo", {"unique": True, "background": True})] 266 | 267 | Foo.create_indexes() 268 | indexes = collection.index_information() 269 | assert "_id_" in indexes 270 | assert "foo_1" in indexes 271 | assert len(indexes) == 2 272 | 273 | 274 | async def test_async_thingy_create_indexes(TestThingy, collection): 275 | class Foo(TestThingy): 276 | _indexes = [("foo", {"unique": True, "background": True})] 277 | 278 | await Foo.create_indexes() 279 | indexes = await collection.index_information() 280 | assert "_id_" in indexes 281 | assert "foo_1" in indexes 282 | assert len(indexes) == 2 283 | 284 | 285 | def test_thingy_distinct(TestThingy, collection): 286 | collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 287 | assert set(TestThingy.distinct("bar")) == {"baz", "qux"} 288 | 289 | collection.insert_one({"bar": None}) 290 | assert None in TestThingy.distinct("bar") 291 | 292 | 293 | async def test_async_thingy_distinct(TestThingy, collection): 294 | await collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 295 | assert set(await TestThingy.distinct("bar")) == {"baz", "qux"} 296 | 297 | await collection.insert_one({"bar": None}) 298 | assert None in await TestThingy.distinct("bar") 299 | 300 | 301 | def test_thingy_find(TestThingy, collection): 302 | collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 303 | cursor = TestThingy.find() 304 | assert len(list(cursor.clone())) == 2 305 | 306 | thingy = cursor.next() 307 | assert isinstance(thingy, TestThingy) 308 | assert thingy.bar == "baz" 309 | 310 | 311 | async def test_async_thingy_find(TestThingy, collection): 312 | await collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 313 | cursor = TestThingy.find() 314 | assert len(await cursor.clone().to_list(length=10)) == 2 315 | 316 | thingy = await cursor.next() 317 | assert isinstance(thingy, TestThingy) 318 | assert thingy.bar == "baz" 319 | 320 | 321 | def test_thingy_find_one(TestThingy, collection): 322 | collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 323 | thingy = TestThingy.find_one() 324 | assert isinstance(thingy, TestThingy) 325 | assert thingy.bar == "baz" 326 | 327 | thingy = TestThingy.find_one(max_time_ms=10) 328 | assert isinstance(thingy, TestThingy) 329 | assert thingy.bar == "baz" 330 | 331 | thingy = TestThingy.find_one(thingy.id) 332 | assert isinstance(thingy, TestThingy) 333 | assert thingy.bar == "baz" 334 | 335 | thingy = TestThingy.find_one({"bar": "qux"}) 336 | assert isinstance(thingy, TestThingy) 337 | assert thingy.bar == "qux" 338 | 339 | thingy = TestThingy.find_one({"bar": "quux"}) 340 | assert thingy is None 341 | 342 | 343 | def test_thingy_delete_many(TestThingy, collection): 344 | collection.insert_many( 345 | [{"foo": "bar"}, {"bar": "qux"}, {"bar": "baz"}, {"bar": "baz"}] 346 | ) 347 | TestThingy.delete_many({"foo": "bar"}) 348 | assert TestThingy.find_one({"foo": "bar"}) is None 349 | 350 | assert TestThingy.find_one({"bar": "qux"}) 351 | 352 | TestThingy.delete_many({"bar": "baz"}) 353 | assert TestThingy.find_one({"bar": "baz"}) is None 354 | 355 | 356 | def test_thingy_delete_one(TestThingy, collection): 357 | collection.insert_many( 358 | [{"foo": "bar"}, {"bar": "qux"}, {"bar": "baz"}, {"bar": "baz"}] 359 | ) 360 | TestThingy.delete_one({"foo": "bar"}) 361 | assert TestThingy.find_one({"foo": "bar"}) is None 362 | 363 | qux = TestThingy.find_one({"bar": "qux"}) 364 | 365 | TestThingy.delete_one(qux.id) 366 | assert TestThingy.find_one({"bar": "qux"}) is None 367 | 368 | TestThingy.delete_one(qux.id) 369 | assert TestThingy.find_one({"bar": "qux"}) is None 370 | 371 | TestThingy.delete_one({"bar": "baz"}) 372 | assert TestThingy.find_one({"bar": "baz"}) is not None 373 | TestThingy.delete_one({"bar": "baz"}) 374 | assert TestThingy.find_one({"bar": "baz"}) is None 375 | 376 | 377 | def test_thingy_update_many(TestThingy, collection): 378 | collection.insert_many( 379 | [{"foo": "bar"}, {"bar": "qux"}, {"bar": "baz"}, {"bar": "baz"}] 380 | ) 381 | updated = TestThingy.update_many({"bar": "baz"}, {"$set": {"bar": "baaz"}}) 382 | assert updated.acknowledged 383 | assert updated.matched_count == 2 384 | assert updated.modified_count == 2 385 | assert TestThingy.find_one({"bar": "baz"}) is None 386 | 387 | updated = TestThingy.update_many( 388 | {"new": "new"}, {"$set": {"bar": "baaz"}}, upsert=False 389 | ) 390 | assert updated.acknowledged 391 | assert updated.matched_count == 0 392 | assert updated.modified_count == 0 393 | assert TestThingy.find_one({"new": "new"}) is None 394 | 395 | updated = TestThingy.update_many( 396 | {"new": "new"}, {"$set": {"bar": "baaz"}}, upsert=True 397 | ) 398 | assert updated.acknowledged 399 | assert updated.matched_count == 0 400 | assert updated.modified_count == 0 401 | assert TestThingy.find_one({"new": "new"}).bar == "baaz" 402 | 403 | updated = TestThingy.update_many( 404 | {"new": "new"}, {"$set": {"already": "exists"}}, upsert=True 405 | ) 406 | assert updated.acknowledged 407 | assert updated.matched_count == 1 408 | assert updated.modified_count == 1 409 | new = TestThingy.find_one({"new": "new"}) 410 | assert new.already == "exists" 411 | assert new.bar == "baaz" 412 | 413 | 414 | def test_thingy_update_one(TestThingy, collection): 415 | collection.insert_many( 416 | [{"foo": "bar"}, {"bar": "qux"}, {"bar": "baz"}, {"bar": "baz"}] 417 | ) 418 | updated = TestThingy.update_one({"bar": "baz"}, {"$set": {"bar": "baaz"}}) 419 | assert updated.acknowledged 420 | assert updated.matched_count == 1 421 | assert updated.modified_count == 1 422 | assert TestThingy.find_one({"bar": "baz"}) is not None 423 | 424 | updated = TestThingy.update_one({"bar": "baz"}, {"$set": {"bar": "baaz"}}) 425 | assert updated.acknowledged 426 | assert updated.matched_count == 1 427 | assert updated.modified_count == 1 428 | assert TestThingy.find_one({"bar": "baz"}) is None 429 | 430 | foo = TestThingy.find_one({"foo": "bar"}) 431 | TestThingy.update_one(foo.id, {"$set": {"new_field": "foo"}}) 432 | assert updated.acknowledged 433 | assert updated.matched_count == 1 434 | assert updated.modified_count == 1 435 | thingy = TestThingy.find_one({"foo": "bar"}) 436 | assert thingy.foo == "bar" 437 | assert thingy.new_field == "foo" 438 | 439 | updated = TestThingy.update_one( 440 | {"new": "new"}, {"$set": {"bar": "baaz"}}, upsert=False 441 | ) 442 | assert updated.acknowledged 443 | assert updated.matched_count == 0 444 | assert updated.modified_count == 0 445 | assert TestThingy.find_one({"new": "new"}) is None 446 | 447 | updated = TestThingy.update_one( 448 | {"new": "new"}, {"$set": {"bar": "baaz"}}, upsert=True 449 | ) 450 | assert updated.acknowledged 451 | assert updated.matched_count == 0 452 | assert updated.modified_count == 0 453 | assert TestThingy.find_one({"new": "new"}).bar == "baaz" 454 | 455 | updated = TestThingy.update_one( 456 | {"new": "new"}, {"$set": {"already": "exists"}}, upsert=True 457 | ) 458 | assert updated.acknowledged 459 | assert updated.matched_count == 1 460 | assert updated.modified_count == 1 461 | new = TestThingy.find_one({"new": "new"}) 462 | assert new.already == "exists" 463 | assert new.bar == "baaz" 464 | 465 | 466 | @pytest.mark.ignore_backends("montydb") 467 | def test_thingy_find_one_and_replace(TestThingy, collection): 468 | collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 469 | thingy = TestThingy.find_one_and_replace({"bar": "baz"}, {"bar": "baaz"}) 470 | assert isinstance(thingy, TestThingy) 471 | assert thingy.bar == "baaz" 472 | 473 | thingy = TestThingy.find_one_and_replace(thingy.id, {"bar": "baaaz"}) 474 | assert isinstance(thingy, TestThingy) 475 | assert thingy.bar == "baaaz" 476 | 477 | 478 | async def test_async_thingy_find_one_and_replace(TestThingy, collection): 479 | await collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 480 | thingy = await TestThingy.find_one_and_replace({"bar": "baz"}, {"bar": "baaz"}) 481 | assert isinstance(thingy, TestThingy) 482 | assert thingy.bar == "baaz" 483 | 484 | thingy = await TestThingy.find_one_and_replace(thingy.id, {"bar": "baaaz"}) 485 | assert isinstance(thingy, TestThingy) 486 | assert thingy.bar == "baaaz" 487 | 488 | 489 | @pytest.mark.ignore_backends("montydb") 490 | def test_thingy_find_one_and_update(TestThingy, collection): 491 | collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 492 | thingy = TestThingy.find_one_and_update({"bar": "baz"}, {"$set": {"bar": "baaz"}}) 493 | assert isinstance(thingy, TestThingy) 494 | assert thingy.bar == "baaz" 495 | 496 | thingy = TestThingy.find_one_and_update(thingy.id, {"$set": {"bar": "baaaz"}}) 497 | assert isinstance(thingy, TestThingy) 498 | assert thingy.bar == "baaaz" 499 | 500 | 501 | async def test_async_thingy_find_one_and_update(TestThingy, collection): 502 | await collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 503 | thingy = await TestThingy.find_one_and_update( 504 | {"bar": "baz"}, {"$set": {"bar": "baaz"}} 505 | ) 506 | assert isinstance(thingy, TestThingy) 507 | assert thingy.bar == "baaz" 508 | 509 | thingy = await TestThingy.find_one_and_update(thingy.id, {"$set": {"bar": "baaaz"}}) 510 | assert isinstance(thingy, TestThingy) 511 | assert thingy.bar == "baaaz" 512 | 513 | 514 | @pytest.mark.all_backends 515 | async def test_base_thingy_id(thingy_cls, collection): 516 | thingy = thingy_cls({"_id": "foo"}) 517 | assert thingy._id == thingy.id == "foo" 518 | 519 | thingy.id = "bar" 520 | assert thingy._id == thingy.id == "bar" 521 | 522 | thingy = thingy_cls({"id": "foo"}) 523 | assert thingy.id == "foo" 524 | assert thingy._id is None 525 | 526 | thingy.id = "bar" 527 | assert thingy.id == "bar" 528 | assert thingy._id is None 529 | 530 | thingy._id = "qux" 531 | assert thingy.id == "bar" 532 | assert thingy._id == "qux" 533 | 534 | 535 | def test_thingy_save(TestThingy, collection): 536 | thingy = TestThingy(bar="baz") 537 | assert TestThingy.count_documents() == 0 538 | thingy.save() 539 | assert TestThingy.count_documents() == 1 540 | assert isinstance(thingy._id, ObjectId) 541 | 542 | thingy = TestThingy(id="bar", bar="qux").save() 543 | assert isinstance(thingy, TestThingy) 544 | assert thingy.bar == "qux" 545 | assert thingy._id == "bar" 546 | 547 | 548 | @pytest.mark.only_backends("pymongo") 549 | def test_thingy_save_refresh(TestThingy): 550 | created_at = datetime.now(timezone.utc) 551 | 552 | thingy = TestThingy(created_at=created_at).save() 553 | assert thingy.created_at == created_at 554 | 555 | thingy = thingy.save(refresh=True) 556 | assert thingy.created_at != created_at 557 | 558 | approx = created_at.replace(microsecond=0, tzinfo=None) 559 | saved_approx = thingy.created_at.replace(microsecond=0, tzinfo=None) 560 | assert approx == saved_approx 561 | 562 | assert TestThingy.find_one().created_at != created_at 563 | assert TestThingy.find_one().created_at == thingy.created_at 564 | 565 | 566 | async def test_async_thingy_save(TestThingy, collection): 567 | thingy = TestThingy(bar="baz") 568 | assert await TestThingy.count_documents() == 0 569 | await thingy.save() 570 | assert await TestThingy.count_documents() == 1 571 | assert isinstance(thingy._id, ObjectId) 572 | 573 | thingy = await TestThingy(id="bar", bar="qux").save() 574 | assert isinstance(thingy, TestThingy) 575 | assert thingy.bar == "qux" 576 | assert thingy._id == "bar" 577 | 578 | 579 | @pytest.mark.only_backends("motor_asyncio", "motor_tornado") 580 | async def test_async_thingy_save_refresh(TestThingy): 581 | created_at = datetime.now(timezone.utc) 582 | 583 | thingy = await TestThingy(created_at=created_at).save() 584 | assert thingy.created_at == created_at 585 | 586 | thingy = await thingy.save(refresh=True) 587 | assert thingy.created_at != created_at 588 | 589 | approx = created_at.replace(microsecond=0, tzinfo=None) 590 | saved_approx = thingy.created_at.replace(microsecond=0, tzinfo=None) 591 | assert approx == saved_approx 592 | 593 | assert (await TestThingy.find_one()).created_at != created_at 594 | assert (await TestThingy.find_one()).created_at == thingy.created_at 595 | 596 | 597 | def test_thingy_save_force_insert(TestThingy, collection): 598 | thingy = TestThingy().save(force_insert=True) 599 | 600 | with pytest.raises(Exception, match="[dD]uplicate [kK]ey [eE]rror"): 601 | TestThingy(_id=thingy._id, bar="qux").save(force_insert=True) 602 | 603 | assert TestThingy.count_documents() == 1 604 | 605 | 606 | async def test_async_thingy_save_force_insert(TestThingy, collection): 607 | thingy = await TestThingy().save(force_insert=True) 608 | 609 | with pytest.raises(Exception, match="[dD]uplicate [kK]ey [eE]rror"): 610 | await TestThingy(_id=thingy._id, bar="qux").save(force_insert=True) 611 | 612 | assert await TestThingy.count_documents() == 1 613 | 614 | 615 | def test_versioned_thingy_save_force_insert(TestVersionedThingy, collection): 616 | thingy = TestVersionedThingy().save(force_insert=True) 617 | 618 | with pytest.raises(Exception, match="[dD]uplicate [kK]ey [eE]rror"): 619 | TestVersionedThingy(_id=thingy._id, bar="qux").save(force_insert=True) 620 | 621 | assert TestVersionedThingy.count_documents() == 1 622 | 623 | 624 | async def test_async_versioned_thingy_save_force_insert( 625 | TestVersionedThingy, collection 626 | ): 627 | thingy = await TestVersionedThingy().save(force_insert=True) 628 | 629 | with pytest.raises(Exception, match="[dD]uplicate [kK]ey [eE]rror"): 630 | await TestVersionedThingy(_id=thingy._id, bar="qux").save(force_insert=True) 631 | 632 | assert await TestVersionedThingy.count_documents() == 1 633 | 634 | 635 | def test_thingy_delete(TestThingy, collection): 636 | thingy = TestThingy(bar="baz").save() 637 | assert TestThingy.count_documents() == 1 638 | thingy.delete() 639 | assert TestThingy.count_documents() == 0 640 | 641 | 642 | async def test_async_thingy_delete(TestThingy, collection): 643 | thingy = await TestThingy(bar="baz").save() 644 | assert await TestThingy.count_documents() == 1 645 | await thingy.delete() 646 | assert await TestThingy.count_documents() == 0 647 | 648 | 649 | @pytest.mark.ignore_backends("montydb") 650 | @pytest.mark.all_backends 651 | async def test_create_indexes(is_async, thingy_cls, database): 652 | del registry[:] 653 | 654 | class Foo(thingy_cls): 655 | _database = database 656 | _indexes = [("foo", {})] 657 | 658 | class Bar(thingy_cls): 659 | _database = database 660 | _indexes = [("bar", {"unique": True}), ("baz", {"sparse": True})] 661 | 662 | create_indexes() 663 | if is_async: 664 | await asyncio.sleep(0.1) 665 | assert len(await Foo.collection.index_information()) == 2 666 | assert len(await Bar.collection.index_information()) == 3 667 | else: 668 | assert len(Foo.collection.index_information()) == 2 669 | assert len(Bar.collection.index_information()) == 3 670 | 671 | 672 | @pytest.mark.all_backends 673 | def test_github_issue_6(thingy_cls, client): 674 | class SynchronisedSwimming(thingy_cls): 675 | _client = client 676 | 677 | assert SynchronisedSwimming.database.name == "synchronised" 678 | assert SynchronisedSwimming.collection.name == "swimming" 679 | 680 | SynchronisedSwimming._database_name = "sport" 681 | assert SynchronisedSwimming.database.name == "sport" 682 | assert SynchronisedSwimming.collection.name == "synchronised_swimming" 683 | -------------------------------------------------------------------------------- /tests/camelcase.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | import pytest 4 | 5 | from mongo_thingy.camelcase import CamelCase, camelize, uncamelize 6 | 7 | 8 | def test_camelize(): 9 | assert camelize("_id") == "_id" 10 | assert camelize("__dict__") == "__dict__" 11 | assert camelize("foo") == "foo" 12 | assert camelize("foo_bar") == "fooBar" 13 | assert camelize("foo_bar_baz") == "fooBarBaz" 14 | assert camelize("fooBar") == "fooBar" 15 | 16 | 17 | def test_uncamelize(): 18 | assert uncamelize("_id") == "_id" 19 | assert uncamelize("__dict__") == "__dict__" 20 | assert uncamelize("foo") == "foo" 21 | assert uncamelize("fooBar") == "foo_bar" 22 | assert uncamelize("fooBAR") == "foo_bar" 23 | assert uncamelize("fooBarBaz") == "foo_bar_baz" 24 | assert uncamelize("foo_bar") == "foo_bar" 25 | 26 | 27 | def test_camelcase(TestThingy): 28 | class TestCamelCaseThingy(CamelCase, TestThingy): 29 | pass 30 | 31 | thingy = TestCamelCaseThingy() 32 | thingy.foo_bar = 1 33 | assert thingy.foo_bar == 1 34 | assert thingy.view() == {"fooBar": 1} 35 | 36 | thingy.save() 37 | assert TestCamelCaseThingy.collection.find_one(thingy.id) == { 38 | "_id": thingy.id, 39 | "fooBar": 1, 40 | } 41 | 42 | thingy = TestCamelCaseThingy({"fooBar": 1}) 43 | assert thingy.foo_bar == 1 44 | 45 | thingy.save() 46 | assert TestCamelCaseThingy.collection.find_one(thingy.id) == { 47 | "_id": thingy.id, 48 | "fooBar": 1, 49 | } 50 | 51 | 52 | def test_camelcase_property(TestThingy): 53 | class TestCamelCaseThingy(CamelCase, TestThingy): 54 | @property 55 | def foo_bar(self): 56 | return self.foo + self.bar 57 | 58 | TestCamelCaseThingy.add_view("properties", include="fooBar") 59 | 60 | thingy = TestCamelCaseThingy(foo=1, bar=1) 61 | assert thingy.foo_bar == 2 62 | assert thingy.view("properties") == {"fooBar": 2} 63 | 64 | thingy.save() 65 | assert TestCamelCaseThingy.collection.find_one(thingy.id) == { 66 | "_id": thingy.id, 67 | "foo": 1, 68 | "bar": 1, 69 | } 70 | 71 | 72 | @pytest.mark.only_backends("pymongo") 73 | def test_camelcase_save_refresh(TestThingy): 74 | class TestCamelCaseThingy(CamelCase, TestThingy): 75 | pass 76 | 77 | created_at = datetime.now(timezone.utc) 78 | 79 | thingy = TestCamelCaseThingy(created_at=created_at).save() 80 | assert thingy.created_at == created_at 81 | 82 | thingy = thingy.save(refresh=True) 83 | assert thingy.created_at != created_at 84 | 85 | approx = created_at.replace(microsecond=0, tzinfo=None) 86 | saved_approx = thingy.created_at.replace(microsecond=0, tzinfo=None) 87 | assert approx == saved_approx 88 | 89 | assert TestCamelCaseThingy.find_one().created_at != created_at 90 | assert TestCamelCaseThingy.find_one().created_at == thingy.created_at 91 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | import pytest 4 | from _pytest.compat import get_real_func 5 | from pymongo import MongoClient 6 | 7 | from mongo_thingy import AsyncThingy, BaseThingy, Thingy 8 | from mongo_thingy.versioned import AsyncRevision, AsyncVersioned, Revision, Versioned 9 | 10 | try: 11 | from mongomock import MongoClient as MongomockClient 12 | except ImportError: 13 | MongomockClient = None 14 | 15 | try: 16 | from montydb import MontyClient 17 | except ImportError: 18 | MontyClient = None 19 | 20 | try: 21 | from motor.motor_tornado import MotorClient 22 | except ImportError: 23 | MotorClient = None 24 | 25 | try: 26 | from motor.motor_asyncio import AsyncIOMotorClient 27 | except ImportError: 28 | AsyncIOMotorClient = None 29 | 30 | try: 31 | from mongomock_motor import AsyncMongoMockClient 32 | except ImportError: 33 | AsyncMongoMockClient = None 34 | 35 | sync_backends = { 36 | "pymongo": MongoClient, 37 | "mongomock": MongomockClient, 38 | "montydb": MontyClient, 39 | } 40 | 41 | async_backends = { 42 | "motor_tornado": MotorClient, 43 | "motor_asyncio": AsyncIOMotorClient, 44 | "mongomock_motor": AsyncMongoMockClient, 45 | } 46 | 47 | backends = {**sync_backends, **async_backends} 48 | 49 | 50 | def pytest_addoption(parser): 51 | help = "Test a single backend. Choices: {}".format(", ".join(backends)) 52 | parser.addoption("--backend", choices=backends.keys(), help=help) 53 | 54 | 55 | def pytest_generate_tests(metafunc): 56 | if "backend" in metafunc.fixturenames: 57 | if metafunc.definition.get_closest_marker("all_backends"): 58 | _backends = backends.keys() 59 | elif inspect.iscoroutinefunction(get_real_func(metafunc.function)): 60 | _backends = async_backends.keys() 61 | else: 62 | _backends = sync_backends.keys() 63 | 64 | option = metafunc.config.getoption("backend") 65 | if option: 66 | _backends = [b for b in _backends if b == option] 67 | 68 | marker = metafunc.definition.get_closest_marker("only_backends") 69 | if marker: 70 | _backends = [b for b in _backends if b in marker.args] 71 | 72 | marker = metafunc.definition.get_closest_marker("ignore_backends") 73 | if marker: 74 | _backends = [b for b in _backends if b not in marker.args] 75 | 76 | metafunc.parametrize("backend", _backends) 77 | 78 | 79 | def pytest_collection_modifyitems(config, items): 80 | if not config.getoption("--backend"): 81 | return items 82 | 83 | selected_items = [] 84 | deselected_items = [] 85 | 86 | for item in items: 87 | if ( 88 | "backend" in item.fixturenames 89 | and item.callspec.getparam("backend") not in backends 90 | ): 91 | deselected_items.append(item) 92 | else: 93 | selected_items.append(item) 94 | 95 | config.hook.pytest_deselected(items=deselected_items) 96 | items[:] = selected_items 97 | 98 | 99 | @pytest.fixture 100 | def client_cls(backend): 101 | client_cls = backends[backend] 102 | if client_cls is None: 103 | pytest.skip() 104 | BaseThingy.client_cls = client_cls 105 | return client_cls 106 | 107 | 108 | @pytest.fixture 109 | def client(backend, client_cls): 110 | if backend == "montydb": 111 | return client_cls(":memory:") 112 | return client_cls("mongodb://localhost") 113 | 114 | 115 | @pytest.fixture 116 | def database(client): 117 | return client.mongo_thingy_tests 118 | 119 | 120 | @pytest.fixture 121 | def is_async(backend): 122 | return backend in async_backends 123 | 124 | 125 | @pytest.fixture 126 | async def collection(request, is_async, database): 127 | collection = database[request.node.name] 128 | if is_async: 129 | await collection.delete_many({}) 130 | else: 131 | collection.delete_many({}) 132 | return collection 133 | 134 | 135 | @pytest.fixture 136 | def thingy_cls(is_async): 137 | if is_async: 138 | return AsyncThingy 139 | return Thingy 140 | 141 | 142 | @pytest.fixture 143 | def TestThingy(thingy_cls, collection): 144 | class TestThingy(thingy_cls): 145 | _collection = collection 146 | 147 | return TestThingy 148 | 149 | 150 | @pytest.fixture 151 | def revision_cls(is_async): 152 | if is_async: 153 | return AsyncRevision 154 | return Revision 155 | 156 | 157 | @pytest.fixture 158 | async def TestRevision(is_async, revision_cls, database): 159 | class TestRevision(revision_cls): 160 | _database = database 161 | 162 | if is_async: 163 | await TestRevision.collection.delete_many({}) 164 | else: 165 | TestRevision.collection.delete_many({}) 166 | return TestRevision 167 | 168 | 169 | @pytest.fixture 170 | def versioned_cls(is_async): 171 | if is_async: 172 | return AsyncVersioned 173 | return Versioned 174 | 175 | 176 | @pytest.fixture 177 | def TestVersioned(versioned_cls, TestRevision): 178 | class TestVersioned(versioned_cls): 179 | _revision_cls = TestRevision 180 | 181 | return TestVersioned 182 | 183 | 184 | @pytest.fixture 185 | def TestVersionedThingy(TestVersioned, TestThingy): 186 | class TestVersionedThingy(TestVersioned, TestThingy): 187 | pass 188 | 189 | return TestVersionedThingy 190 | 191 | 192 | __all__ = [ 193 | "TestThingy", 194 | "TestRevision", 195 | "TestVersioned", 196 | "TestVersionedThingy", 197 | "client", 198 | "database", 199 | "collection", 200 | ] 201 | -------------------------------------------------------------------------------- /tests/cursor.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mongo_thingy import Thingy, ThingyList 4 | from mongo_thingy.cursor import ( 5 | AsyncCursor, 6 | Cursor, 7 | _BindingProxy, 8 | _ChainingProxy, 9 | _Proxy, 10 | ) 11 | 12 | 13 | def test_proxy(): 14 | class Delegate: 15 | def __init__(self, value): 16 | self.value = value 17 | 18 | def foo(self, value=1): 19 | self.value += value 20 | return self.value 21 | 22 | class FooCursor(Cursor): 23 | foo = _Proxy("foo") 24 | 25 | delegate = Delegate(10) 26 | cursor = FooCursor(delegate) 27 | 28 | assert cursor.foo() == 11 29 | assert cursor.foo(4) == 15 30 | assert cursor.delegate.value == 15 31 | 32 | 33 | def test_chaining_proxy(): 34 | class Delegate: 35 | def __init__(self, value): 36 | self.value = value 37 | 38 | def foo(self, value=1): 39 | self.value += value 40 | return self.value 41 | 42 | class FooCursor(Cursor): 43 | foo = _ChainingProxy("foo") 44 | 45 | delegate = Delegate(10) 46 | cursor = FooCursor(delegate) 47 | 48 | assert cursor.foo().foo(4) is cursor 49 | assert cursor.delegate.value == 15 50 | 51 | 52 | def test_binding_proxy(): 53 | class Delegate: 54 | def __init__(self): 55 | self.document = {} 56 | 57 | def foo(self, key, value): 58 | self.document[key] = value 59 | return self.document 60 | 61 | class FooCursor(Cursor): 62 | foo = _BindingProxy("foo") 63 | 64 | cursor = FooCursor(Delegate(), thingy_cls=Thingy) 65 | foo = cursor.foo("bar", "baz") 66 | 67 | assert isinstance(foo, Thingy) 68 | assert foo.bar == "baz" 69 | 70 | 71 | def test_cursor_result_cls(): 72 | cursor = Cursor(None) 73 | assert cursor.result_cls == list 74 | 75 | class Foo(Thingy): 76 | pass 77 | 78 | cursor = Cursor(None, thingy_cls=Foo) 79 | assert cursor.result_cls == ThingyList 80 | 81 | class Foo(Thingy): 82 | _result_cls = set 83 | 84 | cursor = Cursor(None, thingy_cls=Foo) 85 | assert cursor.result_cls == set 86 | 87 | 88 | def test_cursor_bind(): 89 | cursor = Cursor(None) 90 | result = cursor.bind({"foo": "bar"}) 91 | assert isinstance(result, dict) 92 | assert result["foo"] == "bar" 93 | 94 | class Foo(Thingy): 95 | pass 96 | 97 | cursor = Cursor(None, thingy_cls=Foo) 98 | result = cursor.bind({"foo": "bar"}) 99 | assert isinstance(result, Foo) 100 | assert result.foo == "bar" 101 | 102 | 103 | def test_cursor_first(thingy_cls, collection): 104 | collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 105 | 106 | cursor = Cursor(collection.find()) 107 | 108 | result = cursor.first() 109 | assert isinstance(result, dict) 110 | assert result["bar"] == "baz" 111 | 112 | class Foo(thingy_cls): 113 | _collection = collection 114 | 115 | cursor = Cursor(collection.find(), thingy_cls=Foo).sort("_id", -1) 116 | 117 | result = cursor.first() 118 | assert isinstance(result, Foo) 119 | assert result.bar == "qux" 120 | 121 | collection.insert_one({}) 122 | assert cursor.first() is not result 123 | 124 | cursor = Cursor(collection.find({"impossible": True})) 125 | assert cursor.first() is None 126 | 127 | 128 | async def test_async_cursor_first(thingy_cls, collection): 129 | await collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 130 | 131 | cursor = AsyncCursor(collection.find()) 132 | 133 | result = await cursor.first() 134 | assert isinstance(result, dict) 135 | assert result["bar"] == "baz" 136 | 137 | class Foo(thingy_cls): 138 | _collection = collection 139 | 140 | cursor = AsyncCursor(collection.find(), thingy_cls=Foo).sort("_id", -1) 141 | 142 | result = await cursor.first() 143 | assert isinstance(result, Foo) 144 | assert result.bar == "qux" 145 | 146 | await collection.insert_one({}) 147 | assert await cursor.first() is not result 148 | 149 | cursor = AsyncCursor(collection.find({"impossible": True})) 150 | assert await cursor.first() is None 151 | 152 | 153 | def test_cursor_getitem(collection): 154 | collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 155 | 156 | cursor = Cursor(collection.find()) 157 | 158 | result = cursor[0] 159 | assert isinstance(result, dict) 160 | assert result["bar"] == "baz" 161 | 162 | result = cursor[1] 163 | assert isinstance(result, dict) 164 | assert result["bar"] == "qux" 165 | 166 | class Foo(Thingy): 167 | _collection = collection 168 | 169 | cursor = Cursor(collection.find(), thingy_cls=Foo) 170 | 171 | result = cursor[0] 172 | assert isinstance(result, Foo) 173 | assert result.bar == "baz" 174 | 175 | result = cursor[1] 176 | assert isinstance(result, Foo) 177 | assert result.bar == "qux" 178 | 179 | 180 | def test_cursor_clone(collection): 181 | collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 182 | 183 | cursor = Cursor(collection.find()) 184 | 185 | clone = cursor.clone() 186 | assert clone is not cursor 187 | assert clone.delegate is not cursor.delegate 188 | 189 | cursor.skip(1) 190 | assert cursor.first()["bar"] == "qux" 191 | assert clone.first()["bar"] == "baz" 192 | assert cursor.clone().first()["bar"] == "qux" 193 | 194 | 195 | async def test_async_cursor_clone(collection): 196 | await collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 197 | 198 | cursor = AsyncCursor(collection.find()) 199 | 200 | clone = cursor.clone() 201 | assert clone is not cursor 202 | assert clone.delegate is not cursor.delegate 203 | 204 | cursor.skip(1) 205 | assert (await cursor.first())["bar"] == "qux" 206 | assert (await clone.first())["bar"] == "baz" 207 | assert (await cursor.clone().first())["bar"] == "qux" 208 | 209 | 210 | def test_cursor_next(thingy_cls, collection): 211 | class Foo(thingy_cls): 212 | _collection = collection 213 | 214 | collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 215 | cursor = Cursor(collection.find(), thingy_cls=Foo) 216 | 217 | result = cursor.next() 218 | assert isinstance(result, Foo) 219 | assert result.bar == "baz" 220 | 221 | result = cursor.__next__() 222 | assert isinstance(result, Foo) 223 | assert result.bar == "qux" 224 | 225 | 226 | async def test_async_cursor_next(thingy_cls, collection): 227 | class Foo(thingy_cls): 228 | _collection = collection 229 | 230 | await collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 231 | cursor = AsyncCursor(collection.find(), thingy_cls=Foo) 232 | 233 | result = await cursor.next() 234 | assert isinstance(result, Foo) 235 | assert result.bar == "baz" 236 | 237 | result = await cursor.__anext__() 238 | assert isinstance(result, Foo) 239 | assert result.bar == "qux" 240 | 241 | 242 | def test_cursor_to_list(thingy_cls, collection): 243 | class Foo(thingy_cls): 244 | _collection = collection 245 | 246 | collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 247 | cursor = Cursor(collection.find(), thingy_cls=Foo) 248 | 249 | results = cursor.to_list(length=10) 250 | assert isinstance(results, ThingyList) 251 | 252 | assert isinstance(results[0], Foo) 253 | assert results[0].bar == "baz" 254 | 255 | assert isinstance(results[1], Foo) 256 | assert results[1].bar == "qux" 257 | 258 | 259 | async def test_async_cursor_to_list(thingy_cls, collection): 260 | class Foo(thingy_cls): 261 | _collection = collection 262 | 263 | await collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 264 | cursor = AsyncCursor(collection.find(), thingy_cls=Foo) 265 | 266 | results = await cursor.to_list(length=10) 267 | assert isinstance(results, ThingyList) 268 | 269 | assert isinstance(results[0], Foo) 270 | assert results[0].bar == "baz" 271 | 272 | assert isinstance(results[1], Foo) 273 | assert results[1].bar == "qux" 274 | 275 | 276 | def test_cursor_view(thingy_cls, collection): 277 | class Foo(thingy_cls): 278 | _collection = collection 279 | 280 | Foo.add_view("empty") 281 | collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 282 | 283 | for dictionnary in Cursor(collection.find(), thingy_cls=Foo, view="empty"): 284 | assert dictionnary == {} 285 | 286 | for dictionnary in Cursor(collection.find(), thingy_cls=Foo).view("empty"): 287 | assert dictionnary == {} 288 | 289 | for dictionnary in Foo.find(view="empty"): 290 | assert dictionnary == {} 291 | 292 | for dictionnary in Foo.find().view("empty"): 293 | assert dictionnary == {} 294 | 295 | 296 | async def test_async_cursor_view(thingy_cls, collection): 297 | class Foo(thingy_cls): 298 | _collection = collection 299 | 300 | Foo.add_view("empty") 301 | await collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 302 | 303 | async for dictionnary in AsyncCursor( 304 | collection.find(), thingy_cls=Foo, view="empty" 305 | ): 306 | assert dictionnary == {} 307 | 308 | async for dictionnary in AsyncCursor(collection.find(), thingy_cls=Foo).view( 309 | "empty" 310 | ): 311 | assert dictionnary == {} 312 | 313 | async for dictionnary in Foo.find(view="empty"): 314 | assert dictionnary == {} 315 | 316 | async for dictionnary in Foo.find().view("empty"): 317 | assert dictionnary == {} 318 | 319 | 320 | @pytest.mark.ignore_backends("montydb") 321 | def test_cursor_delete(thingy_cls, collection): 322 | class Foo(thingy_cls): 323 | _collection = collection 324 | 325 | collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 326 | Foo.find().delete() 327 | assert collection.count_documents({}) == 0 328 | 329 | collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 330 | Foo.find({"bar": "baz"}).delete() 331 | assert collection.count_documents({}) == 1 332 | assert Foo.find_one().bar == "qux" 333 | 334 | 335 | async def test_async_cursor_delete(thingy_cls, collection): 336 | class Foo(thingy_cls): 337 | _collection = collection 338 | 339 | await collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 340 | await Foo.find().delete() 341 | assert await collection.count_documents({}) == 0 342 | 343 | await collection.insert_many([{"bar": "baz"}, {"bar": "qux"}]) 344 | await Foo.find({"bar": "baz"}).delete() 345 | assert await collection.count_documents({}) == 1 346 | assert (await Foo.find_one()).bar == "qux" 347 | -------------------------------------------------------------------------------- /tests/versioned.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mongo_thingy.cursor import AsyncCursor, Cursor 4 | 5 | 6 | def test_revision_save(TestRevision): 7 | revision = TestRevision().save() 8 | assert revision.creation_date 9 | 10 | 11 | async def test_async_revision_save(TestRevision): 12 | revision = await TestRevision().save() 13 | assert revision.creation_date 14 | 15 | 16 | @pytest.mark.ignore_backends("montydb") 17 | def test_revision_indexes(TestRevision): 18 | TestRevision.create_indexes() 19 | indexes = TestRevision.collection.index_information() 20 | assert "document_id_-1_document_type_-1" in indexes 21 | 22 | 23 | async def test_async_revision_indexes(TestRevision): 24 | await TestRevision.create_indexes() 25 | indexes = await TestRevision.collection.index_information() 26 | assert "document_id_-1_document_type_-1" in indexes 27 | 28 | 29 | def test_versioned_get_revisions(TestVersionedThingy): 30 | thingy = TestVersionedThingy({"bar": "baz"}).save() 31 | cursor = thingy.get_revisions() 32 | assert isinstance(cursor, Cursor) 33 | 34 | revision = cursor[0] 35 | assert revision.document == thingy.__dict__ 36 | assert revision.document_type == "TestVersionedThingy" 37 | 38 | 39 | async def test_async_versioned_get_revisions(TestVersionedThingy): 40 | thingy = await TestVersionedThingy({"bar": "baz"}).save() 41 | cursor = thingy.get_revisions() 42 | assert isinstance(cursor, AsyncCursor) 43 | 44 | revision = await cursor.first() 45 | assert revision.document == thingy.__dict__ 46 | assert revision.document_type == "TestVersionedThingy" 47 | 48 | 49 | def test_versioned_author(TestVersionedThingy): 50 | thingy = TestVersionedThingy({"bar": "baz"}).save() 51 | 52 | revisions = thingy.get_revisions() 53 | assert revisions[0].author is None 54 | assert "author" not in revisions[0].__dict__ 55 | 56 | thingy.bar = "qux" 57 | thingy.save(author={"name": "foo"}) 58 | revisions = thingy.get_revisions() 59 | assert revisions[1].author == {"name": "foo"} 60 | 61 | 62 | async def test_async_versioned_author(TestVersionedThingy): 63 | thingy = await TestVersionedThingy({"bar": "baz"}).save() 64 | 65 | revisions = await thingy.get_revisions().to_list(length=10) 66 | assert revisions[0].author is None 67 | assert "author" not in revisions[0].__dict__ 68 | 69 | thingy.bar = "qux" 70 | await thingy.save(author={"name": "foo"}) 71 | 72 | revisions = await thingy.get_revisions().to_list(length=10) 73 | assert revisions[1].author == {"name": "foo"} 74 | 75 | 76 | def test_versioned_version(TestVersionedThingy): 77 | thingy = TestVersionedThingy({"bar": "baz"}) 78 | with pytest.deprecated_call(): 79 | thingy.version 80 | assert thingy.count_revisions() == 0 81 | 82 | thingy.save() 83 | assert thingy.count_revisions() == 1 84 | 85 | 86 | async def test_async_versioned_version(TestVersionedThingy): 87 | thingy = TestVersionedThingy({"bar": "baz"}) 88 | assert await thingy.count_revisions() == 0 89 | 90 | await thingy.save() 91 | assert await thingy.count_revisions() == 1 92 | 93 | 94 | def test_versioned_revisions(TestVersionedThingy): 95 | thingy = TestVersionedThingy({"bar": "baz"}).save() 96 | thingy.bar = "qux" 97 | thingy.save() 98 | 99 | with pytest.deprecated_call(): 100 | thingy.revisions 101 | 102 | revisions = thingy.get_revisions() 103 | assert revisions[0].document.get("bar") == "baz" 104 | assert revisions[1].document.get("bar") == "qux" 105 | 106 | assert revisions[-1].id == revisions[1].id 107 | assert revisions[-2].id == revisions[0].id 108 | 109 | with pytest.raises(IndexError): 110 | revisions[-3] 111 | 112 | 113 | async def test_async_versioned_revisions(TestVersionedThingy): 114 | thingy = await TestVersionedThingy({"bar": "baz"}).save() 115 | thingy.bar = "qux" 116 | await thingy.save() 117 | 118 | revisions = await thingy.get_revisions().to_list(length=10) 119 | assert revisions[0].document.get("bar") == "baz" 120 | assert revisions[1].document.get("bar") == "qux" 121 | 122 | assert revisions[-1].id == revisions[1].id 123 | assert revisions[-2].id == revisions[0].id 124 | 125 | with pytest.raises(IndexError): 126 | revisions[-3] 127 | 128 | 129 | def test_versioned_revisions_operation(TestVersionedThingy): 130 | thingy = TestVersionedThingy({"bar": "baz"}).save() 131 | revisions = thingy.get_revisions() 132 | assert revisions[0].operation == "create" 133 | 134 | thingy.bar = "qux" 135 | thingy.save() 136 | revisions = thingy.get_revisions() 137 | assert revisions[1].operation == "update" 138 | 139 | thingy.delete() 140 | revisions = thingy.get_revisions() 141 | assert revisions[2].operation == "delete" 142 | 143 | 144 | async def test_async_versioned_revisions_operation(TestVersionedThingy): 145 | thingy = await TestVersionedThingy({"bar": "baz"}).save() 146 | revisions = await thingy.get_revisions().to_list(length=10) 147 | assert revisions[0].operation == "create" 148 | 149 | thingy.bar = "qux" 150 | await thingy.save() 151 | revisions = await thingy.get_revisions().to_list(length=10) 152 | assert revisions[1].operation == "update" 153 | 154 | await thingy.delete() 155 | revisions = await thingy.get_revisions().to_list(length=10) 156 | assert revisions[2].operation == "delete" 157 | 158 | 159 | def test_versioned_versioned(TestVersionedThingy): 160 | thingy = TestVersionedThingy({"bar": "baz"}) 161 | with pytest.deprecated_call(): 162 | thingy.versioned 163 | assert thingy.is_versioned() is False 164 | 165 | thingy.bar = "qux" 166 | assert thingy.is_versioned() is False 167 | 168 | thingy.save() 169 | assert thingy.is_versioned() is True 170 | 171 | 172 | async def test_async_versioned_versioned(TestVersionedThingy): 173 | thingy = TestVersionedThingy({"bar": "baz"}) 174 | assert await thingy.is_versioned() is False 175 | 176 | thingy.bar = "qux" 177 | assert await thingy.is_versioned() is False 178 | 179 | await thingy.save() 180 | assert await thingy.is_versioned() is True 181 | 182 | 183 | def test_versioned_revert(TestVersionedThingy): 184 | thingy = TestVersionedThingy({"bar": "baz"}).save() 185 | assert thingy.count_revisions() == 1 186 | 187 | thingy.revert() 188 | assert thingy.count_revisions() == 2 189 | assert thingy.bar is None 190 | 191 | thingy.revert() 192 | assert thingy.count_revisions() == 3 193 | assert thingy.bar == "baz" 194 | 195 | 196 | async def test_async_versioned_revert(TestVersionedThingy): 197 | thingy = await TestVersionedThingy({"bar": "baz"}).save() 198 | assert await thingy.count_revisions() == 1 199 | 200 | await thingy.revert() 201 | assert await thingy.count_revisions() == 2 202 | assert thingy.bar is None 203 | 204 | await thingy.revert() 205 | assert await thingy.count_revisions() == 3 206 | assert thingy.bar == "baz" 207 | --------------------------------------------------------------------------------