django-postpone-index 0.0.2__py3-none-any.whl → 0.0.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-postpone-index
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Postpone index creation to provide Zero Downtime Migration feature
5
5
  Home-page: https://github.com/nnseva/django-postpone-index
6
6
  Author: Vsevolod Novikov
@@ -105,6 +105,7 @@ The following complex use cases are processed by the package.
105
105
  - Back Migration. The both, forward and backward migrations are processed.
106
106
  - Implicit index drop while removing the table. The Django doesn't issue a separate SQL to drop indexes of the dropped table.
107
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
+ - Rename field (column) name or model (table) name
108
109
 
109
110
  ## Using
110
111
 
@@ -169,6 +170,43 @@ calling the `apply_postponed` migration command again. All not-applied indexes w
169
170
  **NOTICE** the `apply_postponed` management command doesn't have any explicit locking mechanics. Avoid starting this
170
171
  command concurrently with itself or another `migrate` command on the same database.
171
172
 
173
+ ## Intermediate migration state
174
+
175
+ Apart from standard Django migrations, using the `postpone_index` package leads to the *intermediate migration state*
176
+ after the `migrate` management command finished:
177
+
178
+ - new model structure is applied
179
+ - indexes to be deleted are deleted
180
+ - indexes to be created are *not created yet*
181
+
182
+ You should be aware that if you introduce a new unique index or constraint, the database does not control uniqueness
183
+ based on not yet created indexes at this time.
184
+
185
+ Your code works now as expected everywhere, except the code which is based on new unique constraints introduced in applied migrations.
186
+
187
+ Apply the `apply_postponed run` management command to make these new indexes work.
188
+
189
+ Any error while `apply_postponed run` execution is stored in the `PostponedSQL` model instance.
190
+
191
+ You can see erroneous lines using `apply_postponed list` command. See the `[E]` mark at the start of the line.
192
+
193
+ You also can see the error details using the format parameter of the `apply_postponed list -f '... %(error)s'` management command.
194
+
195
+ The `apply_postponed run -x` breaks execution on any error. You can see the error in the standard error or logging streams.
196
+
197
+ The `apply_postponed run` (without `-x` parameter) doesn't stop on error, but outputs warning to the log stream instead.
198
+
199
+ When the error happened, it most probably is caused by the non-unique records. Fix the data and try to execute
200
+ `apply_postponed run` again to create an index.
201
+
202
+ After the successfull `apply_postponed run` execution, the migration state is finalised to be equal as if you applied the migration
203
+ without `postpone_index` package at all.
204
+
205
+ The `apply_postponed run` management command marks all successfully executed `PostponedSQL` instances as `done`. You can see `[X]` mark
206
+ at the start of the line produced by `apply_ponsponed list` management command.
207
+
208
+ You can cleanup `done` instances using `apply_postponed cleanup` management command. This step is optional.
209
+
172
210
  ## Django testing
173
211
 
174
212
  Django migrates testing database before tests. Always use `POSTPONE_INDEX_IGNORE = True` settings to avoid postpone index
@@ -1,23 +1,23 @@
1
1
  postpone_index/__init__.py,sha256=s1NgdtOn7SO7scs-uAaq1134F7Luh-JjB5Ghra3D2fQ,84
2
- postpone_index/_version.py,sha256=huLsL1iGeXWQKZ8bjwDdIWC7JOkj3wnzBh-HFMZl1PY,704
2
+ postpone_index/_version.py,sha256=QlXZ5JTjE_pgpDaeHk0GTExkc75xUZFmd0hA7kGYCJ0,704
3
3
  postpone_index/admin.py,sha256=TAZxaciQkEBNhReYulsU5VopPCyABtMLZvCSxk0CfYE,820
4
4
  postpone_index/apps.py,sha256=1ap2HY49uXVXjY9UWK3___fjPe22wqFOSOnuH2kIAxs,200
5
5
  postpone_index/migration_utils.py,sha256=02rm77mz_MAufl5vdqKPV1CAd1-3bRk6kSy4MIxKsEo,752
6
- postpone_index/models.py,sha256=jMsA5hdiL8Xgl-hq7bgVl5NUX6LXlSYpMuX9pD68BbU,1771
6
+ postpone_index/models.py,sha256=R_S8KBKlfPy6A6Urwdp17IINcd9VEZtkW5_cVbpKN-4,3581
7
7
  postpone_index/testing_utils.py,sha256=ycxp7ovY6JceOvNpotK7OGAebhdHicXJ-0fWJmaq9fo,1224
8
- postpone_index/utils.py,sha256=wYVPsQRs3LN-ZtvfSWrDDqFFp0OCkVxW9j7w9yqd47o,3823
8
+ postpone_index/utils.py,sha256=H_2M7IvSs1BKxvjkFanyBmX5I2iJB2KDDOAcINKlD04,4758
9
9
  postpone_index/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  postpone_index/contrib/postgis/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  postpone_index/contrib/postgis/base.py,sha256=a5h1k11BVSOflSSesTxObKAEYNWtisFLRbApzHeW3c0,289
