├── .gitignore ├── .travis.yml ├── README.rst ├── doc ├── Makefile ├── api │ └── index.rst ├── changelog.rst ├── conf.py ├── getting-started.rst ├── index.rst └── make.bat ├── example └── blog │ ├── README.rst │ ├── blog.py │ ├── blog_models.py │ ├── requirements.txt │ ├── static │ └── css │ │ └── style.css │ └── templates │ ├── 404.html │ ├── base.html │ ├── index.html │ ├── login.html │ ├── new_post.html │ ├── new_user.html │ └── post.html ├── ez_setup.py ├── pymodm ├── __init__.py ├── base │ ├── __init__.py │ ├── fields.py │ ├── models.py │ └── options.py ├── common.py ├── compat.py ├── connection.py ├── context_managers.py ├── dereference.py ├── errors.py ├── fields.py ├── files.py ├── manager.py ├── queryset.py ├── validators.py └── vendor.py ├── setup.py ├── test ├── __init__.py ├── field_types │ ├── __init__.py │ ├── lib │ │ ├── augustus.png │ │ ├── tempfile.txt │ │ └── testfile.txt │ ├── test_biginteger_field.py │ ├── test_binary_field.py │ ├── test_boolean_field.py │ ├── test_char_field.py │ ├── test_datetime_field.py │ ├── test_decimal128_field.py │ ├── test_dict_field.py │ ├── test_email_field.py │ ├── test_embedded_document_field.py │ ├── test_embedded_document_list_field.py │ ├── test_file_field.py │ ├── test_float_field.py │ ├── test_generic_ip_address_field.py │ ├── test_geometrycollection_field.py │ ├── test_image_field.py │ ├── test_integer_field.py │ ├── test_javascript_field.py │ ├── test_linestring_field.py │ ├── test_list_field.py │ ├── test_multilinestring_field.py │ ├── test_multipoint_field.py │ ├── test_multipolygon_field.py │ ├── test_objectid_field.py │ ├── test_ordereddict_field.py │ ├── test_point_field.py │ ├── test_polygon_field.py │ ├── test_reference_field.py │ ├── test_regular_expression_field.py │ ├── test_timestamp_field.py │ ├── test_url_field.py │ └── test_uuid_field.py ├── models.py ├── test_collation.py ├── test_connection.py ├── test_context_managers.py ├── test_delete_rules.py ├── test_dereference.py ├── test_errors.py ├── test_fields.py ├── test_manager.py ├── test_model_basic.py ├── test_model_inheritance.py ├── test_model_lazy_decoder.py ├── test_options.py ├── test_queryset.py └── test_related_fields.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *#* 3 | .DS* 4 | *.pyc 5 | *.pyd 6 | *.egg 7 | build/ 8 | dist/ 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.7 4 | - 3.4 5 | - 3.5 6 | - 3.6 7 | - 3.7 8 | - 3.8 9 | 10 | env: 11 | matrix: 12 | - MONGODB=2.6.12 13 | - MONGODB=3.0.15 14 | - MONGODB=3.2.22 15 | - MONGODB=3.4.19 16 | - MONGODB=3.6.13 17 | - MONGODB=4.0.9 18 | 19 | matrix: 20 | fast_finish: true 21 | 22 | install: 23 | - wget http://fastdl.mongodb.org/linux/mongodb-linux-x86_64-${MONGODB}.tgz 24 | - tar xzf mongodb-linux-x86_64-${MONGODB}.tgz 25 | - ${PWD}/mongodb-linux-x86_64-${MONGODB}/bin/mongod --version 26 | - pip install Pillow 27 | - python setup.py install 28 | 29 | before_script: 30 | - mkdir ${PWD}/mongodb-linux-x86_64-${MONGODB}/data 31 | - ${PWD}/mongodb-linux-x86_64-${MONGODB}/bin/mongod --dbpath ${PWD}/mongodb-linux-x86_64-${MONGODB}/data --logpath ${PWD}/mongodb-linux-x86_64-${MONGODB}/mongodb.log --fork 32 | 33 | script: 34 | - python setup.py test 35 | 36 | after_script: 37 | - pkill mongod 38 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | PyMODM 3 | ====== 4 | 5 | **MongoDB has paused the development of PyMODM.** If there are any users who want 6 | to take over and maintain this project, or if you just have questions, please respond 7 | to `this forum post `_. 8 | 9 | .. image:: https://readthedocs.org/projects/pymodm/badge/?version=stable 10 | :alt: Documentation 11 | :target: http://pymodm.readthedocs.io/en/stable/?badge=stable 12 | 13 | .. image:: https://travis-ci.org/mongodb/pymodm.svg?branch=master 14 | :alt: View build status 15 | :target: https://travis-ci.org/mongodb/pymodm 16 | 17 | A generic ODM around PyMongo_, the MongoDB Python driver. PyMODM works on Python 18 | 2.7 as well as Python 3.3 and up. To learn more, you can browse the `official 19 | documentation`_ or take a look at some `examples`_. 20 | 21 | .. _PyMongo: https://pypi.python.org/pypi/pymongo 22 | .. _official documentation: http://pymodm.readthedocs.io/en/stable 23 | .. _examples: https://github.com/mongodb/pymodm/tree/master/example 24 | 25 | Why PyMODM? 26 | =========== 27 | 28 | PyMODM is a "core" ODM, meaning that it provides simple, extensible 29 | functionality that can be leveraged by other libraries to target platforms like 30 | Django. At the same time, PyMODM is powerful enough to be used for developing 31 | applications on its own. Because MongoDB engineers are involved in developing 32 | and maintaining the project, PyMODM will also be quick to adopt new MongoDB 33 | features. 34 | 35 | Support / Feedback 36 | ================== 37 | 38 | For issues with, questions about, or feedback for PyMODM, please look into 39 | our `support channels `_. Please do not 40 | email any of the PyMODM developers directly with issues or questions - 41 | you're more likely to get an answer on the `MongoDB Community Forums `_. 42 | 43 | Bugs / Feature Requests 44 | ======================= 45 | 46 | Think you’ve found a bug? Want to see a new feature in PyMODM? Please open 47 | a case in our issue management tool, JIRA: 48 | 49 | - `Create an account and login `_. 50 | - Navigate to `the PYMODM project `_. 51 | - Click **Create Issue** - Please provide as much information as possible about the issue type and how to reproduce it. 52 | 53 | Bug reports in JIRA for all driver projects (e.g. PYMODM, PYTHON, JAVA) and the 54 | Core Server (i.e. SERVER) project are **public**. 55 | 56 | How To Ask For Help 57 | ------------------- 58 | 59 | Please include all of the following information when opening an issue: 60 | 61 | - Detailed steps to reproduce the problem, including full traceback, if possible. 62 | - The exact python version used, with patch level:: 63 | 64 | $ python -c "import sys; print(sys.version)" 65 | 66 | - The exact version of PyMODM used, with patch level:: 67 | 68 | $ python -c "import pymodm; print(pymodm.version)" 69 | 70 | - The PyMongo version used, with patch level:: 71 | 72 | $ python -c "import pymongo; print(pymongo.version)" 73 | 74 | - The operating system and version (e.g. Windows 7, OSX 10.8, ...) 75 | - Web framework or asynchronous network library used, if any, with version (e.g. 76 | Django 1.7, mod_wsgi 4.3.0, gevent 1.0.1, Tornado 4.0.2, ...) 77 | 78 | Security Vulnerabilities 79 | ------------------------ 80 | 81 | If you’ve identified a security vulnerability in a driver or any other 82 | MongoDB project, please report it according to the `instructions here 83 | `_. 84 | 85 | Example 86 | ======= 87 | 88 | Here's a basic example of how to define some models and connect them to MongoDB: 89 | 90 | .. code-block:: python 91 | 92 | from pymongo import TEXT 93 | from pymongo.operations import IndexModel 94 | from pymodm import connect, fields, MongoModel, EmbeddedMongoModel 95 | 96 | 97 | # Connect to MongoDB first. PyMODM supports all URI options supported by 98 | # PyMongo. Make sure also to specify a database in the connection string: 99 | connect('mongodb://localhost:27017/myApp') 100 | 101 | 102 | # Now let's define some Models. 103 | class User(MongoModel): 104 | # Use 'email' as the '_id' field in MongoDB. 105 | email = fields.EmailField(primary_key=True) 106 | fname = fields.CharField() 107 | lname = fields.CharField() 108 | 109 | 110 | class BlogPost(MongoModel): 111 | # This field references the User model above. 112 | # It's stored as a bson.objectid.ObjectId in MongoDB. 113 | author = fields.ReferenceField(User) 114 | title = fields.CharField(max_length=100) 115 | content = fields.CharField() 116 | tags = fields.ListField(fields.CharField(max_length=20)) 117 | # These Comment objects will be stored inside each Post document in the 118 | # database. 119 | comments = fields.EmbeddedModelListField('Comment') 120 | 121 | class Meta: 122 | # Text index on content can be used for text search. 123 | indexes = [IndexModel([('content', TEXT)])] 124 | 125 | # This is an "embedded" model and will be stored as a sub-document. 126 | class Comment(EmbeddedMongoModel): 127 | author = fields.ReferenceField(User) 128 | body = fields.CharField() 129 | vote_score = fields.IntegerField(min_value=0) 130 | 131 | 132 | # Start the blog. 133 | # We need to save these objects before referencing them later. 134 | han_solo = User('mongoblogger@reallycoolmongostuff.com', 'Han', 'Solo').save() 135 | chewbacca = User( 136 | 'someoneelse@reallycoolmongostuff.com', 'Chewbacca', 'Thomas').save() 137 | 138 | 139 | post = BlogPost( 140 | # Since this is a ReferenceField, we had to save han_solo first. 141 | author=han_solo, 142 | title="Five Crazy Health Foods Jabba Eats.", 143 | content="...", 144 | tags=['alien health', 'slideshow', 'jabba', 'huts'], 145 | comments=[ 146 | Comment(author=chewbacca, body='Rrrrrrrrrrrrrrrr!', vote_score=42) 147 | ] 148 | ).save() 149 | 150 | 151 | # Find objects using familiar MongoDB-style syntax. 152 | slideshows = BlogPost.objects.raw({'tags': 'slideshow'}) 153 | 154 | # Only retrieve the 'title' field. 155 | slideshow_titles = slideshows.only('title') 156 | 157 | # u'Five Crazy Health Foods Jabba Eats.' 158 | print(slideshow_titles.first().title) 159 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PyMODM.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PyMODM.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/PyMODM" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PyMODM" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /doc/api/index.rst: -------------------------------------------------------------------------------- 1 | .. _api-documentation: 2 | 3 | API Documentation 4 | ================= 5 | 6 | .. warning:: 7 | **MongoDB has paused the development of PyMODM.** If there are any users who want 8 | to take over and maintain this project, or if you just have questions, please respond 9 | to `this forum post `_. 10 | 11 | Welcome to the PyMODM API documentation. 12 | 13 | Connecting 14 | ---------- 15 | 16 | .. automodule:: pymodm.connection 17 | 18 | .. autofunction:: connect 19 | 20 | Defining Models 21 | --------------- 22 | 23 | .. autoclass:: pymodm.MongoModel 24 | :members: 25 | :undoc-members: 26 | :inherited-members: 27 | 28 | .. autoclass:: pymodm.EmbeddedMongoModel 29 | :members: 30 | :undoc-members: 31 | :inherited-members: 32 | 33 | Model Fields 34 | ------------ 35 | 36 | .. autoclass:: pymodm.base.fields.MongoBaseField 37 | :members: 38 | 39 | .. automodule:: pymodm.fields 40 | :members: 41 | :exclude-members: GeoJSONField, ReferenceField, GenericIPAddressField 42 | 43 | .. autoclass:: GenericIPAddressField 44 | :members: 45 | :member-order: bysource 46 | 47 | .. autoclass:: ReferenceField 48 | :members: 49 | :member-order: bysource 50 | 51 | Managers 52 | -------- 53 | 54 | .. automodule:: pymodm.manager 55 | :members: 56 | :undoc-members: 57 | 58 | QuerySet 59 | -------- 60 | 61 | .. automodule:: pymodm.queryset 62 | :members: 63 | :undoc-members: 64 | 65 | Working with Files 66 | ------------------ 67 | 68 | .. automodule:: pymodm.files 69 | :members: 70 | :undoc-members: 71 | 72 | Context Managers 73 | ---------------- 74 | 75 | .. automodule:: pymodm.context_managers 76 | :members: 77 | 78 | Errors 79 | ------ 80 | 81 | .. automodule:: pymodm.errors 82 | :members: 83 | -------------------------------------------------------------------------------- /doc/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | .. warning:: 5 | **MongoDB has paused the development of PyMODM.** If there are any users who want 6 | to take over and maintain this project, or if you just have questions, please respond 7 | to `this forum post `_. 8 | 9 | Version 0.5.0.dev0 10 | ------------------ 11 | 12 | This release fixes a number of bug-fixes and improvements, including: 13 | 14 | * Rename EmbeddedDocumentField to EmbeddedModelField and 15 | EmbeddedDocumentListField to EmbeddedModelListField. 16 | * Deprecate EmbeddedDocumentField and EmbeddedDocumentListField. 17 | 18 | For full list of the issues resolved in this release, visit 19 | https://jira.mongodb.org/secure/ReleaseNote.jspa?projectId=13381&version=21201. 20 | 21 | 22 | Version 0.4.1 23 | ------------- 24 | 25 | This release includes a number of bug-fixes and improvements, including: 26 | 27 | * Improve documentation. 28 | * Improved support defining models before calling 29 | :meth:`~pymodm.connection.connect`. 30 | * A field's :meth:`~pymodm.base.fields.MongoBaseField.to_python` method is no 31 | longer called on every access. It is only called on the first access after a 32 | call to :meth:`~pymodm.MongoModel.refresh_from_db` or, on the 33 | first access after a field is set. 34 | * Fixed bug when appending to an empty :class:`~pymodm.fields.ListField`. 35 | 36 | For full list of the issues resolved in this release, visit 37 | https://jira.mongodb.org/secure/ReleaseNote.jspa?projectId=13381&version=18194. 38 | 39 | 40 | Version 0.4.0 41 | ------------- 42 | 43 | This release fixes a couple problems from the previous 0.3 release and adds a 44 | number of new features, including: 45 | 46 | * Support for callable field defaults. 47 | * Change default values for DictField, OrderedDictField, ListField, and 48 | EmbeddedDocumentListField to be the empty value for their respective 49 | containers instead of None. 50 | * Add the `ignore_unknown_fields` 51 | :ref:`metadata attribute ` which allows unknown 52 | fields when parsing documents into a :class:`~pymodm.MongoModel`. 53 | Note that with this option enabled, calling :meth:`~pymodm.MongoModel.save` 54 | will erase these fields for that model instance. 55 | * Add :meth:`pymodm.queryset.QuerySet.reverse`. 56 | * Properly check that the `mongo_name` parameter to 57 | :class:`~pymodm.base.fields.MongoBaseField` 58 | and all keys in :class:`~pymodm.fields.DictField` and 59 | :class:`~pymodm.fields.OrderedDictField` are valid MongoDB field names. 60 | * Fix multiple issues in dereferencing fields thanks to 61 | https://github.com/ilex. 62 | 63 | 64 | For full list of the issues resolved in this release, visit 65 | https://jira.mongodb.org/browse/PYMODM/fixforversion/17785. 66 | 67 | Version 0.3.0 68 | ------------- 69 | 70 | This release fixes a couple problems from the previous 0.2 release and adds a 71 | number of new features, including: 72 | 73 | * Support for `collations`_ in MongoDB 3.4 74 | * Add a :meth:`pymodm.queryset.QuerySet.project` method to 75 | :class:`pymodm.queryset.QuerySet`. 76 | * Allow :class:`~pymodm.fields.DateTimeField` to parse POSIX timestamps 77 | (i.e. seconds from the epoch). 78 | * Fix explicit validation of blank fields. 79 | 80 | For full list of the issues resolved in this release, visit 81 | https://jira.mongodb.org/browse/PYMODM/fixforversion/17662. 82 | 83 | .. _collations: https://docs.mongodb.com/manual/reference/collation/ 84 | 85 | Version 0.2.0 86 | ------------- 87 | 88 | This version fixes a few issues and allows defining indexes inside the `Meta` 89 | class in a model. 90 | 91 | For a complete list of the issues resolved in this release, visit 92 | https://jira.mongodb.org/browse/PYMODM/fixforversion/17609. 93 | 94 | Version 0.1.0 95 | ------------- 96 | 97 | This version is the very first release of PyMODM. 98 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. PyMODM documentation master file, created by 2 | sphinx-quickstart on Thu Mar 31 11:45:09 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | PyMODM |release| Documentation 7 | ============================== 8 | 9 | .. warning:: 10 | **MongoDB has paused the development of PyMODM.** If there are any users who want 11 | to take over and maintain this project, or if you just have questions, please respond 12 | to `this forum post `_. 13 | 14 | Overview 15 | -------- 16 | 17 | **PyMODM** is a generic ODM on top of `PyMongo`_, the MongoDB Python 18 | driver. This documentation attempts to explain everything you need to know to 19 | use **PyMODM**. In addition to this documentation, you can also check out 20 | `example`_ code as part of the PyMODM project on Github. 21 | 22 | .. _PyMongo: https://pypi.python.org/pypi/pymongo 23 | .. _example: https://github.com/mongodb/pymodm/tree/master/example 24 | 25 | Contents: 26 | 27 | :doc:`getting-started` 28 | Start here for a basic overview of using PyMODM. 29 | 30 | :doc:`api/index` 31 | The complete API documentation. 32 | 33 | .. toctree:: 34 | :hidden: 35 | 36 | api/index 37 | getting-started 38 | changelog 39 | 40 | Changes 41 | ------- 42 | 43 | See the :doc:`changelog` for a full list of changes to PyMODM. 44 | 45 | Indices and tables 46 | ================== 47 | 48 | * :ref:`genindex` 49 | * :ref:`modindex` 50 | * :ref:`search` 51 | -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\PyMODM.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\PyMODM.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /example/blog/README.rst: -------------------------------------------------------------------------------- 1 | PyMODM Blog Example 2 | ------------------- 3 | 4 | This example is a simple blog that demonstrates how to define models, query and 5 | save objects using PyMODM. You are invited to run this example, make 6 | modifications, and use this as a learning tool or even a template to get started 7 | with your own project. 8 | 9 | 10 | Project Structure 11 | ................. 12 | 13 | This project is divided into several files: 14 | 15 | - ``blog.py``: This is where the main application logic lives. 16 | - ``blog_models.py``: MongoModel definitions live here. 17 | - ``static``: This directory contains static files, like css. 18 | - ``templates``: This directory contains `Jinja2`_ HTML templates. 19 | 20 | .. _Jinja2: https://pypi.python.org/pypi/Jinja2 21 | 22 | 23 | Running the Example 24 | ................... 25 | 26 | Follow these directions to start the blog: 27 | 28 | 1. Make sure that MongoDB is running on ``localhost:27017``. `Download MongoDB 29 | `_ if you don't have it installed already. 30 | 31 | 2. Install prerequisite software: this example depends on `flask`_ and 32 | `pymodm`_. You can install these dependencies automatically by running ``pip 33 | install -r requirements.txt``. 34 | 35 | 3. Start the blog by running ``python blog.py``. 36 | 37 | 4. Visit the main page in your browser at `http://localhost:5000 38 | `_. 39 | 40 | .. _pymodm: https://pypi.python.org/pypi/pymodm 41 | .. _flask: https://pypi.python.org/pypi/Flask 42 | 43 | 44 | Site Map 45 | ........ 46 | 47 | After the blog application has been started, there are several URLs available: 48 | 49 | - **http://localhost:5000**: This is the home page. It displays all of the posts 50 | in a summarized form. 51 | - **http://localhost:5000/posts/**: This displays the long form of the 52 | post with the id ``post_id``. It also displays a form for submitting comments 53 | on the post. 54 | - **http://localhost:5000/posts/new**: When sent a ``GET`` request, this returns 55 | a page that displays a form for creating a new post. When sent a ``POST`` 56 | request, this endpoint creates a new post from the form data. 57 | - **http://localhost:5000/comments/new**: This is the endpoint for creating a 58 | new comment object. This endpoint only accepts ``POST`` requests (the form for 59 | creating a new comment is rendered at ``posts/post_id``). 60 | - **http://localhost:5000/users/new**: When sent a ``GET`` request, this returns 61 | a page that displays a form for creating a new user (every post is associated 62 | with a user). When sent a ``POST`` request, this endpoint creates a new user 63 | from the form data. 64 | - **http://localhost:5000/login**: When sent a ``GET`` request, this returns a 65 | page that displays a form for logging in. When sent a ``POST`` request, this 66 | endpoint logs the user in and sets a cookie for the session. 67 | - **http://localhost:5000/logout**: This endpoint logs the user out. This 68 | endpoint only accepts ``POST`` requests. 69 | -------------------------------------------------------------------------------- /example/blog/blog.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import functools 3 | import uuid 4 | 5 | from bson.objectid import ObjectId 6 | from flask import ( 7 | Flask, flash, render_template, session, request, redirect, url_for) 8 | from pymodm.errors import ValidationError 9 | from pymongo.errors import DuplicateKeyError 10 | 11 | from blog_models import User, Post, Comment 12 | 13 | # Secret key used to encrypt session cookies. 14 | # We'll just generate one randomly when the app starts up, since this is just 15 | # a demonstration project. 16 | SECRET_KEY = str(uuid.uuid4()) 17 | 18 | 19 | app = Flask(__name__) 20 | app.secret_key = SECRET_KEY 21 | 22 | 23 | def human_date(value, format="%B %d at %I:%M %p"): 24 | """Format a datetime object to be human-readable in a template.""" 25 | return value.strftime(format) 26 | app.jinja_env.filters['human_date'] = human_date 27 | 28 | 29 | def logged_in(func): 30 | """Decorator that redirects to login page if a user is not logged in.""" 31 | @functools.wraps(func) 32 | def wrapper(*args, **kwargs): 33 | if 'user' not in session: 34 | return redirect(url_for('login')) 35 | return func(*args, **kwargs) 36 | return wrapper 37 | 38 | 39 | @app.route('/posts/new', methods=['GET', 'POST']) 40 | @logged_in 41 | def create_post(): 42 | if request.method == 'GET': 43 | return render_template('new_post.html') 44 | else: 45 | if request.form['date']: 46 | post_date = request.form['date'] 47 | else: 48 | post_date = datetime.datetime.now() 49 | try: 50 | Post(title=request.form['title'], 51 | date=post_date, 52 | body=request.form['content'], 53 | author=session['user']).save() 54 | except ValidationError as exc: 55 | return render_template('new_post.html', errors=exc.message) 56 | return redirect(url_for('index')) 57 | 58 | 59 | @app.route('/login', methods=['GET', 'POST']) 60 | def login(): 61 | if request.method == 'GET': 62 | # Return login form. 63 | return render_template('login.html') 64 | else: 65 | # Login. 66 | email, password = request.form['email'], request.form['password'] 67 | try: 68 | # Note: logging users in like this is acceptable for demonstration 69 | # projects only. 70 | user = User.objects.get({'_id': email, 'password': password}) 71 | except User.DoesNotExist: 72 | return render_template('login.html', error='Bad email or password') 73 | else: 74 | # Store user in the session. 75 | session['user'] = user.email 76 | return redirect(url_for('index')) 77 | 78 | 79 | @app.route('/logout') 80 | def logout(): 81 | session.pop('user', None) 82 | flash('You have been successfully logged out.') 83 | return redirect(url_for('index')) 84 | 85 | 86 | @app.route('/users/new', methods=['GET', 'POST']) 87 | def new_user(): 88 | if request.method == 'GET': 89 | return render_template('new_user.html') 90 | else: 91 | try: 92 | # Note: real applications should handle user registration more 93 | # securely than this. 94 | User(email=request.form['email'], 95 | handle=request.form['handle'], 96 | # Use `force_insert` so that we get a DuplicateKeyError if 97 | # another user already exists with the same email address. 98 | # Without this option, we will update (replace) the user with 99 | # the same id (email). 100 | password=request.form['password']).save(force_insert=True) 101 | except ValidationError as ve: 102 | return render_template('new_user.html', errors=ve.message) 103 | except DuplicateKeyError: 104 | # Email address must be unique. 105 | return render_template('new_user.html', errors={ 106 | 'email': 'There is already a user with that email address.' 107 | }) 108 | return redirect(url_for('index')) 109 | 110 | 111 | @app.route('/') 112 | def index(): 113 | # Use a list here so we can do "if posts" efficiently in the template. 114 | return render_template('index.html', posts=list(Post.objects.all())) 115 | 116 | 117 | @app.route('/posts/') 118 | def get_post(post_id): 119 | try: 120 | # `post_id` is a string, but it's stored as an ObjectId in the database. 121 | post = Post.objects.get({'_id': ObjectId(post_id)}) 122 | except Post.DoesNotExist: 123 | return render_template('404.html'), 404 124 | return render_template('post.html', post=post) 125 | 126 | 127 | @app.route('/comments/new', methods=['POST']) 128 | def new_comment(): 129 | post_id = ObjectId(request.form['post_id']) 130 | try: 131 | post = Post.objects.get({'_id': post_id}) 132 | except Post.DoesNotExist: 133 | flash('No post with id: %s' % post_id) 134 | return redirect(url_for('index')) 135 | comment = Comment( 136 | author=request.form['author'], 137 | date=datetime.datetime.now(), 138 | body=request.form['content']) 139 | post.comments.append(comment) 140 | try: 141 | post.save() 142 | except ValidationError as e: 143 | post.comments.pop() 144 | comment_errors = e.message['comments'][-1] 145 | return render_template('post.html', post=post, errors=comment_errors) 146 | flash('Comment saved successfully.') 147 | return render_template('post.html', post=post) 148 | 149 | 150 | def main(): 151 | app.run(debug=True) 152 | 153 | 154 | if __name__ == '__main__': 155 | main() 156 | -------------------------------------------------------------------------------- /example/blog/blog_models.py: -------------------------------------------------------------------------------- 1 | from pymodm import MongoModel, EmbeddedMongoModel, fields, connect 2 | 3 | 4 | # Establish a connection to the database. 5 | connect('mongodb://localhost:27017/blog') 6 | 7 | 8 | class User(MongoModel): 9 | # Make all these fields required, so that if we try to save a User instance 10 | # that lacks one of these fields, we'll get a ValidationError, which we can 11 | # catch and render as an error on a form. 12 | # 13 | # Use the email as the "primary key" (will be stored as `_id` in MongoDB). 14 | email = fields.EmailField(primary_key=True, required=True) 15 | handle = fields.CharField(required=True) 16 | # `password` here will be stored in plain text! We do this for simplicity of 17 | # the example, but this is not a good idea in general. A real authentication 18 | # system should only store hashed passwords, and queries for a matching 19 | # user/password will need to hash the password portion before of the query. 20 | password = fields.CharField(required=True) 21 | 22 | 23 | # This is an EmbeddedMongoModel, which means that it will be stored *inside* 24 | # another document (i.e. a Post), rather than getting its own collection. This 25 | # makes it very easy to retrieve all comments with a Post, but we might consider 26 | # breaking out Comment into its own top-level MongoModel if we were expecting to 27 | # have very many comments for every Post. 28 | class Comment(EmbeddedMongoModel): 29 | # For comments, we just want an email. We don't require signup like we do 30 | # for a Post, which has an 'author' field that is a ReferenceField to User. 31 | # Again, we make all fields required so that we get a ValidationError if we 32 | # try to save a Comment instance that lacks one of these fields. We can 33 | # catch this error and render it in a form, telling the user that one or 34 | # more fields still need to be filled. 35 | author = fields.EmailField(required=True) 36 | date = fields.DateTimeField(required=True) 37 | body = fields.CharField(required=True) 38 | 39 | 40 | class Post(MongoModel): 41 | # We set "blank=False" so that values like the empty string (i.e. u'') 42 | # aren't considered valid. We want a real title. As above, we also make 43 | # most fields required here. 44 | title = fields.CharField(required=True, blank=False) 45 | body = fields.CharField(required=True) 46 | date = fields.DateTimeField(required=True) 47 | author = fields.ReferenceField(User, required=True) 48 | # Comments will be stored as a list of embedded documents, rather than 49 | # documents in their own collection. We also set "default=[]" so that we can 50 | # always do: 51 | # 52 | # post.comments.append(Comment(...)) 53 | # 54 | # instead of: 55 | # 56 | # if post.comments: 57 | # post.comments.append(Comment(...)) 58 | # else: 59 | # post.comments = [Comment(...)] 60 | comments = fields.EmbeddedModelListField(Comment, default=[]) 61 | 62 | @property 63 | def summary(self): 64 | """Return at most 100 characters of the body.""" 65 | if len(self.body) > 100: 66 | return self.body[:97] + '...' 67 | return self.body 68 | -------------------------------------------------------------------------------- /example/blog/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask>=1.1,<2.0 2 | Jinja2>=2.10,<3.0 3 | pymodm 4 | -------------------------------------------------------------------------------- /example/blog/static/css/style.css: -------------------------------------------------------------------------------- 1 | span.post-author, span.post-date { 2 | font-style: italic; 3 | } 4 | 5 | ul.post-list, ul.comments { 6 | list-style-type: none; 7 | padding-left: 0; 8 | } 9 | 10 | li.post-item, li.comment { 11 | border-left: solid 1px; 12 | padding-left: 5px; 13 | } 14 | 15 | div.comment-body { 16 | display: block; 17 | } 18 | 19 | li.comment { 20 | margin-top: 5px; 21 | } 22 | 23 | div.form-input { 24 | display: block; 25 | } 26 | 27 | textarea { 28 | display: block; 29 | } 30 | 31 | li.error-item { 32 | color: red; 33 | } -------------------------------------------------------------------------------- /example/blog/templates/404.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "base.html" %} 3 | {% block title %}Page Not Found{% endblock %} 4 | {% block body %} 5 |

