Skip to content

Conversation

rubendv
Copy link
Contributor

@rubendv rubendv commented Feb 21, 2022

In the sys.indexes table, is_unique_constraint is true only when an actual constraint was created (using ALTER TABLE ... ADD CONSTRAINT ... UNIQUE).
Because this method is not suitable for nullable fields in practice (you cannot have more than one row with NULL), mssql-django always creates CREATE UNIQUE INDEX instead.
django-pyodbc-azure behaved differently and used a unique constraint whenever possible.

The problem that arises is that mssql-django assumes that an index is used to enforce all unique constraints, and always uses DROP INDEX to remove it.
When migrating a codebase from django-pyodbc-azure to mssql-django, this fails because the database contains actual unique constraints that need to be dropped using "ALTER TABLE ... DROP CONSTRAINT ...".

This commit adds support for is_unique_constraint to the introspection, so we can determine if the constraint is enforced by an actual SQL Server constraint or by a unique index.
Additionally, places that delete unique constraints have been refactored to use a common function that uses introspection to determine the proper method of deletion.

Fixes #100 and #104.

…ermine if we should use DROP CONSTRAINT or DROP INDEX when altering or removing a unique (together) constraint. Fixes microsoft#100 and microsoft#104.

In the sys.indexes table, is_unique_constraint is true only when an actual constraint was created (using ALTER TABLE ... ADD CONSTRAINT ... UNIQUE).
Because this method is not suitable for nullable fields in practice (you cannot have more than one row with NULL), mssql-django always creates CREATE UNIQUE INDEX instead.
django-pyodbc-azure behaved differently and used a unique constraint whenever possible.

The problem that arises is that mssql-django assumes that an index is used to enforce all unique constraints, and always uses DROP INDEX to remove it.
When migrating a codebase from django-pyodbc-azure to mssql-django, this fails because the database contains actual unique constraints that need to be dropped using "ALTER TABLE ... DROP CONSTRAINT ...".

This commit adds support for is_unique_constraint to the introspection, so we can determine if the constraint is enforced by an actual SQL Server constraint or by a unique index.
Additionally, places that delete unique constraints have been refactored to use a common function that uses introspection to determine the proper method of deletion.
@ghost
Copy link

ghost commented Feb 21, 2022

CLA assistant check
All CLA requirements met.

@absci
Copy link
Contributor

absci commented Feb 25, 2022

Hi, thanks for your contribution, there're some test failures I have copied the log below. Would you be able to fix it?

