├── setup.cfg ├── tests ├── urls.py ├── conftest.py ├── __init__.py ├── models.py ├── settings.py └── test_migrate_comment.py ├── django_comment_migrate ├── backends │ ├── __init__.py │ ├── postgresql.py │ ├── base.py │ ├── mysql.py │ └── mssql.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── migratecomment.py ├── __init__.py ├── config.py ├── apps.py ├── db_comments.py └── utils.py ├── MANIFEST.in ├── .gitignore ├── .github └── workflows │ └── release.yml ├── setup.py ├── tox.ini ├── manage.py ├── .travis.yml ├── LICENSE ├── README-zh_CN.rst └── README.rst /setup.cfg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] 2 | -------------------------------------------------------------------------------- /django_comment_migrate/backends/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_comment_migrate/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_comment_migrate/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_comment_migrate/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'django_comment_migrate.apps.DjangoCommentMigrationConfig' 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include django_comment_migrate *.html *.png *.gif *js *.css *jpg *jpeg *svg *py 3 | recursive-exclude tests * -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_configure(): 2 | try: 3 | import django 4 | django.setup() 5 | except AttributeError: 6 | pass 7 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | import pymysql 3 | 4 | pymysql.version_info = (1, 4, 13, "final", 0) 5 | pymysql.install_as_MySQLdb() 6 | except ImportError: 7 | pass 8 | -------------------------------------------------------------------------------- /django_comment_migrate/config.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | class DCMConfig: 5 | defaults = { 6 | "DCM_COMMENT_KEY": "help_text", 7 | "DCM_TABLE_COMMENT_KEY": "verbose_name", 8 | "DCM_BACKEND": None, 9 | "DCM_COMMENT_APP": [] 10 | } 11 | 12 | def __getattr__(self, name): 13 | if name in self.defaults: 14 | return getattr(settings, name, self.defaults[name]) 15 | 16 | 17 | dcm_config = DCMConfig() 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Virtualenvs 26 | .venv 27 | 28 | # Unit test / coverage reports 29 | .coverage 30 | .tox 31 | nosetests.xml 32 | htmlcov 33 | 34 | # Translations 35 | *.mo 36 | 37 | # Mr Developer 38 | .mr.developer.cfg 39 | .project 40 | .pydevproject 41 | 42 | # Pycharm/Intellij 43 | .idea 44 | 45 | # Complexity 46 | output/*.html 47 | output/*/index.html 48 | 49 | # Sphinx 50 | docs/_build 51 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth import models as auth_models 3 | 4 | 5 | class CommentModel(models.Model): 6 | no_comment = models.TextField() 7 | aaa = models.IntegerField( 8 | default=0, help_text="test default", verbose_name="verbose name is aaa" 9 | ) 10 | email = models.CharField(max_length=40, help_text="this is help text") 11 | json_help_text = models.CharField(max_length=40, help_text="{'A', 'B'}") 12 | 13 | class Meta: 14 | app_label = "tests" 15 | db_table = "user" 16 | verbose_name_plural = "测试自定义表注释key" 17 | 18 | 19 | class AnotherUserModel(auth_models.AbstractBaseUser): 20 | class Meta: 21 | app_label = "tests" 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | deploy: 10 | name: Publish python package to pypi 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up python 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: '3.x' 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | 26 | - name: Build and publish 27 | env: 28 | TWINE_USERNAME: ${{secrets.PYPI_USERNAME}} 29 | TWINE_PASSWORD: ${{secrets.PYPI_PASSWORD}} 30 | run: | 31 | python setup.py sdist bdist_wheel 32 | twine upload dist/* -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | readme = open('README.rst', encoding='utf-8').read() 4 | 5 | setup( 6 | name='django-comment-migrate', 7 | version='0.1.7', 8 | description="""An app that provides Django model comment migration """, 9 | long_description=readme, 10 | author='starryrbs', 11 | author_email='1322096624@qq.com', 12 | url='https://github.com/starryrbs/django-comment-migrate.git', 13 | keywords='django-comment-migrate', 14 | packages=['django_comment_migrate'], 15 | include_package_data=True, 16 | zip_safe=False, 17 | license='MIT', 18 | install_requires=['django>=2.2'], 19 | python_requires='>=3.6', 20 | classifiers=[ 21 | 'Programming Language :: Python :: 3.6', 22 | 'Programming Language :: Python :: 3.7', 23 | 'Programming Language :: Python :: 3.8', 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | coverage-clean, 4 | py{36,37,38}-django{22,31,32}-{mysql,postgres,mssql}, 5 | coverage-report, 6 | flake8 7 | 8 | [testenv] 9 | commands = coverage run --include='*/django_comment_migrate/*' {envbindir}/django-admin.py test tests 10 | deps = 11 | flake8 12 | django22: django>=2.2.17,<2.3 13 | django30: django>=3.0.0,<3.1 14 | django31: django>=3.1.0,<3.2 15 | psycopg2==2.8.6 16 | mysql: pymysql>=0.10.1 17 | coverage 18 | mssql-django 19 | 20 | setenv = 21 | PYTHONPATH = {toxinidir} 22 | DJANGO_SETTINGS_MODULE=tests.settings 23 | mysql: TEST_DB_ENGINE=mysql 24 | postgres: TEST_DB_ENGINE=postgres 25 | mssql: TEST_DB_ENGINE=mssql 26 | 27 | 28 | [testenv:flake8] 29 | commands = flake8 30 | deps = flake8 31 | 32 | [testenv:coverage-clean] 33 | commands = coverage erase 34 | 35 | [testenv:coverage-report] 36 | commands = 37 | coverage report 38 | coverage html 39 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """Django's command-line utility for administrative tasks. 4 | 5 | This script is needed to recreate test Model migrations. To do that: 6 | 7 | $ python manage.py makemigrations tests 8 | 9 | This is needed in Django 2.2+ because the test Models have ForeignKeys 10 | to Models outside of this app. 11 | """ 12 | 13 | import os 14 | import sys 15 | 16 | 17 | def main(): 18 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') 19 | try: 20 | from django.core.management import execute_from_command_line 21 | except ImportError as exc: 22 | raise ImportError( 23 | "Couldn't import Django. Are you sure it's installed and " 24 | "available on your PYTHONPATH environment variable? Did you " 25 | "forget to activate a virtual environment?" 26 | ) from exc 27 | execute_from_command_line(sys.argv) 28 | 29 | 30 | if __name__ == '__main__': 31 | main() 32 | -------------------------------------------------------------------------------- /django_comment_migrate/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig, apps 2 | from django.contrib.auth import get_user_model 3 | from django.db import DEFAULT_DB_ALIAS 4 | from django.db.models.signals import post_migrate 5 | 6 | from django_comment_migrate.db_comments import migrate_app_models_comment_to_database 7 | from django_comment_migrate.utils import get_migrations_app_models 8 | 9 | 10 | def handle_post_migrate(app_config, using=DEFAULT_DB_ALIAS, **kwargs): 11 | from django.contrib.auth.models import User 12 | 13 | migrations = (migration for migration, rollback in kwargs.get('plan', []) if not rollback) 14 | app_models = get_migrations_app_models(migrations, apps, using) 15 | # another user model is specified instead. 16 | if get_user_model() != User: 17 | app_models -= {User} 18 | migrate_app_models_comment_to_database(app_models, using) 19 | 20 | 21 | class DjangoCommentMigrationConfig(AppConfig): 22 | name = "django_comment_migrate" 23 | 24 | def ready(self): 25 | post_migrate.connect(handle_post_migrate) 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | dist: xenial 3 | 4 | services: 5 | - postgresql 6 | - mysql 7 | - docker 8 | os: 9 | - linux 10 | language: python 11 | 12 | python: 13 | - "3.6" 14 | - "3.7" 15 | - "3.8" 16 | 17 | addons: 18 | apt: 19 | packages: 20 | - unixodbc 21 | - unixodbc-dev 22 | 23 | before_install: 24 | # install MSSQL driver 25 | - sudo bash -c 'curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -' 26 | - sudo bash -c 'curl https://packages.microsoft.com/config/ubuntu/18.04/prod.list > /etc/apt/sources.list.d/mssql-release.list' 27 | - sudo apt-get update 28 | - sudo ACCEPT_EULA=Y apt-get install msodbcsql17 29 | # 启动sqlserver docker容器 30 | - docker run -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD=django321!' -p 1433:1433 -d mcr.microsoft.com/mssql/server:2017-latest 31 | 32 | install: 33 | - pip install tox-travis coveralls 34 | 35 | before_script: 36 | - psql -c 'create database test;' -U postgres 37 | - mysql -e 'CREATE DATABASE IF NOT EXISTS test;' 38 | 39 | script: 40 | - tox 41 | 42 | after_success: 43 | - coveralls -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /django_comment_migrate/backends/postgresql.py: -------------------------------------------------------------------------------- 1 | from psycopg2 import sql 2 | 3 | from django_comment_migrate.backends.base import BaseCommentMigration 4 | from django_comment_migrate.utils import get_field_comment, get_table_comment 5 | 6 | 7 | class CommentMigration(BaseCommentMigration): 8 | comment_sql = sql.SQL("COMMENT ON COLUMN {}.{} IS %s") 9 | table_comment_sql = sql.SQL("COMMENT ON TABLE {} is %s;") 10 | 11 | def comments_sql(self): 12 | results = [] 13 | comments_sql = self._get_fields_comments_sql() 14 | if comments_sql: 15 | results.extend(comments_sql) 16 | table_comment = get_table_comment(self.model) 17 | if table_comment: 18 | results.append( 19 | ( 20 | self.table_comment_sql.format(sql.Identifier(self.db_table)), 21 | [table_comment], 22 | ) 23 | ) 24 | 25 | return results 26 | 27 | def _get_fields_comments_sql(self): 28 | comments_sql = [] 29 | for field in self.model._meta.local_fields: 30 | comment = get_field_comment(field) 31 | if comment: 32 | comment_sql = self.comment_sql.format( 33 | sql.Identifier(self.db_table), sql.Identifier(field.column) 34 | ) 35 | comments_sql.append((comment_sql, [comment])) 36 | return comments_sql 37 | -------------------------------------------------------------------------------- /django_comment_migrate/backends/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | from typing import Type, List, AnyStr, Tuple 3 | 4 | from django.db import transaction 5 | from django.db.models import Model 6 | 7 | 8 | class BaseCommentMigration(metaclass=ABCMeta): 9 | atomic = True 10 | 11 | def __init__(self, connection, model: Type[Model], collect_sql=False): 12 | self.connection = connection 13 | self.model = model 14 | self.collect_sql = collect_sql 15 | if self.collect_sql: 16 | self.collected_sql = [] 17 | 18 | @property 19 | def db_table(self): 20 | return self.model._meta.db_table 21 | 22 | def comments_sql(self) -> List[Tuple[AnyStr, List[AnyStr]]]: 23 | pass 24 | 25 | def migrate_comments_to_database(self): 26 | pass 27 | 28 | def quote_name(self, name): 29 | return self.connection.ops.quote_name(name) 30 | 31 | def execute(self): 32 | if self.atomic: 33 | with transaction.atomic(): 34 | self.execute_sql() 35 | else: 36 | self.connection.needs_rollback = False 37 | self.execute_sql() 38 | 39 | def execute_sql(self): 40 | comments_sql = self.comments_sql() 41 | if comments_sql: 42 | with self.connection.cursor() as cursor: 43 | for comment_sql, params in comments_sql: 44 | cursor.execute(comment_sql, params) 45 | -------------------------------------------------------------------------------- /django_comment_migrate/db_comments.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.conf import settings 4 | from django.db import connections 5 | from django.utils.module_loading import import_string 6 | 7 | from django_comment_migrate.config import dcm_config 8 | 9 | 10 | def get_migration_class_from_engine(engine): 11 | engine_name = engine.split(".")[-1] 12 | if ( 13 | dcm_config.DCM_BACKEND 14 | and isinstance(dcm_config.DCM_BACKEND, dict) 15 | and dcm_config.DCM_BACKEND.get(engine_name) 16 | ): 17 | path = dcm_config.DCM_BACKEND.get(engine_name) 18 | else: 19 | # This backend was renamed in Django 1.9. 20 | if engine_name == "postgresql_psycopg2": 21 | engine_name = "postgresql" 22 | path = f"django_comment_migrate.backends.{engine_name}.CommentMigration" 23 | 24 | return import_string(path) 25 | 26 | 27 | def migrate_app_models_comment_to_database(app_models, using): 28 | engine = settings.DATABASES[using]["ENGINE"] 29 | try: 30 | migration_class = get_migration_class_from_engine(engine) 31 | except ImportError: 32 | warnings.warn( 33 | f"{engine} is not supported by this comment migration" f" backend." 34 | ) 35 | else: 36 | for model in app_models: 37 | 38 | if not model._meta.managed: 39 | continue 40 | 41 | executor = migration_class(connection=connections[using], model=model) 42 | executor.execute() 43 | -------------------------------------------------------------------------------- /django_comment_migrate/utils.py: -------------------------------------------------------------------------------- 1 | from django.db import router, DEFAULT_DB_ALIAS 2 | from django.db.migrations import Migration 3 | from django.db.models import Field 4 | 5 | from django_comment_migrate.config import dcm_config 6 | 7 | 8 | def get_field_comment(field: Field): 9 | 10 | value = getattr(field, dcm_config.DCM_COMMENT_KEY) 11 | if value is not None: 12 | return str(value) 13 | 14 | 15 | def get_table_comment(model): 16 | value = getattr(model._meta, dcm_config.DCM_TABLE_COMMENT_KEY) 17 | if value is not None: 18 | return str(value) 19 | 20 | 21 | def get_migrations_app_models( 22 | migrations: [Migration], apps, using=DEFAULT_DB_ALIAS 23 | ) -> set: 24 | models = set() 25 | from django_comment_migrate.config import dcm_config # noqa 26 | dcm_comment_app = dcm_config.DCM_COMMENT_APP 27 | for migration in migrations: 28 | if not isinstance(migration, Migration): 29 | continue 30 | app_label = migration.app_label 31 | if not router.allow_migrate(using, app_label) or app_label not in dcm_comment_app: 32 | continue 33 | operations = getattr(migration, "operations", []) 34 | for operation in operations: 35 | model_name = getattr(operation, "model_name", None) or getattr( 36 | operation, "name", None 37 | ) 38 | if model_name is None: 39 | continue 40 | try: 41 | model = apps.get_model(app_label, model_name=model_name) 42 | except LookupError: 43 | continue 44 | models.add(model) 45 | return models 46 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals, absolute_import 3 | 4 | import os 5 | 6 | import django 7 | 8 | DEBUG = True 9 | USE_TZ = True 10 | 11 | # SECURITY WARNING: keep the secret key used in production secret! 12 | SECRET_KEY = "k7n0r44#%6oyhawmz$o&mug!y3@25%u&+rg+4^iu0_tekg4jv3" 13 | 14 | test_db_engine = os.environ.get('TEST_DB_ENGINE', 'postgres') 15 | 16 | test_db_engine_to_databases_mapping = { 17 | 'mysql': { 18 | "default": { 19 | "ENGINE": "django.db.backends.mysql", 20 | "NAME": "test", 21 | "USER": "root", 22 | 'OPTIONS': { 23 | 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", 24 | 'charset': 'utf8mb4', 25 | } 26 | } 27 | }, 28 | 'postgres': { 29 | 'default': { 30 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 31 | "NAME": "test", 32 | 'USER': 'postgres', 33 | "PASSWORD": '', 34 | "HOST": '127.0.0.1', 35 | 'PORT': 5432, 36 | }, 37 | }, 38 | 'mssql': { 39 | 'default': { 40 | 'ENGINE': 'mssql', 41 | "NAME": "test", 42 | 'USER': 'sa', 43 | "PASSWORD": 'django321!', 44 | "HOST": '127.0.0.1', 45 | "OPTIONS": { 46 | "driver": "ODBC Driver 17 for SQL Server", 47 | "isolation_level": "READ UNCOMMITTED", 48 | }, 49 | 50 | } 51 | } 52 | } 53 | 54 | DATABASES = test_db_engine_to_databases_mapping[test_db_engine] 55 | 56 | ROOT_URLCONF = "tests.urls" 57 | 58 | INSTALLED_APPS = [ 59 | "django.contrib.auth", 60 | "django.contrib.contenttypes", 61 | "django.contrib.sites", 62 | "django_comment_migrate", 63 | "tests" 64 | ] 65 | 66 | if django.VERSION >= (1, 10): 67 | MIDDLEWARE = () 68 | else: 69 | MIDDLEWARE_CLASSES = () 70 | -------------------------------------------------------------------------------- /django_comment_migrate/backends/mysql.py: -------------------------------------------------------------------------------- 1 | from django_comment_migrate.backends.base import BaseCommentMigration 2 | from django_comment_migrate.utils import get_field_comment, get_table_comment 3 | 4 | 5 | class CommentMigration(BaseCommentMigration): 6 | atomic = False 7 | sql_alter_column = "ALTER TABLE `%(table)s` %(changes)s" 8 | sql_alter_column_comment_null = ( 9 | "MODIFY COLUMN %(column)s %(type)s NULL" " COMMENT %(comment)s" 10 | ) 11 | sql_alter_column_comment_not_null = ( 12 | "MODIFY COLUMN %(column)s %(type)s " "NOT NULL COMMENT %(comment)s" 13 | ) 14 | sql_alter_table_comment = "ALTER TABLE `%(table)s` COMMENT %(comment)s" 15 | 16 | def comments_sql(self): 17 | changes = [] 18 | params = [] 19 | for field in self.model._meta.fields: 20 | comment = get_field_comment(field) 21 | if comment: 22 | db_parameters = field.db_parameters(connection=self.connection) 23 | sql = ( 24 | self.sql_alter_column_comment_null 25 | if field.null 26 | else self.sql_alter_column_comment_not_null 27 | ) 28 | changes.append( 29 | sql 30 | % { 31 | "column": self.quote_name(field.column), 32 | "type": db_parameters["type"], 33 | "comment": "%s", 34 | } 35 | ) 36 | params.append(comment) 37 | results = [] 38 | if changes: 39 | results.append( 40 | ( 41 | self.sql_alter_column 42 | % {"table": self.db_table, "changes": ",".join(changes)}, 43 | params, 44 | ), 45 | ) 46 | table_comment = get_table_comment(self.model) 47 | if table_comment: 48 | results.append( 49 | ( 50 | self.sql_alter_table_comment % {"table": self.db_table, "comment": "%s"}, 51 | [get_table_comment(self.model)], 52 | ), 53 | ) 54 | return results 55 | -------------------------------------------------------------------------------- /django_comment_migrate/management/commands/migratecomment.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from django.apps import apps 4 | from django.core.management import BaseCommand 5 | from django.db import DEFAULT_DB_ALIAS, router 6 | 7 | from django_comment_migrate.db_comments import migrate_app_models_comment_to_database 8 | 9 | 10 | class Command(BaseCommand): 11 | def add_arguments(self, parser): 12 | parser.add_argument( 13 | '--database', default=DEFAULT_DB_ALIAS, 14 | help='Nominates a database to migrate.' 15 | ' Defaults to the "default" database.', 16 | ) 17 | parser.add_argument( 18 | 'app_label', nargs='?', 19 | help='App labels of applications to limit the migrate comment' 20 | ) 21 | 22 | def handle(self, *args, **options): 23 | using = options['database'] 24 | app_label = options['app_label'] 25 | if app_label: 26 | app_configs = self.filter_valid_app_configs([app_label]) 27 | else: 28 | app_configs = self.load_app_configs(using) 29 | 30 | for app_config in app_configs: 31 | migrate_app_models_comment_to_database(app_config.get_models(), using) 32 | self.stdout.write(self.style.SUCCESS( 33 | f"migrate app {app_config.label} successful")) 34 | 35 | def load_app_configs(self, using): 36 | migrated_apps = set() 37 | for app_config in apps.get_app_configs(): 38 | app_label = app_config.label 39 | if router.allow_migrate(using, app_label): 40 | migrated_apps.add(app_config) 41 | else: 42 | self.stdout.write(f"app {app_label} not allow migration") 43 | return migrated_apps 44 | 45 | def filter_valid_app_configs(self, app_names): 46 | has_bad_names = False 47 | migrated_apps = set() 48 | for app_name in app_names: 49 | try: 50 | migrated_apps.add(apps.get_app_config(app_name)) 51 | except LookupError as error: 52 | self.stderr.write(error) 53 | has_bad_names = True 54 | if has_bad_names: 55 | # 2 代表误用了命令 56 | sys.exit(2) 57 | return migrated_apps 58 | -------------------------------------------------------------------------------- /README-zh_CN.rst: -------------------------------------------------------------------------------- 1 | Django Comment Migrate 2 | ====================== 3 | 4 | |Build| |https://pypi.org/project/django-comment-migrate/| 5 | 6 | 这是一个Django model注释迁移的app 7 | 8 | `English <./README.rst>`__ \| 简体中文 9 | 10 | 特性 11 | ---- 12 | 13 | - 自动化迁移model的字段的help\_text到注释【支持自定义】 14 | - 自动化迁移model的verbose_name到表注释【支持自定义】 15 | - 提供一个命令去迁移指定的app的注释 16 | 17 | 例子 18 | ---- 19 | 20 | 1. 下载python包:: 21 | 22 | pip install django-comment-migrate 23 | 24 | 2. 添加 django\_comment\_migrate app 25 | 26 | project/project/settings.py: 27 | 28 | .. code:: python 29 | 30 | INSTALLED_APPS =[ 31 | "django_comment_migrate", 32 | ... 33 | ] 34 | 35 | 3. 添加 model 36 | 37 | project/app/model.py 38 | 39 | .. code:: python 40 | 41 | from django.db import models 42 | 43 | class CommentModel(models.Model): 44 | no_comment = models.TextField() 45 | aaa = models.IntegerField(default=0, help_text="test default") 46 | help_text = models.CharField(max_length=40, 47 | help_text="this is help text") 48 | 49 | class Meta: 50 | app_label = 'tests' 51 | db_table = 'comment_model' 52 | verbose_name = '这是表注释' 53 | 54 | 4. 添加app 55 | 56 | project/app/settings.py 57 | 58 | .. code:: python 59 | 60 | DCM_COMMENT_APP=["app"] 61 | 62 | 5. 执行数据库迁移:: 63 | 64 | python manage.py makemigrations 65 | python manage.py migrate 66 | 67 | 现在检查数据库的table,注释已经迁移了。 68 | 69 | 自定义配置 70 | -------------------- 71 | 72 | 在 settings.py:: 73 | 74 | DCM_COMMENT_KEY='verbose_name' #注释字段,默认是help_text 75 | DCM_TABLE_COMMENT_KEY='verbose_name' # 表注释字段 76 | DCM_BACKEND={ # 如果自定义了数据的engine,可以使用该配置 77 | "my-engine": "django_comment_migrate.backends.mysql.CommentMigration" 78 | } 79 | DCM_COMMENT_APP=["app"] # 如果不配置则默认生成所有表的注释 80 | 81 | 82 | Command 83 | ------- 84 | 85 | 这里提供了一个命令,可以重新生成指定app的注释:: 86 | 87 | python manage.py migratecomment [app_label] 88 | 89 | 这条命令需要在执行所有迁移文件后执行 90 | 91 | 运行测试 92 | -------- 93 | 94 | 1. Install Tox:: 95 | 96 | pip install tox 97 | 98 | 2. Run:: 99 | 100 | tox 101 | 102 | 支持的数据库 103 | ------------ 104 | 105 | - MySQL 106 | - PostgreSQL 107 | - Microsoft SQL Server 108 | 109 | .. |Build| image:: https://travis-ci.org/starryrbs/django-comment-migrate.svg?branch=master 110 | .. |https://pypi.org/project/django-comment-migrate/| image:: https://img.shields.io/pypi/v/django-comment-migrate 111 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django Comment Migrate 2 | ====================== 3 | 4 | |Build| |https://pypi.org/project/django-comment-migrate/| 5 | 6 | An app that provides Django model comment migration 7 | 8 | English \| `简体中文 <./README-zh_CN.rst>`__ 9 | 10 | Feature 11 | ------- 12 | 13 | - Automatic migration model help\_text to comment [Support customization] 14 | - Automatically migrate the verbose_name of the model to the table comment [Support customization] 15 | - Provide a command to migrate the comment of the specified app 16 | 17 | Examples 18 | -------- 19 | 20 | 1. download python package:: 21 | 22 | pip install django-comment-migrate 23 | 24 | 2. add django\_comment\_migrate app 25 | 26 | project/project/settings.py 27 | 28 | .. code:: python 29 | 30 | INSTALLED_APPS =[ 31 | "django_comment_migrate", 32 | ... 33 | ] 34 | 35 | 3. add model 36 | 37 | project/app/model.py 38 | 39 | .. code:: python 40 | 41 | from django.db import models 42 | 43 | class CommentModel(models.Model): 44 | no_comment = models.TextField() 45 | aaa = models.IntegerField(default=0, help_text="test default") 46 | help_text = models.CharField(max_length=40, 47 | help_text="this is help text") 48 | 49 | class Meta: 50 | app_label = 'tests' 51 | db_table = 'comment_model' 52 | verbose_name = 'It is Comment Table' 53 | 54 | 4. add app 55 | 56 | project/app/settings.py 57 | 58 | .. code:: python 59 | 60 | DCM_COMMENT_APP=["app"] 61 | 62 | 5. execute database migrate:: 63 | 64 | python manage.py makemigrations 65 | python manage.py migrate 66 | 67 | Now check the database table, comments have been generated. 68 | 69 | Custom config 70 | --------------- 71 | 72 | In settings.py:: 73 | 74 | DCM_COMMENT_KEY='verbose_name' 75 | DCM_TABLE_COMMENT_KEY='verbose_name' 76 | DCM_BACKEND={ 77 | "new-engine": "engine.path" 78 | } 79 | DCM_COMMENT_APP=["app"] 80 | 81 | Command 82 | ------- 83 | 84 | Provides a comment migration command, which allows the database to 85 | regenerate comments:: 86 | 87 | python manage.py migratecomment [app_label] 88 | 89 | The command needs to be executed after all migrations are executed 90 | 91 | Running the tests 92 | ----------------- 93 | 94 | 1. Install Tox:: 95 | 96 | pip install tox 97 | 98 | 2. Run:: 99 | 100 | tox 101 | 102 | Supported Database 103 | ------------------ 104 | 105 | - MySQL 106 | - PostgreSQL 107 | - Microsoft SQL Server 108 | 109 | .. |Build| image:: https://travis-ci.org/starryrbs/django-comment-migrate.svg?branch=master 110 | .. |https://pypi.org/project/django-comment-migrate/| image:: https://img.shields.io/pypi/v/django-comment-migrate 111 | -------------------------------------------------------------------------------- /django_comment_migrate/backends/mssql.py: -------------------------------------------------------------------------------- 1 | from django_comment_migrate.backends.base import BaseCommentMigration 2 | from django_comment_migrate.utils import get_field_comment, get_table_comment 3 | 4 | 5 | class CommentMigration(BaseCommentMigration): 6 | sql_has_comment = ( 7 | "SELECT NULL FROM SYS.EXTENDED_PROPERTIES " 8 | "WHERE [major_id] = OBJECT_ID( %s ) " 9 | "AND [name] = N'MS_DESCRIPTION' " 10 | "AND [minor_id] = " 11 | "( SELECT [column_id] " 12 | "FROM SYS.COLUMNS WHERE [name] = %s " 13 | "AND [object_id] = OBJECT_ID( %s ) )" 14 | ) 15 | sql_alter_column_comment = ( 16 | "EXEC sys.sp_updateextendedproperty " 17 | "@name = N'MS_Description'," 18 | "@value = %s, " 19 | "@level0type = N'SCHEMA'," 20 | "@level0name = N'dbo', " 21 | "@level1type = N'TABLE'," 22 | "@level1name = %s, " 23 | "@level2type = N'COLUMN'," 24 | "@level2name = %s" 25 | ) 26 | sql_add_column_comment = ( 27 | "EXEC sys.sp_addextendedproperty " 28 | "@name = N'MS_Description'," 29 | "@value = %s, " 30 | "@level0type = N'SCHEMA'," 31 | "@level0name = N'dbo', " 32 | "@level1type = N'TABLE'," 33 | "@level1name = %s, " 34 | "@level2type = N'COLUMN'," 35 | "@level2name = %s" 36 | ) 37 | 38 | sql_has_table_comment = "SELECT NULL FROM SYS.EXTENDED_PROPERTIES WHERE [major_id]=OBJECT_ID(%s) and minor_id=0" 39 | sql_add_table_comment = ( 40 | "EXEC sys.sp_addextendedproperty " 41 | "@name = N'MS_Description'," 42 | "@value = %s, " 43 | "@level0type = N'SCHEMA'," 44 | "@level0name = N'dbo', " 45 | "@level1type = N'TABLE'," 46 | "@level1name = %s" 47 | ) 48 | sql_alter_table_comment = ( 49 | "EXEC sys.sp_updateextendedproperty " 50 | "@name = N'MS_Description'," 51 | "@value = %s, " 52 | "@level0type = N'SCHEMA'," 53 | "@level0name = N'dbo', " 54 | "@level1type = N'TABLE'," 55 | "@level1name = %s" 56 | ) 57 | 58 | def comments_sql(self): 59 | results = [] 60 | changes = self._get_fields_comments_sql() 61 | if changes: 62 | results.extend(changes) 63 | table_comment = get_table_comment(self.model) 64 | if table_comment: 65 | results.append(self._get_table_comment_sql(table_comment)) 66 | return results 67 | 68 | def _get_table_comment_sql(self, table_comment): 69 | with self.connection.cursor() as cursor: 70 | cursor.execute(self.sql_has_table_comment, (self.db_table,)) 71 | sql = ( 72 | self.sql_alter_table_comment 73 | if cursor.fetchone() 74 | else self.sql_add_table_comment 75 | ) 76 | return sql, (table_comment, self.db_table) 77 | 78 | def _get_fields_comments_sql( 79 | self, 80 | ): 81 | changes = [] 82 | for field in self.model._meta.fields: 83 | comment = get_field_comment(field) 84 | if comment: 85 | with self.connection.cursor() as cursor: 86 | cursor.execute( 87 | self.sql_has_comment, 88 | (self.db_table, field.column, self.db_table), 89 | ) 90 | sql = ( 91 | self.sql_alter_column_comment 92 | if cursor.fetchone() 93 | else self.sql_add_column_comment 94 | ) 95 | changes.append( 96 | ( 97 | sql, 98 | (comment, self.db_table, field.column), 99 | ) 100 | ) 101 | return changes 102 | -------------------------------------------------------------------------------- /tests/test_migrate_comment.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from django.apps.registry import Apps 4 | from django.conf import settings 5 | from django.core import management 6 | from django.test import TransactionTestCase, TestCase, override_settings 7 | from django.db import connections, migrations, models 8 | from django.utils.module_loading import import_string 9 | 10 | from django_comment_migrate.db_comments import get_migration_class_from_engine 11 | from django_comment_migrate.utils import get_migrations_app_models 12 | from tests.models import CommentModel 13 | 14 | 15 | class TestDjangoCommentMigration(TestCase): 16 | def test_get_migration_class_from_engine(self): 17 | engine_migration_class_mapping = { 18 | "django.db.backends.mysql": "django_comment_migrate.backends.mysql.CommentMigration", 19 | "django.db.backends.postgresql": "django_comment_migrate.backends.postgresql.CommentMigration", 20 | "django.db.backends.postgresql_psycopg2": "django_comment_migrate.backends.postgresql.CommentMigration", 21 | "mssql": "django_comment_migrate.backends.mssql.CommentMigration", 22 | } 23 | engine = settings.DATABASES["default"]["ENGINE"] 24 | try: 25 | target_migration_class = get_migration_class_from_engine(engine) 26 | except ImportError: 27 | target_migration_class = None 28 | 29 | migration_class_path = engine_migration_class_mapping[engine] 30 | if migration_class_path is None: 31 | migration_class = migration_class_path 32 | else: 33 | migration_class = import_string(migration_class_path) 34 | self.assertEqual(migration_class, target_migration_class) 35 | 36 | def test_get_comments_for_model(self): 37 | engine = settings.DATABASES["default"]["ENGINE"] 38 | migration_class = get_migration_class_from_engine(engine) 39 | from psycopg2 import sql 40 | 41 | postgres_comments_sql = [ 42 | ( 43 | sql.SQL("COMMENT ON COLUMN {}.{} IS %s").format( 44 | sql.Identifier("user"), sql.Identifier("aaa") 45 | ), 46 | ["test default"], 47 | ), 48 | ( 49 | sql.SQL("COMMENT ON COLUMN {}.{} IS %s").format( 50 | sql.Identifier("user"), sql.Identifier("email") 51 | ), 52 | ["this is help text"], 53 | ), 54 | ( 55 | sql.SQL("COMMENT ON COLUMN {}.{} IS %s").format( 56 | sql.Identifier("user"), sql.Identifier("json_help_text") 57 | ), 58 | ["{'A', 'B'}"], 59 | ), 60 | ( 61 | sql.SQL("COMMENT ON TABLE {} is %s;").format(sql.Identifier("user")), 62 | ["comment model"], 63 | ), 64 | ] 65 | mssql_sql = [ 66 | ( 67 | "EXEC sys.sp_addextendedproperty @name = N'MS_Description'," 68 | "@value = %s, @level0type = N'SCHEMA',@level0name = N'dbo', " 69 | "@level1type = N'TABLE',@level1name = %s, @level2type = N'COLUMN',@level2name = %s", 70 | ("test default", "user", "aaa"), 71 | ), 72 | ( 73 | "EXEC sys.sp_addextendedproperty @name = N'MS_Description'," 74 | "@value = %s, @level0type = N'SCHEMA',@level0name = N'dbo', " 75 | "@level1type = N'TABLE',@level1name = %s, @level2type = N'COLUMN',@level2name = %s", 76 | ("this is help text", "user", "email"), 77 | ), 78 | ( 79 | "EXEC sys.sp_addextendedproperty @name = N'MS_Description'," 80 | "@value = %s, @level0type = N'SCHEMA',@level0name = N'dbo', " 81 | "@level1type = N'TABLE',@level1name = %s, @level2type = N'COLUMN',@level2name = %s", 82 | ("{'A', 'B'}", "user", "json_help_text"), 83 | ), 84 | ( 85 | "EXEC sys.sp_addextendedproperty @name = N'MS_Description'," 86 | "@value = %s, @level0type = N'SCHEMA',@level0name = N'dbo', " 87 | "@level1type = N'TABLE',@level1name = %s", 88 | ("comment model", "user"), 89 | ), 90 | ] 91 | engine_sql_mapping = { 92 | "django.db.backends.mysql": [ 93 | ( 94 | "ALTER TABLE user " 95 | "MODIFY COLUMN `aaa` integer " 96 | "NOT NULL " 97 | "COMMENT %s," 98 | "MODIFY COLUMN `email` " 99 | "varchar(40) NOT NULL " 100 | "COMMENT %s," 101 | "MODIFY COLUMN `json_help_text` " 102 | "varchar(40) NOT NULL " 103 | "COMMENT %s", 104 | ["test default", "this is help text", "{'A', 'B'}"], 105 | ), 106 | ("ALTER TABLE user COMMENT %s", ["comment model"]), 107 | ], 108 | "django.db.backends.postgresql_psycopg2": postgres_comments_sql, 109 | "django.db.backends.postgresql": postgres_comments_sql, 110 | "mssql": mssql_sql, 111 | } 112 | 113 | sql = migration_class( 114 | model=CommentModel, connection=connections["default"] 115 | ).comments_sql() 116 | target_sql = engine_sql_mapping[engine] 117 | self.assertEqual(sql, target_sql) 118 | 119 | @override_settings( 120 | DCM_COMMENT_KEY="verbose_name", DCM_TABLE_COMMENT_KEY="verbose_name_plural" 121 | ) 122 | def test_custom_comment_key(self): 123 | engine = settings.DATABASES["default"]["ENGINE"] 124 | migration_class = get_migration_class_from_engine(engine) 125 | sql = migration_class( 126 | model=CommentModel, connection=connections["default"] 127 | ).comments_sql() 128 | self.assertIn("verbose name is aaa", str(sql)) 129 | self.assertIn("测试自定义表注释key", str(sql)) 130 | 131 | @override_settings( 132 | DCM_BACKEND={ 133 | "new-engine": "django_comment_migrate.backends.mssql.CommentMigration" 134 | } 135 | ) 136 | def test_custom_backend(self): 137 | migration_class = get_migration_class_from_engine("new-engine") 138 | self.assertEqual( 139 | migration_class, 140 | import_string("django_comment_migrate.backends.mssql.CommentMigration"), 141 | ) 142 | 143 | 144 | class TestCommand(TestCase): 145 | def test_migrate_command_with_app_label(self): 146 | out = io.StringIO() 147 | management.call_command("migratecomment", app_label="tests", stdout=out) 148 | self.assertIn( 149 | "migrate app tests successful", 150 | out.getvalue(), 151 | ) 152 | 153 | def test_migrate_command_without_app_label(self): 154 | out = io.StringIO() 155 | management.call_command("migratecomment", stdout=out) 156 | self.assertIn("migrate app tests successful", out.getvalue()) 157 | 158 | 159 | class TestCommandWithAnotherCustomUser(TransactionTestCase): 160 | def test_migrate_command_with_custom_auth_user(self): 161 | # rollback migrations auth and related 162 | management.call_command( 163 | "migrate", app_label="contenttypes", migration_name="zero" 164 | ) 165 | management.call_command("migrate", app_label="auth", migration_name="zero") 166 | with self.settings(AUTH_USER_MODEL="tests.AnotherUserModel"): 167 | out = io.StringIO() 168 | # migrate auth and related again in customize auth_user_model context 169 | management.call_command("migrate", app_label="auth") 170 | management.call_command("migrate", app_label="contenttypes") 171 | management.call_command("migratecomment", stdout=out) 172 | self.assertIn("migrate app tests successful", out.getvalue()) 173 | 174 | 175 | class TestUtil(TestCase): 176 | def test_get_migrations_app_models(self): 177 | new_apps = Apps(["tests"]) 178 | 179 | # 添加用于测试的models 180 | class Author(models.Model): 181 | name = models.CharField(max_length=255) 182 | bio = models.TextField() 183 | age = models.IntegerField(blank=True, null=True) 184 | 185 | class Meta: 186 | app_label = "tests" 187 | apps = new_apps 188 | 189 | class Book(models.Model): 190 | title = models.CharField(max_length=1000) 191 | author = models.ForeignKey(Author, models.CASCADE) 192 | contributors = models.ManyToManyField(Author) 193 | 194 | class Meta: 195 | app_label = "tests" 196 | apps = new_apps 197 | 198 | # 添加测试的migrations 199 | class FieldMigration(migrations.Migration): 200 | operations = [ 201 | migrations.AddField( 202 | model_name="book", 203 | name="new_field", 204 | field=models.CharField(max_length=10), 205 | ) 206 | ] 207 | 208 | class ModelMigration(migrations.Migration): 209 | operations = [migrations.CreateModel(name="Author", fields=[])] 210 | 211 | class MigrationWithNoOperation(migrations.Migration): 212 | pass 213 | 214 | test_migrations = [ 215 | FieldMigration("test_migration_00", "tests"), 216 | MigrationWithNoOperation("test_migration_01", "tests"), 217 | ModelMigration("test_migration_02", "tests"), 218 | migrations.RunPython(lambda x: x), 219 | migrations.AddIndex("author", models.Index(name="aa", fields=["id"])), 220 | ] 221 | 222 | app_models = get_migrations_app_models(test_migrations, new_apps) 223 | self.assertSequenceEqual( 224 | sorted(list(app_models), key=lambda x: str(x)), 225 | sorted([Book, Author], key=lambda x: str(x)), 226 | ) 227 | --------------------------------------------------------------------------------