The page you were looking could not be found.

6 | Go back. 7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /example/blog/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | {% macro input(name, label='', value='', type='text') -%} 3 |
4 | 5 | 6 |
7 | {%- endmacro %} 8 | 9 | {% macro list_errors(errors) -%} 10 |
    11 | {% for error in errors %} 12 |
  • {{ error }}: 13 |
      14 | {% for error_item in errors[error] %} 15 |
    • {{ error_item }}
    • 16 | {% endfor %} 17 |
    18 |
  • 19 | {% endfor %} 20 |
21 | {%- endmacro %} 22 | 23 | 24 | 25 | 26 | {% block title %}My Sweet Blog{% endblock title %} 27 | 28 | 29 | 30 |

{{ self.title() }}

31 | {% with flashed_messages = get_flashed_messages() %} 32 | {% if flashed_messages %} 33 |
    34 | {% for message in flashed_messages %} 35 |
  • {{ message }}
  • 36 | {% endfor %} 37 |
38 | {% endif %} 39 | {% endwith %} 40 | {% block body %}{% endblock %} 41 | 42 | 43 | -------------------------------------------------------------------------------- /example/blog/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "base.html" %} 3 | {% macro render_post(post) -%} 4 |
5 |

6 | {{ post.title }} 7 |

8 | Published by 9 | on . 10 |

{{ post.summary }}

11 |
12 | {%- endmacro %} 13 | 14 | {% block title %}My Sweet Blog{% endblock %} 15 | {% block body %} 16 | {% if 'user' in session %} 17 |

Logged in as {{ session['user'] }}. Log out

18 | {% else %} 19 |

You are not logged in.

20 | {% endif %} 21 | {% if posts %} 22 |
    23 | {% for post in posts %} 24 |
  • 25 | {{ render_post(post) }} 26 |
  • 27 | {% endfor %} 28 |
29 | {% else %} 30 |

Nothing to see yet! Come back soon and check for updates.

31 | {% endif %} 32 | Create a new post. 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /example/blog/templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "base.html" %} 3 | {% block title %}Login{% endblock %} 4 | {% block body %} 5 | {% if error %} 6 |

{{ error }}

7 | {% endif %} 8 |
9 | {{ input("email") }} 10 | {{ input("password", type="password") }} 11 | 12 |
13 |

14 | Don't have an account yet? 15 | Sign Up! 16 |

17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /example/blog/templates/new_post.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "base.html" %} 3 | {% block title %}Create a new Post{% endblock %} 4 | {% block body %} 5 | {% if errors %} 6 |

There were some problems:

7 | {{ list_errors(errors) }} 8 | {% endif %} 9 |
10 | {{ input("title") }} 11 | {{ input("date", label="date YYYY-MM-DD (blank for today, right now)", type="datetime") }} 12 | 13 |
14 | 15 | 16 |
17 | 18 | 19 |
20 | {% endblock body %} 21 | -------------------------------------------------------------------------------- /example/blog/templates/new_user.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "base.html" %} 3 | {% block title %}New User{% endblock %} 4 | {% block body %} 5 | {% if errors %} 6 |

There were some problems:

7 | {{ list_errors(errors) }} 8 | {% endif %} 9 |
10 | {{ input("email") }} 11 | {{ input("handle") }} 12 | {{ input("password", type="password") }} 13 | 14 | 15 |

16 | Already signed up? Log in. 17 |

18 |
19 | {% endblock body %} 20 | -------------------------------------------------------------------------------- /example/blog/templates/post.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "base.html" %} 3 | {% block title %}{{ post.title }}{% endblock %} 4 | {% block body %} 5 |

6 | Published by {{ post.author.handle }} 7 | on {{ post.date|human_date }}. 8 |

9 |

10 | {{ post.body }} 11 |

12 | 13 |
14 | 15 |
16 | {% if post.comments %} 17 |

Comments

18 |
    19 | {% for comment in post.comments %} 20 |
  • 21 | {{ comment.author }} on 22 | {{ comment.date|human_date }} said: 23 |
    24 | {{ comment.body }} 25 |
    26 |
  • 27 | {% endfor %} 28 |
29 | {% else %} 30 |

No comments yet. Be the first one!

31 | {% endif %} 32 |

Add a Comment

33 | {% if errors %} 34 |

There were some problems with your comment:

