Skip to content

Commit d847d22

Browse files
authored
fix: migrations to make postgresql compatible. (#35762)
This commit introduces several improvements to database migration scripts to enhance compatibility between MySQL and PostgreSQL, ensure case-sensitive behavior where needed, and improve migration safety and correctness. The changes include dynamic SQL generation based on the database engine, improved transaction handling, and adjustments to field types and adapters for better cross-database support. Database compatibility and case sensitivity improvements: - Migration scripts in split_modulestore_django and learning_sequences now dynamically generate SQL statements for altering column case sensitivity and uniqueness based on whether the database is MySQL or PostgreSQL, ensuring correct behavior across both backends. - common/djangoapps/split_modulestore_django/migrations/0001_initial.py - openedx/core/djangoapps/content/learning_sequences/migrations/0001_initial.py - The courseware.fields module now checks for "postgresql" in the database engine string instead of a specific backend name, improving compatibility with different PostgreSQL drivers. - lms/djangoapps/courseware/fields.py - The 0011_csm_id_bigint migration in courseware now supports both MySQL and PostgreSQL for altering column types, with specific SQL for each backend. - lms/djangoapps/courseware/migrations/0011_csm_id_bigint.py - The 0009_readd_facebook_url migration in course_overviews now introspects the table structure using backend-specific SQL for MySQL and PostgreSQL, ensuring correct detection of existing fields. - openedx/core/djangoapps/content/course_overviews/migrations/0009_readd_facebook_url.py Migration safety and correctness: - Service user creation and deletion in the commerce app is now wrapped in atomic transactions to ensure database consistency. - lms/djangoapps/commerce/migrations/0001_data__add_ecommerce_service_user.py - The move_overrides_to_edx_when migration in courseware now specifies a no-op reverse migration, preventing accidental data loss on migration rollback. - lms/djangoapps/courseware/migrations/0008_move_idde_to_edx_when.py Adapter registration and code cleanup: - The common_initialization app now registers custom adapters for CourseLocator and related classes in psycopg2 when using PostgreSQL, ensuring proper serialization of these types. - openedx/core/djangoapps/common_initialization/apps.py - Minor code cleanup and formatting improvements in migration files, including import order and field formatting for readability. - lms/djangoapps/grades/migrations/0015_historicalpersistentsubsectiongradeoverride.py
1 parent ef8b03b commit d847d22

14 files changed

Lines changed: 270 additions & 160 deletions

File tree

common/djangoapps/split_modulestore_django/migrations/0001_initial.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,40 @@
11
# Generated by Django 2.2.20 on 2021-05-07 18:29, manually modified to make "course_id" column case sensitive
22

33
from django.conf import settings
4-
from django.db import migrations, models
4+
from django.db import migrations, models, connection
55
import django.db.models.deletion
66
import opaque_keys.edx.django.models
77
import simple_history.models
88

99

10+
def generate_split_module_sql(db_engine):
11+
if 'mysql' in db_engine:
12+
return 'ALTER TABLE split_modulestore_django_splitmodulestorecourseindex MODIFY course_id varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL UNIQUE;'
13+
elif 'postgresql' in db_engine:
14+
return """
15+
ALTER TABLE split_modulestore_django_splitmodulestorecourseindex
16+
ALTER COLUMN course_id TYPE VARCHAR(255),
17+
ALTER COLUMN course_id SET NOT NULL;
18+
19+
ALTER TABLE split_modulestore_django_splitmodulestorecourseindex
20+
ADD CONSTRAINT course_id_unique UNIQUE (course_id);
21+
"""
22+
23+
24+
def generate_split_history_module_sql(db_engine):
25+
if 'mysql' in db_engine:
26+
return 'ALTER TABLE split_modulestore_django_historicalsplitmodulestorecourseindex MODIFY course_id varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL;'
27+
elif 'postgresql' in db_engine:
28+
return """
29+
ALTER TABLE split_modulestore_django_historicalsplitmodulestorecourseindex
30+
ALTER COLUMN course_id TYPE VARCHAR(255),
31+
ALTER COLUMN course_id SET NOT NULL,
32+
ALTER COLUMN course_id SET DATA TYPE VARCHAR(255) COLLATE "C";
33+
"""
1034
class Migration(migrations.Migration):
1135

1236
initial = True
37+
db_engine = connection.settings_dict['ENGINE']
1338

1439
dependencies = [
1540
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
@@ -65,11 +90,11 @@ class Migration(migrations.Migration):
6590
# Custom code: Convert columns to utf8_bin because we want to allow
6691
# case-sensitive comparisons for CourseKeys, which were case-sensitive in MongoDB
6792
migrations.RunSQL(
68-
'ALTER TABLE split_modulestore_django_splitmodulestorecourseindex MODIFY course_id varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL UNIQUE;',
93+
generate_split_module_sql(db_engine),
6994
reverse_sql=migrations.RunSQL.noop,
7095
),
7196
migrations.RunSQL(
72-
'ALTER TABLE split_modulestore_django_historicalsplitmodulestorecourseindex MODIFY course_id varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL;',
97+
generate_split_history_module_sql(db_engine),
7398
reverse_sql=migrations.RunSQL.noop,
7499
),
75100
]

lms/djangoapps/commerce/migrations/0001_data__add_ecommerce_service_user.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
from django.conf import settings
22
from django.contrib.auth import get_user_model
33
from django.contrib.auth.models import User
4-
from django.db import migrations, models
4+
from django.db import migrations, models, transaction
55

66
USERNAME = settings.ECOMMERCE_SERVICE_WORKER_USERNAME
77
EMAIL = USERNAME + '@fake.email'
88

99
def forwards(apps, schema_editor):
1010
"""Add the service user."""
1111
User = get_user_model()
12-
user, created = User.objects.get_or_create(username=USERNAME, email=EMAIL)
13-
if created:
14-
user.set_unusable_password()
15-
user.save()
12+
with transaction.atomic():
13+
user, created = User.objects.get_or_create(username=USERNAME, email=EMAIL)
14+
if created:
15+
user.set_unusable_password()
16+
user.save()
1617

1718
def backwards(apps, schema_editor):
1819
"""Remove the service user."""
19-
User.objects.get(username=USERNAME, email=EMAIL).delete()
20+
with transaction.atomic():
21+
User.objects.get(username=USERNAME, email=EMAIL).delete()
2022

2123
class Migration(migrations.Migration):
2224

lms/djangoapps/courseware/fields.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def db_type(self, connection):
1818
# is an alias for that (https://www.sqlite.org/autoinc.html). An unsigned integer
1919
# isn't an alias for ROWID, so we have to give up on the unsigned part.
2020
return "integer"
21-
elif connection.settings_dict['ENGINE'] == 'django.db.backends.postgresql_psycopg2':
21+
elif "postgresql" in connection.settings_dict['ENGINE']:
2222
# Pg's bigserial is implicitly unsigned (doesn't allow negative numbers) and
2323
# goes 1-9.2x10^18
2424
return "BIGSERIAL"
@@ -30,7 +30,7 @@ def rel_db_type(self, connection):
3030
return "bigint UNSIGNED"
3131
elif connection.settings_dict['ENGINE'] == 'django.db.backends.sqlite3':
3232
return "integer"
33-
elif connection.settings_dict['ENGINE'] == 'django.db.backends.postgresql_psycopg2':
33+
elif "postgresql" in connection.settings_dict['ENGINE']:
3434
return "BIGSERIAL"
3535
else:
3636
return None

lms/djangoapps/courseware/migrations/0008_move_idde_to_edx_when.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,5 @@ class Migration(migrations.Migration):
3333
]
3434

