Fixed #36765 -- Added support for stored GeneratedFields on Oracle 23ai/26ai (23.7+).
Some checks failed
Docs / spelling (push) Has been cancelled
Docs / blacken-docs (push) Has been cancelled
Docs / lint-docs (push) Has been cancelled
Linters / flake8 (push) Has been cancelled
Linters / isort (push) Has been cancelled
Linters / black (push) Has been cancelled
Linters / zizmor (push) Has been cancelled
Tests / Windows, SQLite, Python 3.14 (push) Has been cancelled
Tests / JavaScript tests (push) Has been cancelled

Thanks Jacob Walls for the review.
This commit is contained in:
Mariusz Felisiak 2025-12-13 16:38:04 +01:00 committed by GitHub
parent e95468ed97
commit 0174a85770
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 61 additions and 14 deletions

View file

@ -373,6 +373,8 @@ class BaseDatabaseFeatures:
supports_stored_generated_columns = False supports_stored_generated_columns = False
# Does the backend support virtual generated columns? # Does the backend support virtual generated columns?
supports_virtual_generated_columns = False supports_virtual_generated_columns = False
# Does the backend support altering data types of generated columns?
supports_alter_generated_column_data_type = True
# Does the backend support the logical XOR operator? # Does the backend support the logical XOR operator?
supports_logical_xor = False supports_logical_xor = False

View file

