Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion libs/sql-ddl/src/postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ pub struct Column<'a> {
pub name: Cow<'a, str>,
pub r#type: Cow<'a, str>,
pub default: Option<Cow<'a, str>>,
/// 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<Cow<'a, str>>,
}

impl Display for Column<'_> {
Expand All @@ -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)?;
}
Expand Down Expand Up @@ -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'"#);
}
}
55 changes: 55 additions & 0 deletions psl/parser-database/src/attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
}
Comment thread
bilby91 marked this conversation as resolved.

ctx.validate_visited_attributes();
}

Expand Down
5 changes: 5 additions & 0 deletions psl/parser-database/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub(crate) default: Option<DefaultAttribute>,
/// @map
pub(crate) mapped_name: Option<StringId>,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions psl/parser-database/src/walkers/scalar_field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
Expand Down
4 changes: 3 additions & 1 deletion psl/psl-core/src/common/preview_features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ features!(
FullTextIndex,
FullTextSearch,
FullTextSearchPostgres,
GeneratedColumns,
GroupBy,
ImprovedQueryRaw,
InteractiveTransactions,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion psl/psl-core/src/validate/validation_pipeline/validations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 !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,
));
}
Comment thread
bilby91 marked this conversation as resolved.
}
139 changes: 139 additions & 0 deletions psl/psl/tests/attributes/generated_negative.rs
Original file line number Diff line number Diff line change
@@ -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"));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[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"));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[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"));
}
Loading
Loading