aiomongodel 0.2.2
ODM to use with asynchronous MongoDB Motor driver.
License: MIT

===========
aiomongodel
===========

.. image:: https://travis-ci.org/ilex/aiomongodel.svg?branch=master
    :target: https://travis-ci.org/ilex/aiomongodel

.. image:: https://readthedocs.org/projects/aiomongodel/badge/?version=latest
    :target: http://aiomongodel.readthedocs.io/en/latest/?badge=latest
    :alt: Documentation Status

An asynchronous ODM similar to `PyMODM`_ on top of `Motor`_ an asynchronous 
Python `MongoDB`_ driver. Works on ``Python 3.5`` and up. Some features
such as asynchronous comprehensions require at least ``Python 3.6``. ``aiomongodel``
can be used with `asyncio`_ as well as with `Tornado`_.

Usage of ``session`` requires at least MongoDB version 4.0.

.. _PyMODM: http://pymodm.readthedocs.io/en/stable
.. _Motor: https://pypi.python.org/pypi/motor
.. _MongoDB: https://www.mongodb.com/
.. _asyncio: https://docs.python.org/3/library/asyncio.html
.. _Tornado: https://pypi.python.org/pypi/tornado

Install
=======

Install `aiomongodel` using `pip`::

    pip install aiomongodel

Documentation
=============

Read the `docs`_.

.. _docs: http://aiomongodel.readthedocs.io/

Getting Start
=============

Modeling
--------

To create a model just create a new model class, inherit it from 
``aiomongodel.Document`` class, list all the model fields and place 
a ``Meta`` class with model meta options. To create a subdocument, create
a class with fields and inherit it from ``aiomongodel.EmbeddedDocument``.

.. code-block:: python

    # models.py

    from datetime import datetime

    from pymongo import IndexModel, DESCENDING 

    from aiomongodel import Document, EmbeddedDocument
    from aiomongodel.fields import (
        StrField, BoolField, ListField, EmbDocField, RefField, SynonymField, 
        IntField, FloatField, DateTimeField, ObjectIdField)

    class User(Document):
        _id = StrField(regex=r'[a-zA-Z0-9_]{3, 20}')
        is_active = BoolField(default=True)
        posts = ListField(RefField('models.Post'), default=lambda: list())
        quote = StrField(required=False)

        # create a synonym field
        name = SynonymField(_id)

        class Meta:
            collection = 'users'

    class Post(Document):
        # _id field will be added automatically as 
        # _id = ObjectIdField(defalut=lambda: ObjectId())
        title = StrField(allow_blank=False, max_length=50)
        body = StrField()
        created = DateTimeField(default=lambda: datetime.utcnow())
        views = IntField(default=0)
        rate = FloatField(default=0.0)
        author = RefField(User, mongo_name='user')
        comments = ListField(EmbDocField('models.Comment'), default=lambda: list())

        class Meta:
            collection = 'posts'
            indexes = [IndexModel([('created', DESCENDING)])]
            default_sort = [('created', DESCENDING)]

    class Comment(EmbeddedDocument):
        _id = ObjectIdField(default=lambda: ObjectId())
        author = RefField(User)
        body = StrField()

    # `s` property of the fields can be used to get a mongodb string name
    # to use in queries
    assert User._id.s == '_id'
    assert User.name.s == '_id'  # name is synonym
    assert Post.title.s == 'title'
    assert Post.author.s == 'user'  # field has mongo_name
    assert Post.comments.body.s == 'comments.body'  # compound name

CRUD
----

.. code-block:: python

    from motor.motor_asyncio import AsyncIOMotorClient
    
    async def go(db):
        # create model's indexes 
        await User.q(db).create_indexes()

        # CREATE
        # create using save
        # Note: if do_insert=False (default) save performs a replace
        # with upsert=True, so it does not raise if _id already exists
        # in db but replace document with that _id.
        u = await User(name='Alexandro').save(db, do_insert=True)
        assert u.name == 'Alexandro'
        assert u._id == 'Alexandro'
        assert u.is_active is True
        assert u.posts == []
        assert u.quote is None
        # using query
        u = await User.q(db).create(name='Ihor', is_active=False)

        # READ
        # get by id
        u = await User.q(db).get('Alexandro')
        assert u.name == 'Alexandro'
        # find
        users = await User.q(db).find({User.is_active.s: True}).to_list(10)
        assert len(users) == 2
        # using for loop
        users = []
        async for user in User.q(db).find({User.is_active.s: False}):
            users.append(user)
        assert len(users) == 1
        # in Python 3.6 an up use async comprehensions
        users = [user async for user in User.q(db).find({})]
        assert len(users) == 3

        # UPDATE
        u = await User.q(db).get('Ihor')
        u.is_active = True
        await u.save(db)
        assert (await User.q(db).get('Ihor')).is_active is True
        # using update (without data validation)
        # object is reloaded from db after update.
        await u.update(db, {'$push': {User.posts.s: ObjectId()}})

        # DELETE
        u = await User.q(db).get('Ihor')
        await u.delete(db)


    loop = asyncio.get_event_loop()
    client = AsyncIOMotorClient(io_loop=loop)
    db = client.aiomongodel_test
    loop.run_until_complete(go(db))

Validation
----------
Use model's ``validate`` method to validate model's data. If
there are any invalid data an ``aiomongodel.errors.ValidationError``
will raise.

.. note:: 

    Creating model object or assigning it with invalid data does
    not raise errors! Be careful while saving model without validation.