2022-02-21T12:17:19.1508093Z ======================================================================
2022-02-21T12:17:19.1509195Z ERROR [2.262s]: test_alter_field_reloads_state_on_fk_target_changes (migrations.test_operations.OperationTests)
2022-02-21T12:17:19.1510329Z ----------------------------------------------------------------------
2022-02-21T12:17:19.1511025Z Traceback (most recent call last):
2022-02-21T12:17:19.1511838Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\backends\utils.py", line 85, in _execute
2022-02-21T12:17:19.1512672Z     return self.cursor.execute(sql, params)
2022-02-21T12:17:19.1513442Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\mssql\base.py", line 598, in execute
2022-02-21T12:17:19.1514234Z     return self.cursor.execute(sql, params)
2022-02-21T12:17:19.1515587Z pyodbc.ProgrammingError: ('42000', "[42000] [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]An explicit DROP INDEX is not allowed on index 'alter_alter_field_reloads_state_on_fk_target_changes_rider.PK__alter_al__3213E83F84642CA3'. It is being used for PRIMARY KEY constraint enforcement. (3723) (SQLExecDirectW)")
2022-02-21T12:17:19.1516678Z 
2022-02-21T12:17:19.1517320Z The above exception was the direct cause of the following exception:
2022-02-21T12:17:19.1518030Z 
2022-02-21T12:17:19.1518654Z Traceback (most recent call last):
2022-02-21T12:17:19.1519538Z   File "C:\a\_work\1\s\django\tests\migrations\test_operations.py", line 1747, in test_alter_field_reloads_state_on_fk_target_changes
2022-02-21T12:17:19.1520524Z     project_state = self.apply_operations(app_label, project_state, operations=[
2022-02-21T12:17:19.1521395Z   File "C:\a\_work\1\s\django\tests\migrations\test_base.py", line 188, in apply_operations
2022-02-21T12:17:19.1522189Z     return migration.apply(project_state, editor)
2022-02-21T12:17:19.1523046Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\migrations\migration.py", line 125, in apply
2022-02-21T12:17:19.1523998Z     operation.database_forwards(self.app_label, schema_editor, old_state, project_state)
2022-02-21T12:17:19.1524985Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\migrations\operations\fields.py", line 225, in database_forwards
2022-02-21T12:17:19.1526007Z     schema_editor.alter_field(from_model, from_field, to_field)
2022-02-21T12:17:19.1526934Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\backends\base\schema.py", line 620, in alter_field
2022-02-21T12:17:19.1528344Z     self._alter_field(model, old_field, new_field, old_type, new_type,
2022-02-21T12:17:19.1529283Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\mssql\schema.py", line 414, in _alter_field
2022-02-21T12:17:19.1530181Z     self._delete_unique_constraints(model, old_field, new_field, strict)
2022-02-21T12:17:19.1531093Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\mssql\schema.py", line 695, in _delete_unique_constraints
2022-02-21T12:17:19.1532053Z     self._delete_unique_constraint_for_columns(model, columns, strict=strict)
2022-02-21T12:17:19.1533182Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\mssql\schema.py", line 713, in _delete_unique_constraint_for_columns
2022-02-21T12:17:19.1534182Z     self.execute(self._delete_constraint_sql(self.sql_delete_index, model, index_name))
2022-02-21T12:17:19.1535102Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\mssql\schema.py", line 1039, in execute
2022-02-21T12:17:19.1535857Z     cursor.execute(sql, params)
2022-02-21T12:17:19.1536645Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\backends\utils.py", line 67, in execute
2022-02-21T12:17:19.1537589Z     return self._execute_with_wrappers(sql, params, many=False, executor=self._execute)
2022-02-21T12:17:19.1538898Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\backends\utils.py", line 76, in _execute_with_wrappers
2022-02-21T12:17:19.1539770Z     return executor(sql, params, many, context)
2022-02-21T12:17:19.1540592Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\backends\utils.py", line 85, in _execute
2022-02-21T12:17:19.1541425Z     return self.cursor.execute(sql, params)
2022-02-21T12:17:19.1543067Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\utils.py", line 90, in __exit__
2022-02-21T12:17:19.1543926Z     raise dj_exc_value.with_traceback(traceback) from exc_value
2022-02-21T12:17:19.1544805Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\backends\utils.py", line 85, in _execute
2022-02-21T12:17:19.1545672Z     return self.cursor.execute(sql, params)
2022-02-21T12:17:19.1546457Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\mssql\base.py", line 598, in execute
2022-02-21T12:17:19.1547240Z     return self.cursor.execute(sql, params)
2022-02-21T12:17:19.1548939Z django.db.utils.ProgrammingError: ('42000', "[42000] [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]An explicit DROP INDEX is not allowed on index 'alter_alter_field_reloads_state_on_fk_target_changes_rider.PK__alter_al__3213E83F84642CA3'. It is being used for PRIMARY KEY constraint enforcement. (3723) (SQLExecDirectW)")
2022-02-21T12:17:19.1550100Z 
2022-02-21T12:17:19.1550751Z ======================================================================
2022-02-21T12:17:19.1551676Z ERROR [2.632s]: test_rename_field_reloads_state_on_fk_target_changes (migrations.test_operations.OperationTests)
2022-02-21T12:17:19.1552778Z ----------------------------------------------------------------------
2022-02-21T12:17:19.1553489Z Traceback (most recent call last):
2022-02-21T12:17:19.1554285Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\backends\utils.py", line 85, in _execute
2022-02-21T12:17:19.1555104Z     return self.cursor.execute(sql, params)
2022-02-21T12:17:19.1555898Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\mssql\base.py", line 598, in execute
2022-02-21T12:17:19.1556691Z     return self.cursor.execute(sql, params)
2022-02-21T12:17:19.1558296Z pyodbc.ProgrammingError: ('42000', "[42000] [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]An explicit DROP INDEX is not allowed on index 'alter_rename_field_reloads_state_on_fk_target_changes_pony.PK__alter_re__3213E83F4D1D456D'. It is being used for PRIMARY KEY constraint enforcement. (3723) (SQLExecDirectW)")
2022-02-21T12:17:19.1559412Z 
2022-02-21T12:17:19.1560073Z The above exception was the direct cause of the following exception:
2022-02-21T12:17:19.1560503Z 
2022-02-21T12:17:19.1561032Z Traceback (most recent call last):
2022-02-21T12:17:19.1561895Z   File "C:\a\_work\1\s\django\tests\migrations\test_operations.py", line 1799, in test_rename_field_reloads_state_on_fk_target_changes
2022-02-21T12:17:19.1562938Z     project_state = self.apply_operations(app_label, project_state, operations=[
2022-02-21T12:17:19.1563826Z   File "C:\a\_work\1\s\django\tests\migrations\test_base.py", line 188, in apply_operations
2022-02-21T12:17:19.1564641Z     return migration.apply(project_state, editor)
2022-02-21T12:17:19.1565612Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\migrations\migration.py", line 125, in apply
2022-02-21T12:17:19.1566575Z     operation.database_forwards(self.app_label, schema_editor, old_state, project_state)
2022-02-21T12:17:19.1567588Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\migrations\operations\fields.py", line 225, in database_forwards
2022-02-21T12:17:19.1568903Z     schema_editor.alter_field(from_model, from_field, to_field)
2022-02-21T12:17:19.1569816Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\backends\base\schema.py", line 620, in alter_field
2022-02-21T12:17:19.1570710Z     self._alter_field(model, old_field, new_field, old_type, new_type,
2022-02-21T12:17:19.1571597Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\mssql\schema.py", line 414, in _alter_field
2022-02-21T12:17:19.1572487Z     self._delete_unique_constraints(model, old_field, new_field, strict)
2022-02-21T12:17:19.1573417Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\mssql\schema.py", line 695, in _delete_unique_constraints
2022-02-21T12:17:19.1574491Z     self._delete_unique_constraint_for_columns(model, columns, strict=strict)
2022-02-21T12:17:19.1575421Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\mssql\schema.py", line 713, in _delete_unique_constraint_for_columns
2022-02-21T12:17:19.1576413Z     self.execute(self._delete_constraint_sql(self.sql_delete_index, model, index_name))
2022-02-21T12:17:19.1577326Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\mssql\schema.py", line 1039, in execute
2022-02-21T12:17:19.1578582Z     cursor.execute(sql, params)
2022-02-21T12:17:19.1579423Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\backends\utils.py", line 67, in execute
2022-02-21T12:17:19.1580356Z     return self._execute_with_wrappers(sql, params, many=False, executor=self._execute)
2022-02-21T12:17:19.1581351Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\backends\utils.py", line 76, in _execute_with_wrappers
2022-02-21T12:17:19.1582242Z     return executor(sql, params, many, context)
2022-02-21T12:17:19.1583069Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\backends\utils.py", line 85, in _execute
2022-02-21T12:17:19.1583889Z     return self.cursor.execute(sql, params)
2022-02-21T12:17:19.1584668Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\utils.py", line 90, in __exit__
2022-02-21T12:17:19.1585523Z     raise dj_exc_value.with_traceback(traceback) from exc_value
2022-02-21T12:17:19.1586394Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\django\db\backends\utils.py", line 85, in _execute
2022-02-21T12:17:19.1587227Z     return self.cursor.execute(sql, params)
2022-02-21T12:17:19.1588242Z   File "C:\a\_work\1\s\.tox\py39-django40\lib\site-packages\mssql\base.py", line 598, in execute
2022-02-21T12:17:19.1589075Z     return self.cursor.execute(sql, params)
2022-02-21T12:17:19.1590463Z django.db.utils.ProgrammingError: ('42000', "[42000] [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]An explicit DROP INDEX is not allowed on index 'alter_rename_field_reloads_state_on_fk_target_changes_pony.PK__alter_re__3213E83F4D1D456D'. It is being used for PRIMARY KEY constraint enforcement. (3723) (SQLExecDirectW)")
2022-02-21T12:17:19.1591548Z 
2022-02-21T12:17:19.1592172Z ----------------------------------------------------------------------

@rubendv
Copy link
Contributor Author

rubendv commented Mar 3, 2022

Hi @absci, I fixed the tests. I was not treating primary keys as constraints anymore. This caused a DROP INDEX to be used instead of ALTER TABLE ... DROP CONSTRAINT.

@absci absci merged commit db20051 into microsoft:dev Mar 4, 2022
jmah8 added a commit that referenced this pull request Apr 27, 2022
* Only drop necessary indexes during field rename (#97)

* Only drop unique indexes which include the column to be renamed

This avoids unnecessarily slowing down migrations with
drop/recreate of index(es) on potentially large tables
when doing so is not required.

* Test for rename of unique-and-nullable column

This covers the specific case where we are still
dropping a unique index on a column & re-instating.

Also update references to an issue which was ported
to the new fork and fixed there (here).

* Add general test for presence of indexes

This is not a panacea but hopefully increase the coverage
if nothing else by at least being a regression test for
#77

* Basic logging config for debugging testapp tests

* Fix KeyTransformExact applied to all databases (#98)

* Fixed issue #82

* Issue #90 - fix alter nullability for foreign key (#93)

* Issue #90 bug fix

* Update SUPPORT.md (#101)

Remove paragraph pointing users to a the Django mailing list.

[skip ci]

* Add possibility for token authentication (#102)

* Add possibility for token authentication

* Add documentation for TOKEN setting

* Fixed overridden functions not working with other DBs (#105)

Fixes issue #92 and other potential issues caused by overriding Django functions in functions.py.

* Remove unique constraints (fixes #100 and #104) (#106)

* Add support for unique_constraint to introspection, and use it to determine if we should use DROP CONSTRAINT or DROP INDEX when altering or removing a unique (together) constraint. Fixes #100 and #104.

In the sys.indexes table, is_unique_constraint is true only when an actual constraint was created (using ALTER TABLE ... ADD CONSTRAINT ... UNIQUE).
Because this method is not suitable for nullable fields in practice (you cannot have more than one row with NULL), mssql-django always creates CREATE UNIQUE INDEX instead.
django-pyodbc-azure behaved differently and used a unique constraint whenever possible.

The problem that arises is that mssql-django assumes that an index is used to enforce all unique constraints, and always uses DROP INDEX to remove it.
When migrating a codebase from django-pyodbc-azure to mssql-django, this fails because the database contains actual unique constraints that need to be dropped using "ALTER TABLE ... DROP CONSTRAINT ...".

This commit adds support for is_unique_constraint to the introspection, so we can determine if the constraint is enforced by an actual SQL Server constraint or by a unique index.
Additionally, places that delete unique constraints have been refactored to use a common function that uses introspection to determine the proper method of deletion.

* Also treat primary keys as constraints instead of as indexes.

Co-authored-by: Ruben De Visscher <ruben@awingu.com>

* Returning ids/rows after bulk insert (#107)

* Implement can_return_rows_from_bulk_insert feature, returning ids or rows after bulk inserts.

* Since mssql-django supports Django 2.2, we also need the pre-Django 3.0 version of feature flag can_return_rows_from_bulk_insert (namely, can_return_ids_from_bulk_insert) (cf. https://docs.djangoproject.com/en/4.0/releases/3.0/#database-backend-api)

* My alternative changes on SQLInsertCompiler.as_sql.

Maybe a bit ambitious, as we completely forsake the SCOPE_IDENTITY strategy (dead code path - we keep the code here, but we could decide not to, really) in favor of OUTPUT strategy.

* Don't try to use the OUTPUT clause when inserting without fields

* Actually we don't really have to offer the feature for Django 2.2, so let's only set can_return_rows_from_bulk_insert to True and not can_return_ids_from_bulk_insert

* Tentative fix: when there are returning fields, but no fields (which means default values insertion - for n objects of course!), we must still fulfill our contract, and return the appropriate rows.

This means we won't use INSERT INTO (...) DEFAULT VALUES n times, but a single INSERT INTO (...) VALUES (DEFAULT, (...), DEFAULT), (...), (DEFAULT, (...), DEFAULT)
Also: be more thorough re the infamous feature flag rename from Django 3.0

* Using MERGE INTO to support Bulk Insertion of multiple rows into a table with only an IDENTITY column.

* Add a link to a reference web page.

* Attempt to make Django 2.2 tests pass

* Get back to a lighter diff of as_sql function vs. original

* Use a query to generate sequence of numbers instead of using the master....spt_values table.

* Update mssql/operations.py

Co-authored-by: marcperrinoptel <86617454+marcperrinoptel@users.noreply.github.com>

* Simplification & refactoring

Co-authored-by: marcperrinoptel <marc.perrin@optelgroup.com>
Co-authored-by: marcperrinoptel <86617454+marcperrinoptel@users.noreply.github.com>

* The reset_sequences argument in sql_flush must be understood as relative to the tables passed, not absolute. (#112)

The present fix aligns the relevant code snippet with the one from the closest impl. i.e. Oracle - cf. https://github.com/django/django/blob/795da6306a048b18c0158496b0d49e8e4f197a32/django/db/backends/oracle/operations.py#L493

* Adds unit test for issues #110 and #90 (#115)

* Added test to test changing from non-nullable to nullable with unique
index

* Fixed failing unit test on Django 3.1 and lower due to bad import

* Unskip one passed test (#116)

Unit test `bulk_create.tests.BulkCreateTests.test_bulk_insert_nullable_fields` is passing, remove it from expected failures.

* Add offset clause for all Django versions (#117)

Issue #109, missing OFFSET clause causes select_for_update() to fail

* Create devskim.yml (#120)

* Fixed missing ValidatorError import (#121)

* Updated supported Django versions (#122)

* Bump up version to 1.1.3 (#123)

Co-authored-by: Tom Sparrow <793763+sparrowt@users.noreply.github.com>
Co-authored-by: jmah8 <59151084+jmah8@users.noreply.github.com>
Co-authored-by: jean-frenette-optel <95249592+jean-frenette-optel@users.noreply.github.com>
Co-authored-by: Anthony Shaw <anthony.p.shaw@gmail.com>
Co-authored-by: Petter Moe Kvalvaag <pmkv@equinor.com>
Co-authored-by: Ruben De Visscher <github@rubendv.be>
Co-authored-by: Ruben De Visscher <ruben@awingu.com>
Co-authored-by: marcperrinoptel <marc.perrin@optelgroup.com>
Co-authored-by: marcperrinoptel <86617454+marcperrinoptel@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Alter unique, nullable field to non-unique, nullable fails
2 participants