@ -452,10 +452,14 @@ class BaseDatabaseSchemaEditor:
params = [] params = []
return sql % default_sql, params return sql % default_sql, params
def _column_generated_persistency_sql(self, field):
"""Return the SQL to define the persistency of generated fields."""
return "STORED" if field.db_persist else "VIRTUAL"
def _column_generated_sql(self, field): def _column_generated_sql(self, field):
"""Return the SQL to use in a GENERATED ALWAYS clause.""" """Return the SQL to use in a GENERATED ALWAYS clause."""
expression_sql, params = field.generated_sql(self.connection) expression_sql, params = field.generated_sql(self.connection)
persistency_sql = "STORED" if field.db_persist else "VIRTUAL" persistency_sql = self._column_generated_persistency_sql(field)
if self.connection.features.requires_literal_defaults: if self.connection.features.requires_literal_defaults:
expression_sql = expression_sql % tuple(self.quote_value(p) for p in params) expression_sql = expression_sql % tuple(self.quote_value(p) for p in params)
params = () params = ()
@ -906,6 +910,15 @@ class BaseDatabaseSchemaEditor:
else: else:
new_field_sql = new_field.generated_sql(self.connection) new_field_sql = new_field.generated_sql(self.connection)
modifying_generated_field = old_field_sql != new_field_sql modifying_generated_field = old_field_sql != new_field_sql
db_features = self.connection.features
# Some databases (e.g. Oracle) don't allow altering a data type
# for generated columns.
if (
not modifying_generated_field
and old_type != new_type
and not db_features.supports_alter_generated_column_data_type
):
modifying_generated_field = True
if modifying_generated_field: if modifying_generated_field:
raise ValueError( raise ValueError(
f"Modifying GeneratedFields is not supported - the field {new_field} " f"Modifying GeneratedFields is not supported - the field {new_field} "

View file

@ -69,8 +69,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_ignore_conflicts = False supports_ignore_conflicts = False
max_query_params = 2**16 - 1 max_query_params = 2**16 - 1
supports_partial_indexes = False supports_partial_indexes = False
supports_stored_generated_columns = False
supports_virtual_generated_columns = True supports_virtual_generated_columns = True
supports_alter_generated_column_data_type = False
can_rename_index = True can_rename_index = True
supports_slicing_ordering_in_compound = True supports_slicing_ordering_in_compound = True
requires_compound_order_by_subquery = True requires_compound_order_by_subquery = True
@ -131,6 +131,11 @@ class DatabaseFeatures(BaseDatabaseFeatures):
"Oracle doesn't support casting filters to NUMBER.": { "Oracle doesn't support casting filters to NUMBER.": {
"lookup.tests.LookupQueryingTests.test_aggregate_combined_lookup", "lookup.tests.LookupQueryingTests.test_aggregate_combined_lookup",
}, },
"Oracle doesn't support some data types (e.g. BOOLEAN, BLOB) in "
"GeneratedField expressions (ORA-54003).": {
"schema.tests.SchemaTests.test_add_generated_field_contains",
"schema.tests.SchemaTests.test_add_generated_field_with_kt_model",
},
} }
if self.connection.oracle_version < (23,): if self.connection.oracle_version < (23,):
skips.update( skips.update(
@ -228,3 +233,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
@cached_property @cached_property
def supports_uuid4_function(self): def supports_uuid4_function(self):
return self.connection.oracle_version >= (23, 9) return self.connection.oracle_version >= (23, 9)
@cached_property
def supports_stored_generated_columns(self):
return self.connection.oracle_version >= (23, 7)

View file

@ -251,3 +251,6 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
if collation is None and old_collation is not None: if collation is None and old_collation is not None:
collation = self._get_default_collation(table_name) collation = self._get_default_collation(table_name)
return super()._collate_sql(collation, old_collation, table_name) return super()._collate_sql(collation, old_collation, table_name)
def _column_generated_persistency_sql(self, field):
return "MATERIALIZED" if field.db_persist else "VIRTUAL"

View file

@ -1346,8 +1346,13 @@ materialized view.
real column. If ``False``, the column acts as a virtual column and does real column. If ``False``, the column acts as a virtual column and does
not occupy database storage space. not occupy database storage space.
PostgreSQL < 18 only supports persisted columns. Oracle only supports PostgreSQL < 18 only supports persisted columns. Oracle < 23ai/26ai (23.7)
virtual columns. only supports virtual columns.
.. versionchanged:: 6.1
Support for stored ``GeneratedField``\s was added on Oracle 23ai/26ai
(23.7+).
.. admonition:: Database limitations .. admonition:: Database limitations

View file

@ -266,6 +266,10 @@ Models
* The new :class:`~django.db.models.functions.UUID4` and * The new :class:`~django.db.models.functions.UUID4` and
:class:`~django.db.models.functions.UUID7` database functions were added. :class:`~django.db.models.functions.UUID7` database functions were added.
* :class:`~django.db.models.GeneratedField` now supports stored columns
(:attr:`~django.db.models.GeneratedField.db_persist` set to ``True``) on
Oracle 23ai/26ai (23.7+).
Pagination Pagination
~~~~~~~~~~ ~~~~~~~~~~

View file

@ -1491,7 +1491,7 @@ class OperationTests(OperationTestBase):
"name_and_id", "name_and_id",
models.GeneratedField( models.GeneratedField(
expression=Concat(("name"), ("rider_id")), expression=Concat(("name"), ("rider_id")),
output_field=models.TextField(), output_field=models.CharField(max_length=60),
db_persist=True, db_persist=True,
), ),
), ),
@ -6363,6 +6363,15 @@ class OperationTests(OperationTestBase):
("test_igfc_2", generated_1, regular), ("test_igfc_2", generated_1, regular),
("test_igfc_3", generated_1, generated_2), ("test_igfc_3", generated_1, generated_2),
] ]
if not connection.features.supports_alter_generated_column_data_type:
generated_3 = models.GeneratedField(
expression=F("pink") + F("pink"),
output_field=models.DecimalField(decimal_places=2, max_digits=16),
db_persist=db_persist,
)
tests.append(
("test_igfc_4", generated_1, generated_3),
)
for app_label, add_field, alter_field in tests: for app_label, add_field, alter_field in tests:
project_state = self.set_up_test_model(app_label) project_state = self.set_up_test_model(app_label)
operations = [ operations = [
@ -6441,7 +6450,7 @@ class OperationTests(OperationTestBase):
"Pony", "Pony",
"modified_pink", "modified_pink",
models.GeneratedField( models.GeneratedField(
expression=F("pink"), expression=F("pink") + 2,
output_field=models.IntegerField(), output_field=models.IntegerField(),
db_persist=True, db_persist=True,
), ),
@ -6450,7 +6459,7 @@ class OperationTests(OperationTestBase):
"Pony", "Pony",
"modified_pink", "modified_pink",
models.GeneratedField( models.GeneratedField(
expression=F("pink"), expression=F("pink") + 2,
output_field=models.IntegerField(), output_field=models.IntegerField(),
db_persist=False, db_persist=False,
), ),
@ -6489,7 +6498,9 @@ class OperationTests(OperationTestBase):
operation.database_backwards(app_label, editor, new_state, project_state) operation.database_backwards(app_label, editor, new_state, project_state)
self.assertColumnNotExists(f"{app_label}_pony", "modified_pink") self.assertColumnNotExists(f"{app_label}_pony", "modified_pink")
@skipUnlessDBFeature("supports_stored_generated_columns") @skipUnlessDBFeature(
"supports_stored_generated_columns", "supports_alter_generated_column_data_type"
)
def test_generated_field_changes_output_field(self): def test_generated_field_changes_output_field(self):
app_label = "test_gfcof" app_label = "test_gfcof"
operation = migrations.AddField( operation = migrations.AddField(

View file

@ -9,7 +9,7 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.db import connection, models from django.db import connection, models
from django.db.models import F, Value from django.db.models import F, Value
from django.db.models.fields.files import ImageFieldFile from django.db.models.fields.files import ImageFieldFile
from django.db.models.functions import Lower from django.db.models.functions import Cast, Lower
from django.utils.functional import SimpleLazyObject from django.utils.functional import SimpleLazyObject
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -534,7 +534,7 @@ class UUIDGrandchild(UUIDChild):
class GeneratedModelFieldWithConverters(models.Model): class GeneratedModelFieldWithConverters(models.Model):
field = models.UUIDField() field = models.UUIDField()
field_copy = models.GeneratedField( field_copy = models.GeneratedField(
expression=F("field"), expression=Cast("field", models.UUIDField()),
output_field=models.UUIDField(), output_field=models.UUIDField(),
db_persist=True, db_persist=True,
) )
@ -561,7 +561,7 @@ class GeneratedModelNonAutoPk(models.Model):
id = models.IntegerField(primary_key=True) id = models.IntegerField(primary_key=True)
a = models.IntegerField() a = models.IntegerField()
b = models.GeneratedField( b = models.GeneratedField(
expression=F("a"), expression=F("a") + 1,
output_field=models.IntegerField(), output_field=models.IntegerField(),
db_persist=True, db_persist=True,
) )

View file

@ -413,7 +413,7 @@ class StoredGeneratedFieldTests(GeneratedFieldTestMixin, TestCase):
obj = GeneratedModelNonAutoPk.objects.create(id=1, a=2) obj = GeneratedModelNonAutoPk.objects.create(id=1, a=2)
self.assertEqual(obj.id, 1) self.assertEqual(obj.id, 1)
self.assertEqual(obj.a, 2) self.assertEqual(obj.a, 2)
self.assertEqual(obj.b, 2) self.assertEqual(obj.b, 3)
@skipUnlessDBFeature("supports_virtual_generated_columns") @skipUnlessDBFeature("supports_virtual_generated_columns")

View file

@ -1029,7 +1029,7 @@ class SchemaTests(TransactionTestCase):
class GeneratedFieldIndexedModel(Model): class GeneratedFieldIndexedModel(Model):
number = IntegerField(default=1) number = IntegerField(default=1)
generated = GeneratedField( generated = GeneratedField(
expression=F("number"), expression=F("number") + 1,
db_persist=True, db_persist=True,
output_field=IntegerField(), output_field=IntegerField(),
) )
@ -1042,7 +1042,7 @@ class SchemaTests(TransactionTestCase):
old_field = GeneratedFieldIndexedModel._meta.get_field("generated") old_field = GeneratedFieldIndexedModel._meta.get_field("generated")
new_field = GeneratedField( new_field = GeneratedField(
expression=F("number"), expression=F("number") + 1,
db_persist=True, db_persist=True,
db_index=True, db_index=True,
output_field=IntegerField(), output_field=IntegerField(),