From 4e7e1626aae66ed1045540e96750318d5d8f581d Mon Sep 17 00:00:00 2001 From: long2ice Date: Tue, 12 May 2020 19:33:21 +0800 Subject: [PATCH] add ddl --- Makefile | 19 +++ alice/__init__.py | 8 +- alice/backends/__init__.py | 129 +++++++++++++++--- alice/backends/mysql/__init__.py | 22 +-- poetry.lock | 55 +++++++- pyproject.toml | 1 + tests/__init__.py | 68 --------- tests/backends/__init__.py | 0 .../mysql/__init__.py} | 14 +- tests/backends/mysql/test_ddl.py | 53 +++++++ tests/models.py | 70 ++++++++++ 11 files changed, 318 insertions(+), 121 deletions(-) create mode 100644 Makefile create mode 100644 tests/backends/__init__.py rename tests/{test_backends.py => backends/mysql/__init__.py} (58%) create mode 100644 tests/backends/mysql/test_ddl.py create mode 100644 tests/models.py diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..87f4a0f --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +checkfiles = alice/ examples/ tests/ conftest.py +black_opts = -l 100 -t py38 +py_warn = PYTHONDEVMODE=1 + +help: + @echo "Alice development makefile" + @echo + @echo "usage: make " + @echo "Targets:" + @echo " test Runs all tests" + @echo " style Auto-formats the code" + +deps: + @which pip-sync > /dev/null || pip install -q pip-tools + @pip-sync tests/requirements.txt + +style: deps + isort -rc $(checkfiles) + black $(black_opts) $(checkfiles) \ No newline at end of file diff --git a/alice/__init__.py b/alice/__init__.py index 2003ee1..b6bb958 100644 --- a/alice/__init__.py +++ b/alice/__init__.py @@ -1,9 +1,11 @@ __version__ = '0.1.0' +from alice.cmd import CommandLine -def main(): - pass + +def main(argv): + command = CommandLine(argv) if __name__ == '__main__': - main() + main(None) diff --git a/alice/backends/__init__.py b/alice/backends/__init__.py index 0e149c0..1837876 100644 --- a/alice/backends/__init__.py +++ b/alice/backends/__init__.py @@ -1,36 +1,125 @@ -from typing import Type +from typing import Type, List -from tortoise import Model, BaseDBAsyncClient +from tortoise import Model, BaseDBAsyncClient, ForeignKeyFieldInstance from tortoise.backends.base.schema_generator import BaseSchemaGenerator +from tortoise.fields import Field, UUIDField, TextField, JSONField class DDL: schema_generator_cls: Type[BaseSchemaGenerator] = BaseSchemaGenerator + DIALECT = "sql" + _DROP_TABLE_TEMPLATE = 'DROP TABLE {table_name} IF EXISTS' + _ADD_COLUMN_TEMPLATE = 'ALTER TABLE {table_name} ADD {column}' + _DROP_COLUMN_TEMPLATE = 'ALTER TABLE {table_name} DROP COLUMN {column_name}' + _ADD_INDEX_TEMPLATE = 'ALTER TABLE {table_name} ADD {unique} INDEX {index_name} ({column_names})' + _DROP_INDEX_TEMPLATE = 'ALTER TABLE {table_name} DROP INDEX {index_name}' + _ADD_FK_TEMPLATE = 'ALTER TABLE {table_name} ADD CONSTRAINT `{fk_name}` FOREIGN KEY (`{db_column}`) REFERENCES `{table}` (`{field}`) ON DELETE {on_delete}' + _DROP_FK_TEMPLATE = 'ALTER TABLE {table_name} DROP FOREIGN KEY {fk_name}' - def __init__(self, client: "BaseDBAsyncClient", model: "Type[Model]"): - self.model = model + def __init__(self, client: "BaseDBAsyncClient"): + self.client = client self.schema_generator = self.schema_generator_cls(client) - def create_table(self): - return self.schema_generator._get_table_sql(self.model, True)['table_creation_string'] + def create_table(self, model: "Type[Model]"): + return self.schema_generator._get_table_sql(model, True)['table_creation_string'] - def drop_table(self): - return f'drop table {self.model._meta.db_table}' + def drop_table(self, model: "Type[Model]"): + return self._DROP_TABLE_TEMPLATE.format( + table_name=model._meta.db_table + ) - def add_column(self): - raise NotImplementedError() + def add_column(self, model: "Type[Model]", field_object: Field): + db_table = model._meta.db_table + default = field_object.default + db_column = field_object.model_field_name + auto_now_add = getattr(field_object, "auto_now_add", False) + auto_now = getattr(field_object, "auto_now", False) + if default is not None or auto_now or auto_now_add: + if callable(default) or isinstance(field_object, (UUIDField, TextField, JSONField)): + default = "" + else: + default = field_object.to_db_value(default, model) + try: + default = self.schema_generator._column_default_generator( + db_table, + db_column, + self.schema_generator._escape_default_value(default), + auto_now_add, + auto_now, + ) + except NotImplementedError: + default = "" + else: + default = "" + return self._ADD_COLUMN_TEMPLATE.format( + table_name=db_table, + column=self.schema_generator._create_string( + db_column=field_object.model_field_name, + field_type=field_object.get_for_dialect(self.DIALECT, "SQL_TYPE"), + nullable="NOT NULL" if not field_object.null else "", + unique="UNIQUE" if field_object.unique else "", + comment=self.schema_generator._column_comment_generator( + table=db_table, column=field_object.model_field_name, comment=field_object.description + ) + if field_object.description else "", + is_primary_key=field_object.pk, + default=default, + ) + ) - def drop_column(self): - raise NotImplementedError() + def drop_column(self, model: "Type[Model]", column_name: str): + return self._DROP_COLUMN_TEMPLATE.format( + table_name=model._meta.db_table, + column_name=column_name + ) - def add_index(self): - raise NotImplementedError() + def add_index(self, model: "Type[Model]", field_names: List[str], unique=False): + return self._ADD_INDEX_TEMPLATE.format( + unique='UNIQUE' if unique else '', + index_name=self.schema_generator._generate_index_name("idx" if not unique else "uid", model, + field_names), + table_name=model._meta.db_table, + column_names=", ".join([self.schema_generator.quote(f) for f in field_names]), + ) - def drop_index(self): - raise NotImplementedError() + def drop_index(self, model: "Type[Model]", field_names: List[str], unique=False): + return self._DROP_INDEX_TEMPLATE.format( + index_name=self.schema_generator._generate_index_name("idx" if not unique else "uid", model, + field_names), + table_name=model._meta.db_table, + ) - def add_fk(self): - raise NotImplementedError() + def add_fk(self, model: "Type[Model]", field: ForeignKeyFieldInstance): + db_table = model._meta.db_table + to_field_name = field.to_field_instance.source_field + if not to_field_name: + to_field_name = field.to_field_instance.model_field_name - def drop_fk(self): - raise NotImplementedError() + fk_name = self.schema_generator._generate_fk_name( + from_table=db_table, + from_field=field.model_field_name, + to_table=field.related_model._meta.db_table, + to_field=to_field_name + ) + return self._ADD_FK_TEMPLATE.format( + table_name=db_table, + fk_name=fk_name, + db_column=field.model_field_name, + table=field.related_model._meta.db_table, + field=to_field_name, + on_delete=field.on_delete, + ) + + def drop_fk(self, model: "Type[Model]", field: ForeignKeyFieldInstance): + to_field_name = field.to_field_instance.source_field + if not to_field_name: + to_field_name = field.to_field_instance.model_field_name + return self._DROP_FK_TEMPLATE.format( + table_name=model._meta.db_table, + fk_name=self.schema_generator._generate_fk_name( + from_table=model._meta.db_table, + from_field=field.model_field_name, + to_table=field.related_model._meta.db_table, + to_field=to_field_name + ) + ) diff --git a/alice/backends/mysql/__init__.py b/alice/backends/mysql/__init__.py index 8857045..8963d5e 100644 --- a/alice/backends/mysql/__init__.py +++ b/alice/backends/mysql/__init__.py @@ -5,24 +5,4 @@ from alice.backends import DDL class MysqlDDL(DDL): schema_generator_cls = MySQLSchemaGenerator - - def drop_table(self): - pass - - def add_column(self): - pass - - def drop_column(self): - pass - - def add_index(self): - pass - - def drop_index(self): - pass - - def add_fk(self): - pass - - def drop_fk(self): - pass + DIALECT = "mysql" diff --git a/poetry.lock b/poetry.lock index eb5db17..c69d758 100644 --- a/poetry.lock +++ b/poetry.lock @@ -67,6 +67,19 @@ idna = ["idna (>=2.1)"] pep8test = ["flake8", "flake8-import-order", "pep8-naming"] test = ["pytest (>=3.6.0,<3.9.0 || >3.9.0,<3.9.1 || >3.9.1,<3.9.2 || >3.9.2)", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,<3.79.2 || >3.79.2)"] +[[package]] +category = "dev" +description = "the modular source code checker: pep8 pyflakes and co" +name = "flake8" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +version = "3.8.1" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.6.0a1,<2.7.0" +pyflakes = ">=2.2.0,<2.3.0" + [[package]] category = "main" description = "Simple module to parse ISO 8601 dates" @@ -76,6 +89,22 @@ optional = false python-versions = "*" version = "0.1.12" +[[package]] +category = "dev" +description = "McCabe checker, plugin for flake8" +name = "mccabe" +optional = false +python-versions = "*" +version = "0.6.1" + +[[package]] +category = "dev" +description = "Python style guide checker" +name = "pycodestyle" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.6.0" + [[package]] category = "main" description = "C parser in Python" @@ -84,6 +113,14 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.20" +[[package]] +category = "dev" +description = "passive checker of Python programs" +name = "pyflakes" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.2.0" + [[package]] category = "main" description = "Pure Python MySQL Driver" @@ -154,7 +191,7 @@ python-versions = "*" version = "3.7.4.2" [metadata] -content-hash = "e8e49dcc243fcd3a31fd60ee5a637af7b265c4fea2078a48d2bd7f10794fe323" +content-hash = "0b4c3c6eb6ed2e84b03745542ebc42dc53e6e5466bea0c4eee991bcd063a6ef3" python-versions = "^3.8" [metadata.files] @@ -224,15 +261,31 @@ cryptography = [ {file = "cryptography-2.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5"}, {file = "cryptography-2.9.2.tar.gz", hash = "sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229"}, ] +flake8 = [ + {file = "flake8-3.8.1-py2.py3-none-any.whl", hash = "sha256:6c1193b0c3f853ef763969238f6c81e9e63ace9d024518edc020d5f1d6d93195"}, + {file = "flake8-3.8.1.tar.gz", hash = "sha256:ea6623797bf9a52f4c9577d780da0bb17d65f870213f7b5bcc9fca82540c31d5"}, +] iso8601 = [ {file = "iso8601-0.1.12-py2.py3-none-any.whl", hash = "sha256:210e0134677cc0d02f6028087fee1df1e1d76d372ee1db0bf30bf66c5c1c89a3"}, {file = "iso8601-0.1.12-py3-none-any.whl", hash = "sha256:bbbae5fb4a7abfe71d4688fd64bff70b91bbd74ef6a99d964bab18f7fdf286dd"}, {file = "iso8601-0.1.12.tar.gz", hash = "sha256:49c4b20e1f38aa5cf109ddcd39647ac419f928512c869dc01d5c7098eddede82"}, ] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +pycodestyle = [ + {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, + {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, +] pycparser = [ {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, ] +pyflakes = [ + {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, + {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, +] pymysql = [ {file = "PyMySQL-0.9.2-py2.py3-none-any.whl", hash = "sha256:95f057328357e0e13a30e67857a8c694878b0175797a9a203ee7adbfb9b1ec5f"}, {file = "PyMySQL-0.9.2.tar.gz", hash = "sha256:9ec760cbb251c158c19d6c88c17ca00a8632bac713890e465b2be01fdc30713f"}, diff --git a/pyproject.toml b/pyproject.toml index 2cac0dc..6855f8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ aiomysql = "*" [tool.poetry.dev-dependencies] taskipy = "*" asynctest = "*" +flake8 = "*" [build-system] requires = ["poetry>=0.12"] diff --git a/tests/__init__.py b/tests/__init__.py index a28af5b..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,68 +0,0 @@ -import datetime -from enum import IntEnum -from tortoise import fields, Model - - -class ProductType(IntEnum): - article = 1 - page = 2 - - -class PermissionAction(IntEnum): - create = 1 - delete = 2 - update = 3 - read = 4 - - -class Status(IntEnum): - on = 1 - off = 0 - - -class User(Model): - username = fields.CharField(max_length=20, unique=True) - password = fields.CharField(max_length=200) - last_login = fields.DatetimeField(description='Last Login', default=datetime.datetime.now) - is_active = fields.BooleanField(default=True, description='Is Active') - is_superuser = fields.BooleanField(default=False, description='Is SuperUser') - avatar = fields.CharField(max_length=200, default='') - intro = fields.TextField(default='') - created_at = fields.DatetimeField(auto_now_add=True) - - def __str__(self): - return f'{self.pk}#{self.username}' - - -class Category(Model): - slug = fields.CharField(max_length=200) - name = fields.CharField(max_length=200) - created_at = fields.DatetimeField(auto_now_add=True) - - def __str__(self): - return f'{self.pk}#{self.name}' - - -class Product(Model): - categories = fields.ManyToManyField('models.Category') - name = fields.CharField(max_length=50) - view_num = fields.IntField(description='View Num') - sort = fields.IntField() - is_reviewed = fields.BooleanField(description='Is Reviewed') - type = fields.IntEnumField(ProductType, description='Product Type') - image = fields.CharField(max_length=200) - body = fields.TextField() - created_at = fields.DatetimeField(auto_now_add=True) - - def __str__(self): - return f'{self.pk}#{self.name}' - - -class Config(Model): - label = fields.CharField(max_length=200) - key = fields.CharField(max_length=20) - value = fields.JSONField() - status: Status = fields.IntEnumField(Status, default=Status.on) - - def __str__(self): - return f'{self.pk}#{self.label}' diff --git a/tests/backends/__init__.py b/tests/backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_backends.py b/tests/backends/mysql/__init__.py similarity index 58% rename from tests/test_backends.py rename to tests/backends/mysql/__init__.py index b2e873b..cbd71a8 100644 --- a/tests/test_backends.py +++ b/tests/backends/mysql/__init__.py @@ -2,28 +2,26 @@ from asynctest import TestCase from tortoise import Tortoise from alice.backends.mysql import MysqlDDL -from tests import User +from tests.models import Category TORTOISE_ORM = { 'connections': { - 'default': 'mysql://root:123456@127.0.0.1:3306/fastapi-admin' + 'default': 'mysql://root:123456@127.0.0.1:3306/test' }, 'apps': { 'models': { - 'models': ['tests'], + 'models': ['tests.models'], 'default_connection': 'default', } } } -class TestMysql(TestCase): +class DBTestCase(TestCase): async def setUp(self) -> None: await Tortoise.init(config=TORTOISE_ORM) - - async def test_create_table(self): - ddl = MysqlDDL(Tortoise.get_connection('default'), User) - print(ddl.create_table()) + self.client = Tortoise.get_connection('default') + self.ddl = MysqlDDL(self.client) async def tearDown(self) -> None: await Tortoise.close_connections() diff --git a/tests/backends/mysql/test_ddl.py b/tests/backends/mysql/test_ddl.py new file mode 100644 index 0000000..91932a7 --- /dev/null +++ b/tests/backends/mysql/test_ddl.py @@ -0,0 +1,53 @@ +from tests.backends.mysql import DBTestCase +from tests.models import Category, User + + +class TestDDL(DBTestCase): + def test_create_table(self): + ret = self.ddl.create_table(Category) + self.assertEqual( + ret, """CREATE TABLE IF NOT EXISTS `category` ( + `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT, + `slug` VARCHAR(200) NOT NULL, + `name` VARCHAR(200) NOT NULL, + `created_at` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `user_id` INT NOT NULL COMMENT 'User', + CONSTRAINT `fk_category_user_e2e3874c` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE +) CHARACTER SET utf8mb4;""") + + def test_drop_table(self): + ret = self.ddl.drop_table(Category) + self.assertEqual(ret, "DROP TABLE category IF EXISTS") + + def test_add_column(self): + ret = self.ddl.add_column(Category, Category._meta.fields_map.get('name')) + self.assertEqual(ret, "ALTER TABLE category ADD `name` VARCHAR(200) NOT NULL") + + def test_drop_column(self): + ret = self.ddl.drop_column(Category, 'name') + self.assertEqual(ret, "ALTER TABLE category DROP COLUMN name") + + def test_add_index(self): + ret = self.ddl.add_index(Category, ['name']) + self.assertEqual(ret, "ALTER TABLE category ADD INDEX idx_category_name_8b0cb9 (`name`)") + ret = self.ddl.add_index(Category, ['name'], True) + self.assertEqual(ret, "ALTER TABLE category ADD UNIQUE INDEX uid_category_name_8b0cb9 (`name`)") + + def test_drop_index(self): + ret = self.ddl.drop_index(Category, ['name']) + self.assertEqual(ret, "ALTER TABLE category DROP INDEX idx_category_name_8b0cb9") + ret = self.ddl.drop_index(Category, ['name'], True) + self.assertEqual(ret, "ALTER TABLE category DROP INDEX uid_category_name_8b0cb9") + + def test_add_fk(self): + ret = self.ddl.add_fk(Category, Category._meta.fields_map.get('user')) + self.assertEqual(ret, + "ALTER TABLE category ADD CONSTRAINT `fk_category_user_366ffa6f` FOREIGN KEY (`user`) REFERENCES `user` (`id`) ON DELETE CASCADE") + + def test_drop_fk(self): + ret = self.ddl.drop_fk(Category, Category._meta.fields_map.get('user')) + self.assertEqual(ret, "ALTER TABLE category DROP FOREIGN KEY fk_category_user_366ffa6f") + + async def test_aa(self): + user = await User.get(username='test') + await user.save() diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 0000000..74cb04b --- /dev/null +++ b/tests/models.py @@ -0,0 +1,70 @@ +import datetime +from enum import IntEnum +from tortoise import fields, Model + + +class ProductType(IntEnum): + article = 1 + page = 2 + + +class PermissionAction(IntEnum): + create = 1 + delete = 2 + update = 3 + read = 4 + + +class Status(IntEnum): + on = 1 + off = 0 + + +class User(Model): + username = fields.CharField(max_length=20, unique=True) + password = fields.CharField(max_length=200) + last_login = fields.DatetimeField(description='Last Login', default=datetime.datetime.now) + is_active = fields.BooleanField(default=True, description='Is Active') + is_superuser = fields.BooleanField(default=False, description='Is SuperUser') + avatar = fields.CharField(max_length=200, default='') + intro = fields.TextField(default='') + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + def __str__(self): + return f'{self.pk}#{self.username}' + + +class Category(Model): + slug = fields.CharField(max_length=200) + name = fields.CharField(max_length=200) + user = fields.ForeignKeyField('models.User', description='User') + created_at = fields.DatetimeField(auto_now_add=True) + + def __str__(self): + return f'{self.pk}#{self.name}' + + +class Product(Model): + categories = fields.ManyToManyField('models.Category') + name = fields.CharField(max_length=50) + view_num = fields.IntField(description='View Num') + sort = fields.IntField() + is_reviewed = fields.BooleanField(description='Is Reviewed') + type = fields.IntEnumField(ProductType, description='Product Type') + image = fields.CharField(max_length=200) + body = fields.TextField() + created_at = fields.DatetimeField(auto_now_add=True) + + def __str__(self): + return f'{self.pk}#{self.name}' + + +class Config(Model): + label = fields.CharField(max_length=200) + key = fields.CharField(max_length=20) + value = fields.JSONField() + status: Status = fields.IntEnumField(Status, default=Status.on) + + def __str__(self): + return f'{self.pk}#{self.label}'