diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..922eaa1 Binary files /dev/null and b/.coverage differ diff --git a/README.md b/README.md index 07b3ba6..d0c2e2a 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,18 @@ The `async-orm` package is an async ORM for Python, with support for Postgres, MySQL, and SQLite. ORM is built with: -* [SQLAlchemy core][sqlalchemy-core] for query building. +* [`SQLAlchemy core`][sqlalchemy-core] for query building. * [`databases`][databases] for cross-database async support. * [`pydantic`][pydantic] for data validation. -Because ORM is built on SQLAlchemy core, you can use Alembic to provide +Because ORM 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 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 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. -**ORM is still under development: We recommend pinning any dependencies with `orm~=0.1`** +**aysn-orm is still under development: We recommend pinning any dependencies with `async-orm~=0.1`** **Note**: Use `ipython` to try this from the console, since it supports `await`. @@ -179,4 +180,6 @@ All fields are required unless one of the following is set: [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/ \ No newline at end of file +[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/orm/__pycache__/__init__.cpython-38.pyc b/orm/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..4d8d4b7 Binary files /dev/null and b/orm/__pycache__/__init__.cpython-38.pyc differ diff --git a/orm/__pycache__/exceptions.cpython-38.pyc b/orm/__pycache__/exceptions.cpython-38.pyc new file mode 100644 index 0000000..2515eef Binary files /dev/null and b/orm/__pycache__/exceptions.cpython-38.pyc differ diff --git a/orm/__pycache__/fields.cpython-38.pyc b/orm/__pycache__/fields.cpython-38.pyc new file mode 100644 index 0000000..5e5e6e7 Binary files /dev/null and b/orm/__pycache__/fields.cpython-38.pyc differ diff --git a/orm/__pycache__/models.cpython-38.pyc b/orm/__pycache__/models.cpython-38.pyc new file mode 100644 index 0000000..fe7bd71 Binary files /dev/null and b/orm/__pycache__/models.cpython-38.pyc differ diff --git a/orm/exceptions.py b/orm/exceptions.py new file mode 100644 index 0000000..ebc4114 --- /dev/null +++ b/orm/exceptions.py @@ -0,0 +1,6 @@ +class AsyncOrmException(Exception): + pass + + +class ModelDefinitionError(AsyncOrmException): + pass diff --git a/orm/fields.py b/orm/fields.py new file mode 100644 index 0000000..221c9a2 --- /dev/null +++ b/orm/fields.py @@ -0,0 +1,73 @@ +import sqlalchemy + +from orm.exceptions import ModelDefinitionError + + +class BaseField: + __type__ = None + + def __init__(self, *args, **kwargs): + name = kwargs.pop('name', None) + args = list(args) + if args: + if isinstance(args[0], str): + if name is not None: + raise ModelDefinitionError( + 'Column name cannot be passed positionally and as a keyword.' + ) + name = args.pop(0) + + self.name = name + self.primary_key = kwargs.pop('primary_key', False) + self.autoincrement = kwargs.pop('autoincrement', 'auto') + + self.nullable = kwargs.pop('nullable', not self.primary_key) + self.default = kwargs.pop('default', None) + self.server_default = kwargs.pop('server_default', None) + + self.index = kwargs.pop('index', None) + self.unique = kwargs.pop('unique', None) + + def get_column(self, name=None) -> sqlalchemy.Column: + name = self.name or name + constraints = self.get_constraints() + return sqlalchemy.Column( + name, + self.get_column_type(), + *constraints, + primary_key=self.primary_key, + autoincrement=self.autoincrement, + nullable=self.nullable, + index=self.index, + unique=self.unique, + default=self.default, + server_default=self.server_default + ) + + def get_column_type(self) -> sqlalchemy.types.TypeEngine: + raise NotImplementedError() # pragma: no cover + + def get_constraints(self): + return [] + + +class String(BaseField): + __type__ = str + + def __init__(self, *args, **kwargs): + assert 'length' in kwargs, 'length is required' + self.length = kwargs.pop('length') + super().__init__(*args, **kwargs) + + def get_column_type(self): + return sqlalchemy.String(self.length) + + +class Integer(BaseField): + __type__ = int + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def get_column_type(self): + return sqlalchemy.Integer() diff --git a/orm/models.py b/orm/models.py new file mode 100644 index 0000000..605b23a --- /dev/null +++ b/orm/models.py @@ -0,0 +1,69 @@ +from typing import Any + +import sqlalchemy +from pydantic import create_model + +from orm.fields import BaseField + + +class ModelMetaclass(type): + def __new__( + mcs: type, name: str, bases: Any, attrs: dict + ) -> type: + new_model = super().__new__( # type: ignore + mcs, name, bases, attrs + ) + + if attrs.get("__abstract__"): + return new_model + + tablename = attrs["__tablename__"] + metadata = attrs["__metadata__"] + pkname = None + + columns = [] + for field_name, field in new_model.__dict__.items(): + if isinstance(field, BaseField): + if field.primary_key: + pkname = field_name + columns.append(field.get_column(field_name)) + + pydantic_fields = {field_name: (base_field.__type__, base_field.default or ...) + for field_name, base_field in new_model.__dict__.items() + if isinstance(base_field, BaseField)} + + new_model.__table__ = sqlalchemy.Table(tablename, metadata, *columns) + new_model.__columns__ = columns + new_model.__pkname__ = pkname + new_model.__pydantic_fields__ = pydantic_fields + new_model.__pydantic_model__ = create_model(name, **pydantic_fields) + new_model.__fields__ = new_model.__pydantic_model__.__fields__ + + return new_model + + +class Model(metaclass=ModelMetaclass): + __abstract__ = True + + def __init__(self, *args, **kwargs): + if "pk" in kwargs: + kwargs[self.__pkname__] = kwargs.pop("pk") + self.values = self.__pydantic_model__(**kwargs) + + def __setattr__(self, key, value): + if key in self.__fields__: + setattr(self.values, key, value) + super().__setattr__(key, value) + + def __getattribute__(self, item): + if item != '__fields__' and item in self.__fields__: + return getattr(self.values, item) + return super().__getattribute__(item) + + @property + def pk(self): + return getattr(self.values, self.__pkname__) + + @pk.setter + def pk(self, value): + setattr(self.values, self.__pkname__, value) diff --git a/tests/__pycache__/__init__.cpython-38.pyc b/tests/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..b1f8f4d Binary files /dev/null and b/tests/__pycache__/__init__.cpython-38.pyc differ diff --git a/tests/__pycache__/test_columns.cpython-38-pytest-6.0.1.pyc b/tests/__pycache__/test_columns.cpython-38-pytest-6.0.1.pyc new file mode 100644 index 0000000..ca0373f Binary files /dev/null and b/tests/__pycache__/test_columns.cpython-38-pytest-6.0.1.pyc differ diff --git a/tests/__pycache__/test_columns.cpython-38.pyc b/tests/__pycache__/test_columns.cpython-38.pyc new file mode 100644 index 0000000..26c6513 Binary files /dev/null and b/tests/__pycache__/test_columns.cpython-38.pyc differ diff --git a/tests/test_columns.py b/tests/test_columns.py new file mode 100644 index 0000000..c687ace --- /dev/null +++ b/tests/test_columns.py @@ -0,0 +1,52 @@ +import pytest +import sqlalchemy + +import orm.fields as fields +from orm.exceptions import ModelDefinitionError +from orm.models import Model + +metadata = sqlalchemy.MetaData() + + +class ExampleModel(Model): + __tablename__ = "example" + __metadata__ = metadata + test = fields.Integer(primary_key=True) + test2 = fields.String(length=250) + + +class ExampleModel2(Model): + __tablename__ = "example2" + __metadata__ = metadata + test = fields.Integer(name='test12', primary_key=True) + test2 = fields.String('test22', length=250) + + +def test_model_attribute_access(): + example = ExampleModel(test=1, test2='test') + assert example.test == 1 + assert example.test2 == 'test' + + example.test = 12 + assert example.test == 12 + + example.new_attr = 12 + assert 'new_attr' in example.__dict__ + + +def test_primary_key_access_and_setting(): + example = ExampleModel(pk=1, test2='test') + assert example.pk == 1 + example.pk = 2 + + assert example.pk == 2 + assert example.test == 2 + + +def test_wrong_model_definition(): + with pytest.raises(ModelDefinitionError): + class ExampleModel2(Model): + __tablename__ = "example3" + __metadata__ = metadata + test = fields.Integer(name='test12', primary_key=True) + test2 = fields.String('test22', name='test22', length=250)