From 96ec33fe16590dff3a51d3bf6eea237b182c33b8 Mon Sep 17 00:00:00 2001 From: collerek Date: Mon, 3 Aug 2020 08:17:30 +0200 Subject: [PATCH] attribute access and setting for pydantic_model uned the hood --- .coverage | Bin 0 -> 53248 bytes README.md | 15 ++-- orm/__pycache__/__init__.cpython-38.pyc | Bin 0 -> 128 bytes orm/__pycache__/exceptions.cpython-38.pyc | Bin 0 -> 438 bytes orm/__pycache__/fields.cpython-38.pyc | Bin 0 -> 2613 bytes orm/__pycache__/models.cpython-38.pyc | Bin 0 -> 2536 bytes orm/exceptions.py | 6 ++ orm/fields.py | 73 ++++++++++++++++++ orm/models.py | 69 +++++++++++++++++ tests/__pycache__/__init__.cpython-38.pyc | Bin 0 -> 130 bytes .../test_columns.cpython-38-pytest-6.0.1.pyc | Bin 0 -> 3459 bytes tests/__pycache__/test_columns.cpython-38.pyc | Bin 0 -> 670 bytes tests/test_columns.py | 52 +++++++++++++ 13 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 .coverage create mode 100644 orm/__pycache__/__init__.cpython-38.pyc create mode 100644 orm/__pycache__/exceptions.cpython-38.pyc create mode 100644 orm/__pycache__/fields.cpython-38.pyc create mode 100644 orm/__pycache__/models.cpython-38.pyc create mode 100644 orm/exceptions.py create mode 100644 orm/fields.py create mode 100644 orm/models.py create mode 100644 tests/__pycache__/__init__.cpython-38.pyc create mode 100644 tests/__pycache__/test_columns.cpython-38-pytest-6.0.1.pyc create mode 100644 tests/__pycache__/test_columns.cpython-38.pyc create mode 100644 tests/test_columns.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..922eaa1c16bc5e9ad0f3557871e19de633a21510 GIT binary patch literal 53248 zcmeI)+l$=R9S3k}q|tcxc2dkTbryHeOFdqDXT9T1i|b%or}cx|K#A8Rp`KRJXwK}) z8A)SFv)=8Mbqf9i3VrQML;r+Aq4YhpFCm4}hXhhc14$`?5(o})e?ML3V(%;=FUfAc zVppTfIY;Mw&PAhSN2( z7OUFYQ*vHBugzJ%)x=NcUlfhGSLZLx{&Q}p`kUE5RBl&$v;z|aAOHafKp+*^e{QZ) zJ9SFG_lv}7_f!%&u8Okx{C96$yL$7Qym|GRXRpa@n>;%sX=*g&RT+hMWnV?I>-CiM zf{y1pi5G0iWLp)X2eIn#8IQKnIgbObrSW;m_Bs?RQCpNmKk|Gh+LJG2G$TG_+w< zlfnLgD=EoY-8AU!IW*$6`+@;I~VZl0z$cLuLX`X|D-zM_g1HaFe zLou=gC*ic1)8Zy!Uf54vHwV<^sJs)^S&(y$X0Zl=cW0o6mA@)SF;~-K1mUXWLQo6% z`m9y^)=6EXR;hO5J3V^iWjli;OxJCye7i}rxnFRPl zH;0Qe`=_i*ZD~orS52EyUR(J~WugfcHF>}=TI}+b6Az+dAUSN1EMkmC$)bzMu679+&vPJW&YVGU@`4oCrRB_WMqEgb z%U12_#c3JmEZ9w+Td&+GrCGjTN%egi^!eYI$TDvkW%&ncud(CwXf)|NUPN7%#^CG> zAUR;ip}=pSUjDVc1hn zkew1J;Psde<;5~R^y4R^Z;#T5_c)EMoqC#;;+(>x`%UU=MYo~_-*#67Y{W0 z!vp~cKmY;|fB*y_009U<00Izz!0{6>^palT>wi;xRTG=y8F7XdFhKwU5P$##AOHaf zKmY;|fB*!(L;`cBaat5lMyx++7>gVHWWdGsi|dW`OO5pnxqj(#^P871ZitexRLk$H ze`D?9T66!LZl1n(y?7+zR{i^zTdjT+-d1iBw^~lT7q|_2-kvsYT-s5=POH`P+N~Bn zvZoq-$9#0TWxdg$Cj+if^wA23i-)U?uH*F@1A4|R zy3$-B>nW&4+g@@wr;?-kS#V8B}AOHafKmY;|fB*y_009U< zK-22Rrhf31^~CkRb>6sjNVtjX|MKB!4zK^s72}0N(j2=+H!c|Nl#mBr|Ci1gx2gx1 z>apv8{Qe)h0R$ib0SG_<0uX=z1Rwwb2teRi3mEj6z&zjo*Tu&g{b7Ot1Rwwb2tWV= z5P$##AOHafK;Re(7{+`x{rlWuK>PAE0`bv0SG_<0uX=z1Rwwb2tWV= z5cq-!ELf&q{hIZoU+( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d8d4b7aaa909737d54fac940d004045fd209070 GIT binary patch literal 128 zcmWIL<>g`kf=RJz@gVv!h(HF6K#l_t7qb9~6oz01O-8?!3`HPe1o2DN%POXzC_gJT uxuiHIvA8lXSvS8ZHwJ{`<1_OzOXB183My}L*yQG?l;)(`fwX@HVg>*mlN&n# literal 0 HcmV?d00001 diff --git a/orm/__pycache__/exceptions.cpython-38.pyc b/orm/__pycache__/exceptions.cpython-38.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2515eefefe82c35a623377c3459a4582803970f7 GIT binary patch literal 438 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b1f8f4d9f4595a84cd83d906b354bf5ad18de23c GIT binary patch literal 130 zcmWIL<>g`kg4wZZ@gVv!h(HF6K#l_t7qb9~6oz01O-8?!3`HPe1o2DV%POXzC_gJT yxuiHIvA8lXSvS8ZH>M=D7|4&0&&?$ zMoo%+p*JrGVfjY&ZD+iKbtp`@7$^3<$2fumR4z}0WBSqKVx&5HeI zF(~S>9haKrpiGHyH!3|69QcUv9M3-{JTJ|hLdhr7VLnm@=rUU*D`L)_;@cpv5^ zUWWG(PH&THWee;g^HrK1x!K|(zAhG{Bub-Jazo0Ng!#OlZ=l%d&jw}=9(5jw&`t#w zr-3cNA>k~$+~W2V8h9`+pn)*a^jVSQxhGaXmU&Ds za6b1SnuV-98qGQgnQ+!K2Thx*-`TXX97aPruz{Ja*QE=LMC<0IkHev(W^@GSjsiIZ z&xjWls%Lk2fm%@A2Pk^WA|!=_uJdnoNrx=cF6Fdqb!f+0vATB0R^O)9hRt&wdy(=y z&M#WM9Cy;(M!rW@9Q7%zI~~%a@(UQbI7%#7$1&|u{kD3PyFb|Dq?^Md-pNH2#`)BP z)j}uVqmSq}P-;2SrSRCWKUwy{5^L0wr&NuD{J<7}A6tHfbz{r#df%5~3omvEqy5+v zcUkn)h(-HE2W(CTHa(?s?Il}P}2lD22El%K^kIipUI9_rLNY&CWiAQsjZd*yB?WpD9v}L2!Y==^0C0J$)t5KXri2@95 zfrQGAF?FxC8gnhmmg3ez7;8w)3QUEI0ySin_jD8ncR+TSEdtPmDHCh$I7~vk9dzvF zDedXN;;N^^niX+t6UnJj=He(bZ5X5Z(kOE!7*Q}e%G5_d3h^mBp#uOHfrxdiqlMK} zu&~h(N*xCQ^&t=f$m;+Mc<{`V5~+{@?+kd$=}S1|mXUy&9C6^A!Hk>%O*>Ez(R7G< zzrf-Af1)1uN07$fg*30}ntnD8|2C*(`RATG{1+m=z(EAGYN{}A;MUrO`xRY+(b zUoxw`VvhI;n1tdz`wtM%UfEIqx(MVOsO&8yCxL9irAXxKK&nMk!sR5& z`__nMs%BUvyJ|#Uf$w%y1Ojvc9^D6^Gb!1CyShie8>a-?Qn-7eEwxhnk=-RZ1BO|U{(RRF%Go^z5OfjRQXfbG`gO=p>Xj4%6Wrfyw zS$*gNF3}AZuWPYDhOv2Nr5 zt!fmF9*xg-dvrY>q!ix8Fey%ntP>*3#eXZ6 dPl