From 8c7051b07eb4860e1756aaf7bb20ec5d6dfdd26e Mon Sep 17 00:00:00 2001 From: collerek Date: Thu, 13 Aug 2020 12:54:42 +0200 Subject: [PATCH] finish fields docs intial ver,add test for related name, fix child_name(s) in reverse relations --- .coverage | Bin 53248 -> 53248 bytes docs/fields.md | 206 +++++++++++++++++++++++++++++++++++ docs/index.md | 214 ++++++++++++++++++++++++++++++++++--- docs_src/fields/docs001.py | 36 +++++++ docs_src/fields/docs002.py | 36 +++++++ docs_src/fields/docs003.py | 41 +++++++ orm/fields/base.py | 4 +- orm/fields/foreign_key.py | 19 ++-- orm/models/metaclass.py | 12 +-- orm/relations.py | 19 ++-- tests/test_foreign_keys.py | 26 ++++- 11 files changed, 572 insertions(+), 41 deletions(-) create mode 100644 docs_src/fields/docs001.py create mode 100644 docs_src/fields/docs002.py create mode 100644 docs_src/fields/docs003.py diff --git a/.coverage b/.coverage index a41bf1cbb0cf75a863d6c855a07c6e51169c3201..3afbc70f18fa0551be087317173b260cdce545d2 100644 GIT binary patch delta 226 zcmV<803H8;paX!Q1F!}l3I_lWLJu(y6%Plq5fJwdlMpW;A~PT{GCD9eIx;p70s|Wt zc4cyNX>V>dE;24Lfja>N9fE6EpTLFv;Hp#P% by default False. + +Sets the primary key column on a table, foreign keys always refer to the pk of the `Model`. + +Used in sql only. + +### autoincrement + +`autoincrement`: `bool` = `primary_key and type == int` -> defaults to True if column is a primary key and of type Integer, otherwise False. + +Can be only used with int fields. + +If a field has autoincrement it becomes optional. + +Used only in sql. + +### nullable + +`nullable`: `bool` = `not primary_key` -> defaults to False for primary key column, and True for all other. + +Specifies if field is optional or required, used both with sql and pydantic. + +!!!note + By default all `ForeignKeys` are also nullable, meaning the related `Model` is not required. + + If you change the `ForeignKey` column to `nullable`, it not only becomes required, it changes also the way in which data is loaded in queries. + + If you select `Model` without explicitly adding related `Model` assigned by not nullable `ForeignKey`, the `Model` is still gona be appended automatically, see example below. + +```Python hl_lines="24 32 33 34 35 37 38 39 40 41" +--8<-- "../docs_src/fields/docs003.py" +``` + +!!!info + If you want to know more about how you can preload related models during queries and how the relations work read the [queries][queries] and [relations][relations] sections. + + +### default + +`default`: `Any` = `None` -> defaults to None. + +A default value used if no other value is passed. + +In sql invoked on an insert, used during pydantic model definition. + +If the field has a default value it becomes optional. + +You can pass a static value or a Callable (function etc.) + +Used both in sql and pydantic. + +### server default + +`server_default`: `Any` = `None` -> defaults to None. + +A default value used if no other value is passed. + +In sql invoked on the server side so you can pass i.e. sql function (like now() wrapped in sqlalchemy text() clause). + +If the field has a server_default value it becomes optional. + +You can pass a static value or a Callable (function etc.) + +Used in sql only. + +### index + +`index`: `bool` = `False` -> by default False, + +Sets the index on a table's column. + +Used in sql only. + +### unique + +`unique`: `bool` = `False` + +Sets the unique constraint on a table's column. + +Used in sql only. + +## Fields Types + +### String + +`String(length)` has a required `length` parameter. + +* Sqlalchemy column: `sqlalchemy.String` +* Type (used for pydantic): `str` + +### Text + +`Text()` has no required parameters. + +* Sqlalchemy column: `sqlalchemy.Text` +* Type (used for pydantic): `str` + +### Boolean + +`Boolean()` has no required parameters. + +* Sqlalchemy column: `sqlalchemy.Boolean` +* Type (used for pydantic): `bool` + +### Integer + +`Integer()` has no required parameters. + +* Sqlalchemy column: `sqlalchemy.Integer` +* Type (used for pydantic): `int` + +### BigInteger + +`BigInteger()` has no required parameters. + +* Sqlalchemy column: `sqlalchemy.BigInteger` +* Type (used for pydantic): `int` + +### Float + +`Float()` has no required parameters. + +* Sqlalchemy column: `sqlalchemy.Float` +* Type (used for pydantic): `float` + +### Decimal + +`Decimal(lenght, precision)` has required `length` and `precision` parameters. + +* Sqlalchemy column: `sqlalchemy.DECIMAL` +* Type (used for pydantic): `decimal.Decimal` + +### Date + +`Date()` has no required parameters. + +* Sqlalchemy column: `sqlalchemy.Date` +* Type (used for pydantic): `datetime.date` + +### Time + +`Time()` has no required parameters. + +* Sqlalchemy column: `sqlalchemy.Time` +* Type (used for pydantic): `datetime.time` + +### DateTime + +`DateTime()` has no required parameters. + +* Sqlalchemy column: `sqlalchemy.DateTime` +* Type (used for pydantic): `datetime.datetime` + +### JSON + +`JSON()` has no required parameters. + +* Sqlalchemy column: `sqlalchemy.JSON` +* Type (used for pydantic): `pydantic.Json` + +### ForeignKey + +`ForeignKey(to, related_name=None)` has required parameters `to` that takes target `Model` class. + +Sqlalchemy column and Type are automatically taken from target `Model`. + +* Sqlalchemy column: class of a target `Model` primary key column +* Type (used for pydantic): type of a target `Model` primary key column + +`ForeignKey` fields are automatically registering reverse side of the relation. + +By default it's child (source) `Model` name + s, like courses in snippet below: + +```Python hl_lines="25 31" +--8<-- "../docs_src/fields/docs001.py" +``` + +But you can overwrite this name by providing `related_name` parameter like below: + +```Python hl_lines="25 30" +--8<-- "../docs_src/fields/docs002.py" +``` + +!!!tip + Since related models are coming from Relationship Manager the reverse relation on access returns list of `wekref.proxy` to avoid circular references. + +!!!info + All relations are stored in lists, but when you access parent `Model` the ORM is unpacking the value for you. + Read more in [relations][relations]. + +[relations]: ./relations.md +[queries]: ./queries.md \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 000ea34..24756fe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,17 +1,207 @@ -# Welcome to MkDocs +# Async-ORM -For full documentation visit [mkdocs.org](https://www.mkdocs.org). +

+ + Build Status + + + Coverage + + +CodeFactor + + +Codacy + +

-## Commands +The `async-orm` package is an async ORM for Python, with support for Postgres, +MySQL, and SQLite. ORM is built with: -* `mkdocs new [dir-name]` - Create a new project. -* `mkdocs serve` - Start the live-reloading docs server. -* `mkdocs build` - Build the documentation site. -* `mkdocs -h` - Print help message and exit. + * [`SQLAlchemy core`][sqlalchemy-core] for query building. + * [`databases`][databases] for cross-database async support. + * [`pydantic`][pydantic] for data validation. -## Project layout +Because ORM is built on SQLAlchemy core, you can use [`alembic`][alembic] to provide +database migrations. - mkdocs.yml # The configuration file. - docs/ - index.md # The documentation homepage. - ... # Other markdown pages, images and other files. +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 encode package was too simple (i.e. no ability to join two times to the same table) and used typesystem for data checks. + +**async-orm is still under development:** We recommend pinning any dependencies with `aorm~=0.0.1` + +**Note**: Use `ipython` to try this from the console, since it supports `await`. + +```python +import databases +import orm +import sqlalchemy + +database = databases.Database("sqlite:///db.sqlite") +metadata = sqlalchemy.MetaData() + + +class Note(orm.Model): + __tablename__ = "notes" + __database__ = database + __metadata__ = metadata + + # primary keys of type int by dafault are set to autoincrement + id = orm.Integer(primary_key=True) + text = orm.String(length=100) + completed = orm.Boolean(default=False) + +# Create the database +engine = sqlalchemy.create_engine(str(database.url)) +metadata.create_all(engine) + +# .create() +await Note.objects.create(text="Buy the groceries.", completed=False) +await Note.objects.create(text="Call Mum.", completed=True) +await Note.objects.create(text="Send invoices.", completed=True) + +# .all() +notes = await Note.objects.all() + +# .filter() +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() + +# .get() +note = await Note.objects.get(id=1) + +# .update() +await note.update(completed=True) + +# .delete() +await note.delete() + +# 'pk' always refers to the primary key +note = await Note.objects.get(pk=2) +note.pk # 2 +``` + +ORM supports loading and filtering across foreign keys... + +```python +import databases +import orm +import sqlalchemy + +database = databases.Database("sqlite:///db.sqlite") +metadata = sqlalchemy.MetaData() + + +class Album(orm.Model): + __tablename__ = "album" + __metadata__ = metadata + __database__ = database + + id = orm.Integer(primary_key=True) + name = orm.String(length=100) + + +class Track(orm.Model): + __tablename__ = "track" + __metadata__ = metadata + __database__ = database + + id = orm.Integer(primary_key=True) + album = orm.ForeignKey(Album) + title = orm.String(length=100) + position = orm.Integer() + + +# Create some records to work with. +malibu = await Album.objects.create(name="Malibu") +await Track.objects.create(album=malibu, title="The Bird", position=1) +await Track.objects.create(album=malibu, title="Heart don't stand a chance", position=2) +await Track.objects.create(album=malibu, title="The Waters", position=3) + +fantasies = await Album.objects.create(name="Fantasies") +await Track.objects.create(album=fantasies, title="Help I'm Alive", position=1) +await Track.objects.create(album=fantasies, title="Sick Muse", position=2) + + +# Fetch an instance, without loading a foreign key relationship on it. +track = await Track.objects.get(title="The Bird") + +# We have an album instance, but it only has the primary key populated +print(track.album) # Album(id=1) [sparse] +print(track.album.pk) # 1 +print(track.album.name) # Raises AttributeError + +# Load the relationship from the database +await track.album.load() +assert track.album.name == "Malibu" + +# This time, fetch an instance, loading the foreign key relationship. +track = await Track.objects.select_related("album").get(title="The Bird") +assert track.album.name == "Malibu" + +# By default you also get a second side of the relation +# constructed as lowercase source model name +'s' (tracks in this case) +# you can also provide custom name with parameter related_name +album = await Album.objects.select_related("tracks").all() +assert len(album.tracks) == 3 + +# Fetch instances, with a filter across an FK relationship. +tracks = Track.objects.filter(album__name="Fantasies") +assert len(tracks) == 2 + +# Fetch instances, with a filter and operator across an FK relationship. +tracks = Track.objects.filter(album__name__iexact="fantasies") +assert len(tracks) == 2 + +# Limit a query +tracks = await Track.objects.limit(1).all() +assert len(tracks) == 1 +``` + +## Data types + +The following keyword arguments are supported on all field types. + + * `primary_key` + * `nullable` + * `default` + * `server_default` + * `index` + * `unique` + +## Model Fields + +### Common parameters + +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. + +### Fields Types + +* `orm.String(length)` +* `orm.Text()` +* `orm.Boolean()` +* `orm.Integer()` +* `orm.Float()` +* `orm.Date()` +* `orm.Time()` +* `orm.DateTime()` +* `orm.JSON()` +* `orm.BigInteger()` +* `orm.Decimal(lenght, precision)` + +[sqlalchemy-core]: https://docs.sqlalchemy.org/en/latest/core/ +[databases]: https://github.com/encode/databases +[pydantic]: https://pydantic-docs.helpmanual.io/ +[encode/orm]: https://github.com/encode/orm/ +[alembic]: https://alembic.sqlalchemy.org/en/latest/ +[fastapi]: https://fastapi.tiangolo.com/ \ No newline at end of file diff --git a/docs_src/fields/docs001.py b/docs_src/fields/docs001.py new file mode 100644 index 0000000..f28b9d0 --- /dev/null +++ b/docs_src/fields/docs001.py @@ -0,0 +1,36 @@ +import databases +import sqlalchemy + +import orm + +database = databases.Database("sqlite:///db.sqlite") +metadata = sqlalchemy.MetaData() + + +class Department(orm.Model): + __database__ = database + __metadata__ = metadata + + id = orm.Integer(primary_key=True) + name = orm.String(length=100) + + +class Course(orm.Model): + __database__ = database + __metadata__ = metadata + + id = orm.Integer(primary_key=True) + name = orm.String(length=100) + completed = orm.Boolean(default=False) + department = orm.ForeignKey(Department) + + +department = Department(name='Science') +course = Course(name='Math', completed=False, department=department) + +print(department.courses[0]) +# Will produce: +# Course(id=None, +# name='Math', +# completed=False, +# department=Department(id=None, name='Science')) diff --git a/docs_src/fields/docs002.py b/docs_src/fields/docs002.py new file mode 100644 index 0000000..5c9f4a9 --- /dev/null +++ b/docs_src/fields/docs002.py @@ -0,0 +1,36 @@ +import databases +import sqlalchemy + +import orm + +database = databases.Database("sqlite:///db.sqlite") +metadata = sqlalchemy.MetaData() + + +class Department(orm.Model): + __database__ = database + __metadata__ = metadata + + id = orm.Integer(primary_key=True) + name = orm.String(length=100) + + +class Course(orm.Model): + __database__ = database + __metadata__ = metadata + + id = orm.Integer(primary_key=True) + name = orm.String(length=100) + completed = orm.Boolean(default=False) + department = orm.ForeignKey(Department, related_name="my_courses") + +department = Department(name='Science') +course = Course(name='Math', completed=False, department=department) + +print(department.my_courses[0]) +# Will produce: +# Course(id=None, +# name='Math', +# completed=False, +# department=Department(id=None, name='Science')) + diff --git a/docs_src/fields/docs003.py b/docs_src/fields/docs003.py new file mode 100644 index 0000000..c3df501 --- /dev/null +++ b/docs_src/fields/docs003.py @@ -0,0 +1,41 @@ +import orm +import databases +import sqlalchemy + +database = databases.Database("sqlite:///db.sqlite") +metadata = sqlalchemy.MetaData() + + +class Album(orm.Model): + __tablename__ = "album" + __metadata__ = metadata + __database__ = database + + id = orm.Integer(primary_key=True) + name = orm.String(length=100) + + +class Track(orm.Model): + __tablename__ = "track" + __metadata__ = metadata + __database__ = database + + id = orm.Integer(primary_key=True) + album = orm.ForeignKey(Album, nullable=False) + title = orm.String(length=100) + position = orm.Integer() + + +album = await Album.objects.create(name="Brooklyn") +await Track.objects.create(album=album, title="The Bird", position=1) + +# explicit preload of related Album Model +track = await Track.objects.select_related("album").get(title="The Bird") +assert track.album.name == 'Brooklyn' +# Will produce: True + +# even without explicit select_related if ForeignKey is not nullable, +# the Album Model is still preloaded. +track2 = await Track.objects.get(title="The Bird") +assert track2.album.name == 'Brooklyn' +# Will produce: True diff --git a/orm/fields/base.py b/orm/fields/base.py index 2a0feb5..73b3131 100644 --- a/orm/fields/base.py +++ b/orm/fields/base.py @@ -35,12 +35,12 @@ class BaseField: @property def is_required(self) -> bool: return ( - not self.nullable and not self.has_default and not self.is_auto_primary_key + not self.nullable and not self.has_default and not self.is_auto_primary_key ) @property def default_value(self) -> Any: - default = self.default if self.default is not None else self.server_default + default = self.default return default() if callable(default) else default @property diff --git a/orm/fields/foreign_key.py b/orm/fields/foreign_key.py index b32a887..81398e2 100644 --- a/orm/fields/foreign_key.py +++ b/orm/fields/foreign_key.py @@ -25,12 +25,12 @@ def create_dummy_instance(fk: Type["Model"], pk: Any = None) -> "Model": class ForeignKey(BaseField): def __init__( - self, - to: Type["Model"], - name: str = None, - related_name: str = None, - nullable: bool = True, - virtual: bool = False, + self, + to: Type["Model"], + name: str = None, + related_name: str = None, + nullable: bool = True, + virtual: bool = False, ) -> None: super().__init__(nullable=nullable, name=name) self.virtual = virtual @@ -50,7 +50,7 @@ class ForeignKey(BaseField): return to_column.get_column_type() def _extract_model_from_sequence( - self, value: List, child: "Model" + self, value: List, child: "Model" ) -> Union["Model", List["Model"]]: return [self.expand_relationship(val, child) for val in value] @@ -75,10 +75,11 @@ class ForeignKey(BaseField): return model def register_relation(self, model: "Model", child: "Model") -> None: - model._orm_relationship_manager.add_relation(model, child, virtual=self.virtual) + child_model_name = self.related_name or child.get_name() + model._orm_relationship_manager.add_relation(model, child, child_model_name, virtual=self.virtual) def expand_relationship( - self, value: Any, child: "Model" + self, value: Any, child: "Model" ) -> Optional[Union["Model", List["Model"]]]: if value is None: diff --git a/orm/models/metaclass.py b/orm/models/metaclass.py index 37f1108..13a237c 100644 --- a/orm/models/metaclass.py +++ b/orm/models/metaclass.py @@ -28,8 +28,8 @@ def parse_pydantic_field_from_model_fields(object_dict: dict) -> Dict[str, Tuple def register_relation_on_build(table_name: str, field: ForeignKey, name: str) -> None: - child_relation_name = field.to.get_name(title=True) + "_" + name.lower() + "s" - reverse_name = field.related_name or child_relation_name + child_relation_name = field.to.get_name(title=True) + "_" + (field.related_name or (name.lower() + "s")) + reverse_name = child_relation_name relation_name = name.lower().title() + "_" + field.to.get_name() relationship_manager.add_relation_type( relation_name, reverse_name, field, table_name @@ -43,14 +43,14 @@ def expand_reverse_relationships(model: Type["Model"]) -> None: parent_model = model_field.to child = model if ( - child_model_name not in parent_model.__fields__ - and child.get_name() not in parent_model.__fields__ + child_model_name not in parent_model.__fields__ + and child.get_name() not in parent_model.__fields__ ): register_reverse_model_fields(parent_model, child, child_model_name) def register_reverse_model_fields( - model: Type["Model"], child: Type["Model"], child_model_name: str + model: Type["Model"], child: Type["Model"], child_model_name: str ) -> None: model.__fields__[child_model_name] = ModelField( name=child_model_name, @@ -64,7 +64,7 @@ def register_reverse_model_fields( def sqlalchemy_columns_from_model_fields( - name: str, object_dict: Dict, table_name: str + name: str, object_dict: Dict, table_name: str ) -> Tuple[Optional[str], List[sqlalchemy.Column], Dict[str, BaseField]]: columns = [] pkname = None diff --git a/orm/relations.py b/orm/relations.py index 269d027..59ec981 100644 --- a/orm/relations.py +++ b/orm/relations.py @@ -37,25 +37,28 @@ class RelationshipManager: del self._relations[rel_type][model._orm_id] def add_relation( - self, parent: "FakePydantic", child: "FakePydantic", virtual: bool = False, + self, + parent: "FakePydantic", + child: "FakePydantic", + child_model_name: str, + virtual: bool = False, ) -> None: parent_id, child_id = parent._orm_id, child._orm_id - parent_name, child_name = ( - parent.get_name(title=True), - child.get_name(title=True), - ) + parent_name =parent.get_name(title=True) + child_name = child_model_name if child.get_name() != child_model_name else child.get_name()+'s' if virtual: - child_name, parent_name = parent_name, child_name + child_name, parent_name = parent_name, child.get_name() child_id, parent_id = parent_id, child_id child, parent = parent, proxy(child) + child_name = child_name.lower()+'s' else: child = proxy(child) - parent_relation_name = parent_name + "_" + child_name.lower() + "s" + parent_relation_name = parent_name.title() + "_" + child_name parents_list = self._relations[parent_relation_name].setdefault(parent_id, []) self.append_related_model(parents_list, child) - child_relation_name = child_name + "_" + parent_name.lower() + child_relation_name = child.get_name(title=True) + "_" + parent_name.lower() children_list = self._relations[child_relation_name].setdefault(child_id, []) self.append_related_model(children_list, parent) diff --git a/tests/test_foreign_keys.py b/tests/test_foreign_keys.py index 7121e5a..aaea6ff 100644 --- a/tests/test_foreign_keys.py +++ b/tests/test_foreign_keys.py @@ -3,7 +3,6 @@ import pytest import sqlalchemy import orm -import orm.fields.foreign_key from orm.exceptions import NoMatch, MultipleMatches, RelationshipInstanceError from tests.settings import DATABASE_URL @@ -26,11 +25,21 @@ class Track(orm.Model): __database__ = database id = orm.Integer(primary_key=True) - album = orm.fields.foreign_key.ForeignKey(Album) + album = orm.ForeignKey(Album) title = orm.String(length=100) position = orm.Integer() +class Cover(orm.Model): + __tablename__ = "covers" + __metadata__ = metadata + __database__ = database + + id = orm.Integer(primary_key=True) + album = orm.ForeignKey(Album, related_name='cover_pictures') + title = orm.String(length=100) + + class Organisation(orm.Model): __tablename__ = "org" __metadata__ = metadata @@ -46,7 +55,7 @@ class Team(orm.Model): __database__ = database id = orm.Integer(primary_key=True) - org = orm.fields.foreign_key.ForeignKey(Organisation) + org = orm.ForeignKey(Organisation) name = orm.String(length=100) @@ -56,7 +65,7 @@ class Member(orm.Model): __database__ = database id = orm.Integer(primary_key=True) - team = orm.fields.foreign_key.ForeignKey(Team) + team = orm.ForeignKey(Team) email = orm.String(length=100) @@ -81,6 +90,15 @@ async def test_setting_explicitly_empty_relation(): assert track.album is None +@pytest.mark.asyncio +async def test_related_name(): + async with database: + album = await Album.objects.create(name="Vanilla") + await Cover.objects.create(album=album, title="The cover file") + + assert len(album.cover_pictures) == 1 + + @pytest.mark.asyncio async def test_model_crud(): async with database: