Skip to content

Commit c0476cf

Browse files
abscisparrowtjmah8jean-frenette-opteltonybaloney
authored
Prepare for 1.1.3 release (#124)
* 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 <[email protected]> * 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 <[email protected]> * Simplification & refactoring Co-authored-by: marcperrinoptel <[email protected]> Co-authored-by: marcperrinoptel <[email protected]> * 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 <[email protected]> Co-authored-by: jmah8 <[email protected]> Co-authored-by: jean-frenette-optel <[email protected]> Co-authored-by: Anthony Shaw <[email protected]> Co-authored-by: Petter Moe Kvalvaag <[email protected]> Co-authored-by: Ruben De Visscher <[email protected]> Co-authored-by: Ruben De Visscher <[email protected]> Co-authored-by: marcperrinoptel <[email protected]> Co-authored-by: marcperrinoptel <[email protected]>
1 parent 39ee995 commit c0476cf

27 files changed

+904
-124
lines changed

.github/workflows/devskim.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# This workflow uses actions that are not certified by GitHub.
2+
# They are provided by a third-party and are governed by
3+
# separate terms of service, privacy policy, and support
4+
# documentation.
5+
6+
name: DevSkim
7+
8+
on:
9+
push:
10+
branches: [ dev, master ]
11+
pull_request:
12+
branches: [ dev ]
13+
schedule:
14+
- cron: '29 14 * * 3'
15+
16+
jobs:
17+
lint:
18+
name: DevSkim
19+
runs-on: ubuntu-20.04
20+
permissions:
21+
actions: read
22+
contents: read
23+
security-events: write
24+
steps:
25+
- name: Checkout code
26+
uses: actions/checkout@v3
27+
28+
- name: Run DevSkim scanner
29+
uses: microsoft/DevSkim-Action@v1
30+
31+
- name: Upload DevSkim scan results to GitHub Security tab
32+
uses: github/codeql-action/upload-sarif@v2
33+
with:
34+
sarif_file: devskim-results.sarif

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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, 3.2 and 4.0
13+
- Supports Django 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
@@ -67,6 +67,13 @@ in DATABASES control the behavior of the backend:
6767

6868
String. Database user password.
6969

70+
- TOKEN
71+
72+
String. Access token fetched as a user or service principal which
73+
has access to the database. E.g. when using `azure.identity`, the
74+
result of `DefaultAzureCredential().get_token('https://database.windows.net/.default')`
75+
can be passed.
76+
7077
- AUTOCOMMIT
7178

7279
Boolean. Set this to `False` if you want to disable

SUPPORT.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ This project uses GitHub Issues to track bugs and feature requests. Please searc
66
issues before filing new issues to avoid duplicates. For new issues, file your bug or
77
feature request as a new Issue.
88

9-
For help and questions about using this project, please utilize the Django Developers form at https://groups.google.com/g/django-developers. Please search for an existing discussion on your topic before adding a new conversation. For new conversations, include "MSSQL" in a descriptive subject.
10-
119
## Microsoft Support Policy
1210

1311
Support for this project is limited to the resources listed above.

mssql/base.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import re
99
import time
10+
import struct
1011

1112
from django.core.exceptions import ImproperlyConfigured
1213

@@ -53,7 +54,22 @@ def encode_connection_string(fields):
5354
'%s=%s' % (k, encode_value(v))
5455
for k, v in fields.items()
5556
)
57+
def prepare_token_for_odbc(token):
58+
"""
59+
Will prepare token for passing it to the odbc driver, as it expects
60+
bytes and not a string
61+
:param token:
62+
:return: packed binary byte representation of token string
63+
"""
64+
if not isinstance(token, str):
65+
raise TypeError("Invalid token format provided.")
5666

67+
tokenstr = token.encode()
68+
exptoken = b""
69+
for i in tokenstr:
70+
exptoken += bytes({i})
71+
exptoken += bytes(1)
72+
return struct.pack("=i", len(exptoken)) + exptoken
5773

