django-postpone-index 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,276 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-postpone-index
3
+ Version: 0.0.1
4
+ Summary: Postpone index creation to provide Zero Downtime Migration feature
5
+ Home-page: https://github.com/nnseva/django-postpone-index
6
+ Author: Vsevolod Novikov
7
+ Author-email: nnseva@gmail.com
8
+ License: LGPLv3
9
+ Project-URL: Source, https://github.com/nnseva/django-postpone-index
10
+ Project-URL: Issues, https://github.com/nnseva/django-postpone-index/issues
11
+ Keywords: zero-downtime-migration,django,migration,zero-downtime,downtime,postgres,postgresql
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
19
+ Classifier: Framework :: Django
20
+ Classifier: Framework :: Django :: 2.2
21
+ Classifier: Framework :: Django :: 3.0
22
+ Classifier: Framework :: Django :: 3.1
23
+ Classifier: Framework :: Django :: 3.2
24
+ Classifier: Framework :: Django :: 4.0
25
+ Classifier: Framework :: Django :: 4.1
26
+ Classifier: Framework :: Django :: 4.2
27
+ Classifier: Framework :: Django :: 5.0
28
+ Classifier: Framework :: Django :: 5.1
29
+ Classifier: Framework :: Django :: 5.2
30
+ Classifier: Development Status :: 4 - Beta
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: Topic :: Software Development :: Libraries
33
+ Requires-Python: >=3.10
34
+ Description-Content-Type: text/markdown
35
+ Requires-Dist: django2-postgres-backport
36
+ Requires-Dist: django-unlimited-char
37
+ Dynamic: author
38
+ Dynamic: author-email
39
+ Dynamic: classifier
40
+ Dynamic: description
41
+ Dynamic: description-content-type
42
+ Dynamic: home-page
43
+ Dynamic: keywords
44
+ Dynamic: license
45
+ Dynamic: project-url
46
+ Dynamic: requires-dist
47
+ Dynamic: requires-python
48
+ Dynamic: summary
49
+
50
+ [![Tests](https://github.com/nnseva/django-postpone-index/actions/workflows/ci.yml/badge.svg)](https://github.com/nnseva/django-postpone-index/actions/workflows/ci.yml)
51
+
52
+ # Django Postpone Index
53
+
54
+ This package provides modules and tools to postpone any index creation instead doing it inside the migration,
55
+ to provide *Zero Downtime Migration* feature.
56
+
57
+ The package is now using the PostgresSQL-specific `CREATE INDEX CONCURRENTLY` SQL command, so is applicable
58
+ only to the PostgreSQL backend.
59
+
60
+ ## Installation
61
+
62
+ *Stable version* from the PyPi package repository
63
+ ```bash
64
+ pip install django-postpone-index
65
+ ```
66
+
67
+ *Last development version* from the GitHub source version control system
68
+ ```
69
+ pip install git+git://github.com/nnseva/django-postpone-index.git
70
+ ```
71
+
72
+ ## Problem Description
73
+
74
+ Large data leads to long index creation time.
75
+
76
+ When the migration is automatically created, it executes all SQL commands creating index inside a transaction.
77
+
78
+ Large data and index creation inside a transaction lead to long-term table lock which blocks any data writting to the table.
79
+
80
+ On the other side, `CREATE INDEX CONCURRENTLY` SQL command may solve the problem, but this SQL command can not be executed inside a transaction block.
81
+
82
+ The `AddIndexConcurrently` might be created in a separate migration, moving out the automatically generated `AddIndex` from the migration,
83
+ but not all indexes are created using `AddIndex`.
84
+
85
+ ## Solution
86
+
87
+ All index creation SQL commands (as well as unique constraints creation) are catched
88
+ and postponed using a special `PostponedSQL` model (the `DROP INDEX` and `DROP CONSTRAINT` SQL commands
89
+ are still executed immediately).
90
+
91
+ When the migration is finished, the postponed indexes may be created in a separate process
92
+ using `CREATE INDEX CONCURRENTLY` SQL command by the `apply_postponed` management command.
93
+ Apart from the standard migration, this process doesn't lock the whole table for a long time.
94
+
95
+ Failed index creation statements don't lead to the command failure
96
+ (until a special command line parameter passed). Every failed statement is stored
97
+ as erroneous instead. When the data is fixed, you can execute the `apply_postponed` management
98
+ command again to restore the failed indexes.
99
+
100
+ ## Complex Use Cases
101
+
102
+ The following complex use cases are processed by the package.
103
+
104
+ - Several create/drop pairs. There can be several create/drop index pairs if several migrations applied at once.
105
+ - Back Migration. The both, forward and backward migrations are processed.
106
+ - Implicit index drop while removing the table. The Django doesn't issue a separate SQL to drop indexes of the dropped table.
107
+ - Implicit index drop while removing the field. The Django doesn't issue a separate SQL to drop indexes related to the dropped column.
108
+
109
+ ## Using
110
+
111
+ Include the `postpone_index` application in `setting.py`:
112
+
113
+ ```python
114
+ INSTALLED_APPS = [
115
+ ...
116
+ 'postpone_index',
117
+ ...
118
+ ]
119
+ ```
120
+
121
+ Use `pospone_index.contrib.postgres` or `postpone_index.contrib.postgis` engines instead of the Django-provided in `settings.py`:
122
+
123
+ ```python
124
+ DATABASES = {
125
+ 'default': {
126
+ 'ENGINE': 'postpone_index.contrib.postgres',
127
+ ...
128
+ }
129
+ }
130
+ ```
131
+
132
+ If you provide your own database engine instead of the Django-provided, you can also
133
+ combine `pospone_index.contrib.postgres.schema.DatabaseSchemaEditorMixin` with your own Database Schema Editor, f.e.:
134
+
135
+ `mybackend/schema.py`
136
+ ```python
137
+ from django.db.backends.postgresql.schema import DatabaseSchemaEditor as _DatabaseSchemaEditor
138
+ from pospone_index.contrib.postgres.schema import DatabaseSchemaEditorMixin
139
+
140
+ class PostponeIndexDatabaseSchemaEditor(DatabaseSchemaEditorMixin, _DatabaseSchemaEditor):
141
+ # Your own code
142
+ ...
143
+ ```
144
+
145
+ `mybackend/base.py`
146
+ ```python
147
+ from django.db.backends.postgresql.base import (
148
+ DatabaseWrapper as _DatabaseWrapper,
149
+ )
150
+
151
+ from mybackend.schema import PostponeIndexDatabaseSchemaEditor
152
+
153
+
154
+ class DatabaseWrapper(_DatabaseWrapper):
155
+ """Database wrapper"""
156
+
157
+ SchemaEditorClass = PostponeIndexDatabaseSchemaEditor
158
+ # Your own code
159
+ ...
160
+ ```
161
+
162
+ Execute `apply_postponed` management command every time after the `migrate` management command to create new postponed indexes.
163
+
164
+ Monitor `PostponedSQL` model instances to see errors on the SQL execution.
165
+
166
+ After the data is fixed, you can try to recreate the postponed invalid indexes just
167
+ calling the `apply_postponed` migration command again. All not-applied indexes will be tried to create again.
168
+
169
+ **NOTICE** the `apply_postponed` management command doesn't have any explicit locking mechanics. Avoid starting this
170
+ command concurrently with itself or another `migrate` command on the same database.
171
+
172
+ ## Django testing
173
+
174
+ Django migrates testing database before tests. Always use `POSTPONE_INDEX_IGNORE = True` settings to avoid postpone index
175
+ for the testing database.
176
+
177
+ If you want to check your own migration with the postpone index switched on,
178
+ use the `postpone_index.testing_utils.TestCase` and `override_settings` Django feature with the following trick:
179
+
180
+ ```python
181
+ from django.core.management import call_command
182
+ from django.test import override_settings
183
+ from postpone_index.models import PostponedSQL
184
+ from postpone_index import testing_utils
185
+
186
+ class ModuleTest(testing_utils.TestCase):
187
+ # Notice that the base TestCase is TransactionalTestCase
188
+
189
+ @classmethod
190
+ def setUpClass(cls):
191
+ # If you want to have customized setUpClass, call the method of the base class
192
+ super().setUpClass()
193
+
194
+ @classmethod
195
+ def tearDownClass(cls):
196
+ # If you want to have customized tearDownClass, call the method of the base class
197
+ super().tearDownClass()
198
+
199
+ def test_my_special_migration_case(self):
200
+ """Explicitly check my migration with postpone_index"""
201
+
202
+ module_to_check = "my_module" # Your Django App
203
+ migration_before_the_check = "0005" # Just before your migration
204
+ migration_to_check = "0006" # The migration you check
205
+
206
+ # Notice that POSTPONED_INDEX_IGNORE is True by default while testing
207
+ call_command('migrate', module_to_check, migration_before_the_check)
208
+
209
+ with override_settings(
210
+ POSTPONE_INDEX_IGNORE=False
211
+ ):
212
+ # Here we can check how it's going with `postpone_index` activated
213
+
214
+ # Check whether your migration works as expected with postponed indexes
215
+ call_command('migrate', module_to_check, migration_to_check)
216
+
217
+ # Here you can check how the module works before apply_postponed
218
+ ...
219
+
220
+ # Check whether the indexes applied properly. The `-x` parameter
221
+ # causes exception on errors
222
+ call_command('apply_postponed', 'run', '-x')
223
+ ```
224
+
225
+ ## Django settings
226
+
227
+ ### `POSTPONE_INDEX_IGNORE`
228
+
229
+ The setting totally switches off the functionality of the package.
230
+
231
+ Always use this setting in the test environment to avoid using postponed index creation for the test database.
232
+
233
+ May be used in a heterogeneous database environment to switch off the package functionality on unsupported databases.
234
+
235
+ ### `POSTPONE_INDEX_ADMIN_IGNORE`
236
+
237
+ The `PostponedSQL` model admin view is switched on by default. You can totally switch it off,
238
+ or create your own admin class instead. Use `postpone_index.admin.PostponedSQLAdminMixin` as a base class if necessary.
239
+
240
+ ## Django database
241
+
242
+ The Django supports heterogeneous database environment in a single project. Every single database has it's own
243
+ state of migrations executed by the `manage.py migrate --database <alias>`.
244
+
245
+ The `apply_postponed` command also supports selection of the database alias using similar syntax:
246
+
247
+ ```bash
248
+
249
+ # The 'default' database alias is used as a default
250
+ python manage.py migrate
251
+ python manage.py apply_postponed
252
+
253
+ # A non-default database alias parameter has similar syntax
254
+ python manage.py migrate --database another-postgres-database
255
+ python manage.py apply_postponed --database another-postgres-database
256
+ ```
257
+
258
+ Use `POSTPONE_INDEX_IGNORE=1` environment to switch off the package functionality on migrations running on unsupported database engines like:
259
+
260
+ ```bash
261
+ POSTPONE_INDEX_IGNORE=1 python manage.py migrate --database non-postgres-database
262
+ ```
263
+
264
+ ## Special migrations to avoid postpone index
265
+
266
+ Sometimes you may need to avoid the `postpone_index` applied to a single migration.
267
+
268
+ Just include the `PostponeIndexIgnoreMigrationMixin` into a base class list for your special migration:
269
+
270
+ ```python
271
+ from django.db import migrations, models
272
+ from postpone_index.migration_utils import PostponeIndexIgnoreMigrationMixin
273
+
274
+ class Migration(PostponeIndexIgnoreMigrationMixin, migrations.Migration):
275
+ ...
276
+ ```
@@ -0,0 +1,12 @@
1
+ postpone_index/__init__.py,sha256=s1NgdtOn7SO7scs-uAaq1134F7Luh-JjB5Ghra3D2fQ,84
2
+ postpone_index/_version.py,sha256=qf6R-J7-UyuABBo8c0HgaquJ8bejVbf07HodXgwAwgQ,704
3
+ postpone_index/admin.py,sha256=TAZxaciQkEBNhReYulsU5VopPCyABtMLZvCSxk0CfYE,820
4
+ postpone_index/apps.py,sha256=1ap2HY49uXVXjY9UWK3___fjPe22wqFOSOnuH2kIAxs,200
5
+ postpone_index/migration_utils.py,sha256=02rm77mz_MAufl5vdqKPV1CAd1-3bRk6kSy4MIxKsEo,752
6
+ postpone_index/models.py,sha256=jMsA5hdiL8Xgl-hq7bgVl5NUX6LXlSYpMuX9pD68BbU,1771
7
+ postpone_index/testing_utils.py,sha256=ycxp7ovY6JceOvNpotK7OGAebhdHicXJ-0fWJmaq9fo,1224
8
+ postpone_index/utils.py,sha256=wYVPsQRs3LN-ZtvfSWrDDqFFp0OCkVxW9j7w9yqd47o,3823
9
+ django_postpone_index-0.0.1.dist-info/METADATA,sha256=f6r6FaLnag7wQ4NjfFRPhMDZAffRmXtXK7Bg7o4viLU,10415
10
+ django_postpone_index-0.0.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
11
+ django_postpone_index-0.0.1.dist-info/top_level.txt,sha256=SzF44QR8Fr9ArKXZPY7FSJUlTBKYo_dPNHn2hbqKOrc,15
12
+ django_postpone_index-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ postpone_index
@@ -0,0 +1,4 @@
1
+ from ._version import * # noqa
2
+
3
+
4
+ default_app_config = 'postpone_index.apps.Config'
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.0.1'
32
+ __version_tuple__ = version_tuple = (0, 0, 1)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,29 @@
1
+ """
2
+ Admin interface
3
+ """
4
+
5
+ from django.conf import settings
6
+ from django.contrib import admin
7
+ from django.utils.translation import gettext_lazy as _
8
+
9
+ from postpone_index.models import PostponedSQL
10
+
11
+
12
+ class PostponedSQLAdminMixin:
13
+ list_display = ('d', 'description', 'table', 'db_index')
14
+ list_display_links = ('d', 'description')
15
+ search_fields = ('description', 'table', 'db_index')
16
+ list_filter = (
17
+ 'table',
18
+ 'done'
19
+ )
20
+
21
+ def d(self, obj):
22
+ return '✅' if obj.done else '🚫' if obj.error else '➡'
23
+ d.short_description = _('Status')
24
+
25
+
26
+ if not getattr(settings, 'POSTPONE_INDEX_IGNORE', False) and not getattr(settings, 'POSTPONE_INDEX_ADMIN_IGNORE', False):
27
+ @admin.register(PostponedSQL)
28
+ class PostponedSQLAdmin(PostponedSQLAdminMixin, admin.ModelAdmin):
29
+ pass
postpone_index/apps.py ADDED
@@ -0,0 +1,8 @@
1
+ """App config"""
2
+ from django.apps import AppConfig
3
+ from django.utils.translation import gettext_lazy as _
4
+
5
+
6
+ class Config(AppConfig):
7
+ verbose_name = _('Postpone Index')
8
+ name = 'postpone_index'
@@ -0,0 +1,20 @@
1
+ """Utility for special migration cases"""
2
+
3
+
4
+ class PostponeIndexIgnoreMigrationMixin:
5
+ """Migration class mixin to avoid postpone index"""
6
+
7
+ def apply(self, project_state, schema_editor, *av, **kw):
8
+ """Override to avoid postpone index"""
9
+
10
+ schema_editor._postpone_index_ignore = True
11
+ ret = super().apply(project_state, schema_editor, *av, **kw)
12
+ schema_editor._postpone_index_ignore = False
13
+ return ret
14
+
15
+ def unapply(self, project_state, schema_editor, *av, **kw):
16
+ """Override to avoid postpone index"""
17
+ schema_editor._postpone_index_ignore = True
18
+ ret = super().unapply(project_state, schema_editor, *av, **kw)
19
+ schema_editor._postpone_index_ignore = False
20
+ return ret
@@ -0,0 +1,61 @@
1
+ import time
2
+
3
+ from unlimited_char.fields import CharField
4
+
5
+ from django.db import models
6
+ from django.utils.translation import gettext_lazy as _
7
+
8
+
9
+ class PostponedSQL(models.Model):
10
+ ts = models.BigIntegerField(
11
+ primary_key=True, editable=False,
12
+ default=time.time_ns,
13
+ verbose_name=_('Timestamp ns'),
14
+ help_text=_('Timestamp in nanoseconds')
15
+ )
16
+ description = CharField(
17
+ verbose_name=_('Description'),
18
+ help_text=_('Free-form short description')
19
+ )
20
+ sql = models.TextField(
21
+ verbose_name=_('SQL'),
22
+ help_text=_('Original SQL generated on the migration stage'),
23
+ )
24
+ table = CharField(
25
+ blank=True, null=True, db_index=True,
26
+ verbose_name=_('Table'),
27
+ help_text=_('Table name if applied to the table')
28
+ )
29
+ db_index = CharField(
30
+ blank=True, null=True, db_index=True,
31
+ verbose_name=_('Index'),
32
+ help_text=_('Index name if applied to the index')
33
+ )
34
+ fields = CharField(
35
+ blank=True, null=True,
36
+ verbose_name=_('Fields'),
37
+ help_text=_('Field names separated by comma if applied to the fields')
38
+ )
39
+ done = models.BooleanField(
40
+ default=False,
41
+ verbose_name=_('Done'),
42
+ help_text=_('Has the command been applied')
43
+ )
44
+ error = models.TextField(
45
+ blank=True, null=True,
46
+ verbose_name=_('Error'),
47
+ help_text=_('Last error reported when tried to apply')
48
+ )
49
+
50
+ @property
51
+ def d(self):
52
+ """Status mark"""
53
+ return '[X]' if self.done else '[E]' if self.error else '[ ]'
54
+
55
+ class Meta:
56
+ managed = False
57
+ verbose_name = _('Postponed SQL')
58
+ verbose_name_plural = _('Postponed SQLs')
59
+
60
+ def __str__(self):
61
+ return self.description
@@ -0,0 +1,37 @@
1
+ """Testing Utilities"""
2
+
3
+ from django.conf import settings
4
+ from django.test import TransactionTestCase
5
+
6
+ from postpone_index.models import PostponedSQL
7
+
8
+
9
+ class TestCase(TransactionTestCase):
10
+ """Base module testcase class"""
11
+
12
+ @staticmethod
13
+ def _check_postponed_sql_empty(alias='default'):
14
+ try:
15
+ first = PostponedSQL.objects.using(alias).all().first()
16
+ if not first:
17
+ # No record
18
+ return True
19
+ except Exception:
20
+ # No table
21
+ return True
22
+ return False
23
+
24
+ @classmethod
25
+ def _assert_postponed_sql_empty(cls, message=None, alias='default'):
26
+ cls.assertTrue(cls._check_postponed_sql_empty(alias=alias), message or 'Postponed SQL list not empty')
27
+
28
+ @classmethod
29
+ def setUpClass(cls):
30
+ cls.assertTrue(settings.POSTPONE_INDEX_IGNORE, 'Tests should be started with POSTPONE_INDEX_IGNORE=True')
31
+
32
+ @classmethod
33
+ def tearDownClass(cls):
34
+ if not cls._check_postponed_sql_empty(alias='default'):
35
+ PostponedSQL.objects.using('default').all().delete()
36
+ if not cls._check_postponed_sql_empty(alias='additional'):
37
+ PostponedSQL.objects.using('additional').all().delete()
@@ -0,0 +1,111 @@
1
+ """Some utilities"""
2
+
3
+ import re
4
+ from collections.abc import MappingView
5
+
6
+
7
+ class ObjMap(MappingView):
8
+ """Gives access to any attribute using map protocol, combining with another map for defaults"""
9
+
10
+ def __init__(self, obj, defaults=None):
11
+ """Initialize by the original object"""
12
+ self._obj = obj
13
+ self._defaults = defaults or {}
14
+
15
+ def __getitem__(self, k):
16
+ """Override to get access"""
17
+ try:
18
+ return getattr(self._obj, k)
19
+ except AttributeError:
20
+ return self._defaults[k]
21
+
22
+
23
+ class Utils:
24
+ """Utilities class"""
25
+ _create_index_re = re.compile(
26
+ r'^\s*CREATE\s+(?P<unique>UNIQUE\s+)?INDEX\s+(IF\s+NOT\s+EXISTS\s+)?'
27
+ r'(((?P<iq>")?(?P<index_nameq>[^"]+)(?P=iq))|(?P<index_name>[^\s]+))'
28
+ r'\s+ON\s+(?P<only>ONLY\s+)?'
29
+ r'(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[^\s]+))'
30
+ r'(?P<rest>.*)$',
31
+ re.IGNORECASE | re.MULTILINE
32
+ )
33
+ _drop_index_re = re.compile(
34
+ r'^\s*DROP\s+INDEX\s+(IF\s+EXISTS\s+)?'
35
+ r'(((?P<iq>")?(?P<index_nameq>[^"]+)(?P=iq))|(?P<index_name>[^\s]+))'
36
+ r'(?P<rest>.*)$',
37
+ re.IGNORECASE | re.MULTILINE
38
+ )
39
+ _drop_table_re = re.compile(
40
+ r'^\s*DROP\s+TABLE\s+(IF\s+EXISTS\s+)?'
41
+ r'(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[^\s]+))'
42
+ r'(?P<rest>.*)$',
43
+ re.IGNORECASE | re.MULTILINE
44
+ )
45
+ _add_constraint_re = re.compile(
46
+ r'^\s*ALTER\s+TABLE\s+'
47
+ r'(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[^\s]+))'
48
+ r'\s+ADD\s+CONSTRAINT\s+'
49
+ r'(((?P<iq>")?(?P<index_nameq>[^"]+)(?P=iq))|(?P<index_name>[^\s]+))'
50
+ r'\s+UNIQUE\s+'
51
+ r'(?P<rest>.*)$',
52
+ re.IGNORECASE | re.MULTILINE
53
+ )
54
+ _drop_constraint_re = re.compile(
55
+ r'^\s*ALTER\s+TABLE\s+'
56
+ r'(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[^\s]+))'
57
+ r'\s+DROP\s+CONSTRAINT\s+'
58
+ r'(((?P<iq>")?(?P<index_nameq>[^"]+)(?P=iq))|(?P<index_name>[^\s]+))'
59
+ r'(?P<rest>.*)$',
60
+ re.IGNORECASE | re.MULTILINE
61
+ )
62
+ _drop_column_re = re.compile(
63
+ r'^\s*ALTER\s+TABLE\s+'
64
+ r'(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[^\s]+))'
65
+ r'\s+DROP\s+COLUMN\s+'
66
+ r'(((?P<cq>")?(?P<column_nameq>[^"]+)(?P=cq))|(?P<column_name>[^\s]+))'
67
+ r'(?P<rest>.*)$',
68
+ re.IGNORECASE | re.MULTILINE
69
+ )
70
+ _column_name_re = re.compile(
71
+ r'(((?P<cq>")(?P<column_nameq>[^"]+)(?P=cq))|(?P<column_name>[^\s]+))',
72
+ re.IGNORECASE | re.MULTILINE
73
+ )
74
+ _enclosed_re = re.compile(
75
+ r'\((?P<enclosed>[^\(\)]*)\)',
76
+ re.IGNORECASE | re.MULTILINE
77
+ )
78
+
79
+ @classmethod
80
+ def _extract_column_names(cls, rest):
81
+ """Extracts column names from complex index suffix"""
82
+ rest = cls._extract_enclosed(rest)
83
+ while match := cls._enclosed_re.search(rest):
84
+ rest = rest[:match.start()] + rest[match.end():]
85
+ ret = []
86
+ for column in [c for c in rest.split(',') if c.strip()]:
87
+ match = cls._column_name_re.search(column)
88
+ if not match:
89
+ continue
90
+ ret.append('"%s"' % (match.group('column_nameq') or match.group('column_name')))
91
+ return ret
92
+
93
+ @classmethod
94
+ def _extract_enclosed(cls, rest):
95
+ """Extract first enclosed statement with recursive parentheses"""
96
+ deep = 0
97
+ start = 0
98
+ end = 0
99
+ for i, c in enumerate(rest):
100
+ if c == '(':
101
+ if deep == 0:
102
+ start = i + 1
103
+ deep += 1
104
+ elif c == ')':
105
+ deep -= 1
106
+ if deep == 0:
107
+ end = i
108
+ break
109
+ if end == 0:
110
+ return rest
111
+ return rest[start:end]