From 4456be46a0d155bc540c7bb8780469e7d83b1569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 27 Mar 2026 17:58:31 -0300 Subject: [PATCH 01/11] feat: add @generated attribute for stored generated columns (PostgreSQL) Add support for PostgreSQL stored generated (computed) columns via a new @generated("sql_expr") schema attribute. Generated columns are excluded from CreateInput/UpdateInput, readable in queries, and usable in orderBy. - PSL: parse @generated("expr"), validate against @default/@id/@updatedAt/lists - Query compiler: exclude generated fields from create/update input types - DMMF: set isGenerated=true, isReadOnly=true - DDL: render GENERATED ALWAYS AS (expr) STORED - Diffing: GenerationExpression change triggers DropAndRecreateColumn - Introspection: read attgenerated from pg_attribute, render @generated on db pull Closes prisma/prisma#6336 Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/sql-ddl/src/postgres.rs | 52 ++++++++- psl/parser-database/src/attributes.rs | 51 +++++++++ psl/parser-database/src/types.rs | 5 + .../src/walkers/scalar_field.rs | 10 ++ .../tests/attributes/generated_negative.rs | 102 ++++++++++++++++++ .../tests/attributes/generated_positive.rs | 73 +++++++++++++ psl/psl/tests/attributes/mod.rs | 2 + psl/psl/tests/common/asserts.rs | 7 ++ .../src/ast_builders/datamodel_ast_builder.rs | 4 +- .../postgres_generated_column.prisma | 6 ++ query-compiler/dmmf/src/tests/tests.rs | 40 +++++++ .../query-structure/src/field/scalar.rs | 8 ++ .../input_types/objects/update_one_objects.rs | 2 +- .../schema/src/build/mutations/create_one.rs | 5 +- .../src/flavour/postgres/renderer.rs | 15 +++ .../introspection_pair/scalar_field.rs | 5 + .../introspection/rendering/scalar_field.rs | 4 + .../sql-schema-connector/src/sql_migration.rs | 5 + .../src/sql_schema_calculator.rs | 6 ++ .../src/sql_schema_differ.rs | 7 ++ .../src/sql_schema_differ/column.rs | 15 +++ .../datamodel-renderer/src/datamodel/field.rs | 20 ++++ .../postgres/generated_column.prisma | 25 +++++ schema-engine/sql-schema-describer/src/lib.rs | 4 + .../sql-schema-describer/src/mssql.rs | 1 + .../sql-schema-describer/src/mysql.rs | 1 + .../sql-schema-describer/src/postgres.rs | 32 ++++-- .../sql-schema-describer/src/sqlite.rs | 1 + .../src/walkers/column.rs | 5 + .../src/walkers/column/table_column.rs | 5 + 30 files changed, 504 insertions(+), 14 deletions(-) create mode 100644 psl/psl/tests/attributes/generated_negative.rs create mode 100644 psl/psl/tests/attributes/generated_positive.rs create mode 100644 query-compiler/dmmf/src/tests/test-schemas/postgres_generated_column.prisma create mode 100644 schema-engine/sql-migration-tests/tests/single_migration_tests/postgres/generated_column.prisma diff --git a/libs/sql-ddl/src/postgres.rs b/libs/sql-ddl/src/postgres.rs index b7b1240b4773..ec1c386639a3 100644 --- a/libs/sql-ddl/src/postgres.rs +++ b/libs/sql-ddl/src/postgres.rs @@ -72,6 +72,9 @@ pub struct Column<'a> { pub name: Cow<'a, str>, pub r#type: Cow<'a, str>, pub default: Option>, + /// SQL expression for a generated (computed) column. + /// When set, renders as `GENERATED ALWAYS AS (expr) STORED` instead of a DEFAULT clause. + pub generation_expression: Option>, } impl Display for Column<'_> { @@ -80,7 +83,11 @@ impl Display for Column<'_> { f.write_str(" ")?; f.write_str(self.r#type.as_ref())?; - if let Some(default) = &self.default { + if let Some(expr) = &self.generation_expression { + f.write_str(" GENERATED ALWAYS AS (")?; + f.write_str(expr)?; + f.write_str(") STORED")?; + } else if let Some(default) = &self.default { f.write_str(" DEFAULT ")?; f.write_str(default)?; } @@ -545,4 +552,47 @@ mod tests { assert_eq!(alter_table.to_string(), expected); } + + #[test] + fn column_with_generation_expression() { + let col = Column { + name: "status_priority".into(), + r#type: "INT".into(), + default: None, + generation_expression: Some("CASE status WHEN 'A' THEN 1 WHEN 'B' THEN 2 END".into()), + }; + + assert_eq!( + col.to_string(), + r#""status_priority" INT GENERATED ALWAYS AS (CASE status WHEN 'A' THEN 1 WHEN 'B' THEN 2 END) STORED"# + ); + } + + #[test] + fn column_with_generation_expression_takes_precedence_over_default() { + let col = Column { + name: "computed".into(), + r#type: "TEXT".into(), + default: Some("'fallback'".into()), + generation_expression: Some("first || ' ' || last".into()), + }; + + // generation_expression should win over default + assert_eq!( + col.to_string(), + r#""computed" TEXT GENERATED ALWAYS AS (first || ' ' || last) STORED"# + ); + } + + #[test] + fn column_without_generation_expression_renders_default() { + let col = Column { + name: "name".into(), + r#type: "TEXT".into(), + default: Some("'unknown'".into()), + generation_expression: None, + }; + + assert_eq!(col.to_string(), r#""name" TEXT DEFAULT 'unknown'"#); + } } diff --git a/psl/parser-database/src/attributes.rs b/psl/parser-database/src/attributes.rs index 271f27d54576..a1e96e301479 100644 --- a/psl/parser-database/src/attributes.rs +++ b/psl/parser-database/src/attributes.rs @@ -249,6 +249,26 @@ fn visit_scalar_field_attributes( ctx.validate_visited_arguments(); } + // @generated("sql expression") + if ctx.visit_optional_single_attr("generated") { + if ast_field.arity.is_list() { + ctx.push_attribute_validation_error("Fields that are marked with @generated cannot be lists."); + } + + match ctx.visit_default_arg("expression") { + Ok(expr) => { + if let Some((expr_str, _span)) = expr.as_string_value() { + ctx.types[scalar_field_id].is_generated_column = true; + ctx.types[scalar_field_id].generation_expression = Some(expr_str.to_owned()); + } else { + ctx.push_attribute_validation_error("The @generated attribute requires a string argument with the SQL expression."); + } + } + Err(err) => ctx.push_error(err), + } + ctx.validate_visited_arguments(); + } + // @default if ctx.visit_optional_single_attr("default") { default::visit_model_field_default(scalar_field_id, model_id, field_id, r#type, ctx); @@ -281,6 +301,37 @@ fn visit_scalar_field_attributes( ctx.validate_visited_arguments(); } + // Cross-attribute validations for @generated + if ctx.types[scalar_field_id].is_generated_column { + if ctx.types[scalar_field_id].default.is_some() { + ctx.push_error(DatamodelError::new_attribute_validation_error( + "Fields that are marked with @generated cannot have a @default value. The column value is always computed from the generation expression.", + "generated", + ast_field.span(), + )); + } + if ctx.types[scalar_field_id].is_updated_at { + ctx.push_error(DatamodelError::new_attribute_validation_error( + "Fields that are marked with @generated cannot also be marked with @updatedAt.", + "generated", + ast_field.span(), + )); + } + let is_id_field = model_data + .primary_key + .as_ref() + .and_then(|pk| pk.source_field) + .map(|pk_field_id| pk_field_id == field_id) + .unwrap_or(false); + if is_id_field { + ctx.push_error(DatamodelError::new_attribute_validation_error( + "Fields that are marked with @generated cannot be the primary key (@id).", + "generated", + ast_field.span(), + )); + } + } + ctx.validate_visited_attributes(); } diff --git a/psl/parser-database/src/types.rs b/psl/parser-database/src/types.rs index 55b6665a2c50..608d993667bd 100644 --- a/psl/parser-database/src/types.rs +++ b/psl/parser-database/src/types.rs @@ -328,6 +328,9 @@ pub(crate) struct ScalarField { pub(crate) r#type: ScalarFieldType, pub(crate) is_ignored: bool, pub(crate) is_updated_at: bool, + pub(crate) is_generated_column: bool, + /// The SQL expression for a generated (computed) column, e.g. "CASE status WHEN 'A' THEN 1 END" + pub(crate) generation_expression: Option, pub(crate) default: Option, /// @map pub(crate) mapped_name: Option, @@ -759,6 +762,8 @@ fn visit_model<'db>(model_id: crate::ModelId, ast_model: &'db ast::Model, ctx: & r#type: scalar_field_type, is_ignored: false, is_updated_at: false, + is_generated_column: false, + generation_expression: None, default: None, mapped_name: None, native_type: None, diff --git a/psl/parser-database/src/walkers/scalar_field.rs b/psl/parser-database/src/walkers/scalar_field.rs index e1b78aee0f1d..fd23bd7580b0 100644 --- a/psl/parser-database/src/walkers/scalar_field.rs +++ b/psl/parser-database/src/walkers/scalar_field.rs @@ -115,6 +115,16 @@ impl<'db> ScalarFieldWalker<'db> { self.attributes().is_updated_at } + /// Is this a generated (computed) column? + pub fn is_generated_column(self) -> bool { + self.attributes().is_generated_column + } + + /// The SQL expression for a generated column, if any. + pub fn generation_expression(self) -> Option<&'db str> { + self.attributes().generation_expression.as_deref() + } + fn attributes(self) -> &'db ScalarField { &self.db.types[self.id] } diff --git a/psl/psl/tests/attributes/generated_negative.rs b/psl/psl/tests/attributes/generated_negative.rs new file mode 100644 index 000000000000..cf4f2d5ccbb6 --- /dev/null +++ b/psl/psl/tests/attributes/generated_negative.rs @@ -0,0 +1,102 @@ +use crate::common::*; + +#[test] +fn should_fail_without_expression_argument() { + let dml = indoc! {r#" + datasource db { + provider = "postgres" + } + + model User { + id Int @id + name String @generated + } + "#}; + + let error = parse_unwrap_err(dml); + + // @generated requires a string argument + assert!(error.contains("@generated")); +} + +#[test] +fn should_fail_with_non_string_argument() { + let dml = indoc! {r#" + datasource db { + provider = "postgres" + } + + model User { + id Int @id + calc Int @generated(42) + } + "#}; + + let error = parse_unwrap_err(dml); + + assert!(error.contains("@generated")); +} + +#[test] +fn should_fail_when_combined_with_default() { + let dml = indoc! {r#" + model User { + id Int @id + calc Int? @generated("id * 2") @default(0) + } + "#}; + + let error = parse_unwrap_err(dml); + + assert!(error.contains("@generated")); + assert!(error.contains("@default")); +} + +#[test] +fn should_fail_when_combined_with_updated_at() { + let dml = indoc! {r#" + model User { + id Int @id + ts DateTime @generated("now()") @updatedAt + } + "#}; + + let error = parse_unwrap_err(dml); + + assert!(error.contains("@generated")); + assert!(error.contains("@updatedAt")); +} + +#[test] +fn should_fail_when_combined_with_id() { + let dml = indoc! {r#" + model User { + id Int @id @generated("1") + name String + } + "#}; + + let error = parse_unwrap_err(dml); + + assert!(error.contains("@generated")); + assert!(error.contains("@id")); +} + +#[test] +fn should_fail_on_list_field() { + let dml = indoc! {r#" + datasource db { + provider = "postgres" + } + + model User { + id Int @id + tags Int[] @generated("ARRAY[1,2,3]") + } + "#}; + + let error = parse_unwrap_err(dml); + + assert!(error.contains("@generated")); + assert!(error.contains("list")); +} diff --git a/psl/psl/tests/attributes/generated_positive.rs b/psl/psl/tests/attributes/generated_positive.rs new file mode 100644 index 000000000000..d5efecab48a5 --- /dev/null +++ b/psl/psl/tests/attributes/generated_positive.rs @@ -0,0 +1,73 @@ +use psl::parser_database::ScalarType; + +use crate::common::*; + +#[test] +fn should_accept_generated_attribute_on_int_field() { + let dml = indoc! {r#" + datasource db { + provider = "postgres" + } + + model Session { + id Int @id + statusPriority Int? @generated("CASE status WHEN 'A' THEN 1 WHEN 'B' THEN 2 END") + } + "#}; + + let schema = psl::parse_schema_without_extensions(dml).unwrap(); + let model = schema.assert_has_model("Session"); + + model + .assert_has_scalar_field("statusPriority") + .assert_scalar_type(ScalarType::Int) + .assert_is_generated_column(); +} + +#[test] +fn should_accept_generated_attribute_on_string_field() { + let dml = indoc! {r#" + datasource db { + provider = "postgres" + } + + model User { + id Int @id + first String + last String + fullName String? @generated("first || ' ' || last") + } + "#}; + + let schema = psl::parse_schema_without_extensions(dml).unwrap(); + let model = schema.assert_has_model("User"); + + model + .assert_has_scalar_field("fullName") + .assert_scalar_type(ScalarType::String) + .assert_is_generated_column(); +} + +#[test] +fn generated_field_should_be_readable() { + let dml = indoc! {r#" + datasource db { + provider = "postgres" + } + + model Item { + id Int @id + price Float + tax Float? @generated("price * 0.2") + } + "#}; + + let schema = psl::parse_schema_without_extensions(dml).unwrap(); + let model = schema.assert_has_model("Item"); + + // Generated fields should exist as scalar fields + model + .assert_has_scalar_field("tax") + .assert_is_generated_column() + .assert_optional(); +} diff --git a/psl/psl/tests/attributes/mod.rs b/psl/psl/tests/attributes/mod.rs index 8591bf7e54e6..8c9c4cabdf2f 100644 --- a/psl/psl/tests/attributes/mod.rs +++ b/psl/psl/tests/attributes/mod.rs @@ -24,3 +24,5 @@ mod unique_negative; mod unique_positive; mod updated_at_negative; mod updated_at_positive; +mod generated_positive; +mod generated_negative; diff --git a/psl/psl/tests/common/asserts.rs b/psl/psl/tests/common/asserts.rs index 523cb5cc5550..fd260535bd37 100644 --- a/psl/psl/tests/common/asserts.rs +++ b/psl/psl/tests/common/asserts.rs @@ -53,6 +53,7 @@ pub(crate) trait ScalarFieldAssert { fn assert_default_value(&self) -> walkers::DefaultValueWalker<'_>; fn assert_mapped_name(&self, name: &str) -> &Self; fn assert_is_updated_at(&self) -> &Self; + fn assert_is_generated_column(&self) -> &Self; fn assert_native_type(&self, connector: &dyn Connector, typ: &T) -> &Self where @@ -367,6 +368,12 @@ impl ScalarFieldAssert for walkers::ScalarFieldWalker<'_> { assert!(self.is_updated_at()); self } + + #[track_caller] + fn assert_is_generated_column(&self) -> &Self { + assert!(self.is_generated_column()); + self + } } impl DefaultValueAssert for walkers::DefaultValueWalker<'_> { diff --git a/query-compiler/dmmf/src/ast_builders/datamodel_ast_builder.rs b/query-compiler/dmmf/src/ast_builders/datamodel_ast_builder.rs index b8c7bcd0f432..c3b237825ddc 100644 --- a/query-compiler/dmmf/src/ast_builders/datamodel_ast_builder.rs +++ b/query-compiler/dmmf/src/ast_builders/datamodel_ast_builder.rs @@ -188,7 +188,7 @@ fn scalar_field_to_dmmf(field: walkers::ScalarFieldWalker<'_>) -> Field { is_required: matches!(ast_field.arity, FieldArity::Required | FieldArity::List), is_unique: !is_id && field.is_unique() && !field.is_partial_unique(), is_id, - is_read_only: field.model().relation_fields().any(|rf| { + is_read_only: field.is_generated_column() || field.model().relation_fields().any(|rf| { rf.referencing_fields() .into_iter() .flatten() @@ -212,7 +212,7 @@ fn scalar_field_to_dmmf(field: walkers::ScalarFieldWalker<'_>) -> Field { relation_to_fields: None, relation_on_delete: None, relation_on_update: None, - is_generated: Some(false), + is_generated: Some(field.is_generated_column()), is_updated_at: Some(field.is_updated_at()), documentation: ast_field.documentation().map(ToOwned::to_owned), } diff --git a/query-compiler/dmmf/src/tests/test-schemas/postgres_generated_column.prisma b/query-compiler/dmmf/src/tests/test-schemas/postgres_generated_column.prisma new file mode 100644 index 000000000000..ea889a960559 --- /dev/null +++ b/query-compiler/dmmf/src/tests/test-schemas/postgres_generated_column.prisma @@ -0,0 +1,6 @@ +model Session { + id Int @id @default(autoincrement()) + status String + statusPriority Int? @generated("CASE status WHEN 'WAITING' THEN 1 WHEN 'FAILED' THEN 2 END") + createdAt DateTime @default(now()) +} diff --git a/query-compiler/dmmf/src/tests/tests.rs b/query-compiler/dmmf/src/tests/tests.rs index 48d5b4fb567d..13860ca520e0 100644 --- a/query-compiler/dmmf/src/tests/tests.rs +++ b/query-compiler/dmmf/src/tests/tests.rs @@ -167,3 +167,43 @@ fn dmmf_rendering() { &serde_json::to_string_pretty(&new_dmmf).unwrap(), ); } + +#[test] +fn generated_column_is_read_only_and_excluded_from_inputs() { + let dmmf = dmmf_from_schema(include_str!("./test-schemas/postgres_generated_column.prisma")); + + // Find the Session model + let session = dmmf.data_model.models.iter().find(|m| m.name == "Session").unwrap(); + + // statusPriority should be present in the datamodel + let field = session.fields.iter().find(|f| f.name == "statusPriority").unwrap(); + assert!(field.is_read_only, "Generated column should be read-only"); + assert_eq!(field.is_generated, Some(true), "Generated column should have isGenerated=true"); + assert!(!field.is_required, "Generated column should be optional"); + + // statusPriority should NOT be in CreateInput + let create_input = dmmf.schema.input_object_types.get("prisma").unwrap() + .iter() + .find(|t| t.name == "SessionCreateInput") + .unwrap(); + assert!( + !create_input.fields.iter().any(|f| f.name == "statusPriority"), + "Generated column should not appear in SessionCreateInput" + ); + + // statusPriority should NOT be in UpdateInput + let update_input = dmmf.schema.input_object_types.get("prisma").unwrap() + .iter() + .find(|t| t.name == "SessionUpdateInput") + .unwrap(); + assert!( + !update_input.fields.iter().any(|f| f.name == "statusPriority"), + "Generated column should not appear in SessionUpdateInput" + ); + + // Non-generated fields should still be in CreateInput + assert!( + create_input.fields.iter().any(|f| f.name == "status"), + "Non-generated field 'status' should be in CreateInput" + ); +} diff --git a/query-compiler/query-structure/src/field/scalar.rs b/query-compiler/query-structure/src/field/scalar.rs index fd1e82073fce..270ad73f8e15 100644 --- a/query-compiler/query-structure/src/field/scalar.rs +++ b/query-compiler/query-structure/src/field/scalar.rs @@ -60,6 +60,14 @@ impl ScalarField { relation_fields.any(|rf| rf.fields().into_iter().flatten().any(|sf2| sf.id == sf2.id)) } + pub fn is_generated_column(&self) -> bool { + let sfid = match self.id { + ScalarFieldId::InModel(id) => id, + ScalarFieldId::InCompositeType(_) => return false, + }; + self.dm.walk(sfid).is_generated_column() + } + pub fn is_numeric(&self) -> bool { self.type_identifier().is_numeric() } diff --git a/query-compiler/schema/src/build/input_types/objects/update_one_objects.rs b/query-compiler/schema/src/build/input_types/objects/update_one_objects.rs index 585758dc53c7..21f56e9ce0e2 100644 --- a/query-compiler/schema/src/build/input_types/objects/update_one_objects.rs +++ b/query-compiler/schema/src/build/input_types/objects/update_one_objects.rs @@ -81,7 +81,7 @@ pub(super) fn filter_checked_update_fields<'a>( true }; - !sf.is_read_only() && is_not_autoinc && is_not_disallowed_id + !sf.is_read_only() && !sf.is_generated_column() && is_not_autoinc && is_not_disallowed_id } // If the relation field `rf` is the one that was traversed to by the parent relation field `parent_field`, diff --git a/query-compiler/schema/src/build/mutations/create_one.rs b/query-compiler/schema/src/build/mutations/create_one.rs index c1077705babe..522ce3d43f40 100644 --- a/query-compiler/schema/src/build/mutations/create_one.rs +++ b/query-compiler/schema/src/build/mutations/create_one.rs @@ -116,9 +116,8 @@ fn filter_checked_create_fields( ) -> impl Iterator + '_ { model.fields().filter_all(move |field| { match field { - // Scalars must be writable and not an autogenerated ID, which are disallowed for checked inputs - // regardless of whether or not the connector supports it. - ModelField::Scalar(sf) => !sf.is_auto_generated_int_id() && !sf.is_read_only(), + // Scalars must be writable, not an autogenerated ID, and not a generated (computed) column. + ModelField::Scalar(sf) => !sf.is_auto_generated_int_id() && !sf.is_read_only() && !sf.is_generated_column(), // If the relation field `rf` is the one that was traversed to by the parent relation field `parent_field`, // then exclude it for checked inputs - this prevents endless nested type circles that are useless to offer as API. diff --git a/schema-engine/connectors/sql-schema-connector/src/flavour/postgres/renderer.rs b/schema-engine/connectors/sql-schema-connector/src/flavour/postgres/renderer.rs index 3fc93e50078e..1a33bfa0e583 100644 --- a/schema-engine/connectors/sql-schema-connector/src/flavour/postgres/renderer.rs +++ b/schema-engine/connectors/sql-schema-connector/src/flavour/postgres/renderer.rs @@ -38,6 +38,14 @@ impl PostgresRenderer { let column_name = Quoted::postgres_ident(column.name()); let tpe_str = render_column_type(column, self); let nullability_str = render_nullability(column); + + // Generated (computed) columns use GENERATED ALWAYS AS instead of DEFAULT. + if let Some(expr) = column.generation_expression() { + return format!( + "{SQL_INDENTATION}{column_name} {tpe_str}{nullability_str} GENERATED ALWAYS AS ({expr}) STORED", + ); + } + let default_str = column .default() .map(|d| render_default(d.inner(), &render_column_type(column, self))) @@ -812,6 +820,13 @@ fn expand_alter_column( changes.push(PostgresAlterColumn::AddSequence) } } + ColumnChange::GenerationExpression => { + // Generation expression changes are handled via DropAndRecreateColumn + // (DROP COLUMN + ADD COLUMN) because ALTER COLUMN SET EXPRESSION is only + // available in PostgreSQL 17+. Prisma supports PG 12+, so we use the + // compatible path. This arm should not be reached in practice. + unreachable!("GenerationExpression changes are handled via DropAndRecreateColumn") + } } } diff --git a/schema-engine/connectors/sql-schema-connector/src/introspection/introspection_pair/scalar_field.rs b/schema-engine/connectors/sql-schema-connector/src/introspection/introspection_pair/scalar_field.rs index ca20d237b9db..47a5bd1d7efb 100644 --- a/schema-engine/connectors/sql-schema-connector/src/introspection/introspection_pair/scalar_field.rs +++ b/schema-engine/connectors/sql-schema-connector/src/introspection/introspection_pair/scalar_field.rs @@ -46,6 +46,11 @@ impl<'a> ScalarFieldPair<'a> { self.previous.map(|f| f.is_ignored()).unwrap_or(false) } + /// The generation expression for a computed column, if any. + pub fn generation_expression(self) -> Option<&'a str> { + self.next.generation_expression() + } + /// True if we took the name from the PSL. pub(crate) fn remapped_name_from_psl(&self) -> bool { self.previous.and_then(|p| p.mapped_name()).is_some() diff --git a/schema-engine/connectors/sql-schema-connector/src/introspection/rendering/scalar_field.rs b/schema-engine/connectors/sql-schema-connector/src/introspection/rendering/scalar_field.rs index 9cc368c5a3a0..e699ae120109 100644 --- a/schema-engine/connectors/sql-schema-connector/src/introspection/rendering/scalar_field.rs +++ b/schema-engine/connectors/sql-schema-connector/src/introspection/rendering/scalar_field.rs @@ -37,6 +37,10 @@ pub(crate) fn render(field: ScalarFieldPair<'_>) -> renderer::Field<'_> { rendered.default(default); } + if let Some(expr) = field.generation_expression() { + rendered.generated(expr); + } + if field.is_updated_at() { rendered.updated_at(); } diff --git a/schema-engine/connectors/sql-schema-connector/src/sql_migration.rs b/schema-engine/connectors/sql-schema-connector/src/sql_migration.rs index 2b7c4e00a2bc..931c4ea0c0d5 100644 --- a/schema-engine/connectors/sql-schema-connector/src/sql_migration.rs +++ b/schema-engine/connectors/sql-schema-connector/src/sql_migration.rs @@ -444,6 +444,11 @@ fn render_column_changes(columns: MigrationPair>, changes: "column became autoincrementing".to_owned() } } + ColumnChange::GenerationExpression => format!( + "generation expression changed from `{:?}` to `{:?}`", + columns.previous.generation_expression(), + columns.next.generation_expression() + ), }) .join(", "); diff --git a/schema-engine/connectors/sql-schema-connector/src/sql_schema_calculator.rs b/schema-engine/connectors/sql-schema-connector/src/sql_schema_calculator.rs index d560ec0a5c68..8c55fc6130b0 100644 --- a/schema-engine/connectors/sql-schema-connector/src/sql_schema_calculator.rs +++ b/schema-engine/connectors/sql-schema-connector/src/sql_schema_calculator.rs @@ -333,6 +333,7 @@ fn push_relation_tables(ctx: &mut Context<'_>) { tpe: column_a_type, auto_increment: false, description: None, + generation_expression: None, }, ); let column_b_id = ctx.schema.describer_schema.push_table_column( @@ -342,6 +343,7 @@ fn push_relation_tables(ctx: &mut Context<'_>) { tpe: column_b_type, auto_increment: false, description: None, + generation_expression: None, }, ); @@ -502,6 +504,7 @@ fn push_column_for_model_enum_scalar_field( ), auto_increment: false, description: None, + generation_expression: field.generation_expression().map(ToOwned::to_owned), }; ctx.schema.describer_schema.push_table_column(table_id, column); @@ -531,6 +534,7 @@ fn push_column_for_model_unsupported_scalar_field( ), auto_increment: false, description: None, + generation_expression: None, }; ctx.schema.describer_schema.push_table_column(table_id, column); @@ -568,6 +572,7 @@ fn push_column_for_extension_type( }, auto_increment: field.is_autoincrement() || ctx.flavour.field_is_implicit_autoincrement_primary_key(field), description: None, + generation_expression: field.generation_expression().map(ToOwned::to_owned), }; ctx.schema.describer_schema.push_table_column(table_id, column); @@ -654,6 +659,7 @@ fn push_column_for_builtin_scalar_type( }, auto_increment: field.is_autoincrement() || ctx.flavour.field_is_implicit_autoincrement_primary_key(field), description: None, + generation_expression: field.generation_expression().map(ToOwned::to_owned), }; let column_id = ctx.schema.describer_schema.push_table_column(table_id, column); diff --git a/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ.rs b/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ.rs index ff6e2c42cb6c..9f9959e0a284 100644 --- a/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ.rs +++ b/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ.rs @@ -195,6 +195,13 @@ fn alter_columns(table_differ: &TableDiffer<'_, '_>) -> Vec { let column_id = MigrationPair::new(column_differ.previous.id, column_differ.next.id); match changes.type_change { + // Generated column expression changes require drop+recreate. + // PostgreSQL 17+ supports ALTER COLUMN SET EXPRESSION, but we use + // DROP+ADD for compatibility with PG 12-16. Indexes on the column + // are cascaded by DROP and independently recreated by the differ. + _ if changes.generation_expression_changed() => { + Some(TableChange::DropAndRecreateColumn { column_id, changes }) + } Some(ColumnTypeChange::NotCastable) => Some(TableChange::DropAndRecreateColumn { column_id, changes }), Some(ColumnTypeChange::RiskyCast) => Some(TableChange::AlterColumn(AlterColumn { column_id, diff --git a/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ/column.rs b/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ/column.rs index b8df2444f3c8..cab3ef13f386 100644 --- a/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ/column.rs +++ b/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ/column.rs @@ -28,6 +28,16 @@ pub(crate) fn all_changes( changes |= ColumnChange::Autoincrement; } + // For generated columns, only detect changes when the "generated-ness" itself changes + // (added or removed), not when the expression text differs. The database normalizes + // the expression (e.g. adding type casts), so the stored text won't match the schema text. + match (cols.previous.generation_expression(), cols.next.generation_expression()) { + (Some(_), None) | (None, Some(_)) => { + changes |= ColumnChange::GenerationExpression; + } + _ => {} + } + ColumnChanges { type_change, changes } } @@ -167,6 +177,7 @@ pub(crate) enum ColumnChange { Default, TypeChanged, Autoincrement, + GenerationExpression, } // This should be pub(crate), but SqlMigration is exported, so it has to be @@ -210,6 +221,10 @@ impl ColumnChanges { self.changes.contains(ColumnChange::Arity) } + pub(crate) fn generation_expression_changed(&self) -> bool { + self.changes.contains(ColumnChange::GenerationExpression) + } + pub(crate) fn default_changed(&self) -> bool { self.changes.contains(ColumnChange::Default) } diff --git a/schema-engine/datamodel-renderer/src/datamodel/field.rs b/schema-engine/datamodel-renderer/src/datamodel/field.rs index 35cdc984e9a2..9b95958563ce 100644 --- a/schema-engine/datamodel-renderer/src/datamodel/field.rs +++ b/schema-engine/datamodel-renderer/src/datamodel/field.rs @@ -17,6 +17,7 @@ pub struct Field<'a> { unique: Option>, id: Option>, default: Option>, + generated: Option>, map: Option>, relation: Option>, native_type: Option>, @@ -46,6 +47,7 @@ impl<'a> Field<'a> { unique: None, id: None, default: None, + generated: None, relation: None, native_type: None, ignore: None, @@ -207,6 +209,20 @@ impl<'a> Field<'a> { self.relation = Some(relation); } + /// Marks the field as a generated (computed) column. + /// + /// ```ignore + /// model Session { + /// statusPriority Int? @generated("CASE status WHEN 'A' THEN 1 END") + /// // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this + /// } + /// ``` + pub fn generated(&mut self, expression: impl Into>) { + let mut fun = Function::new("generated"); + fun.push_param(crate::value::Value::from(crate::value::Text::new(expression))); + self.generated = Some(FieldAttribute::new(fun)); + } + /// Ignores the field. /// /// ```ignore @@ -253,6 +269,10 @@ impl fmt::Display for Field<'_> { write!(f, " {def}")?; } + if let Some(ref generated) = self.generated { + write!(f, " {generated}")?; + } + if let Some(ref map) = self.map { write!(f, " {map}")?; } diff --git a/schema-engine/sql-migration-tests/tests/single_migration_tests/postgres/generated_column.prisma b/schema-engine/sql-migration-tests/tests/single_migration_tests/postgres/generated_column.prisma new file mode 100644 index 000000000000..87fd11fb07d5 --- /dev/null +++ b/schema-engine/sql-migration-tests/tests/single_migration_tests/postgres/generated_column.prisma @@ -0,0 +1,25 @@ +// tags=postgres +// exclude=cockroachdb + +datasource testds { + provider = "postgresql" +} + +model Session { + id Int @id @default(autoincrement()) + status String + statusPriority Int? @generated("CASE status WHEN 'WAITING' THEN 1 WHEN 'FAILED' THEN 2 WHEN 'ACTIVE' THEN 3 END") +} + +// Expected Migration: +// -- CreateSchema +// CREATE SCHEMA IF NOT EXISTS "public"; +// +// -- CreateTable +// CREATE TABLE "Session" ( +// "id" SERIAL NOT NULL, +// "status" TEXT NOT NULL, +// "statusPriority" INTEGER GENERATED ALWAYS AS (CASE status WHEN 'WAITING' THEN 1 WHEN 'FAILED' THEN 2 WHEN 'ACTIVE' THEN 3 END) STORED, +// +// CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +// ); diff --git a/schema-engine/sql-schema-describer/src/lib.rs b/schema-engine/sql-schema-describer/src/lib.rs index c731c5236b27..1a60553ff715 100644 --- a/schema-engine/sql-schema-describer/src/lib.rs +++ b/schema-engine/sql-schema-describer/src/lib.rs @@ -641,6 +641,10 @@ pub struct Column { pub auto_increment: bool, /// The comment in the database pub description: Option, + /// SQL expression for a generated (computed) column, e.g. "CASE status WHEN 'A' THEN 1 END". + /// When set, the column is rendered as GENERATED ALWAYS AS (expr) STORED. + #[serde(default)] + pub generation_expression: Option, } /// The type of a column. diff --git a/schema-engine/sql-schema-describer/src/mssql.rs b/schema-engine/sql-schema-describer/src/mssql.rs index 28f7a8924f41..f8b021ea3c45 100644 --- a/schema-engine/sql-schema-describer/src/mssql.rs +++ b/schema-engine/sql-schema-describer/src/mssql.rs @@ -343,6 +343,7 @@ impl<'a> SqlSchemaDescriber<'a> { tpe, auto_increment, description: None, + generation_expression: None, }; match container_id { diff --git a/schema-engine/sql-schema-describer/src/mysql.rs b/schema-engine/sql-schema-describer/src/mysql.rs index ee353b9d9418..81f5449dd382 100644 --- a/schema-engine/sql-schema-describer/src/mysql.rs +++ b/schema-engine/sql-schema-describer/src/mysql.rs @@ -503,6 +503,7 @@ impl<'a> SqlSchemaDescriber<'a> { tpe, auto_increment, description, + generation_expression: None, }; match container_id { diff --git a/schema-engine/sql-schema-describer/src/postgres.rs b/schema-engine/sql-schema-describer/src/postgres.rs index 97815057157f..197eeaaa3983 100644 --- a/schema-engine/sql-schema-describer/src/postgres.rs +++ b/schema-engine/sql-schema-describer/src/postgres.rs @@ -802,7 +802,8 @@ impl<'a> SqlSchemaDescriber<'a> { info.is_nullable, info.is_identity, info.character_maximum_length, - col_description(att.attrelid, ordinal_position) AS description + col_description(att.attrelid, ordinal_position) AS description, + att.attgenerated::text AS attgenerated FROM information_schema.columns info JOIN pg_attribute att ON att.attname = info.column_name JOIN ( @@ -861,6 +862,11 @@ impl<'a> SqlSchemaDescriber<'a> { let description = col.get_string("description"); + // attgenerated = 's' means a stored generated column (empty string for non-generated) + // attgenerated = 's' means a stored generated column (cast to text because pg's "char" type + // is not mapped to string by quaint). + let is_generated_stored = col.get_string("attgenerated").as_deref() == Some("s"); + let auto_increment = is_identity || matches!(default.as_ref().map(|d| &d.kind), Some(DefaultKind::Sequence(_))) || (self.is_cockroach() @@ -869,12 +875,23 @@ impl<'a> SqlSchemaDescriber<'a> { Some(DefaultKind::DbGenerated(Some(s))) if s == "unique_rowid()" )); - match container_id { - Either::Left(table_id) => { - table_defaults.push((table_id, default)); - } - Either::Right(view_id) => { - view_defaults.push((view_id, default)); + // For generated columns, the raw default from pg_get_expr contains the + // generation expression. Extract it and skip storing it as a default. + let generation_expression = if is_generated_stored { + col.get("column_default") + .and_then(|v| v.to_string()) + } else { + None + }; + + if !is_generated_stored { + match container_id { + Either::Left(table_id) => { + table_defaults.push((table_id, default)); + } + Either::Right(view_id) => { + view_defaults.push((view_id, default)); + } } } @@ -883,6 +900,7 @@ impl<'a> SqlSchemaDescriber<'a> { tpe, auto_increment, description, + generation_expression, }; match container_id { diff --git a/schema-engine/sql-schema-describer/src/sqlite.rs b/schema-engine/sql-schema-describer/src/sqlite.rs index 26982a12bce5..d1da264ed703 100644 --- a/schema-engine/sql-schema-describer/src/sqlite.rs +++ b/schema-engine/sql-schema-describer/src/sqlite.rs @@ -355,6 +355,7 @@ async fn push_columns( tpe, auto_increment: false, description: None, + generation_expression: None, }; match container_id { diff --git a/schema-engine/sql-schema-describer/src/walkers/column.rs b/schema-engine/sql-schema-describer/src/walkers/column.rs index f6ab342024f5..c84d2bb746aa 100644 --- a/schema-engine/sql-schema-describer/src/walkers/column.rs +++ b/schema-engine/sql-schema-describer/src/walkers/column.rs @@ -116,6 +116,11 @@ impl<'a> ColumnWalker<'a> { self.get().description.as_deref() } + /// The SQL expression for a generated (computed) column, if any. + pub fn generation_expression(self) -> Option<&'a str> { + self.get().generation_expression.as_deref() + } + fn get(self) -> &'a Column { match self.id { Either::Left(table_column_id) => &self.schema.table_columns[table_column_id.0 as usize].1, diff --git a/schema-engine/sql-schema-describer/src/walkers/column/table_column.rs b/schema-engine/sql-schema-describer/src/walkers/column/table_column.rs index 9d8d995f03d0..7caa52b85b66 100644 --- a/schema-engine/sql-schema-describer/src/walkers/column/table_column.rs +++ b/schema-engine/sql-schema-describer/src/walkers/column/table_column.rs @@ -110,6 +110,11 @@ impl<'a> TableColumnWalker<'a> { .unwrap_or(false) } + /// The SQL expression for a generated (computed) column, if any. + pub fn generation_expression(self) -> Option<&'a str> { + self.get().1.generation_expression.as_deref() + } + /// Traverse to the column's table. pub fn table(self) -> TableWalker<'a> { self.walk(self.get().0) From a3f9d1f5c7cc778ff5d553087e8f8b61aa55939d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 27 Mar 2026 18:19:23 -0300 Subject: [PATCH 02/11] feat: add generatedColumns preview feature flag and migration tests - Gate @generated behind previewFeatures = ["generatedColumns"] - Add validation error when feature flag is missing - Add migration tests: add/remove/change generated columns on existing tables - All tests verify idempotency (no drift on re-push) Co-Authored-By: Claude Opus 4.6 (1M context) --- psl/psl-core/src/common/preview_features.rs | 4 +- .../validation_pipeline/validations.rs | 3 +- .../validation_pipeline/validations/fields.rs | 14 +++ .../tests/attributes/generated_negative.rs | 41 +++---- .../tests/attributes/generated_positive.rs | 21 +--- .../postgres_generated_column.prisma | 5 + .../tests/migrations/postgres.rs | 102 ++++++++++++++++++ .../postgres/generated_column.prisma | 5 + 8 files changed, 157 insertions(+), 38 deletions(-) diff --git a/psl/psl-core/src/common/preview_features.rs b/psl/psl-core/src/common/preview_features.rs index 6ddff050183e..b382f36f0d40 100644 --- a/psl/psl-core/src/common/preview_features.rs +++ b/psl/psl-core/src/common/preview_features.rs @@ -56,6 +56,7 @@ features!( FullTextIndex, FullTextSearch, FullTextSearchPostgres, + GeneratedColumns, GroupBy, ImprovedQueryRaw, InteractiveTransactions, @@ -157,7 +158,8 @@ impl<'a> FeatureMapWithProvider<'a> { // Generator preview features (alphabetically sorted) let feature_map: FeatureMap = FeatureMap { active: enumflags2::make_bitflags!(PreviewFeature::{ - NativeDistinct + GeneratedColumns + | NativeDistinct | PartialIndexes | PostgresqlExtensions | RelationJoins diff --git a/psl/psl-core/src/validate/validation_pipeline/validations.rs b/psl/psl-core/src/validate/validation_pipeline/validations.rs index 9e19b13b2d8b..20d79162c544 100644 --- a/psl/psl-core/src/validate/validation_pipeline/validations.rs +++ b/psl/psl-core/src/validate/validation_pipeline/validations.rs @@ -96,7 +96,8 @@ pub(super) fn validate(ctx: &mut Context<'_>) { fields::has_a_unique_default_constraint_name(field, &names, ctx); fields::validate_native_type_arguments(field, ctx); fields::validate_default_value(field, ctx); - fields::validate_unsupported_field_type(field, ctx) + fields::validate_unsupported_field_type(field, ctx); + fields::validate_generated_column(field, ctx); } for field in model.relation_fields() { diff --git a/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs b/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs index 67b01495500b..cdd25e794437 100644 --- a/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs +++ b/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs @@ -419,3 +419,17 @@ pub(crate) fn clustering_can_be_defined_only_once(pk: PrimaryKeyWalker<'_>, ctx: return; } } + +pub(super) fn validate_generated_column(field: ScalarFieldWalker<'_>, ctx: &mut Context<'_>) { + if !field.is_generated_column() { + return; + } + + if !ctx.preview_features.contains(crate::PreviewFeature::GeneratedColumns) { + ctx.push_error(DatamodelError::new_attribute_validation_error( + "Generated columns are a preview feature. Add \"generatedColumns\" to previewFeatures in your generator block.", + "generated", + field.ast_field().span(), + )); + } +} diff --git a/psl/psl/tests/attributes/generated_negative.rs b/psl/psl/tests/attributes/generated_negative.rs index cf4f2d5ccbb6..8a75b28ad908 100644 --- a/psl/psl/tests/attributes/generated_negative.rs +++ b/psl/psl/tests/attributes/generated_negative.rs @@ -1,38 +1,45 @@ -use crate::common::*; +use crate::{Provider, common::*, with_header}; #[test] -fn should_fail_without_expression_argument() { +fn should_fail_without_preview_feature() { let dml = indoc! {r#" - datasource db { - provider = "postgres" + model User { + id Int @id + calc Int? @generated("id * 2") } + "#}; + + // No "generatedColumns" in preview features + let error = parse_unwrap_err(&with_header(dml, Provider::Postgres, &[])); + + assert!(error.contains("preview feature")); + assert!(error.contains("generatedColumns")); +} +#[test] +fn should_fail_without_expression_argument() { + let dml = indoc! {r#" model User { id Int @id name String @generated } "#}; - let error = parse_unwrap_err(dml); + let error = parse_unwrap_err(&with_header(dml, Provider::Postgres, &["generatedColumns"])); - // @generated requires a string argument assert!(error.contains("@generated")); } #[test] fn should_fail_with_non_string_argument() { let dml = indoc! {r#" - datasource db { - provider = "postgres" - } - model User { id Int @id calc Int @generated(42) } "#}; - let error = parse_unwrap_err(dml); + let error = parse_unwrap_err(&with_header(dml, Provider::Postgres, &["generatedColumns"])); assert!(error.contains("@generated")); } @@ -46,7 +53,7 @@ fn should_fail_when_combined_with_default() { } "#}; - let error = parse_unwrap_err(dml); + let error = parse_unwrap_err(&with_header(dml, Provider::Postgres, &["generatedColumns"])); assert!(error.contains("@generated")); assert!(error.contains("@default")); @@ -61,7 +68,7 @@ fn should_fail_when_combined_with_updated_at() { } "#}; - let error = parse_unwrap_err(dml); + let error = parse_unwrap_err(&with_header(dml, Provider::Postgres, &["generatedColumns"])); assert!(error.contains("@generated")); assert!(error.contains("@updatedAt")); @@ -76,7 +83,7 @@ fn should_fail_when_combined_with_id() { } "#}; - let error = parse_unwrap_err(dml); + let error = parse_unwrap_err(&with_header(dml, Provider::Postgres, &["generatedColumns"])); assert!(error.contains("@generated")); assert!(error.contains("@id")); @@ -85,17 +92,13 @@ fn should_fail_when_combined_with_id() { #[test] fn should_fail_on_list_field() { let dml = indoc! {r#" - datasource db { - provider = "postgres" - } - model User { id Int @id tags Int[] @generated("ARRAY[1,2,3]") } "#}; - let error = parse_unwrap_err(dml); + let error = parse_unwrap_err(&with_header(dml, Provider::Postgres, &["generatedColumns"])); assert!(error.contains("@generated")); assert!(error.contains("list")); diff --git a/psl/psl/tests/attributes/generated_positive.rs b/psl/psl/tests/attributes/generated_positive.rs index d5efecab48a5..1f2fd3370668 100644 --- a/psl/psl/tests/attributes/generated_positive.rs +++ b/psl/psl/tests/attributes/generated_positive.rs @@ -1,21 +1,17 @@ use psl::parser_database::ScalarType; -use crate::common::*; +use crate::{Provider, common::*, with_header}; #[test] fn should_accept_generated_attribute_on_int_field() { let dml = indoc! {r#" - datasource db { - provider = "postgres" - } - model Session { id Int @id statusPriority Int? @generated("CASE status WHEN 'A' THEN 1 WHEN 'B' THEN 2 END") } "#}; - let schema = psl::parse_schema_without_extensions(dml).unwrap(); + let schema = psl::parse_schema_without_extensions(with_header(dml, Provider::Postgres, &["generatedColumns"])).unwrap(); let model = schema.assert_has_model("Session"); model @@ -27,10 +23,6 @@ fn should_accept_generated_attribute_on_int_field() { #[test] fn should_accept_generated_attribute_on_string_field() { let dml = indoc! {r#" - datasource db { - provider = "postgres" - } - model User { id Int @id first String @@ -39,7 +31,7 @@ fn should_accept_generated_attribute_on_string_field() { } "#}; - let schema = psl::parse_schema_without_extensions(dml).unwrap(); + let schema = psl::parse_schema_without_extensions(with_header(dml, Provider::Postgres, &["generatedColumns"])).unwrap(); let model = schema.assert_has_model("User"); model @@ -51,10 +43,6 @@ fn should_accept_generated_attribute_on_string_field() { #[test] fn generated_field_should_be_readable() { let dml = indoc! {r#" - datasource db { - provider = "postgres" - } - model Item { id Int @id price Float @@ -62,10 +50,9 @@ fn generated_field_should_be_readable() { } "#}; - let schema = psl::parse_schema_without_extensions(dml).unwrap(); + let schema = psl::parse_schema_without_extensions(with_header(dml, Provider::Postgres, &["generatedColumns"])).unwrap(); let model = schema.assert_has_model("Item"); - // Generated fields should exist as scalar fields model .assert_has_scalar_field("tax") .assert_is_generated_column() diff --git a/query-compiler/dmmf/src/tests/test-schemas/postgres_generated_column.prisma b/query-compiler/dmmf/src/tests/test-schemas/postgres_generated_column.prisma index ea889a960559..9990afb85853 100644 --- a/query-compiler/dmmf/src/tests/test-schemas/postgres_generated_column.prisma +++ b/query-compiler/dmmf/src/tests/test-schemas/postgres_generated_column.prisma @@ -1,3 +1,8 @@ +generator client { + provider = "prisma-client" + previewFeatures = ["generatedColumns"] +} + model Session { id Int @id @default(autoincrement()) status String diff --git a/schema-engine/sql-migration-tests/tests/migrations/postgres.rs b/schema-engine/sql-migration-tests/tests/migrations/postgres.rs index a9717a199d24..6dcc23329735 100644 --- a/schema-engine/sql-migration-tests/tests/migrations/postgres.rs +++ b/schema-engine/sql-migration-tests/tests/migrations/postgres.rs @@ -1114,3 +1114,105 @@ fn postgres_create_index_concurrently_works(api: TestApi) { .send_sync() .assert_applied_migrations(&["01init"]); } + +#[test_connector(tags(Postgres), preview_features("generatedColumns"))] +fn add_generated_column_to_existing_table(api: TestApi) { + let dm1 = r#" + model Session { + id Int @id @default(autoincrement()) + status String + } + "#; + + api.schema_push_w_datasource(dm1).send().assert_green(); + + let dm2 = r#" + model Session { + id Int @id @default(autoincrement()) + status String + statusPriority Int? @generated("CASE status WHEN 'A' THEN 1 WHEN 'B' THEN 2 END") + } + "#; + + api.schema_push_w_datasource(dm2).send().assert_green(); + + // Verify idempotency + api.schema_push_w_datasource(dm2).send().assert_green().assert_no_steps(); +} + +#[test_connector(tags(Postgres), preview_features("generatedColumns"))] +fn remove_generated_column(api: TestApi) { + let dm1 = r#" + model Session { + id Int @id @default(autoincrement()) + status String + statusPriority Int? @generated("CASE status WHEN 'A' THEN 1 END") + } + "#; + + api.schema_push_w_datasource(dm1).send().assert_green(); + + let dm2 = r#" + model Session { + id Int @id @default(autoincrement()) + status String + } + "#; + + api.schema_push_w_datasource(dm2).send().assert_green(); + + // Verify idempotency + api.schema_push_w_datasource(dm2).send().assert_green().assert_no_steps(); +} + +#[test_connector(tags(Postgres), preview_features("generatedColumns"))] +fn change_regular_column_to_generated(api: TestApi) { + let dm1 = r#" + model Session { + id Int @id @default(autoincrement()) + status String + priority Int? + } + "#; + + api.schema_push_w_datasource(dm1).send().assert_green(); + + let dm2 = r#" + model Session { + id Int @id @default(autoincrement()) + status String + priority Int? @generated("CASE status WHEN 'A' THEN 1 END") + } + "#; + + api.schema_push_w_datasource(dm2).send().assert_green(); + + // Verify idempotency + api.schema_push_w_datasource(dm2).send().assert_green().assert_no_steps(); +} + +#[test_connector(tags(Postgres), preview_features("generatedColumns"))] +fn change_generated_column_to_regular(api: TestApi) { + let dm1 = r#" + model Session { + id Int @id @default(autoincrement()) + status String + priority Int? @generated("CASE status WHEN 'A' THEN 1 END") + } + "#; + + api.schema_push_w_datasource(dm1).send().assert_green(); + + let dm2 = r#" + model Session { + id Int @id @default(autoincrement()) + status String + priority Int? + } + "#; + + api.schema_push_w_datasource(dm2).send().assert_green(); + + // Verify idempotency + api.schema_push_w_datasource(dm2).send().assert_green().assert_no_steps(); +} diff --git a/schema-engine/sql-migration-tests/tests/single_migration_tests/postgres/generated_column.prisma b/schema-engine/sql-migration-tests/tests/single_migration_tests/postgres/generated_column.prisma index 87fd11fb07d5..c0a5a65755f2 100644 --- a/schema-engine/sql-migration-tests/tests/single_migration_tests/postgres/generated_column.prisma +++ b/schema-engine/sql-migration-tests/tests/single_migration_tests/postgres/generated_column.prisma @@ -5,6 +5,11 @@ datasource testds { provider = "postgresql" } +generator js { + provider = "prisma-client" + previewFeatures = ["generatedColumns"] +} + model Session { id Int @id @default(autoincrement()) status String From d4cc9c4c7cf98dd7c68169f696df9089df734ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 27 Mar 2026 18:44:37 -0300 Subject: [PATCH 03/11] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20PG=209-11=20compatibility=20and=20diff=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate att.attgenerated query behind SupportsGeneratedColumns circumstance flag (PG 12+). On PG 9-11, selects ''::text constant to avoid parse error. - Add SupportsGeneratedColumns to both describer and connector Circumstances, set when version_num >= 120000. - Improve diff comment explaining why Some↔Some expression comparison is skipped (PG normalizes expressions, making text comparison unreliable). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../sql-schema-connector/src/flavour/postgres.rs | 14 ++++++++++++-- .../src/sql_schema_differ/column.rs | 9 ++++++--- schema-engine/sql-schema-describer/src/postgres.rs | 11 ++++++++++- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/schema-engine/connectors/sql-schema-connector/src/flavour/postgres.rs b/schema-engine/connectors/sql-schema-connector/src/flavour/postgres.rs index 1548500d3f6d..29894b260f6e 100644 --- a/schema-engine/connectors/sql-schema-connector/src/flavour/postgres.rs +++ b/schema-engine/connectors/sql-schema-connector/src/flavour/postgres.rs @@ -649,6 +649,10 @@ async fn describe_schema_with( if circumstances.contains(Circumstances::CanPartitionTables) { describer_circumstances |= describer::Circumstances::CanPartitionTables; } + + if circumstances.contains(Circumstances::SupportsGeneratedColumns) { + describer_circumstances |= describer::Circumstances::SupportsGeneratedColumns; + } let namespaces_vec = Namespaces::to_vec(namespaces, schema); let namespaces_str: Vec<&str> = namespaces_vec.iter().map(AsRef::as_ref).collect(); @@ -727,6 +731,7 @@ pub(crate) enum Circumstances { IsCockroachDb, CockroachWithPostgresNativeTypes, // FIXME: we should really break and remove this CanPartitionTables, + SupportsGeneratedColumns, } async fn setup_connection( @@ -781,8 +786,13 @@ async fn setup_connection( .raw_cmd(COCKROACHDB_PRELUDE) .await .map_err(imp::quaint_error_mapper(params))?; - } else if version_num >= 100000 { - circumstances |= Circumstances::CanPartitionTables; + } else { + if version_num >= 100000 { + circumstances |= Circumstances::CanPartitionTables; + } + if version_num >= 120000 { + circumstances |= Circumstances::SupportsGeneratedColumns; + } } } None => { diff --git a/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ/column.rs b/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ/column.rs index cab3ef13f386..15b2bbd48efb 100644 --- a/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ/column.rs +++ b/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ/column.rs @@ -28,9 +28,12 @@ pub(crate) fn all_changes( changes |= ColumnChange::Autoincrement; } - // For generated columns, only detect changes when the "generated-ness" itself changes - // (added or removed), not when the expression text differs. The database normalizes - // the expression (e.g. adding type casts), so the stored text won't match the schema text. + // Detect changes to generation expression "generated-ness" (added or removed). + // We intentionally skip comparing expression text when both sides are Some, + // because PostgreSQL normalizes expressions on storage (adds type casts, + // reformats whitespace), so the schema text and introspected text will never + // match. Expression text changes within a generated column require the user + // to manually edit the migration SQL. match (cols.previous.generation_expression(), cols.next.generation_expression()) { (Some(_), None) | (None, Some(_)) => { changes |= ColumnChange::GenerationExpression; diff --git a/schema-engine/sql-schema-describer/src/postgres.rs b/schema-engine/sql-schema-describer/src/postgres.rs index 197eeaaa3983..7157dc5e196d 100644 --- a/schema-engine/sql-schema-describer/src/postgres.rs +++ b/schema-engine/sql-schema-describer/src/postgres.rs @@ -104,6 +104,7 @@ pub enum Circumstances { Cockroach, CockroachWithPostgresNativeTypes, // TODO: this is a temporary workaround CanPartitionTables, + SupportsGeneratedColumns, } pub struct SqlSchemaDescriber<'a> { @@ -784,6 +785,14 @@ impl<'a> SqlSchemaDescriber<'a> { "" }; + // att.attgenerated exists only on PostgreSQL 12+. On older versions, + // select an empty string constant to avoid a parse error. + let attgenerated_clause = if self.circumstances.contains(Circumstances::SupportsGeneratedColumns) { + "att.attgenerated::text AS attgenerated" + } else { + "''::text AS attgenerated" + }; + let sql = format!( r#" SELECT @@ -803,7 +812,7 @@ impl<'a> SqlSchemaDescriber<'a> { info.is_identity, info.character_maximum_length, col_description(att.attrelid, ordinal_position) AS description, - att.attgenerated::text AS attgenerated + {attgenerated_clause} FROM information_schema.columns info JOIN pg_attribute att ON att.attname = info.column_name JOIN ( From 5d2a16d67990f047b267c913d582ca2ef357d4b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 27 Mar 2026 18:49:02 -0300 Subject: [PATCH 04/11] fix: address CodeRabbit review feedback - Exclude generated columns from unchecked create/update inputs - Propagate generation_expression for Unsupported type fields - Alphabetize generated test modules in mod.rs - Deduplicate attgenerated comment in postgres.rs - Add more specific assertions in negative tests Co-Authored-By: Claude Opus 4.6 (1M context) --- psl/psl/tests/attributes/generated_negative.rs | 2 ++ psl/psl/tests/attributes/mod.rs | 4 ++-- .../src/build/input_types/objects/update_one_objects.rs | 3 ++- query-compiler/schema/src/build/mutations/create_one.rs | 2 +- .../sql-schema-connector/src/sql_schema_calculator.rs | 2 +- schema-engine/sql-schema-describer/src/postgres.rs | 5 ++--- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/psl/psl/tests/attributes/generated_negative.rs b/psl/psl/tests/attributes/generated_negative.rs index 8a75b28ad908..e66ec5a452a7 100644 --- a/psl/psl/tests/attributes/generated_negative.rs +++ b/psl/psl/tests/attributes/generated_negative.rs @@ -28,6 +28,7 @@ fn should_fail_without_expression_argument() { let error = parse_unwrap_err(&with_header(dml, Provider::Postgres, &["generatedColumns"])); assert!(error.contains("@generated")); + assert!(error.contains("Argument")); } #[test] @@ -42,6 +43,7 @@ fn should_fail_with_non_string_argument() { let error = parse_unwrap_err(&with_header(dml, Provider::Postgres, &["generatedColumns"])); assert!(error.contains("@generated")); + assert!(error.contains("string")); } #[test] diff --git a/psl/psl/tests/attributes/mod.rs b/psl/psl/tests/attributes/mod.rs index 8c9c4cabdf2f..12bb68fd7db1 100644 --- a/psl/psl/tests/attributes/mod.rs +++ b/psl/psl/tests/attributes/mod.rs @@ -22,7 +22,7 @@ mod postgres_indices; mod relations; mod unique_negative; mod unique_positive; +mod generated_negative; +mod generated_positive; mod updated_at_negative; mod updated_at_positive; -mod generated_positive; -mod generated_negative; diff --git a/query-compiler/schema/src/build/input_types/objects/update_one_objects.rs b/query-compiler/schema/src/build/input_types/objects/update_one_objects.rs index 21f56e9ce0e2..e517537b7a9d 100644 --- a/query-compiler/schema/src/build/input_types/objects/update_one_objects.rs +++ b/query-compiler/schema/src/build/input_types/objects/update_one_objects.rs @@ -126,7 +126,8 @@ pub(super) fn filter_unchecked_update_fields<'a>( // link the model to the parent record in case of a nested unchecked create, as this would introduce complexities we don't want to deal with right now. // 2) Exclude @@id or @id fields if not updatable ModelField::Scalar(sf) => { - !linking_fields.contains(sf) + !sf.is_generated_column() + && !linking_fields.contains(sf) && if let Some(id_fields) = &id_fields { // Exclude @@id or @id fields if not updatable if id_fields.clone().any(|f| f.id == sf.id) { diff --git a/query-compiler/schema/src/build/mutations/create_one.rs b/query-compiler/schema/src/build/mutations/create_one.rs index 522ce3d43f40..086d57601ff8 100644 --- a/query-compiler/schema/src/build/mutations/create_one.rs +++ b/query-compiler/schema/src/build/mutations/create_one.rs @@ -157,7 +157,7 @@ fn filter_unchecked_create_fields<'a>( model.fields().filter_all(move |field| match field { // In principle, all scalars are writable for unchecked inputs. However, it still doesn't make any sense to be able to write the scalars that // link the model to the parent record in case of a nested unchecked create, as this would introduce complexities we don't want to deal with right now. - ModelField::Scalar(sf) => !linking_fields.contains(sf), + ModelField::Scalar(sf) => !sf.is_generated_column() && !linking_fields.contains(sf), // If the relation field `rf` is the one that was traversed to by the parent relation field `parent_field`, // then exclude it for checked inputs - this prevents endless nested type circles that are useless to offer as API. diff --git a/schema-engine/connectors/sql-schema-connector/src/sql_schema_calculator.rs b/schema-engine/connectors/sql-schema-connector/src/sql_schema_calculator.rs index 8c55fc6130b0..f71431e644cc 100644 --- a/schema-engine/connectors/sql-schema-connector/src/sql_schema_calculator.rs +++ b/schema-engine/connectors/sql-schema-connector/src/sql_schema_calculator.rs @@ -534,7 +534,7 @@ fn push_column_for_model_unsupported_scalar_field( ), auto_increment: false, description: None, - generation_expression: None, + generation_expression: field.generation_expression().map(ToOwned::to_owned), }; ctx.schema.describer_schema.push_table_column(table_id, column); diff --git a/schema-engine/sql-schema-describer/src/postgres.rs b/schema-engine/sql-schema-describer/src/postgres.rs index 7157dc5e196d..9c326edbcefc 100644 --- a/schema-engine/sql-schema-describer/src/postgres.rs +++ b/schema-engine/sql-schema-describer/src/postgres.rs @@ -871,9 +871,8 @@ impl<'a> SqlSchemaDescriber<'a> { let description = col.get_string("description"); - // attgenerated = 's' means a stored generated column (empty string for non-generated) - // attgenerated = 's' means a stored generated column (cast to text because pg's "char" type - // is not mapped to string by quaint). + // attgenerated = 's' means a stored generated column. Cast to text in the SQL + // query because pg's "char" type is not mapped to string by quaint. let is_generated_stored = col.get_string("attgenerated").as_deref() == Some("s"); let auto_increment = is_identity From c7dd94754eb7d08b466cfd1d0b8a3e6e13abeb98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 27 Mar 2026 19:09:35 -0300 Subject: [PATCH 05/11] fix: critical default alignment bug and comment accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix column ID alignment: push None placeholder for generated columns in table_defaults/view_defaults instead of skipping, to keep positional indexing correct for subsequent columns. - Reword differ comment to accurately reflect that only Some↔None transitions are detected, not expression text changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/sql_schema_differ.rs | 9 +++++---- .../sql-schema-describer/src/postgres.rs | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ.rs b/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ.rs index 9f9959e0a284..043958a00b6a 100644 --- a/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ.rs +++ b/schema-engine/connectors/sql-schema-connector/src/sql_schema_differ.rs @@ -195,10 +195,11 @@ fn alter_columns(table_differ: &TableDiffer<'_, '_>) -> Vec { let column_id = MigrationPair::new(column_differ.previous.id, column_differ.next.id); match changes.type_change { - // Generated column expression changes require drop+recreate. - // PostgreSQL 17+ supports ALTER COLUMN SET EXPRESSION, but we use - // DROP+ADD for compatibility with PG 12-16. Indexes on the column - // are cascaded by DROP and independently recreated by the differ. + // Adding or removing a generated column requires drop+recreate. + // Note: expression text changes (Some↔Some) are not detected because + // PG normalizes expressions. Only generated↔non-generated transitions + // (Some↔None) trigger this. PG 17+ supports ALTER COLUMN SET EXPRESSION, + // but we use DROP+ADD for compatibility with PG 12-16. _ if changes.generation_expression_changed() => { Some(TableChange::DropAndRecreateColumn { column_id, changes }) } diff --git a/schema-engine/sql-schema-describer/src/postgres.rs b/schema-engine/sql-schema-describer/src/postgres.rs index 9c326edbcefc..3896103d7695 100644 --- a/schema-engine/sql-schema-describer/src/postgres.rs +++ b/schema-engine/sql-schema-describer/src/postgres.rs @@ -892,14 +892,16 @@ impl<'a> SqlSchemaDescriber<'a> { None }; - if !is_generated_stored { - match container_id { - Either::Left(table_id) => { - table_defaults.push((table_id, default)); - } - Either::Right(view_id) => { - view_defaults.push((view_id, default)); - } + // For generated columns, push None instead of the default to keep + // the vector aligned with column IDs (they are indexed positionally). + let default = if is_generated_stored { None } else { default }; + + match container_id { + Either::Left(table_id) => { + table_defaults.push((table_id, default)); + } + Either::Right(view_id) => { + view_defaults.push((view_id, default)); } } From d716af6f96023fa806c6736a092f83f44ddac076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 27 Mar 2026 19:17:39 -0300 Subject: [PATCH 06/11] fix: add connector-specific validation for @generated Reject @generated on non-PostgreSQL connectors with a clear error message. Uses Flavour::Postgres check since ConnectorCapability bitflags are at the u64 limit. Add negative test for MySQL. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../validation_pipeline/validations/fields.rs | 10 ++++++++++ psl/psl/tests/attributes/generated_negative.rs | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs b/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs index cdd25e794437..287d13cce99e 100644 --- a/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs +++ b/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs @@ -421,6 +421,8 @@ pub(crate) fn clustering_can_be_defined_only_once(pk: PrimaryKeyWalker<'_>, ctx: } pub(super) fn validate_generated_column(field: ScalarFieldWalker<'_>, ctx: &mut Context<'_>) { + use crate::datamodel_connector::Flavour; + if !field.is_generated_column() { return; } @@ -432,4 +434,12 @@ pub(super) fn validate_generated_column(field: ScalarFieldWalker<'_>, ctx: &mut field.ast_field().span(), )); } + + if !matches!(ctx.connector.flavour(), Flavour::Postgres) { + ctx.push_error(DatamodelError::new_attribute_validation_error( + "Generated columns are not supported by the current connector. Currently only PostgreSQL supports @generated.", + "generated", + field.ast_field().span(), + )); + } } diff --git a/psl/psl/tests/attributes/generated_negative.rs b/psl/psl/tests/attributes/generated_negative.rs index e66ec5a452a7..7b130040cff8 100644 --- a/psl/psl/tests/attributes/generated_negative.rs +++ b/psl/psl/tests/attributes/generated_negative.rs @@ -105,3 +105,18 @@ fn should_fail_on_list_field() { assert!(error.contains("@generated")); assert!(error.contains("list")); } + +#[test] +fn should_fail_on_unsupported_connector() { + let dml = indoc! {r#" + model User { + id Int @id + calc Int? @generated("id * 2") + } + "#}; + + let error = parse_unwrap_err(&with_header(dml, Provider::Mysql, &["generatedColumns"])); + + assert!(error.contains("@generated")); + assert!(error.contains("not supported")); +} From f1f6b215595013d6e168ec5b6c967980c9a9633c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 27 Mar 2026 19:29:13 -0300 Subject: [PATCH 07/11] fix: improve error spans and attribute name formatting Use @generated (with @ prefix) and span_for_attribute("generated") for validation errors, matching Prisma's convention for error highlighting. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../validation_pipeline/validations/fields.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs b/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs index 287d13cce99e..df511dbb4d60 100644 --- a/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs +++ b/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs @@ -427,19 +427,24 @@ pub(super) fn validate_generated_column(field: ScalarFieldWalker<'_>, ctx: &mut return; } + let span = field + .ast_field() + .span_for_attribute("generated") + .unwrap_or_else(|| field.ast_field().span()); + if !ctx.preview_features.contains(crate::PreviewFeature::GeneratedColumns) { ctx.push_error(DatamodelError::new_attribute_validation_error( "Generated columns are a preview feature. Add \"generatedColumns\" to previewFeatures in your generator block.", - "generated", - field.ast_field().span(), + "@generated", + span, )); } if !matches!(ctx.connector.flavour(), Flavour::Postgres) { ctx.push_error(DatamodelError::new_attribute_validation_error( "Generated columns are not supported by the current connector. Currently only PostgreSQL supports @generated.", - "generated", - field.ast_field().span(), + "@generated", + span, )); } } From 6e3a9eefcb00ec8649ed07140c7f33c17538cabe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 27 Mar 2026 19:46:33 -0300 Subject: [PATCH 08/11] fix: validate @generated against both @id and @@id Move primary key validation for generated columns to model-level (after @@id is processed) so it catches both single-field @id and compound @@id. Add negative test for @@id([field]) case. Co-Authored-By: Claude Opus 4.6 (1M context) --- psl/parser-database/src/attributes.rs | 30 +++++++++++-------- .../tests/attributes/generated_negative.rs | 17 +++++++++++ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/psl/parser-database/src/attributes.rs b/psl/parser-database/src/attributes.rs index a1e96e301479..d8a21ec3183c 100644 --- a/psl/parser-database/src/attributes.rs +++ b/psl/parser-database/src/attributes.rs @@ -188,6 +188,21 @@ fn resolve_model_attributes(model_id: crate::ModelId, ctx: &mut Context<'_>) { id::validate_id_field_arities(model_id, &model_attributes, ctx); shard_key::validate_shard_key_field_arities(model_id, &model_attributes, ctx); + // Validate that generated columns are not part of the primary key (@@id or @id). + if let Some(pk) = &model_attributes.primary_key { + for pk_field in &pk.fields { + let sfid = pk_field.path.root(); + if ctx.types[sfid].is_generated_column { + let ast_field = &ctx.asts[ctx.types[sfid].model_id][ctx.types[sfid].field_id]; + ctx.push_error(DatamodelError::new_attribute_validation_error( + "Fields that are marked with @generated cannot be part of the primary key (@id or @@id).", + "generated", + ast_field.span(), + )); + } + } + } + ctx.types.model_attributes.insert(model_id, model_attributes); ctx.validate_visited_attributes(); } @@ -317,19 +332,8 @@ fn visit_scalar_field_attributes( ast_field.span(), )); } - let is_id_field = model_data - .primary_key - .as_ref() - .and_then(|pk| pk.source_field) - .map(|pk_field_id| pk_field_id == field_id) - .unwrap_or(false); - if is_id_field { - ctx.push_error(DatamodelError::new_attribute_validation_error( - "Fields that are marked with @generated cannot be the primary key (@id).", - "generated", - ast_field.span(), - )); - } + // Note: @generated + primary key (@id / @@id) is validated at model level + // in resolve_model_attributes(), after @@id has been processed. } ctx.validate_visited_attributes(); diff --git a/psl/psl/tests/attributes/generated_negative.rs b/psl/psl/tests/attributes/generated_negative.rs index 7b130040cff8..f27236c592cd 100644 --- a/psl/psl/tests/attributes/generated_negative.rs +++ b/psl/psl/tests/attributes/generated_negative.rs @@ -91,6 +91,23 @@ fn should_fail_when_combined_with_id() { assert!(error.contains("@id")); } +#[test] +fn should_fail_when_part_of_compound_id() { + let dml = indoc! {r#" + model User { + a Int + b Int @generated("a * 2") + + @@id([a, b]) + } + "#}; + + let error = parse_unwrap_err(&with_header(dml, Provider::Postgres, &["generatedColumns"])); + + assert!(error.contains("@generated")); + assert!(error.contains("@@id")); +} + #[test] fn should_fail_on_list_field() { let dml = indoc! {r#" From 6be80c223257d09e36e4e755cb64cc12377a99a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 27 Mar 2026 19:57:20 -0300 Subject: [PATCH 09/11] fix: use @generated prefix consistently in all error messages Co-Authored-By: Claude Opus 4.6 (1M context) --- psl/parser-database/src/attributes.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/psl/parser-database/src/attributes.rs b/psl/parser-database/src/attributes.rs index d8a21ec3183c..5d5ad0fa2a91 100644 --- a/psl/parser-database/src/attributes.rs +++ b/psl/parser-database/src/attributes.rs @@ -196,7 +196,7 @@ fn resolve_model_attributes(model_id: crate::ModelId, ctx: &mut Context<'_>) { let ast_field = &ctx.asts[ctx.types[sfid].model_id][ctx.types[sfid].field_id]; ctx.push_error(DatamodelError::new_attribute_validation_error( "Fields that are marked with @generated cannot be part of the primary key (@id or @@id).", - "generated", + "@generated", ast_field.span(), )); } @@ -321,14 +321,14 @@ fn visit_scalar_field_attributes( if ctx.types[scalar_field_id].default.is_some() { ctx.push_error(DatamodelError::new_attribute_validation_error( "Fields that are marked with @generated cannot have a @default value. The column value is always computed from the generation expression.", - "generated", + "@generated", ast_field.span(), )); } if ctx.types[scalar_field_id].is_updated_at { ctx.push_error(DatamodelError::new_attribute_validation_error( "Fields that are marked with @generated cannot also be marked with @updatedAt.", - "generated", + "@generated", ast_field.span(), )); } From 3a924ee5f7867603c989b14b7445e383f7437803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 27 Mar 2026 22:05:57 -0300 Subject: [PATCH 10/11] fix: exclude generated columns from createMany input Add is_generated_column() check to filter_create_many_fields(), completing coverage across all mutation input types: - createOne (checked + unchecked) - createMany - updateOne (checked + unchecked) - updateMany (reuses updateOne filters) - upsert (reuses create/update inputs) Co-Authored-By: Claude Opus 4.6 (1M context) --- query-compiler/schema/src/build/mutations/create_many.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/query-compiler/schema/src/build/mutations/create_many.rs b/query-compiler/schema/src/build/mutations/create_many.rs index 0409683289de..4dc830144738 100644 --- a/query-compiler/schema/src/build/mutations/create_many.rs +++ b/query-compiler/schema/src/build/mutations/create_many.rs @@ -129,7 +129,7 @@ fn filter_create_many_fields<'a>( // 2) Only allow writing autoincrement fields if the connector supports it. fields.filter_all(move |field| match field { ModelField::Scalar(sf) => { - if linking_fields.contains(sf) { + if sf.is_generated_column() || linking_fields.contains(sf) { false } else if sf.is_autoincrement() { ctx.has_capability(ConnectorCapability::CreateManyWriteableAutoIncId) From d7f80cfc669545b207f16080377fc05b42468f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 27 Mar 2026 22:09:32 -0300 Subject: [PATCH 11/11] fix: test all mutation input types and guard against empty connector - Expand DMMF test to verify statusPriority is excluded from all 6 writable input types (CreateInput, UncheckedCreateInput, CreateManyInput, UpdateInput, UncheckedUpdateInput, UpdateManyMutationInput) - Guard connector flavour check with datasource.is_some() to avoid panic on the empty connector (which has unreachable!() in flavour()) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../validation_pipeline/validations/fields.rs | 2 +- query-compiler/dmmf/src/tests/tests.rs | 40 ++++++++++--------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs b/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs index df511dbb4d60..b38ca74524c1 100644 --- a/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs +++ b/psl/psl-core/src/validate/validation_pipeline/validations/fields.rs @@ -440,7 +440,7 @@ pub(super) fn validate_generated_column(field: ScalarFieldWalker<'_>, ctx: &mut )); } - if !matches!(ctx.connector.flavour(), Flavour::Postgres) { + if ctx.datasource.is_some() && !matches!(ctx.connector.flavour(), Flavour::Postgres) { ctx.push_error(DatamodelError::new_attribute_validation_error( "Generated columns are not supported by the current connector. Currently only PostgreSQL supports @generated.", "@generated", diff --git a/query-compiler/dmmf/src/tests/tests.rs b/query-compiler/dmmf/src/tests/tests.rs index 13860ca520e0..14dabab75a57 100644 --- a/query-compiler/dmmf/src/tests/tests.rs +++ b/query-compiler/dmmf/src/tests/tests.rs @@ -172,6 +172,8 @@ fn dmmf_rendering() { fn generated_column_is_read_only_and_excluded_from_inputs() { let dmmf = dmmf_from_schema(include_str!("./test-schemas/postgres_generated_column.prisma")); + let prisma_types = dmmf.schema.input_object_types.get("prisma").unwrap(); + // Find the Session model let session = dmmf.data_model.models.iter().find(|m| m.name == "Session").unwrap(); @@ -181,27 +183,27 @@ fn generated_column_is_read_only_and_excluded_from_inputs() { assert_eq!(field.is_generated, Some(true), "Generated column should have isGenerated=true"); assert!(!field.is_required, "Generated column should be optional"); - // statusPriority should NOT be in CreateInput - let create_input = dmmf.schema.input_object_types.get("prisma").unwrap() - .iter() - .find(|t| t.name == "SessionCreateInput") - .unwrap(); - assert!( - !create_input.fields.iter().any(|f| f.name == "statusPriority"), - "Generated column should not appear in SessionCreateInput" - ); - - // statusPriority should NOT be in UpdateInput - let update_input = dmmf.schema.input_object_types.get("prisma").unwrap() - .iter() - .find(|t| t.name == "SessionUpdateInput") - .unwrap(); - assert!( - !update_input.fields.iter().any(|f| f.name == "statusPriority"), - "Generated column should not appear in SessionUpdateInput" - ); + // statusPriority should NOT appear in any writable input type. + let writable_input_names = [ + "SessionCreateInput", + "SessionUncheckedCreateInput", + "SessionCreateManyInput", + "SessionUpdateInput", + "SessionUncheckedUpdateInput", + "SessionUpdateManyMutationInput", + ]; + + for input_name in &writable_input_names { + if let Some(input_type) = prisma_types.iter().find(|t| t.name == *input_name) { + assert!( + !input_type.fields.iter().any(|f| f.name == "statusPriority"), + "Generated column should not appear in {input_name}" + ); + } + } // Non-generated fields should still be in CreateInput + let create_input = prisma_types.iter().find(|t| t.name == "SessionCreateInput").unwrap(); assert!( create_input.fields.iter().any(|f| f.name == "status"), "Non-generated field 'status' should be in CreateInput"