Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions django/db/backends/base/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,8 @@ class BaseDatabaseFeatures:
supports_stored_generated_columns = False
# Does the backend support virtual generated columns?
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?
supports_logical_xor = False
Expand Down
15 changes: 14 additions & 1 deletion django/db/backends/base/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,10 +452,14 @@ def db_default_sql(self, field):
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):
"""Return the SQL to use in a GENERATED ALWAYS clause."""
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:
expression_sql = expression_sql % tuple(self.quote_value(p) for p in params)
params = ()
Expand Down Expand Up @@ -906,6 +910,15 @@ def alter_field(self, model, old_field, new_field, strict=False):
else:
new_field_sql = new_field.generated_sql(self.connection)
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:
raise ValueError(
f"Modifying GeneratedFields is not supported - the field {new_field} "
Expand Down
11 changes: 10 additions & 1 deletion django/db/backends/oracle/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_ignore_conflicts = False
max_query_params = 2**16 - 1
supports_partial_indexes = False
supports_stored_generated_columns = False
supports_virtual_generated_columns = True
supports_alter_generated_column_data_type = False
can_rename_index = True
supports_slicing_ordering_in_compound = True
requires_compound_order_by_subquery = True
Expand Down Expand Up @@ -131,6 +131,11 @@ def django_test_skips(self):
"Oracle doesn't support casting filters to NUMBER.": {
"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,):
skips.update(
Expand Down Expand Up @@ -228,3 +233,7 @@ def supports_tuple_lookups(self):
@cached_property
def supports_uuid4_function(self):
return self.connection.oracle_version >= (23, 9)

@cached_property
def supports_stored_generated_columns(self):
return self.connection.oracle_version >= (23, 7)
3 changes: 3 additions & 0 deletions django/db/backends/oracle/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,6 @@ def _collate_sql(self, collation, old_collation=None, table_name=None):
if collation is None and old_collation is not None:
collation = self._get_default_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"
9 changes: 7 additions & 2 deletions docs/ref/models/fields.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1346,8 +1346,13 @@ materialized view.
real column. If ``False``, the column acts as a virtual column and does
not occupy database storage space.

PostgreSQL < 18 only supports persisted columns. Oracle only supports
virtual columns.
PostgreSQL < 18 only supports persisted columns. Oracle < 23ai/26ai (23.7)
only supports virtual columns.

.. versionchanged:: 6.1

Support for stored ``GeneratedField``\s was added on Oracle 23ai/26ai
(23.7+).

.. admonition:: Database limitations

Expand Down
4 changes: 4 additions & 0 deletions docs/releases/6.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,10 @@ Models
* The new :class:`~django.db.models.functions.UUID4` and
: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
~~~~~~~~~~

Expand Down
19 changes: 15 additions & 4 deletions tests/migrations/test_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -1491,7 +1491,7 @@ def test_add_generated_field(self):
"name_and_id",
models.GeneratedField(
expression=Concat(("name"), ("rider_id")),
output_field=models.TextField(),
output_field=models.CharField(max_length=60),
db_persist=True,
),
),
Expand Down Expand Up @@ -6363,6 +6363,15 @@ def _test_invalid_generated_field_changes(self, db_persist):
("test_igfc_2", generated_1, regular),
("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:
project_state = self.set_up_test_model(app_label)
operations = [
Expand Down Expand Up @@ -6441,7 +6450,7 @@ def test_invalid_generated_field_persistency_change(self):
"Pony",
"modified_pink",
models.GeneratedField(
expression=F("pink"),
expression=F("pink") + 2,
output_field=models.IntegerField(),
db_persist=True,
),
Expand All @@ -6450,7 +6459,7 @@ def test_invalid_generated_field_persistency_change(self):
"Pony",
"modified_pink",
models.GeneratedField(
expression=F("pink"),
expression=F("pink") + 2,
output_field=models.IntegerField(),
db_persist=False,
),
Expand Down Expand Up @@ -6489,7 +6498,9 @@ def _test_add_generated_field(self, db_persist):
operation.database_backwards(app_label, editor, new_state, project_state)
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):
app_label = "test_gfcof"
operation = migrations.AddField(
Expand Down
6 changes: 3 additions & 3 deletions tests/model_fields/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from django.db import connection, models
from django.db.models import F, Value
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.translation import gettext_lazy as _

Expand Down Expand Up @@ -534,7 +534,7 @@ class UUIDGrandchild(UUIDChild):
class GeneratedModelFieldWithConverters(models.Model):
field = models.UUIDField()
field_copy = models.GeneratedField(
expression=F("field"),
expression=Cast("field", models.UUIDField()),
output_field=models.UUIDField(),
db_persist=True,
)
Expand All @@ -561,7 +561,7 @@ class GeneratedModelNonAutoPk(models.Model):
id = models.IntegerField(primary_key=True)
a = models.IntegerField()
b = models.GeneratedField(
expression=F("a"),
expression=F("a") + 1,
output_field=models.IntegerField(),
db_persist=True,
)
Expand Down
2 changes: 1 addition & 1 deletion tests/model_fields/test_generatedfield.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ def test_create_with_non_auto_pk(self):
obj = GeneratedModelNonAutoPk.objects.create(id=1, a=2)
self.assertEqual(obj.id, 1)
self.assertEqual(obj.a, 2)
self.assertEqual(obj.b, 2)
self.assertEqual(obj.b, 3)


@skipUnlessDBFeature("supports_virtual_generated_columns")
Expand Down
4 changes: 2 additions & 2 deletions tests/schema/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1029,7 +1029,7 @@ def test_alter_generated_field(self):
class GeneratedFieldIndexedModel(Model):
number = IntegerField(default=1)
generated = GeneratedField(
expression=F("number"),
expression=F("number") + 1,
db_persist=True,
output_field=IntegerField(),
)
Expand All @@ -1042,7 +1042,7 @@ class Meta:

old_field = GeneratedFieldIndexedModel._meta.get_field("generated")
new_field = GeneratedField(
expression=F("number"),
expression=F("number") + 1,
db_persist=True,
db_index=True,
output_field=IntegerField(),
Expand Down