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..5d5ad0fa2a91 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(); } @@ -249,6 +264,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 +316,26 @@ 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(), + )); + } + // 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/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-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..b38ca74524c1 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,32 @@ pub(crate) fn clustering_can_be_defined_only_once(pk: PrimaryKeyWalker<'_>, ctx: return; } } + +pub(super) fn validate_generated_column(field: ScalarFieldWalker<'_>, ctx: &mut Context<'_>) { + use crate::datamodel_connector::Flavour; + + if !field.is_generated_column() { + 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", + span, + )); + } + + 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", + span, + )); + } +} diff --git a/psl/psl/tests/attributes/generated_negative.rs b/psl/psl/tests/attributes/generated_negative.rs new file mode 100644 index 000000000000..f27236c592cd --- /dev/null +++ b/psl/psl/tests/attributes/generated_negative.rs @@ -0,0 +1,139 @@ +use crate::{Provider, common::*, with_header}; + +#[test] +fn should_fail_without_preview_feature() { + let dml = indoc! {r#" + 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(&with_header(dml, Provider::Postgres, &["generatedColumns"])); + + assert!(error.contains("@generated")); + assert!(error.contains("Argument")); +} + +#[test] +fn should_fail_with_non_string_argument() { + let dml = indoc! {r#" + model User { + id Int @id + calc Int @generated(42) + } + "#}; + + let error = parse_unwrap_err(&with_header(dml, Provider::Postgres, &["generatedColumns"])); + + assert!(error.contains("@generated")); + assert!(error.contains("string")); +} + +#[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(&with_header(dml, Provider::Postgres, &["generatedColumns"])); + + 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(&with_header(dml, Provider::Postgres, &["generatedColumns"])); + + 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(&with_header(dml, Provider::Postgres, &["generatedColumns"])); + + assert!(error.contains("@generated")); + 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#" + model User { + id Int @id + tags Int[] @generated("ARRAY[1,2,3]") + } + "#}; + + let error = parse_unwrap_err(&with_header(dml, Provider::Postgres, &["generatedColumns"])); + + 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")); +} diff --git a/psl/psl/tests/attributes/generated_positive.rs b/psl/psl/tests/attributes/generated_positive.rs new file mode 100644 index 000000000000..1f2fd3370668 --- /dev/null +++ b/psl/psl/tests/attributes/generated_positive.rs @@ -0,0 +1,60 @@ +use psl::parser_database::ScalarType; + +use crate::{Provider, common::*, with_header}; + +#[test] +fn should_accept_generated_attribute_on_int_field() { + let dml = indoc! {r#" + 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(with_header(dml, Provider::Postgres, &["generatedColumns"])).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#" + model User { + id Int @id + first String + last String + fullName String? @generated("first || ' ' || last") + } + "#}; + + let schema = psl::parse_schema_without_extensions(with_header(dml, Provider::Postgres, &["generatedColumns"])).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#" + model Item { + id Int @id + price Float + tax Float? @generated("price * 0.2") + } + "#}; + + let schema = psl::parse_schema_without_extensions(with_header(dml, Provider::Postgres, &["generatedColumns"])).unwrap(); + let model = schema.assert_has_model("Item"); + + 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..12bb68fd7db1 100644 --- a/psl/psl/tests/attributes/mod.rs +++ b/psl/psl/tests/attributes/mod.rs @@ -22,5 +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; 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..9990afb85853 --- /dev/null +++ b/query-compiler/dmmf/src/tests/test-schemas/postgres_generated_column.prisma @@ -0,0 +1,11 @@ +generator client { + provider = "prisma-client" + previewFeatures = ["generatedColumns"] +} + +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..14dabab75a57 100644 --- a/query-compiler/dmmf/src/tests/tests.rs +++ b/query-compiler/dmmf/src/tests/tests.rs @@ -167,3 +167,45 @@ 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")); + + 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(); + + // 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 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" + ); +} 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..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 @@ -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`, @@ -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_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) diff --git a/query-compiler/schema/src/build/mutations/create_one.rs b/query-compiler/schema/src/build/mutations/create_one.rs index c1077705babe..086d57601ff8 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. @@ -158,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/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/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..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 @@ -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: field.generation_expression().map(ToOwned::to_owned), }; 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..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,6 +195,14 @@ fn alter_columns(table_differ: &TableDiffer<'_, '_>) -> Vec { let column_id = MigrationPair::new(column_differ.previous.id, column_differ.next.id); match changes.type_change { + // 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 }) + } 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..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,6 +28,19 @@ pub(crate) fn all_changes( changes |= ColumnChange::Autoincrement; } + // 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; + } + _ => {} + } + ColumnChanges { type_change, changes } } @@ -167,6 +180,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 +224,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/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 new file mode 100644 index 000000000000..c0a5a65755f2 --- /dev/null +++ b/schema-engine/sql-migration-tests/tests/single_migration_tests/postgres/generated_column.prisma @@ -0,0 +1,30 @@ +// tags=postgres +// exclude=cockroachdb + +datasource testds { + provider = "postgresql" +} + +generator js { + provider = "prisma-client" + previewFeatures = ["generatedColumns"] +} + +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..3896103d7695 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 @@ -802,7 +811,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, + {attgenerated_clause} FROM information_schema.columns info JOIN pg_attribute att ON att.attname = info.column_name JOIN ( @@ -861,6 +871,10 @@ impl<'a> SqlSchemaDescriber<'a> { let description = col.get_string("description"); + // 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 || matches!(default.as_ref().map(|d| &d.kind), Some(DefaultKind::Sequence(_))) || (self.is_cockroach() @@ -869,6 +883,19 @@ impl<'a> SqlSchemaDescriber<'a> { Some(DefaultKind::DbGenerated(Some(s))) if s == "unique_rowid()" )); + // 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 + }; + + // 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)); @@ -883,6 +910,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)