Skip to content

Commit a88c246

Browse files
authored
Merge pull request #78 from microsoft/dev
Prepare for for 1.1 release
2 parents 1263fc5 + 9758247 commit a88c246

19 files changed

+608
-345
lines changed

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Project
1+
# SQL Server backend for Django
22

33
Welcome to the MSSQL-Django 3rd party backend project!
44

@@ -10,7 +10,7 @@ We hope you enjoy using the MSSQL-Django 3rd party backend.
1010

1111
## Features
1212

13-
- Supports Django 2.2, 3.0, 3.1 and 3.2
13+
- Supports Django 2.2, 3.0, 3.1, 3.2 and 4.0
1414
- Tested on Microsoft SQL Server 2016, 2017, 2019
1515
- Passes most of the tests of the Django test suite
1616
- Compatible with
@@ -153,7 +153,7 @@ Dictionary. Current available keys are:
153153
- extra_params
154154

155155
String. Additional parameters for the ODBC connection. The format is
156-
``"param=value;param=value"``, [Azure AD Authentication](https://github.com/microsoft/mssql-django/wiki/Azure-AD-Authentication) can be added to this field.
156+
``"param=value;param=value"``, [Azure AD Authentication](https://github.com/microsoft/mssql-django/wiki/Azure-AD-Authentication) (Service Principal, Interactive, Msi) can be added to this field.
157157

158158
- collation
159159

@@ -225,7 +225,6 @@ The following features are currently not fully supported:
225225
- Exists function in order_by
226226
- Righthand power and arithmetic with datatimes
227227
- Timezones, timedeltas not fully supported
228-
- `bulk_update` multiple field to null
229228
- Rename field/model with foreign key constraint
230229
- Database level constraints
231230
- Math degrees power or radians

azure-pipelines.yml

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,21 @@ jobs:
1616
pool:
1717
name: Django-1ES-pool
1818
demands:
19-
- imageOverride -equals MMS2016
19+
- imageOverride -equals JDBC-MMS2019-SQL2019
2020
timeoutInMinutes: 120
2121

2222
strategy:
2323
matrix:
24+
Python3.10 - Django 4.0:
25+
python.version: '3.10'
26+
tox.env: 'py310-django40'
27+
Python 3.9 - Django 4.0:
28+
python.version: '3.9'
29+
tox.env: 'py39-django40'
30+
Python 3.8 - Django 4.0:
31+
python.version: '3.8'
32+
tox.env: 'py38-django40'
33+
2434
Python 3.9 - Django 3.2:
2535
python.version: '3.9'
2636
tox.env: 'py39-django32'
@@ -85,10 +95,20 @@ jobs:
8595
Invoke-WebRequest https://download.microsoft.com/download/E/6/B/E6BFDC7A-5BCD-4C51-9912-635646DA801E/en-US/17.5.2.1/x64/msodbcsql.msi -OutFile msodbcsql.msi
8696
msiexec /quiet /passive /qn /i msodbcsql.msi IACCEPTMSODBCSQLLICENSETERMS=YES
8797
Get-OdbcDriver
98+
displayName: Install ODBC
8899
89-
docker pull microsoft/mssql-server-windows-developer
90-
docker run -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD=MyPassword42' -p 1433:1433 -d microsoft/mssql-server-windows-developer
91-
docker ps
100+
- powershell: |
101+
Import-Module "sqlps"
102+
Invoke-Sqlcmd @"
103+
EXEC xp_instance_regwrite N'HKEY_LOCAL_MACHINE', N'Software\Microsoft\MSSQLServer\MSSQLServer', N'LoginMode', REG_DWORD, 2
104+
ALTER LOGIN [sa] ENABLE;
105+
ALTER LOGIN [sa] WITH PASSWORD = 'MyPassword42', CHECK_POLICY=OFF;
106+
"@
107+
displayName: Set up SQL Server
108+
109+
- powershell: |
110+
Restart-Service -Name MSSQLSERVER -Force
111+
displayName: Restart SQL Server
92112
93113
- powershell: |
94114
python -m pip install --upgrade pip wheel setuptools
@@ -107,6 +127,16 @@ jobs:
107127

108128
strategy:
109129
matrix:
130+
Python3.10 - Django 4.0:
131+
python.version: '3.10'
132+
tox.env: 'py310-django40'
133+
Python 3.9 - Django 4.0:
134+
python.version: '3.9'
135+
tox.env: 'py39-django40'
136+
Python 3.8 - Django 4.0:
137+
python.version: '3.8'
138+
tox.env: 'py38-django40'
139+
110140
Python 3.9 - Django 3.2:
111141
python.version: '3.9'
112142
tox.env: 'py39-django32'
@@ -186,5 +216,5 @@ jobs:
186216
displayName: Publish test results via jUnit
187217
inputs:
188218
testResultsFormat: 'JUnit'
189-
testResultsFiles: 'result.xml'
219+
testResultsFiles: 'django/result.xml'
190220
testRunTitle: 'junit-$(Agent.OS)-$(Agent.OSArchitecture)-$(tox.env)'

docker/Dockerfile

Lines changed: 0 additions & 20 deletions
This file was deleted.

docker/docker-compose.yml

Lines changed: 0 additions & 22 deletions
This file was deleted.

mssql/base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ def get_new_connection(self, conn_params):
258258
options = conn_params.get('OPTIONS', {})
259259
driver = options.get('driver', 'ODBC Driver 17 for SQL Server')
260260
dsn = options.get('dsn', None)
261+
options_extra_params = options.get('extra_params', '')
261262

262263
# Microsoft driver names assumed here are:
263264
# * SQL Server Native Client 10.0/11.0
@@ -291,10 +292,10 @@ def get_new_connection(self, conn_params):
291292

292293
if user:
293294
cstr_parts['UID'] = user
294-
if 'Authentication=ActiveDirectoryInteractive' not in options.get('extra_params', ''):
295+
if 'Authentication=ActiveDirectoryInteractive' not in options_extra_params:
295296
cstr_parts['PWD'] = password
296297
else:
297-
if ms_drivers.match(driver):
298+
if ms_drivers.match(driver) and 'Authentication=ActiveDirectoryMsi' not in options_extra_params:
298299
cstr_parts['Trusted_Connection'] = trusted_connection
299300
else:
300301
cstr_parts['Integrated Security'] = 'SSPI'

mssql/compiler.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,8 @@ def as_sql(self, with_limits=True, with_col_aliases=False):
302302
result.append('HAVING %s' % having)
303303
params.extend(h_params)
304304

305-
if self.query.explain_query:
305+
explain = self.query.explain_info if django.VERSION >= (4, 0) else self.query.explain_query
306+
if explain:
306307
result.insert(0, self.connection.ops.explain_query_prefix(
307308
self.query.explain_format,
308309
**self.query.explain_options

mssql/creation.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import binascii
55
import os
66

7+
from django.db.utils import InterfaceError
78
from django.db.backends.base.creation import BaseDatabaseCreation
89
from django import VERSION as django_version
910

@@ -16,6 +17,25 @@ def cursor(self):
1617

1718
return self.connection._nodb_connection.cursor()
1819

20+
def _create_test_db(self, verbosity, autoclobber, keepdb=False):
21+
"""
22+
Internal implementation - create the test db tables.
23+
"""
24+
25+
# Try to create the test DB, but if we fail due to 28000 (Login failed for user),
26+
# it's probably because the user doesn't have permission to [dbo].[master],
27+
# so we can proceed if we're keeping the DB anyway.
28+
# https://github.com/microsoft/mssql-django/issues/61
29+
try:
30+
return super()._create_test_db(verbosity, autoclobber, keepdb)
31+
except InterfaceError as err:
32+
if err.args[0] == '28000' and keepdb:
33+
self.log('Received error %s, proceeding because keepdb=True' % (
34+
err.args[1],
35+
))
36+
else:
37+
raise err
38+
1939
def _destroy_test_db(self, test_database_name, verbosity):
2040
"""
2141
Internal implementation - remove the test db tables.

mssql/features.py

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
1212
can_introspect_small_integer_field = True
1313
can_return_columns_from_insert = True
1414
can_return_id_from_insert = True
15+
can_rollback_ddl = True
1516
can_use_chunked_reads = False
1617
for_update_after_from = True
1718
greatest_least_ignores_nulls = True
@@ -47,26 +48,11 @@ class DatabaseFeatures(BaseDatabaseFeatures):
4748
supports_timezones = False
4849
supports_transactions = True
4950
uses_savepoints = True
50-
51-
@cached_property
52-
def has_bulk_insert(self):
53-
return self.connection.sql_server_version > 2005
54-
55-
@cached_property
56-
def supports_nullable_unique_constraints(self):
57-
return self.connection.sql_server_version > 2005
58-
59-
@cached_property
60-
def supports_partially_nullable_unique_constraints(self):
61-
return self.connection.sql_server_version > 2005
62-
63-
@cached_property
64-
def supports_partial_indexes(self):
65-
return self.connection.sql_server_version > 2005
66-
67-
@cached_property
68-
def supports_functions_in_partial_indexes(self):
69-
return self.connection.sql_server_version > 2005
51+
has_bulk_insert = True
52+
supports_nullable_unique_constraints = True
53+
supports_partially_nullable_unique_constraints = True
54+
supports_partial_indexes = True
55+
supports_functions_in_partial_indexes = True
7056

7157
@cached_property
7258
def has_zoneinfo_database(self):
@@ -76,4 +62,4 @@ def has_zoneinfo_database(self):
7662

7763
@cached_property
7864
def supports_json_field(self):
79-
return self.connection.sql_server_version >= 2016
65+
return self.connection.sql_server_version >= 2016 or self.connection.to_azure_sql_db

mssql/functions.py

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44
import json
55

66
from django import VERSION
7-
from django.db import NotSupportedError
7+
8+
from django.db import NotSupportedError, connections, transaction
89
from django.db.models import BooleanField, Value
910
from django.db.models.functions import Cast, NthValue
1011
from django.db.models.functions.math import ATan2, Log, Ln, Mod, Round
11-
from django.db.models.expressions import Case, Exists, OrderBy, When, Window
12+
from django.db.models.expressions import Case, Exists, OrderBy, When, Window, Expression
1213
from django.db.models.lookups import Lookup, In
13-
from django.db.models import lookups
14+
from django.db.models import lookups, CheckConstraint
1415
from django.db.models.fields import BinaryField, Field
16+
from django.db.models.sql.query import Query
17+
from django.db.models.query import QuerySet
1518
from django.core import validators
1619

1720
if VERSION >= (3, 1):
@@ -129,7 +132,8 @@ def split_parameter_list_as_sql(self, compiler, connection):
129132
with connection.cursor() as cursor:
130133
cursor.execute("IF OBJECT_ID('tempdb.dbo.#Temp_params', 'U') IS NOT NULL DROP TABLE #Temp_params; ")
131134
parameter_data_type = self.lhs.field.db_type(connection)
132-
cursor.execute(f"CREATE TABLE #Temp_params (params {parameter_data_type})")
135+
Temp_table_collation = 'COLLATE DATABASE_DEFAULT' if 'char' in parameter_data_type else ''
136+
cursor.execute(f"CREATE TABLE #Temp_params (params {parameter_data_type} {Temp_table_collation})")
133137
for offset in range(0, len(rhs_params), 1000):
134138
sqls_params = rhs_params[offset: offset + 1000]
135139
sqls_params = ", ".join("('{}')".format(item) for item in sqls_params)
@@ -198,6 +202,74 @@ def BinaryField_init(self, *args, **kwargs):
198202
else:
199203
self.max_length = 'max'
200204

205+
def _get_check_sql(self, model, schema_editor):
206+
if VERSION >= (3, 1):
207+
query = Query(model=model, alias_cols=False)
208+
else:
209+
query = Query(model=model)
210+
where = query.build_where(self.check)
211+
compiler = query.get_compiler(connection=schema_editor.connection)
212+
sql, params = where.as_sql(compiler, schema_editor.connection)
213+
try:
214+
for p in params: str(p).encode('ascii')
215+
except UnicodeEncodeError:
216+
sql = sql.replace('%s', 'N%s')
217+
218+
return sql % tuple(schema_editor.quote_value(p) for p in params)
219+
220+
def bulk_update_with_default(self, objs, fields, batch_size=None, default=0):
221+
"""
222+
Update the given fields in each of the given objects in the database.
223+
224+
When bulk_update all fields to null,
225+
SQL Server require that at least one of the result expressions in a CASE specification must be an expression other than the NULL constant.
226+
Patched with a default value 0. The user can also pass a custom default value for CASE statement.
227+
"""
228+
if batch_size is not None and batch_size < 0:
229+
raise ValueError('Batch size must be a positive integer.')
230+
if not fields:
231+
raise ValueError('Field names must be given to bulk_update().')
232+
objs = tuple(objs)
233+
if any(obj.pk is None for obj in objs):
234+
raise ValueError('All bulk_update() objects must have a primary key set.')
235+
fields = [self.model._meta.get_field(name) for name in fields]
236+
if any(not f.concrete or f.many_to_many for f in fields):
237+
raise ValueError('bulk_update() can only be used with concrete fields.')
238+
if any(f.primary_key for f in fields):
239+
raise ValueError('bulk_update() cannot be used with primary key fields.')
240+
if not objs:
241+
return
242+
# PK is used twice in the resulting update query, once in the filter
243+
# and once in the WHEN. Each field will also have one CAST.
244+
max_batch_size = connections[self.db].ops.bulk_batch_size(['pk', 'pk'] + fields, objs)
245+
batch_size = min(batch_size, max_batch_size) if batch_size else max_batch_size
246+
requires_casting = connections[self.db].features.requires_casted_case_in_updates
247+
batches = (objs[i:i + batch_size] for i in range(0, len(objs), batch_size))
248+
updates = []
249+
for batch_objs in batches:
250+
update_kwargs = {}
251+
for field in fields:
252+
value_none_counter = 0
253+
when_statements = []
254+
for obj in batch_objs:
255+
attr = getattr(obj, field.attname)
256+
if not isinstance(attr, Expression):
257+
if attr is None:
258+
value_none_counter+=1
259+
attr = Value(attr, output_field=field)
260+
when_statements.append(When(pk=obj.pk, then=attr))
261+
if(value_none_counter == len(when_statements)):
262+
case_statement = Case(*when_statements, output_field=field, default=Value(default))
263+
else:
264+
case_statement = Case(*when_statements, output_field=field)
265+
if requires_casting:
266+
case_statement = Cast(case_statement, output_field=field)
267+
update_kwargs[field.attname] = case_statement
268+
updates.append(([obj.pk for obj in batch_objs], update_kwargs))
269+
with transaction.atomic(using=self.db, savepoint=False):
270+
for pks, update_kwargs in updates:
271+
self.filter(pk__in=pks).update(**update_kwargs)
272+
201273
ATan2.as_microsoft = sqlserver_atan2
202274
In.split_parameter_list_as_sql = split_parameter_list_as_sql
203275
if VERSION >= (3, 1):
@@ -211,6 +283,7 @@ def BinaryField_init(self, *args, **kwargs):
211283
Round.as_microsoft = sqlserver_round
212284
Window.as_microsoft = sqlserver_window
213285
BinaryField.__init__ = BinaryField_init
286+
CheckConstraint._get_check_sql = _get_check_sql
214287

215288
if VERSION >= (3, 2):
216289
Random.as_microsoft = sqlserver_random
@@ -221,4 +294,4 @@ def BinaryField_init(self, *args, **kwargs):
221294
Exists.as_microsoft = sqlserver_exists
222295

223296
OrderBy.as_microsoft = sqlserver_orderby
224-
297+
QuerySet.bulk_update = bulk_update_with_default

0 commit comments

Comments
 (0)