From b85e4f1c0697e4e83cd4cdc66db643bfcc0f7726 Mon Sep 17 00:00:00 2001 From: mShan0 Date: Wed, 14 Dec 2022 10:10:29 -0800 Subject: [PATCH 01/14] Add 4.1 and MSSQL 2022 to README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 893b39f7..74e47537 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ We hope you enjoy using the MSSQL-Django 3rd party backend. ## Features -- Supports Django 3.2 and 4.0 -- Tested on Microsoft SQL Server 2016, 2017, 2019 +- Supports Django 3.2, 4.0, and 4.1 +- Tested on Microsoft SQL Server 2016, 2017, 2019, 2022 - Passes most of the tests of the Django test suite - Compatible with [Micosoft ODBC Driver for SQL Server](https://docs.microsoft.com/en-us/sql/connect/odbc/microsoft-odbc-driver-for-sql-server), From f59b59b8b88ae9f41047468bd791cf0da47bf536 Mon Sep 17 00:00:00 2001 From: Adel Khayata <63958307+adelkhayata76@users.noreply.github.com> Date: Wed, 28 Dec 2022 15:53:01 +0400 Subject: [PATCH 02/14] Fix an issue of adding OFFSET keyword for legacy SQL Server vresions I was getting the following exception when trying to access legacy SQL Server 2008 R2 database: pyodbc.ProgrammingError: ('42000', "[42000] [Microsoft][ODBC Driver 17 for SQL Server][SQL Server]Incorrect syntax near 'OFFSET'. (102) (SQLExecDirectW)") I only added one more condition before adding the OFFSET keyword. --- mssql/compiler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mssql/compiler.py b/mssql/compiler.py index 50fa0cf5..b7e7584a 100644 --- a/mssql/compiler.py +++ b/mssql/compiler.py @@ -364,7 +364,7 @@ def as_sql(self, with_limits=True, with_col_aliases=False): # Django 2.x. See https://github.com/microsoft/mssql-django/issues/12 # Add OFFSET for all Django versions. # https://github.com/microsoft/mssql-django/issues/109 - if not (do_offset or do_limit): + if not (do_offset or do_limit) and supports_offset_clause: result.append("OFFSET 0 ROWS") # SQL Server requires the backend-specific emulation (2008 or earlier) From a62e064bd940ff2e0d6458869dc599c4b4637173 Mon Sep 17 00:00:00 2001 From: Khanh Bui <85855766+khanhmaibui@users.noreply.github.com> Date: Fri, 6 Jan 2023 14:32:13 -0800 Subject: [PATCH 03/14] Allow `bash` with allowlist_externals (#221) * allow `bash` with allowlist_externals * delete old `bash` paths --- tox.ini | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index c6cdf81c..12d89b3c 100644 --- a/tox.ini +++ b/tox.ini @@ -6,9 +6,7 @@ envlist = [testenv] allowlist_externals = - /bin/bash - /usr/bin/bash - C:\Program Files\Git\bin\bash.EXE + bash commands = python manage.py test --noinput From 54ef30ddaf6bb91c24266a014b58c7b981502385 Mon Sep 17 00:00:00 2001 From: SGills <5122866+sg3-141-592@users.noreply.github.com> Date: Tue, 10 Jan 2023 21:23:22 +0000 Subject: [PATCH 04/14] Changed getting system datetime to cached_property (#217) --- mssql/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mssql/base.py b/mssql/base.py index 1bd26c68..cf11c506 100644 --- a/mssql/base.py +++ b/mssql/base.py @@ -11,6 +11,7 @@ import datetime from django.core.exceptions import ImproperlyConfigured +from django.utils.functional import cached_property try: import pyodbc as Database @@ -430,7 +431,7 @@ def init_connection_state(self): if (options.get('return_rows_bulk_insert', False)): self.features_class.can_return_rows_from_bulk_insert = True - val = self.get_system_datetime() + val = self.get_system_datetime if isinstance(val, str): raise ImproperlyConfigured( "The database driver doesn't support modern datatime types.") @@ -443,6 +444,7 @@ def is_usable(self): else: return True + @cached_property def get_system_datetime(self): # http://blogs.msdn.com/b/sqlnativeclient/archive/2008/02/27/microsoft-sql-server-native-client-and-microsoft-sql-server-2008-native-client.aspx with self.temporary_connection() as cursor: From bcca753306a44ffef5115cc44c64bd81f5c0ef9e Mon Sep 17 00:00:00 2001 From: Khanh Bui <85855766+khanhmaibui@users.noreply.github.com> Date: Fri, 20 Jan 2023 15:40:45 -0800 Subject: [PATCH 05/14] Add case-sensitive feature for Replace function (#222) * replacetests case sensitive fix * remove case sensitive skipped test * refix to use django .replace --- mssql/functions.py | 15 +++++++++++++++ testapp/settings.py | 1 - 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/mssql/functions.py b/mssql/functions.py index cd6e4a3d..ddf34ca7 100644 --- a/mssql/functions.py +++ b/mssql/functions.py @@ -11,6 +11,7 @@ from django.db.models.fields import BinaryField, Field from django.db.models.functions import Cast, NthValue, MD5, SHA1, SHA224, SHA256, SHA384, SHA512 from django.db.models.functions.math import ATan2, Ln, Log, Mod, Round, Degrees, Radians, Power +from django.db.models.functions.text import Replace from django.db.models.lookups import In, Lookup from django.db.models.query import QuerySet from django.db.models.sql.query import Query @@ -44,6 +45,19 @@ def sqlserver_log(self, compiler, connection, **extra_context): def sqlserver_ln(self, compiler, connection, **extra_context): return self.as_sql(compiler, connection, function='LOG', **extra_context) + +def sqlserver_replace(self, compiler, connection, **extra_context): + current_db = "CONVERT(varchar, (SELECT DB_NAME()))" + with connection.cursor() as cursor: + cursor.execute("SELECT CONVERT(varchar, DATABASEPROPERTYEX(%s, 'collation'))" % current_db) + default_collation = cursor.fetchone()[0] + current_collation = default_collation.replace('_CI', '_CS') + return self.as_sql( + compiler, connection, function='REPLACE', + template = 'REPLACE(%s COLLATE %s)' % ('%(expressions)s', current_collation), + **extra_context + ) + def sqlserver_degrees(self, compiler, connection, **extra_context): return self.as_sql( compiler, connection, function='DEGREES', @@ -441,6 +455,7 @@ def sqlserver_sha512(self, compiler, connection, **extra_context): NthValue.as_microsoft = sqlserver_nth_value Round.as_microsoft = sqlserver_round Window.as_microsoft = sqlserver_window +Replace.as_microsoft = sqlserver_replace MD5.as_microsoft = sqlserver_md5 SHA1.as_microsoft = sqlserver_sha1 SHA224.as_microsoft = sqlserver_sha224 diff --git a/testapp/settings.py b/testapp/settings.py index 2b633480..f3245911 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -138,7 +138,6 @@ 'aggregation.tests.AggregateTestCase.test_count_star', 'aggregation_regress.tests.AggregationTests.test_values_list_annotation_args_ordering', 'db_functions.text.test_pad.PadTests.test_pad', - 'db_functions.text.test_replace.ReplaceTests.test_case_sensitive', 'expressions.tests.FTimeDeltaTests.test_invalid_operator', 'fixtures_regress.tests.TestFixtures.test_loaddata_raises_error_when_fixture_has_invalid_foreign_key', 'invalid_models_tests.test_ordinary_fields.TextFieldTests.test_max_length_warning', From 6a2903765671ad7ac95312c6509b001769b39523 Mon Sep 17 00:00:00 2001 From: Khanh Bui <85855766+khanhmaibui@users.noreply.github.com> Date: Fri, 3 Feb 2023 10:04:38 -0800 Subject: [PATCH 06/14] Fix left padding function to return strings with correct length (#226) * fix left padding to return strings with correct length * unskip pad test --- mssql/compiler.py | 5 +++-- testapp/settings.py | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mssql/compiler.py b/mssql/compiler.py index b7e7584a..e71aa14f 100644 --- a/mssql/compiler.py +++ b/mssql/compiler.py @@ -76,8 +76,9 @@ def _as_sql_lpad(self, compiler, connection): params.extend(length_arg) params.extend(expression_arg) params.extend(expression_arg) - template = ('LEFT(REPLICATE(%(fill_text)s, %(length)s), CASE WHEN %(length)s > LEN(%(expression)s) ' - 'THEN %(length)s - LEN(%(expression)s) ELSE 0 END) + %(expression)s') + params.extend(length_arg) + template = ('LEFT(LEFT(REPLICATE(%(fill_text)s, %(length)s), CASE WHEN %(length)s > LEN(%(expression)s) ' + 'THEN %(length)s - LEN(%(expression)s) ELSE 0 END) + %(expression)s, %(length)s)') return template % {'expression': expression, 'length': length, 'fill_text': fill_text}, params diff --git a/testapp/settings.py b/testapp/settings.py index f3245911..767f0f3d 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -137,7 +137,6 @@ 'schema.tests.SchemaTests.test_unique_together_with_fk_with_existing_index', 'aggregation.tests.AggregateTestCase.test_count_star', 'aggregation_regress.tests.AggregationTests.test_values_list_annotation_args_ordering', - 'db_functions.text.test_pad.PadTests.test_pad', 'expressions.tests.FTimeDeltaTests.test_invalid_operator', 'fixtures_regress.tests.TestFixtures.test_loaddata_raises_error_when_fixture_has_invalid_foreign_key', 'invalid_models_tests.test_ordinary_fields.TextFieldTests.test_max_length_warning', From 6810e5c71640f883501a91426473ac9cc010f120 Mon Sep 17 00:00:00 2001 From: mShan0 Date: Mon, 27 Feb 2023 09:54:20 -0800 Subject: [PATCH 07/14] make earliest supported version `3.2` --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index eba21613..215fe006 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ license='BSD', packages=find_packages(), install_requires=[ - 'django>=2.2,<4.2', + 'django>=3.2,<4.2', 'pyodbc>=3.0', 'pytz', ], From 3bcc66e5150c13931f3e220442de11a22036cfe9 Mon Sep 17 00:00:00 2001 From: Khanh Bui Date: Mon, 6 Mar 2023 14:17:23 -0800 Subject: [PATCH 08/14] fix limit_offset_sql to handle cases when there is no OFFSET --- mssql/operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mssql/operations.py b/mssql/operations.py index 9222ea5e..77ebfab6 100644 --- a/mssql/operations.py +++ b/mssql/operations.py @@ -404,7 +404,7 @@ def limit_offset_sql(self, low_mark, high_mark): """Return LIMIT/OFFSET SQL clause.""" limit, offset = self._get_limit_offset_params(low_mark, high_mark) return '%s%s' % ( - (' OFFSET %d ROWS' % offset) if offset else '', + (' OFFSET %d ROWS' % offset) if offset else ' OFFSET 0 ROWS', (' FETCH FIRST %d ROWS ONLY' % limit) if limit else '', ) From 75790ffbe057551babf3627f09777b808322883e Mon Sep 17 00:00:00 2001 From: Khanh Bui <85855766+khanhmaibui@users.noreply.github.com> Date: Fri, 14 Apr 2023 12:42:01 -0700 Subject: [PATCH 09/14] unskip some tests (#252) --- testapp/settings.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/testapp/settings.py b/testapp/settings.py index 767f0f3d..ff733a4b 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -119,7 +119,6 @@ 'ordering.tests.OrderingTests.test_orders_nulls_first_on_filtered_subquery', 'get_or_create.tests.UpdateOrCreateTransactionTests.test_creation_in_transaction', 'indexes.tests.PartialIndexTests.test_multiple_conditions', - 'introspection.tests.IntrospectionTests.test_get_constraints', 'migrations.test_executor.ExecutorTests.test_alter_id_type_with_fk', 'migrations.test_operations.OperationTests.test_add_constraint_percent_escaping', 'migrations.test_operations.OperationTests.test_alter_field_pk', @@ -144,7 +143,6 @@ 'ordering.tests.OrderingTests.test_deprecated_values_annotate', 'queries.test_qs_combinators.QuerySetSetOperationTests.test_limits', 'backends.tests.BackendTestCase.test_unicode_password', - 'introspection.tests.IntrospectionTests.test_get_table_description_types', 'migrations.test_commands.MigrateTests.test_migrate_syncdb_app_label', 'migrations.test_commands.MigrateTests.test_migrate_syncdb_deferred_sql_executed_with_schemaeditor', 'migrations.test_operations.OperationTests.test_alter_field_pk_fk', @@ -216,7 +214,6 @@ 'lookup.tests.LookupTests.test_in_ignore_none', 'lookup.tests.LookupTests.test_in_ignore_none_with_unhashable_items', 'queries.test_qs_combinators.QuerySetSetOperationTests.test_exists_union', - 'introspection.tests.IntrospectionTests.test_get_constraints_unique_indexes_orders', 'schema.tests.SchemaTests.test_ci_cs_db_collation', 'select_for_update.tests.SelectForUpdateTests.test_unsuported_no_key_raises_error', From 9ff5fd520764c90df59d24abc789ee9c1e3776e8 Mon Sep 17 00:00:00 2001 From: mShan0 <96149598+mShan0@users.noreply.github.com> Date: Mon, 17 Apr 2023 10:03:09 -0700 Subject: [PATCH 10/14] add `python 3.11` and drop `python 3.6` (#254) * add `python 3.11` and drop `python 3.6` * add to windows ci --- azure-pipelines.yml | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 3436990c..b5b7db72 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -24,6 +24,9 @@ jobs: strategy: matrix: + Python3.11 - Django 4.1: + python.version: '3.11' + tox.env: 'py311-django41' Python3.10 - Django 4.1: python.version: '3.10' tox.env: 'py310-django41' @@ -34,6 +37,9 @@ jobs: python.version: '3.8' tox.env: 'py38-django41' + Python3.11 - Django 4.0: + python.version: '3.11' + tox.env: 'py311-django40' Python3.10 - Django 4.0: python.version: '3.10' tox.env: 'py310-django40' @@ -44,6 +50,9 @@ jobs: python.version: '3.8' tox.env: 'py38-django40' + Python3.11 - Django 3.2: + python.version: '3.11' + tox.env: 'py311-django32' Python 3.9 - Django 3.2: python.version: '3.9' tox.env: 'py39-django32' @@ -53,9 +62,7 @@ jobs: Python 3.7 - Django 3.2: python.version: '3.7' tox.env: 'py37-django32' - Python 3.6 - Django 3.2: - python.version: '3.6' - tox.env: 'py36-django32' + steps: - task: CredScan@2 @@ -111,6 +118,9 @@ jobs: strategy: matrix: + Python3.11 - Django 4.1: + python.version: '3.11' + tox.env: 'py311-django41' Python3.10 - Django 4.1: python.version: '3.10' tox.env: 'py310-django41' @@ -121,6 +131,9 @@ jobs: python.version: '3.8' tox.env: 'py38-django41' + Python3.11 - Django 4.0: + python.version: '3.11' + tox.env: 'py311-django40' Python3.10 - Django 4.0: python.version: '3.10' tox.env: 'py310-django40' @@ -131,6 +144,9 @@ jobs: python.version: '3.8' tox.env: 'py38-django40' + Python3.11 - Django 3.2: + python.version: '3.11' + tox.env: 'py311-django32' Python 3.9 - Django 3.2: python.version: '3.9' tox.env: 'py39-django32' @@ -140,9 +156,6 @@ jobs: Python 3.7 - Django 3.2: python.version: '3.7' tox.env: 'py37-django32' - Python 3.6 - Django 3.2: - python.version: '3.6' - tox.env: 'py36-django32' steps: - task: UsePythonVersion@0 From 03268bd75155ef3276e61fd54814b26a22ce4c00 Mon Sep 17 00:00:00 2001 From: mShan0 <96149598+mShan0@users.noreply.github.com> Date: Mon, 17 Apr 2023 14:04:16 -0700 Subject: [PATCH 11/14] update to codeql v2 (#255) --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a9913b3f..f5865a66 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -39,7 +39,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -50,7 +50,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v2 # ℹī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -64,4 +64,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 From d07417217065ef2539c8b2a8afb81f762086e09c Mon Sep 17 00:00:00 2001 From: mShan0 <96149598+mShan0@users.noreply.github.com> Date: Tue, 18 Apr 2023 12:28:23 -0700 Subject: [PATCH 12/14] update checkout action to v3 (#257) --- .github/workflows/codeql-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f5865a66..91ec5aa6 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL From 422cc3dedec2491997c1423f6b99bd25088ed105 Mon Sep 17 00:00:00 2001 From: mShan0 <96149598+mShan0@users.noreply.github.com> Date: Wed, 31 May 2023 16:00:47 -0700 Subject: [PATCH 13/14] Prepare for 1.3 release (#269) * Allow Django 4.2 (#227) * Allow Django 4.2 * allow Django 4.2 * Fix errors with raising FullResultSet exception and with alter_column_type_sql() and collate_sql() functions (#229) * fix error with raising fullresultset * add django4.2 condition * fix alter_column_type_sql and collate_sql to take 2 additional arguments * delete argument 'old_rel_collation' * fix arguments names * fix last_executed_query() to properly replace placeholders with params (#234) * disable allows_group_by_select_index * unskip old tests * unskip some tests * skip more tests * Use latest Django 4.2 beta for tox (#238) * use 4.2 rc1 branch (#240) * allow partial support for filtering against window functions (#239) * add subsecond support to Now() (#242) * assign value to display_size (#244) * add latest django 4.2 branch to ci * raise an error when batch_size is zero. (#259) * replicate get or create test for mssql (#265) * Add skipped tests to Django 4.2 (#268) * skip django 4.2 failing tests * skip schema test * skip aggregate annotation pruning test --------- Co-authored-by: mShan0 * syntax fix * ci fix * bump version to 1.3 --------- Co-authored-by: Khanh Bui <85855766+khanhmaibui@users.noreply.github.com> Co-authored-by: Khanh Bui --- azure-pipelines.yml | 26 ++++++++++ mssql/compiler.py | 19 +++++++- mssql/features.py | 3 +- mssql/functions.py | 8 +++- mssql/introspection.py | 2 +- mssql/operations.py | 8 +++- mssql/schema.py | 37 +++++++++++---- setup.py | 5 +- testapp/migrations/0024_publisher_book.py | 58 +++++++++++++++++++++++ testapp/models.py | 20 +++++++- testapp/settings.py | 18 +++++-- testapp/tests/test_getorcreate.py | 41 ++++++++++++++++ tox.ini | 6 ++- 13 files changed, 225 insertions(+), 26 deletions(-) create mode 100644 testapp/migrations/0024_publisher_book.py create mode 100644 testapp/tests/test_getorcreate.py diff --git a/azure-pipelines.yml b/azure-pipelines.yml index b5b7db72..7cb512dd 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -24,6 +24,19 @@ jobs: strategy: matrix: + Python3.11 - Django 4.2: + python.version: '3.11' + tox.env: 'py311-django42' + Python3.10 - Django 4.2: + python.version: '3.10' + tox.env: 'py310-django42' + Python 3.9 - Django 4.2: + python.version: '3.9' + tox.env: 'py39-django42' + Python 3.8 - Django 4.2: + python.version: '3.8' + tox.env: 'py38-django42' + Python3.11 - Django 4.1: python.version: '3.11' tox.env: 'py311-django41' @@ -118,6 +131,19 @@ jobs: strategy: matrix: + Python3.11 - Django 4.2: + python.version: '3.11' + tox.env: 'py311-django42' + Python3.10 - Django 4.2: + python.version: '3.10' + tox.env: 'py310-django42' + Python 3.9 - Django 4.2: + python.version: '3.9' + tox.env: 'py39-django42' + Python 3.8 - Django 4.2: + python.version: '3.8' + tox.env: 'py38-django42' + Python3.11 - Django 4.1: python.version: '3.11' tox.env: 'py311-django41' diff --git a/mssql/compiler.py b/mssql/compiler.py index e71aa14f..2dfe1b6c 100644 --- a/mssql/compiler.py +++ b/mssql/compiler.py @@ -15,6 +15,8 @@ from django.db.utils import NotSupportedError if django.VERSION >= (3, 1): from django.db.models.fields.json import compile_json_path, KeyTransform as json_KeyTransform +if django.VERSION >= (4, 2): + from django.core.exceptions import FullResultSet def _as_sql_agv(self, compiler, connection): return self.as_sql(compiler, connection, template='%(function)s(CONVERT(float, %(field)s))') @@ -228,13 +230,26 @@ def as_sql(self, with_limits=True, with_col_aliases=False): if not getattr(features, 'supports_select_{}'.format(combinator)): raise NotSupportedError('{} is not supported on this database backend.'.format(combinator)) result, params = self.get_combinator_sql(combinator, self.query.combinator_all) + elif django.VERSION >= (4, 2) and self.qualify: + result, params = self.get_qualify_sql() + order_by = None else: distinct_fields, distinct_params = self.get_distinct() # This must come after 'select', 'ordering', and 'distinct' -- see # docstring of get_from_clause() for details. from_, f_params = self.get_from_clause() - where, w_params = self.compile(self.where) if self.where is not None else ("", []) - having, h_params = self.compile(self.having) if self.having is not None else ("", []) + if django.VERSION >= (4, 2): + try: + where, w_params = self.compile(self.where) if self.where is not None else ("", []) + except FullResultSet: + where, w_params = "", [] + try: + having, h_params = self.compile(self.having) if self.having is not None else ("", []) + except FullResultSet: + having, h_params = "", [] + else: + where, w_params = self.compile(self.where) if self.where is not None else ("", []) + having, h_params = self.compile(self.having) if self.having is not None else ("", []) params = [] result = ['SELECT'] diff --git a/mssql/features.py b/mssql/features.py index a60a9283..faaa0bbb 100644 --- a/mssql/features.py +++ b/mssql/features.py @@ -6,6 +6,7 @@ class DatabaseFeatures(BaseDatabaseFeatures): + allows_group_by_select_index = False allow_sliced_subqueries_with_in = False can_introspect_autofield = True can_introspect_json_field = False @@ -71,4 +72,4 @@ def introspected_field_types(self): return { **super().introspected_field_types, "DurationField": "BigIntegerField", - } \ No newline at end of file + } diff --git a/mssql/functions.py b/mssql/functions.py index ddf34ca7..4fdedc7d 100644 --- a/mssql/functions.py +++ b/mssql/functions.py @@ -10,6 +10,7 @@ from django.db.models.expressions import Case, Exists, Expression, OrderBy, When, Window from django.db.models.fields import BinaryField, Field from django.db.models.functions import Cast, NthValue, MD5, SHA1, SHA224, SHA256, SHA384, SHA512 +from django.db.models.functions.datetime import Now from django.db.models.functions.math import ATan2, Ln, Log, Mod, Round, Degrees, Radians, Power from django.db.models.functions.text import Replace from django.db.models.lookups import In, Lookup @@ -123,6 +124,10 @@ def sqlserver_exists(self, compiler, connection, template=None, **extra_context) sql = 'CASE WHEN {} THEN 1 ELSE 0 END'.format(sql) return sql, params +def sqlserver_now(self, compiler, connection, **extra_context): + return self.as_sql( + compiler, connection, template="SYSDATETIME()", **extra_context + ) def sqlserver_lookup(self, compiler, connection): # MSSQL doesn't allow EXISTS() to be compared to another expression @@ -287,7 +292,7 @@ def bulk_update_with_default(self, objs, fields, batch_size=None, default=0): SQL Server require that at least one of the result expressions in a CASE specification must be an expression other than the NULL constant. Patched with a default value 0. The user can also pass a custom default value for CASE statement. """ - if batch_size is not None and batch_size < 0: + if batch_size is not None and batch_size <= 0: raise ValueError('Batch size must be a positive integer.') if not fields: raise ValueError('Field names must be given to bulk_update().') @@ -456,6 +461,7 @@ def sqlserver_sha512(self, compiler, connection, **extra_context): Round.as_microsoft = sqlserver_round Window.as_microsoft = sqlserver_window Replace.as_microsoft = sqlserver_replace +Now.as_microsoft = sqlserver_now MD5.as_microsoft = sqlserver_md5 SHA1.as_microsoft = sqlserver_sha1 SHA224.as_microsoft = sqlserver_sha224 diff --git a/mssql/introspection.py b/mssql/introspection.py index c5645f30..f23af078 100644 --- a/mssql/introspection.py +++ b/mssql/introspection.py @@ -107,7 +107,7 @@ def get_table_description(self, cursor, table_name, identity_check=True): """ # map pyodbc's cursor.columns to db-api cursor description - columns = [[c[3], c[4], None, c[6], c[6], c[8], c[10], c[12]] for c in cursor.columns(table=table_name)] + columns = [[c[3], c[4], c[6], c[6], c[6], c[8], c[10], c[12]] for c in cursor.columns(table=table_name)] if not columns: raise DatabaseError(f"Table {table_name} does not exist.") diff --git a/mssql/operations.py b/mssql/operations.py index 77ebfab6..47f4001e 100644 --- a/mssql/operations.py +++ b/mssql/operations.py @@ -418,7 +418,13 @@ def last_executed_query(self, cursor, sql, params): exists for database backends to provide a better implementation according to their own quoting schemes. """ - return super().last_executed_query(cursor, cursor.last_sql, cursor.last_params) + if params: + if isinstance(params, list): + params = tuple(params) + return sql % params + # Just return sql when there are no parameters. + else: + return sql def savepoint_create_sql(self, sid): """ diff --git a/mssql/schema.py b/mssql/schema.py index f977fdc0..00ee5403 100644 --- a/mssql/schema.py +++ b/mssql/schema.py @@ -161,9 +161,14 @@ def _alter_column_null_sql(self, model, old_field, new_field): [], ) - def _alter_column_type_sql(self, model, old_field, new_field, new_type): - new_type = self._set_field_new_type_null_status(old_field, new_type) - return super()._alter_column_type_sql(model, old_field, new_field, new_type) + if django_version >= (4, 2): + def _alter_column_type_sql(self, model, old_field, new_field, new_type, old_collation, new_collation): + new_type = self._set_field_new_type_null_status(old_field, new_type) + return super()._alter_column_type_sql(model, old_field, new_field, new_type, old_collation, new_collation) + else: + def _alter_column_type_sql(self, model, old_field, new_field, new_type): + new_type = self._set_field_new_type_null_status(old_field, new_type) + return super()._alter_column_type_sql(model, old_field, new_field, new_type) def alter_unique_together(self, model, old_unique_together, new_unique_together): """ @@ -443,7 +448,12 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type, post_actions = [] # Type change? if old_type != new_type: - fragment, other_actions = self._alter_column_type_sql(model, old_field, new_field, new_type) + if django_version >= (4, 2): + fragment, other_actions = self._alter_column_type_sql( + model, old_field, new_field, new_type, old_collation=None, new_collation=None + ) + else: + fragment, other_actions = self._alter_column_type_sql(model, old_field, new_field, new_type) actions.append(fragment) post_actions.extend(other_actions) # Drop unique constraint, SQL Server requires explicit deletion @@ -683,9 +693,14 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type, for old_rel, new_rel in rels_to_update: rel_db_params = new_rel.field.db_parameters(connection=self.connection) rel_type = rel_db_params['type'] - fragment, other_actions = self._alter_column_type_sql( - new_rel.related_model, old_rel.field, new_rel.field, rel_type - ) + if django_version >= (4, 2): + fragment, other_actions = self._alter_column_type_sql( + new_rel.related_model, old_rel.field, new_rel.field, rel_type, old_collation=None, new_collation=None + ) + else: + fragment, other_actions = self._alter_column_type_sql( + new_rel.related_model, old_rel.field, new_rel.field, rel_type + ) # Drop related_model indexes, so it can be altered index_names = self._db_table_constraint_names(old_rel.related_model._meta.db_table, index=True) for index_name in index_names: @@ -1262,8 +1277,12 @@ def add_constraint(self, model, constraint): (constraint.condition.connector, constraint.name)) super().add_constraint(model, constraint) - def _collate_sql(self, collation): - return ' COLLATE ' + collation + if django_version >= (4, 2): + def _collate_sql(self, collation, old_collation=None, table_name=None): + return ' COLLATE ' + collation if collation else "" + else: + def _collate_sql(self, collation): + return ' COLLATE ' + collation def _create_index_name(self, table_name, column_names, suffix=""): index_name = super()._create_index_name(table_name, column_names, suffix) diff --git a/setup.py b/setup.py index 215fe006..78532e94 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,7 @@ 'Framework :: Django :: 3.2', 'Framework :: Django :: 4.0', 'Framework :: Django :: 4.1', + 'Framework :: Django :: 4.2', ] this_directory = path.abspath(path.dirname(__file__)) @@ -26,7 +27,7 @@ setup( name='mssql-django', - version='1.2', + version='1.3', description='Django backend for Microsoft SQL Server', long_description=long_description, long_description_content_type='text/markdown', @@ -39,7 +40,7 @@ license='BSD', packages=find_packages(), install_requires=[ - 'django>=3.2,<4.2', + 'django>=3.2,<4.3', 'pyodbc>=3.0', 'pytz', ], diff --git a/testapp/migrations/0024_publisher_book.py b/testapp/migrations/0024_publisher_book.py new file mode 100644 index 00000000..b555d0d0 --- /dev/null +++ b/testapp/migrations/0024_publisher_book.py @@ -0,0 +1,58 @@ +# Generated by Django 4.2 on 2023-05-03 15:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("testapp", "0023_number"), + ] + + operations = [ + migrations.CreateModel( + name="Publisher", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ], + ), + migrations.CreateModel( + name="Book", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "authors", + models.ManyToManyField(related_name="books", to="testapp.author"), + ), + ( + "publisher", + models.ForeignKey( + db_column="publisher_id_column", + on_delete=django.db.models.deletion.CASCADE, + related_name="books", + to="testapp.publisher", + ), + ), + ], + ), + ] diff --git a/testapp/models.py b/testapp/models.py index fb5fdbcf..f92d10f2 100644 --- a/testapp/models.py +++ b/testapp/models.py @@ -9,7 +9,7 @@ from django.db.models import Q from django.utils import timezone -# We are using this Mixin to test casting of BigAuto and Auto fields +# We are using this Mixin to test casting of BigAuto and Auto fields class BigAutoFieldMixin(models.Model): id = models.BigAutoField(primary_key=True) @@ -229,4 +229,20 @@ class Number(models.Model): decimal_value = models.DecimalField(max_digits=20, decimal_places=17, null=True) def __str__(self): - return "%i, %.3f, %.17f" % (self.integer, self.float, self.decimal_value) \ No newline at end of file + return "%i, %.3f, %.17f" % (self.integer, self.float, self.decimal_value) + + +class Publisher(models.Model): + name = models.CharField(max_length=100) + + +class Book(models.Model): + name = models.CharField(max_length=100) + authors = models.ManyToManyField(Author, related_name="books") + publisher = models.ForeignKey( + Publisher, + models.CASCADE, + related_name="books", + db_column="publisher_id_column", + ) + updated = models.DateTimeField(auto_now=True) diff --git a/testapp/settings.py b/testapp/settings.py index ff733a4b..ecdafbde 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -99,8 +99,6 @@ TEST_RUNNER = "testapp.runners.ExcludedTestSuiteRunner" EXCLUDED_TESTS = [ - 'aggregation.tests.AggregateTestCase.test_expression_on_aggregation', - 'aggregation_regress.tests.AggregationTests.test_annotated_conditional_aggregate', 'aggregation_regress.tests.AggregationTests.test_annotation_with_value', 'aggregation.tests.AggregateTestCase.test_distinct_on_aggregate', 'annotations.tests.NonAggregateAnnotationTestCase.test_annotate_exists', @@ -150,7 +148,6 @@ 'schema.tests.SchemaTests.test_unique_and_reverse_m2m', 'schema.tests.SchemaTests.test_unique_no_unnecessary_fk_drops', 'select_for_update.tests.SelectForUpdateTests.test_for_update_after_from', - 'backends.tests.LastExecutedQueryTest.test_last_executed_query', 'db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_year_exact_lookup', 'db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_year_greaterthan_lookup', 'db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_year_lessthan_lookup', @@ -172,9 +169,7 @@ 'expressions.tests.FTimeDeltaTests.test_time_subquery_subtraction', 'migrations.test_operations.OperationTests.test_alter_field_reloads_state_on_fk_with_to_field_target_type_change', 'schema.tests.SchemaTests.test_alter_smallint_pk_to_smallautofield_pk', - 'annotations.tests.NonAggregateAnnotationTestCase.test_combined_expression_annotation_with_aggregation', - 'db_functions.comparison.test_cast.CastTests.test_cast_to_integer', 'db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_func', 'db_functions.datetime.test_extract_trunc.DateFunctionTests.test_extract_iso_weekday_func', 'db_functions.datetime.test_extract_trunc.DateFunctionWithTimeZoneTests.test_extract_func', @@ -291,6 +286,19 @@ 'model_fields.test_jsonfield.TestQuerying.test_lookups_with_key_transform', 'model_fields.test_jsonfield.TestQuerying.test_ordering_grouping_by_count', 'model_fields.test_jsonfield.TestQuerying.test_has_key_number', + + # Django 4.2 + 'get_or_create.tests.UpdateOrCreateTests.test_update_only_defaults_and_pre_save_fields_when_local_fields', + 'aggregation.test_filter_argument.FilteredAggregateTests.test_filtered_aggregate_empty_condition', + 'aggregation.test_filter_argument.FilteredAggregateTests.test_filtered_aggregate_ref_multiple_subquery_annotation', + 'aggregation.test_filter_argument.FilteredAggregateTests.test_filtered_aggregate_ref_subquery_annotation', + "aggregation.tests.AggregateAnnotationPruningTests.test_referenced_group_by_annotation_kept", + 'aggregation.tests.AggregateTestCase.test_group_by_nested_expression_with_params', + 'expressions.tests.BasicExpressionsTests.test_aggregate_subquery_annotation', + 'queries.test_qs_combinators.QuerySetSetOperationTests.test_union_order_with_null_first_last', + 'queries.test_qs_combinators.QuerySetSetOperationTests.test_union_with_select_related_and_order', + 'expressions_window.tests.WindowFunctionTests.test_limited_filter', + 'schema.tests.SchemaTests.test_remove_ignored_unique_constraint_not_create_fk_index', ] REGEX_TESTS = [ diff --git a/testapp/tests/test_getorcreate.py b/testapp/tests/test_getorcreate.py new file mode 100644 index 00000000..1fe9baf0 --- /dev/null +++ b/testapp/tests/test_getorcreate.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the BSD license. +from unittest import skipUnless + +from django import VERSION +from django.test import TestCase +from django.db import connection +from django.test.utils import CaptureQueriesContext + +from ..models import Book, Publisher + +DJANGO42 = VERSION >= (4, 2) + +# Copied from Django test suite but modified to test our code +@skipUnless(DJANGO42, "Django 4.2 specific tests") +class UpdateOrCreateTests(TestCase): + + def test_update_only_defaults_and_pre_save_fields_when_local_fields(self): + publisher = Publisher.objects.create(name="Acme Publishing") + book = Book.objects.create(publisher=publisher, name="The Book of Ed & Fred") + + for defaults in [{"publisher": publisher}, {"publisher_id": publisher}]: + with self.subTest(defaults=defaults): + with CaptureQueriesContext(connection) as captured_queries: + book, created = Book.objects.update_or_create( + pk=book.pk, + defaults=defaults, + ) + self.assertIs(created, False) + update_sqls = [ + q["sql"] for q in captured_queries if "UPDATE" in q["sql"] + ] + self.assertEqual(len(update_sqls), 1) + update_sql = update_sqls[0] + self.assertIsNotNone(update_sql) + self.assertIn( + connection.ops.quote_name("publisher_id_column"), update_sql + ) + self.assertIn(connection.ops.quote_name("updated"), update_sql) + # Name should not be updated. + self.assertNotIn(connection.ops.quote_name("name"), update_sql) diff --git a/tox.ini b/tox.ini index 12d89b3c..7d2794ef 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,9 @@ [tox] envlist = {py36,py37,py38,py39}-django32, - {py38, py39, py310}-django40 - {py38, py39, py310}-django41 + {py38, py39, py310}-django40, + {py38, py39, py310}-django41, + {py38, py39, py310}-django42 [testenv] allowlist_externals = @@ -19,3 +20,4 @@ deps = django32: django==3.2.* django40: django>=4.0a1,<4.1 django41: django>=4.1a1,<4.2 + django42: django>=4.2,<4.3 From c0563d3eb85d72ceedcd12b20f4a8b0b039ba2fc Mon Sep 17 00:00:00 2001 From: mShan0 <96149598+mShan0@users.noreply.github.com> Date: Thu, 1 Jun 2023 10:25:59 -0700 Subject: [PATCH 14/14] add 4.2 to readme (#274) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 74e47537..d4b30afa 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ We hope you enjoy using the MSSQL-Django 3rd party backend. ## Features -- Supports Django 3.2, 4.0, and 4.1 +- Supports Django 3.2, 4.0, 4.1 and 4.2 - Tested on Microsoft SQL Server 2016, 2017, 2019, 2022 - Passes most of the tests of the Django test suite - Compatible with