diff --git a/README.md b/README.md index cb31386..c897f33 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,7 @@ await Book.objects.delete(genre='Fantasy') # delete all fantasy books all_books = await Book.objects.all() assert len(all_books) == 3 -# queryset needs to be filtered before deleting to prevent accidental overwrite +# queryset needs to be filtered before deleting/ updating to prevent accidental overwrite # to update whole database table each=True needs to be provided as a safety switch await Book.objects.update(each=True, genre='Fiction') all_books = await Book.objects.filter(genre='Fiction').all() diff --git a/docs/index.md b/docs/index.md index bf035bf..cb31386 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,4 @@ # ORMar -
The `ormar` package is an async ORM for Python, with support for Postgres, -MySQL, and SQLite. Ormar is built with: +MySQL, and SQLite. + +Ormar - apart form obvious ORM in name - get it's name from ormar in swedish which means snakes, and ormar(e) in italian which means cabinet. +And what's a better name for python ORM than snakes cabinet :) + +Ormar is built with: * [`SQLAlchemy core`][sqlalchemy-core] for query building. * [`databases`][databases] for cross-database async support. @@ -31,11 +35,12 @@ MySQL, and SQLite. Ormar is built with: Because ormar is built on SQLAlchemy core, you can use [`alembic`][alembic] to provide database migrations. -The goal was to create a simple ORM that can be used directly with [`fastapi`][fastapi] that bases it's data validation on pydantic. -Initial work was inspired by [`encode/orm`][encode/orm]. +The goal was to create a simple ORM that can be used directly (as request and response models) with [`fastapi`][fastapi] that bases it's data validation on pydantic. +Initial work was inspired by [`encode/orm`][encode/orm], later I found `ormantic` and used it as a further inspiration. The encode package was too simple (i.e. no ability to join two times to the same table) and used typesystem for data checks. -**ormar is still under development:** We recommend pinning any dependencies with `ormar~=0.0.1` + +**ormar is still under development:** We recommend pinning any dependencies with `ormar~=0.2.0` **Note**: Use `ipython` to try this from the console, since it supports `await`. @@ -47,16 +52,18 @@ import sqlalchemy database = databases.Database("sqlite:///db.sqlite") metadata = sqlalchemy.MetaData() - class Note(ormar.Model): - __tablename__ = "notes" - __database__ = database - __metadata__ = metadata + class Meta: + tablename = "notes" + database = database + metadata = metadata # primary keys of type int by dafault are set to autoincrement - id = ormar.Integer(primary_key=True) - text = ormar.String(length=100) - completed = ormar.Boolean(default=False) + id: ormar.Integer(primary_key=True) + text: ormar.String(length=100) + completed: ormar.Boolean(default=False) + # as of ormar >=0.3.2 you can provide a list of choices that will be validated + flag: ormar.String(default='To do', choices=['To do', 'Pending', 'Done']) # Create the database engine = sqlalchemy.create_engine(str(database.url)) @@ -76,6 +83,14 @@ notes = await Note.objects.filter(completed=True).all() # exact, iexact, contains, icontains, lt, lte, gt, gte, in notes = await Note.objects.filter(text__icontains="mum").all() +# exclude - from ormar >= 0.3.1 +notes = await Note.objects.exclude(text__icontains="mum").all() + +# startswith, istartswith, endswith, iendswith - from ormar >= 0.3.3 +notes = await Note.objects.filter(text__iendswith="mum.").all() +notes = await Note.objects.filter(text__istartswith="call").all() +notes = await Note.objects.filter(text__startswith="Buy").all() + # .get() note = await Note.objects.get(id=1) @@ -102,23 +117,25 @@ metadata = sqlalchemy.MetaData() class Album(ormar.Model): - __tablename__ = "album" - __metadata__ = metadata - __database__ = database + class Meta: + tablename = "album" + metadata = metadata + database = database - id = ormar.Integer(primary_key=True) - name = ormar.String(length=100) + id: ormar.Integer(primary_key=True) + name: ormar.String(length=100) class Track(ormar.Model): - __tablename__ = "track" - __metadata__ = metadata - __database__ = database + class Meta: + tablename = "track" + metadata = metadata + database = database - id = ormar.Integer(primary_key=True) - album = ormar.ForeignKey(Album) - title = ormar.String(length=100) - position = ormar.Integer() + id: ormar.Integer(primary_key=True) + album: ormar.ForeignKey(Album) + title: ormar.String(length=100) + position: ormar.Integer() # Create some records to work with. @@ -167,33 +184,330 @@ tracks = await Track.objects.limit(1).all() assert len(tracks) == 1 ``` +Since version >=0.3 Ormar supports also many to many relationships +```python +import databases +import ormar +import sqlalchemy + +database = databases.Database("sqlite:///db.sqlite") +metadata = sqlalchemy.MetaData() + +class Author(ormar.Model): + class Meta: + tablename = "authors" + database = database + metadata = metadata + + id: ormar.Integer(primary_key=True) + first_name: ormar.String(max_length=80) + last_name: ormar.String(max_length=80) + + +class Category(ormar.Model): + class Meta: + tablename = "categories" + database = database + metadata = metadata + + id: ormar.Integer(primary_key=True) + name: ormar.String(max_length=40) + + +class PostCategory(ormar.Model): + class Meta: + tablename = "posts_categories" + database = database + metadata = metadata + + +class Post(ormar.Model): + class Meta: + tablename = "posts" + database = database + metadata = metadata + + id: ormar.Integer(primary_key=True) + title: ormar.String(max_length=200) + categories: ormar.ManyToMany(Category, through=PostCategory) + author: ormar.ForeignKey(Author) + +guido = await Author.objects.create(first_name="Guido", last_name="Van Rossum") +post = await Post.objects.create(title="Hello, M2M", author=guido) +news = await Category.objects.create(name="News") + +# Add a category to a post. +await post.categories.add(news) +# or from the other end: +await news.posts.add(post) + +# Creating columns object from instance: +await post.categories.create(name="Tips") +assert len(await post.categories.all()) == 2 + +# Many to many relation exposes a list of columns models +# and an API of the Queryset: +assert news == await post.categories.get(name="News") + +# with all Queryset methods - filtering, selecting columns, counting etc. +await news.posts.filter(title__contains="M2M").all() +await Category.objects.filter(posts__author=guido).get() + +# columns models of many to many relation can be prefetched +news_posts = await news.posts.select_related("author").all() +assert news_posts[0].author == guido + +# Removal of the relationship by one +await news.posts.remove(post) +# or all at once +await news.posts.clear() + +``` + +Since version >=0.3.4 Ormar supports also queryset level delete and update statements, +as well as get_or_create and update_or_create +```python +import databases +import ormar +import sqlalchemy + +database = databases.Database("sqlite:///db.sqlite") +metadata = sqlalchemy.MetaData() + +class Book(ormar.Model): + class Meta: + tablename = "books" + metadata = metadata + database = database + + id: ormar.Integer(primary_key=True) + title: ormar.String(max_length=200) + author: ormar.String(max_length=100) + genre: ormar.String(max_length=100, default='Fiction', choices=['Fiction', 'Adventure', 'Historic', 'Fantasy']) + +await Book.objects.create(title='Tom Sawyer', author="Twain, Mark", genre='Adventure') +await Book.objects.create(title='War and Peace', author="Tolstoy, Leo", genre='Fiction') +await Book.objects.create(title='Anna Karenina', author="Tolstoy, Leo", genre='Fiction') +await Book.objects.create(title='Harry Potter', author="Rowling, J.K.", genre='Fantasy') +await Book.objects.create(title='Lord of the Rings', author="Tolkien, J.R.", genre='Fantasy') + +# update accepts kwargs that are used to update queryset model +# all other arguments are ignored (argument names not in own model table) +await Book.objects.filter(author="Tolstoy, Leo").update(author="Lenin, Vladimir") # update all Tolstoy's books +all_books = await Book.objects.filter(author="Lenin, Vladimir").all() +assert len(all_books) == 2 + +# delete accepts kwargs that will be used in filter +# acting in same way as queryset.filter(**kwargs).delete() +await Book.objects.delete(genre='Fantasy') # delete all fantasy books +all_books = await Book.objects.all() +assert len(all_books) == 3 + +# queryset needs to be filtered before deleting to prevent accidental overwrite +# to update whole database table each=True needs to be provided as a safety switch +await Book.objects.update(each=True, genre='Fiction') +all_books = await Book.objects.filter(genre='Fiction').all() +assert len(all_books) == 3 + +# helper get/update or create methods of queryset +# if not exists it will be created +vol1 = await Book.objects.get_or_create(title="Volume I", author='Anonymous', genre='Fiction') +assert await Book.objects.count() == 1 + +# if exists it will be returned +assert await Book.objects.get_or_create(title="Volume I", author='Anonymous', genre='Fiction') == vol1 +assert await Book.objects.count() == 1 + +# if not exist the instance will be persisted in db +vol2 = await Book.objects.update_or_create(title="Volume II", author='Anonymous', genre='Fiction') +assert await Book.objects.count() == 1 + +# if pk or pkname passed in kwargs (like id here) the object will be updated +assert await Book.objects.update_or_create(id=vol2.id, genre='Historic') +assert await Book.objects.count() == 1 + +``` + + +Since version >=0.3.5 Ormar supports also bulk operations -> bulk_create and bulk_update +```python +import databases +import ormar +import sqlalchemy + +database = databases.Database("sqlite:///db.sqlite") +metadata = sqlalchemy.MetaData() + + +class ToDo(ormar.Model): + class Meta: + tablename = "todos" + metadata = metadata + database = database + + id: ormar.Integer(primary_key=True) + text: ormar.String(max_length=500) + completed: ormar.Boolean(default=False) + +# create multiple instances at once with bulk_create +await ToDo.objects.bulk_create( + [ + ToDo(text="Buy the groceries."), + ToDo(text="Call Mum.", completed=True), + ToDo(text="Send invoices.", completed=True), + ] + ) + +todoes = await ToDo.objects.all() +assert len(todoes) == 3 + +# update objects +for todo in todoes: + todo.completed = False + +# perform update of all objects at once +# objects need to have pk column set, otherwise exception is raised +await ToDo.objects.bulk_update(todoes) + +completed = await ToDo.objects.filter(completed=False).all() +assert len(completed) == 3 + +``` + +Since version >=0.3.6 Ormar supports unique constraints on multiple columns +```python +import databases +import ormar +import sqlalchemy + +database = databases.Database("sqlite:///db.sqlite") +metadata = sqlalchemy.MetaData() + + +class Product(ormar.Model): + class Meta: + tablename = "products" + metadata = metadata + database = database + # define your constraints in Meta class of the model + # it's a list that can contain multiple constraints + constraints = [ormar.UniqueColumns("name", "company")] + + id: ormar.Integer(primary_key=True) + name: ormar.String(max_length=100) + company: ormar.String(max_length=200) + +await Product.objects.create(name="Cookies", company="Nestle") +await Product.objects.create(name="Mars", company="Mars") +await Product.objects.create(name="Mars", company="Nestle") + + +# will raise error based on backend +# (sqlite3.IntegrityError, pymysql.IntegrityError, asyncpg.exceptions.UniqueViolationError) +await Product.objects.create(name="Mars", company="Mars") + +``` + +Since version >=0.3.6 Ormar supports selecting subset of model columns to limit the data load. +Warning - mandatory fields cannot be excluded as it will raise validation error, to exclude a field it has to be nullable. +Pk column cannot be excluded - it's always auto added even if not explicitly included. +```python +import databases +import pydantic +import pytest +import sqlalchemy + +import ormar +from tests.settings import DATABASE_URL + +database = databases.Database(DATABASE_URL, force_rollback=True) +metadata = sqlalchemy.MetaData() + + +class Company(ormar.Model): + class Meta: + tablename = "companies" + metadata = metadata + database = database + + id: ormar.Integer(primary_key=True) + name: ormar.String(max_length=100) + founded: ormar.Integer(nullable=True) + + +class Car(ormar.Model): + class Meta: + tablename = "cars" + metadata = metadata + database = database + + id: ormar.Integer(primary_key=True) + manufacturer: ormar.ForeignKey(Company) + name: ormar.String(max_length=100) + year: ormar.Integer(nullable=True) + gearbox_type: ormar.String(max_length=20, nullable=True) + gears: ormar.Integer(nullable=True) + aircon_type: ormar.String(max_length=20, nullable=True) + + + +# build some sample data +toyota = await Company.objects.create(name="Toyota", founded=1937) +await Car.objects.create(manufacturer=toyota, name="Corolla", year=2020, gearbox_type='Manual', gears=5, + aircon_type='Manual') +await Car.objects.create(manufacturer=toyota, name="Yaris", year=2019, gearbox_type='Manual', gears=5, + aircon_type='Manual') +await Car.objects.create(manufacturer=toyota, name="Supreme", year=2020, gearbox_type='Auto', gears=6, + aircon_type='Auto') + +# select manufacturer but only name - to include related models use notation {model_name}__{column} +all_cars = await Car.objects.select_related('manufacturer').fields(['id', 'name', 'company__name']).all() +for car in all_cars: + # excluded columns will yield None + assert all(getattr(car, x) is None for x in ['year', 'gearbox_type', 'gears', 'aircon_type']) + # included column on related models will be available, pk column is always included + # even if you do not include it in fields list + assert car.manufacturer.name == 'Toyota' + # also in the nested related models - you cannot exclude pk - it's always auto added + assert car.manufacturer.founded is None + +# fields() can be called several times, building up the columns to select +# models selected in select_related but with no columns in fields list implies all fields +all_cars = await Car.objects.select_related('manufacturer').fields('id').fields( + ['name']).all() +# all fiels from company model are selected +assert all_cars[0].manufacturer.name == 'Toyota' +assert all_cars[0].manufacturer.founded == 1937 + +# cannot exclude mandatory model columns - company__name in this example +await Car.objects.select_related('manufacturer').fields(['id', 'name', 'company__founded']).all() +# will raise pydantic ValidationError as company.name is required + +``` + + ## Data types The following keyword arguments are supported on all field types. - * `primary_key` - * `nullable` - * `default` - * `server_default` - * `index` - * `unique` - -## Model Fields - -### Common parameters + * `primary_key: bool` + * `nullable: bool` + * `default: Any` + * `server_default: Any` + * `index: bool` + * `unique: bool` + * `choices: typing.Sequence` All fields are required unless one of the following is set: * `nullable` - Creates a nullable column. Sets the default to `None`. * `default` - Set a default value for the field. * `server_default` - Set a default value for the field on server side (like sqlalchemy's `func.now()`). - * `primary key` - Set a primary key on a column. - * `autoincrement` - When a column is set to primary key and autoincrement is set on this column. - Autoincrement is set by default on int primary keys. + * `primary key` with `autoincrement` - When a column is set to primary key and autoincrement is set on this column. +Autoincrement is set by default on int primary keys. -### Fields Types - -* `String(length)` +Available Model Fields (with required args - optional ones in docs): +* `String(max_length)` * `Text()` * `Boolean()` * `Integer()` @@ -203,7 +517,10 @@ All fields are required unless one of the following is set: * `DateTime()` * `JSON()` * `BigInteger()` -* `Decimal(lenght, precision)` +* `Decimal(scale, precision)` +* `UUID()` +* `ForeignKey(to)` +* `Many2Many(to, through)` [sqlalchemy-core]: https://docs.sqlalchemy.org/en/latest/core/ [databases]: https://github.com/encode/databases diff --git a/docs/models.md b/docs/models.md index 5fd3cd6..e1c5f31 100644 --- a/docs/models.md +++ b/docs/models.md @@ -21,7 +21,7 @@ Each table **has to** have a primary key column, which you specify by setting `p Only one primary key column is allowed. -```Python hl_lines="14 15 16" +```Python hl_lines="15 16 17" --8<-- "../docs_src/models/docs001.py" ``` @@ -34,7 +34,7 @@ By default if you assign primary key to `Integer` field, the `autoincrement` opt You can disable by passing `autoincremant=False`. ```Python -id = ormar.Integer(primary_key=True, autoincrement=False) +id: ormar.Integer(primary_key=True, autoincrement=False) ``` Names of the fields will be used for both the underlying `pydantic` model and `sqlalchemy` table. @@ -48,9 +48,9 @@ and table creation you need to assign each `Model` with two special parameters. One is `Database` instance created with your database url in [sqlalchemy connection string][sqlalchemy connection string] format. -Created instance needs to be passed to every `Model` with `__database__` parameter. +Created instance needs to be passed to every `Model` with `Meta` class `database` parameter. -```Python hl_lines="1 6 11" +```Python hl_lines="1 6 12" --8<-- "../docs_src/models/docs001.py" ``` @@ -62,9 +62,9 @@ Created instance needs to be passed to every `Model` with `__database__` paramet Second dependency is sqlalchemy `MetaData` instance. -Created instance needs to be passed to every `Model` with `__metadata__` parameter. +Created instance needs to be passed to every `Model` with `Meta` class `metadata` parameter. -```Python hl_lines="2 7 12" +```Python hl_lines="2 7 13" --8<-- "../docs_src/models/docs001.py" ``` @@ -76,9 +76,9 @@ Created instance needs to be passed to every `Model` with `__metadata__` paramet By default table name is created from Model class name as lowercase name plus 's'. -You can overwrite this parameter by providing `__tablename__` argument. +You can overwrite this parameter by providing `Meta` class `tablename` argument. -```Python hl_lines="11 12 13" +```Python hl_lines="12 13 14" --8<-- "../docs_src/models/docs002.py" ``` @@ -91,7 +91,7 @@ There are two ways to create and persist the `Model` instance in the database. If you plan to modify the instance in the later execution of your program you can initiate your `Model` as a normal class and later await a `save()` call. -```Python hl_lines="19 20" +```Python hl_lines="20 21" --8<-- "../docs_src/models/docs007.py" ``` @@ -99,41 +99,24 @@ If you want to initiate your `Model` and at the same time save in in the databas Each model has a `QuerySet` initialised as `objects` parameter -```Python hl_lines="22" +```Python hl_lines="23" --8<-- "../docs_src/models/docs007.py" ``` !!!info To read more about `QuerySets` and available methods visit [queries][queries] -## Attributes Delegation - -Each call to `Model` fields parameter under the hood is delegated to either the `pydantic` model -or other related `Model` in case of relations. - -The fields and relations are not stored on the `Model` itself - -```Python hl_lines="31 32 33 34 35 36 37 38 39 40 41" ---8<-- "../docs_src/models/docs006.py" -``` - -!!! warning - In example above model instances are created but not persisted that's why `id` of `department` is None! - -!!!info - To read more about `ForeignKeys` and `Model` relations visit [relations][relations] - ## Internals Apart from special parameters defined in the `Model` during definition (tablename, metadata etc.) the `Model` provides you with useful internals. ### Pydantic Model -To access auto created pydantic model you can use `Model.__pydantic_model__` parameter +All `Model` classes inherit from `pydantic.BaseModel` so you can access all normal attributes of pydantic models. For example to list model fields you can: -```Python hl_lines="18" +```Python hl_lines="20" --8<-- "../docs_src/models/docs003.py" ``` @@ -145,11 +128,11 @@ For example to list model fields you can: ### Sqlalchemy Table -To access auto created sqlalchemy table you can use `Model.__table__` parameter +To access auto created sqlalchemy table you can use `Model.Meta.table` parameter For example to list table columns you can: -```Python hl_lines="18" +```Python hl_lines="20" --8<-- "../docs_src/models/docs004.py" ``` @@ -161,14 +144,26 @@ For example to list table columns you can: ### Fields Definition -To access ormar `Fields` you can use `Model.__model_fields__` parameter +To access ormar `Fields` you can use `Model.Meta.model_fields` parameter For example to list table model fields you can: -```Python hl_lines="18" +```Python hl_lines="19" --8<-- "../docs_src/models/docs005.py" ``` +!!!info + Note that fields stored on a model are `classes` not `instances`. + + So if you print just model fields you will get: + + `{'id':