3535
operations = [
36-
migrations.RunPython(move_overrides_to_edx_when)
36+
migrations.RunPython(move_overrides_to_edx_when, reverse_code=migrations.RunPython.noop)
3737
]

lms/djangoapps/courseware/migrations/0011_csm_id_bigint.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,38 @@
11
# Generated by Django 1.11.23 on 2019-08-28 15:50
22

3-
43
import lms.djangoapps.courseware.fields
54

65
from django.conf import settings
7-
from django.db import migrations
6+
from django.db import migrations, models
87
from django.db.migrations import AlterField
98

109
class CsmBigInt(AlterField):
1110
'''
1211
Subclass AlterField migration class to split SQL between two different databases
13-
We can't use the normal AlterField migration operation because Django generate and routes migrations at the model
12+
We can't use the normal AlterField migration operation because Django generates and routes migrations at the model
1413
level and the coursewarehistoryextended_studentmodulehistoryextended table is in a different database
1514
'''
1615
def database_forwards(self, app_label, schema_editor, from_state, to_state):
1716
if hasattr(schema_editor.connection, 'is_in_memory_db') and schema_editor.connection.is_in_memory_db():
1817
# sqlite3 doesn't support 'MODIFY', so skipping during tests
1918
return
19+
2020
to_model = to_state.apps.get_model(app_label, self.model_name)
21+
2122
if schema_editor.connection.alias == 'student_module_history':
2223
if settings.FEATURES["ENABLE_CSMH_EXTENDED"]:
23-
schema_editor.execute("ALTER TABLE `coursewarehistoryextended_studentmodulehistoryextended` MODIFY `student_module_id` bigint UNSIGNED NOT NULL;")
24+
if schema_editor.connection.vendor == 'mysql':
25+
schema_editor.execute("ALTER TABLE `coursewarehistoryextended_studentmodulehistoryextended` MODIFY `student_module_id` bigint UNSIGNED NOT NULL;")
26+
elif schema_editor.connection.vendor == 'postgresql':
27+
schema_editor.execute("ALTER TABLE coursewarehistoryextended_studentmodulehistoryextended ALTER COLUMN student_module_id TYPE bigint;")
2428
elif self.allow_migrate_model(schema_editor.connection.alias, to_model):
25-
schema_editor.execute("ALTER TABLE `courseware_studentmodule` MODIFY `id` bigint UNSIGNED AUTO_INCREMENT NOT NULL;")
29+
if schema_editor.connection.vendor == 'postgresql':
30+
# For PostgreSQL
31+
schema_editor.execute("ALTER TABLE courseware_studentmodule ALTER COLUMN id SET DATA TYPE bigint;")
32+
schema_editor.execute("ALTER TABLE courseware_studentmodule ALTER COLUMN id SET NOT NULL;")
33+
else:
34+
# For MySQL
35+
schema_editor.execute("ALTER TABLE `courseware_studentmodule` MODIFY `id` bigint UNSIGNED AUTO_INCREMENT NOT NULL;")
2636