.. code-block:: python

    class Model(Document):
        name = StrField(max_length=7)
        value = IntField(gt=5, lte=13)
        data = FloatField()

    def go():
        m = Model(name='xxx', value=10, data=1.6)
        # validate data
        # should not raise any error
        m.validate()

        # invalid data
        # note that there are no errors while creating
        # model with invalid data
        invalid = Model(name='too long string', value=0)
        try:
            invalid.validate()
        except aiomongodel.errors.ValidationError as e:
            assert e.as_dict() == {
                'name': 'length is greater than 7',
                'value': 'value should be greater than 5',
                'data': 'field is required'
            }
            
            # using translation - you can translate messages
            # to your language or modify them
            translation = {
                "field is required": "This field is required",
                "length is greater than {constraint}": ("Length of the field "
                                                        "is greater than "
                                                        "{constraint} characters"),
                # see all error messages in ValidationError docs
                # for missed messages default messages will be used
            }
            assert e.as_dict(translation=translation) == {
                'name': 'Length of the field is greater than 7 characters',
                'value': 'value should be greater than 5',
                'data': 'This field is required'
            }
 

Querying
--------

.. code-block:: python

    async def go(db):
        # find returns a cursor 
        cursor = User.q(db).find({}, {'_id': 1}).skip(1).limit(2)
        async for user in cursor:
            print(user.name)
            assert user.is_active is None  # we used projection

        # find one
        user = await User.q(db).find_one({User.name.s: 'Alexandro'})
        assert user.name == 'Alexandro'

        # update
        await User.q(db).update_many(
            {User.is_active.s: True},
            {'$set': {User.is_active.s: False}})

        # delete 
        await User.q(db).delete_many({})

Models Inheritance
------------------

A hierarchy of models can be built by inheriting one model from another.
A ``aiomongodel.Document`` class should be somewhere in hierarchy for model
adn ``aiomongodel.EmbeddedDocument`` for subdocuments. 
Note that fields are inherited but meta options are not. 

.. code-block:: python
    
    class Mixin:
        value = IntField()

    class Parent(Document):
        name = StrField()

    class Child(Mixin, Parent):
        # also has value and name fields
        rate = FloatField()

    class OtherChild(Child):
        # also has rate and name fields
        value = FloatField() # overwrite value field from Mixin

    class SubDoc(Mixin, EmbeddedDocument):
        # has value field
        pass

Models Inheritance With Same Collection
---------------------------------------

.. code-block:: python

    class Mixin:
        is_active = BoolField(default=True)

    class User(Mixin, Document):
        _id = StrField() 
        role = StrField()
        name = SynonymField(_id)

        class Meta:
            collection = 'users'
        
        @classmethod
        def from_mongo(cls, data):
            # create appropriate model when loading from db
            if data['role'] == 'customer':
                return super(User, Customer).from_mongo(data)
            if data['role'] == 'admin':
                return super(User, Admin).from_mongo(data)

    class Customer(User):
        role = StrField(default='customer', choices=['customer'])  # overwrite role field
        address = StrField()

        class Meta:
            collection = 'users'
            default_query = {User.role.s: 'customer'}

    class Admin(User):
        role = StrField(default='admin', choices=['admin'])  # overwrite role field
        rights = ListField(StrField(), default=lambda: list())

        class Meta:
            collection = 'users'
            default_query = {User.role.s: 'admin'}


Transaction
-----------

.. code-block:: python

    from motor.motor_asyncio import AsyncIOMotorClient
    
    async def go(db):
        # create collection before using transaction
        await User.create_collection(db)

        async with await db.client.start_session() as session:
            try:
                async with s.start_transaction():
                    # all statements that use session inside this block
                    # will be executed in one transaction

                    # pass session to QuerySet
                    await User.q(db, session=session).create(name='user')  # note session param
                    # pass session to QuerySet method 
                    await User.q(db).update_one(
                        {User.name.s: 'user'},
                        {'$set': {User.is_active.s: False}},
                        session=session)  # note session usage
                    assert await User.q(db, session).count_documents({User.name.s: 'user'}) == 1

                    # session could be used in document crud methods
                    u = await User(name='user2').save(db, session=session)
                    await u.delete(db, session=session)

                    raise Exception()  # simulate error in transaction block
             except Exception:
                 # transaction was not committed 
                 assert await User.q(db).count_documents({User.name.s: 'user'}) == 0
                    
        
    loop = asyncio.get_event_loop()
    client = AsyncIOMotorClient(io_loop=loop)
    db = client.aiomongodel_test
    loop.run_until_complete(go(db))


License
=======

The library is licensed under MIT License.

Changelog
=========

0.2.2 (2020-12-14)
------------------

Bump version of motor for python 3.11 compatibility.

Add tests workflow for GitHub Actions CI.

0.2.1 (2020-12-14)
------------------

Add verbose_name to ``Field`` for meta information.

Fix ``DecimalField``'s issue to load field from float value.

0.2.0 (2018-09-12)
------------------

Move requirements to motor>=2.0.

Remove ``count`` method from ``MotorQuerySetCursor``.

Add session support to ``MotorQuerySet`` and ``Document``.

Add ``create_collection`` method to ``Document``.

Fix ``__aiter__`` of ``MotorQuerySetCursor`` for python 3.7.

Deprecate ``count`` method of ``MotorQuerySet``.

Deprecate ``create`` method of ``Document``.

0.1.0 (2017-05-19)
------------------

The first ``aiomongodel`` release.