35 | {{ list_errors(errors) }} 36 | {% endif %} 37 |
38 | {{ input('author', label='email') }} 39 | 40 | 41 | 42 | 43 |
44 |
45 | Back to main page. 46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /pymodm/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm.base import MongoModel, EmbeddedMongoModel 16 | from pymodm.connection import connect 17 | from pymodm.fields import * 18 | 19 | from pymodm import base, connection, fields 20 | 21 | __all__ = fields.__all__ + connection.__all__ + base.__all__ 22 | 23 | version = '0.5.0.dev0' 24 | -------------------------------------------------------------------------------- /pymodm/base/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm.base.models import MongoModel, EmbeddedMongoModel 16 | 17 | from pymodm.base import models 18 | 19 | __all__ = models.__all__ 20 | -------------------------------------------------------------------------------- /pymodm/base/options.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from bisect import bisect 16 | 17 | from bson.codec_options import CodecOptions 18 | 19 | from pymodm.connection import _get_db, DEFAULT_CONNECTION_ALIAS 20 | from pymodm.errors import InvalidModel 21 | from pymodm.fields import RelatedEmbeddedModelFieldsBase 22 | 23 | # Attributes that can be user-specified in MongoOptions. 24 | DEFAULT_NAMES = ( 25 | 'connection_alias', 'collection_name', 'codec_options', 'final', 26 | 'cascade', 'read_preference', 'read_concern', 'write_concern', 27 | 'indexes', 'collation', 'ignore_unknown_fields') 28 | 29 | 30 | class MongoOptions(object): 31 | """Base class for metadata stored in Model classes.""" 32 | 33 | def __init__(self, meta=None): 34 | self.meta = meta 35 | self.connection_alias = DEFAULT_CONNECTION_ALIAS 36 | self.collection_name = None 37 | self.codec_options = CodecOptions() 38 | self.fields_dict = {} 39 | self.fields_attname_dict = {} 40 | self.fields_ordered = [] 41 | self.implicit_id = False 42 | self.delete_rules = {} 43 | self.final = False 44 | self.cascade = False 45 | self.pk = None 46 | self.codec_options = None 47 | self.object_name = None 48 | self.model = None 49 | self.read_preference = None 50 | self.read_concern = None 51 | self.write_concern = None 52 | self.indexes = [] 53 | self.collation = None 54 | self.ignore_unknown_fields = False 55 | self._auto_dereference = True 56 | self._indexes_created = False 57 | 58 | @property 59 | def collection(self): 60 | coll = _get_db(self.connection_alias).get_collection( 61 | self.collection_name, 62 | read_preference=self.read_preference, 63 | read_concern=self.read_concern, 64 | write_concern=self.write_concern, 65 | codec_options=self.codec_options) 66 | if self.indexes and not self._indexes_created: 67 | coll.create_indexes(self.indexes) 68 | self._indexes_created = True 69 | return coll 70 | 71 | @property 72 | def auto_dereference(self): 73 | return self._auto_dereference 74 | 75 | @auto_dereference.setter 76 | def auto_dereference(self, auto_dereference): 77 | """Turn automatic dereferencing on or off.""" 78 | for field in self.get_fields(): 79 | if isinstance(field, RelatedEmbeddedModelFieldsBase): 80 | embedded_options = field.related_model._mongometa 81 | embedded_options.auto_dereference = auto_dereference 82 | self._auto_dereference = auto_dereference 83 | 84 | def get_field(self, field_name): 85 | """Retrieve a Field instance with the given MongoDB name.""" 86 | return self.fields_dict.get(field_name) 87 | 88 | def get_field_from_attname(self, attname): 89 | """Retrieve a Fields instance with the given attribute name.""" 90 | return self.fields_attname_dict.get(attname) 91 | 92 | def add_field(self, field_inst): 93 | """Add or replace a given Field.""" 94 | try: 95 | orig_field = self.get_field(field_inst.mongo_name) 96 | except Exception: 97 | # FieldDoesNotExist, etc. may be raised by subclasses. 98 | orig_field = None 99 | if orig_field is None: 100 | try: 101 | orig_field = self.get_field_from_attname(field_inst.attname) 102 | except Exception: 103 | pass 104 | 105 | if orig_field: 106 | if field_inst.attname != orig_field.attname: 107 | raise InvalidModel('%r cannot have the same mongo_name of ' 108 | 'existing field %r' % (field_inst.attname, 109 | orig_field.attname)) 110 | # Remove the field as it may have a different MongoDB name. 111 | del self.fields_dict[orig_field.mongo_name] 112 | self.fields_ordered.remove(orig_field) 113 | 114 | self.fields_dict[field_inst.mongo_name] = field_inst 115 | self.fields_attname_dict[field_inst.attname] = field_inst 116 | index = bisect(self.fields_ordered, field_inst) 117 | self.fields_ordered.insert(index, field_inst) 118 | 119 | # Set the primary key if we don't have one yet, or it if is implicit. 120 | if field_inst.primary_key and self.pk is None or self.implicit_id: 121 | self.pk = field_inst 122 | 123 | def get_fields(self, include_parents=True, include_hidden=False): 124 | """Get a list of all fields on the Model.""" 125 | return self.fields_ordered 126 | 127 | def contribute_to_class(self, cls, name): 128 | """Callback executed when added to a Model class definition.""" 129 | self.model = cls 130 | # Name used to look up this class with get_document(). 131 | self.object_name = '%s.%s' % (cls.__module__, cls.__name__) 132 | setattr(cls, name, self) 133 | 134 | # Metadata was defined by user. 135 | if self.meta: 136 | for attr in DEFAULT_NAMES: 137 | if attr in self.meta.__dict__: 138 | setattr(self, attr, getattr(self.meta, attr)) 139 | -------------------------------------------------------------------------------- /pymodm/common.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import re 16 | 17 | from importlib import import_module 18 | 19 | import pymongo 20 | 21 | from pymodm.errors import ModelDoesNotExist 22 | 23 | from pymodm.compat import abc, string_types 24 | 25 | 26 | # Mapping of class names to class objects. 27 | # Used for fields that nest or reference other Model classes. 28 | _DOCUMENT_REGISTRY = {} 29 | 30 | # Mapping of fully-qualified names to their imported objects. 31 | _IMPORT_CACHE = {} 32 | 33 | CTS1 = re.compile('(.)([A-Z][a-z]+)') 34 | CTS2 = re.compile('([a-z0-9])([A-Z])') 35 | 36 | 37 | def snake_case(camel_case): 38 | snake = re.sub(CTS1, r'\1_\2', camel_case) 39 | snake = re.sub(CTS2, r'\1_\2', snake) 40 | return snake.lower() 41 | 42 | 43 | def _import(full_name): 44 | """Avoid circular imports without re-importing each time.""" 45 | if full_name in _IMPORT_CACHE: 46 | return _IMPORT_CACHE[full_name] 47 | 48 | module_name, class_name = full_name.rsplit('.', 1) 49 | module = import_module(module_name) 50 | 51 | _IMPORT_CACHE[full_name] = getattr(module, class_name) 52 | return _IMPORT_CACHE[full_name] 53 | 54 | 55 | def register_document(document): 56 | key = '%s.%s' % (document.__module__, document.__name__) 57 | _DOCUMENT_REGISTRY[key] = document 58 | 59 | 60 | def get_document(name): 61 | """Retrieve the definition for a class by name.""" 62 | if name in _DOCUMENT_REGISTRY: 63 | return _DOCUMENT_REGISTRY[name] 64 | 65 | possible_matches = [] 66 | for key in _DOCUMENT_REGISTRY: 67 | parts = key.split('.') 68 | if name == parts[-1]: 69 | possible_matches.append(key) 70 | if len(possible_matches) == 1: 71 | return _DOCUMENT_REGISTRY[possible_matches[0]] 72 | raise ModelDoesNotExist('No document type by the name %r.' % (name,)) 73 | 74 | 75 | # 76 | # Type validation. 77 | # 78 | 79 | def validate_string(option, value): 80 | if not isinstance(value, string_types): 81 | raise TypeError('%s must be a string type, not a %s' 82 | % (option, value.__class__.__name__)) 83 | return value 84 | 85 | 86 | def validate_string_or_none(option, value): 87 | if value is None: 88 | return value 89 | return validate_string(option, value) 90 | 91 | 92 | def validate_mongo_field_name(option, value): 93 | """Validates the MongoDB field name format described in: 94 | https://docs.mongodb.com/manual/core/document/#field-names 95 | """ 96 | validate_string(option, value) 97 | if value == '': 98 | return value 99 | if value[0] == '$': 100 | raise ValueError('%s cannot start with the dollar sign ($) ' 101 | 'character, %r.' % (option, value)) 102 | if '.' in value: 103 | raise ValueError('%s cannot contain the dot (.) character, %r.' 104 | % (option, value)) 105 | if '\x00' in value: 106 | raise ValueError('%s cannot contain the null character, %r.' 107 | % (option, value)) 108 | return value 109 | 110 | 111 | def validate_mongo_keys(option, dct): 112 | """Recursively validate that all dictionary keys are valid in MongoDB.""" 113 | for key in dct: 114 | validate_mongo_field_name(option, key) 115 | value = dct[key] 116 | if isinstance(value, dict): 117 | validate_mongo_keys(option, value) 118 | elif isinstance(value, (list, tuple)): 119 | validate_mongo_keys_in_list(option, value) 120 | 121 | 122 | def validate_mongo_keys_in_list(option, lst): 123 | for elem in lst: 124 | if isinstance(elem, dict): 125 | validate_mongo_keys(option, elem) 126 | elif isinstance(elem, (list, tuple)): 127 | validate_mongo_keys_in_list(option, elem) 128 | 129 | 130 | def validate_mongo_field_name_or_none(option, value): 131 | if value is None: 132 | return value 133 | return validate_mongo_field_name(option, value) 134 | 135 | 136 | def validate_boolean(option, value): 137 | if not isinstance(value, bool): 138 | raise TypeError('%s must be a boolean, not a %s' 139 | % (option, value.__class__.__name__)) 140 | return value 141 | 142 | 143 | def validate_boolean_or_none(option, value): 144 | if value is None: 145 | return value 146 | return validate_boolean(option, value) 147 | 148 | 149 | def validate_list_or_tuple(option, value): 150 | if not isinstance(value, (list, tuple)): 151 | raise TypeError('%s must be a list or a tuple, not a %s' 152 | % (option, value.__class__.__name__)) 153 | return value 154 | 155 | 156 | def validate_list_tuple_or_none(option, value): 157 | if value is None: 158 | return value 159 | return validate_list_or_tuple(option, value) 160 | 161 | 162 | def validate_mapping(option, value): 163 | if not isinstance(value, abc.Mapping): 164 | raise TypeError('%s must be a Mapping, not a %s' 165 | % (option, value.__class__.__name__)) 166 | return value 167 | 168 | 169 | def validate_ordering(option, ordering): 170 | ordering = validate_list_or_tuple(option, ordering) 171 | for order in ordering: 172 | order = validate_list_or_tuple(option + "'s elements", order) 173 | if len(order) != 2: 174 | raise ValueError("%s's elements must be (field_name, " 175 | "direction) not %s" % (option, order)) 176 | validate_string("field_name", order[0]) 177 | if order[1] not in (pymongo.ASCENDING, pymongo.DESCENDING): 178 | raise ValueError("sort direction must be pymongo.ASCENDING or '" 179 | "pymongo.DECENDING not %s" % (order[1])) 180 | return ordering 181 | -------------------------------------------------------------------------------- /pymodm/compat.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tools for Python 2/3 compatibility.""" 16 | import sys 17 | 18 | try: 19 | import collections.abc as abc 20 | except ImportError: 21 | # Python < 3.3 22 | import collections as abc # noqa 23 | 24 | PY3 = sys.version_info[0] == 3 25 | 26 | 27 | def with_metaclass(metaclass, *bases): 28 | class _metaclass(metaclass): 29 | def __new__(mcls, name, _bases, attrs): 30 | return metaclass(name, bases, attrs) 31 | return type.__new__(_metaclass, 'dummy', (), {}) 32 | 33 | 34 | if PY3: 35 | string_types = str, 36 | text_type = str 37 | integer_types = int 38 | 39 | def reraise(exctype, value, trace=None): 40 | raise exctype(str(value)).with_traceback(trace) 41 | else: 42 | string_types = basestring, 43 | text_type = unicode 44 | integer_types = (int, long) 45 | 46 | exec("""def reraise(exctype, value, trace=None): 47 | raise exctype, str(value), trace 48 | """) 49 | -------------------------------------------------------------------------------- /pymodm/connection.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tools for managing connections in MongoModels.""" 16 | import sys 17 | 18 | from collections import namedtuple 19 | 20 | from pymongo import uri_parser, MongoClient 21 | 22 | from pymodm.compat import reraise 23 | 24 | try: 25 | from pymongo.driver_info import DriverInfo 26 | except ImportError: 27 | DriverInfo = None 28 | 29 | 30 | __all__ = ['connect'] 31 | 32 | 33 | """Information stored with each connection alias.""" 34 | ConnectionInfo = namedtuple( 35 | 'ConnectionInfo', ('parsed_uri', 'conn_string', 'database')) 36 | 37 | 38 | DEFAULT_CONNECTION_ALIAS = 'default' 39 | 40 | 41 | _CONNECTIONS = dict() 42 | 43 | 44 | def connect(mongodb_uri, alias=DEFAULT_CONNECTION_ALIAS, **kwargs): 45 | """Register a connection to MongoDB, optionally providing a name for it. 46 | 47 | Note: :func:`connect` must be called with before any 48 | :class:`~pymodm.MongoModel` is used with the given `alias`. 49 | 50 | :parameters: 51 | - `mongodb_uri`: A MongoDB connection string. Any options may be passed 52 | within the string that are supported by PyMongo. `mongodb_uri` must 53 | specify a database, which will be used by any 54 | :class:`~pymodm.MongoModel` that uses this connection. 55 | - `alias`: An optional name for this connection, backed by a 56 | :class:`~pymongo.mongo_client.MongoClient` instance that is cached under 57 | this name. You can specify what connection a MongoModel uses by 58 | specifying the connection's alias via the `connection_alias` attribute 59 | inside their `Meta` class. Switching connections is also possible using 60 | the :class:`~pymodm.context_managers.switch_connection` context 61 | manager. Note that calling `connect()` multiple times with the same 62 | alias will replace any previous connections. 63 | - `kwargs`: Additional keyword arguments to pass to the underlying 64 | :class:`~pymongo.mongo_client.MongoClient`. 65 | 66 | """ 67 | # Make sure the database is provided. 68 | parsed_uri = uri_parser.parse_uri(mongodb_uri) 69 | if not parsed_uri.get('database'): 70 | raise ValueError('Connection must specify a database.') 71 | 72 | # Include client metadata if available. 73 | if DriverInfo is not None: 74 | # Import version locally to avoid circular import. 75 | from pymodm import version 76 | kwargs.setdefault('driver', DriverInfo('PyMODM', version)) 77 | 78 | _CONNECTIONS[alias] = ConnectionInfo( 79 | parsed_uri=parsed_uri, 80 | conn_string=mongodb_uri, 81 | database=MongoClient(mongodb_uri, **kwargs)[parsed_uri['database']]) 82 | 83 | 84 | def _get_connection(alias=DEFAULT_CONNECTION_ALIAS): 85 | """Return a `ConnectionInfo` by connection alias.""" 86 | try: 87 | return _CONNECTIONS[alias] 88 | except KeyError: 89 | _, _, tb = sys.exc_info() 90 | reraise(ValueError, 91 | "No such alias '%s'. Did you forget to call connect()?" % alias, 92 | tb) 93 | 94 | 95 | def _get_db(alias=DEFAULT_CONNECTION_ALIAS): 96 | """Return the `pymongo.database.Database` instance for the given alias.""" 97 | return _get_connection(alias).database 98 | -------------------------------------------------------------------------------- /pymodm/context_managers.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | 16 | class switch_connection(object): 17 | """Context manager that changes the active connection for a Model. 18 | 19 | Example:: 20 | 21 | connect('mongodb://.../mainDatabase', alias='main-app') 22 | connect('mongodb://.../backupDatabase', alias='backup') 23 | 24 | # 'MyModel' normally writes to 'mainDatabase'. Let's change that. 25 | with switch_connection(MyModel, 'backup'): 26 | # This goes to 'backupDatabase'. 27 | MyModel(name='Bilbo').save() 28 | 29 | """ 30 | 31 | def __init__(self, model, connection_alias): 32 | """ 33 | :parameters: 34 | - `model`: A :class:`~pymodm.MongoModel` class. 35 | - `connection_alias`: A connection alias that was set up earlier via 36 | a call to :func:`~pymodm.connection.connect`. 37 | 38 | """ 39 | self.model = model 40 | self.original_connection_alias = self.model._mongometa.connection_alias 41 | self.target_connection_alias = connection_alias 42 | 43 | def __enter__(self): 44 | self.model._mongometa.connection_alias = self.target_connection_alias 45 | return self.model 46 | 47 | def __exit__(self, typ, val, tb): 48 | self.model._mongometa.connection_alias = self.original_connection_alias 49 | 50 | 51 | class switch_collection(object): 52 | """Context manager that changes the active collection for a Model. 53 | 54 | Example:: 55 | 56 | with switch_collection(MyModel, "other_collection"): 57 | ... 58 | 59 | """ 60 | 61 | def __init__(self, model, collection_name): 62 | """ 63 | :parameters: 64 | - `model`: A :class:`~pymodm.MongoModel` class. 65 | - `collection_name`: The name of the new collection to use. 66 | 67 | """ 68 | self.model = model 69 | self.original_collection_name = self.model._mongometa.collection_name 70 | self.target_collection_name = collection_name 71 | 72 | def __enter__(self): 73 | self.model._mongometa.collection_name = self.target_collection_name 74 | return self.model 75 | 76 | def __exit__(self, typ, val, tb): 77 | self.model._mongometa.collection_name = self.original_collection_name 78 | 79 | 80 | class collection_options(object): 81 | """Context manager that changes the collections options for a Model. 82 | 83 | Example:: 84 | 85 | with collection_options( 86 | MyModel, 87 | read_preference=ReadPreference.SECONDARY): 88 | # Read objects off of a secondary. 89 | MyModel.objects.raw(...) 90 | 91 | """ 92 | 93 | def __init__(self, model, codec_options=None, read_preference=None, 94 | write_concern=None, read_concern=None): 95 | """ 96 | :parameters: 97 | - `model`: A :class:`~pymodm.MongoModel` class. 98 | - `codec_options`: An instance of 99 | :class:`~bson.codec_options.CodecOptions`. 100 | - `read_preference`: A read preference from the 101 | :mod:`~pymongo.read_preferences` module. 102 | - `write_concern`: An instance of 103 | :class:`~pymongo.write_concern.WriteConcern`. 104 | - `read_concern`: An instance of 105 | :class:`~pymongo.read_concern.ReadConcern`. 106 | 107 | """ 108 | self.model = model 109 | meta = self.model._mongometa 110 | self.orig_read_preference = meta.read_preference 111 | self.orig_read_concern = meta.read_concern 112 | self.orig_write_concern = meta.write_concern 113 | self.orig_codec_options = meta.codec_options 114 | 115 | self.read_preference = read_preference 116 | self.read_concern = read_concern 117 | self.write_concern = write_concern 118 | self.codec_options = codec_options 119 | 120 | def __enter__(self): 121 | meta = self.model._mongometa 122 | meta.read_preference = self.read_preference 123 | meta.read_concern = self.read_concern 124 | meta.write_concern = self.write_concern 125 | meta.codec_options = self.codec_options 126 | # Clear cached reference to Collection. 127 | self.model._mongometa._collection = None 128 | return self.model 129 | 130 | def __exit__(self, typ, val, tb): 131 | meta = self.model._mongometa 132 | meta.read_preference = self.orig_read_preference 133 | meta.read_concern = self.orig_read_concern 134 | meta.write_concern = self.orig_write_concern 135 | meta.codec_options = self.orig_codec_options 136 | 137 | self.model._mongometa._collection = None 138 | 139 | 140 | class no_auto_dereference(object): 141 | """Context manager that turns off automatic dereferencing. 142 | 143 | Example:: 144 | 145 | >>> some_profile = UserProfile.objects.first() 146 | >>> with no_auto_dereference(UserProfile): 147 | ... some_profile.user 148 | ObjectId('5786cf1d6e32ab419952fce4') 149 | >>> some_profile.user 150 | User(name='Sammy', points=123) 151 | 152 | """ 153 | 154 | def __init__(self, model): 155 | """ 156 | :parameters: 157 | - `model`: A :class:`~pymodm.MongoModel` class. 158 | 159 | """ 160 | self.model = model 161 | self.orig_auto_deref = self.model._mongometa.auto_dereference 162 | 163 | def __enter__(self): 164 | self.model._mongometa.auto_dereference = False 165 | 166 | def __exit__(self, typ, val, tb): 167 | self.model._mongometa.auto_dereference = self.orig_auto_deref 168 | -------------------------------------------------------------------------------- /pymodm/dereference.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from collections import defaultdict, deque 16 | 17 | from pymodm.base.models import MongoModelBase 18 | from pymodm.connection import _get_db 19 | from pymodm.context_managers import no_auto_dereference 20 | from pymodm.fields import ReferenceField, ListField, EmbeddedModelListField 21 | 22 | 23 | class _ObjectMap(dict): 24 | def __init__(self): 25 | self.hashed = {} 26 | self.nohash = [] 27 | 28 | def __getitem__(self, item): 29 | try: 30 | return self.hashed[item] 31 | except TypeError: 32 | # Unhashable type 33 | for key, value in self.nohash: 34 | if key == item: 35 | return value 36 | raise KeyError(item) 37 | 38 | def __setitem__(self, key, value): 39 | try: 40 | self.hashed[key] = value 41 | except TypeError: 42 | # Unhashable type. 43 | self.nohash.append((key, value)) 44 | 45 | def __contains__(self, key): 46 | try: 47 | self[key] 48 | return True 49 | except KeyError: 50 | return False 51 | 52 | 53 | def _find_references_in_object(object, field, reference_map, fields=None): 54 | if (isinstance(field, ReferenceField) and 55 | not isinstance(object, field.related_model)): 56 | collection_name = field.related_model._mongometa.collection_name 57 | reference_map[collection_name].append( 58 | field.related_model._mongometa.pk.to_mongo(object)) 59 | elif isinstance(object, list): 60 | if hasattr(field, '_field'): 61 | field = field._field 62 | for item in object: 63 | _find_references_in_object(item, field, reference_map, fields) 64 | elif isinstance(object, MongoModelBase): 65 | _find_references(object, reference_map, fields) 66 | # else: doesn't matter... 67 | 68 | 69 | def _find_references(model_instance, reference_map, fields=None): 70 | # Gather the names of the fields we're looking for at this level. 71 | field_names_map = {} 72 | if fields: 73 | for idx, field in enumerate(fields): 74 | if field: 75 | field_names_map[idx] = field.popleft() 76 | field_names = set(field_names_map.values()) 77 | 78 | for field in model_instance._mongometa.get_fields(): 79 | # Skip any fields we don't care about. 80 | if fields and field.attname not in field_names: 81 | continue 82 | field_value = getattr(model_instance, field.attname) 83 | _find_references_in_object(field_value, field, reference_map, fields) 84 | 85 | # Restore parts of field names that we took off while scanning. 86 | for field_idx, field_name in field_names_map.items(): 87 | fields[field_idx].appendleft(field_name) 88 | 89 | 90 | def _resolve_references(database, reference_map): 91 | document_map = defaultdict(_ObjectMap) 92 | for collection_name in reference_map: 93 | collection = database[collection_name] 94 | query = {'_id': {'$in': reference_map[collection_name]}} 95 | documents = collection.find(query) 96 | for document in documents: 97 | document_map[collection_name][document['_id']] = document 98 | 99 | return document_map 100 | 101 | 102 | def _get_reference_document(document_map, collection_name, ref_id): 103 | try: 104 | return document_map[collection_name][ref_id] 105 | except KeyError: 106 | return None 107 | 108 | 109 | def _attach_objects_in_path(container, document_map, fields, key, field): 110 | try: 111 | value = container.get_python_value(key, field.to_python) 112 | except AttributeError: 113 | value = container[key] 114 | except KeyError: 115 | # there is no value for given key 116 | return 117 | 118 | if (isinstance(field, ReferenceField) and 119 | not isinstance(value, field.related_model)): 120 | # value is reference id 121 | meta = field.related_model._mongometa 122 | dereferenced_document = _get_reference_document( 123 | document_map, meta.collection_name, meta.pk.to_mongo(value)) 124 | try: 125 | container.set_mongo_value(key, dereferenced_document) 126 | except AttributeError: 127 | container[key] = field.related_model.from_document( 128 | dereferenced_document) 129 | elif isinstance(field, ListField): 130 | # value is list 131 | for idx, item in enumerate(value): 132 | _attach_objects_in_path(value, document_map, fields, 133 | idx, field._field) 134 | elif isinstance(field, EmbeddedModelListField): 135 | # value is list of embedded models instances 136 | for emb_model_inst in value: 137 | _attach_objects(emb_model_inst, document_map, fields) 138 | elif isinstance(value, MongoModelBase): 139 | # value is embedded model instance or reference is 140 | # already dereferenced 141 | _attach_objects(value, document_map, fields) 142 | 143 | 144 | def _attach_objects(model_instance, document_map, fields=None): 145 | container = model_instance._data 146 | field_names_map = {} 147 | if fields: 148 | for idx, field in enumerate(fields): 149 | if field: 150 | field_names_map[idx] = field.popleft() 151 | field_names = set(field_names_map.values()) 152 | 153 | for field in model_instance._mongometa.get_fields(): 154 | # Skip any fields we don't care about. 155 | if fields and field.attname not in field_names: 156 | continue 157 | 158 | _attach_objects_in_path(container, document_map, fields, 159 | field.attname, field) 160 | 161 | if fields: 162 | # Restore parts of field names that we took off while scanning. 163 | for field_idx, field_name in field_names_map.items(): 164 | fields[field_idx].appendleft(field_name) 165 | 166 | 167 | def dereference(model_instance, fields=None): 168 | """Dereference ReferenceFields on a MongoModel instance. 169 | 170 | This function is handy for dereferencing many fields at once and is more 171 | efficient than dereferencing one field at a time. 172 | 173 | :parameters: 174 | - `model_instance`: The MongoModel instance. 175 | - `fields`: An iterable of field names in "dot" notation that 176 | should be dereferenced. If left blank, all fields will be dereferenced. 177 | """ 178 | # Map of collection name --> list of ids to retrieve from the collection. 179 | reference_map = defaultdict(list) 180 | 181 | # Fields may be nested (dot-notation). Split each field into its parts. 182 | if fields: 183 | fields = [deque(field.split('.')) for field in fields] 184 | 185 | # Tell ReferenceFields not to look up their value while we scan the object. 186 | with no_auto_dereference(model_instance): 187 | _find_references(model_instance, reference_map, fields) 188 | 189 | db = _get_db(model_instance._mongometa.connection_alias) 190 | # Resolve all references, one collection at a time. 191 | # This will give us a mapping of 192 | # {collection_name --> {id --> resolved object}} 193 | document_map = _resolve_references(db, reference_map) 194 | 195 | # Traverse the object and attach resolved references where needed. 196 | _attach_objects(model_instance, document_map, fields) 197 | 198 | return model_instance 199 | 200 | 201 | def dereference_id(model_class, model_id): 202 | """Dereference a single object by id. 203 | 204 | :parameters: 205 | - `model_class`: The class of a model to be dereferenced. 206 | - `model_id`: The id of the model to be dereferenced. 207 | """ 208 | meta = model_class._mongometa 209 | document = meta.collection.find_one({'_id': meta.pk.to_mongo(model_id)}) 210 | if document: 211 | return model_class.from_document(document) 212 | -------------------------------------------------------------------------------- /pymodm/errors.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tools and types for Exception handling.""" 16 | from pymodm.compat import text_type 17 | 18 | 19 | class DoesNotExist(Exception): 20 | pass 21 | 22 | 23 | class MultipleObjectsReturned(Exception): 24 | pass 25 | 26 | 27 | class ConnectionError(Exception): 28 | pass 29 | 30 | 31 | class ModelDoesNotExist(Exception): 32 | """Raised when a reference to a Model cannot be resolved.""" 33 | pass 34 | 35 | 36 | class InvalidModel(Exception): 37 | """Raised if a Model definition is invalid.""" 38 | pass 39 | 40 | 41 | class ValidationError(Exception): 42 | """Indicates an error while validating data. 43 | 44 | A ValidationError may contain a single error, a list of errors, or even a 45 | dictionary mapping field names to errors. Any of these cases are acceptable 46 | to pass as a "message" to the constructor for ValidationError. 47 | """ 48 | 49 | def __init__(self, message, **kwargs): 50 | self._message = message 51 | 52 | def _get_message(self, message): 53 | if isinstance(message, ValidationError): 54 | return message.message 55 | elif isinstance(message, Exception): 56 | return text_type(message) 57 | elif isinstance(message, list): 58 | message_list = [] 59 | for item in message: 60 | extracted = self._get_message(item) 61 | if isinstance(extracted, list): 62 | message_list.extend(extracted) 63 | else: 64 | message_list.append(extracted) 65 | return message_list 66 | elif isinstance(message, dict): 67 | return {key: self._get_message(message[key]) 68 | for key in message} 69 | return message 70 | 71 | @property 72 | def message(self): 73 | return self._get_message(self._message) 74 | 75 | def __str__(self): 76 | return text_type(self.message) 77 | 78 | def __repr__(self): 79 | return '%s(%s)' % (self.__class__.__name__, self) 80 | 81 | 82 | class OperationError(Exception): 83 | """Raised when an operation cannot be performed.""" 84 | 85 | 86 | class ConfigurationError(Exception): 87 | """Raised when there is a configuration error.""" 88 | -------------------------------------------------------------------------------- /pymodm/manager.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import functools 16 | import inspect 17 | 18 | from pymodm.common import _import 19 | from pymodm.compat import PY3 20 | from pymodm.queryset import QuerySet 21 | 22 | 23 | class BaseManager(object): 24 | """Abstract base class for all Managers. 25 | 26 | `BaseManager` has no underlying :class:`~pymodm.queryset.QuerySet` 27 | implementation. To extend this class into a concrete class, a `QuerySet` 28 | implementation must be provided by calling :meth:`~from_queryset`:: 29 | 30 | class MyQuerySet(QuerySet): 31 | ... 32 | 33 | MyManager = BaseManager.from_queryset(MyQuerySet) 34 | 35 | Extending this class by calling `from_queryset` creates a new Manager class 36 | that wraps only the methods from the given `QuerySet` type (and not from the 37 | default `QuerySet` implementation). 38 | 39 | .. seealso:: The default :class:`~pymodm.manager.Manager`. 40 | 41 | """ 42 | # Creation counter to keep track of order within a Model. 43 | __creation_counter = 0 44 | 45 | def __init__(self): 46 | self.__counter = BaseManager.__creation_counter 47 | BaseManager.__creation_counter += 1 48 | 49 | def __get__(self, instance, cls): 50 | """Only let Manager be accessible from Model classes.""" 51 | TopLevelMongoModel = _import('pymodm.base.models.TopLevelMongoModel') 52 | if isinstance(instance, TopLevelMongoModel): 53 | raise AttributeError( 54 | "Manager isn't accessible via %s instances." % (cls.__name__,)) 55 | return self 56 | 57 | def get_queryset(self): 58 | """Get a QuerySet instance.""" 59 | return self._queryset_class(self.model) 60 | 61 | @property 62 | def creation_order(self): 63 | """The order in which this Manager instance was created.""" 64 | return self.__counter 65 | 66 | @classmethod 67 | def _get_queryset_methods(cls, queryset_class): 68 | def create_method(name, queryset_method): 69 | @functools.wraps(queryset_method) 70 | def manager_method(self, *args, **kwargs): 71 | return getattr(self.get_queryset(), name)(*args, **kwargs) 72 | return manager_method 73 | 74 | predicate = inspect.isfunction if PY3 else inspect.ismethod 75 | queryset_methods = inspect.getmembers(queryset_class, predicate) 76 | method_dict = { 77 | name: create_method(name, method) 78 | for name, method in queryset_methods 79 | # Don't shadow existing Manager methods. 80 | if not hasattr(cls, name)} 81 | return method_dict 82 | 83 | @classmethod 84 | def from_queryset(cls, queryset_class, class_name=None): 85 | """Create a Manager that delegates methods to the given 86 | :class:`~pymodm.queryset.QuerySet` class. 87 | 88 | The Manager class returned is a subclass of this Manager class. 89 | 90 | :parameters: 91 | - `queryset_class`: The QuerySet class to be instantiated by the 92 | Manager. 93 | - `class_name`: The name of the Manager class. If one is not provided, 94 | the name of the Manager will be `XXXFromYYY`, where `XXX` 95 | is the name of this Manager class, and YYY is the name of the 96 | QuerySet class. 97 | 98 | """ 99 | if class_name is None: 100 | class_name = '%sFrom%s' % (cls.__name__, queryset_class.__name__) 101 | class_dict = dict( 102 | cls._get_queryset_methods(queryset_class), 103 | _queryset_class=queryset_class) 104 | return type(class_name, (cls,), class_dict) 105 | 106 | def contribute_to_class(self, cls, name): 107 | self.model = cls 108 | setattr(cls, name, self) 109 | 110 | 111 | class Manager(BaseManager.from_queryset(QuerySet)): 112 | """The default manager used for :class:`~pymodm.MongoModel` instances. 113 | 114 | This implementation of :class:`~pymodm.manager.BaseManager` uses 115 | :class:`~pymodm.queryset.QuerySet` as its QuerySet class. 116 | 117 | This Manager class (accessed via the ``objects`` attribute on a 118 | :class:`~pymodm.MongoModel`) is used by default for all MongoModel classes, 119 | unless another Manager instance is supplied as an attribute within the 120 | MongoModel definition. 121 | 122 | Managers have two primary functions: 123 | 124 | 1. Construct :class:`~pymodm.queryset.QuerySet` instances for use when 125 | querying or working with :class:`~pymodm.MongoModel` instances in bulk. 126 | 2. Define collection-level functionality that can be reused across different 127 | MongoModel types. 128 | 129 | If you created a custom QuerySet that makes certain queries easier, for 130 | example, you will need to create a custom Manager type that returns this 131 | queryset using the :meth:`~pymodm.manager.BaseManager.from_queryset` 132 | method:: 133 | 134 | class UserQuerySet(QuerySet): 135 | def active(self): 136 | '''Return only active users.''' 137 | return self.raw({"active": True}) 138 | 139 | class User(MongoModel): 140 | active = fields.BooleanField() 141 | # Add our custom Manager. 142 | users = Manager.from_queryset(UserQuerySet) 143 | 144 | In the above example, we added a `users` attribute on `User` so that we can 145 | use the `active` method on our new QuerySet type:: 146 | 147 | active_users = User.users.active() 148 | 149 | If we wanted every method on the QuerySet to examine active users *only*, we 150 | can do that by customizing the Manager itself:: 151 | 152 | class UserManager(Manager): 153 | def get_queryset(self): 154 | # Override get_queryset, so that every QuerySet created will 155 | # have this filter applied. 156 | return super(UserManager, self).get_queryset().raw( 157 | {"active": True}) 158 | 159 | class User(MongoModel): 160 | active = fields.BooleanField() 161 | users = UserManager() 162 | 163 | active_users = User.users.all() 164 | 165 | """ 166 | pass 167 | -------------------------------------------------------------------------------- /pymodm/validators.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm.errors import ValidationError 16 | 17 | 18 | def together(*funcs): 19 | """Run several validators successively on the same value.""" 20 | def validator(value): 21 | for func in funcs: 22 | func(value) 23 | return validator 24 | 25 | 26 | def validator_for_func(func): 27 | """Return a validator that re-raises any errors from the given function.""" 28 | def validator(value): 29 | try: 30 | func(value) 31 | except Exception as exc: 32 | raise ValidationError(exc) 33 | return validator 34 | 35 | 36 | def validator_for_type(types, value_name=None): 37 | """Return a validator that ensures its value is among the given `types`.""" 38 | def validator(value): 39 | if not isinstance(value, types): 40 | if isinstance(types, tuple): # multiple types 41 | type_names = tuple(t.__name__ for t in types) 42 | err = 'must be one of %r' % (type_names,) 43 | else: 44 | err = 'must be a %s' % types.__name__ 45 | raise ValidationError( 46 | '%s %s, not %r' 47 | % (value_name or 'Value', err, value)) 48 | return validator 49 | 50 | 51 | def validator_for_geojson_type(geojson_type): 52 | """Return a validator that validates its value as having the given GeoJSON 53 | ``type``. 54 | """ 55 | def validator(value): 56 | if value.get('type') != geojson_type: 57 | raise ValidationError( 58 | 'GeoJSON type must be %r, not %r' 59 | % (geojson_type, value.get('type'))) 60 | return validator 61 | 62 | 63 | def validator_for_min_max(min, max): 64 | """Return a validator that validates its value against a minimum/maximum.""" 65 | def validator(value): 66 | if min is not None and value < min: 67 | raise ValidationError( 68 | '%s is less than minimum value of %s.' % (value, min)) 69 | if max is not None and value > max: 70 | raise ValidationError( 71 | '%s is greater than maximum value of %s.' % (value, max)) 72 | return validator 73 | 74 | 75 | def validator_for_length(min, max): 76 | """Return a validator that validates a given value's length.""" 77 | def validator(value): 78 | len_value = len(value) 79 | if min is not None and len_value < min: 80 | raise ValidationError( 81 | '%s is under the minimum length of %d.' % (value, min)) 82 | if max is not None and len_value > max: 83 | raise ValidationError( 84 | 'value exceeds the maximum length of %d.' % (max,)) 85 | return validator 86 | -------------------------------------------------------------------------------- /pymodm/vendor.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Django Software Foundation and individual contributors. 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # 1. Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 10 | # 2. Redistributions in binary form must reproduce the above copyright 11 | # notice, this list of conditions and the following disclaimer in the 12 | # documentation and/or other materials provided with 13 | # the distribution. 14 | # 15 | # 3. Neither the name of Django nor the names of its contributors may be 16 | # used to endorse or promote products derived from this software 17 | # without specific prior written permission. 18 | # 19 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE 23 | # FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | import datetime 31 | import re 32 | 33 | from bson.tz_util import utc, FixedOffset 34 | 35 | DATETIME_PATTERN = re.compile( 36 | r'(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})' # YY-MM-DD 37 | r'(?:[T ](?P\d{1,2}):(?P\d{1,2})' # Optional HH:mm 38 | r'(?::(?P\d{1,2})' # Optional seconds 39 | r'(?:\.(?P\d{1,6})0*)?)?)?' # Optional microseconds 40 | r'(?PZ|[+-]\d{2}(?::?\d{2})?)?\Z' # Optional timezone 41 | ) 42 | 43 | 44 | # This section is largely taken from django.util.dateparse.parse_datetime. 45 | def parse_datetime(string): 46 | """Try to parse a `datetime.datetime` out of a string. 47 | 48 | Return the parsed datetime, or ``None`` if unsuccessful. 49 | """ 50 | match = re.match(DATETIME_PATTERN, string) 51 | if match: 52 | time_parts = match.groupdict() 53 | if time_parts['microsecond'] is not None: 54 | time_parts['microsecond'] = ( 55 | time_parts['microsecond'].ljust(6, '0')) 56 | tzinfo = time_parts.pop('tzinfo') 57 | if 'Z' == tzinfo: 58 | tzinfo = utc 59 | elif tzinfo is not None: 60 | offset_hours = int(tzinfo[1:3]) 61 | offset_minutes = int(tzinfo[4:]) if len(tzinfo) > 3 else 0 62 | offset_total = offset_hours * 60 + offset_minutes 63 | sign = '+' 64 | if '-' == tzinfo[0]: 65 | offset_total *= -1 66 | sign = '-' 67 | offset_name = '%s%02d:%02d' % ( 68 | sign, offset_hours, offset_minutes) 69 | tzinfo = FixedOffset(offset_total, offset_name) 70 | time_parts = {k: int(time_parts[k]) for k in time_parts 71 | if time_parts[k] is not None} 72 | time_parts['tzinfo'] = tzinfo 73 | return datetime.datetime(**time_parts) 74 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import sys 16 | try: 17 | from setuptools import setup, find_packages 18 | except ImportError: 19 | from ez_setup import use_setuptools 20 | use_setuptools() 21 | from setuptools import setup, find_packages 22 | 23 | VERSION = '0.5.0.dev0' 24 | 25 | LONG_DESCRIPTION = None 26 | try: 27 | LONG_DESCRIPTION = open('README.rst').read() 28 | except Exception: 29 | pass 30 | 31 | 32 | CLASSIFIERS = [ 33 | 'Development Status :: 3 - Alpha', 34 | 'Intended Audience :: Developers', 35 | 'License :: OSI Approved :: Apache Software License', 36 | 'Operating System :: OS Independent', 37 | 'Programming Language :: Python', 38 | "Programming Language :: Python :: 2.7", 39 | "Programming Language :: Python :: 3", 40 | "Programming Language :: Python :: 3.3", 41 | "Programming Language :: Python :: 3.4", 42 | "Programming Language :: Python :: 3.5", 43 | "Programming Language :: Python :: 3.6", 44 | "Programming Language :: Python :: Implementation :: CPython", 45 | "Programming Language :: Python :: Implementation :: PyPy", 46 | 'Topic :: Database', 47 | 'Topic :: Software Development :: Libraries :: Python Modules', 48 | ] 49 | 50 | 51 | requires = ['pymongo>=3.4,<4.0'] 52 | if sys.version_info[:2] == (2, 7): 53 | requires.append('ipaddress') 54 | 55 | setup( 56 | name='pymodm', 57 | version=VERSION, 58 | author='Luke Lovett', 59 | author_email='mongodb-user@googlegroups.com', 60 | license='Apache License, Version 2.0', 61 | include_package_data=True, 62 | description='PyMODM is a generic ODM on top of PyMongo.', 63 | long_description=LONG_DESCRIPTION, 64 | packages=find_packages(exclude=['test', 'test.*']), 65 | platforms=['any'], 66 | classifiers=CLASSIFIERS, 67 | test_suite='test', 68 | install_requires=requires, 69 | extras_require={'images': 'Pillow'} 70 | ) 71 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | import sys 17 | import unittest 18 | 19 | import pymongo 20 | 21 | from pymodm.connection import connect 22 | 23 | 24 | PY3 = sys.version_info[0] == 3 25 | 26 | MONGO_URI = os.environ.get('MONGO_URI', 'mongodb://localhost:27017') 27 | 28 | CLIENT = pymongo.MongoClient(MONGO_URI) 29 | DB = CLIENT.odm_test 30 | 31 | # Get the version of MongoDB. 32 | server_info = pymongo.MongoClient(MONGO_URI).server_info() 33 | MONGO_VERSION = tuple(server_info.get('versionArray', [])) 34 | 35 | 36 | INVALID_MONGO_NAMES = ['$dollar', 'has.dot', 'null\x00character'] 37 | VALID_MONGO_NAMES = ['', 'dollar$', 'forty-two'] 38 | 39 | 40 | def connect_to_test_DB(alias=None): 41 | if alias is None: 42 | connect('%s/%s' % (MONGO_URI, DB.name)) 43 | else: 44 | connect('%s/%s' % (MONGO_URI, DB.name), alias=alias) 45 | 46 | 47 | connect_to_test_DB() 48 | 49 | 50 | class ODMTestCase(unittest.TestCase): 51 | 52 | def tearDown(self): 53 | CLIENT.drop_database(DB.name) 54 | 55 | if not PY3: 56 | # assertRaisesRegexp is deprecated in Python 3 but is all we have in 57 | # Python 2. 58 | def assertRaisesRegex(self, *args, **kwargs): 59 | return self.assertRaisesRegexp(*args, **kwargs) 60 | 61 | def assertEqualsModel(self, expected, model_instance): 62 | """Assert that a Model instance equals the expected document.""" 63 | actual = model_instance.to_son() 64 | actual.pop('_cls', None) 65 | self.assertEqual(expected, actual) 66 | -------------------------------------------------------------------------------- /test/field_types/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from test import ODMTestCase 16 | 17 | 18 | class FieldTestCase(ODMTestCase): 19 | """Base class for basic Field test cases.""" 20 | 21 | def assertConversion(self, field, expected, case): 22 | """Convenience assert method that tests both to_python and to_mongo.""" 23 | self.assertEqual(expected, field.to_python(case)) 24 | self.assertEqual(expected, field.to_mongo(case)) 25 | -------------------------------------------------------------------------------- /test/field_types/lib/augustus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb/pymodm/be1c7b079df4954ef7e79e46f1b4a9ac9510766c/test/field_types/lib/augustus.png -------------------------------------------------------------------------------- /test/field_types/lib/tempfile.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mongodb/pymodm/be1c7b079df4954ef7e79e46f1b4a9ac9510766c/test/field_types/lib/tempfile.txt -------------------------------------------------------------------------------- /test/field_types/lib/testfile.txt: -------------------------------------------------------------------------------- 1 | Hello from testfile! 2 | -------------------------------------------------------------------------------- /test/field_types/test_biginteger_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import bson 16 | 17 | from pymodm.errors import ValidationError 18 | from pymodm.fields import BigIntegerField 19 | 20 | from test.field_types import FieldTestCase 21 | 22 | 23 | class BigIntegerFieldTestCase(FieldTestCase): 24 | 25 | field = BigIntegerField(min_value=0, max_value=100) 26 | 27 | def test_conversion(self): 28 | self.assertConversion(self.field, 42, '42') 29 | self.assertConversion(self.field, 42, 42) 30 | self.assertIsInstance(self.field.to_python(42), bson.int64.Int64) 31 | self.assertIsInstance(self.field.to_mongo(42), bson.int64.Int64) 32 | 33 | def test_validate(self): 34 | with self.assertRaisesRegex(ValidationError, 'greater than maximum'): 35 | self.field.validate(101) 36 | with self.assertRaisesRegex(ValidationError, 'less than minimum'): 37 | self.field.validate(-1) 38 | # No Exception. 39 | self.field.validate(42) 40 | -------------------------------------------------------------------------------- /test/field_types/test_binary_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from bson.binary import OLD_BINARY_SUBTYPE, Binary 16 | 17 | from pymodm.fields import BinaryField 18 | 19 | from test.field_types import FieldTestCase 20 | 21 | 22 | class BinaryFieldTestCase(FieldTestCase): 23 | 24 | field = BinaryField(subtype=OLD_BINARY_SUBTYPE) 25 | binary = Binary(b'\x01\x02\x03\x04', OLD_BINARY_SUBTYPE) 26 | 27 | def test_conversion(self): 28 | self.assertConversion(self.field, self.binary, self.binary) 29 | self.assertConversion(self.field, self.binary, b'\x01\x02\x03\x04') 30 | -------------------------------------------------------------------------------- /test/field_types/test_boolean_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm.fields import BooleanField 16 | 17 | from test.field_types import FieldTestCase 18 | 19 | 20 | class BinaryFieldTestCase(FieldTestCase): 21 | 22 | field = BooleanField() 23 | 24 | def test_conversion(self): 25 | self.assertConversion(self.field, False, '') 26 | self.assertConversion(self.field, False, False) 27 | self.assertConversion(self.field, True, 'hello') 28 | self.assertConversion(self.field, True, True) 29 | -------------------------------------------------------------------------------- /test/field_types/test_char_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm.errors import ValidationError 16 | from pymodm.fields import CharField 17 | 18 | from test.field_types import FieldTestCase 19 | 20 | 21 | class CharFieldTestCase(FieldTestCase): 22 | 23 | field = CharField(min_length=2, max_length=5) 24 | 25 | def test_conversion(self): 26 | self.assertConversion(self.field, 'hello', 'hello') 27 | self.assertConversion(self.field, '42', 42) 28 | 29 | def test_validate(self): 30 | msg = 'exceeds the maximum length of 5' 31 | with self.assertRaisesRegex(ValidationError, msg): 32 | self.field.validate('onomatopoeia') 33 | msg = 'is under the minimum length of 2' 34 | with self.assertRaisesRegex(ValidationError, msg): 35 | self.field.validate('a') 36 | self.field.validate('hello') 37 | -------------------------------------------------------------------------------- /test/field_types/test_datetime_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import datetime 16 | import time 17 | 18 | from bson.tz_util import utc, FixedOffset 19 | 20 | from pymodm.errors import ValidationError 21 | from pymodm.fields import DateTimeField 22 | 23 | from test.field_types import FieldTestCase 24 | 25 | 26 | # (expected value, value to be converted) 27 | DATETIME_CASES = [ 28 | ( # datetimes are given back as-is. 29 | datetime.datetime(2006, 7, 2, 1, 3, 4, 123456, utc), 30 | datetime.datetime(2006, 7, 2, 1, 3, 4, 123456, utc) 31 | ), 32 | ( # parse str() of datetime. 33 | datetime.datetime(2006, 7, 2, 1, 3, 4, 123456, utc), 34 | '2006-07-02 01:03:04.123456+00:00' 35 | ), 36 | ( # alternative format 37 | datetime.datetime(2006, 7, 2, 1, 3, 4, 123456, utc), 38 | '2006-7-2T01:03:04.123456Z' 39 | ), 40 | ( # with fixed timezone offset 41 | datetime.datetime( 42 | 2006, 7, 2, 1, 3, 4, 123456, FixedOffset(270, '+04:30')), 43 | '2006-7-2T01:03:04.123456+04:30' 44 | ), 45 | ( # missing microseconds 46 | datetime.datetime(2006, 7, 2, 1, 3, 4, 0, FixedOffset(270, '+04:30')), 47 | '2006-7-2T01:03:04+04:30' 48 | ), 49 | ( # missing seconds 50 | datetime.datetime(2006, 7, 2, 1, 3, 0, 0, FixedOffset(270, '+04:30')), 51 | '2006-7-2T01:03+04:30' 52 | ), 53 | ( # only hour and minute 54 | datetime.datetime(2006, 7, 2, 0, 0, 0, 0, FixedOffset(270, '+04:30')), 55 | '2006-7-2+04:30' 56 | ), 57 | ( # with negative timezone offset 58 | datetime.datetime(2006, 7, 2, 1, 3, 4, 123456, 59 | FixedOffset(-180, '-03:00')), 60 | '2006-7-2T01:03:04.123456-0300' 61 | ), 62 | ( # only three digits of microseconds 63 | datetime.datetime(2006, 7, 2, 1, 3, 4, 123000, 64 | FixedOffset(-180, '-03:00')), 65 | '2006-7-2T01:03:04.123-0300' 66 | ) 67 | ] 68 | 69 | 70 | class DateTimeFieldTestCase(FieldTestCase): 71 | 72 | field = DateTimeField() 73 | date = datetime.datetime( 74 | year=2006, month=7, day=2, hour=1, minute=3, second=4, 75 | microsecond=123456, tzinfo=utc) 76 | 77 | def test_conversion(self): 78 | self.assertConversion( 79 | self.field, 80 | datetime.datetime(2006, 7, 2), 81 | datetime.date(2006, 7, 2)) 82 | 83 | now = time.time() 84 | self.assertConversion( 85 | self.field, 86 | datetime.datetime.utcfromtimestamp(now), 87 | now) 88 | 89 | for expected, to_convert in DATETIME_CASES: 90 | self.assertConversion(self.field, expected, to_convert) 91 | 92 | def test_validate(self): 93 | msg = 'cannot be converted to a datetime object' 94 | invalid_values = [ 95 | # Microseconds without seconds. 96 | '2006-7-2T123.456', 97 | # Nonsense timezone. 98 | '2006-7-2T01:03:04.123456-03000', 99 | # Hours only. 100 | '2006-7-2T01', 101 | # Wrong format. 102 | '12/4/89' 103 | ] 104 | for invalid in invalid_values: 105 | msg = '%r cannot be converted to a datetime object.' % (invalid,) 106 | with self.assertRaisesRegex(ValidationError, msg): 107 | self.field.validate(invalid) 108 | -------------------------------------------------------------------------------- /test/field_types/test_decimal128_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import decimal 16 | 17 | from unittest import SkipTest 18 | 19 | from pymodm.errors import ValidationError 20 | from pymodm.fields import Decimal128Field 21 | 22 | HAS_DECIMAL128 = True 23 | try: 24 | from bson.decimal128 import Decimal128, create_decimal128_context 25 | except ImportError: 26 | HAS_DECIMAL128 = False 27 | 28 | from test import DB 29 | from test.field_types import FieldTestCase 30 | 31 | 32 | class Decimal128FieldTestCase(FieldTestCase): 33 | 34 | @classmethod 35 | def setUpClass(cls): 36 | if not HAS_DECIMAL128: 37 | raise SkipTest( 38 | 'Need PyMongo >= 3.4 in order to test Decimal128Field.') 39 | buildinfo = DB.command('buildinfo') 40 | version = tuple(buildinfo['versionArray'][:3]) 41 | if version < (3, 3, 6): 42 | raise SkipTest('Must have MongoDB >= 3.4 to test Decimal128Field.') 43 | cls.field = Decimal128Field(min_value=0, max_value=100) 44 | 45 | def test_conversion(self): 46 | with decimal.localcontext(create_decimal128_context()) as ctx: 47 | expected = Decimal128(ctx.create_decimal('42')) 48 | self.assertConversion(self.field, expected, 42) 49 | self.assertConversion(self.field, expected, '42') 50 | self.assertConversion(self.field, expected, decimal.Decimal('42')) 51 | 52 | def test_validate(self): 53 | with self.assertRaisesRegex(ValidationError, 'greater than maximum'): 54 | self.field.validate(101) 55 | with self.assertRaisesRegex(ValidationError, 'less than minimum'): 56 | self.field.validate(-1) 57 | msg = 'Cannot convert value .* InvalidOperation' 58 | with self.assertRaisesRegex(ValidationError, msg): 59 | self.field.validate('hello') 60 | # No Exception. 61 | self.field.validate(42) 62 | self.field.validate(decimal.Decimal('42.111')) 63 | self.field.validate('42.111') 64 | -------------------------------------------------------------------------------- /test/field_types/test_dict_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm.errors import ValidationError 16 | from pymodm.fields import DictField 17 | 18 | from test import INVALID_MONGO_NAMES, VALID_MONGO_NAMES 19 | from test.field_types import FieldTestCase 20 | 21 | 22 | class DictFieldTestCase(FieldTestCase): 23 | 24 | field = DictField() 25 | 26 | def test_conversion(self): 27 | self.assertConversion(self.field, 28 | {'one': 1, 'two': 2}, {'one': 1, 'two': 2}) 29 | 30 | def test_validate(self): 31 | msg = 'Dictionary keys must be a string type, not a int' 32 | with self.assertRaisesRegex(ValidationError, msg): 33 | self.field.validate({42: 'forty-two'}) 34 | 35 | for invalid_mongo_name in INVALID_MONGO_NAMES: 36 | msg = "Dictionary keys cannot .*" 37 | with self.assertRaisesRegex(ValidationError, msg): 38 | self.field.validate({invalid_mongo_name: 42}) 39 | # Invalid name in a sub dict. 40 | with self.assertRaisesRegex(ValidationError, msg): 41 | self.field.validate({'foo': {invalid_mongo_name: 42}}) 42 | # Invalid name in a sub dict inside an array. 43 | with self.assertRaisesRegex(ValidationError, msg): 44 | self.field.validate({'foo': [[{invalid_mongo_name: 42}]]}) 45 | 46 | for valid_mongo_name in VALID_MONGO_NAMES: 47 | self.field.validate({valid_mongo_name: 42}) 48 | self.field.validate({valid_mongo_name: [{valid_mongo_name: 42}]}) 49 | 50 | def test_get_default(self): 51 | self.assertEqual({}, self.field.get_default()) 52 | -------------------------------------------------------------------------------- /test/field_types/test_email_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm.errors import ValidationError 16 | from pymodm.fields import EmailField 17 | 18 | from test.field_types import FieldTestCase 19 | 20 | 21 | class EmailFieldTestCase(FieldTestCase): 22 | 23 | field = EmailField() 24 | 25 | def test_conversion(self): 26 | self.assertConversion(self.field, 'foo@bar.com', 'foo@bar.com') 27 | 28 | def test_validate(self): 29 | msg = 'not a valid email address' 30 | with self.assertRaisesRegex(ValidationError, msg): 31 | self.field.validate('hello') 32 | self.field.validate('foo@bar.com') 33 | -------------------------------------------------------------------------------- /test/field_types/test_embedded_document_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | from bson import SON 15 | 16 | from pymodm import EmbeddedMongoModel 17 | from pymodm.errors import ValidationError 18 | from pymodm.fields import EmbeddedModelField, CharField 19 | 20 | from test.field_types import FieldTestCase 21 | 22 | 23 | class EmbeddedDocument(EmbeddedMongoModel): 24 | name = CharField() 25 | 26 | class Meta: 27 | final = True 28 | 29 | 30 | class EmbeddedModelFieldTestCase(FieldTestCase): 31 | 32 | field = EmbeddedModelField(EmbeddedDocument) 33 | 34 | def test_to_python(self): 35 | value = self.field.to_python({'name': 'Bob'}) 36 | self.assertIsInstance(value, EmbeddedDocument) 37 | 38 | doc = EmbeddedDocument(name='Bob') 39 | value = self.field.to_python(doc) 40 | self.assertIsInstance(value, EmbeddedDocument) 41 | self.assertEqual(value, doc) 42 | 43 | def test_to_mongo(self): 44 | doc = EmbeddedDocument(name='Bob') 45 | value = self.field.to_mongo(doc) 46 | self.assertIsInstance(value, SON) 47 | self.assertEqual(value, SON({'name': 'Bob'})) 48 | 49 | son = value 50 | value = self.field.to_mongo(son) 51 | self.assertIsInstance(value, SON) 52 | self.assertEqual(value, SON({'name': 'Bob'})) 53 | 54 | value = self.field.to_mongo({'name': 'Bob'}) 55 | 56 | self.assertIsInstance(value, SON) 57 | self.assertEqual(value, SON({'name': 'Bob'})) 58 | 59 | def test_to_mongo_wrong_model(self): 60 | with self.assertRaises(ValidationError) as cm: 61 | self.field.to_mongo(1234) 62 | exc = cm.exception 63 | self.assertEqual(exc.message, '1234 is not a valid EmbeddedDocument') 64 | -------------------------------------------------------------------------------- /test/field_types/test_embedded_document_list_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | from bson import SON 15 | 16 | from pymodm import EmbeddedMongoModel 17 | from pymodm.fields import EmbeddedModelListField, CharField 18 | 19 | from test.field_types import FieldTestCase 20 | 21 | 22 | class EmbeddedDocument(EmbeddedMongoModel): 23 | name = CharField() 24 | 25 | class Meta: 26 | final = True 27 | 28 | 29 | class EmbeddedModelFieldTestCase(FieldTestCase): 30 | 31 | field = EmbeddedModelListField(EmbeddedDocument) 32 | 33 | def test_to_python(self): 34 | # pass a raw list 35 | value = self.field.to_python([{'name': 'Bob'}, {'name': 'Alice'}]) 36 | 37 | self.assertIsInstance(value, list) 38 | self.assertIsInstance(value[0], EmbeddedDocument) 39 | self.assertEqual(value[0].name, 'Bob') 40 | self.assertIsInstance(value[1], EmbeddedDocument) 41 | self.assertEqual(value[1].name, 'Alice') 42 | 43 | # pass a list of models 44 | bob = EmbeddedDocument(name='Bob') 45 | alice = EmbeddedDocument(name='Alice') 46 | value = self.field.to_python([bob, alice]) 47 | 48 | self.assertIsInstance(value, list) 49 | self.assertIsInstance(value[0], EmbeddedDocument) 50 | self.assertEqual(value[0].name, 'Bob') 51 | self.assertIsInstance(value[1], EmbeddedDocument) 52 | self.assertEqual(value[1].name, 'Alice') 53 | 54 | def test_to_mongo(self): 55 | bob = EmbeddedDocument(name='Bob') 56 | alice = EmbeddedDocument(name='Alice') 57 | emb_list = [bob, alice] 58 | value = self.field.to_mongo(emb_list) 59 | self.assertIsInstance(value, list) 60 | self.assertIsInstance(value[0], SON) 61 | self.assertEqual(value[0], SON({'name': 'Bob'})) 62 | self.assertIsInstance(value[1], SON) 63 | self.assertEqual(value[1], SON({'name': 'Alice'})) 64 | 65 | son = value 66 | value = self.field.to_mongo(son) 67 | self.assertIsInstance(value, list) 68 | self.assertIsInstance(value[0], SON) 69 | self.assertEqual(value[0], SON({'name': 'Bob'})) 70 | self.assertIsInstance(value[1], SON) 71 | self.assertEqual(value[1], SON({'name': 'Alice'})) 72 | 73 | value = self.field.to_mongo([{'name': 'Bob'}, alice]) 74 | self.assertIsInstance(value, list) 75 | self.assertIsInstance(value[0], SON) 76 | self.assertEqual(value[0], SON({'name': 'Bob'})) 77 | self.assertIsInstance(value[1], SON) 78 | self.assertEqual(value[1], SON({'name': 'Alice'})) 79 | 80 | def test_get_default(self): 81 | self.assertEqual([], self.field.get_default()) 82 | -------------------------------------------------------------------------------- /test/field_types/test_file_field.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import shutil 4 | 5 | from test import DB 6 | from test.field_types import FieldTestCase 7 | 8 | from pymodm import MongoModel 9 | from pymodm.errors import ValidationError 10 | from pymodm.fields import FileField 11 | from pymodm.files import File, Storage 12 | 13 | from test import connect_to_test_DB 14 | 15 | 16 | TEST_FILE_ROOT = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'lib') 17 | UPLOADS = os.path.join(TEST_FILE_ROOT, 'uploads') 18 | 19 | 20 | class ModelWithFile(MongoModel): 21 | upload = FileField() 22 | secondary_upload = FileField(required=False) 23 | 24 | 25 | # Test another Storage type. 26 | class LocalFileSystemStorage(Storage): 27 | """A Storage implementation that saves to a local folder. 28 | 29 | This is for test use only! 30 | """ 31 | def __init__(self, upload_to): 32 | self.upload_to = upload_to 33 | 34 | def _path(self, name): 35 | _, name = os.path.split(name) 36 | return os.path.join(self.upload_to, name) 37 | 38 | def open(self, name, mode='rb'): 39 | return File(open(self._path(name), mode)) 40 | 41 | def save(self, name, content, metadata=None): 42 | name = self._path(name) 43 | # Create directories up to given filename. 44 | if not os.path.exists(self.upload_to): 45 | os.makedirs(self.upload_to) 46 | dest = None 47 | try: 48 | for chunk in content.chunks(): 49 | if dest is None: 50 | mode = 'wb' if isinstance(chunk, bytes) else 'wt' 51 | dest = open(name, mode) 52 | dest.write(chunk) 53 | # Create an empty file. 54 | if dest is None: 55 | dest = open(name, 'wb') 56 | finally: 57 | dest.close() 58 | return name 59 | 60 | def delete(self, name): 61 | os.remove(self._path(name)) 62 | 63 | def exists(self, name): 64 | return os.path.exists(self._path(name)) 65 | 66 | 67 | class ModelWithLocalFile(MongoModel): 68 | upload = FileField(storage=LocalFileSystemStorage(UPLOADS)) 69 | secondary_upload = FileField(storage=LocalFileSystemStorage(UPLOADS), 70 | required=False) 71 | 72 | 73 | class FileFieldTestMixin(object): 74 | 75 | testfile = os.path.join(TEST_FILE_ROOT, 'testfile.txt') 76 | tempfile = os.path.join(TEST_FILE_ROOT, 'tempfile.txt') 77 | 78 | def test_set_file(self): 79 | # Create directly with builtin 'open'. 80 | with open(self.testfile) as this_file: 81 | mwf = self.model(this_file).save() 82 | mwf.refresh_from_db() 83 | self.assertEqual('testfile.txt', os.path.basename(mwf.upload.name)) 84 | self.assertIn(b'Hello from testfile!', mwf.upload.read()) 85 | 86 | def test_set_file_object(self): 87 | # Create with File object. 88 | with open(self.testfile) as this_file: 89 | # Set a name explicitly ("uploaded"). 90 | wrapped_with_name = File( 91 | this_file, 'uploaded', metadata=self.file_metadata) 92 | # Name set from underlying file. 93 | wrapped = File(this_file, metadata=self.file_metadata) 94 | mwf = self.model(wrapped_with_name, wrapped).save() 95 | mwf.refresh_from_db() 96 | self.assertEqual('uploaded', os.path.basename(mwf.upload.name)) 97 | self.assertEqual('testfile.txt', 98 | os.path.basename(mwf.secondary_upload.name)) 99 | self.assertIn(b'Hello from testfile!', mwf.upload.read()) 100 | if self.file_metadata is not None: 101 | self.assertEqual(self.file_metadata['contentType'], 102 | mwf.upload.metadata['contentType']) 103 | 104 | def test_delete_file(self): 105 | # Create a file to delete. 106 | open(self.tempfile, 'w').close() 107 | with open(self.tempfile) as tempfile: 108 | mwf = self.model(tempfile).save() 109 | mwf.upload.delete() 110 | self.assertIsNone(mwf.upload) 111 | self.assertIsNone(DB.fs.files.find_one()) 112 | 113 | def test_seek(self): 114 | with open(self.testfile) as this_file: 115 | mwf = self.model(this_file).save() 116 | self.assertEqual(0, mwf.upload.tell()) 117 | with open(self.testfile, 'rb') as compare: 118 | self.assertEqual(compare.read(), mwf.upload.read()) 119 | compare.seek(7) 120 | mwf.upload.seek(7) 121 | self.assertEqual(compare.read(10), mwf.upload.read(10)) 122 | self.assertEqual(compare.tell(), mwf.upload.tell()) 123 | self.assertEqual(compare.read(), mwf.upload.read()) 124 | self.assertEqual(compare.tell(), mwf.upload.tell()) 125 | 126 | def test_multiple_references_to_same_file(self): 127 | with open(self.testfile) as testfile: 128 | mwf = self.model(testfile).save() 129 | # Try to copy the file from its current location to a new field. 130 | mwf.secondary_upload = mwf.upload 131 | mwf.save() 132 | self.assertEqual(mwf.upload, mwf.secondary_upload) 133 | self.assertIs(mwf.upload, mwf.secondary_upload) 134 | 135 | def test_exists(self): 136 | storage = self.model.upload.storage 137 | self.assertFalse(storage.exists(self.testfile)) 138 | with open(self.testfile) as this_file: 139 | instance = self.model(this_file).save() 140 | self.assertTrue(storage.exists(instance.upload.file_id)) 141 | 142 | def test_file_modes(self): 143 | text_file = open(self.testfile, 'rt') 144 | binary_file = open(self.testfile, 'rb') 145 | instance = self.model(text_file, binary_file).save() 146 | text_file.close() 147 | binary_file.close() 148 | upload_content = instance.upload.read() 149 | self.assertIsInstance(upload_content, bytes) 150 | self.assertEqual( 151 | instance.secondary_upload.read(), 152 | upload_content) 153 | 154 | 155 | class FileFieldGridFSTestCase(FieldTestCase, FileFieldTestMixin): 156 | model = ModelWithFile 157 | file_metadata = {'contentType': 'text/python'} 158 | 159 | 160 | class FileFieldAlternateStorageTestCase(FieldTestCase, FileFieldTestMixin): 161 | model = ModelWithLocalFile 162 | file_metadata = None 163 | 164 | def tearDown(self): 165 | # Remove uploads directory. 166 | try: 167 | shutil.rmtree(UPLOADS) 168 | except OSError: 169 | pass 170 | 171 | 172 | class FileFieldGridFSNoConnectionTestCase(FieldTestCase): 173 | testfile = os.path.join(TEST_FILE_ROOT, 'testfile.txt') 174 | file_metadata = {'contentType': 'text/python'} 175 | 176 | def test_lazy_storage_initialization(self): 177 | conn_alias = 'test-field-file-no-connection' 178 | class ModelWithFileNoConnection(MongoModel): 179 | upload = FileField() 180 | class Meta: 181 | connection_alias = conn_alias 182 | 183 | with open(self.testfile) as this_file: 184 | model = ModelWithFileNoConnection( 185 | File(this_file, 'new', metadata=self.file_metadata), 186 | ) 187 | 188 | # With no connection, an exception should be thrown. 189 | with self.assertRaisesRegex( 190 | ValidationError, "No such alias {!r}".format( 191 | conn_alias)): 192 | model.save() 193 | 194 | # Should succeed once connection is created. 195 | connect_to_test_DB(conn_alias) 196 | model.save() 197 | -------------------------------------------------------------------------------- /test/field_types/test_float_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm.errors import ValidationError 16 | from pymodm.fields import FloatField 17 | 18 | from test.field_types import FieldTestCase 19 | 20 | 21 | class FloatFieldTestCase(FieldTestCase): 22 | 23 | field = FloatField(min_value=0, max_value=100) 24 | 25 | def test_conversion(self): 26 | self.assertConversion(self.field, 42.0, '42') 27 | self.assertConversion(self.field, 42.0, 42) 28 | 29 | def test_validate(self): 30 | with self.assertRaisesRegex(ValidationError, 'greater than maximum'): 31 | self.field.validate(101) 32 | with self.assertRaisesRegex(ValidationError, 'less than minimum'): 33 | self.field.validate(-1) 34 | # No Exception. 35 | self.field.validate(42.123) 36 | -------------------------------------------------------------------------------- /test/field_types/test_generic_ip_address_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm.errors import ValidationError 16 | from pymodm.fields import GenericIPAddressField 17 | 18 | from test.field_types import FieldTestCase 19 | 20 | 21 | class GenericIPAddressFieldTestCase(FieldTestCase): 22 | 23 | field_ipv4 = GenericIPAddressField(protocol=GenericIPAddressField.IPV4) 24 | field_ipv6 = GenericIPAddressField(protocol=GenericIPAddressField.IPV6) 25 | field_both = GenericIPAddressField(protocol=GenericIPAddressField.BOTH) 26 | 27 | def test_conversion(self): 28 | self.assertConversion(self.field_ipv4, '192.168.1.100', '192.168.1.100') 29 | self.assertConversion(self.field_ipv6, 30 | 'fe80::6203:8ff:fe89', 'fe80::6203:8ff:fe89') 31 | self.assertConversion(self.field_both, '192.168.1.100', '192.168.1.100') 32 | self.assertConversion(self.field_both, 33 | 'fe80::6203:8ff:fe89', 'fe80::6203:8ff:fe89') 34 | 35 | def test_validate(self): 36 | with self.assertRaisesRegex(ValidationError, 'not a valid IP address'): 37 | self.field_ipv4.validate('fe80::6203:8ff:fe89') 38 | with self.assertRaisesRegex(ValidationError, 'not a valid IP address'): 39 | self.field_ipv6.validate('192.168.1.100') 40 | with self.assertRaisesRegex(ValidationError, 'not a valid IP address'): 41 | self.field_both.validate('hello') 42 | with self.assertRaisesRegex(ValidationError, 'not a valid IP address'): 43 | self.field_both.validate('192.168.1.100 ') # Trailing space 44 | self.field_ipv4.validate('192.168.1.100') 45 | self.field_ipv6.validate('fe80::6203:8ff:fe89') 46 | self.field_both.validate('192.168.1.100') 47 | self.field_both.validate('fe80::6203:8ff:fe89') 48 | -------------------------------------------------------------------------------- /test/field_types/test_geometrycollection_field.py: -------------------------------------------------------------------------------- 1 | from pymodm.errors import ValidationError 2 | from pymodm.fields import GeometryCollectionField 3 | 4 | from test import ODMTestCase 5 | 6 | 7 | class GeometryCollectionFieldTestCase(ODMTestCase): 8 | 9 | field = GeometryCollectionField() 10 | geometries = [ 11 | {'type': 'Point', 'coordinates': [1, 2]}, 12 | {'type': 'LineString', 'coordinates': [[1, 2]]} 13 | ] 14 | geojson = {'type': 'GeometryCollection', 'geometries': geometries} 15 | 16 | def test_to_python(self): 17 | self.assertEqual(self.geojson, self.field.to_python(self.geometries)) 18 | self.assertEqual(self.geojson, self.field.to_python(self.geojson)) 19 | 20 | def test_to_mongo(self): 21 | self.assertEqual(self.geojson, self.field.to_mongo(self.geometries)) 22 | self.assertEqual(self.geojson, self.field.to_mongo(self.geojson)) 23 | 24 | def test_validate(self): 25 | msg = 'Value must be a dict' 26 | with self.assertRaisesRegex(ValidationError, msg): 27 | self.field.validate(42) 28 | msg = "GeoJSON type must be 'GeometryCollection'" 29 | with self.assertRaisesRegex(ValidationError, msg): 30 | self.field.validate({'type': 'Polygon', 'coordinates': []}) 31 | msg = 'geometries must contain at least one geometry' 32 | with self.assertRaisesRegex(ValidationError, msg): 33 | self.field.validate({'type': 'GeometryCollection', 34 | 'geometries': []}) 35 | msg = 'Geometries must be one of .*list.*tuple' 36 | with self.assertRaisesRegex(ValidationError, msg): 37 | self.field.validate( 38 | {'type': 'GeometryCollection', 'geometries': 42}) 39 | msg = 'LineString must start and end at the same Point' 40 | with self.assertRaisesRegex(ValidationError, msg): 41 | self.field.validate( 42 | {'type': 'GeometryCollection', 'geometries': [ 43 | {'type': 'Polygon', 'coordinates': [ 44 | [[1, 2], [3, 4]] 45 | ]} 46 | ]}) 47 | -------------------------------------------------------------------------------- /test/field_types/test_image_field.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | 4 | from unittest import SkipTest 5 | 6 | from test import DB 7 | from test.field_types import FieldTestCase 8 | 9 | from pymodm import MongoModel 10 | from pymodm.errors import ConfigurationError 11 | from pymodm.fields import ImageField 12 | from pymodm.files import File 13 | 14 | 15 | class ImageFieldTestCase(FieldTestCase): 16 | 17 | image_src = os.path.join( 18 | os.path.dirname(os.path.abspath(__file__)), 'lib', 'augustus.png') 19 | image_width = 589 20 | image_height = 387 21 | image_format = 'PNG' 22 | 23 | @classmethod 24 | def setUpClass(cls): 25 | try: 26 | # Define class here so tests don't fail with an error if PIL is not 27 | # installed. 28 | class ModelWithImage(MongoModel): 29 | image = ImageField() 30 | cls.ModelWithImage = ModelWithImage 31 | except ConfigurationError: 32 | raise SkipTest('Cannot test ImageField without PIL installed.') 33 | 34 | def test_set_file(self): 35 | # Create directly with builtin 'open'. 36 | with open(self.image_src, 'rb') as image_src: 37 | mwi = self.ModelWithImage(image_src).save() 38 | mwi.refresh_from_db() 39 | # Uploaded! 40 | self.assertEqual(self.image_src, mwi.image.name) 41 | self.assertTrue(DB.fs.files.find_one().get('length')) 42 | 43 | def test_set_file_object(self): 44 | # Create with File object. 45 | with open(self.image_src, 'rb') as image_src: 46 | wrapped = File(image_src, metadata={'contentType': 'image/png'}) 47 | mwi = self.ModelWithImage(wrapped).save() 48 | mwi.refresh_from_db() 49 | self.assertEqual(self.image_src, mwi.image.name) 50 | self.assertTrue(DB.fs.files.find_one().get('length')) 51 | self.assertEqual('image/png', mwi.image.metadata.get('contentType')) 52 | 53 | def test_image_field_file_properties(self): 54 | with open(self.image_src, 'rb') as image_src: 55 | mwi = self.ModelWithImage(image_src).save() 56 | self.assertEqual(self.image_width, mwi.image.width) 57 | self.assertEqual(self.image_height, mwi.image.height) 58 | self.assertEqual(self.image_format, mwi.image.format) 59 | -------------------------------------------------------------------------------- /test/field_types/test_integer_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm.errors import ValidationError 16 | from pymodm.fields import IntegerField 17 | 18 | from test.field_types import FieldTestCase 19 | 20 | 21 | class IntegerFieldTestCase(FieldTestCase): 22 | 23 | field = IntegerField(min_value=0, max_value=100) 24 | 25 | def test_conversion(self): 26 | self.assertConversion(self.field, 42, '42') 27 | self.assertConversion(self.field, 42, 42) 28 | 29 | def test_validate(self): 30 | with self.assertRaisesRegex(ValidationError, 'greater than maximum'): 31 | self.field.validate(101) 32 | with self.assertRaisesRegex(ValidationError, 'less than minimum'): 33 | self.field.validate(-1) 34 | # No Exception. 35 | self.field.validate(42) 36 | -------------------------------------------------------------------------------- /test/field_types/test_javascript_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from bson.code import Code 16 | 17 | from pymodm.fields import JavaScriptField 18 | 19 | from test.field_types import FieldTestCase 20 | 21 | 22 | class JavaScriptFieldTestCase(FieldTestCase): 23 | 24 | field = JavaScriptField() 25 | code_str = 'function() { return true; }' 26 | code = Code(code_str) 27 | 28 | def test_conversion(self): 29 | self.assertConversion(self.field, self.code, self.code_str) 30 | self.assertConversion(self.field, self.code, self.code) 31 | -------------------------------------------------------------------------------- /test/field_types/test_linestring_field.py: -------------------------------------------------------------------------------- 1 | from pymodm.errors import ValidationError 2 | from pymodm.fields import LineStringField 3 | 4 | from test.field_types import FieldTestCase 5 | 6 | 7 | class LineStringFieldTestCase(FieldTestCase): 8 | 9 | field = LineStringField() 10 | geojson = {'type': 'LineString', 'coordinates': [[1, 2], [3, 4]]} 11 | 12 | def test_conversion(self): 13 | self.assertConversion(self.field, self.geojson, [[1, 2], [3, 4]]) 14 | self.assertConversion(self.field, self.geojson, self.geojson) 15 | 16 | def test_validate(self): 17 | msg = 'Value must be a dict' 18 | with self.assertRaisesRegex(ValidationError, msg): 19 | self.field.validate(42) 20 | msg = "GeoJSON type must be 'LineString'" 21 | with self.assertRaisesRegex(ValidationError, msg): 22 | self.field.validate({'type': 'Polygon', 'coordinates': []}) 23 | msg = 'Coordinates must be one of .*list.*tuple' 24 | with self.assertRaisesRegex(ValidationError, msg): 25 | self.field.validate({'type': 'LineString', 'coordinates': 42}) 26 | msg = 'must contain at least one Point' 27 | with self.assertRaisesRegex(ValidationError, msg): 28 | self.field.validate({'type': 'LineString', 'coordinates': [42]}) 29 | with self.assertRaisesRegex(ValidationError, msg): 30 | self.field.validate({'type': 'LineString', 'coordinates': [[]]}) 31 | -------------------------------------------------------------------------------- /test/field_types/test_list_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm import MongoModel 16 | from pymodm.errors import ValidationError 17 | from pymodm.fields import ListField, IntegerField, CharField 18 | 19 | from test.field_types import FieldTestCase 20 | 21 | 22 | class ListFieldTestCase(FieldTestCase): 23 | 24 | field = ListField(IntegerField(min_value=0)) 25 | 26 | def test_conversion(self): 27 | self.assertConversion(self.field, [1, 2, 3], [1, 2, 3]) 28 | self.assertConversion(self.field, [1, 2, 3], ['1', '2', '3']) 29 | 30 | def test_validate(self): 31 | with self.assertRaisesRegex(ValidationError, 'less than minimum'): 32 | self.field.validate([-1, 3, 4]) 33 | self.field.validate([1, 2, 3]) 34 | 35 | def test_get_default(self): 36 | self.assertEqual([], self.field.get_default()) 37 | 38 | def test_generic_list_field(self): 39 | class MyModel(MongoModel): 40 | data = ListField() 41 | 42 | mydata = [1, 'hello', {'a': 1}] 43 | mymodel = MyModel(data=mydata).save() 44 | mymodel.refresh_from_db() 45 | 46 | self.assertEqual(mymodel.data, mydata) 47 | 48 | def test_field_validation_on_initialization(self): 49 | # Initializing ListField with field type raises exception. 50 | with self.assertRaisesRegex( 51 | ValueError, 52 | "field must be an instance of MongoBaseField"): 53 | _ = ListField(CharField) 54 | -------------------------------------------------------------------------------- /test/field_types/test_multilinestring_field.py: -------------------------------------------------------------------------------- 1 | from pymodm.errors import ValidationError 2 | from pymodm.fields import MultiLineStringField 3 | 4 | from test import ODMTestCase 5 | 6 | 7 | class MultiLineStringFieldTestCase(ODMTestCase): 8 | 9 | field = MultiLineStringField() 10 | geojson = {'type': 'MultiLineString', 'coordinates': [[[1, 2], [3, 4]]]} 11 | 12 | def test_to_python(self): 13 | self.assertEqual(self.geojson, self.field.to_python([[[1, 2], [3, 4]]])) 14 | self.assertEqual(self.geojson, self.field.to_python(self.geojson)) 15 | 16 | def test_to_mongo(self): 17 | self.assertEqual(self.geojson, self.field.to_mongo([[[1, 2], [3, 4]]])) 18 | self.assertEqual(self.geojson, self.field.to_mongo(self.geojson)) 19 | 20 | def test_validate(self): 21 | msg = 'Value must be a dict' 22 | with self.assertRaisesRegex(ValidationError, msg): 23 | self.field.validate(42) 24 | msg = "GeoJSON type must be 'MultiLineString'" 25 | with self.assertRaisesRegex(ValidationError, msg): 26 | self.field.validate({'type': 'Polygon', 'coordinates': []}) 27 | msg = 'Coordinates must be one of .*list.*tuple' 28 | with self.assertRaisesRegex(ValidationError, msg): 29 | self.field.validate({'type': 'MultiLineString', 'coordinates': 42}) 30 | msg = 'must contain at least one LineString' 31 | with self.assertRaisesRegex(ValidationError, msg): 32 | self.field.validate({'type': 'MultiLineString', 'coordinates': []}) 33 | with self.assertRaisesRegex(ValidationError, msg): 34 | self.field.validate( 35 | {'type': 'MultiLineString', 'coordinates': [[]]}) 36 | -------------------------------------------------------------------------------- /test/field_types/test_multipoint_field.py: -------------------------------------------------------------------------------- 1 | from pymodm.errors import ValidationError 2 | from pymodm.fields import MultiPointField 3 | 4 | from test import ODMTestCase 5 | 6 | 7 | class MultiPointFieldTestCase(ODMTestCase): 8 | 9 | field = MultiPointField() 10 | geojson = {'type': 'MultiPoint', 'coordinates': [[1, 2], [3, 4]]} 11 | 12 | def test_to_python(self): 13 | self.assertEqual(self.geojson, self.field.to_python([[1, 2], [3, 4]])) 14 | self.assertEqual(self.geojson, self.field.to_python(self.geojson)) 15 | 16 | def test_to_mongo(self): 17 | self.assertEqual(self.geojson, self.field.to_mongo([[1, 2], [3, 4]])) 18 | self.assertEqual(self.geojson, self.field.to_mongo(self.geojson)) 19 | 20 | def test_validate(self): 21 | msg = 'Value must be a dict' 22 | with self.assertRaisesRegex(ValidationError, msg): 23 | self.field.validate(42) 24 | msg = "GeoJSON type must be 'MultiPoint'" 25 | with self.assertRaisesRegex(ValidationError, msg): 26 | self.field.validate({'type': 'Polygon', 'coordinates': []}) 27 | msg = 'Coordinates must be one of .*list.*tuple' 28 | with self.assertRaisesRegex(ValidationError, msg): 29 | self.field.validate({'type': 'MultiPoint', 'coordinates': 42}) 30 | msg = 'must contain at least one Point' 31 | with self.assertRaisesRegex(ValidationError, msg): 32 | self.field.validate({'type': 'MultiPoint', 'coordinates': []}) 33 | with self.assertRaisesRegex(ValidationError, msg): 34 | self.field.validate({'type': 'MultiPoint', 'coordinates': [[]]}) 35 | -------------------------------------------------------------------------------- /test/field_types/test_multipolygon_field.py: -------------------------------------------------------------------------------- 1 | from pymodm.errors import ValidationError 2 | from pymodm.fields import MultiPolygonField 3 | 4 | from test import ODMTestCase 5 | 6 | 7 | class MultiPolygonFieldTestCase(ODMTestCase): 8 | 9 | field = MultiPolygonField() 10 | coordinates = [[ 11 | [ 12 | [1, 2], [3, 4], [1, 2] 13 | ], 14 | [ 15 | [-1, -2] 16 | ] 17 | ]] 18 | geojson = {'type': 'MultiPolygon', 'coordinates': coordinates} 19 | 20 | def test_to_python(self): 21 | self.assertEqual(self.geojson, self.field.to_python(self.coordinates)) 22 | self.assertEqual(self.geojson, self.field.to_python(self.geojson)) 23 | 24 | def test_to_mongo(self): 25 | self.assertEqual(self.geojson, self.field.to_mongo(self.coordinates)) 26 | self.assertEqual(self.geojson, self.field.to_mongo(self.geojson)) 27 | 28 | def test_validate(self): 29 | msg = 'Value must be a dict' 30 | with self.assertRaisesRegex(ValidationError, msg): 31 | self.field.validate(42) 32 | msg = "GeoJSON type must be 'MultiPolygon'" 33 | with self.assertRaisesRegex(ValidationError, msg): 34 | self.field.validate({'type': 'Polygon', 'coordinates': []}) 35 | msg = 'Coordinates must be one of .*list.*tuple' 36 | with self.assertRaisesRegex(ValidationError, msg): 37 | self.field.validate({'type': 'MultiPolygon', 'coordinates': 42}) 38 | msg = 'must contain at least one Polygon' 39 | with self.assertRaisesRegex(ValidationError, msg): 40 | self.field.validate({'type': 'MultiPolygon', 'coordinates': [42]}) 41 | with self.assertRaisesRegex(ValidationError, msg): 42 | self.field.validate( 43 | {'type': 'MultiPolygon', 'coordinates': [[[]]]}) 44 | -------------------------------------------------------------------------------- /test/field_types/test_objectid_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from bson.objectid import ObjectId 16 | 17 | from pymodm.errors import ValidationError 18 | from pymodm.fields import ObjectIdField 19 | 20 | from test.field_types import FieldTestCase 21 | 22 | 23 | class ObjectIdFieldTestCase(FieldTestCase): 24 | 25 | field = ObjectIdField() 26 | oid = ObjectId() 27 | 28 | def test_conversion(self): 29 | self.assertConversion(self.field, self.oid, self.oid) 30 | self.assertConversion(self.field, self.oid, str(self.oid)) 31 | 32 | def test_validate(self): 33 | msg = 'not a valid ObjectId' 34 | with self.assertRaisesRegex(ValidationError, msg): 35 | self.field.validate('hello') 36 | # No Exception. 37 | self.field.validate(str(self.oid)) 38 | -------------------------------------------------------------------------------- /test/field_types/test_ordereddict_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from collections import OrderedDict 16 | 17 | from pymodm.errors import ValidationError 18 | from pymodm.fields import OrderedDictField 19 | 20 | from test import INVALID_MONGO_NAMES, VALID_MONGO_NAMES 21 | from test.field_types import FieldTestCase 22 | 23 | 24 | class OrderedDictFieldTestCase(FieldTestCase): 25 | 26 | field = OrderedDictField() 27 | 28 | def test_conversion(self): 29 | data = OrderedDict({'one': 1, 'two': 2}) 30 | self.assertConversion(self.field, data, data) 31 | self.assertConversion(self.field, data, {'one': 1, 'two': 2}) 32 | 33 | def test_validate(self): 34 | msg = 'Dictionary keys must be a string type, not a int' 35 | with self.assertRaisesRegex(ValidationError, msg): 36 | self.field.validate({42: 'forty-two'}) 37 | 38 | for invalid_mongo_name in INVALID_MONGO_NAMES: 39 | msg = "Dictionary keys cannot .*" 40 | with self.assertRaisesRegex(ValidationError, msg): 41 | self.field.validate({invalid_mongo_name: 42}) 42 | # Invalid name in a sub dict. 43 | with self.assertRaisesRegex(ValidationError, msg): 44 | self.field.validate({'foo': {invalid_mongo_name: 42}}) 45 | # Invalid name in a sub dict inside an array. 46 | with self.assertRaisesRegex(ValidationError, msg): 47 | self.field.validate({'foo': [[{invalid_mongo_name: 42}]]}) 48 | 49 | for valid_mongo_name in VALID_MONGO_NAMES: 50 | self.field.validate({valid_mongo_name: 42}) 51 | self.field.validate({valid_mongo_name: [{valid_mongo_name: 42}]}) 52 | 53 | def test_get_default(self): 54 | self.assertEqual(OrderedDict(), self.field.get_default()) 55 | 56 | def test_blank(self): 57 | self.assertTrue(self.field.is_blank(OrderedDict())) 58 | -------------------------------------------------------------------------------- /test/field_types/test_point_field.py: -------------------------------------------------------------------------------- 1 | from pymodm.errors import ValidationError 2 | from pymodm.fields import PointField 3 | 4 | from test.field_types import FieldTestCase 5 | 6 | 7 | class PointFieldTestCase(FieldTestCase): 8 | 9 | field = PointField() 10 | geojson = {'type': 'Point', 'coordinates': [1, 2]} 11 | 12 | def test_conversion(self): 13 | self.assertConversion(self.field, self.geojson, [1, 2]) 14 | self.assertConversion(self.field, self.geojson, self.geojson) 15 | 16 | def test_validate(self): 17 | msg = 'Value must be a dict' 18 | with self.assertRaisesRegex(ValidationError, msg): 19 | self.field.validate(42) 20 | msg = "GeoJSON type must be 'Point'" 21 | with self.assertRaisesRegex(ValidationError, msg): 22 | self.field.validate({'type': 'banana', 'coordinates': [1, 2]}) 23 | msg = 'Coordinates must be one of .*list.*tuple' 24 | with self.assertRaisesRegex(ValidationError, msg): 25 | self.field.validate({'type': 'Point', 'coordinates': 42}) 26 | msg = 'Point is not a pair' 27 | with self.assertRaisesRegex(ValidationError, msg): 28 | self.field.validate({'type': 'Point', 'coordinates': [42]}) 29 | -------------------------------------------------------------------------------- /test/field_types/test_polygon_field.py: -------------------------------------------------------------------------------- 1 | from pymodm.errors import ValidationError 2 | from pymodm.fields import PolygonField 3 | 4 | from test.field_types import FieldTestCase 5 | 6 | 7 | class PolygonFieldTestCase(FieldTestCase): 8 | 9 | field = PolygonField() 10 | coordinates = [ 11 | [ 12 | [1, 2], [3, 4], [1, 2] 13 | ], 14 | [ 15 | [-1, -2] 16 | ] 17 | ] 18 | geojson = {'type': 'Polygon', 'coordinates': coordinates} 19 | 20 | def test_conversion(self): 21 | self.assertConversion(self.field, self.geojson, self.coordinates) 22 | self.assertConversion(self.field, self.geojson, self.geojson) 23 | 24 | def test_validate(self): 25 | msg = 'Value must be a dict' 26 | with self.assertRaisesRegex(ValidationError, msg): 27 | self.field.validate(42) 28 | msg = "GeoJSON type must be 'Polygon'" 29 | with self.assertRaisesRegex(ValidationError, msg): 30 | self.field.validate({'type': 'Point', 'coordinates': [[]]}) 31 | msg = 'Coordinates must be one of .*list.*tuple' 32 | with self.assertRaisesRegex(ValidationError, msg): 33 | self.field.validate({'type': 'Polygon', 'coordinates': 42}) 34 | msg = 'must contain at least one LineString' 35 | with self.assertRaisesRegex(ValidationError, msg): 36 | self.field.validate({'type': 'Polygon', 'coordinates': [42]}) 37 | with self.assertRaisesRegex(ValidationError, msg): 38 | self.field.validate({'type': 'Polygon', 'coordinates': [[]]}) 39 | msg = 'must start and end at the same Point' 40 | with self.assertRaisesRegex(ValidationError, msg): 41 | self.field.validate({'type': 'Polygon', 'coordinates': [ 42 | [[1, 2], [3, 4]] 43 | ]}) 44 | -------------------------------------------------------------------------------- /test/field_types/test_reference_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm import MongoModel 16 | from pymodm.fields import ReferenceField, IntegerField, CharField 17 | 18 | from test.field_types import FieldTestCase 19 | 20 | 21 | class DummyReferenceModel(MongoModel): 22 | data = CharField() 23 | 24 | 25 | class ReferenceFieldTestCase(FieldTestCase): 26 | def test_validation_on_initialization(self): 27 | # Initializing ReferenceField with a model instance raises exception. 28 | dummy = DummyReferenceModel(data='hello') 29 | with self.assertRaisesRegex( 30 | ValueError, 31 | "model must be a Model class or a string"): 32 | _ = ReferenceField(dummy) 33 | 34 | # Initializing ReferenceField with a model class is OK. 35 | _ = ReferenceField(DummyReferenceModel) 36 | -------------------------------------------------------------------------------- /test/field_types/test_regular_expression_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import re 16 | 17 | from bson.regex import Regex 18 | 19 | from pymodm.fields import RegularExpressionField 20 | 21 | from test.field_types import FieldTestCase 22 | 23 | 24 | class RegularExpressionFieldTestCase(FieldTestCase): 25 | 26 | field = RegularExpressionField() 27 | pattern = re.compile('hello', re.UNICODE) 28 | regex = Regex.from_native(pattern) 29 | 30 | def assertPatternEquals(self, reg1, reg2): 31 | """Assert two compiled regular expression pattern objects are equal.""" 32 | self.assertEqual(reg1.pattern, reg2.pattern) 33 | self.assertEqual(reg1.flags, reg2.flags) 34 | 35 | def test_to_python(self): 36 | self.assertPatternEquals( 37 | self.pattern, self.field.to_python(self.pattern)) 38 | self.assertPatternEquals( 39 | self.pattern, self.field.to_python(self.regex)) 40 | 41 | def test_to_mongo(self): 42 | self.assertEqual(self.regex, self.field.to_mongo(self.regex)) 43 | -------------------------------------------------------------------------------- /test/field_types/test_timestamp_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import datetime 16 | 17 | from bson.timestamp import Timestamp 18 | 19 | from pymodm.errors import ValidationError 20 | from pymodm.fields import TimestampField 21 | 22 | from test.field_types import FieldTestCase 23 | from test.field_types.test_datetime_field import DATETIME_CASES 24 | 25 | 26 | class TimestampFieldTestCase(FieldTestCase): 27 | 28 | field = TimestampField() 29 | 30 | def test_conversion(self): 31 | for dt_expected, to_convert in DATETIME_CASES: 32 | self.assertConversion( 33 | self.field, 34 | Timestamp(dt_expected, 0), to_convert) 35 | 36 | def test_validate(self): 37 | msg = 'cannot be converted to a Timestamp' 38 | with self.assertRaisesRegex(ValidationError, msg): 39 | # Inconvertible type. 40 | self.field.validate(42) 41 | with self.assertRaisesRegex(ValidationError, msg): 42 | # Unacceptable format for date string. 43 | self.field.validate("2006-7-2T01:03:04.123456-03000") 44 | self.field.validate(datetime.datetime.now()) 45 | self.field.validate('2006-7-2T01:03:04.123456-0300') 46 | self.field.validate(Timestamp(0, 0)) 47 | -------------------------------------------------------------------------------- /test/field_types/test_url_field.py: -------------------------------------------------------------------------------- 1 | from pymodm.errors import ValidationError 2 | from pymodm.fields import URLField 3 | 4 | from test.field_types import FieldTestCase 5 | 6 | 7 | class URLFieldTestCase(FieldTestCase): 8 | 9 | field = URLField() 10 | 11 | def test_conversion(self): 12 | self.assertConversion(self.field, 13 | 'http://192.168.1.100/admin', 14 | 'http://192.168.1.100/admin') 15 | 16 | def test_validate(self): 17 | with self.assertRaisesRegex(ValidationError, 'Unrecognized scheme'): 18 | # Bad scheme. 19 | self.field.validate('afp://192.168.1.100') 20 | with self.assertRaisesRegex(ValidationError, 'Invalid URL'): 21 | # Bad domain. 22 | self.field.validate('http://??????????') 23 | with self.assertRaisesRegex(ValidationError, 'Invalid URL'): 24 | # Bad port. 25 | self.field.validate('http://foo.com:bar') 26 | with self.assertRaisesRegex(ValidationError, 'Invalid path'): 27 | # Bad path. 28 | self.field.validate('http://foo.com/ index.html') 29 | self.field.validate('http://foo.com:8080/index.html') 30 | self.field.validate('ftps://fe80::6203:8ff:fe89:b6b0:1234/foo/bar') 31 | -------------------------------------------------------------------------------- /test/field_types/test_uuid_field.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import uuid 16 | 17 | from bson.binary import JAVA_LEGACY 18 | from bson.codec_options import CodecOptions 19 | 20 | from pymodm import MongoModel 21 | from pymodm.errors import ValidationError 22 | from pymodm.fields import UUIDField 23 | 24 | from test import DB 25 | from test.field_types import FieldTestCase 26 | 27 | 28 | class ModelWithUUID(MongoModel): 29 | id = UUIDField(primary_key=True) 30 | 31 | class Meta: 32 | # The UUID representation is given at the Model level instead of at the 33 | # field level because PyMongo's CodecOptions has collection level 34 | # granularity. Providing the ability to mix UUID representations will 35 | # make those UUIDs very difficult to read back properly. 36 | codec_options = CodecOptions(uuid_representation=JAVA_LEGACY) 37 | 38 | 39 | class UUIDFieldTestCase(FieldTestCase): 40 | id = uuid.UUID('026fab8f-975f-4965-9fbf-85ad874c60ff') 41 | field = ModelWithUUID.id 42 | 43 | def test_conversion(self): 44 | self.assertConversion(self.field, self.id, self.id) 45 | self.assertConversion(self.field, 46 | self.id, '{026fab8f-975f-4965-9fbf-85ad874c60ff}') 47 | 48 | def test_validate(self): 49 | # Error message comes from UUID.__init__. 50 | with self.assertRaises(ValidationError): 51 | self.field.validate('hello') 52 | self.field.validate('{026fab8f-975f-4965-9fbf-85ad874c60ff}') 53 | self.field.validate(self.id) 54 | 55 | def test_uuid_representation(self): 56 | ModelWithUUID(self.id).save() 57 | collection = DB.get_collection( 58 | 'model_with_uuid', 59 | codec_options=CodecOptions(uuid_representation=JAVA_LEGACY)) 60 | self.assertEqual(self.id, collection.find_one()['_id']) 61 | -------------------------------------------------------------------------------- /test/models.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm import MongoModel, fields 16 | 17 | 18 | class ParentModel(MongoModel): 19 | fname = fields.CharField("Customer first name", primary_key=True) 20 | lname = fields.CharField() 21 | phone = fields.IntegerField("Phone #", 22 | min_value=1000000, max_value=9999999) 23 | foo = 'bar' # Not counted among fields. 24 | 25 | class Meta: 26 | collection_name = 'some_collection' 27 | 28 | 29 | class UserOtherCollection(ParentModel): 30 | class Meta: 31 | collection_name = 'other_collection' 32 | 33 | 34 | class User(ParentModel): 35 | address = fields.CharField() 36 | -------------------------------------------------------------------------------- /test/test_collation.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | import unittest 4 | 5 | from pymongo.collation import Collation, CollationStrength 6 | from pymodm import fields, MongoModel 7 | 8 | from test import ODMTestCase, MONGO_VERSION 9 | 10 | 11 | class ModelForCollations(MongoModel): 12 | name = fields.CharField() 13 | 14 | class Meta: 15 | # Default collation: American English, differentiate base characters. 16 | collation = Collation('en_US', strength=CollationStrength.PRIMARY) 17 | 18 | 19 | class CollationTestCase(ODMTestCase): 20 | 21 | @classmethod 22 | @unittest.skipIf(MONGO_VERSION < (3, 4), 'Requires MongoDB >= 3.4') 23 | def setUpClass(cls): 24 | super(CollationTestCase, cls).setUpClass() 25 | 26 | def setUp(self): 27 | # Initial data. 28 | ModelForCollations._mongometa.collection.drop() 29 | ModelForCollations.objects.bulk_create([ 30 | ModelForCollations(u'Aargren'), 31 | ModelForCollations(u'Åårgren'), 32 | ]) 33 | 34 | def test_collation(self): 35 | # Use a different collation (not default) for this QuerySet. 36 | qs = ModelForCollations.objects.collation( 37 | Collation('en_US', strength=CollationStrength.TERTIARY)) 38 | self.assertEqual(1, qs.raw({'name': 'Aargren'}).count()) 39 | 40 | def test_count(self): 41 | self.assertEqual( 42 | 2, ModelForCollations.objects.raw({'name': 'Aargren'}).count()) 43 | 44 | def test_aggregate(self): 45 | self.assertEqual( 46 | [{'name': u'Aargren'}, {'name': u'Åårgren'}], 47 | list(ModelForCollations.objects.aggregate( 48 | {'$match': {'name': 'Aargren'}}, 49 | {'$project': {'name': 1, '_id': 0}} 50 | )) 51 | ) 52 | # Override with keyword argument. 53 | alternate_collation = Collation( 54 | 'en_US', strength=CollationStrength.TERTIARY) 55 | self.assertEqual( 56 | [{'name': u'Aargren'}], 57 | list(ModelForCollations.objects.aggregate( 58 | {'$match': {'name': 'Aargren'}}, 59 | {'$project': {'name': 1, '_id': 0}}, 60 | collation=alternate_collation))) 61 | 62 | def test_delete(self): 63 | self.assertEqual(2, ModelForCollations.objects.delete()) 64 | 65 | def test_update(self): 66 | self.assertEqual(2, ModelForCollations.objects.raw( 67 | {'name': 'Aargren'}).update({'$set': {'touched': 1}})) 68 | # Override with keyword argument. 69 | alternate_collation = Collation( 70 | 'en_US', strength=CollationStrength.TERTIARY) 71 | self.assertEqual( 72 | 1, ModelForCollations.objects.raw({'name': 'Aargren'}).update( 73 | {'$set': {'touched': 2}}, 74 | collation=alternate_collation)) 75 | 76 | def test_query(self): 77 | qs = ModelForCollations.objects.raw({'name': 'Aargren'}) 78 | # Iterate the QuerySet. 79 | self.assertEqual(2, sum(1 for _ in qs)) 80 | -------------------------------------------------------------------------------- /test/test_connection.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from pymodm import connection 3 | from pymodm.connection import connect, _get_connection 4 | from pymodm import MongoModel, CharField 5 | from pymongo import IndexModel, MongoClient 6 | from pymongo.monitoring import CommandListener, ServerHeartbeatListener 7 | 8 | from test import ODMTestCase 9 | 10 | 11 | class HeartbeatStartedListener(ServerHeartbeatListener): 12 | def __init__(self): 13 | self.results = [] 14 | 15 | def started(self, event): 16 | self.results.append(event) 17 | 18 | def succeeded(self, event): 19 | pass 20 | 21 | def failed(self, event): 22 | pass 23 | 24 | 25 | class WhiteListEventListener(CommandListener): 26 | def __init__(self, *commands): 27 | self.commands = set(commands) 28 | self.results = defaultdict(list) 29 | 30 | def started(self, event): 31 | if event.command_name in self.commands: 32 | self.results['started'].append(event) 33 | 34 | def succeeded(self, event): 35 | if event.command_name in self.commands: 36 | self.results['succeeded'].append(event) 37 | 38 | def failed(self, event): 39 | if event.command_name in self.commands: 40 | self.results['failed'].append(event) 41 | 42 | 43 | class MockMongoClient(object): 44 | """Intercept and record calls to MongoClient.""" 45 | def __init__(self): 46 | self.args = None 47 | self.kwargs = None 48 | 49 | def enable(self): 50 | def _mock_mongoclient(*args, **kwargs): 51 | self.args = args 52 | self.kwargs = kwargs 53 | return MongoClient(*args, **kwargs) 54 | 55 | connection.MongoClient = _mock_mongoclient 56 | 57 | def disable(self): 58 | connection.MongoClient = MongoClient 59 | 60 | def __enter__(self): 61 | self.enable() 62 | return self 63 | 64 | def __exit__(self, exc_type, exc_val, exc_tb): 65 | self.disable() 66 | 67 | 68 | class ConnectionTestCase(ODMTestCase): 69 | def test_connect_with_kwargs(self): 70 | connect('mongodb://localhost:27017/foo?maxPoolSize=42', 71 | 'foo-connection', 72 | minpoolsize=10) 73 | client = _get_connection('foo-connection').database.client 74 | self.assertEqual(42, client.max_pool_size) 75 | self.assertEqual(10, client.min_pool_size) 76 | 77 | def test_handshake(self): 78 | from pymodm.connection import DriverInfo 79 | from pymodm import version 80 | if DriverInfo is None: 81 | self.skipTest("Underlying PyMongo version does not implement the " 82 | "handshake specification.") 83 | 84 | # PyMODM should implicitly pass along DriverInfo. 85 | with MockMongoClient() as mock: 86 | connect('mongodb://localhost:27017/foo', 'foo-connection') 87 | self.assertEqual(DriverInfo('PyMODM', version), mock.kwargs['driver']) 88 | 89 | # PyMODM should not override user-provided DriverInfo. 90 | driver_info = DriverInfo('bar', 'baz') 91 | with MockMongoClient() as mock: 92 | connect('mongodb://localhost:27017/foo', 'foo-connection', 93 | driver=driver_info) 94 | self.assertEqual(driver_info, mock.kwargs['driver']) 95 | 96 | def test_connect_lazily(self): 97 | heartbeat_listener = HeartbeatStartedListener() 98 | connect('mongodb://localhost:27017/foo', 99 | 'foo-connection', 100 | connect=False, 101 | event_listeners=[heartbeat_listener]) 102 | 103 | class Article(MongoModel): 104 | title = CharField() 105 | class Meta: 106 | connection_alias = 'foo-connection' 107 | 108 | # Creating the class didn't create a connection. 109 | self.assertEqual(len(heartbeat_listener.results), 0) 110 | 111 | # The connection is created on the first query. 112 | self.assertEqual(Article.objects.count(), 0) 113 | self.assertGreaterEqual(len(heartbeat_listener.results), 1) 114 | 115 | def test_connect_lazily_with_index(self): 116 | heartbeat_listener = HeartbeatStartedListener() 117 | create_indexes_listener = WhiteListEventListener('createIndexes') 118 | connect('mongodb://localhost:27017/foo', 119 | 'foo-connection', 120 | connect=False, 121 | event_listeners=[heartbeat_listener, create_indexes_listener]) 122 | 123 | class Article(MongoModel): 124 | title = CharField() 125 | class Meta: 126 | connection_alias = 'foo-connection' 127 | indexes = [ 128 | IndexModel([('title', 1)]) 129 | ] 130 | 131 | # Creating the class didn't create a connection, or any indexes. 132 | self.assertEqual(len(heartbeat_listener.results), 0) 133 | self.assertEqual(len(create_indexes_listener.results['started']), 0) 134 | 135 | # The connection and indexes are created on the first query. 136 | self.assertEqual(Article.objects.count(), 0) 137 | self.assertGreaterEqual(len(heartbeat_listener.results), 1) 138 | self.assertGreaterEqual( 139 | len(create_indexes_listener.results['started']), 1) 140 | -------------------------------------------------------------------------------- /test/test_context_managers.py: -------------------------------------------------------------------------------- 1 | from bson.objectid import ObjectId 2 | 3 | from pymongo.errors import DuplicateKeyError 4 | from pymongo.write_concern import WriteConcern 5 | 6 | from pymodm import MongoModel, EmbeddedMongoModel, fields 7 | from pymodm.connection import connect 8 | from pymodm.context_managers import ( 9 | switch_connection, switch_collection, no_auto_dereference, 10 | collection_options) 11 | 12 | from test import ODMTestCase, MONGO_URI, CLIENT, DB 13 | 14 | 15 | class Game(MongoModel): 16 | title = fields.CharField() 17 | 18 | 19 | class Badge(EmbeddedMongoModel): 20 | name = fields.CharField() 21 | game = fields.ReferenceField(Game) 22 | 23 | 24 | class User(MongoModel): 25 | fname = fields.CharField() 26 | friend = fields.ReferenceField('test.test_context_managers.User') 27 | badges = fields.EmbeddedModelListField(Badge) 28 | 29 | 30 | class ContextManagersTestCase(ODMTestCase): 31 | 32 | @classmethod 33 | def setUpClass(cls): 34 | cls.db_name = 'alternate-db' 35 | connect(MONGO_URI + '/' + cls.db_name, 'backups') 36 | cls.db = CLIENT[cls.db_name] 37 | 38 | @classmethod 39 | def tearDownClass(cls): 40 | CLIENT.drop_database(cls.db_name) 41 | 42 | def test_switch_connection(self): 43 | with switch_connection(User, 'backups') as BackupUser: 44 | BackupUser('Bert').save() 45 | User('Ernie').save() 46 | 47 | self.assertEqual('Ernie', DB.user.find_one()['fname']) 48 | self.assertEqual('Bert', self.db.user.find_one()['fname']) 49 | 50 | def test_switch_collection(self): 51 | with switch_collection(User, 'copies') as CopiedUser: 52 | CopiedUser('Bert').save() 53 | User('Ernie').save() 54 | 55 | self.assertEqual('Ernie', DB.user.find_one()['fname']) 56 | self.assertEqual('Bert', DB.copies.find_one()['fname']) 57 | 58 | def test_no_auto_dereference(self): 59 | game = Game('Civilization').save() 60 | badge = Badge(name='World Domination', game=game) 61 | ernie = User(fname='Ernie').save() 62 | bert = User(fname='Bert', badges=[badge], friend=ernie).save() 63 | 64 | bert.refresh_from_db() 65 | 66 | with no_auto_dereference(User): 67 | self.assertIsInstance(bert.friend, ObjectId) 68 | self.assertIsInstance(bert.badges[0].game, ObjectId) 69 | self.assertIsInstance(bert.friend, User) 70 | self.assertIsInstance(bert.badges[0].game, Game) 71 | 72 | def test_collection_options(self): 73 | user_id = ObjectId() 74 | User(_id=user_id).save() 75 | wc = WriteConcern(w=0) 76 | with collection_options(User, write_concern=wc): 77 | User(_id=user_id).save(force_insert=True) 78 | with self.assertRaises(DuplicateKeyError): 79 | User(_id=user_id).save(force_insert=True) 80 | -------------------------------------------------------------------------------- /test/test_delete_rules.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from test import ODMTestCase 16 | 17 | from pymodm.base import MongoModel 18 | from pymodm.errors import OperationError 19 | from pymodm import fields 20 | 21 | 22 | class ReferencedModel(MongoModel): 23 | pass 24 | 25 | 26 | class ReferencingModel(MongoModel): 27 | ref = fields.ReferenceField(ReferencedModel) 28 | 29 | 30 | # Model classes that both reference each other. 31 | class A(MongoModel): 32 | ref = fields.ReferenceField('B') 33 | 34 | 35 | class B(MongoModel): 36 | ref = fields.ReferenceField(A) 37 | 38 | 39 | class DeleteRulesTestCase(ODMTestCase): 40 | 41 | def tearDown(self): 42 | super(DeleteRulesTestCase, self).tearDown() 43 | # Remove all delete rules. 44 | for model_class in (ReferencedModel, ReferencingModel, A, B): 45 | model_class._mongometa.delete_rules.clear() 46 | 47 | def test_nullify(self): 48 | ReferencedModel.register_delete_rule( 49 | ReferencingModel, 'ref', fields.ReferenceField.NULLIFY) 50 | reffed = ReferencedModel().save() 51 | reffing = ReferencingModel(reffed).save() 52 | reffed.delete() 53 | reffing.refresh_from_db() 54 | self.assertIsNone(reffing.ref) 55 | 56 | # Test the on_delete attribute for one rule. 57 | def test_nullify_on_delete_attribute(self): 58 | class ReferencingModelWithAttribute(MongoModel): 59 | ref = fields.ReferenceField( 60 | ReferencedModel, 61 | on_delete=fields.ReferenceField.NULLIFY) 62 | reffed = ReferencedModel().save() 63 | reffing = ReferencingModelWithAttribute(reffed).save() 64 | reffed.delete() 65 | reffing.refresh_from_db() 66 | self.assertIsNone(reffing.ref) 67 | 68 | def test_bidirectional_on_delete_attribute(self): 69 | msg = 'Cannot specify on_delete without providing a Model class' 70 | with self.assertRaisesRegex(ValueError, msg): 71 | class ReferencingModelWithAttribute(MongoModel): 72 | ref = fields.ReferenceField( 73 | # Cannot specify class a string. 74 | 'ReferencedModel', 75 | on_delete=fields.ReferenceField.NULLIFY) 76 | 77 | def test_cascade(self): 78 | ReferencedModel.register_delete_rule( 79 | ReferencingModel, 'ref', fields.ReferenceField.CASCADE) 80 | reffed = ReferencedModel().save() 81 | ReferencingModel(reffed).save() 82 | reffed.delete() 83 | self.assertEqual(0, ReferencingModel.objects.count()) 84 | 85 | def test_infinite_cascade(self): 86 | A.register_delete_rule(B, 'ref', fields.ReferenceField.CASCADE) 87 | B.register_delete_rule(A, 'ref', fields.ReferenceField.CASCADE) 88 | a = A().save() 89 | b = B().save() 90 | a.ref = b 91 | b.ref = a 92 | a.save() 93 | b.save() 94 | # No SystemError due to infinite recursion. 95 | a.delete() 96 | self.assertFalse(A.objects.count()) 97 | self.assertFalse(B.objects.count()) 98 | 99 | def test_deny(self): 100 | ReferencedModel.register_delete_rule( 101 | ReferencingModel, 'ref', fields.ReferenceField.DENY) 102 | reffed = ReferencedModel().save() 103 | ReferencingModel(reffed).save() 104 | with self.assertRaises(OperationError): 105 | ReferencedModel.objects.delete() 106 | with self.assertRaises(OperationError): 107 | reffed.delete() 108 | 109 | def _pull_test(self, referencing_model): 110 | refs = [ReferencedModel().save() for i in range(3)] 111 | multi_reffing = referencing_model(refs).save() 112 | 113 | refs[0].delete() 114 | multi_reffing.refresh_from_db() 115 | self.assertEqual(refs[1:], multi_reffing.refs) 116 | 117 | def test_pull(self): 118 | class MultiReferencingModel(MongoModel): 119 | refs = fields.ListField(fields.ReferenceField(ReferencedModel)) 120 | ReferencedModel.register_delete_rule( 121 | MultiReferencingModel, 'refs', fields.ReferenceField.PULL) 122 | 123 | self._pull_test(MultiReferencingModel) 124 | 125 | def test_pull_list_field_with_on_delete(self): 126 | class MultiReferencingModel(MongoModel): 127 | refs = fields.ListField(fields.ReferenceField( 128 | ReferencedModel, on_delete=fields.ReferenceField.PULL)) 129 | 130 | self._pull_test(MultiReferencingModel) 131 | 132 | def test_bidirectional(self): 133 | A.register_delete_rule(B, 'ref', fields.ReferenceField.DENY) 134 | B.register_delete_rule(A, 'ref', fields.ReferenceField.NULLIFY) 135 | 136 | a = A().save() 137 | b = B(a).save() 138 | a.ref = b 139 | a.save() 140 | 141 | with self.assertRaises(OperationError): 142 | a.delete() 143 | b.delete() 144 | a.refresh_from_db() 145 | self.assertIsNone(a.ref) 146 | 147 | def test_bidirectional_order(self): 148 | A.register_delete_rule(B, 'ref', fields.ReferenceField.DENY) 149 | B.register_delete_rule(A, 'ref', fields.ReferenceField.CASCADE) 150 | 151 | a = A().save() 152 | b = B(a).save() 153 | a.ref = b 154 | a.save() 155 | 156 | # Cannot delete A while referenced by a B. 157 | with self.assertRaises(OperationError): 158 | a.delete() 159 | # OK to delete a B, and doing so deletes all referencing A objects. 160 | b.delete() 161 | self.assertFalse(A.objects.count()) 162 | self.assertFalse(B.objects.count()) 163 | -------------------------------------------------------------------------------- /test/test_errors.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unittest 3 | 4 | from pymodm.base import MongoModel 5 | from pymodm.errors import ValidationError 6 | from pymodm.fields import CharField, IntegerField 7 | 8 | 9 | def must_be_all_caps(value): 10 | if re.search(r'[^A-Z]', value): 11 | raise ValidationError('field must be all uppercase.') 12 | 13 | 14 | def must_be_three_letters(value): 15 | if len(value) != 3: 16 | raise ValidationError('field must be exactly three characters.') 17 | 18 | 19 | class Document(MongoModel): 20 | region_code = CharField( 21 | validators=[must_be_all_caps, must_be_three_letters]) 22 | number = IntegerField(min_value=0, max_value=100) 23 | title = CharField(required=True) 24 | 25 | 26 | class ErrorTestCase(unittest.TestCase): 27 | 28 | def test_validation_error(self): 29 | messed_up_document = Document(region_code='asdf', number=12345) 30 | 31 | with self.assertRaises(ValidationError) as cm: 32 | messed_up_document.full_clean() 33 | 34 | message = cm.exception.message 35 | self.assertIsInstance(message, dict) 36 | self.assertIn('region_code', message) 37 | self.assertIn('number', message) 38 | 39 | self.assertIsInstance(message['region_code'], list) 40 | self.assertIn('field must be all uppercase.', 41 | message['region_code']) 42 | self.assertIn('field must be exactly three characters.', 43 | message['region_code']) 44 | 45 | self.assertIsInstance(message['number'], list) 46 | self.assertIn('12345 is greater than maximum value of 100.', 47 | message['number']) 48 | 49 | self.assertEqual(['field is required.'], message['title']) 50 | -------------------------------------------------------------------------------- /test/test_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm import fields, MongoModel 16 | from pymodm.manager import Manager 17 | from pymodm.queryset import QuerySet 18 | 19 | from test import ODMTestCase 20 | 21 | 22 | class CustomQuerySet(QuerySet): 23 | def authors(self): 24 | """Return a QuerySet over documents representing authors.""" 25 | return self.raw({'role': 'A'}) 26 | 27 | def editors(self): 28 | """Return a QuerySet over documents representing editors.""" 29 | return self.raw({'role': 'E'}) 30 | 31 | 32 | CustomManager = Manager.from_queryset(CustomQuerySet) 33 | 34 | 35 | class BookCredit(MongoModel): 36 | first_name = fields.CharField() 37 | last_name = fields.CharField() 38 | role = fields.CharField(choices=[('A', 'author'), ('E', 'editor')]) 39 | contributors = CustomManager() 40 | more_contributors = CustomManager() 41 | 42 | 43 | class ManagerTestCase(ODMTestCase): 44 | 45 | def test_default_manager(self): 46 | # No auto-created Manager, since we defined our own. 47 | self.assertFalse(hasattr(BookCredit, 'objects')) 48 | # Check that our custom Manager was installed. 49 | self.assertIsInstance(BookCredit.contributors, CustomManager) 50 | # Contributors should be the default manager, not more_contributors. 51 | self.assertIs(BookCredit.contributors, 52 | BookCredit._mongometa.default_manager) 53 | 54 | def test_get_queryset(self): 55 | self.assertIsInstance( 56 | BookCredit.contributors.get_queryset(), CustomQuerySet) 57 | 58 | def test_access(self): 59 | credit = BookCredit(first_name='Frank', last_name='Herbert', role='A') 60 | msg = "Manager isn't accessible via BookCredit instances" 61 | with self.assertRaisesRegex(AttributeError, msg): 62 | credit.contributors 63 | 64 | def test_wrappers(self): 65 | manager = BookCredit.contributors 66 | self.assertTrue(hasattr(manager, 'editors')) 67 | self.assertTrue(hasattr(manager, 'authors')) 68 | self.assertEqual( 69 | CustomQuerySet.editors.__doc__, manager.editors.__doc__) 70 | self.assertEqual( 71 | CustomQuerySet.authors.__doc__, manager.authors.__doc__) 72 | -------------------------------------------------------------------------------- /test/test_model_basic.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from test import ODMTestCase, DB 16 | from test.models import User 17 | 18 | from pymodm import MongoModel, CharField, IntegerField 19 | from pymodm.errors import InvalidModel, ValidationError 20 | 21 | 22 | class BasicModelTestCase(ODMTestCase): 23 | 24 | def test_instantiation(self): 25 | msg = 'Got 5 arguments for only 4 fields' 26 | with self.assertRaisesRegex(ValueError, msg): 27 | User('Gary', 1234567, '12 Apple Street', 42, 'cucumber') 28 | msg = 'Unrecognized field name' 29 | with self.assertRaisesRegex(ValueError, msg): 30 | User(last_name='Gygax') 31 | msg = 'name specified more than once in constructor for User' 32 | with self.assertRaisesRegex(ValueError, msg): 33 | User('Gary', fname='Gygax') 34 | 35 | def test_save(self): 36 | User('Gary').save() 37 | self.assertEqual('Gary', DB.some_collection.find_one()['_id']) 38 | 39 | def test_delete(self): 40 | gary = User('Gary').save() 41 | self.assertTrue(DB.some_collection.find_one()) 42 | gary.delete() 43 | self.assertIsNone(DB.some_collection.find_one()) 44 | 45 | def test_refresh_from_db(self): 46 | gary = User('Gary').save() 47 | DB.some_collection.update_one( 48 | {'_id': 'Gary'}, 49 | {'$set': {'phone': 1234567}}) 50 | gary.refresh_from_db() 51 | self.assertEqual(1234567, gary.phone) 52 | 53 | def test_blank_field(self): 54 | class ModelWithBlankField(MongoModel): 55 | field = CharField(blank=True) 56 | 57 | instance = ModelWithBlankField(None) 58 | # No exception. 59 | instance.full_clean() 60 | self.assertIsNone(instance.field) 61 | 62 | def test_same_mongo_name(self): 63 | msg = '.* cannot have the same mongo_name of existing field .*' 64 | with self.assertRaisesRegex(InvalidModel, msg): 65 | class SameMongoName(MongoModel): 66 | field = CharField() 67 | new_field = CharField(mongo_name='field') 68 | 69 | class Parent(MongoModel): 70 | field = CharField(mongo_name='child_field') 71 | 72 | with self.assertRaisesRegex(InvalidModel, msg): 73 | class SameMongoNameAsParent(Parent): 74 | child_field = CharField() 75 | 76 | def test_save_pk_field_required(self): 77 | self.assertTrue(User.fname.required) 78 | 79 | # This should raise ValidationError, since we explicitly defined 80 | # `fname` as the primary_key, but it hasn't been given a value. 81 | # `fname` should be required: 82 | # ValidationError: {'fname': ['field is required.']} 83 | with self.assertRaises(ValidationError) as cm: 84 | User().save() 85 | 86 | message = cm.exception.message 87 | self.assertIsInstance(message, dict) 88 | self.assertIn('fname', message) 89 | self.assertIsInstance(message['fname'], list) 90 | self.assertIn('field is required.', message['fname']) 91 | 92 | def test_remove_field_from_model(self): 93 | class Document(MongoModel): 94 | name = CharField() 95 | age = IntegerField() 96 | 97 | Document('Test', 42).save() 98 | 99 | # Redefine Document. 100 | class Document(MongoModel): 101 | name = CharField() 102 | 103 | with self.assertRaisesRegex(ValueError, 'Unrecognized field .*age'): 104 | retrieved = Document.objects.raw({'name': 'Test'}).first() 105 | 106 | # Redefine document, this time ignoring unknown fields. 107 | class Document(MongoModel): 108 | name = CharField() 109 | 110 | class Meta: 111 | ignore_unknown_fields = True 112 | 113 | # No error. 114 | retrieved = Document.objects.raw({'name': 'Test'}).first() 115 | 116 | self.assertEqual('Test', retrieved.name) 117 | self.assertRaises(AttributeError, getattr, retrieved, 'age') 118 | 119 | retrieved.save() 120 | self.assertNotIn('age', DB.document.find_one()) 121 | -------------------------------------------------------------------------------- /test/test_model_inheritance.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from bson import SON 16 | 17 | from pymongo.operations import IndexModel 18 | 19 | from pymodm import fields, MongoModel 20 | from pymodm.errors import InvalidModel 21 | 22 | from test import ODMTestCase, DB 23 | from test.models import ParentModel, User 24 | 25 | 26 | class AnotherUser(ParentModel): 27 | class Meta: 28 | collection_name = 'bulbasaur' 29 | 30 | 31 | class MultipleInheritanceModel(User, AnotherUser): 32 | phone = fields.CharField() # Shadow phone field from ParentModel. 33 | 34 | 35 | class FinalModel(MongoModel): 36 | def _set_attributes(self, dict): 37 | """ 38 | To test proper functioning of super in Model classes with Python 3.8+ - see https://bugs.python.org/issue23722 39 | """ 40 | return super(FinalModel, self)._set_attributes(dict) 41 | 42 | class Meta: 43 | final = True 44 | 45 | 46 | class ModelInheritanceTest(ODMTestCase): 47 | 48 | def test_simple_inheritance(self): 49 | child = User(fname='Gary', phone=1234567) 50 | self.assertIsInstance(child, User) 51 | self.assertIsInstance(child, ParentModel) 52 | # 'name' is primary key from parent. 53 | self.assertEqualsModel( 54 | SON([('_id', 'Gary'), ('phone', 1234567)]), child) 55 | child.save() 56 | # We use the correct collection name. 57 | result = User.objects.first() 58 | self.assertEqual(child, result) 59 | 60 | def test_no_field_shadow(self): 61 | self.assertIsInstance( 62 | MultipleInheritanceModel.phone, fields.CharField) 63 | 64 | def test_multiple_inheritance(self): 65 | mim = MultipleInheritanceModel( 66 | fname='Ash', phone='123', address='24 Pallet Town Ave.') 67 | self.assertIsInstance(mim, User) 68 | self.assertIsInstance(mim, AnotherUser) 69 | self.assertEqualsModel( 70 | SON([('_id', 'Ash'), ('address', '24 Pallet Town Ave.'), 71 | ('phone', '123')]), 72 | mim) 73 | mim.save() 74 | result = MultipleInheritanceModel.objects.first() 75 | self.assertEqual(mim, result) 76 | # Use the correct collection name. 77 | self.assertEqual( 78 | 'bulbasaur', MultipleInheritanceModel._mongometa.collection_name) 79 | self.assertEqual( 80 | MultipleInheritanceModel.from_document(DB.bulbasaur.find_one()), 81 | result) 82 | 83 | def test_inheritance_collocation(self): 84 | parent = ParentModel('Oak', phone=9876432).save() 85 | user = User('Blane', phone=3456789, address='72 Cinnabar').save() 86 | results = list(ParentModel.objects.order_by([('phone', 1)])) 87 | self.assertEqual([user, parent], results) 88 | self.assertEqual([user], list(User.objects.all())) 89 | 90 | def test_final(self): 91 | msg = 'Cannot extend class .* because it has been declared final' 92 | with self.assertRaisesRegex(InvalidModel, msg): 93 | class ExtendsFinalModel(FinalModel): 94 | pass 95 | 96 | def test_final_metadata_storage(self): 97 | FinalModel().save() 98 | self.assertNotIn('_cls', DB.final_model.find_one()) 99 | 100 | def test_indexes(self): 101 | class ModelWithIndexes(MongoModel): 102 | product_id = fields.UUIDField() 103 | name = fields.CharField() 104 | 105 | class Meta: 106 | indexes = [ 107 | IndexModel([('product_id', 1), ('name', 1)], unique=True) 108 | ] 109 | 110 | class ChildModel(ModelWithIndexes): 111 | pass 112 | 113 | # Creating a model class does not create the indexes. 114 | index_info = DB.model_with_indexes.index_information() 115 | self.assertNotIn('product_id_1_name_1', index_info) 116 | 117 | # Indexes are only created once the Model's collection is accessed. 118 | ModelWithIndexes._mongometa.collection 119 | index_info = DB.model_with_indexes.index_information() 120 | self.assertTrue(index_info['product_id_1_name_1']['unique']) 121 | 122 | # Creating indexes on the child should not error. 123 | ChildModel._mongometa.collection 124 | 125 | # Index info should not have changed. 126 | final_index_info = DB.model_with_indexes.index_information() 127 | self.assertEqual(index_info, final_index_info) 128 | -------------------------------------------------------------------------------- /test/test_model_lazy_decoder.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-present MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | 16 | import copy 17 | import unittest 18 | 19 | from itertools import chain 20 | 21 | from bson.decimal128 import Decimal128 22 | 23 | from pymodm.base.models import _LazyDecoder 24 | 25 | 26 | PYTHON_DATA = { 27 | "python": "helloworld" 28 | } 29 | 30 | 31 | MONGO_DATA = { 32 | "mongo": Decimal128("1.23") 33 | } 34 | 35 | 36 | class TestLazyDecoder(unittest.TestCase): 37 | def setUp(self): 38 | self.ld = _LazyDecoder() 39 | self.populate_lazy_decoder(self.ld) 40 | 41 | def populate_lazy_decoder(self, ld): 42 | for key in PYTHON_DATA: 43 | ld.set_python_value(key, PYTHON_DATA[key]) 44 | for key in MONGO_DATA: 45 | ld.set_mongo_value(key, MONGO_DATA[key]) 46 | 47 | def test_clear(self): 48 | self.ld.clear() 49 | 50 | self.assertEqual(self.ld, _LazyDecoder()) 51 | self.assertEqual(sum(1 for _ in self.ld), 0) 52 | 53 | def test_iter(self): 54 | all_members = set(iter(self.ld)) 55 | expected_members = set( 56 | iter(chain(self.ld._mongo_data, self.ld._python_data)) 57 | ) 58 | 59 | self.assertGreater(len(expected_members), 0) 60 | self.assertEqual(all_members, expected_members) 61 | for item in self.ld: 62 | self.assertIn(item, expected_members) 63 | 64 | def test_eq(self): 65 | ld2 = _LazyDecoder() 66 | self.populate_lazy_decoder(ld2) 67 | self.assertEqual(self.ld, ld2) 68 | 69 | self.ld.set_python_value('python2', 'hellonewworld') 70 | self.assertNotEqual(self.ld, ld2) 71 | 72 | def test_remove(self): 73 | def _generate_keyset(*iterables): 74 | return set([k for k in iter(chain(*iterables))]) 75 | 76 | all_keys = _generate_keyset(MONGO_DATA, PYTHON_DATA) 77 | expected_keyset = copy.copy(all_keys) 78 | for key in all_keys: 79 | self.ld.remove(key) 80 | expected_keyset.discard(key) 81 | data_keyset = _generate_keyset( 82 | self.ld._python_data, self.ld._mongo_data) 83 | iter_keyset = _generate_keyset(self.ld) 84 | 85 | # For each check the data, iter, and contains. 86 | self.assertEqual(data_keyset, expected_keyset) 87 | self.assertEqual(iter_keyset, expected_keyset) 88 | for candidate_key in expected_keyset: 89 | self.assertTrue(candidate_key in self.ld) 90 | 91 | def test_get_mongo_data_as_mongo_value(self): 92 | ldcopy = copy.deepcopy(self.ld) 93 | key = next(iter(self.ld._mongo_data)) 94 | def _to_mongo(value): 95 | _to_mongo.call_count += 1 96 | return str.upper(value) 97 | _to_mongo.call_count = 0 98 | value = self.ld.get_mongo_value(key, _to_mongo) 99 | 100 | self.assertEqual(_to_mongo.call_count, 0) 101 | self.assertEqual(value, self.ld._mongo_data[key]) 102 | self.assertEqual(self.ld, ldcopy) 103 | 104 | def test_get_python_data_as_mongo_value(self): 105 | ldcopy = copy.deepcopy(self.ld) 106 | key = next(iter(self.ld._python_data)) 107 | def _to_mongo(value): 108 | _to_mongo.call_count +=1 109 | return str.upper(value) 110 | _to_mongo.call_count = 0 111 | value = self.ld.get_mongo_value(key, _to_mongo) 112 | 113 | self.assertEqual(_to_mongo.call_count, 1) 114 | self.assertEqual(value, _to_mongo(self.ld._python_data[key])) 115 | self.assertEqual(self.ld, ldcopy) 116 | 117 | def test_get_mongo_data_as_python_value(self): 118 | ldcopy = copy.deepcopy(self.ld) 119 | key = next(iter(self.ld._mongo_data)) 120 | def _to_python(value): 121 | _to_python.call_count += 1 122 | return value.to_decimal() 123 | _to_python.call_count = 0 124 | mongo_value = self.ld.get_mongo_value(key, lambda x: None) 125 | value = self.ld.get_python_value(key, _to_python) 126 | 127 | # We expect the data to be converted to python and cached. 128 | self.assertEqual(_to_python.call_count, 1) 129 | self.assertEqual(value, _to_python(mongo_value)) 130 | self.assertIn(key, self.ld._python_data) 131 | self.assertNotIn(key, self.ld._mongo_data) 132 | self.assertNotEqual(self.ld, ldcopy) 133 | self.assertEqual(set(iter(self.ld)), set(iter(ldcopy))) 134 | 135 | # Second access should not call conversion again. 136 | _to_python.call_count = 0 137 | _ = self.ld.get_python_value(key, _to_python) 138 | self.assertEqual(_to_python.call_count, 0) 139 | 140 | def test_get_python_data_as_python_value(self): 141 | ldcopy = copy.deepcopy(self.ld) 142 | key = next(iter(self.ld._python_data)) 143 | def _to_python(value): 144 | _to_python.call_count += 1 145 | return str.upper(value) 146 | _to_python.call_count = 0 147 | value = self.ld.get_python_value(key, _to_python) 148 | 149 | self.assertEqual(_to_python.call_count, 0) 150 | self.assertEqual(value, self.ld._python_data[key]) 151 | self.assertEqual(self.ld, ldcopy) 152 | -------------------------------------------------------------------------------- /test/test_options.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from pymodm.base.options import MongoOptions 16 | from pymodm.connection import DEFAULT_CONNECTION_ALIAS 17 | from pymodm import fields 18 | 19 | from test import ODMTestCase 20 | from test.models import ParentModel, UserOtherCollection, User 21 | 22 | 23 | class Renamed(User): 24 | # Override existing field and change mongo_name 25 | address = fields.CharField(mongo_name='mongo_address') 26 | 27 | 28 | class MongoOptionsTestCase(ODMTestCase): 29 | 30 | def test_metadata(self): 31 | # Defined on Model. 32 | self.assertEqual( 33 | 'other_collection', 34 | UserOtherCollection._mongometa.collection_name) 35 | # Not inherited from parent. 36 | self.assertEqual( 37 | DEFAULT_CONNECTION_ALIAS, 38 | UserOtherCollection._mongometa.connection_alias) 39 | # Inherited from parent. 40 | self.assertEqual( 41 | 'some_collection', 42 | User._mongometa.collection_name) 43 | 44 | def test_collection(self): 45 | self.assertEqual( 46 | 'some_collection', 47 | ParentModel._mongometa.collection.name) 48 | self.assertEqual( 49 | 'other_collection', 50 | UserOtherCollection._mongometa.collection.name) 51 | 52 | def test_get_fields(self): 53 | # Fields are returned in order. 54 | self.assertEqual( 55 | ['_id', 'lname', 'phone', 'address'], 56 | [field.mongo_name for field in User._mongometa.get_fields()]) 57 | 58 | def test_get_field(self): 59 | self.assertIs( 60 | User.address, User._mongometa.get_field('address')) 61 | self.assertIs( 62 | Renamed.address, Renamed._mongometa.get_field('mongo_address')) 63 | self.assertIsNone(Renamed._mongometa.get_field('address')) 64 | 65 | def test_get_field_from_attname(self): 66 | self.assertIs( 67 | User.address, User._mongometa.get_field_from_attname('address')) 68 | self.assertIs( 69 | Renamed.address, 70 | Renamed._mongometa.get_field_from_attname('address')) 71 | self.assertIsNone( 72 | Renamed._mongometa.get_field_from_attname('mongo_address')) 73 | 74 | def test_add_field(self): 75 | options = MongoOptions() 76 | # Add new Fields. 77 | options.add_field(fields.CharField(mongo_name='fname')) 78 | options.add_field(fields.UUIDField(mongo_name='id')) 79 | self.assertEqual( 80 | ['fname', 'id'], 81 | [field.mongo_name for field in options.get_fields()]) 82 | # Replace a Field. 83 | options.add_field(fields.ObjectIdField(mongo_name='id')) 84 | self.assertIsInstance(options.get_fields()[-1], fields.ObjectIdField) 85 | # Replace a field with a different mongo_name, but same attname. 86 | new_field = fields.ObjectIdField(mongo_name='newid') 87 | new_field.attname = 'id' 88 | options.add_field(new_field) 89 | self.assertEqual(len(options.get_fields()), 2) 90 | -------------------------------------------------------------------------------- /test/test_related_fields.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 MongoDB, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Test Embedded and Referenced documents.""" 16 | import warnings 17 | 18 | from bson.objectid import ObjectId 19 | 20 | from pymodm.base import MongoModel, EmbeddedMongoModel 21 | from pymodm.context_managers import no_auto_dereference 22 | from pymodm.errors import ValidationError 23 | from pymodm import fields 24 | 25 | from test import ODMTestCase, DB 26 | 27 | 28 | class Contributor(MongoModel): 29 | name = fields.CharField() 30 | thumbnail = fields.EmbeddedModelField('Image') 31 | 32 | 33 | class Image(EmbeddedMongoModel): 34 | image_url = fields.CharField(required=True) 35 | alt_text = fields.CharField() 36 | photographer = fields.ReferenceField(Contributor) 37 | 38 | 39 | class Post(MongoModel): 40 | body = fields.CharField() 41 | images = fields.EmbeddedModelListField(Image) 42 | 43 | 44 | class Comment(MongoModel): 45 | body = fields.CharField() 46 | post = fields.ReferenceField(Post) 47 | 48 | 49 | class RelatedFieldsTestCase(ODMTestCase): 50 | 51 | def test_basic_reference(self): 52 | post = Post(body='This is a post.') 53 | comment = Comment(body='Love your post!', post=post) 54 | post.save() 55 | self.assertTrue(post._id) 56 | 57 | comment.save() 58 | self.assertEqual(post, Comment.objects.first().post) 59 | 60 | def test_assign_id_to_reference_field(self): 61 | # No ValidationError raised. 62 | Comment(post="58b477046e32ab215dca2b57").full_clean() 63 | 64 | def test_validate_embedded_model(self): 65 | with self.assertRaisesRegex(ValidationError, 'field is required'): 66 | # Image has all fields left blank, which isn't allowed. 67 | Contributor(name='Mr. Contributor', thumbnail=Image()).save() 68 | # Test with a dict. 69 | with self.assertRaisesRegex(ValidationError, 'field is required'): 70 | Contributor(name='Mr. Contributor', thumbnail={ 71 | 'alt_text': 'a picture of nothing, since there is no image_url.' 72 | }).save() 73 | 74 | def test_validate_embedded_model_list(self): 75 | with self.assertRaisesRegex(ValidationError, 'field is required'): 76 | Post(images=[Image(alt_text='Vast, empty space.')]).save() 77 | with self.assertRaisesRegex(ValidationError, 'field is required'): 78 | Post(images=[{'alt_text': 'Vast, empty space.'}]).save() 79 | 80 | def test_reference_errors(self): 81 | post = Post(body='This is a post.') 82 | comment = Comment(body='Love your post!', post=post) 83 | 84 | # post has not yet been saved to the database. 85 | with self.assertRaises(ValidationError) as cm: 86 | comment.full_clean() 87 | message = cm.exception.message 88 | self.assertIn('post', message) 89 | self.assertEqual( 90 | ['Referenced Models must be saved to the database first.'], 91 | message['post']) 92 | 93 | # Cannot save document when reference is unresolved. 94 | with self.assertRaises(ValidationError) as cm: 95 | comment.save() 96 | self.assertIn('post', message) 97 | self.assertEqual( 98 | ['Referenced Models must be saved to the database first.'], 99 | message['post']) 100 | 101 | def test_embedded_model(self): 102 | contr = Contributor(name='Shep') 103 | # embedded field is not required. 104 | contr.full_clean() 105 | contr.save() 106 | 107 | # Attach an image. 108 | thumb = Image(image_url='/images/shep.png', alt_text="It's Shep.") 109 | contr.thumbnail = thumb 110 | contr.save() 111 | 112 | self.assertEqual(thumb, next(Contributor.objects.all()).thumbnail) 113 | 114 | def test_embedded_model_list(self): 115 | images = [ 116 | Image(image_url='/images/kittens.png', 117 | alt_text='some kittens'), 118 | Image(image_url='/images/blobfish.png', 119 | alt_text='some kittens fighting a blobfish.') 120 | ] 121 | post = Post(body='Look at my fantastic photography.', 122 | images=images) 123 | 124 | # Images get saved when the parent object is saved. 125 | post.save() 126 | 127 | # Embedded documents are converted to their Model type when retrieved. 128 | retrieved_posts = Post.objects.all() 129 | self.assertEqual(images, next(retrieved_posts).images) 130 | 131 | def test_refresh_from_db(self): 132 | post = Post(body='This is a post.') 133 | comment = Comment(body='This is a comment on the post.', 134 | post=post) 135 | post.save() 136 | comment.save() 137 | 138 | comment.refresh_from_db() 139 | with no_auto_dereference(Comment): 140 | self.assertIsInstance(comment.post, ObjectId) 141 | 142 | # Use PyMongo to update the comment, then update the Comment instance's 143 | # view of itself. 144 | 145 | DB.comment.update_one( 146 | {'_id': comment.pk}, {'$set': {'body': 'Edited comment.'}}) 147 | # Set the comment's "post" to something else. 148 | other_post = Post(body='This is a different post.') 149 | comment.post = other_post 150 | comment.refresh_from_db(fields=['body']) 151 | self.assertEqual('Edited comment.', comment.body) 152 | # "post" field is gone, since it wasn't part of the projection. 153 | self.assertIsNone(comment.post) 154 | 155 | def test_circular_reference(self): 156 | class ReferenceA(MongoModel): 157 | ref = fields.ReferenceField('ReferenceB') 158 | 159 | class ReferenceB(MongoModel): 160 | ref = fields.ReferenceField(ReferenceA) 161 | 162 | a = ReferenceA().save() 163 | b = ReferenceB().save() 164 | a.ref = b 165 | b.ref = a 166 | a.save() 167 | b.save() 168 | 169 | self.assertEqual(a, ReferenceA.objects.first()) 170 | with no_auto_dereference(ReferenceA): 171 | self.assertEqual(b.pk, ReferenceA.objects.first().ref) 172 | self.assertEqual(b, ReferenceA.objects.select_related().first().ref) 173 | 174 | def test_cascade_save(self): 175 | photographer = Contributor('Curly').save() 176 | image = Image('kitten.png', 'kitten', photographer) 177 | post = Post('This is a post.', [image]) 178 | # Photographer has already been saved to the database. Let's change it. 179 | photographer_thumbnail = Image('curly.png', "It's Curly.") 180 | photographer.thumbnail = photographer_thumbnail 181 | post.body += "edit: I'm a real author because I have a thumbnail now." 182 | # {'body': 'This is a post', 'images': [{ 183 | # 'image_url': 'stew.png', 'photographer': { 184 | # 'name': 'Curly', 'thumbnail': { 185 | # 'image_url': 'curly.png', 'alt_text': "It's Curly."} 186 | # }] 187 | # } 188 | post.save(cascade=True) 189 | post.refresh_from_db() 190 | self.assertEqual( 191 | post.images[0].photographer.thumbnail, photographer_thumbnail) 192 | 193 | def test_coerce_reference_type(self): 194 | post = Post('this is a post').save() 195 | post_id = str(post.pk) 196 | comment = Comment(body='this is a comment', post=post_id).save() 197 | comment.refresh_from_db() 198 | self.assertEqual('this is a post', comment.post.body) 199 | 200 | def test_validate_embedded_model_definition(self): 201 | class Review(MongoModel): 202 | text = fields.CharField() 203 | 204 | msg = 'model must be a EmbeddedMongoModel' 205 | with self.assertRaisesRegex(ValueError, msg): 206 | class Blog(MongoModel): 207 | name = fields.CharField() 208 | reviews = fields.EmbeddedModelField(Review) 209 | 210 | with self.assertRaisesRegex(ValueError, msg): 211 | class Blog(MongoModel): 212 | name = fields.CharField() 213 | reviews = fields.EmbeddedModelListField(Review) 214 | 215 | def test_validate_embedded_document_field_warning(self): 216 | with warnings.catch_warnings(): 217 | warnings.simplefilter("error", DeprecationWarning) 218 | class Review(EmbeddedMongoModel): 219 | text = fields.CharField() 220 | 221 | with self.assertRaisesRegex(DeprecationWarning, "deprecated"): 222 | class Blog(MongoModel): 223 | name = fields.CharField() 224 | reviews = fields.EmbeddedDocumentField(Review) 225 | 226 | with self.assertRaisesRegex(DeprecationWarning, "deprecated"): 227 | class Blog(MongoModel): 228 | name = fields.CharField() 229 | reviews = fields.EmbeddedDocumentListField(Review) 230 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, 4 | # "pip install tox" and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py27, py33, py34, py35, py36, py37, py38, pypy, pypy3 8 | skip_missing_interpreters = True 9 | 10 | [testenv] 11 | deps = pillow 12 | commands = 13 | {envpython} setup.py test 14 | --------------------------------------------------------------------------------