├── .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.1.1 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 | env: 4 | - TOX_ENV=py27-django18 5 | - TOX_ENV=py27-django19 6 | - TOX_ENV=py33-django18 7 | - TOX_ENV=py34-django18 8 | - TOX_ENV=py34-django19 9 | 10 | addons: 11 | postgesql: "9.3" 12 | 13 | services: 14 | - postgresql 15 | 16 | before_install: 17 | - pip install codecov 18 | 19 | install: 20 | - pip install tox 21 | 22 | before_script: 23 | - psql -c 'create database migrate_sql_test_db;' -U postgres 24 | 25 | script: 26 | - tox -e $TOX_ENV 27 | 28 | after_success: 29 | - codecov -e TOX_ENV 30 | -------------------------------------------------------------------------------- /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 2 | ================== 3 | 4 | |Build Status| |codecov.io| 5 | 6 | Django Migrations support for raw SQL. 7 | 8 | About 9 | ----- 10 | 11 | This tool implements mechanism for managing changes to custom SQL 12 | entities (functions, types, indices, triggers) using built-in migration 13 | mechanism. Technically creates a sophistication layer on top of the 14 | ``RunSQL`` Django operation. 15 | 16 | What it does 17 | ------------ 18 | 19 | - Makes maintaining your SQL functions, custom composite types, indices 20 | and triggers easier. 21 | - Structures SQL into configuration of **SQL items**, that are 22 | identified by names and divided among apps, just like models. 23 | - Automatically gathers and persists changes of your custom SQL into 24 | migrations using ``makemigrations``. 25 | - Properly executes backwards/forwards keeping integrity of database. 26 | - Create -> Drop -> Recreate approach for changes to items that do not 27 | support altering and require dropping and recreating. 28 | - Dependencies system for SQL items, which solves the problem of 29 | updating items, that rely on others (for example custom 30 | types/functions that use other custom types), and require dropping 31 | all dependency tree previously with further recreation. 32 | 33 | What it does not 34 | ---------------- 35 | 36 | - Does not parse SQL nor validate queries during ``makemigrations`` or 37 | ``migrate`` because is database-agnostic. For this same reason 38 | setting up proper dependencies is user's responsibility. 39 | - Does not create ``ALTER`` queries for items that support this, for 40 | example ``ALTER TYPE`` in Postgre SQL, because is database-agnostic. 41 | In case your tools allow rolling all the changes through ``ALTER`` 42 | queries, you can consider not using this app **or** restructure 43 | migrations manually after creation by nesting generated operations 44 | into ```state_operations`` of 45 | ``RunSQL`` `__ 46 | that does ``ALTER``. 47 | - (**TODO**)During ``migrate`` does not restore full state of items for 48 | analysis, thus does not notify about existing changes to schema that 49 | are not migrated **nor** does not recognize circular dependencies 50 | during migration execution. 51 | 52 | Installation 53 | ------------ 54 | 55 | Install from PyPi: 56 | 57 | :: 58 | 59 | $ pip install django-migrate-sql 60 | 61 | Add ``migrate_sql`` to ``INSTALLED_APPS``: 62 | 63 | .. code:: python 64 | 65 | INSTALLED_APPS = [ 66 | # ... 67 | 'migrate_sql', 68 | ] 69 | 70 | App defines a custom ``makemigrations`` command, that inherits from 71 | Django's core one, so in order ``migrate_sql`` app to kick in put it 72 | after any other apps that redefine ``makemigrations`` command too. 73 | 74 | Usage 75 | ----- 76 | 77 | 1) Create ``sql_config.py`` module to root of a target app you want to 78 | manage custom SQL for. 79 | 80 | 2) Define SQL items in it (``sql_items``), for example: 81 | 82 | .. code:: python 83 | 84 | # PostgreSQL example. 85 | # Let's define a simple function and let `migrate_sql` manage it's changes. 86 | 87 | from migrate_sql.config import SQLItem 88 | 89 | sql_items = [ 90 | SQLItem( 91 | 'make_sum', # name of the item 92 | 'create or replace function make_sum(a int, b int) returns int as $$ ' 93 | 'begin return a + b; end; ' 94 | '$$ language plpgsql;', # forward sql 95 | reverse_sql='drop function make_sum(int, int);', # sql for removal 96 | ), 97 | ] 98 | 99 | 3) Create migration ``./manage.py makemigrations``: 100 | 101 | :: 102 | 103 | Migrations for 'app_name': 104 | 0002_auto_xxxx.py: 105 | - Create SQL "make_sum" 106 | 107 | You can take a look at content this generated: 108 | 109 | .. code:: python 110 | 111 | # -*- coding: utf-8 -*- 112 | from __future__ import unicode_literals 113 | from django.db import migrations, models 114 | import migrate_sql.operations 115 | 116 | 117 | class Migration(migrations.Migration): 118 | dependencies = [ 119 | ('app_name', '0001_initial'), 120 | ] 121 | operations = [ 122 | migrate_sql.operations.CreateSQL( 123 | name='make_sum', 124 | sql='create or replace function make_sum(a int, b int) returns int as $$ begin return a + b; end; $$ language plpgsql;', 125 | reverse_sql='drop function make_sum(int, int);', 126 | ), 127 | ] 128 | 129 | 4) Execute migration ``./manage.py migrate``: 130 | 131 | :: 132 | 133 | Operations to perform: 134 | Apply all migrations: app_name 135 | Running migrations: 136 | Rendering model states... DONE 137 | Applying app_name.0002_xxxx... OK 138 | 139 | Check result in ``./manage.py dbshell``: 140 | 141 | :: 142 | 143 | db_name=# select make_sum(12, 15); 144 | make_sum 145 | ---------- 146 | 27 147 | (1 row) 148 | 149 | Now, say, you want to change the function implementation so that it 150 | takes a custom type as argument: 151 | 152 | 5) Edit your ``sql_config.py``: 153 | 154 | .. code:: python 155 | 156 | # PostgreSQL example #2. 157 | # Function and custom type. 158 | 159 | from migrate_sql.config import SQLItem 160 | 161 | sql_items = [ 162 | SQLItem( 163 | 'make_sum', # name of the item 164 | 'create or replace function make_sum(a mynum, b mynum) returns mynum as $$ ' 165 | 'begin return (a.num + b.num, 'result')::mynum; end; ' 166 | '$$ language plpgsql;', # forward sql 167 | reverse_sql='drop function make_sum(mynum, mynum);', # sql for removal 168 | # depends on `mynum` since takes it as argument. we won't be able to drop function 169 | # without dropping `mynum` first. 170 | dependencies=[('app_name', 'mynum')], 171 | ), 172 | SQLItem( 173 | 'mynum' # name of the item 174 | 'create type mynum as (num int, name varchar(20));', # forward sql 175 | reverse_sql='drop type mynum;', # sql for removal 176 | ), 177 | ] 178 | 179 | 6) Generate migration ``./manage.py makemigrations``: 180 | 181 | :: 182 | 183 | Migrations for 'app_name': 184 | 0003_xxxx: 185 | - Reverse alter SQL "make_sum" 186 | - Create SQL "mynum" 187 | - Alter SQL "make_sum" 188 | - Alter SQL state "make_sum" 189 | 190 | You can take a look at the content this generated: 191 | 192 | .. code:: python 193 | 194 | # -*- coding: utf-8 -*- 195 | from __future__ import unicode_literals 196 | from django.db import migrations, models 197 | import migrate_sql.operations 198 | 199 | 200 | class Migration(migrations.Migration): 201 | dependencies = [ 202 | ('app_name', '0002_xxxx'), 203 | ] 204 | operations = [ 205 | migrate_sql.operations.ReverseAlterSQL( 206 | name='make_sum', 207 | sql='drop function make_sum(int, int);', 208 | reverse_sql='create or replace function make_sum(a int, b int) returns int as $$ begin return a + b; end; $$ language plpgsql;', 209 | ), 210 | migrate_sql.operations.CreateSQL( 211 | name='mynum', 212 | sql='create type mynum as (num int, name varchar(20));', 213 | reverse_sql='drop type mynum;', 214 | ), 215 | migrate_sql.operations.AlterSQL( 216 | name='make_sum', 217 | 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;', 218 | reverse_sql='drop function make_sum(mynum, mynum);', 219 | ), 220 | migrate_sql.operations.AlterSQLState( 221 | name='make_sum', 222 | add_dependencies=(('app_name', 'mynum'),), 223 | ), 224 | ] 225 | 226 | ***NOTE:** Previous function is completely dropped before creation 227 | because definition of it changed. ``CREATE OR REPLACE`` would create 228 | another version of it, so ``DROP`` makes it clean.* 229 | 230 | ***If you put ``replace=True`` as kwarg to an ``SQLItem`` definition, it 231 | will NOT drop + create it, but just rerun forward SQL, which is 232 | ``CREATE OR REPLACE`` in this example.*** 233 | 234 | 7) Execute migration ``./manage.py migrate``: 235 | 236 | :: 237 | 238 | Operations to perform: 239 | Apply all migrations: app_name 240 | Running migrations: 241 | Rendering model states... DONE 242 | Applying brands.0003_xxxx... OK 243 | 244 | Check results: 245 | 246 | :: 247 | 248 | db_name=# select make_sum((5, 'a')::mynum, (3, 'b')::mynum); 249 | make_sum 250 | ------------ 251 | (8,result) 252 | (1 row) 253 | 254 | db_name=# select make_sum(12, 15); 255 | ERROR: function make_sum(integer, integer) does not exist 256 | LINE 1: select make_sum(12, 15); 257 | ^ 258 | HINT: No function matches the given name and argument types. You might need to add explicit type casts. 259 | 260 | For more examples see ``tests``. 261 | 262 | Feel free to `open new 263 | issues `__. 264 | 265 | .. |Build Status| image:: https://travis-ci.org/klichukb/django-migrate-sql.svg?branch=master 266 | :target: https://travis-ci.org/klichukb/django-migrate-sql 267 | .. |codecov.io| image:: https://img.shields.io/codecov/c/github/klichukb/django-migrate-sql/master.svg 268 | :target: https://codecov.io/github/klichukb/django-migrate-sql?branch=master 269 | -------------------------------------------------------------------------------- /migrate_sql/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | __version__ = '0.1.1' 5 | -------------------------------------------------------------------------------- /migrate_sql/autodetector.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db.migrations.autodetector import MigrationAutodetector as DjangoMigrationAutodetector 5 | from django.db.migrations.operations import RunSQL 6 | 7 | from migrate_sql.operations import (AlterSQL, ReverseAlterSQL, CreateSQL, DeleteSQL, AlterSQLState) 8 | from migrate_sql.graph import SQLStateGraph 9 | 10 | 11 | class SQLBlob(object): 12 | pass 13 | 14 | # Dummy object used to identify django dependency as the one used by this tool only. 15 | SQL_BLOB = SQLBlob() 16 | 17 | 18 | def _sql_params(sql): 19 | """ 20 | Identify `sql` as either SQL string or 2-tuple of SQL and params. 21 | Same format as supported by Django's RunSQL operation for sql/reverse_sql. 22 | """ 23 | params = None 24 | if isinstance(sql, (list, tuple)): 25 | elements = len(sql) 26 | if elements == 2: 27 | sql, params = sql 28 | else: 29 | raise ValueError("Expected a 2-tuple but got %d" % elements) 30 | return sql, params 31 | 32 | 33 | def is_sql_equal(sqls1, sqls2): 34 | """ 35 | Find out equality of two SQL items. 36 | 37 | See https://docs.djangoproject.com/en/1.8/ref/migration-operations/#runsql. 38 | Args: 39 | sqls1, sqls2: SQL items, have the same format as supported by Django's RunSQL operation. 40 | Returns: 41 | (bool) `True` if equal, otherwise `False`. 42 | """ 43 | is_seq1 = isinstance(sqls1, (list, tuple)) 44 | is_seq2 = isinstance(sqls2, (list, tuple)) 45 | 46 | if not is_seq1: 47 | sqls1 = (sqls1,) 48 | if not is_seq2: 49 | sqls2 = (sqls2,) 50 | 51 | if len(sqls1) != len(sqls2): 52 | return False 53 | 54 | for sql1, sql2 in zip(sqls1, sqls2): 55 | sql1, params1 = _sql_params(sql1) 56 | sql2, params2 = _sql_params(sql2) 57 | if sql1 != sql2 or params1 != params2: 58 | return False 59 | return True 60 | 61 | 62 | class MigrationAutodetector(DjangoMigrationAutodetector): 63 | """ 64 | Substitutes Django's MigrationAutodetector class, injecting SQL migrations logic. 65 | """ 66 | def __init__(self, from_state, to_state, questioner=None, to_sql_graph=None): 67 | super(MigrationAutodetector, self).__init__(from_state, to_state, questioner) 68 | self.to_sql_graph = to_sql_graph 69 | self.from_sql_graph = getattr(self.from_state, 'sql_state', None) or SQLStateGraph() 70 | self.from_sql_graph.build_graph() 71 | self._sql_operations = [] 72 | 73 | def assemble_changes(self, keys, resolve_keys, sql_state): 74 | """ 75 | Accepts keys of SQL items available, sorts them and adds additional dependencies. 76 | Uses graph of `sql_state` nodes to build `keys` and `resolve_keys` into sequence that 77 | starts with leaves (items that have not dependents) and ends with roots. 78 | 79 | Changes `resolve_keys` argument as dependencies are added to the result. 80 | 81 | Args: 82 | keys (list): List of migration keys, that are one of create/delete operations, and 83 | dont require respective reverse operations. 84 | resolve_keys (list): List of migration keys, that are changing existing items, 85 | and may require respective reverse operations. 86 | sql_sate (graph.SQLStateGraph): State of SQL items. 87 | Returns: 88 | (list) Sorted sequence of migration keys, enriched with dependencies. 89 | """ 90 | result_keys = [] 91 | all_keys = keys | resolve_keys 92 | for key in all_keys: 93 | node = sql_state.node_map[key] 94 | sql_item = sql_state.nodes[key] 95 | ancs = node.ancestors()[:-1] 96 | ancs.reverse() 97 | pos = next((i for i, k in enumerate(result_keys) if k in ancs), len(result_keys)) 98 | result_keys.insert(pos, key) 99 | 100 | if key in resolve_keys and not sql_item.replace: 101 | # ancestors() and descendants() include key itself, need to cut it out. 102 | descs = reversed(node.descendants()[:-1]) 103 | for desc in descs: 104 | if desc not in all_keys and desc not in result_keys: 105 | result_keys.insert(pos, desc) 106 | # these items added may also need reverse operations. 107 | resolve_keys.add(desc) 108 | return result_keys 109 | 110 | def add_sql_operation(self, app_label, sql_name, operation, dependencies): 111 | """ 112 | Add SQL operation and register it to be used as dependency for further 113 | sequential operations. 114 | """ 115 | deps = [(dp[0], SQL_BLOB, dp[1], self._sql_operations.get(dp)) for dp in dependencies] 116 | 117 | self.add_operation(app_label, operation, dependencies=deps) 118 | self._sql_operations[(app_label, sql_name)] = operation 119 | 120 | def _generate_reversed_sql(self, keys, changed_keys): 121 | """ 122 | Generate reversed operations for changes, that require full rollback and creation. 123 | """ 124 | for key in keys: 125 | if key not in changed_keys: 126 | continue 127 | app_label, sql_name = key 128 | old_item = self.from_sql_graph.nodes[key] 129 | new_item = self.to_sql_graph.nodes[key] 130 | if not old_item.reverse_sql or old_item.reverse_sql == RunSQL.noop or new_item.replace: 131 | continue 132 | 133 | # migrate backwards 134 | operation = ReverseAlterSQL(sql_name, old_item.reverse_sql, reverse_sql=old_item.sql) 135 | sql_deps = [n.key for n in self.from_sql_graph.node_map[key].children] 136 | sql_deps.append(key) 137 | self.add_sql_operation(app_label, sql_name, operation, sql_deps) 138 | 139 | def _generate_sql(self, keys, changed_keys): 140 | """ 141 | Generate forward operations for changing/creating SQL items. 142 | """ 143 | for key in reversed(keys): 144 | app_label, sql_name = key 145 | new_item = self.to_sql_graph.nodes[key] 146 | sql_deps = [n.key for n in self.to_sql_graph.node_map[key].parents] 147 | reverse_sql = new_item.reverse_sql 148 | 149 | if key in changed_keys: 150 | operation_cls = AlterSQL 151 | kwargs = {} 152 | # in case of replace mode, AlterSQL will hold sql, reverse_sql and 153 | # state_reverse_sql, the latter one will be used for building state forward 154 | # instead of reverse_sql. 155 | if new_item.replace: 156 | kwargs['state_reverse_sql'] = reverse_sql 157 | reverse_sql = self.from_sql_graph.nodes[key].sql 158 | else: 159 | operation_cls = CreateSQL 160 | kwargs = {'dependencies': list(sql_deps)} 161 | 162 | operation = operation_cls( 163 | sql_name, new_item.sql, reverse_sql=reverse_sql, **kwargs) 164 | sql_deps.append(key) 165 | self.add_sql_operation(app_label, sql_name, operation, sql_deps) 166 | 167 | def _generate_altered_sql_dependencies(self, dep_changed_keys): 168 | """ 169 | Generate forward operations for changing/creating SQL item dependencies. 170 | 171 | Dependencies are only in-memory and should be reflecting database dependencies, so 172 | changing them in SQL config does not alter database. Such actions are persisted in separate 173 | type operation - `AlterSQLState`. 174 | 175 | Args: 176 | dep_changed_keys (list): Data about keys, that have their dependencies changed. 177 | List of tuples (key, removed depndencies, added_dependencies). 178 | """ 179 | for key, removed_deps, added_deps in dep_changed_keys: 180 | app_label, sql_name = key 181 | operation = AlterSQLState(sql_name, add_dependencies=tuple(added_deps), 182 | remove_dependencies=tuple(removed_deps)) 183 | sql_deps = [key] 184 | self.add_sql_operation(app_label, sql_name, operation, sql_deps) 185 | 186 | def _generate_delete_sql(self, delete_keys): 187 | """ 188 | Generate forward delete operations for SQL items. 189 | """ 190 | for key in delete_keys: 191 | app_label, sql_name = key 192 | old_node = self.from_sql_graph.nodes[key] 193 | operation = DeleteSQL(sql_name, old_node.reverse_sql, reverse_sql=old_node.sql) 194 | sql_deps = [n.key for n in self.from_sql_graph.node_map[key].children] 195 | sql_deps.append(key) 196 | self.add_sql_operation(app_label, sql_name, operation, sql_deps) 197 | 198 | def generate_sql_changes(self): 199 | """ 200 | Starting point of this tool, which identifies changes and generates respective 201 | operations. 202 | """ 203 | from_keys = set(self.from_sql_graph.nodes.keys()) 204 | to_keys = set(self.to_sql_graph.nodes.keys()) 205 | new_keys = to_keys - from_keys 206 | delete_keys = from_keys - to_keys 207 | changed_keys = set() 208 | dep_changed_keys = [] 209 | 210 | for key in from_keys & to_keys: 211 | old_node = self.from_sql_graph.nodes[key] 212 | new_node = self.to_sql_graph.nodes[key] 213 | 214 | # identify SQL changes -- these will alter database. 215 | if not is_sql_equal(old_node.sql, new_node.sql): 216 | changed_keys.add(key) 217 | 218 | # identify dependencies change 219 | old_deps = self.from_sql_graph.dependencies[key] 220 | new_deps = self.to_sql_graph.dependencies[key] 221 | removed_deps = old_deps - new_deps 222 | added_deps = new_deps - old_deps 223 | if removed_deps or added_deps: 224 | dep_changed_keys.append((key, removed_deps, added_deps)) 225 | 226 | # we do basic sort here and inject dependency keys here. 227 | # operations built using these keys will properly set operation dependencies which will 228 | # enforce django to build/keep a correct order of operations (stable_topological_sort). 229 | keys = self.assemble_changes(new_keys, changed_keys, self.to_sql_graph) 230 | delete_keys = self.assemble_changes(delete_keys, set(), self.from_sql_graph) 231 | 232 | self._sql_operations = {} 233 | self._generate_reversed_sql(keys, changed_keys) 234 | self._generate_sql(keys, changed_keys) 235 | self._generate_delete_sql(delete_keys) 236 | self._generate_altered_sql_dependencies(dep_changed_keys) 237 | 238 | def check_dependency(self, operation, dependency): 239 | """ 240 | Enhances default behavior of method by checking dependency for matching operation. 241 | """ 242 | if isinstance(dependency[1], SQLBlob): 243 | # NOTE: we follow the sort order created by `assemble_changes` so we build a fixed chain 244 | # of operations. thus we should match exact operation here. 245 | return dependency[3] == operation 246 | return super(MigrationAutodetector, self).check_dependency(operation, dependency) 247 | 248 | def generate_altered_fields(self): 249 | """ 250 | Injecting point. This is quite awkward, and i'm looking forward Django for having the logic 251 | divided into smaller methods/functions for easier enhancement and substitution. 252 | So far we're doing all the SQL magic in this method. 253 | """ 254 | result = super(MigrationAutodetector, self).generate_altered_fields() 255 | self.generate_sql_changes() 256 | return result 257 | -------------------------------------------------------------------------------- /migrate_sql/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | 5 | class SQLItem(object): 6 | """ 7 | Represents any SQL entity (unit), for example function, type, index or trigger. 8 | """ 9 | def __init__(self, name, sql, reverse_sql=None, dependencies=None, replace=False): 10 | """ 11 | Args: 12 | name (str): Name of the SQL item. Should be unique among other items in the current 13 | application. It is the name that other items can refer to. 14 | sql (str/tuple): Forward SQL that creates entity. 15 | drop_sql (str/tuple, optional): Backward SQL that destroyes entity. (DROPs). 16 | dependencies (list, optional): Collection of item keys, that the current one depends on. 17 | Each element is a tuple of two: (app, item_name). Order does not matter. 18 | replace (bool, optional): If `True`, further migrations will not drop previous version 19 | of item before creating, assuming that a forward SQL replaces. For example Postgre's 20 | `create or replace function` which does not require dropping it previously. 21 | If `False` then each changed item will get two operations: dropping previous version 22 | and creating new one. 23 | Default = `False`. 24 | """ 25 | self.name = name 26 | self.sql = sql 27 | self.reverse_sql = reverse_sql 28 | self.dependencies = dependencies or [] 29 | self.replace = replace 30 | -------------------------------------------------------------------------------- /migrate_sql/graph.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from collections import defaultdict 5 | from importlib import import_module 6 | 7 | from django.db.migrations.graph import Node, NodeNotFoundError, CircularDependencyError 8 | from django.conf import settings 9 | from django.apps import apps 10 | 11 | SQL_CONFIG_MODULE = settings.__dict__.get('SQL_CONFIG_MODULE', 'sql_config') 12 | 13 | 14 | class SQLStateGraph(object): 15 | """ 16 | Represents graph assembled by SQL items as nodes and parent-child relations as arcs. 17 | """ 18 | def __init__(self): 19 | self.nodes = {} 20 | self.node_map = {} 21 | self.dependencies = defaultdict(set) 22 | 23 | def remove_node(self, key): 24 | # XXX: Workaround for Issue #2 25 | # Silences state aggregation problem in `migrate` command. 26 | if key in self.nodes and key in self.node_map: 27 | del self.nodes[key] 28 | del self.node_map[key] 29 | 30 | def add_node(self, key, sql_item): 31 | node = Node(key) 32 | self.node_map[key] = node 33 | self.nodes[key] = sql_item 34 | 35 | def add_lazy_dependency(self, child, parent): 36 | """ 37 | Add dependency to be resolved and applied later. 38 | """ 39 | self.dependencies[child].add(parent) 40 | 41 | def remove_lazy_dependency(self, child, parent): 42 | """ 43 | Add dependency to be resolved and applied later. 44 | """ 45 | self.dependencies[child].remove(parent) 46 | 47 | def remove_lazy_for_child(self, child): 48 | """ 49 | Remove dependency to be resolved and applied later. 50 | """ 51 | if child in self.dependencies: 52 | del self.dependencies[child] 53 | 54 | def build_graph(self): 55 | """ 56 | Read lazy dependency list and build graph. 57 | """ 58 | for child, parents in self.dependencies.items(): 59 | if child not in self.nodes: 60 | raise NodeNotFoundError( 61 | "App %s SQL item dependencies reference nonexistent child node %r" % ( 62 | child[0], child), 63 | child 64 | ) 65 | for parent in parents: 66 | if parent not in self.nodes: 67 | raise NodeNotFoundError( 68 | "App %s SQL item dependencies reference nonexistent parent node %r" % ( 69 | child[0], parent), 70 | parent 71 | ) 72 | self.node_map[child].add_parent(self.node_map[parent]) 73 | self.node_map[parent].add_child(self.node_map[child]) 74 | 75 | for node in self.nodes: 76 | self.ensure_not_cyclic(node, 77 | lambda x: (parent.key for parent in self.node_map[x].parents)) 78 | 79 | def ensure_not_cyclic(self, start, get_children): 80 | # Algo from GvR: 81 | # http://neopythonic.blogspot.co.uk/2009/01/detecting-cycles-in-directed-graph.html 82 | todo = set(self.nodes) 83 | while todo: 84 | node = todo.pop() 85 | stack = [node] 86 | while stack: 87 | top = stack[-1] 88 | for node in get_children(top): 89 | if node in stack: 90 | cycle = stack[stack.index(node):] 91 | raise CircularDependencyError(", ".join("%s.%s" % n for n in cycle)) 92 | if node in todo: 93 | stack.append(node) 94 | todo.remove(node) 95 | break 96 | else: 97 | node = stack.pop() 98 | 99 | 100 | def build_current_graph(): 101 | """ 102 | Read current state of SQL items from the current project state. 103 | 104 | Returns: 105 | (SQLStateGraph) Current project state graph. 106 | """ 107 | graph = SQLStateGraph() 108 | for app_name, config in apps.app_configs.items(): 109 | try: 110 | module = import_module( 111 | '.'.join((config.module.__name__, SQL_CONFIG_MODULE))) 112 | sql_items = module.sql_items 113 | except (ImportError, AttributeError): 114 | continue 115 | for sql_item in sql_items: 116 | graph.add_node((app_name, sql_item.name), sql_item) 117 | 118 | for dep in sql_item.dependencies: 119 | graph.add_lazy_dependency((app_name, sql_item.name), dep) 120 | 121 | graph.build_graph() 122 | return graph 123 | -------------------------------------------------------------------------------- /migrate_sql/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klichukb/django-migrate-sql/be48ff2c9283404e3d951128c459c3496d1ba25d/migrate_sql/management/__init__.py -------------------------------------------------------------------------------- /migrate_sql/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klichukb/django-migrate-sql/be48ff2c9283404e3d951128c459c3496d1ba25d/migrate_sql/management/commands/__init__.py -------------------------------------------------------------------------------- /migrate_sql/management/commands/makemigrations.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | """ 5 | Replaces built-in Django command and forces it generate SQL item modification operations 6 | into regular Django migrations. 7 | """ 8 | 9 | import sys 10 | 11 | from django.core.management.commands.makemigrations import Command as MakeMigrationsCommand 12 | from django.db.migrations.loader import MigrationLoader 13 | from django.db.migrations import Migration 14 | from django.core.management.base import CommandError 15 | from django.db.migrations.questioner import InteractiveMigrationQuestioner 16 | from django.apps import apps 17 | from django.db.migrations.state import ProjectState 18 | from django.utils.six import iteritems 19 | 20 | from migrate_sql.autodetector import MigrationAutodetector 21 | from migrate_sql.graph import build_current_graph 22 | 23 | 24 | class Command(MakeMigrationsCommand): 25 | 26 | def handle(self, *app_labels, **options): 27 | 28 | self.verbosity = options.get('verbosity') 29 | self.interactive = options.get('interactive') 30 | self.dry_run = options.get('dry_run', False) 31 | self.merge = options.get('merge', False) 32 | self.empty = options.get('empty', False) 33 | self.migration_name = options.get('name', None) 34 | self.exit_code = options.get('exit_code', False) 35 | 36 | # Make sure the app they asked for exists 37 | app_labels = set(app_labels) 38 | bad_app_labels = set() 39 | for app_label in app_labels: 40 | try: 41 | apps.get_app_config(app_label) 42 | except LookupError: 43 | bad_app_labels.add(app_label) 44 | if bad_app_labels: 45 | for app_label in bad_app_labels: 46 | self.stderr.write("App '%s' could not be found. Is it in INSTALLED_APPS?" % app_label) 47 | sys.exit(2) 48 | 49 | # Load the current graph state. Pass in None for the connection so 50 | # the loader doesn't try to resolve replaced migrations from DB. 51 | loader = MigrationLoader(None, ignore_no_migrations=True) 52 | 53 | # Before anything else, see if there's conflicting apps and drop out 54 | # hard if there are any and they don't want to merge 55 | conflicts = loader.detect_conflicts() 56 | 57 | # If app_labels is specified, filter out conflicting migrations for unspecified apps 58 | if app_labels: 59 | conflicts = { 60 | app_label: conflict for app_label, conflict in iteritems(conflicts) 61 | if app_label in app_labels 62 | } 63 | 64 | if conflicts and not self.merge: 65 | name_str = "; ".join( 66 | "%s in %s" % (", ".join(names), app) 67 | for app, names in conflicts.items() 68 | ) 69 | raise CommandError( 70 | "Conflicting migrations detected (%s).\nTo fix them run " 71 | "'python manage.py makemigrations --merge'" % name_str 72 | ) 73 | 74 | # If they want to merge and there's nothing to merge, then politely exit 75 | if self.merge and not conflicts: 76 | self.stdout.write("No conflicts detected to merge.") 77 | return 78 | 79 | # If they want to merge and there is something to merge, then 80 | # divert into the merge code 81 | if self.merge and conflicts: 82 | return self.handle_merge(loader, conflicts) 83 | 84 | state = loader.project_state() 85 | 86 | # NOTE: customization. Passing graph to autodetector. 87 | sql_graph = build_current_graph() 88 | 89 | # Set up autodetector 90 | autodetector = MigrationAutodetector( 91 | state, 92 | ProjectState.from_apps(apps), 93 | InteractiveMigrationQuestioner(specified_apps=app_labels, dry_run=self.dry_run), 94 | sql_graph, 95 | ) 96 | 97 | # If they want to make an empty migration, make one for each app 98 | if self.empty: 99 | if not app_labels: 100 | raise CommandError("You must supply at least one app label when using --empty.") 101 | # Make a fake changes() result we can pass to arrange_for_graph 102 | changes = { 103 | app: [Migration("custom", app)] 104 | for app in app_labels 105 | } 106 | changes = autodetector.arrange_for_graph( 107 | changes=changes, 108 | graph=loader.graph, 109 | migration_name=self.migration_name, 110 | ) 111 | self.write_migration_files(changes) 112 | return 113 | 114 | # Detect changes 115 | changes = autodetector.changes( 116 | graph=loader.graph, 117 | trim_to_apps=app_labels or None, 118 | convert_apps=app_labels or None, 119 | migration_name=self.migration_name, 120 | ) 121 | 122 | if not changes: 123 | # No changes? Tell them. 124 | if self.verbosity >= 1: 125 | if len(app_labels) == 1: 126 | self.stdout.write("No changes detected in app '%s'" % app_labels.pop()) 127 | elif len(app_labels) > 1: 128 | self.stdout.write("No changes detected in apps '%s'" % ("', '".join(app_labels))) 129 | else: 130 | self.stdout.write("No changes detected") 131 | 132 | if self.exit_code: 133 | sys.exit(1) 134 | else: 135 | return 136 | 137 | self.write_migration_files(changes) 138 | -------------------------------------------------------------------------------- /migrate_sql/operations.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db.migrations.operations import RunSQL 5 | from django.db.migrations.operations.base import Operation 6 | 7 | from migrate_sql.graph import SQLStateGraph 8 | from migrate_sql.config import SQLItem 9 | 10 | 11 | class MigrateSQLMixin(object): 12 | def get_sql_state(self, state): 13 | """ 14 | Get SQLStateGraph from state. 15 | """ 16 | if not hasattr(state, 'sql_state'): 17 | setattr(state, 'sql_state', SQLStateGraph()) 18 | return state.sql_state 19 | 20 | 21 | class AlterSQLState(MigrateSQLMixin, Operation): 22 | """ 23 | Alters in-memory state of SQL item. 24 | This operation is generated separately from others since it does not affect database. 25 | """ 26 | def describe(self): 27 | return 'Alter SQL state "{name}"'.format(name=self.name) 28 | 29 | def deconstruct(self): 30 | kwargs = { 31 | 'name': self.name, 32 | } 33 | if self.add_dependencies: 34 | kwargs['add_dependencies'] = self.add_dependencies 35 | if self.remove_dependencies: 36 | kwargs['remove_dependencies'] = self.remove_dependencies 37 | return (self.__class__.__name__, [], kwargs) 38 | 39 | def state_forwards(self, app_label, state): 40 | sql_state = self.get_sql_state(state) 41 | key = (app_label, self.name) 42 | 43 | if key not in sql_state.nodes: 44 | # XXX: dummy for `migrate` command, that does not preserve state object. 45 | # Should fail with error when fixed. 46 | return 47 | 48 | sql_item = sql_state.nodes[key] 49 | 50 | for dep in self.add_dependencies: 51 | # we are also adding relations to aggregated SQLItem, but only to restore 52 | # original items. Still using graph for advanced node/arc manipulations. 53 | 54 | # XXX: dummy `if` for `migrate` command, that does not preserve state object. 55 | # Fail with error when fixed 56 | if dep in sql_item.dependencies: 57 | sql_item.dependencies.remove(dep) 58 | sql_state.add_lazy_dependency(key, dep) 59 | 60 | for dep in self.remove_dependencies: 61 | sql_item.dependencies.append(dep) 62 | sql_state.remove_lazy_dependency(key, dep) 63 | 64 | def database_forwards(self, app_label, schema_editor, from_state, to_state): 65 | pass 66 | 67 | def database_backwards(self, app_label, schema_editor, from_state, to_state): 68 | pass 69 | 70 | @property 71 | def reversible(self): 72 | return True 73 | 74 | def __init__(self, name, add_dependencies=None, remove_dependencies=None): 75 | """ 76 | Args: 77 | name (str): Name of SQL item in current application to alter state for. 78 | add_dependencies (list): 79 | Unordered list of dependencies to add to state. 80 | remove_dependencies (list): 81 | Unordered list of dependencies to remove from state. 82 | """ 83 | self.name = name 84 | self.add_dependencies = add_dependencies or () 85 | self.remove_dependencies = remove_dependencies or () 86 | 87 | 88 | class BaseAlterSQL(MigrateSQLMixin, RunSQL): 89 | """ 90 | Base class for operations that alter database. 91 | """ 92 | def __init__(self, name, sql, reverse_sql=None, state_operations=None, hints=None): 93 | super(BaseAlterSQL, self).__init__(sql, reverse_sql=reverse_sql, 94 | state_operations=state_operations, hints=hints) 95 | self.name = name 96 | 97 | def deconstruct(self): 98 | name, args, kwargs = super(BaseAlterSQL, self).deconstruct() 99 | kwargs['name'] = self.name 100 | return (name, args, kwargs) 101 | 102 | 103 | class ReverseAlterSQL(BaseAlterSQL): 104 | def describe(self): 105 | return 'Reverse alter SQL "{name}"'.format(name=self.name) 106 | 107 | 108 | class AlterSQL(BaseAlterSQL): 109 | """ 110 | Updates SQL item with a new version. 111 | """ 112 | def __init__(self, name, sql, reverse_sql=None, state_operations=None, hints=None, 113 | state_reverse_sql=None): 114 | """ 115 | Args: 116 | name (str): Name of SQL item in current application to alter state for. 117 | sql (str/list): Forward SQL for item creation. 118 | reverse_sql (str/list): Backward SQL for reversing create operation. 119 | state_reverse_sql (str/list): Backward SQL used to alter state of backward SQL 120 | *instead* of `reverse_sql`. Used for operations generated for items with 121 | `replace` = `True`. 122 | """ 123 | super(AlterSQL, self).__init__(name, sql, reverse_sql=reverse_sql, 124 | state_operations=state_operations, hints=hints) 125 | self.state_reverse_sql = state_reverse_sql 126 | 127 | def deconstruct(self): 128 | name, args, kwargs = super(AlterSQL, self).deconstruct() 129 | kwargs['name'] = self.name 130 | if self.state_reverse_sql: 131 | kwargs['state_reverse_sql'] = self.state_reverse_sql 132 | return (name, args, kwargs) 133 | 134 | def describe(self): 135 | return 'Alter SQL "{name}"'.format(name=self.name) 136 | 137 | def state_forwards(self, app_label, state): 138 | super(AlterSQL, self).state_forwards(app_label, state) 139 | sql_state = self.get_sql_state(state) 140 | key = (app_label, self.name) 141 | 142 | if key not in sql_state.nodes: 143 | # XXX: dummy for `migrate` command, that does not preserve state object. 144 | # Fail with error when fixed 145 | return 146 | 147 | sql_item = sql_state.nodes[key] 148 | sql_item.sql = self.sql 149 | sql_item.reverse_sql = self.state_reverse_sql or self.reverse_sql 150 | 151 | 152 | class CreateSQL(BaseAlterSQL): 153 | """ 154 | Creates new SQL item in database. 155 | """ 156 | def describe(self): 157 | return 'Create SQL "{name}"'.format(name=self.name) 158 | 159 | def deconstruct(self): 160 | name, args, kwargs = super(CreateSQL, self).deconstruct() 161 | kwargs['name'] = self.name 162 | if self.dependencies: 163 | kwargs['dependencies'] = self.dependencies 164 | return (name, args, kwargs) 165 | 166 | def __init__(self, name, sql, reverse_sql=None, state_operations=None, hints=None, 167 | dependencies=None): 168 | super(CreateSQL, self).__init__(name, sql, reverse_sql=reverse_sql, 169 | state_operations=state_operations, hints=hints) 170 | self.dependencies = dependencies or () 171 | 172 | def state_forwards(self, app_label, state): 173 | super(CreateSQL, self).state_forwards(app_label, state) 174 | sql_state = self.get_sql_state(state) 175 | 176 | sql_state.add_node( 177 | (app_label, self.name), 178 | SQLItem(self.name, self.sql, self.reverse_sql, list(self.dependencies)), 179 | ) 180 | 181 | for dep in self.dependencies: 182 | sql_state.add_lazy_dependency((app_label, self.name), dep) 183 | 184 | 185 | class DeleteSQL(BaseAlterSQL): 186 | """ 187 | Deltes SQL item from database. 188 | """ 189 | def describe(self): 190 | return 'Delete SQL "{name}"'.format(name=self.name) 191 | 192 | def state_forwards(self, app_label, state): 193 | super(DeleteSQL, self).state_forwards(app_label, state) 194 | sql_state = self.get_sql_state(state) 195 | 196 | sql_state.remove_node((app_label, self.name)) 197 | sql_state.remove_lazy_for_child((app_label, self.name)) 198 | -------------------------------------------------------------------------------- /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 | # -*- coding: utf-8 -*- 3 | from __future__ import unicode_literals 4 | 5 | import os 6 | import sys 7 | 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.test_project.settings") 9 | sys.path.insert(0, 'tests') 10 | 11 | import django 12 | from django.core.management import call_command 13 | 14 | django.setup() 15 | 16 | call_command('test') 17 | 18 | sys.exit(0) 19 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import re 5 | import os 6 | import sys 7 | 8 | from setuptools import setup, find_packages 9 | from setuptools.command.test import test as TestCommand 10 | 11 | 12 | class Tox(TestCommand): 13 | user_options = [('tox-args=', 'a', "Arguments to pass to tox")] 14 | 15 | def initialize_options(self): 16 | TestCommand.initialize_options(self) 17 | self.tox_args = None 18 | 19 | def finalize_options(self): 20 | TestCommand.finalize_options(self) 21 | self.test_args = [] 22 | self.test_suite = True 23 | 24 | def run_tests(self): 25 | import tox 26 | import shlex 27 | 28 | args = self.tox_args 29 | if args: 30 | args = shlex.split(self.tox_args) 31 | errno = tox.cmdline(args=args) 32 | sys.exit(errno) 33 | 34 | 35 | def get_version(package): 36 | """ 37 | Get migrate_sql version as listed in `__version__` in `__init__.py`. 38 | """ 39 | init_py = open(os.path.join(package, '__init__.py')).read() 40 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 41 | 42 | 43 | with open('README.rst') as readme_file: 44 | readme = readme_file.read() 45 | 46 | 47 | VERSION = get_version('migrate_sql') 48 | 49 | setup( 50 | name='django-migrate-sql', 51 | version=VERSION, 52 | description='Migration support for raw SQL in Django', 53 | long_description=readme, 54 | author='Bogdan Klichuk', 55 | author_email='klichukb@gmail.com', 56 | packages=find_packages(), 57 | package_dir={'migrate_sql': 'migrate_sql'}, 58 | license='BSD', 59 | zip_safe=False, 60 | url='https://github.com/klichukb/django-migrate-sql', 61 | classifiers=[ 62 | 'Development Status :: 3 - Alpha', 63 | 'Framework :: Django', 64 | 'Intended Audience :: Developers', 65 | 'License :: OSI Approved :: BSD License', 66 | 'Natural Language :: English', 67 | 'Programming Language :: Python :: 2.7', 68 | 'Programming Language :: Python :: 3.3', 69 | 'Programming Language :: Python :: 3.4', 70 | 'Programming Language :: Python :: 3.5', 71 | ], 72 | tests_require=['tox'], 73 | cmdclass={'test': Tox}, 74 | install_requires=[], 75 | ) 76 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klichukb/django-migrate-sql/be48ff2c9283404e3d951128c459c3496d1ba25d/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/klichukb/django-migrate-sql/be48ff2c9283404e3d951128c459c3496d1ba25d/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/klichukb/django-migrate-sql/be48ff2c9283404e3d951128c459c3496d1ba25d/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/klichukb/django-migrate-sql/be48ff2c9283404e3d951128c459c3496d1ba25d/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/klichukb/django-migrate-sql/be48ff2c9283404e3d951128c459c3496d1ba25d/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/klichukb/django-migrate-sql/be48ff2c9283404e3d951128c459c3496d1ba25d/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 patterns, include, url 5 | from django.contrib import admin 6 | 7 | urlpatterns = patterns('', 8 | # Examples: 9 | # url(r'^$', 'test_project.views.home', name='home'), 10 | # url(r'^blog/', include('blog.urls')), 11 | 12 | url(r'^admin/', include(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/klichukb/django-migrate-sql/be48ff2c9283404e3d951128c459c3496d1ba25d/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/klichukb/django-migrate-sql/be48ff2c9283404e3d951128c459c3496d1ba25d/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/klichukb/django-migrate-sql/be48ff2c9283404e3d951128c459c3496d1ba25d/tests/test_app2/migrations_deps_update/__init__.py -------------------------------------------------------------------------------- /tests/test_app2/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klichukb/django-migrate-sql/be48ff2c9283404e3d951128c459c3496d1ba25d/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 patterns, include, url 2 | from django.contrib import admin 3 | 4 | urlpatterns = patterns('', 5 | # Examples: 6 | # url(r'^$', 'test_project.views.home', name='home'), 7 | # url(r'^blog/', include('blog.urls')), 8 | 9 | url(r'^admin/', include(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 | TEMPLATE_DEBUG = True 26 | 27 | ALLOWED_HOSTS = [] 28 | 29 | 30 | # Application definition 31 | 32 | INSTALLED_APPS = ( 33 | 'django.contrib.admin', 34 | 'django.contrib.auth', 35 | 'django.contrib.contenttypes', 36 | 'django.contrib.sessions', 37 | 'django.contrib.messages', 38 | 'django.contrib.staticfiles', 39 | 'migrate_sql', 40 | 'test_app', 41 | 'test_app2', 42 | ) 43 | 44 | MIDDLEWARE_CLASSES = ( 45 | 'django.contrib.sessions.middleware.SessionMiddleware', 46 | 'django.middleware.common.CommonMiddleware', 47 | 'django.middleware.csrf.CsrfViewMiddleware', 48 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 49 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | ) 53 | 54 | ROOT_URLCONF = 'test_app.urls' 55 | 56 | WSGI_APPLICATION = 'test_app.wsgi.application' 57 | 58 | 59 | # Database 60 | # https://docs.djangoproject.com/en/1.8/ref/settings/#databases 61 | 62 | DATABASES = { 63 | 'default': { 64 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 65 | 'NAME': 'migrate_sql_test_db', 66 | 'USER': 'postgres', 67 | 'HOST': 'localhost', 68 | 'PORT': 5432, 69 | } 70 | } 71 | 72 | # Internationalization 73 | # https://docs.djangoproject.com/en/1.8/topics/i18n/ 74 | 75 | LANGUAGE_CODE = 'en-us' 76 | 77 | TIME_ZONE = 'UTC' 78 | 79 | USE_I18N = True 80 | 81 | USE_L10N = True 82 | 83 | USE_TZ = True 84 | 85 | 86 | # Static files (CSS, JavaScript, Images) 87 | # https://docs.djangoproject.com/en/1.8/howto/static-files/ 88 | 89 | STATIC_URL = '/static/' 90 | 91 | try: 92 | from settings_local import * 93 | except ImportError: 94 | pass 95 | -------------------------------------------------------------------------------- /tests/test_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | from django.contrib import admin 3 | 4 | urlpatterns = patterns('', 5 | # Examples: 6 | # url(r'^$', 'test_project.views.home', name='home'), 7 | # url(r'^blog/', include('blog.urls')), 8 | 9 | url(r'^admin/', include(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{27,33,34,35}-django{18} 4 | py{27,34,35}-django{19} 5 | 6 | [testenv] 7 | commands = 8 | coverage erase 9 | {envbindir}/coverage run runtests.py 10 | coverage combine 11 | deps= 12 | django18: Django>=1.8,<1.9 13 | django19: Django>=1.9 14 | -rrequirements-testing.txt 15 | 16 | whitelist_externals = coverage 17 | --------------------------------------------------------------------------------