2737
def database_backwards(self, app_label, schema_editor, from_state, to_state):
2838
# Make backwards migration a no-op, app will still work if column is wider than expected
@@ -33,6 +43,7 @@ class Migration(migrations.Migration):
3343
dependencies = [
3444
('courseware', '0010_auto_20190709_1559'),
3545
]
46+
3647
if settings.FEATURES["ENABLE_CSMH_EXTENDED"]:
3748
dependencies.append(('coursewarehistoryextended', '0002_force_studentmodule_index'))
3849

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
# Generated by Django 1.11.20 on 2019-06-05 13:59
22

33

4-
from django.conf import settings
5-
from django.db import migrations, models
64
import django.db.models.deletion
75
import simple_history.models
6+
from django.conf import settings
7+
from django.db import migrations, models
88

99

1010
class Migration(migrations.Migration):
11-
1211
dependencies = [
1312
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
1413
('grades', '0014_persistentsubsectiongradeoverridehistory'),
@@ -28,15 +27,24 @@ class Migration(migrations.Migration):
2827
('history_id', models.AutoField(primary_key=True, serialize=False)),
2928
('history_date', models.DateTimeField()),
3029
('history_change_reason', models.CharField(max_length=100, null=True)),
31-
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
32-
('grade', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='grades.PersistentSubsectionGrade')),
33-
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
30+
('history_type',
31+
models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
32+
('history_user',
33+
models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+',
34+
to=settings.AUTH_USER_MODEL)),
3435
],
35-
options={
36-
'ordering': ('-history_date', '-history_id'),
37-
'get_latest_by': 'history_date',
38-
'verbose_name': 'historical persistent subsection grade override',
39-
},
40-
bases=(simple_history.models.HistoricalChanges, models.Model),
36+
options = {
37+
'ordering': ('-history_date', '-history_id'),
38+
'get_latest_by': 'history_date',
39+
'verbose_name': 'historical persistent subsection grade override',
40+
},
41+
bases = (simple_history.models.HistoricalChanges, models.Model),
42+
),
43+
migrations.AddField(
44+
model_name='historicalpersistentsubsectiongradeoverride',
45+
name='grade',
46+
field=models.ForeignKey(blank=True, db_constraint=False, null=True,
47+
on_delete=django.db.models.deletion.DO_NOTHING, related_name='+',
48+
to='grades.PersistentSubsectionGrade'),
4149
),
4250
]