12
12
  postpone_index/contrib/postgis/schema.py,sha256=eantuNhFYWDngDnAeURJurpW58UztPnjTIySmUlXx7s,340
13
13
  postpone_index/contrib/postgres/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  postpone_index/contrib/postgres/base.py,sha256=Xq90nJkxGSgR8Im9JPnFepeMb0-bur9sRjPpm3zMd6U,281
15
- postpone_index/contrib/postgres/schema.py,sha256=45xt6mhVgGgFWYH_OVZE0WzcyBv9J6sCQVR4WnxA1Xc,10914
15
+ postpone_index/contrib/postgres/schema.py,sha256=tbqNy8l-YfR-h_snNaiHe2J4Uxb2JH5pZSzNDrfxtUQ,12690
16
16
  postpone_index/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  postpone_index/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
18
  postpone_index/management/commands/apply_postponed.py,sha256=wVbw0ugUyPuYwUikGnn5MvP9l9OsEIZja9sb6nsMtOw,9107
19
19
  postpone_index/sql/start.sql,sha256=44yFbZTjknjXUvl7MpO1uuPv-CVBsZcnUDuKc8mVj3k,871
20
- django_postpone_index-0.0.2.dist-info/METADATA,sha256=8jnBu6D07a_Nszgmkk9e9kP7wMJzeE3N9jCsxc5PGOk,10415
21
- django_postpone_index-0.0.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
22
- django_postpone_index-0.0.2.dist-info/top_level.txt,sha256=B6q_TICqApvruf_NJfnLFNLZLpxMLebQF6cPdKrWm5A,162
23
- django_postpone_index-0.0.2.dist-info/RECORD,,
20
+ django_postpone_index-0.0.4.dist-info/METADATA,sha256=1ZnvVAhdApfcxVFNJkEKJn0m_r-1kMMHCzGB6xPrTV4,12416
21
+ django_postpone_index-0.0.4.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
22
+ django_postpone_index-0.0.4.dist-info/top_level.txt,sha256=B6q_TICqApvruf_NJfnLFNLZLpxMLebQF6cPdKrWm5A,162
23
+ django_postpone_index-0.0.4.dist-info/RECORD,,
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.0.2'
32
- __version_tuple__ = version_tuple = (0, 0, 2)
31
+ __version__ = version = '0.0.4'
32
+ __version_tuple__ = version_tuple = (0, 0, 4)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -25,20 +25,10 @@ class DatabaseSchemaEditorMixin(Utils):
25
25
  """
26
26
  if self._base_tables_created:
27
27
  return
28
-
29
28
  # Avoid circular import
30
29
  from postpone_index.models import PostponedSQL
31
30
 
32
- cursor = self.connection.cursor()
33
- cursor.execute("SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '%s' limit 1" % PostponedSQL._meta.db_table)
34
- if cursor.fetchall():
35
- self._base_tables_created = True
36
- logger.debug('[%s] The PostponedSQL storage already exists', self.connection.alias)
37
- return
38
- logger.info('[%s] The PostponedSQL storage is absent, creating', self.connection.alias)
39
- with open(os.path.join(package_folder, 'sql/start.sql')) as f:
40
- sql = f.read()
41
- cursor.execute(sql)
31
+ PostponedSQL.objects.using(self.connection.alias)._create_base_tables()
42
32
  self._base_tables_created = True
43
33
 
44
34
  def _ignore(self):
@@ -128,7 +118,7 @@ class DatabaseSchemaEditorMixin(Utils):
128
118
  logger.info('[%s] Removed all indexes on table %s from postponed', self.connection.alias, table_name)
129
119
  return super().execute(sql, params)
130
120
  elif match := self._drop_column_re.fullmatch(str(sql)):
131
- # Table dropped cancels all postponed operations related
121
+ # Column dropped cancels all postponed operations related
132
122
  table_name = match.group('table_nameq') or match.group('table_name')
133
123
  column_name = match.group('column_nameq') or match.group('column_name')
134
124
  if PostponedSQL.objects.using(self.connection.alias).filter(
@@ -139,6 +129,37 @@ class DatabaseSchemaEditorMixin(Utils):
139
129
  self.connection.alias, column_name, table_name
140
130
  )
141
131
  return super().execute(sql, params)
132
+ elif match := self._rename_column_re.fullmatch(str(sql)):
133
+ # Rename column changes the postponed indexes to alter SQL but not the index name
134
+ table_name = match.group('table_nameq') or match.group('table_name')
135
+ column_name = match.group('column_nameq') or match.group('column_name')
136
+ ncolumn_name = match.group('ncolumn_nameq') or match.group('ncolumn_name')
137
+ for postponed_sql in PostponedSQL.objects.using(self.connection.alias).filter(
138
+ table=table_name, fields__contains='"%s"' % column_name, done=False
139
+ ):
140
+ logger.info(
141
+ '[%s] Reformat index %s SQL to rename column %s to %s of table %s from postponed',
142
+ self.connection.alias, postponed_sql.db_index, column_name, ncolumn_name, table_name,
143
+ )
144
+ postponed_sql.sql = postponed_sql.sql.replace('"%s"' % column_name, '"%s"' % ncolumn_name)
145
+ postponed_sql.fields = postponed_sql.fields.replace('"%s"' % column_name, '"%s"' % ncolumn_name)
146
+ postponed_sql.save(update_fields=['sql', 'fields'])
147
+ return super().execute(sql, params)
148
+ elif match := self._rename_table_re.fullmatch(str(sql)):
149
+ # Rename table changes the postponed indexes to alter SQL and table name but not anything other
150
+ table_name = match.group('table_nameq') or match.group('table_name')
151
+ ntable_name = match.group('ntable_nameq') or match.group('ntable_name')
152
+ for postponed_sql in PostponedSQL.objects.using(self.connection.alias).filter(
153
+ table=table_name, done=False
154
+ ):
155
+ logger.info(
156
+ '[%s] Reformat index %s SQL to rename table %s to %s from postponed',
157
+ self.connection.alias, postponed_sql.db_index, table_name, ntable_name,
158
+ )
159
+ postponed_sql.sql = postponed_sql.sql.replace('"%s"' % table_name, '"%s"' % ntable_name)
160
+ postponed_sql.table = ntable_name
161
+ postponed_sql.save(update_fields=['sql', 'table'])
162
+ return super().execute(sql, params)
142
163
  else:
143
164
  logger.debug('[%s] No special statements: %s', self.connection.alias, sql)
144
165
  return super().execute(sql, params)
postpone_index/models.py CHANGED
@@ -1,12 +1,79 @@
1
+ import logging
2
+ import os
1
3
  import time
2
4
 
3
5
  from unlimited_char.fields import CharField
4
6
 
5
- from django.db import models
7
+ from django.db import connections, models
6
8
  from django.utils.translation import gettext_lazy as _
7
9
 
8
10
 
11
+ logger = logging.getLogger(__name__)
12
+ package_folder = os.path.dirname(os.path.abspath(__file__))
13
+
14
+
15
+ class PostponedSQLQuerySet(models.QuerySet):
16
+ """
17
+ QuerySet for PostponedSQL model.
18
+
19
+ Redefined for admin view when table is absent.
20
+ """
21
+
22
+ def _create_base_tables(self):
23
+ """
24
+ Create base package table if absent
25
+ """
26
+ if self._is_present():
27
+ logger.debug('[%s] The PostponedSQL storage already exists', self.db)
28
+ return
29
+ logger.info('[%s] The PostponedSQL storage is absent, creating', self.db)
30
+ with open(os.path.join(package_folder, 'sql/start.sql')) as f:
31
+ sql = f.read()
32
+ cursor = connections[self.db].cursor()
33
+ cursor.execute(sql)
34
+
35
+ def count(self):
36
+ """
37
+ Count only if table is present
38
+ """
39
+ if not self._is_present():
40
+ return 0
41
+ return super().count()
42
+
43
+ def _is_present(self):
44
+ """
45
+ Check if table is present
46
+ """
47
+ cursor = connections[self.db].cursor()
48
+ cursor.execute(
49
+ """
50
+ SELECT 1 FROM information_schema.tables
51
+ WHERE table_schema = 'public' AND table_name = '%s' limit 1
52
+ """ % PostponedSQL._meta.db_table
53
+ )
54
+ return bool(cursor.fetchall())
55
+
56
+
57
+ class PostponedSQLManager(models.Manager):
58
+ """
59
+ Manager for PostponedSQL model.
60
+
61
+ Redefined for admin view when table is absent.
62
+ """
63
+
64
+ def get_queryset(self):
65
+ """
66
+ Get custom QuerySet
67
+ """
68
+ qs = PostponedSQLQuerySet(self.model, using=self._db)
69
+ if not qs._is_present():
70
+ return qs.none()
71
+ return qs
72
+
73
+
9
74
  class PostponedSQL(models.Model):
75
+ """Model to store postponed SQL commands"""
76
+
10
77
  ts = models.BigIntegerField(
11
78
  primary_key=True, editable=False,
12
79
  default=time.time_ns,
@@ -47,6 +114,8 @@ class PostponedSQL(models.Model):
47
114
  help_text=_('Last error reported when tried to apply')
48
115
  )
49
116
 
117
+ objects = PostponedSQLManager()
118
+
50
119
  @property
51
120
  def d(self):
52
121
  """Status mark"""
postpone_index/utils.py CHANGED
@@ -26,7 +26,7 @@ class Utils:
26
26
  r'^\s*CREATE\s+(?P<unique>UNIQUE\s+)?INDEX\s+(IF\s+NOT\s+EXISTS\s+)?'
27
27
  r'(((?P<iq>")?(?P<index_nameq>[^"]+)(?P=iq))|(?P<index_name>[^\s]+))'
28
28
  r'\s+ON\s+(?P<only>ONLY\s+)?'
29
- r'(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[^\s]+))'
29
+ r'(("?)public("?)\.)?(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[_a-zA-Z0-9]+))'
30
30
  r'(?P<rest>.*)$',
31
31
  re.IGNORECASE | re.MULTILINE
32
32
  )
@@ -38,13 +38,13 @@ class Utils:
38
38
  )
39
39
  _drop_table_re = re.compile(
40
40
  r'^\s*DROP\s+TABLE\s+(IF\s+EXISTS\s+)?'
41
- r'(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[^\s]+))'
41
+ r'(("?)public("?)\.)?(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[_a-zA-Z0-9]+))'
42
42
  r'(?P<rest>.*)$',
43
43
  re.IGNORECASE | re.MULTILINE
44
44
  )
45
45
  _add_constraint_re = re.compile(
46
46
  r'^\s*ALTER\s+TABLE\s+'
47
- r'(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[^\s]+))'
47
+ r'(("?)public("?)\.)?(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[_a-zA-Z0-9]+))'
48
48
  r'\s+ADD\s+CONSTRAINT\s+'
49
49
  r'(((?P<iq>")?(?P<index_nameq>[^"]+)(?P=iq))|(?P<index_name>[^\s]+))'
50
50
  r'\s+UNIQUE\s+'
@@ -53,7 +53,7 @@ class Utils:
53
53
  )
54
54
  _drop_constraint_re = re.compile(
55
55
  r'^\s*ALTER\s+TABLE\s+'
56
- r'(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[^\s]+))'
56
+ r'(("?)public("?)\.)?(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[_a-zA-Z0-9]+))'
57
57
  r'\s+DROP\s+CONSTRAINT\s+'
58
58
  r'(((?P<iq>")?(?P<index_nameq>[^"]+)(?P=iq))|(?P<index_name>[^\s]+))'
59
59
  r'(?P<rest>.*)$',
@@ -61,12 +61,30 @@ class Utils:
61
61
  )
62
62
  _drop_column_re = re.compile(
63
63
  r'^\s*ALTER\s+TABLE\s+'
64
- r'(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[^\s]+))'
64
+ r'(("?)public("?)\.)?(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[_a-zA-Z0-9]+))'
65
65
  r'\s+DROP\s+COLUMN\s+'
66
66
  r'(((?P<cq>")?(?P<column_nameq>[^"]+)(?P=cq))|(?P<column_name>[^\s]+))'
67
67
  r'(?P<rest>.*)$',
68
68
  re.IGNORECASE | re.MULTILINE
69
69
  )
70
+ _rename_column_re = re.compile(
71
+ r'^\s*ALTER\s+TABLE\s+'
72
+ r'(("?)public("?)\.)?(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[_a-zA-Z0-9]+))'
73
+ r'\s+RENAME\s+COLUMN\s+'
74
+ r'(((?P<cq>")?(?P<column_nameq>[^"]+)(?P=cq))|(?P<column_name>[^\s]+))'
75
+ r'\s+TO\s+'
76
+ r'(((?P<nq>")?(?P<ncolumn_nameq>[^"]+)(?P=nq))|(?P<ncolumn_name>[^\s]+))'
77
+ r'(?P<rest>.*)$',
78
+ re.IGNORECASE | re.MULTILINE
79
+ )
80
+ _rename_table_re = re.compile(
81
+ r'^\s*ALTER\s+TABLE\s+'
82
+ r'(("?)public("?)\.)?(((?P<tq>")?(?P<table_nameq>[^"]+)(?P=tq))|(?P<table_name>[_a-zA-Z0-9]+))'
83
+ r'\s+RENAME\s+TO\s+'
84
+ r'(((?P<nq>")?(?P<ntable_nameq>[^"]+)(?P=nq))|(?P<ntable_name>[^\s]+))'
85
+ r'(?P<rest>.*)$',
86
+ re.IGNORECASE | re.MULTILINE
87
+ )
70
88
  _column_name_re = re.compile(
71
89
  r'(((?P<cq>")(?P<column_nameq>[^"]+)(?P=cq))|(?P<column_name>[^\s]+))',
72
90
  re.IGNORECASE | re.MULTILINE