5874
def encode_value(v):
5975
"""If the value contains a semicolon, or starts with a left curly brace,
@@ -294,7 +310,7 @@ def get_new_connection(self, conn_params):
294310
cstr_parts['UID'] = user
295311
if 'Authentication=ActiveDirectoryInteractive' not in options_extra_params:
296312
cstr_parts['PWD'] = password
297-
else:
313+
elif 'TOKEN' not in conn_params:
298314
if ms_drivers.match(driver) and 'Authentication=ActiveDirectoryMsi' not in options_extra_params:
299315
cstr_parts['Trusted_Connection'] = trusted_connection
300316
else:
@@ -324,11 +340,17 @@ def get_new_connection(self, conn_params):
324340
conn = None
325341
retry_count = 0
326342
need_to_retry = False
343+
args = {
344+
'unicode_results': unicode_results,
345+
'timeout': timeout,
346+
}
347+
if 'TOKEN' in conn_params:
348+
args['attrs_before'] = {
349+
1256: prepare_token_for_odbc(conn_params['TOKEN'])
350+
}
327351
while conn is None:
328352
try:
329-
conn = Database.connect(connstr,
330-
unicode_results=unicode_results,
331-
timeout=timeout)
353+
conn = Database.connect(connstr, **args)
332354
except Exception as e:
333355
for error_number in self._transient_error_numbers:
334356
if error_number in e.args[1]:

mssql/compiler.py

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,9 @@ def as_sql(self, with_limits=True, with_col_aliases=False):
319319
# For subqueres with an ORDER BY clause, SQL Server also
320320
# requires a TOP or OFFSET clause which is not generated for
321321
# Django 2.x. See https://github.com/microsoft/mssql-django/issues/12
322-
if django.VERSION < (3, 0, 0) and not (do_offset or do_limit):
322+
# Add OFFSET for all Django versions.
323+
# https://github.com/microsoft/mssql-django/issues/109
324+
if not (do_offset or do_limit):
323325
result.append("OFFSET 0 ROWS")
324326

325327
# SQL Server requires the backend-specific emulation (2008 or earlier)
@@ -426,6 +428,16 @@ def get_returned_fields(self):
426428
return self.returning_fields
427429
return self.return_id
428430

431+
def can_return_columns_from_insert(self):
432+
if django.VERSION >= (3, 0, 0):
433+
return self.connection.features.can_return_columns_from_insert
434+
return self.connection.features.can_return_id_from_insert
435+
436+
def can_return_rows_from_bulk_insert(self):
437+
if django.VERSION >= (3, 0, 0):
438+
return self.connection.features.can_return_rows_from_bulk_insert
439+
return self.connection.features.can_return_ids_from_bulk_insert
440+
429441
def fix_auto(self, sql, opts, fields, qn):
430442
if opts.auto_field is not None:
431443
# db_column is None if not explicitly specified by model field
@@ -441,15 +453,39 @@ def fix_auto(self, sql, opts, fields, qn):
441453

442454
return sql
443455

456+
def bulk_insert_default_values_sql(self, table):
457+
seed_rows_number = 8
458+
cross_join_power = 4 # 8^4 = 4096 > maximum allowed batch size for the backend = 1000
459+
460+
def generate_seed_rows(n):
461+
return " UNION ALL ".join("SELECT 1 AS x" for _ in range(n))
462+
463+
def cross_join(p):
464+
return ", ".join("SEED_ROWS AS _%s" % i for i in range(p))
465+
466+
return """
467+
WITH SEED_ROWS AS (%s)
468+
MERGE INTO %s
469+
USING (
470+
SELECT TOP %s * FROM (SELECT 1 as x FROM %s) FAKE_ROWS
471+
) FAKE_DATA
472+
ON 1 = 0
473+
WHEN NOT MATCHED THEN
474+
INSERT DEFAULT VALUES
475+
""" % (generate_seed_rows(seed_rows_number),
476+
table,
477+
len(self.query.objs),
478+
cross_join(cross_join_power))
479+
444480
def as_sql(self):
445481
# We don't need quote_name_unless_alias() here, since these are all
446482
# going to be column names (so we can avoid the extra overhead).
447483
qn = self.connection.ops.quote_name
448484
opts = self.query.get_meta()
449485
result = ['INSERT INTO %s' % qn(opts.db_table)]
450-
fields = self.query.fields or [opts.pk]
451486

452487
if self.query.fields:
488+
fields = self.query.fields
453489
result.append('(%s)' % ', '.join(qn(f.column) for f in fields))
454490
values_format = 'VALUES (%s)'
455491
value_rows = [
@@ -470,11 +506,31 @@ def as_sql(self):
470506

471507
placeholder_rows, param_rows = self.assemble_as_sql(fields, value_rows)
472508

473-
if self.get_returned_fields() and self.connection.features.can_return_id_from_insert:
474-
result.insert(0, 'SET NOCOUNT ON')
475-
result.append((values_format + ';') % ', '.join(placeholder_rows[0]))
476-
params = [param_rows[0]]
477-
result.append('SELECT CAST(SCOPE_IDENTITY() AS bigint)')
509+
if self.get_returned_fields() and self.can_return_columns_from_insert():
510+
if self.can_return_rows_from_bulk_insert():
511+
if not(self.query.fields):
512+
# There isn't really a single statement to bulk multiple DEFAULT VALUES insertions,
513+
# so we have to use a workaround:
514+
# https://dba.stackexchange.com/questions/254771/insert-multiple-rows-into-a-table-with-only-an-identity-column
515+
result = [self.bulk_insert_default_values_sql(qn(opts.db_table))]
516+
r_sql, self.returning_params = self.connection.ops.return_insert_columns(self.get_returned_fields())
517+
if r_sql:
518+
result.append(r_sql)
519+
sql = " ".join(result) + ";"
520+
return [(sql, None)]
521+
# Regular bulk insert
522+
params = []
523+
r_sql, self.returning_params = self.connection.ops.return_insert_columns(self.get_returned_fields())
524+
if r_sql:
525+
result.append(r_sql)
526+
params += [self.returning_params]
527+
params += param_rows
528+
result.append(self.connection.ops.bulk_insert_sql(fields, placeholder_rows))
529+
else:
530+
result.insert(0, 'SET NOCOUNT ON')
531+
result.append((values_format + ';') % ', '.join(placeholder_rows[0]))
532+
params = [param_rows[0]]
533+
result.append('SELECT CAST(SCOPE_IDENTITY() AS bigint)')
478534
sql = [(" ".join(result), tuple(chain.from_iterable(params)))]
479535
else:
480536
if can_bulk:

mssql/features.py

Lines changed: 1 addition & 0 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_return_rows_from_bulk_insert = True
1516
can_rollback_ddl = True
1617
can_use_chunked_reads = False
1718
for_update_after_from = True

mssql/functions.py

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,16 @@
44
import json
55

66
from django import VERSION
7-
7+
from django.core import validators
88
from django.db import NotSupportedError, connections, transaction
9-
from django.db.models import BooleanField, Value
10-
from django.db.models.functions import Cast, NthValue
11-
from django.db.models.functions.math import ATan2, Log, Ln, Mod, Round
12-
from django.db.models.expressions import Case, Exists, OrderBy, When, Window, Expression
13-
from django.db.models.lookups import Lookup, In
14-
from django.db.models import lookups, CheckConstraint
9+
from django.db.models import BooleanField, CheckConstraint, Value
10+
from django.db.models.expressions import Case, Exists, Expression, OrderBy, When, Window
1511
from django.db.models.fields import BinaryField, Field
16-
from django.db.models.sql.query import Query
12+
from django.db.models.functions import Cast, NthValue
13+
from django.db.models.functions.math import ATan2, Ln, Log, Mod, Round
14+
from django.db.models.lookups import In, Lookup
1715
from django.db.models.query import QuerySet
18-
from django.core import validators
16+
from django.db.models.sql.query import Query
1917

2018
if VERSION >= (3, 1):
2119
from django.db.models.fields.json import (
@@ -67,9 +65,11 @@ def sqlserver_nth_value(self, compiler, connection, **extra_content):
6765
def sqlserver_round(self, compiler, connection, **extra_context):
6866
return self.as_sql(compiler, connection, template='%(function)s(%(expressions)s, 0)', **extra_context)
6967

68+
7069
def sqlserver_random(self, compiler, connection, **extra_context):
7170
return self.as_sql(compiler, connection, function='RAND', **extra_context)
7271

72+
7373
def sqlserver_window(self, compiler, connection, template=None):
7474
# MSSQL window functions require an OVER clause with ORDER BY
7575
if self.order_by is None:
@@ -125,6 +125,13 @@ def sqlserver_orderby(self, compiler, connection):
125125

126126

127127
def split_parameter_list_as_sql(self, compiler, connection):
128+
if connection.vendor == 'microsoft':
129+
return mssql_split_parameter_list_as_sql(self, compiler, connection)
130+
else:
131+
return in_split_parameter_list_as_sql(self, compiler, connection)
132+
133+
134+
def mssql_split_parameter_list_as_sql(self, compiler, connection):
128135
# Insert In clause parameters 1000 at a time into a temp table.
129136
lhs, _ = self.process_lhs(compiler, connection)
130137
_, rhs_params = self.batch_process_rhs(compiler, connection)
@@ -143,26 +150,29 @@ def split_parameter_list_as_sql(self, compiler, connection):
143150

144151
return in_clause, ()
145152

153+
146154
def unquote_json_rhs(rhs_params):
147155
for value in rhs_params:
148156
value = json.loads(value)
149157
if not isinstance(value, (list, dict)):
150158
rhs_params = [param.replace('"', '') for param in rhs_params]
151159
return rhs_params
152160

161+
153162
def json_KeyTransformExact_process_rhs(self, compiler, connection):
154-
if isinstance(self.rhs, KeyTransform):
155-
return super(lookups.Exact, self).process_rhs(compiler, connection)
156-
rhs, rhs_params = super(KeyTransformExact, self).process_rhs(compiler, connection)
163+
rhs, rhs_params = key_transform_exact_process_rhs(self, compiler, connection)
164+
if connection.vendor == 'microsoft':
165+
rhs_params = unquote_json_rhs(rhs_params)
166+
return rhs, rhs_params
157167

158-
return rhs, unquote_json_rhs(rhs_params)
159168

160169
def json_KeyTransformIn(self, compiler, connection):
161170
lhs, _ = super(KeyTransformIn, self).process_lhs(compiler, connection)
162171
rhs, rhs_params = super(KeyTransformIn, self).process_rhs(compiler, connection)
163172

164173
return (lhs + ' IN ' + rhs, unquote_json_rhs(rhs_params))
165174

175+
166176
def json_HasKeyLookup(self, compiler, connection):
167177
# Process JSON path from the left-hand side.
168178
if isinstance(self.lhs, KeyTransform):
@@ -193,6 +203,7 @@ def json_HasKeyLookup(self, compiler, connection):
193203

194204
return sql % tuple(rhs_params), []
195205

206+
196207
def BinaryField_init(self, *args, **kwargs):
197208
# Add max_length option for BinaryField, default to max
198209
kwargs.setdefault('editable', False)
@@ -202,6 +213,7 @@ def BinaryField_init(self, *args, **kwargs):
202213
else:
203214
self.max_length = 'max'
204215

216+
205217
def _get_check_sql(self, model, schema_editor):
206218
if VERSION >= (3, 1):
207219
query = Query(model=model, alias_cols=False)
@@ -210,13 +222,16 @@ def _get_check_sql(self, model, schema_editor):
210222
where = query.build_where(self.check)
211223
compiler = query.get_compiler(connection=schema_editor.connection)
212224
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')
225+
if schema_editor.connection.vendor == 'microsoft':
226+
try:
227+
for p in params:
228+
str(p).encode('ascii')
229+
except UnicodeEncodeError:
230+
sql = sql.replace('%s', 'N%s')
217231

218232
return sql % tuple(schema_editor.quote_value(p) for p in params)
219233

234+
220235
def bulk_update_with_default(self, objs, fields, batch_size=None, default=0):
221236
"""
222237
Update the given fields in each of the given objects in the database.
@@ -255,10 +270,10 @@ def bulk_update_with_default(self, objs, fields, batch_size=None, default=0):
255270
attr = getattr(obj, field.attname)
256271
if not isinstance(attr, Expression):
257272
if attr is None:
258-
value_none_counter+=1
273+
value_none_counter += 1
259274
attr = Value(attr, output_field=field)
260275
when_statements.append(When(pk=obj.pk, then=attr))
261-
if(value_none_counter == len(when_statements)):
276+
if connections[self.db].vendor == 'microsoft' and value_none_counter == len(when_statements):
262277
case_statement = Case(*when_statements, output_field=field, default=Value(default))
263278
else:
264279
case_statement = Case(*when_statements, output_field=field)
@@ -272,10 +287,15 @@ def bulk_update_with_default(self, objs, fields, batch_size=None, default=0):
272287
rows_updated += self.filter(pk__in=pks).update(**update_kwargs)
273288
return rows_updated
274289

290+
275291
ATan2.as_microsoft = sqlserver_atan2
292+
# Need copy of old In.split_parameter_list_as_sql for other backends to call
293+
in_split_parameter_list_as_sql = In.split_parameter_list_as_sql
276294
In.split_parameter_list_as_sql = split_parameter_list_as_sql
277295
if VERSION >= (3, 1):
278296
KeyTransformIn.as_microsoft = json_KeyTransformIn
297+
# Need copy of old KeyTransformExact.process_rhs to call later
298+
key_transform_exact_process_rhs = KeyTransformExact.process_rhs
279299
KeyTransformExact.process_rhs = json_KeyTransformExact_process_rhs
280300
HasKeyLookup.as_microsoft = json_HasKeyLookup
281301
Ln.as_microsoft = sqlserver_ln

0 commit comments

Comments
 (0)