openedx/core/djangoapps/common_initialization/apps.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
Common initialization app for the LMS and CMS
33
"""
44

5-
65
from django.apps import AppConfig
6+
from django.db import connection
77

88

99
class CommonInitializationConfig(AppConfig): # lint-amnesty, pylint: disable=missing-class-docstring
@@ -14,6 +14,7 @@ def ready(self):
1414
# Common settings validations for the LMS and CMS.
1515
from . import checks # lint-amnesty, pylint: disable=unused-import
1616
self._add_mimetypes()
17+
self._add_required_adapters()
1718

1819
@staticmethod
1920
def _add_mimetypes():
@@ -26,3 +27,21 @@ def _add_mimetypes():
2627
mimetypes.add_type('application/x-font-opentype', '.otf')
2728
mimetypes.add_type('application/x-font-ttf', '.ttf')
2829
mimetypes.add_type('application/font-woff', '.woff')
30+
31+
@staticmethod
32+
def _add_required_adapters():
33+
"""
34+
Register CourseLocator in psycopg2 extensions
35+
:return:
36+
"""
37+
if 'postgresql' in connection.vendor.lower():
38+
from opaque_keys.edx.locator import CourseLocator, LibraryLocator, BlockUsageLocator
39+
from psycopg2.extensions import QuotedString, register_adapter
40+
41+
def adapt_course_locator(course_locator):
42+
return QuotedString(str(course_locator)) # lint-amnesty, pylint: disable=protected-access
43+
44+
# Register the adapter
45+
register_adapter(CourseLocator, adapt_course_locator)
46+
register_adapter(LibraryLocator, adapt_course_locator)
47+
register_adapter(BlockUsageLocator, adapt_course_locator)
Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,42 @@
11
from django.db import migrations, models, connection
22

33
def table_description():
4-
"""Handle Mysql/Pg vs Sqlite"""
5-
# django's mysql/pg introspection.get_table_description tries to select *
6-
# from table and fails during initial migrations from scratch.
7-
# sqlite does not have this failure, so we can use the API.
8-
# For not-sqlite, query information-schema directly with code lifted
9-
# from the internals of django.db.backends.mysql.introspection.py
10-
4+
"""Handle MySQL/Postgres vs SQLite compatibility for table introspection"""
115
if connection.vendor == 'sqlite':
126
fields = connection.introspection.get_table_description(connection.cursor(), 'course_overviews_courseoverview')
137
return [f.name for f in fields]
148
else:
159
cursor = connection.cursor()
16-
cursor.execute("""
17-
SELECT column_name
18-
FROM information_schema.columns
19-
WHERE table_name = 'course_overviews_courseoverview' AND table_schema = DATABASE()""")
10+
if connection.vendor == 'mysql':
11+
cursor.execute("""
12+
SELECT column_name
13+
FROM information_schema.columns
14+
WHERE table_name = 'course_overviews_courseoverview' AND table_schema = DATABASE()
15+
""")
16+
elif connection.vendor == 'postgresql':
17+
cursor.execute("""
18+
SELECT column_name
19+
FROM information_schema.columns
20+
WHERE table_name = 'course_overviews_courseoverview' AND table_catalog = current_database()
21+
""")
2022
rows = cursor.fetchall()
2123
return [r[0] for r in rows]
2224

23-
2425
class Migration(migrations.Migration):
2526

2627
dependencies = [
2728
('course_overviews', '0008_remove_courseoverview_facebook_url'),
2829
]
2930

30-
# An original version of 0008 removed the facebook_url field We need to
31-
# handle the case where our noop 0008 ran AND the case where the original
32-
# 0008 ran. We do that by using the standard information_schema to find out
33-
# what columns exist. _meta is unavailable as the column has already been
34-
# removed from the model
3531
operations = []
3632
fields = table_description()
3733

38-
# during a migration from scratch, fields will be empty, but we do not want to add
39-
# an additional facebook_url
34+
# Ensure 'facebook_url' is added if it does not exist in the table
4035
if fields and not any(f == 'facebook_url' for f in fields):
41-
operations += migrations.AddField(
42-
model_name='courseoverview',
43-
name='facebook_url',
44-
field=models.TextField(null=True),
45-
),
36+
operations.append(
37+
migrations.AddField(
38+
model_name='courseoverview',
39+
name='facebook_url',
40+
field=models.TextField(null=True),
41+
)
42+
)

0 commit comments

Comments
 (0)