From 601914722956cc41f1f2c53972d669ddee6ffc04 Mon Sep 17 00:00:00 2001 From: Patrick Rauscher Date: Thu, 30 Oct 2025 10:13:14 +0100 Subject: [PATCH 1/2] Fixed #36696 -- Fixed NameError when inspecting functions with deferred annotations. In Python 3.14, annotations are deferred by default, so we should not assume that the names in them have been imported unconditionally. --- django/utils/inspect.py | 16 +++++++++++++++- tests/utils_tests/test_inspect.py | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/django/utils/inspect.py b/django/utils/inspect.py index 31f3cf994b95..ac5e82ca1414 100644 --- a/django/utils/inspect.py +++ b/django/utils/inspect.py @@ -1,10 +1,24 @@ import functools import inspect +from django.utils.version import PY314 + +if PY314: + import annotationlib + @functools.lru_cache(maxsize=512) def _get_func_parameters(func, remove_first): - parameters = tuple(inspect.signature(func).parameters.values()) + # As the annotations are not used in any case, inspect the signature with + # FORWARDREF to leave any deferred annotations unevaluated. + if PY314: + signature = inspect.signature( + func, annotation_format=annotationlib.Format.FORWARDREF + ) + else: + signature = inspect.signature(func) + + parameters = tuple(signature.parameters.values()) if remove_first: parameters = parameters[1:] return parameters diff --git a/tests/utils_tests/test_inspect.py b/tests/utils_tests/test_inspect.py index 38ea35ecfbb1..f6e82e58083d 100644 --- a/tests/utils_tests/test_inspect.py +++ b/tests/utils_tests/test_inspect.py @@ -1,8 +1,13 @@ import subprocess import unittest +from typing import TYPE_CHECKING from django.shortcuts import aget_object_or_404 from django.utils import inspect +from django.utils.version import PY314 + +if TYPE_CHECKING: + from django.utils.safestring import SafeString class Person: @@ -103,6 +108,16 @@ def test_func_accepts_kwargs(self): self.assertIs(inspect.func_accepts_kwargs(Person.all_kinds), True) self.assertIs(inspect.func_accepts_kwargs(Person().just_args), False) + @unittest.skipUnless(PY314, "Deferred annotations are Python 3.14+ only") + def test_func_accepts_kwargs_deferred_annotations(self): + + def func_with_annotations(self, name: str, complex: SafeString) -> None: + pass + + # Inspection fails with deferred annotations with python 3.14+. Earlier + # Python versions trigger the NameError on module initialization. + self.assertIs(inspect.func_accepts_kwargs(func_with_annotations), False) + class IsModuleLevelFunctionTestCase(unittest.TestCase): @classmethod From 05ba1a9228128614fb3c475f1c4bdf0160f44dba Mon Sep 17 00:00:00 2001 From: Mariusz Felisiak Date: Fri, 31 Oct 2025 14:33:27 +0100 Subject: [PATCH 2/2] Fixed #36661 -- Added introspection of database-level delete options. --- django/core/management/commands/inspectdb.py | 13 +++++- django/db/backends/base/introspection.py | 16 ++++++- django/db/backends/mysql/base.py | 1 + django/db/backends/mysql/introspection.py | 34 +++++++++----- django/db/backends/oracle/introspection.py | 17 +++++-- .../db/backends/postgresql/introspection.py | 24 ++++++++-- django/db/backends/sqlite3/introspection.py | 16 +++++-- docs/releases/6.1.txt | 5 ++ tests/inspectdb/models.py | 8 ++++ tests/inspectdb/tests.py | 21 +++++++++ tests/introspection/models.py | 15 ++++++ tests/introspection/tests.py | 46 +++++++++++++++++-- 12 files changed, 185 insertions(+), 31 deletions(-) diff --git a/django/core/management/commands/inspectdb.py b/django/core/management/commands/inspectdb.py index 8c271498c60b..e44edcfe9168 100644 --- a/django/core/management/commands/inspectdb.py +++ b/django/core/management/commands/inspectdb.py @@ -4,6 +4,7 @@ from django.core.management.base import BaseCommand, CommandError from django.db import DEFAULT_DB_ALIAS, connections from django.db.models.constants import LOOKUP_SEP +from django.db.models.deletion import DatabaseOnDelete class Command(BaseCommand): @@ -163,7 +164,9 @@ def handle_inspection(self, options): extra_params["unique"] = True if is_relation: - ref_db_column, ref_db_table = relations[column_name] + ref_db_column, ref_db_table, db_on_delete = relations[ + column_name + ] if extra_params.pop("unique", False) or extra_params.get( "primary_key" ): @@ -191,6 +194,8 @@ def handle_inspection(self, options): model_name.lower(), att_name, ) + if db_on_delete and isinstance(db_on_delete, DatabaseOnDelete): + extra_params["on_delete"] = f"models.{db_on_delete}" used_relations.add(rel_to) else: # Calling `get_field_type` to get the field type string @@ -227,8 +232,12 @@ def handle_inspection(self, options): "" if "." in field_type else "models.", field_type, ) + on_delete_qualname = extra_params.pop("on_delete", None) if field_type.startswith(("ForeignKey(", "OneToOneField(")): - field_desc += ", models.DO_NOTHING" + if on_delete_qualname: + field_desc += f", {on_delete_qualname}" + else: + field_desc += ", models.DO_NOTHING" # Add comment. if connection.features.supports_comments and row.comment: diff --git a/django/db/backends/base/introspection.py b/django/db/backends/base/introspection.py index 3a62ab632714..b86f5b6124f6 100644 --- a/django/db/backends/base/introspection.py +++ b/django/db/backends/base/introspection.py @@ -1,5 +1,7 @@ from collections import namedtuple +from django.db.models import DB_CASCADE, DB_SET_DEFAULT, DB_SET_NULL, DO_NOTHING + # Structure returned by DatabaseIntrospection.get_table_list() TableInfo = namedtuple("TableInfo", ["name", "type"]) @@ -15,6 +17,13 @@ class BaseDatabaseIntrospection: """Encapsulate backend-specific introspection utilities.""" data_types_reverse = {} + on_delete_types = { + "CASCADE": DB_CASCADE, + "NO ACTION": DO_NOTHING, + "SET DEFAULT": DB_SET_DEFAULT, + "SET NULL": DB_SET_NULL, + # DB_RESTRICT - "RESTRICT" is not supported. + } def __init__(self, connection): self.connection = connection @@ -169,8 +178,11 @@ def get_sequences(self, cursor, table_name, table_fields=()): def get_relations(self, cursor, table_name): """ - Return a dictionary of {field_name: (field_name_other_table, - other_table)} representing all foreign keys in the given table. + Return a dictionary of + { + field_name: (field_name_other_table, other_table, db_on_delete) + } + representing all foreign keys in the given table. """ raise NotImplementedError( "subclasses of BaseDatabaseIntrospection may require a " diff --git a/django/db/backends/mysql/base.py b/django/db/backends/mysql/base.py index d4b98971fa46..514db70d235f 100644 --- a/django/db/backends/mysql/base.py +++ b/django/db/backends/mysql/base.py @@ -334,6 +334,7 @@ def check_constraints(self, table_names=None): for column_name, ( referenced_column_name, referenced_table_name, + _, ) in relations.items(): cursor.execute( """ diff --git a/django/db/backends/mysql/introspection.py b/django/db/backends/mysql/introspection.py index 24f773f0091a..59105b4e7632 100644 --- a/django/db/backends/mysql/introspection.py +++ b/django/db/backends/mysql/introspection.py @@ -196,24 +196,36 @@ def get_sequences(self, cursor, table_name, table_fields=()): def get_relations(self, cursor, table_name): """ - Return a dictionary of {field_name: (field_name_other_table, - other_table)} representing all foreign keys in the given table. + Return a dictionary of + { + field_name: (field_name_other_table, other_table, db_on_delete) + } + representing all foreign keys in the given table. """ cursor.execute( """ - SELECT column_name, referenced_column_name, referenced_table_name - FROM information_schema.key_column_usage - WHERE table_name = %s - AND table_schema = DATABASE() - AND referenced_table_schema = DATABASE() - AND referenced_table_name IS NOT NULL - AND referenced_column_name IS NOT NULL + SELECT + kcu.column_name, + kcu.referenced_column_name, + kcu.referenced_table_name, + rc.delete_rule + FROM + information_schema.key_column_usage kcu + JOIN + information_schema.referential_constraints rc + ON rc.constraint_name = kcu.constraint_name + AND rc.constraint_schema = kcu.constraint_schema + WHERE kcu.table_name = %s + AND kcu.table_schema = DATABASE() + AND kcu.referenced_table_schema = DATABASE() + AND kcu.referenced_table_name IS NOT NULL + AND kcu.referenced_column_name IS NOT NULL """, [table_name], ) return { - field_name: (other_field, other_table) - for field_name, other_field, other_table in cursor.fetchall() + field_name: (other_field, other_table, self.on_delete_types.get(on_delete)) + for field_name, other_field, other_table, on_delete in cursor.fetchall() } def get_storage_engine(self, cursor, table_name): diff --git a/django/db/backends/oracle/introspection.py b/django/db/backends/oracle/introspection.py index 12b9b9a09700..6a0947f8abe1 100644 --- a/django/db/backends/oracle/introspection.py +++ b/django/db/backends/oracle/introspection.py @@ -254,13 +254,16 @@ def get_sequences(self, cursor, table_name, table_fields=()): def get_relations(self, cursor, table_name): """ - Return a dictionary of {field_name: (field_name_other_table, - other_table)} representing all foreign keys in the given table. + Return a dictionary of + { + field_name: (field_name_other_table, other_table, db_on_delete) + } + representing all foreign keys in the given table. """ table_name = table_name.upper() cursor.execute( """ - SELECT ca.column_name, cb.table_name, cb.column_name + SELECT ca.column_name, cb.table_name, cb.column_name, user_constraints.delete_rule FROM user_constraints, USER_CONS_COLUMNS ca, USER_CONS_COLUMNS cb WHERE user_constraints.table_name = %s AND user_constraints.constraint_name = ca.constraint_name AND @@ -273,8 +276,14 @@ def get_relations(self, cursor, table_name): self.identifier_converter(field_name): ( self.identifier_converter(rel_field_name), self.identifier_converter(rel_table_name), + self.on_delete_types.get(on_delete), ) - for field_name, rel_table_name, rel_field_name in cursor.fetchall() + for ( + field_name, + rel_table_name, + rel_field_name, + on_delete, + ) in cursor.fetchall() } def get_primary_key_columns(self, cursor, table_name): diff --git a/django/db/backends/postgresql/introspection.py b/django/db/backends/postgresql/introspection.py index fc69e0a381e1..30edaf10dafd 100644 --- a/django/db/backends/postgresql/introspection.py +++ b/django/db/backends/postgresql/introspection.py @@ -3,7 +3,7 @@ from django.db.backends.base.introspection import BaseDatabaseIntrospection from django.db.backends.base.introspection import FieldInfo as BaseFieldInfo from django.db.backends.base.introspection import TableInfo as BaseTableInfo -from django.db.models import Index +from django.db.models import DB_CASCADE, DB_SET_DEFAULT, DB_SET_NULL, DO_NOTHING, Index FieldInfo = namedtuple("FieldInfo", [*BaseFieldInfo._fields, "is_autofield", "comment"]) TableInfo = namedtuple("TableInfo", [*BaseTableInfo._fields, "comment"]) @@ -38,6 +38,14 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): ignored_tables = [] + on_delete_types = { + "a": DO_NOTHING, + "c": DB_CASCADE, + "d": DB_SET_DEFAULT, + "n": DB_SET_NULL, + # DB_RESTRICT - "r" is not supported. + } + def get_field_type(self, data_type, description): field_type = super().get_field_type(data_type, description) if description.is_autofield or ( @@ -154,12 +162,15 @@ def get_sequences(self, cursor, table_name, table_fields=()): def get_relations(self, cursor, table_name): """ - Return a dictionary of {field_name: (field_name_other_table, - other_table)} representing all foreign keys in the given table. + Return a dictionary of + { + field_name: (field_name_other_table, other_table, db_on_delete) + } + representing all foreign keys in the given table. """ cursor.execute( """ - SELECT a1.attname, c2.relname, a2.attname + SELECT a1.attname, c2.relname, a2.attname, con.confdeltype FROM pg_constraint con LEFT JOIN pg_class c1 ON con.conrelid = c1.oid LEFT JOIN pg_class c2 ON con.confrelid = c2.oid @@ -175,7 +186,10 @@ def get_relations(self, cursor, table_name): """, [table_name], ) - return {row[0]: (row[2], row[1]) for row in cursor.fetchall()} + return { + row[0]: (row[2], row[1], self.on_delete_types.get(row[3])) + for row in cursor.fetchall() + } def get_constraints(self, cursor, table_name): """ diff --git a/django/db/backends/sqlite3/introspection.py b/django/db/backends/sqlite3/introspection.py index 1404c71e1ed8..b90f1265055f 100644 --- a/django/db/backends/sqlite3/introspection.py +++ b/django/db/backends/sqlite3/introspection.py @@ -153,20 +153,27 @@ def get_sequences(self, cursor, table_name, table_fields=()): def get_relations(self, cursor, table_name): """ - Return a dictionary of {column_name: (ref_column_name, ref_table_name)} + Return a dictionary of + {column_name: (ref_column_name, ref_table_name, db_on_delete)} representing all foreign keys in the given table. """ cursor.execute( "PRAGMA foreign_key_list(%s)" % self.connection.ops.quote_name(table_name) ) return { - column_name: (ref_column_name, ref_table_name) + column_name: ( + ref_column_name, + ref_table_name, + self.on_delete_types.get(on_delete), + ) for ( _, _, ref_table_name, column_name, ref_column_name, + _, + on_delete, *_, ) in cursor.fetchall() } @@ -407,7 +414,10 @@ def get_constraints(self, cursor, table_name): "check": False, "index": False, } - for index, (column_name, (ref_column_name, ref_table_name)) in relations + for index, ( + column_name, + (ref_column_name, ref_table_name, _), + ) in relations } ) return constraints diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt index dba26cca05d4..036fee53cfc2 100644 --- a/docs/releases/6.1.txt +++ b/docs/releases/6.1.txt @@ -314,6 +314,11 @@ backends. database has native support for ``DurationField``, override this method to simply return the value. +* The ``DatabaseIntrospection.get_relations()`` should now return a dictionary + with 3-tuples containing (``field_name_other_table``, ``other_table``, + ``db_on_delete``) as values. ``db_on_delete`` is one of the database-level + delete options e.g. :attr:`~django.db.models.DB_CASCADE`. + :mod:`django.contrib.gis` ------------------------- diff --git a/tests/inspectdb/models.py b/tests/inspectdb/models.py index 515a6cd207ae..3d6388a7be37 100644 --- a/tests/inspectdb/models.py +++ b/tests/inspectdb/models.py @@ -161,3 +161,11 @@ class CompositePKModel(models.Model): pk = models.CompositePrimaryKey("column_1", "column_2") column_1 = models.IntegerField() column_2 = models.IntegerField() + + +class DbOnDeleteModel(models.Model): + fk_do_nothing = models.ForeignKey(UniqueTogether, on_delete=models.DO_NOTHING) + fk_db_cascade = models.ForeignKey(ColumnTypes, on_delete=models.DB_CASCADE) + fk_set_null = models.ForeignKey( + DigitsInColumnName, on_delete=models.DB_SET_NULL, null=True + ) diff --git a/tests/inspectdb/tests.py b/tests/inspectdb/tests.py index c554488c10e2..9104671b4fef 100644 --- a/tests/inspectdb/tests.py +++ b/tests/inspectdb/tests.py @@ -299,6 +299,27 @@ def test_foreign_key_to_field(self): out.getvalue(), ) + @skipUnlessDBFeature("can_introspect_foreign_keys") + def test_foreign_key_db_on_delete(self): + out = StringIO() + call_command("inspectdb", "inspectdb_dbondeletemodel", stdout=out) + output = out.getvalue() + self.assertIn( + "fk_do_nothing = models.ForeignKey('InspectdbUniquetogether', " + "models.DO_NOTHING)", + output, + ) + self.assertIn( + "fk_db_cascade = models.ForeignKey('InspectdbColumntypes', " + "models.DB_CASCADE)", + output, + ) + self.assertIn( + "fk_set_null = models.ForeignKey('InspectdbDigitsincolumnname', " + "models.DB_SET_NULL, blank=True, null=True)", + output, + ) + def test_digits_column_name_introspection(self): """ Introspection of column names consist/start with digits (#16536/#17676) diff --git a/tests/introspection/models.py b/tests/introspection/models.py index c4a60ab18272..6c94f5212c9d 100644 --- a/tests/introspection/models.py +++ b/tests/introspection/models.py @@ -110,3 +110,18 @@ class DbCommentModel(models.Model): class Meta: db_table_comment = "Custom table comment" required_db_features = {"supports_comments"} + + +class DbOnDeleteModel(models.Model): + fk_do_nothing = models.ForeignKey(Country, on_delete=models.DO_NOTHING) + fk_db_cascade = models.ForeignKey(City, on_delete=models.DB_CASCADE) + fk_set_null = models.ForeignKey(Reporter, on_delete=models.DB_SET_NULL, null=True) + + +class DbOnDeleteSetDefaultModel(models.Model): + fk_db_set_default = models.ForeignKey( + Country, on_delete=models.DB_SET_DEFAULT, db_default=models.Value(1) + ) + + class Meta: + required_db_features = {"supports_on_delete_db_default"} diff --git a/tests/introspection/tests.py b/tests/introspection/tests.py index 327e5cc8c60f..1f7f22b2dc16 100644 --- a/tests/introspection/tests.py +++ b/tests/introspection/tests.py @@ -1,5 +1,5 @@ from django.db import DatabaseError, connection -from django.db.models import Index +from django.db.models import DB_CASCADE, DB_SET_DEFAULT, DB_SET_NULL, DO_NOTHING, Index from django.test import TransactionTestCase, skipUnlessDBFeature from .models import ( @@ -10,6 +10,8 @@ Comment, Country, DbCommentModel, + DbOnDeleteModel, + DbOnDeleteSetDefaultModel, District, Reporter, UniqueConstraintConditionModel, @@ -219,10 +221,14 @@ def test_get_relations(self): cursor, Article._meta.db_table ) - # That's {field_name: (field_name_other_table, other_table)} + if connection.vendor == "mysql" and connection.mysql_is_mariadb: + no_db_on_delete = None + else: + no_db_on_delete = DO_NOTHING + # {field_name: (field_name_other_table, other_table, db_on_delete)} expected_relations = { - "reporter_id": ("id", Reporter._meta.db_table), - "response_to_id": ("id", Article._meta.db_table), + "reporter_id": ("id", Reporter._meta.db_table, no_db_on_delete), + "response_to_id": ("id", Article._meta.db_table, no_db_on_delete), } self.assertEqual(relations, expected_relations) @@ -238,6 +244,38 @@ def test_get_relations(self): editor.add_field(Article, body) self.assertEqual(relations, expected_relations) + @skipUnlessDBFeature("can_introspect_foreign_keys") + def test_get_relations_db_on_delete(self): + with connection.cursor() as cursor: + relations = connection.introspection.get_relations( + cursor, DbOnDeleteModel._meta.db_table + ) + + if connection.vendor == "mysql" and connection.mysql_is_mariadb: + no_db_on_delete = None + else: + no_db_on_delete = DO_NOTHING + # {field_name: (field_name_other_table, other_table, db_on_delete)} + expected_relations = { + "fk_db_cascade_id": ("id", City._meta.db_table, DB_CASCADE), + "fk_do_nothing_id": ("id", Country._meta.db_table, no_db_on_delete), + "fk_set_null_id": ("id", Reporter._meta.db_table, DB_SET_NULL), + } + self.assertEqual(relations, expected_relations) + + @skipUnlessDBFeature("can_introspect_foreign_keys", "supports_on_delete_db_default") + def test_get_relations_db_on_delete_default(self): + with connection.cursor() as cursor: + relations = connection.introspection.get_relations( + cursor, DbOnDeleteSetDefaultModel._meta.db_table + ) + + # {field_name: (field_name_other_table, other_table, db_on_delete)} + expected_relations = { + "fk_db_set_default_id": ("id", Country._meta.db_table, DB_SET_DEFAULT), + } + self.assertEqual(relations, expected_relations) + def test_get_primary_key_column(self): with connection.cursor() as cursor: primary_key_column = connection.introspection.get_primary_key_column(