├── .bumpversion.cfg ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.rst ├── migrate_sql ├── __init__.py ├── autodetector.py ├── config.py ├── graph.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── makemigrations.py └── operations.py ├── requirements-testing.txt ├── requirements.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── manage.py ├── test_app │ ├── __init__.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── migrations_change │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20151224_1827.py │ │ └── __init__.py │ ├── migrations_deps_delete │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20160106_0947.py │ │ ├── 0003_auto_20160108_0048.py │ │ ├── 0004_auto_20160108_0048.py │ │ └── __init__.py │ ├── migrations_deps_update │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20160106_0947.py │ │ └── __init__.py │ ├── migrations_recreate │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20151224_1827.py │ │ ├── 0003_auto_20160106_1038.py │ │ └── __init__.py │ ├── models.py │ ├── sql_config.py │ ├── test_migrations.py │ ├── test_utils.py │ └── urls.py ├── test_app2 │ ├── __init__.py │ ├── apps.py │ ├── migrations │ │ └── __init__.py │ ├── migrations_deps_delete │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20160108_0041.py │ │ └── __init__.py │ ├── migrations_deps_update │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── sql_config.py │ └── urls.py └── test_project │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.4.0 3 | files = migrate_sql/__init__.py 4 | tag = True 5 | commit = True 6 | 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | db.sqlite3 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # PyBuilder 58 | target/ 59 | 60 | # local settings 61 | settings_local.py 62 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | matrix: 4 | fast_finish: true 5 | include: 6 | - python: "3.6" 7 | env: TOX_ENV=py36-django21 8 | - python: "3.6" 9 | env: TOX_ENV=py36-django22 10 | 11 | - python: "3.7" 12 | env: TOX_ENV=py37-django22 13 | - python: "3.7" 14 | env: TOX_ENV=py37-django30 15 | 16 | services: 17 | - postgresql 18 | 19 | before_install: 20 | - pip install codecov 21 | 22 | install: 23 | - pip install tox 24 | 25 | before_script: 26 | - psql -c 'create database migrate_sql_test_db;' -U postgres 27 | 28 | script: 29 | - tox -e $TOX_ENV 30 | 31 | after_success: 32 | - codecov -e TOX_ENV 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Bogdan Klichuk 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-migrate-sql-deux 2 | ======================= 3 | 4 | .. note:: 5 | 6 | This package is a fork of the ``django-migrate-sql`` package, originally published 7 | by Bogdan Klichuk. This package appears unmaintained, so we decided to start a fork 8 | as we depended on it. Most of the code is from him. 9 | 10 | |Build Status| |codecov.io| 11 | 12 | Django Migrations support for raw SQL. 13 | 14 | About 15 | ----- 16 | 17 | This tool implements mechanism for managing changes to custom SQL 18 | entities (functions, types, indices, triggers) using built-in migration 19 | mechanism. Technically creates a sophistication layer on top of the 20 | ``RunSQL`` Django operation. 21 | 22 | What it does 23 | ------------ 24 | 25 | - Makes maintaining your SQL functions, custom composite types, indices 26 | and triggers easier. 27 | - Structures SQL into configuration of **SQL items**, that are 28 | identified by names and divided among apps, just like models. 29 | - Automatically gathers and persists changes of your custom SQL into 30 | migrations using ``makemigrations``. 31 | - Properly executes backwards/forwards keeping integrity of database. 32 | - Create -> Drop -> Recreate approach for changes to items that do not 33 | support altering and require dropping and recreating. 34 | - Dependencies system for SQL items, which solves the problem of 35 | updating items, that rely on others (for example custom 36 | types/functions that use other custom types), and require dropping 37 | all dependency tree previously with further recreation. 38 | 39 | What it does not 40 | ---------------- 41 | 42 | - Does not parse SQL nor validate queries during ``makemigrations`` or 43 | ``migrate`` because is database-agnostic. For this same reason 44 | setting up proper dependencies is user's responsibility. 45 | - Does not create ``ALTER`` queries for items that support this, for 46 | example ``ALTER TYPE`` in Postgre SQL, because is database-agnostic. 47 | In case your tools allow rolling all the changes through ``ALTER`` 48 | queries, you can consider not using this app **or** restructure 49 | migrations manually after creation by nesting generated operations 50 | into ```state_operations`` of 51 | ``RunSQL`` `__ 52 | that does ``ALTER``. 53 | - (**TODO**)During ``migrate`` does not restore full state of items for 54 | analysis, thus does not notify about existing changes to schema that 55 | are not migrated **nor** does not recognize circular dependencies 56 | during migration execution. 57 | 58 | Installation 59 | ------------ 60 | 61 | Install from PyPi: 62 | 63 | :: 64 | 65 | $ pip install django-migrate-sql-deux 66 | 67 | Add ``migrate_sql`` to ``INSTALLED_APPS``: 68 | 69 | .. code:: python 70 | 71 | INSTALLED_APPS = [ 72 | # ... 73 | 'migrate_sql', 74 | ] 75 | 76 | App defines a custom ``makemigrations`` command, that inherits from 77 | Django's core one, so in order ``migrate_sql`` app to kick in put it 78 | after any other apps that redefine ``makemigrations`` command too. 79 | 80 | Usage 81 | ----- 82 | 83 | 1) Create ``sql_config.py`` module to root of a target app you want to 84 | manage custom SQL for. 85 | 86 | 2) Define SQL items in it (``sql_items``), for example: 87 | 88 | .. code:: python 89 | 90 | # PostgreSQL example. 91 | # Let's define a simple function and let `migrate_sql` manage it's changes. 92 | 93 | from migrate_sql.config import SQLItem 94 | 95 | sql_items = [ 96 | SQLItem( 97 | 'make_sum', # name of the item 98 | 'create or replace function make_sum(a int, b int) returns int as $$ ' 99 | 'begin return a + b; end; ' 100 | '$$ language plpgsql;', # forward sql 101 | reverse_sql='drop function make_sum(int, int);', # sql for removal 102 | ), 103 | ] 104 | 105 | 3) Create migration ``./manage.py makemigrations``: 106 | 107 | :: 108 | 109 | Migrations for 'app_name': 110 | 0002_auto_xxxx.py: 111 | - Create SQL "make_sum" 112 | 113 | You can take a look at content this generated: 114 | 115 | .. code:: python 116 | 117 | # -*- coding: utf-8 -*- 118 | from __future__ import unicode_literals 119 | from django.db import migrations, models 120 | import migrate_sql.operations 121 | 122 | 123 | class Migration(migrations.Migration): 124 | dependencies = [ 125 | ('app_name', '0001_initial'), 126 | ] 127 | operations = [ 128 | migrate_sql.operations.CreateSQL( 129 | name='make_sum', 130 | sql='create or replace function make_sum(a int, b int) returns int as $$ begin return a + b; end; $$ language plpgsql;', 131 | reverse_sql='drop function make_sum(int, int);', 132 | ), 133 | ] 134 | 135 | 4) Execute migration ``./manage.py migrate``: 136 | 137 | :: 138 | 139 | Operations to perform: 140 | Apply all migrations: app_name 141 | Running migrations: 142 | Rendering model states... DONE 143 | Applying app_name.0002_xxxx... OK 144 | 145 | Check result in ``./manage.py dbshell``: 146 | 147 | :: 148 | 149 | db_name=# select make_sum(12, 15); 150 | make_sum 151 | ---------- 152 | 27 153 | (1 row) 154 | 155 | Now, say, you want to change the function implementation so that it 156 | takes a custom type as argument: 157 | 158 | 5) Edit your ``sql_config.py``: 159 | 160 | .. code:: python 161 | 162 | # PostgreSQL example #2. 163 | # Function and custom type. 164 | 165 | from migrate_sql.config import SQLItem 166 | 167 | sql_items = [ 168 | SQLItem( 169 | 'make_sum', # name of the item 170 | 'create or replace function make_sum(a mynum, b mynum) returns mynum as $$ ' 171 | 'begin return (a.num + b.num, 'result')::mynum; end; ' 172 | '$$ language plpgsql;', # forward sql 173 | reverse_sql='drop function make_sum(mynum, mynum);', # sql for removal 174 | # depends on `mynum` since takes it as argument. we won't be able to drop function 175 | # without dropping `mynum` first. 176 | dependencies=[('app_name', 'mynum')], 177 | ), 178 | SQLItem( 179 | 'mynum' # name of the item 180 | 'create type mynum as (num int, name varchar(20));', # forward sql 181 | reverse_sql='drop type mynum;', # sql for removal 182 | ), 183 | ] 184 | 185 | 6) Generate migration ``./manage.py makemigrations``: 186 | 187 | :: 188 | 189 | Migrations for 'app_name': 190 | 0003_xxxx: 191 | - Reverse alter SQL "make_sum" 192 | - Create SQL "mynum" 193 | - Alter SQL "make_sum" 194 | - Alter SQL state "make_sum" 195 | 196 | You can take a look at the content this generated: 197 | 198 | .. code:: python 199 | 200 | # -*- coding: utf-8 -*- 201 | from __future__ import unicode_literals 202 | from django.db import migrations, models 203 | import migrate_sql.operations 204 | 205 | 206 | class Migration(migrations.Migration): 207 | dependencies = [ 208 | ('app_name', '0002_xxxx'), 209 | ] 210 | operations = [ 211 | migrate_sql.operations.ReverseAlterSQL( 212 | name='make_sum', 213 | sql='drop function make_sum(int, int);', 214 | reverse_sql='create or replace function make_sum(a int, b int) returns int as $$ begin return a + b; end; $$ language plpgsql;', 215 | ), 216 | migrate_sql.operations.CreateSQL( 217 | name='mynum', 218 | sql='create type mynum as (num int, name varchar(20));', 219 | reverse_sql='drop type mynum;', 220 | ), 221 | migrate_sql.operations.AlterSQL( 222 | name='make_sum', 223 | sql='create or replace function make_sum(a mynum, b mynum) returns mynum as $$ begin return (a.num + b.num, \'result\')::mynum; end; $$ language plpgsql;', 224 | reverse_sql='drop function make_sum(mynum, mynum);', 225 | ), 226 | migrate_sql.operations.AlterSQLState( 227 | name='make_sum', 228 | add_dependencies=(('app_name', 'mynum'),), 229 | ), 230 | ] 231 | 232 | ***NOTE:** Previous function is completely dropped before creation 233 | because definition of it changed. ``CREATE OR REPLACE`` would create 234 | another version of it, so ``DROP`` makes it clean.* 235 | 236 | ***If you put ``replace=True`` as kwarg to an ``SQLItem`` definition, it 237 | will NOT drop + create it, but just rerun forward SQL, which is 238 | ``CREATE OR REPLACE`` in this example.*** 239 | 240 | 7) Execute migration ``./manage.py migrate``: 241 | 242 | :: 243 | 244 | Operations to perform: 245 | Apply all migrations: app_name 246 | Running migrations: 247 | Rendering model states... DONE 248 | Applying brands.0003_xxxx... OK 249 | 250 | Check results: 251 | 252 | :: 253 | 254 | db_name=# select make_sum((5, 'a')::mynum, (3, 'b')::mynum); 255 | make_sum 256 | ------------ 257 | (8,result) 258 | (1 row) 259 | 260 | db_name=# select make_sum(12, 15); 261 | ERROR: function make_sum(integer, integer) does not exist 262 | LINE 1: select make_sum(12, 15); 263 | ^ 264 | HINT: No function matches the given name and argument types. You might need to add explicit type casts. 265 | 266 | For more examples see ``tests``. 267 | 268 | Feel free to `open new 269 | issues `__. 270 | 271 | .. |Build Status| image:: https://travis-ci.org/klichukb/django-migrate-sql.svg?branch=master 272 | :target: https://travis-ci.org/klichukb/django-migrate-sql 273 | .. |codecov.io| image:: https://img.shields.io/codecov/c/github/klichukb/django-migrate-sql/master.svg 274 | :target: https://codecov.io/github/klichukb/django-migrate-sql?branch=master 275 | -------------------------------------------------------------------------------- /migrate_sql/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.4.0' 2 | -------------------------------------------------------------------------------- /migrate_sql/autodetector.py: -------------------------------------------------------------------------------- 1 | from django.db.migrations.autodetector import MigrationAutodetector as DjangoMigrationAutodetector 2 | from django.db.migrations.operations import RunSQL 3 | from django.utils.datastructures import OrderedSet 4 | 5 | from migrate_sql.operations import (AlterSQL, ReverseAlterSQL, CreateSQL, DeleteSQL, AlterSQLState) 6 | from migrate_sql.graph import SQLStateGraph 7 | 8 | 9 | class SQLBlob(object): 10 | pass 11 | 12 | # Dummy object used to identify django dependency as the one used by this tool only. 13 | SQL_BLOB = SQLBlob() 14 | 15 | 16 | def _sql_params(sql): 17 | """ 18 | Identify `sql` as either SQL string or 2-tuple of SQL and params. 19 | Same format as supported by Django's RunSQL operation for sql/reverse_sql. 20 | """ 21 | params = None 22 | if isinstance(sql, (list, tuple)): 23 | elements = len(sql) 24 | if elements == 2: 25 | sql, params = sql 26 | else: 27 | raise ValueError("Expected a 2-tuple but got %d" % elements) 28 | return sql, params 29 | 30 | 31 | def is_sql_equal(sqls1, sqls2): 32 | """ 33 | Find out equality of two SQL items. 34 | 35 | See https://docs.djangoproject.com/en/1.8/ref/migration-operations/#runsql. 36 | Args: 37 | sqls1, sqls2: SQL items, have the same format as supported by Django's RunSQL operation. 38 | Returns: 39 | (bool) `True` if equal, otherwise `False`. 40 | """ 41 | is_seq1 = isinstance(sqls1, (list, tuple)) 42 | is_seq2 = isinstance(sqls2, (list, tuple)) 43 | 44 | if not is_seq1: 45 | sqls1 = (sqls1,) 46 | if not is_seq2: 47 | sqls2 = (sqls2,) 48 | 49 | if len(sqls1) != len(sqls2): 50 | return False 51 | 52 | for sql1, sql2 in zip(sqls1, sqls2): 53 | sql1, params1 = _sql_params(sql1) 54 | sql2, params2 = _sql_params(sql2) 55 | if sql1 != sql2 or params1 != params2: 56 | return False 57 | return True 58 | 59 | 60 | def get_ancestors(node): 61 | """Logic extracted from Django <2.2 as this is dropped in later versions.""" 62 | if '_ancestors' not in node.__dict__: 63 | ancestors = [] 64 | for parent in sorted(node.parents, reverse=True): 65 | ancestors += get_ancestors(parent) 66 | ancestors.append(node.key) 67 | node.__dict__['_ancestors'] = list(OrderedSet(ancestors)) 68 | return node.__dict__['_ancestors'] 69 | 70 | 71 | def get_descendants(node): 72 | """Logic extracted from Django <2.2 as this is dropped in later versions.""" 73 | if '_descendants' not in node.__dict__: 74 | descendants = [] 75 | for child in sorted(node.children, reverse=True): 76 | descendants += get_descendants(child) 77 | descendants.append(node.key) 78 | node.__dict__['_descendants'] = list(OrderedSet(descendants)) 79 | return node.__dict__['_descendants'] 80 | 81 | 82 | class MigrationAutodetector(DjangoMigrationAutodetector): 83 | """ 84 | Substitutes Django's MigrationAutodetector class, injecting SQL migrations logic. 85 | """ 86 | def __init__(self, from_state, to_state, questioner=None, to_sql_graph=None): 87 | super(MigrationAutodetector, self).__init__(from_state, to_state, questioner) 88 | self.to_sql_graph = to_sql_graph 89 | self.from_sql_graph = getattr(self.from_state, 'sql_state', None) or SQLStateGraph() 90 | self.from_sql_graph.build_graph() 91 | self._sql_operations = [] 92 | 93 | def assemble_changes(self, keys, resolve_keys, sql_state): 94 | """ 95 | Accepts keys of SQL items available, sorts them and adds additional dependencies. 96 | Uses graph of `sql_state` nodes to build `keys` and `resolve_keys` into sequence that 97 | starts with leaves (items that have not dependents) and ends with roots. 98 | 99 | Changes `resolve_keys` argument as dependencies are added to the result. 100 | 101 | Args: 102 | keys (list): List of migration keys, that are one of create/delete operations, and 103 | dont require respective reverse operations. 104 | resolve_keys (list): List of migration keys, that are changing existing items, 105 | and may require respective reverse operations. 106 | sql_sate (graph.SQLStateGraph): State of SQL items. 107 | Returns: 108 | (list) Sorted sequence of migration keys, enriched with dependencies. 109 | """ 110 | result_keys = [] 111 | all_keys = keys | resolve_keys 112 | for key in all_keys: 113 | node = sql_state.node_map[key] 114 | sql_item = sql_state.nodes[key] 115 | ancs = get_ancestors(node)[:-1] 116 | ancs.reverse() 117 | pos = next((i for i, k in enumerate(result_keys) if k in ancs), len(result_keys)) 118 | result_keys.insert(pos, key) 119 | 120 | if key in resolve_keys and not sql_item.replace: 121 | # ancestors() and descendants() include key itself, need to cut it out. 122 | descs = reversed(get_descendants(node)[:-1]) 123 | for desc in descs: 124 | if desc not in all_keys and desc not in result_keys: 125 | result_keys.insert(pos, desc) 126 | # these items added may also need reverse operations. 127 | resolve_keys.add(desc) 128 | return result_keys 129 | 130 | def add_sql_operation(self, app_label, sql_name, operation, dependencies): 131 | """ 132 | Add SQL operation and register it to be used as dependency for further 133 | sequential operations. 134 | """ 135 | deps = [(dp[0], SQL_BLOB, dp[1], self._sql_operations.get(dp)) for dp in dependencies] 136 | 137 | self.add_operation(app_label, operation, dependencies=deps) 138 | self._sql_operations[(app_label, sql_name)] = operation 139 | 140 | def _generate_reversed_sql(self, keys, changed_keys): 141 | """ 142 | Generate reversed operations for changes, that require full rollback and creation. 143 | """ 144 | for key in keys: 145 | if key not in changed_keys: 146 | continue 147 | app_label, sql_name = key 148 | old_item = self.from_sql_graph.nodes[key] 149 | new_item = self.to_sql_graph.nodes[key] 150 | if not old_item.reverse_sql or old_item.reverse_sql == RunSQL.noop or new_item.replace: 151 | continue 152 | 153 | # migrate backwards 154 | operation = ReverseAlterSQL(sql_name, old_item.reverse_sql, reverse_sql=old_item.sql) 155 | sql_deps = [n.key for n in self.from_sql_graph.node_map[key].children] 156 | sql_deps.append(key) 157 | self.add_sql_operation(app_label, sql_name, operation, sql_deps) 158 | 159 | def _generate_sql(self, keys, changed_keys): 160 | """ 161 | Generate forward operations for changing/creating SQL items. 162 | """ 163 | for key in reversed(keys): 164 | app_label, sql_name = key 165 | new_item = self.to_sql_graph.nodes[key] 166 | sql_deps = [n.key for n in self.to_sql_graph.node_map[key].parents] 167 | reverse_sql = new_item.reverse_sql 168 | 169 | if key in changed_keys: 170 | operation_cls = AlterSQL 171 | kwargs = {} 172 | # in case of replace mode, AlterSQL will hold sql, reverse_sql and 173 | # state_reverse_sql, the latter one will be used for building state forward 174 | # instead of reverse_sql. 175 | if new_item.replace: 176 | kwargs['state_reverse_sql'] = reverse_sql 177 | reverse_sql = self.from_sql_graph.nodes[key].sql 178 | else: 179 | operation_cls = CreateSQL 180 | kwargs = {'dependencies': list(sql_deps)} 181 | 182 | operation = operation_cls( 183 | sql_name, new_item.sql, reverse_sql=reverse_sql, **kwargs) 184 | sql_deps.append(key) 185 | self.add_sql_operation(app_label, sql_name, operation, sql_deps) 186 | 187 | def _generate_altered_sql_dependencies(self, dep_changed_keys): 188 | """ 189 | Generate forward operations for changing/creating SQL item dependencies. 190 | 191 | Dependencies are only in-memory and should be reflecting database dependencies, so 192 | changing them in SQL config does not alter database. Such actions are persisted in separate 193 | type operation - `AlterSQLState`. 194 | 195 | Args: 196 | dep_changed_keys (list): Data about keys, that have their dependencies changed. 197 | List of tuples (key, removed depndencies, added_dependencies). 198 | """ 199 | for key, removed_deps, added_deps in dep_changed_keys: 200 | app_label, sql_name = key 201 | operation = AlterSQLState(sql_name, add_dependencies=tuple(added_deps), 202 | remove_dependencies=tuple(removed_deps)) 203 | sql_deps = [key] 204 | self.add_sql_operation(app_label, sql_name, operation, sql_deps) 205 | 206 | def _generate_delete_sql(self, delete_keys): 207 | """ 208 | Generate forward delete operations for SQL items. 209 | """ 210 | for key in delete_keys: 211 | app_label, sql_name = key 212 | old_node = self.from_sql_graph.nodes[key] 213 | operation = DeleteSQL(sql_name, old_node.reverse_sql, reverse_sql=old_node.sql) 214 | sql_deps = [n.key for n in self.from_sql_graph.node_map[key].children] 215 | sql_deps.append(key) 216 | self.add_sql_operation(app_label, sql_name, operation, sql_deps) 217 | 218 | def generate_sql_changes(self): 219 | """ 220 | Starting point of this tool, which identifies changes and generates respective 221 | operations. 222 | """ 223 | from_keys = set(self.from_sql_graph.nodes.keys()) 224 | to_keys = set(self.to_sql_graph.nodes.keys()) 225 | new_keys = to_keys - from_keys 226 | delete_keys = from_keys - to_keys 227 | changed_keys = set() 228 | dep_changed_keys = [] 229 | 230 | for key in from_keys & to_keys: 231 | old_node = self.from_sql_graph.nodes[key] 232 | new_node = self.to_sql_graph.nodes[key] 233 | 234 | # identify SQL changes -- these will alter database. 235 | if not is_sql_equal(old_node.sql, new_node.sql): 236 | changed_keys.add(key) 237 | 238 | # identify dependencies change 239 | old_deps = self.from_sql_graph.dependencies[key] 240 | new_deps = self.to_sql_graph.dependencies[key] 241 | removed_deps = old_deps - new_deps 242 | added_deps = new_deps - old_deps 243 | if removed_deps or added_deps: 244 | dep_changed_keys.append((key, removed_deps, added_deps)) 245 | 246 | # we do basic sort here and inject dependency keys here. 247 | # operations built using these keys will properly set operation dependencies which will 248 | # enforce django to build/keep a correct order of operations (stable_topological_sort). 249 | keys = self.assemble_changes(new_keys, changed_keys, self.to_sql_graph) 250 | delete_keys = self.assemble_changes(delete_keys, set(), self.from_sql_graph) 251 | 252 | self._sql_operations = {} 253 | self._generate_reversed_sql(keys, changed_keys) 254 | self._generate_sql(keys, changed_keys) 255 | self._generate_delete_sql(delete_keys) 256 | self._generate_altered_sql_dependencies(dep_changed_keys) 257 | 258 | def check_dependency(self, operation, dependency): 259 | """ 260 | Enhances default behavior of method by checking dependency for matching operation. 261 | """ 262 | if isinstance(dependency[1], SQLBlob): 263 | # NOTE: we follow the sort order created by `assemble_changes` so we build a fixed chain 264 | # of operations. thus we should match exact operation here. 265 | return dependency[3] == operation 266 | return super(MigrationAutodetector, self).check_dependency(operation, dependency) 267 | 268 | def generate_altered_fields(self): 269 | """ 270 | Injecting point. This is quite awkward, and i'm looking forward Django for having the logic 271 | divided into smaller methods/functions for easier enhancement and substitution. 272 | So far we're doing all the SQL magic in this method. 273 | """ 274 | result = super(MigrationAutodetector, self).generate_altered_fields() 275 | self.generate_sql_changes() 276 | return result 277 | -------------------------------------------------------------------------------- /migrate_sql/config.py: -------------------------------------------------------------------------------- 1 | class SQLItem(object): 2 | """ 3 | Represents any SQL entity (unit), for example function, type, index or trigger. 4 | """ 5 | def __init__(self, name, sql, reverse_sql=None, dependencies=None, replace=False): 6 | """ 7 | Args: 8 | name (str): Name of the SQL item. Should be unique among other items in the current 9 | application. It is the name that other items can refer to. 10 | sql (str/tuple): Forward SQL that creates entity. 11 | drop_sql (str/tuple, optional): Backward SQL that destroyes entity. (DROPs). 12 | dependencies (list, optional): Collection of item keys, that the current one depends on. 13 | Each element is a tuple of two: (app, item_name). Order does not matter. 14 | replace (bool, optional): If `True`, further migrations will not drop previous version 15 | of item before creating, assuming that a forward SQL replaces. For example Postgre's 16 | `create or replace function` which does not require dropping it previously. 17 | If `False` then each changed item will get two operations: dropping previous version 18 | and creating new one. 19 | Default = `False`. 20 | """ 21 | self.name = name 22 | self.sql = sql 23 | self.reverse_sql = reverse_sql 24 | self.dependencies = dependencies or [] 25 | self.replace = replace 26 | -------------------------------------------------------------------------------- /migrate_sql/graph.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from importlib import import_module 3 | 4 | from django.db.migrations.graph import Node, NodeNotFoundError, CircularDependencyError 5 | from django.conf import settings 6 | from django.apps import apps 7 | 8 | SQL_CONFIG_MODULE = settings.__dict__.get('SQL_CONFIG_MODULE', 'sql_config') 9 | 10 | 11 | class SQLStateGraph(object): 12 | """ 13 | Represents graph assembled by SQL items as nodes and parent-child relations as arcs. 14 | """ 15 | def __init__(self): 16 | self.nodes = {} 17 | self.node_map = {} 18 | self.dependencies = defaultdict(set) 19 | 20 | def remove_node(self, key): 21 | # XXX: Workaround for Issue #2 22 | # Silences state aggregation problem in `migrate` command. 23 | if key in self.nodes and key in self.node_map: 24 | del self.nodes[key] 25 | del self.node_map[key] 26 | 27 | def add_node(self, key, sql_item): 28 | node = Node(key) 29 | self.node_map[key] = node 30 | self.nodes[key] = sql_item 31 | 32 | def add_lazy_dependency(self, child, parent): 33 | """ 34 | Add dependency to be resolved and applied later. 35 | """ 36 | self.dependencies[child].add(parent) 37 | 38 | def remove_lazy_dependency(self, child, parent): 39 | """ 40 | Add dependency to be resolved and applied later. 41 | """ 42 | self.dependencies[child].remove(parent) 43 | 44 | def remove_lazy_for_child(self, child): 45 | """ 46 | Remove dependency to be resolved and applied later. 47 | """ 48 | if child in self.dependencies: 49 | del self.dependencies[child] 50 | 51 | def build_graph(self): 52 | """ 53 | Read lazy dependency list and build graph. 54 | """ 55 | for child, parents in self.dependencies.items(): 56 | if child not in self.nodes: 57 | raise NodeNotFoundError( 58 | "App %s SQL item dependencies reference nonexistent child node %r" % ( 59 | child[0], child), 60 | child 61 | ) 62 | for parent in parents: 63 | if parent not in self.nodes: 64 | raise NodeNotFoundError( 65 | "App %s SQL item dependencies reference nonexistent parent node %r" % ( 66 | child[0], parent), 67 | parent 68 | ) 69 | self.node_map[child].add_parent(self.node_map[parent]) 70 | self.node_map[parent].add_child(self.node_map[child]) 71 | 72 | for node in self.nodes: 73 | self.ensure_not_cyclic(node, 74 | lambda x: (parent.key for parent in self.node_map[x].parents)) 75 | 76 | def ensure_not_cyclic(self, start, get_children): 77 | # Algo from GvR: 78 | # http://neopythonic.blogspot.co.uk/2009/01/detecting-cycles-in-directed-graph.html 79 | todo = set(self.nodes) 80 | while todo: 81 | node = todo.pop() 82 | stack = [node] 83 | while stack: 84 | top = stack[-1] 85 | for node in get_children(top): 86 | if node in stack: 87 | cycle = stack[stack.index(node):] 88 | raise CircularDependencyError(", ".join("%s.%s" % n for n in cycle)) 89 | if node in todo: 90 | stack.append(node) 91 | todo.remove(node) 92 | break 93 | else: 94 | node = stack.pop() 95 | 96 | 97 | def build_current_graph(): 98 | """ 99 | Read current state of SQL items from the current project state. 100 | 101 | Returns: 102 | (SQLStateGraph) Current project state graph. 103 | """ 104 | graph = SQLStateGraph() 105 | for app_name, config in apps.app_configs.items(): 106 | try: 107 | module = import_module( 108 | '.'.join((config.module.__name__, SQL_CONFIG_MODULE))) 109 | sql_items = module.sql_items 110 | except (ImportError, AttributeError): 111 | continue 112 | for sql_item in sql_items: 113 | graph.add_node((app_name, sql_item.name), sql_item) 114 | 115 | for dep in sql_item.dependencies: 116 | graph.add_lazy_dependency((app_name, sql_item.name), dep) 117 | 118 | graph.build_graph() 119 | return graph 120 | -------------------------------------------------------------------------------- /migrate_sql/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/festicket/django-migrate-sql/344ea98a1e7a921d3ad63db237d445f42ab58158/migrate_sql/management/__init__.py -------------------------------------------------------------------------------- /migrate_sql/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/festicket/django-migrate-sql/344ea98a1e7a921d3ad63db237d445f42ab58158/migrate_sql/management/commands/__init__.py -------------------------------------------------------------------------------- /migrate_sql/management/commands/makemigrations.py: -------------------------------------------------------------------------------- 1 | """ 2 | Replaces built-in Django command and forces it generate SQL item modification operations 3 | into regular Django migrations. 4 | """ 5 | 6 | import sys 7 | 8 | from django.core.management.commands.makemigrations import Command as MakeMigrationsCommand 9 | from django.db.migrations.loader import MigrationLoader 10 | from django.db.migrations import Migration 11 | from django.core.management.base import CommandError, no_translations 12 | from django.db.migrations.questioner import InteractiveMigrationQuestioner 13 | from django.apps import apps 14 | from django.db.migrations.state import ProjectState 15 | 16 | from migrate_sql.autodetector import MigrationAutodetector 17 | from migrate_sql.graph import build_current_graph 18 | 19 | 20 | class Command(MakeMigrationsCommand): 21 | 22 | @no_translations 23 | def handle(self, *app_labels, **options): 24 | 25 | self.verbosity = options.get('verbosity') 26 | self.interactive = options.get('interactive') 27 | self.dry_run = options.get('dry_run', False) 28 | self.merge = options.get('merge', False) 29 | self.empty = options.get('empty', False) 30 | self.migration_name = options.get('name', None) 31 | self.exit_code = options.get('exit_code', False) 32 | self.include_header = options.get('include_header', True) 33 | check_changes = options.get('check_changes', False) 34 | 35 | # Make sure the app they asked for exists 36 | app_labels = set(app_labels) 37 | bad_app_labels = set() 38 | for app_label in app_labels: 39 | try: 40 | apps.get_app_config(app_label) 41 | except LookupError: 42 | bad_app_labels.add(app_label) 43 | if bad_app_labels: 44 | for app_label in bad_app_labels: 45 | self.stderr.write("App '%s' could not be found. Is it in INSTALLED_APPS?" % app_label) 46 | sys.exit(2) 47 | 48 | # Load the current graph state. Pass in None for the connection so 49 | # the loader doesn't try to resolve replaced migrations from DB. 50 | loader = MigrationLoader(None, ignore_no_migrations=True) 51 | 52 | # Before anything else, see if there's conflicting apps and drop out 53 | # hard if there are any and they don't want to merge 54 | conflicts = loader.detect_conflicts() 55 | 56 | # If app_labels is specified, filter out conflicting migrations for unspecified apps 57 | if app_labels: 58 | conflicts = { 59 | app_label: conflict for app_label, conflict in conflicts.items() 60 | if app_label in app_labels 61 | } 62 | 63 | if conflicts and not self.merge: 64 | name_str = "; ".join( 65 | "%s in %s" % (", ".join(names), app) 66 | for app, names in conflicts.items() 67 | ) 68 | raise CommandError( 69 | "Conflicting migrations detected (%s).\nTo fix them run " 70 | "'python manage.py makemigrations --merge'" % name_str 71 | ) 72 | 73 | # If they want to merge and there's nothing to merge, then politely exit 74 | if self.merge and not conflicts: 75 | self.stdout.write("No conflicts detected to merge.") 76 | return 77 | 78 | # If they want to merge and there is something to merge, then 79 | # divert into the merge code 80 | if self.merge and conflicts: 81 | return self.handle_merge(loader, conflicts) 82 | 83 | state = loader.project_state() 84 | 85 | # NOTE: customization. Passing graph to autodetector. 86 | sql_graph = build_current_graph() 87 | 88 | # Set up autodetector 89 | autodetector = MigrationAutodetector( 90 | state, 91 | ProjectState.from_apps(apps), 92 | InteractiveMigrationQuestioner(specified_apps=app_labels, dry_run=self.dry_run), 93 | sql_graph, 94 | ) 95 | 96 | # If they want to make an empty migration, make one for each app 97 | if self.empty: 98 | if not app_labels: 99 | raise CommandError("You must supply at least one app label when using --empty.") 100 | # Make a fake changes() result we can pass to arrange_for_graph 101 | changes = { 102 | app: [Migration("custom", app)] 103 | for app in app_labels 104 | } 105 | changes = autodetector.arrange_for_graph( 106 | changes=changes, 107 | graph=loader.graph, 108 | migration_name=self.migration_name, 109 | ) 110 | self.write_migration_files(changes) 111 | return 112 | 113 | # Detect changes 114 | changes = autodetector.changes( 115 | graph=loader.graph, 116 | trim_to_apps=app_labels or None, 117 | convert_apps=app_labels or None, 118 | migration_name=self.migration_name, 119 | ) 120 | 121 | if not changes: 122 | # No changes? Tell them. 123 | if self.verbosity >= 1: 124 | if len(app_labels) == 1: 125 | self.stdout.write("No changes detected in app '%s'" % app_labels.pop()) 126 | elif len(app_labels) > 1: 127 | self.stdout.write("No changes detected in apps '%s'" % ("', '".join(app_labels))) 128 | else: 129 | self.stdout.write("No changes detected") 130 | 131 | if self.exit_code: 132 | sys.exit(1) 133 | else: 134 | self.write_migration_files(changes) 135 | if check_changes: 136 | sys.exit(1) 137 | -------------------------------------------------------------------------------- /migrate_sql/operations.py: -------------------------------------------------------------------------------- 1 | from django.db.migrations.operations import RunSQL 2 | from django.db.migrations.operations.base import Operation 3 | 4 | from migrate_sql.graph import SQLStateGraph 5 | from migrate_sql.config import SQLItem 6 | 7 | 8 | class MigrateSQLMixin(object): 9 | def get_sql_state(self, state): 10 | """ 11 | Get SQLStateGraph from state. 12 | """ 13 | if not hasattr(state, 'sql_state'): 14 | setattr(state, 'sql_state', SQLStateGraph()) 15 | return state.sql_state 16 | 17 | 18 | class AlterSQLState(MigrateSQLMixin, Operation): 19 | """ 20 | Alters in-memory state of SQL item. 21 | This operation is generated separately from others since it does not affect database. 22 | """ 23 | def describe(self): 24 | return 'Alter SQL state "{name}"'.format(name=self.name) 25 | 26 | def deconstruct(self): 27 | kwargs = { 28 | 'name': self.name, 29 | } 30 | if self.add_dependencies: 31 | kwargs['add_dependencies'] = self.add_dependencies 32 | if self.remove_dependencies: 33 | kwargs['remove_dependencies'] = self.remove_dependencies 34 | return (self.__class__.__name__, [], kwargs) 35 | 36 | def state_forwards(self, app_label, state): 37 | sql_state = self.get_sql_state(state) 38 | key = (app_label, self.name) 39 | 40 | if key not in sql_state.nodes: 41 | # XXX: dummy for `migrate` command, that does not preserve state object. 42 | # Should fail with error when fixed. 43 | return 44 | 45 | sql_item = sql_state.nodes[key] 46 | 47 | for dep in self.add_dependencies: 48 | # we are also adding relations to aggregated SQLItem, but only to restore 49 | # original items. Still using graph for advanced node/arc manipulations. 50 | 51 | # XXX: dummy `if` for `migrate` command, that does not preserve state object. 52 | # Fail with error when fixed 53 | if dep in sql_item.dependencies: 54 | sql_item.dependencies.remove(dep) 55 | sql_state.add_lazy_dependency(key, dep) 56 | 57 | for dep in self.remove_dependencies: 58 | sql_item.dependencies.append(dep) 59 | sql_state.remove_lazy_dependency(key, dep) 60 | 61 | def database_forwards(self, app_label, schema_editor, from_state, to_state): 62 | pass 63 | 64 | def database_backwards(self, app_label, schema_editor, from_state, to_state): 65 | pass 66 | 67 | @property 68 | def reversible(self): 69 | return True 70 | 71 | def __init__(self, name, add_dependencies=None, remove_dependencies=None): 72 | """ 73 | Args: 74 | name (str): Name of SQL item in current application to alter state for. 75 | add_dependencies (list): 76 | Unordered list of dependencies to add to state. 77 | remove_dependencies (list): 78 | Unordered list of dependencies to remove from state. 79 | """ 80 | self.name = name 81 | self.add_dependencies = add_dependencies or () 82 | self.remove_dependencies = remove_dependencies or () 83 | 84 | 85 | class BaseAlterSQL(MigrateSQLMixin, RunSQL): 86 | """ 87 | Base class for operations that alter database. 88 | """ 89 | def __init__(self, name, sql, reverse_sql=None, state_operations=None, hints=None): 90 | super(BaseAlterSQL, self).__init__(sql, reverse_sql=reverse_sql, 91 | state_operations=state_operations, hints=hints) 92 | self.name = name 93 | 94 | def deconstruct(self): 95 | name, args, kwargs = super(BaseAlterSQL, self).deconstruct() 96 | kwargs['name'] = self.name 97 | return (name, args, kwargs) 98 | 99 | 100 | class ReverseAlterSQL(BaseAlterSQL): 101 | def describe(self): 102 | return 'Reverse alter SQL "{name}"'.format(name=self.name) 103 | 104 | 105 | class AlterSQL(BaseAlterSQL): 106 | """ 107 | Updates SQL item with a new version. 108 | """ 109 | def __init__(self, name, sql, reverse_sql=None, state_operations=None, hints=None, 110 | state_reverse_sql=None): 111 | """ 112 | Args: 113 | name (str): Name of SQL item in current application to alter state for. 114 | sql (str/list): Forward SQL for item creation. 115 | reverse_sql (str/list): Backward SQL for reversing create operation. 116 | state_reverse_sql (str/list): Backward SQL used to alter state of backward SQL 117 | *instead* of `reverse_sql`. Used for operations generated for items with 118 | `replace` = `True`. 119 | """ 120 | super(AlterSQL, self).__init__(name, sql, reverse_sql=reverse_sql, 121 | state_operations=state_operations, hints=hints) 122 | self.state_reverse_sql = state_reverse_sql 123 | 124 | def deconstruct(self): 125 | name, args, kwargs = super(AlterSQL, self).deconstruct() 126 | kwargs['name'] = self.name 127 | if self.state_reverse_sql: 128 | kwargs['state_reverse_sql'] = self.state_reverse_sql 129 | return (name, args, kwargs) 130 | 131 | def describe(self): 132 | return 'Alter SQL "{name}"'.format(name=self.name) 133 | 134 | def state_forwards(self, app_label, state): 135 | super(AlterSQL, self).state_forwards(app_label, state) 136 | sql_state = self.get_sql_state(state) 137 | key = (app_label, self.name) 138 | 139 | if key not in sql_state.nodes: 140 | # XXX: dummy for `migrate` command, that does not preserve state object. 141 | # Fail with error when fixed 142 | return 143 | 144 | sql_item = sql_state.nodes[key] 145 | sql_item.sql = self.sql 146 | sql_item.reverse_sql = self.state_reverse_sql or self.reverse_sql 147 | 148 | 149 | class CreateSQL(BaseAlterSQL): 150 | """ 151 | Creates new SQL item in database. 152 | """ 153 | def describe(self): 154 | return 'Create SQL "{name}"'.format(name=self.name) 155 | 156 | def deconstruct(self): 157 | name, args, kwargs = super(CreateSQL, self).deconstruct() 158 | kwargs['name'] = self.name 159 | if self.dependencies: 160 | kwargs['dependencies'] = self.dependencies 161 | return (name, args, kwargs) 162 | 163 | def __init__(self, name, sql, reverse_sql=None, state_operations=None, hints=None, 164 | dependencies=None): 165 | super(CreateSQL, self).__init__(name, sql, reverse_sql=reverse_sql, 166 | state_operations=state_operations, hints=hints) 167 | self.dependencies = dependencies or () 168 | 169 | def state_forwards(self, app_label, state): 170 | super(CreateSQL, self).state_forwards(app_label, state) 171 | sql_state = self.get_sql_state(state) 172 | 173 | sql_state.add_node( 174 | (app_label, self.name), 175 | SQLItem(self.name, self.sql, self.reverse_sql, list(self.dependencies)), 176 | ) 177 | 178 | for dep in self.dependencies: 179 | sql_state.add_lazy_dependency((app_label, self.name), dep) 180 | 181 | 182 | class DeleteSQL(BaseAlterSQL): 183 | """ 184 | Deltes SQL item from database. 185 | """ 186 | def describe(self): 187 | return 'Delete SQL "{name}"'.format(name=self.name) 188 | 189 | def state_forwards(self, app_label, state): 190 | super(DeleteSQL, self).state_forwards(app_label, state) 191 | sql_state = self.get_sql_state(state) 192 | 193 | sql_state.remove_node((app_label, self.name)) 194 | sql_state.remove_lazy_for_child((app_label, self.name)) 195 | -------------------------------------------------------------------------------- /requirements-testing.txt: -------------------------------------------------------------------------------- 1 | psycopg2>=2.6.1 2 | coverage==4.0.3 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=1.8 2 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.test_project.settings") 6 | sys.path.insert(0, 'tests') 7 | 8 | import django 9 | from django.core.management import call_command 10 | 11 | django.setup() 12 | 13 | call_command('test') 14 | 15 | sys.exit(0) 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import re 3 | import os 4 | import sys 5 | 6 | from setuptools import setup, find_packages 7 | from setuptools.command.test import test as TestCommand 8 | 9 | 10 | class Tox(TestCommand): 11 | user_options = [('tox-args=', 'a', "Arguments to pass to tox")] 12 | 13 | def initialize_options(self): 14 | TestCommand.initialize_options(self) 15 | self.tox_args = None 16 | 17 | def finalize_options(self): 18 | TestCommand.finalize_options(self) 19 | self.test_args = [] 20 | self.test_suite = True 21 | 22 | def run_tests(self): 23 | import tox 24 | import shlex 25 | 26 | args = self.tox_args 27 | if args: 28 | args = shlex.split(self.tox_args) 29 | errno = tox.cmdline(args=args) 30 | sys.exit(errno) 31 | 32 | 33 | def get_version(package): 34 | """ 35 | Get migrate_sql version as listed in `__version__` in `__init__.py`. 36 | """ 37 | init_py = open(os.path.join(package, '__init__.py')).read() 38 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 39 | 40 | 41 | with open('README.rst') as readme_file: 42 | readme = readme_file.read() 43 | 44 | 45 | VERSION = get_version('migrate_sql') 46 | 47 | setup( 48 | name='django-migrate-sql-deux', 49 | version=VERSION, 50 | description='Migration support for raw SQL in Django', 51 | long_description=readme, 52 | author='Festicket', 53 | author_email='dev@festicket.com', 54 | packages=find_packages(), 55 | package_dir={'migrate_sql': 'migrate_sql'}, 56 | license='BSD', 57 | zip_safe=False, 58 | url='https://github.com/festicket/django-migrate-sql', 59 | classifiers=[ 60 | 'Development Status :: 3 - Alpha', 61 | 'Framework :: Django', 62 | 'Intended Audience :: Developers', 63 | 'License :: OSI Approved :: BSD License', 64 | 'Natural Language :: English', 65 | 'Programming Language :: Python :: 3.6', 66 | 'Programming Language :: Python :: 3.7', 67 | 'Framework :: Django :: 2.1', 68 | 'Framework :: Django :: 2.2', 69 | 'Framework :: Django :: 3.0', 70 | ], 71 | tests_require=['tox'], 72 | cmdclass={'test': Tox}, 73 | install_requires=[], 74 | ) 75 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/festicket/django-migrate-sql/344ea98a1e7a921d3ad63db237d445f42ab58158/tests/__init__.py -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import unicode_literals 4 | 5 | import os 6 | import sys 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 10 | 11 | from django.core.management import execute_from_command_line 12 | 13 | execute_from_command_line(sys.argv) 14 | -------------------------------------------------------------------------------- /tests/test_app/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'test_app.apps.TestAppConfig' 2 | -------------------------------------------------------------------------------- /tests/test_app/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.apps import AppConfig 5 | 6 | 7 | class TestAppConfig(AppConfig): 8 | name = 'test_app' 9 | verbose_name = 'Test App' 10 | -------------------------------------------------------------------------------- /tests/test_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Book', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('name', models.CharField(max_length=200)), 18 | ('author', models.CharField(max_length=200)), 19 | ('rating', models.IntegerField(null=True, blank=True)), 20 | ('published', models.BooleanField(default=True)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tests/test_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/festicket/django-migrate-sql/344ea98a1e7a921d3ad63db237d445f42ab58158/tests/test_app/migrations/__init__.py -------------------------------------------------------------------------------- /tests/test_app/migrations_change/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import migrate_sql.operations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Book', 16 | fields=[ 17 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 18 | ('name', models.CharField(max_length=200)), 19 | ('author', models.CharField(max_length=200)), 20 | ('rating', models.IntegerField(null=True, blank=True)), 21 | ('published', models.BooleanField(default=True)), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /tests/test_app/migrations_change/0002_auto_20151224_1827.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import migrate_sql.operations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('test_app', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrate_sql.operations.CreateSQL( 16 | name='top_books', 17 | sql=[('\n CREATE OR REPLACE FUNCTION top_books()\n RETURNS SETOF test_app_book AS $$\n BEGIN\n RETURN QUERY\n SELECT * FROM test_app_book ab\n WHERE ab.rating > %s\n ORDER BY ab.rating DESC;\n END;\n $$ LANGUAGE plpgsql;\n ', [5])], 18 | reverse_sql='DROP FUNCTION top_books()', 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tests/test_app/migrations_change/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/festicket/django-migrate-sql/344ea98a1e7a921d3ad63db237d445f42ab58158/tests/test_app/migrations_change/__init__.py -------------------------------------------------------------------------------- /tests/test_app/migrations_deps_delete/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Book', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('name', models.CharField(max_length=200)), 18 | ('author', models.CharField(max_length=200)), 19 | ('rating', models.IntegerField(null=True, blank=True)), 20 | ('published', models.BooleanField(default=True)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tests/test_app/migrations_deps_delete/0002_auto_20160106_0947.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import migrate_sql.operations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('test_app2', '0001_initial'), 12 | ('test_app', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrate_sql.operations.CreateSQL( 17 | name='book', 18 | sql='CREATE TYPE book AS (arg1 int); -- 1', 19 | reverse_sql='DROP TYPE book', 20 | ), 21 | migrate_sql.operations.CreateSQL( 22 | name='rating', 23 | sql='CREATE TYPE rating AS (arg1 int); -- 1', 24 | reverse_sql='DROP TYPE rating', 25 | ), 26 | migrate_sql.operations.CreateSQL( 27 | name='narration', 28 | sql='CREATE TYPE narration AS (sale1 sale, book1 book, arg1 int); -- 1', 29 | reverse_sql='DROP TYPE narration', 30 | dependencies=[('test_app', 'book'), ('test_app2', 'sale')], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /tests/test_app/migrations_deps_delete/0003_auto_20160108_0048.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import migrate_sql.operations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('test_app', '0002_auto_20160106_0947'), 12 | ] 13 | 14 | operations = [ 15 | migrate_sql.operations.ReverseAlterSQL( 16 | name='narration', 17 | sql='DROP TYPE narration', 18 | reverse_sql='CREATE TYPE narration AS (sale1 sale, book1 book, arg1 int); -- 1', 19 | ), 20 | migrate_sql.operations.CreateSQL( 21 | name='edition', 22 | sql='CREATE TYPE edition AS (arg1 int); -- 1', 23 | reverse_sql='DROP TYPE edition', 24 | ), 25 | migrate_sql.operations.ReverseAlterSQL( 26 | name='book', 27 | sql='DROP TYPE book', 28 | reverse_sql='CREATE TYPE book AS (arg1 int); -- 1', 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /tests/test_app/migrations_deps_delete/0004_auto_20160108_0048.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import migrate_sql.operations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('test_app2', '0002_auto_20160108_0041'), 12 | ('test_app', '0003_auto_20160108_0048'), 13 | ] 14 | 15 | operations = [ 16 | migrate_sql.operations.AlterSQL( 17 | name='book', 18 | sql='CREATE TYPE book AS (sale2 sale, rating2 rating, arg1 int, arg2 int); -- 2', 19 | reverse_sql='DROP TYPE book', 20 | ), 21 | migrate_sql.operations.CreateSQL( 22 | name='author', 23 | sql='CREATE TYPE author AS (book1 book, arg1 int); -- 1', 24 | reverse_sql='DROP TYPE author', 25 | dependencies=[('test_app', 'book')], 26 | ), 27 | migrate_sql.operations.AlterSQL( 28 | name='narration', 29 | sql='CREATE TYPE narration AS (sale1 sale, book1 book, arg1 int); -- 1', 30 | reverse_sql='DROP TYPE narration', 31 | ), 32 | migrate_sql.operations.AlterSQLState( 33 | name='book', 34 | add_dependencies=[('test_app', 'rating'), ('test_app2', 'sale')], 35 | ), 36 | migrate_sql.operations.CreateSQL( 37 | name='product', 38 | sql='CREATE TYPE product AS (book1 book, author1 author, edition1 edition, arg1 int); -- 1', 39 | reverse_sql='DROP TYPE product', 40 | dependencies=[('test_app', 'edition'), ('test_app', 'book'), ('test_app', 'author')], 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /tests/test_app/migrations_deps_delete/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/festicket/django-migrate-sql/344ea98a1e7a921d3ad63db237d445f42ab58158/tests/test_app/migrations_deps_delete/__init__.py -------------------------------------------------------------------------------- /tests/test_app/migrations_deps_update/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Book', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('name', models.CharField(max_length=200)), 18 | ('author', models.CharField(max_length=200)), 19 | ('rating', models.IntegerField(null=True, blank=True)), 20 | ('published', models.BooleanField(default=True)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tests/test_app/migrations_deps_update/0002_auto_20160106_0947.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import migrate_sql.operations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('test_app2', '0001_initial'), 12 | ('test_app', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrate_sql.operations.CreateSQL( 17 | name='book', 18 | sql='CREATE TYPE book AS (arg1 int); -- 1', 19 | reverse_sql='DROP TYPE book', 20 | ), 21 | migrate_sql.operations.CreateSQL( 22 | name='rating', 23 | sql='CREATE TYPE rating AS (arg1 int); -- 1', 24 | reverse_sql='DROP TYPE rating', 25 | ), 26 | migrate_sql.operations.CreateSQL( 27 | name='narration', 28 | sql='CREATE TYPE narration AS (sale1 sale, book1 book, arg1 int); -- 1', 29 | reverse_sql='DROP TYPE narration', 30 | dependencies=[('test_app', 'book'), ('test_app2', 'sale')], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /tests/test_app/migrations_deps_update/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/festicket/django-migrate-sql/344ea98a1e7a921d3ad63db237d445f42ab58158/tests/test_app/migrations_deps_update/__init__.py -------------------------------------------------------------------------------- /tests/test_app/migrations_recreate/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import migrate_sql.operations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Book', 16 | fields=[ 17 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 18 | ('name', models.CharField(max_length=200)), 19 | ('author', models.CharField(max_length=200)), 20 | ('rating', models.IntegerField(null=True, blank=True)), 21 | ('published', models.BooleanField(default=True)), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /tests/test_app/migrations_recreate/0002_auto_20151224_1827.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import migrate_sql.operations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('test_app', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrate_sql.operations.CreateSQL( 16 | name='top_books', 17 | sql=[('\n CREATE OR REPLACE FUNCTION top_books()\n RETURNS SETOF test_app_book AS $$\n BEGIN\n RETURN QUERY\n SELECT * FROM test_app_book ab\n WHERE ab.rating > %s\n ORDER BY ab.rating DESC;\n END;\n $$ LANGUAGE plpgsql;\n ', [5])], 18 | reverse_sql='DROP FUNCTION top_books()', 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tests/test_app/migrations_recreate/0003_auto_20160106_1038.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import migrate_sql.operations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('test_app', '0002_auto_20151224_1827'), 12 | ] 13 | 14 | operations = [ 15 | migrate_sql.operations.DeleteSQL( 16 | name='top_books', 17 | sql='DROP FUNCTION top_books()', 18 | reverse_sql=[('\n CREATE OR REPLACE FUNCTION top_books()\n RETURNS SETOF test_app_book AS $$\n BEGIN\n RETURN QUERY\n SELECT * FROM test_app_book ab\n WHERE ab.rating > %s\n ORDER BY ab.rating DESC;\n END;\n $$ LANGUAGE plpgsql;\n ', [5])], 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tests/test_app/migrations_recreate/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/festicket/django-migrate-sql/344ea98a1e7a921d3ad63db237d445f42ab58158/tests/test_app/migrations_recreate/__init__.py -------------------------------------------------------------------------------- /tests/test_app/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db import models 4 | 5 | 6 | class Book(models.Model): 7 | name = models.CharField(max_length=200) 8 | author = models.CharField(max_length=200) 9 | rating = models.IntegerField(null=True, blank=True) 10 | published = models.BooleanField(default=True) 11 | 12 | def __unicode__(self): 13 | return "Book [{}]".format(self.name) 14 | -------------------------------------------------------------------------------- /tests/test_app/sql_config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | sql_items = [ 5 | # TODO: Insert SQL items here. 6 | ] 7 | -------------------------------------------------------------------------------- /tests/test_app/test_migrations.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import tempfile 5 | import shutil 6 | import os 7 | 8 | from contextlib import contextmanager 9 | from importlib import import_module 10 | from psycopg2.extras import register_composite, CompositeCaster 11 | 12 | try: 13 | from StringIO import StringIO 14 | except ImportError: 15 | from io import StringIO 16 | 17 | from django.test import TestCase 18 | from django.db import connection 19 | from django.db.migrations.loader import MigrationLoader 20 | from django.apps import apps 21 | from django.core.management import call_command 22 | from django.conf import settings 23 | from django.test.utils import extend_sys_path 24 | 25 | from test_app.models import Book 26 | from migrate_sql.config import SQLItem 27 | 28 | 29 | class TupleComposite(CompositeCaster): 30 | """ 31 | Loads composite type object as tuple. 32 | """ 33 | def make(self, values): 34 | return tuple(values) 35 | 36 | 37 | def module_dir(module): 38 | """ 39 | Find the name of the directory that contains a module, if possible. 40 | RMigrateaise ValueError otherwise, e.g. for namespace packages that are split 41 | over several directories. 42 | """ 43 | # Convert to list because _NamespacePath does not support indexing on 3.3. 44 | paths = list(getattr(module, '__path__', [])) 45 | if len(paths) == 1: 46 | return paths[0] 47 | else: 48 | filename = getattr(module, '__file__', None) 49 | if filename is not None: 50 | return os.path.dirname(filename) 51 | raise ValueError("Cannot determine directory containing %s" % module) 52 | 53 | 54 | def item(name, version, dependencies=None): 55 | """ 56 | Creates mock SQL item represented by Postgre composite type. 57 | Returns: 58 | (SQLItem): Resuling composite type: 59 | * sql = CREATE TYPE AS ( 60 | [ , ..., ], arg1 int, arg2 int, .., argN int); 61 | dependencies are arguments, version affects amount of extra int arguments. 62 | Version = 1 means one int argument. 63 | * sql = DROP TYPE . 64 | """ 65 | dependencies = dependencies or () 66 | args = ', '.join(['{name}{ver} {name}'.format(name=dep[1], ver=version) 67 | for dep in dependencies] + ['arg{i} int'.format(i=i + 1) 68 | for i in range(version)]) 69 | sql, reverse_sql = ('CREATE TYPE {name} AS ({args}); -- {ver}'.format( 70 | name=name, args=args, ver=version), 71 | 'DROP TYPE {}'.format(name)) 72 | return SQLItem(name, sql, reverse_sql, dependencies=dependencies) 73 | 74 | 75 | def contains_ordered(lst, order): 76 | """ 77 | Checks if `order` sequence exists in `lst` in the defined order. 78 | """ 79 | prev_idx = -1 80 | try: 81 | for item in order: 82 | idx = lst.index(item) 83 | if idx <= prev_idx: 84 | return False 85 | prev_idx = idx 86 | except ValueError: 87 | return False 88 | return True 89 | 90 | 91 | def mig_name(name): 92 | """ 93 | Returns name[0] (app name) and first 4 letters of migartion name (name[1]). 94 | """ 95 | return name[0], name[1][:4] 96 | 97 | 98 | def run_query(sql, params=None): 99 | cursor = connection.cursor() 100 | cursor.execute(sql, params=params) 101 | return cursor.fetchall() 102 | 103 | 104 | class BaseMigrateSQLTestCase(TestCase): 105 | """ 106 | Tests `migrate_sql` using sample PostgreSQL functions and their body/argument changes. 107 | """ 108 | def setUp(self): 109 | super(BaseMigrateSQLTestCase, self).setUp() 110 | self.config = import_module('test_app.sql_config') 111 | self.config2 = import_module('test_app2.sql_config') 112 | self.out = StringIO() 113 | 114 | def tearDown(self): 115 | super(BaseMigrateSQLTestCase, self).tearDown() 116 | if hasattr(self.config, 'sql_items'): 117 | delattr(self.config, 'sql_items') 118 | if hasattr(self.config2, 'sql_items'): 119 | delattr(self.config2, 'sql_items') 120 | 121 | def check_migrations_content(self, expected): 122 | """ 123 | Check content (operations) of migrations. 124 | """ 125 | loader = MigrationLoader(None, load=True) 126 | available = loader.disk_migrations.keys() 127 | for expc_mig, (check_exists, dependencies, op_groups) in expected.items(): 128 | key = next((mig for mig in available if mig_name(mig) == mig_name(expc_mig)), None) 129 | if check_exists: 130 | self.assertIsNotNone(key, 'Expected migration {} not found.'.format(expc_mig)) 131 | else: 132 | self.assertIsNone(key, 'Unexpected migration {} was found.'.format(expc_mig)) 133 | continue 134 | migration = loader.disk_migrations[key] 135 | self.assertEqual({mig_name(dep) for dep in migration.dependencies}, set(dependencies)) 136 | mig_ops = [(op.__class__.__name__, op.name) for op in migration.operations] 137 | for op_group in op_groups: 138 | self.assertTrue(contains_ordered(mig_ops, op_group)) 139 | 140 | @contextmanager 141 | def temporary_migration_module(self, app_label='test_app', module=None): 142 | """ 143 | Allows testing management commands in a temporary migrations module. 144 | The migrations module is used as a template for creating the temporary 145 | migrations module. If it isn't provided, the application's migrations 146 | module is used, if it exists. 147 | Returns the filesystem path to the temporary migrations module. 148 | """ 149 | temp_dir = tempfile.mkdtemp() 150 | try: 151 | target_dir = tempfile.mkdtemp(dir=temp_dir) 152 | with open(os.path.join(target_dir, '__init__.py'), 'w'): 153 | pass 154 | target_migrations_dir = os.path.join(target_dir, 'migrations') 155 | 156 | if module is None: 157 | module = apps.get_app_config(app_label).name + '.migrations' 158 | 159 | try: 160 | source_migrations_dir = module_dir(import_module(module)) 161 | except (ImportError, ValueError): 162 | pass 163 | else: 164 | shutil.copytree(source_migrations_dir, target_migrations_dir) 165 | 166 | with extend_sys_path(temp_dir): 167 | new_module = os.path.basename(target_dir) + '.migrations' 168 | new_setting = settings.MIGRATION_MODULES.copy() 169 | new_setting[app_label] = new_module 170 | with self.settings(MIGRATION_MODULES=new_setting): 171 | yield target_migrations_dir 172 | finally: 173 | shutil.rmtree(temp_dir) 174 | 175 | 176 | class MigrateSQLTestCase(BaseMigrateSQLTestCase): 177 | SQL_V1 = ( 178 | # sql 179 | [(""" 180 | CREATE OR REPLACE FUNCTION top_books() 181 | RETURNS SETOF test_app_book AS $$ 182 | BEGIN 183 | RETURN QUERY SELECT * FROM test_app_book ab WHERE ab.rating > %s 184 | ORDER BY ab.rating DESC; 185 | END; 186 | $$ LANGUAGE plpgsql; 187 | """, [5])], 188 | # reverse sql 189 | 'DROP FUNCTION top_books()', 190 | ) 191 | 192 | SQL_V2 = ( 193 | # sql 194 | [(""" 195 | CREATE OR REPLACE FUNCTION top_books(min_rating int = %s) 196 | RETURNS SETOF test_app_book AS $$ 197 | BEGIN 198 | RETURN QUERY EXECUTE 'SELECT * FROM test_app_book ab 199 | WHERE ab.rating > $1 AND ab.published 200 | ORDER BY ab.rating DESC' 201 | USING min_rating; 202 | END; 203 | $$ LANGUAGE plpgsql; 204 | """, [5])], 205 | # reverse sql 206 | 'DROP FUNCTION top_books(int)', 207 | ) 208 | 209 | SQL_V3 = ( 210 | # sql 211 | [(""" 212 | CREATE OR REPLACE FUNCTION top_books() 213 | RETURNS SETOF test_app_book AS $$ 214 | DECLARE 215 | min_rating int := %s; 216 | BEGIN 217 | RETURN QUERY EXECUTE 'SELECT * FROM test_app_book ab 218 | WHERE ab.rating > $1 AND ab.published 219 | ORDER BY ab.rating DESC' 220 | USING min_rating; 221 | END; 222 | $$ LANGUAGE plpgsql; 223 | """, [5])], 224 | # reverse sql 225 | 'DROP FUNCTION top_books()', 226 | ) 227 | 228 | def setUp(self): 229 | super(MigrateSQLTestCase, self).setUp() 230 | books = ( 231 | Book(name="Clone Wars", author="John Ben", rating=4, published=True), 232 | Book(name="The mysterious dog", author="John Ben", rating=6, published=True), 233 | Book(name="HTML 5", author="John Ben", rating=9, published=True), 234 | Book(name="Management", author="John Ben", rating=8, published=False), 235 | Book(name="Python 3", author="John Ben", rating=3, published=False), 236 | ) 237 | Book.objects.bulk_create(books) 238 | 239 | def check_run_migrations(self, migrations): 240 | """ 241 | Launch migrations requested and compare results. 242 | """ 243 | for migration, expected in migrations: 244 | call_command('migrate', 'test_app', migration, stdout=self.out) 245 | if expected: 246 | result = run_query('SELECT name FROM top_books()') 247 | self.assertEqual(result, expected) 248 | else: 249 | result = run_query("SELECT COUNT(*) FROM pg_proc WHERE proname = 'top_books'") 250 | self.assertEqual(result, [(0,)]) 251 | 252 | def check_migrations(self, content, results, migration_module=None, app_label='test_app'): 253 | """ 254 | Checks migrations content and results after being run. 255 | """ 256 | with self.temporary_migration_module(module=migration_module): 257 | call_command('makemigrations', app_label, stdout=self.out) 258 | self.check_migrations_content(content) 259 | 260 | call_command('migrate', app_label, stdout=self.out) 261 | self.check_run_migrations(results) 262 | 263 | def test_migration_add(self): 264 | """ 265 | Items newly created should be properly persisted into migrations and created in database. 266 | """ 267 | sql, reverse_sql = self.SQL_V1 268 | self.config.sql_items = [SQLItem('top_books', sql, reverse_sql)] 269 | expected_content = { 270 | ('test_app', '0002'): ( 271 | True, 272 | [('test_app', '0001')], 273 | [[('CreateSQL', 'top_books')]], 274 | ), 275 | } 276 | expected_results = ( 277 | ('0002', [('HTML 5',), ('Management',), ('The mysterious dog',)]), 278 | ) 279 | self.check_migrations(expected_content, expected_results) 280 | 281 | def test_migration_change(self): 282 | """ 283 | Items changed should properly persist changes into migrations and alter database. 284 | """ 285 | sql, reverse_sql = self.SQL_V2 286 | self.config.sql_items = [SQLItem('top_books', sql, reverse_sql)] 287 | 288 | expected_content = { 289 | ('test_app', '0003'): ( 290 | True, 291 | [('test_app', '0002')], 292 | [[('ReverseAlterSQL', 'top_books'), ('AlterSQL', 'top_books')]], 293 | ), 294 | } 295 | expected_results = ( 296 | ('0003', [('HTML 5',), ('The mysterious dog',)]), 297 | ('0002', [('HTML 5',), ('Management',), ('The mysterious dog',)]), 298 | ('0001', None), 299 | ) 300 | self.check_migrations(expected_content, expected_results, 'test_app.migrations_change') 301 | 302 | def test_migration_replace(self): 303 | """ 304 | Items changed with `replace` = Truel should properly persist changes into migrations and 305 | replace object in database without reversing previously. 306 | """ 307 | sql, reverse_sql = self.SQL_V3 308 | self.config.sql_items = [SQLItem('top_books', sql, reverse_sql, replace=True)] 309 | 310 | expected_content = { 311 | ('test_app', '0003'): ( 312 | True, 313 | [('test_app', '0002')], 314 | [[('AlterSQL', 'top_books')]], 315 | ), 316 | } 317 | expected_results = ( 318 | ('0003', [('HTML 5',), ('The mysterious dog',)]), 319 | ('0002', [('HTML 5',), ('Management',), ('The mysterious dog',)]), 320 | ('0001', None), 321 | ('0002', [('HTML 5',), ('Management',), ('The mysterious dog',)]), 322 | ) 323 | self.check_migrations(expected_content, expected_results, 'test_app.migrations_change') 324 | 325 | def test_migration_delete(self): 326 | """ 327 | Items deleted should properly embed deletion into migration and run backward SQL in DB. 328 | """ 329 | self.config.sql_items = [] 330 | 331 | expected_content = { 332 | ('test_app', '0003'): ( 333 | True, 334 | [('test_app', '0002')], 335 | [[('DeleteSQL', 'top_books')]], 336 | ), 337 | } 338 | expected_results = ( 339 | ('0003', None), 340 | ) 341 | self.check_migrations(expected_content, expected_results, 'test_app.migrations_change') 342 | 343 | def test_migration_recreate(self): 344 | """ 345 | Items created after deletion should properly embed recreation into migration and alter DB. 346 | """ 347 | sql, reverse_sql = self.SQL_V2 348 | self.config.sql_items = [SQLItem('top_books', sql, reverse_sql)] 349 | 350 | expected_content = { 351 | ('test_app', '0004'): ( 352 | True, 353 | [('test_app', '0003')], 354 | [[('CreateSQL', 'top_books')]], 355 | ), 356 | } 357 | expected_results = ( 358 | ('0003', None), 359 | ('0002', [('HTML 5',), ('Management',), ('The mysterious dog',)]), 360 | ) 361 | self.check_migrations(expected_content, expected_results, 'test_app.migrations_recreate') 362 | 363 | 364 | class SQLDependenciesTestCase(BaseMigrateSQLTestCase): 365 | """ 366 | Tests SQL item dependencies system. 367 | """ 368 | 369 | # Expected datasets (input and output) for different migration states. 370 | # When migration is run, database is checked against expected result. 371 | # Key = name of migration (app, name), value is a list of : 372 | # * SQL arguments passed to Postgre's ROW 373 | # * composite type to cast ROW built above into. 374 | # * dependency types (included into psycopg2 `register_composite`) 375 | # * expected result after fetching built ROW from database. 376 | RESULTS_EXPECTED = { 377 | ('test_app', '0004'): [ 378 | # product check 379 | ("(('(1, 2)', '(3)', 4, 5), (('(6, 7)', '(8)', 9, 10), 11), '(12)', 13)", 380 | 'product', 381 | ['product', 'book', 'author', 382 | 'rating', 'sale', 'edition'], 383 | (((1, 2), (3,), 4, 5), (((6, 7), (8,), 9, 10), 11), (12,), 13)), 384 | 385 | # narration check 386 | ("('(1, 2)', ('(3, 4)', '(5)', 6, 7), 8)", 387 | 'narration', 388 | ['narration', 'book', 'sale', 'rating'], 389 | ((1, 2), ((3, 4), (5,), 6, 7), 8)), 390 | ], 391 | ('test_app', '0002'): [ 392 | # narration check 393 | ("('(1)', '(2)', 3)", 394 | 'narration', 395 | ['rating', 'book', 'sale', 'narration'], 396 | ((1,), (2,), 3)), 397 | ], 398 | ('test_app2', 'zero'): [ 399 | # edition check 400 | (None, 'edition', [], None), 401 | # ratings check 402 | (None, 'ratings', [], None), 403 | ], 404 | ('test_app', '0005'): [ 405 | # narration check 406 | ("(1)", 'edition', ['edition'], (1,)), 407 | 408 | # product check 409 | (None, 'product', [], None), 410 | ], 411 | ('test_app2', '0003'): [ 412 | # sale check 413 | (None, 'sale', [], None), 414 | ], 415 | } 416 | 417 | def check_type(self, repr_sql, fetch_type, known_types, expect): 418 | """ 419 | Checks composite type structure and format. 420 | """ 421 | cursor = connection.cursor() 422 | if repr_sql: 423 | for _type in known_types: 424 | register_composite(str(_type), cursor.cursor, factory=TupleComposite) 425 | 426 | sql = 'SELECT ROW{repr_sql}::{ftype}'.format(repr_sql=repr_sql, ftype=fetch_type) 427 | cursor.execute(sql) 428 | result = cursor.fetchone()[0] 429 | self.assertEqual(result, expect) 430 | else: 431 | result = run_query("SELECT COUNT(*) FROM pg_type WHERE typname = %s", 432 | [fetch_type]) 433 | self.assertEqual(result, [(0,)]) 434 | 435 | def check_migrations(self, content, migrations, module=None, module2=None): 436 | """ 437 | Checks migrations content and result after being run. 438 | """ 439 | with self.temporary_migration_module(app_label='test_app', module=module): 440 | with self.temporary_migration_module(app_label='test_app2', module=module2): 441 | call_command('makemigrations', stdout=self.out) 442 | self.check_migrations_content(content) 443 | 444 | for app_label, migration in migrations: 445 | call_command('migrate', app_label, migration, stdout=self.out) 446 | check_cases = self.RESULTS_EXPECTED[(app_label, migration)] 447 | for check_case in check_cases: 448 | self.check_type(*check_case) 449 | 450 | def test_deps_create(self): 451 | """ 452 | Creating a graph of items with dependencies should embed relations in migrations. 453 | """ 454 | self.config.sql_items = [ 455 | item('rating', 1), 456 | item('book', 1), 457 | item('narration', 1, [('test_app2', 'sale'), ('test_app', 'book')]), 458 | ] 459 | self.config2.sql_items = [item('sale', 1)] 460 | expected_content = { 461 | ('test_app2', '0001'): ( 462 | True, 463 | [], 464 | [[('CreateSQL', 'sale')]], 465 | ), 466 | ('test_app', '0002'): ( 467 | True, 468 | [('test_app2', '0001'), ('test_app', '0001')], 469 | [[('CreateSQL', 'rating')], 470 | [('CreateSQL', 'book'), ('CreateSQL', 'narration')]], 471 | ), 472 | } 473 | migrations = ( 474 | ('test_app', '0002'), 475 | ) 476 | self.check_migrations(expected_content, migrations) 477 | 478 | def test_deps_update(self): 479 | """ 480 | Updating a graph of items with dependencies should embed relation changes in migrations. 481 | """ 482 | self.config.sql_items = [ 483 | item('rating', 1), 484 | item('edition', 1), 485 | item('author', 1, [('test_app', 'book')]), 486 | item('narration', 1, [('test_app2', 'sale'), ('test_app', 'book')]), 487 | item('book', 2, [('test_app2', 'sale'), ('test_app', 'rating')]), 488 | item('product', 1, 489 | [('test_app', 'book'), ('test_app', 'author'), ('test_app', 'edition')]), 490 | ] 491 | self.config2.sql_items = [item('sale', 2)] 492 | 493 | expected_content = { 494 | ('test_app', '0003'): ( 495 | True, 496 | [('test_app', '0002')], 497 | [[('CreateSQL', 'edition')], 498 | [('ReverseAlterSQL', 'narration'), ('ReverseAlterSQL', 'book')]], 499 | ), 500 | ('test_app2', '0002'): ( 501 | True, 502 | [('test_app', '0003'), ('test_app2', '0001')], 503 | [[('ReverseAlterSQL', 'sale'), ('AlterSQL', 'sale')]], 504 | ), 505 | ('test_app', '0004'): ( 506 | True, 507 | [('test_app2', '0002'), ('test_app', '0003')], 508 | [[('AlterSQL', 'book'), ('CreateSQL', 'author'), ('CreateSQL', 'product')], 509 | [('AlterSQL', 'book'), ('AlterSQL', 'narration')], 510 | [('AlterSQL', 'book'), ('AlterSQLState', u'book')]], 511 | ), 512 | } 513 | migrations = ( 514 | ('test_app', '0004'), 515 | ('test_app', '0002'), 516 | ('test_app', '0004'), 517 | ) 518 | self.check_migrations( 519 | expected_content, migrations, 520 | module='test_app.migrations_deps_update', module2='test_app2.migrations_deps_update', 521 | ) 522 | 523 | def test_deps_circular(self): 524 | """ 525 | Graph with items that refer to themselves in their dependencies should raise an error. 526 | """ 527 | from django.db.migrations.graph import CircularDependencyError 528 | 529 | self.config.sql_items = [ 530 | item('narration', 1, [('test_app2', 'sale'), ('test_app', 'book')]), 531 | item('book', 2, [('test_app2', 'sale'), ('test_app', 'narration')]), 532 | ] 533 | self.config2.sql_items = [item('sale', 1)] 534 | 535 | with self.assertRaises(CircularDependencyError): 536 | self.check_migrations( 537 | {}, (), 538 | module='test_app.migrations_deps_update', 539 | module2='test_app2.migrations_deps_update', 540 | ) 541 | 542 | def test_deps_no_changes(self): 543 | """ 544 | In case no changes are made to structure of sql config, no migrations should be created. 545 | """ 546 | self.config.sql_items = [ 547 | item('rating', 1), 548 | item('book', 1), 549 | item('narration', 1, [('test_app2', 'sale'), ('test_app', 'book')]), 550 | ] 551 | self.config2.sql_items = [item('sale', 1)] 552 | 553 | expected_content = { 554 | ('test_app', '0003'): (False, [], []), 555 | ('test_app2', '0002'): (False, [], []), 556 | } 557 | migrations = () 558 | self.check_migrations( 559 | expected_content, migrations, 560 | module='test_app.migrations_deps_update', module2='test_app2.migrations_deps_update', 561 | ) 562 | 563 | def test_deps_delete(self): 564 | """ 565 | Graph with items that gets some of them removed along with dependencies should reflect 566 | changes into migrations. 567 | """ 568 | self.config.sql_items = [ 569 | item('rating', 1), 570 | item('edition', 1), 571 | ] 572 | self.config2.sql_items = [] 573 | 574 | expected_content = { 575 | ('test_app', '0005'): ( 576 | True, 577 | [('test_app', '0004')], 578 | [[('DeleteSQL', 'narration'), ('DeleteSQL', 'book')], 579 | [('DeleteSQL', 'product'), ('DeleteSQL', 'author'), ('DeleteSQL', 'book')]], 580 | ), 581 | ('test_app2', '0003'): ( 582 | True, 583 | [('test_app', '0005'), ('test_app2', '0002')], 584 | [[('DeleteSQL', 'sale')]], 585 | ), 586 | } 587 | migrations = ( 588 | ('test_app', '0005'), 589 | ('test_app', '0002'), 590 | ('test_app2', 'zero'), 591 | ('test_app', '0005'), 592 | ('test_app2', '0003'), 593 | ('test_app', '0004'), 594 | ) 595 | self.check_migrations( 596 | expected_content, migrations, 597 | module='test_app.migrations_deps_delete', module2='test_app2.migrations_deps_delete', 598 | ) 599 | -------------------------------------------------------------------------------- /tests/test_app/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.test import TestCase 5 | 6 | from migrate_sql.autodetector import is_sql_equal 7 | 8 | 9 | class SQLComparisonTestCase(TestCase): 10 | """ 11 | Tests comparison algorithm for two SQL item contents. 12 | """ 13 | def test_flat(self): 14 | self.assertTrue(is_sql_equal('SELECT 1', 'SELECT 1')) 15 | self.assertFalse(is_sql_equal('SELECT 1', 'SELECT 2')) 16 | 17 | def test_nested(self): 18 | self.assertTrue(is_sql_equal(['SELECT 1', 'SELECT 2'], ['SELECT 1', 'SELECT 2'])) 19 | self.assertFalse(is_sql_equal(['SELECT 1', 'SELECT 2'], ['SELECT 1', 'SELECT 3'])) 20 | 21 | def test_nested_with_params(self): 22 | self.assertTrue(is_sql_equal([('SELECT %s', [1]), ('SELECT %s', [2])], 23 | [('SELECT %s', [1]), ('SELECT %s', [2])])) 24 | self.assertFalse(is_sql_equal([('SELECT %s', [1]), ('SELECT %s', [2])], 25 | [('SELECT %s', [1]), ('SELECT %s', [3])])) 26 | 27 | def test_mixed_with_params(self): 28 | self.assertFalse(is_sql_equal([('SELECT %s', [1]), ('SELECT %s', [2])], 29 | ['SELECT 1', ('SELECT %s', [2])])) 30 | self.assertFalse(is_sql_equal(['SELECT 1', ('SELECT %s', [2])], 31 | ['SELECT 1', ('SELECT %s', [3])])) 32 | 33 | def test_mixed_nesting(self): 34 | self.assertTrue(is_sql_equal('SELECT 1', ['SELECT 1'])) 35 | self.assertFalse(is_sql_equal('SELECT 1', [('SELECT %s', [1])])) 36 | -------------------------------------------------------------------------------- /tests/test_app/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.conf.urls import url 5 | from django.contrib import admin 6 | 7 | urlpatterns = [ 8 | # Examples: 9 | # url(r'^$', 'test_project.views.home', name='home'), 10 | # url(r'^blog/', include('blog.urls')), 11 | 12 | url(r'^admin/', admin.site.urls), 13 | ] 14 | -------------------------------------------------------------------------------- /tests/test_app2/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'test_app2.apps.TestApp2Config' 2 | -------------------------------------------------------------------------------- /tests/test_app2/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestApp2Config(AppConfig): 5 | name = 'test_app2' 6 | verbose_name = 'Test App2' 7 | -------------------------------------------------------------------------------- /tests/test_app2/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/festicket/django-migrate-sql/344ea98a1e7a921d3ad63db237d445f42ab58158/tests/test_app2/migrations/__init__.py -------------------------------------------------------------------------------- /tests/test_app2/migrations_deps_delete/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import migrate_sql.operations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrate_sql.operations.CreateSQL( 15 | name='sale', 16 | sql='CREATE TYPE sale AS (arg1 int); -- 1', 17 | reverse_sql='DROP TYPE sale', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/test_app2/migrations_deps_delete/0002_auto_20160108_0041.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import migrate_sql.operations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('test_app', '0003_auto_20160108_0048'), 12 | ('test_app2', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrate_sql.operations.ReverseAlterSQL( 17 | name='sale', 18 | sql='DROP TYPE sale', 19 | reverse_sql='CREATE TYPE sale AS (arg1 int); -- 1', 20 | ), 21 | migrate_sql.operations.AlterSQL( 22 | name='sale', 23 | sql='CREATE TYPE sale AS (arg1 int, arg2 int); -- 2', 24 | reverse_sql='DROP TYPE sale', 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /tests/test_app2/migrations_deps_delete/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/festicket/django-migrate-sql/344ea98a1e7a921d3ad63db237d445f42ab58158/tests/test_app2/migrations_deps_delete/__init__.py -------------------------------------------------------------------------------- /tests/test_app2/migrations_deps_update/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import models, migrations 5 | import migrate_sql.operations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrate_sql.operations.CreateSQL( 15 | name='sale', 16 | sql='CREATE TYPE sale AS (arg1 int); -- 1', 17 | reverse_sql='DROP TYPE sale', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /tests/test_app2/migrations_deps_update/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/festicket/django-migrate-sql/344ea98a1e7a921d3ad63db237d445f42ab58158/tests/test_app2/migrations_deps_update/__init__.py -------------------------------------------------------------------------------- /tests/test_app2/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/festicket/django-migrate-sql/344ea98a1e7a921d3ad63db237d445f42ab58158/tests/test_app2/models.py -------------------------------------------------------------------------------- /tests/test_app2/sql_config.py: -------------------------------------------------------------------------------- 1 | sql_items = [ 2 | # TODO: Insert SQL items here. 3 | ] 4 | -------------------------------------------------------------------------------- /tests/test_app2/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib import admin 3 | 4 | urlpatterns = [ 5 | # Examples: 6 | # url(r'^$', 'test_project.views.home', name='home'), 7 | # url(r'^blog/', include('blog.urls')), 8 | 9 | url(r'^admin/', admin.site.urls), 10 | ] 11 | -------------------------------------------------------------------------------- /tests/test_project/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'test_app.apps.TestAppConfig' 2 | -------------------------------------------------------------------------------- /tests/test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_app project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/1.8/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/1.8/ref/settings/ 9 | """ 10 | 11 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 12 | import os 13 | BASE_DIR = os.path.dirname(os.path.dirname(__file__)) 14 | 15 | 16 | # Quick-start development settings - unsuitable for production 17 | # See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ 18 | 19 | # SECURITY WARNING: keep the secret key used in production secret! 20 | SECRET_KEY = 'maj(3fo0b^-ywd4)27qavl#p+j6(1uv)glr+3e4-p_$4_t6bki' 21 | 22 | # SECURITY WARNING: don't run with debug turned on in production! 23 | DEBUG = True 24 | 25 | TEMPLATES = [ 26 | { 27 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 28 | 'APP_DIRS': True, 29 | 'OPTIONS': { 30 | 'context_processors': ( 31 | 'django.template.context_processors.request', 32 | 'django.contrib.auth.context_processors.auth', 33 | 'django.contrib.messages.context_processors.messages', 34 | ), 35 | 'debug': True, 36 | }, 37 | 38 | }, 39 | ] 40 | 41 | ALLOWED_HOSTS = [] 42 | 43 | 44 | # Application definition 45 | 46 | INSTALLED_APPS = ( 47 | 'django.contrib.admin', 48 | 'django.contrib.auth', 49 | 'django.contrib.contenttypes', 50 | 'django.contrib.sessions', 51 | 'django.contrib.messages', 52 | 'django.contrib.staticfiles', 53 | 'migrate_sql', 54 | 'test_app', 55 | 'test_app2', 56 | ) 57 | 58 | MIDDLEWARE = ( 59 | 'django.contrib.sessions.middleware.SessionMiddleware', 60 | 'django.middleware.common.CommonMiddleware', 61 | 'django.middleware.csrf.CsrfViewMiddleware', 62 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 63 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 64 | 'django.contrib.messages.middleware.MessageMiddleware', 65 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 66 | ) 67 | 68 | ROOT_URLCONF = 'test_app.urls' 69 | 70 | WSGI_APPLICATION = 'test_app.wsgi.application' 71 | 72 | 73 | # Database 74 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 75 | 76 | DATABASES = { 77 | 'default': { 78 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 79 | 'NAME': 'migrate_sql_test_db', 80 | 'USER': 'postgres', 81 | 'HOST': 'localhost', 82 | 'PORT': 5432, 83 | } 84 | } 85 | 86 | # Internationalization 87 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 88 | 89 | LANGUAGE_CODE = 'en-us' 90 | 91 | TIME_ZONE = 'UTC' 92 | 93 | USE_I18N = True 94 | 95 | USE_L10N = True 96 | 97 | USE_TZ = True 98 | 99 | 100 | # Static files (CSS, JavaScript, Images) 101 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 102 | 103 | STATIC_URL = '/static/' 104 | 105 | try: 106 | from settings_local import * 107 | except ImportError: 108 | pass 109 | -------------------------------------------------------------------------------- /tests/test_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib import admin 3 | 4 | urlpatterns = [ 5 | # Examples: 6 | # url(r'^$', 'test_project.views.home', name='home'), 7 | # url(r'^blog/', include('blog.urls')), 8 | 9 | url(r'^admin/', admin.site.urls), 10 | ] 11 | -------------------------------------------------------------------------------- /tests/test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.7/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 12 | 13 | from django.core.wsgi import get_wsgi_application 14 | application = get_wsgi_application() 15 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{36,37}-django{21,22,30} 4 | 5 | [testenv] 6 | commands = 7 | coverage erase 8 | {envbindir}/coverage run runtests.py 9 | coverage combine 10 | deps= 11 | django20: Django>=2.0,<2.1 12 | django21: Django>=2.1,<2.2 13 | django22: Django>=2.2,<3.0 14 | django30: Django>=3.0,<3.1 15 | -rrequirements-testing.txt 16 | 17 | whitelist_externals = coverage 18 | --------------------------------------------------------------------------------