use std::collections::BTreeSet;
use cratestack_core::{Field, TypeArity};
use crate::diagnostics::{SchemaError, span_error};
#[derive(Clone, Copy)]
pub(super) enum CustomFieldSupport {
Rejected,
TypeOnly,
}
pub(super) fn validate_custom_field_attribute(
field: &Field,
owner_kind: &str,
owner_name: &str,
support: CustomFieldSupport,
) -> Result<(), SchemaError> {
let mut custom_count = 0usize;
for attribute in &field.attributes {
if !attribute.raw.starts_with("@custom") {
continue;
}
custom_count += 1;
if attribute.raw != "@custom" {
return Err(span_error(
format!(
"field `{}` on {} `{}` uses unsupported custom field directive `{}`; use bare `@custom` in this slice",
field.name, owner_kind, owner_name, attribute.raw,
),
field.span,
));
}
if matches!(support, CustomFieldSupport::Rejected) {
return Err(span_error(
format!(
"field `{}` on {} `{}` cannot use `@custom`; resolver-backed custom fields are currently only supported on `type` declarations",
field.name, owner_kind, owner_name,
),
field.span,
));
}
}
if custom_count > 1 {
return Err(span_error(
format!(
"field `{}` on {} `{}` declares `@custom` more than once",
field.name, owner_kind, owner_name,
),
field.span,
));
}
Ok(())
}
pub(super) fn validate_field_policy_attributes(
model_name: &str,
field: &cratestack_core::Field,
) -> Result<(), SchemaError> {
let is_id = field.attributes.iter().any(|a| a.raw.starts_with("@id"));
let has_readonly = field.attributes.iter().any(|a| a.raw == "@readonly");
let has_server_only = field.attributes.iter().any(|a| a.raw == "@server_only");
if is_id && (has_readonly || has_server_only) {
let attr = if has_readonly {
"@readonly"
} else {
"@server_only"
};
return Err(span_error(
format!(
"field `{}.{}` is the primary key and must not declare {attr}",
model_name, field.name,
),
field.span,
));
}
if has_readonly && has_server_only {
return Err(span_error(
format!(
"field `{}.{}` declares both @readonly and @server_only; use @server_only alone",
model_name, field.name,
),
field.span,
));
}
Ok(())
}
pub(super) fn validate_default_dbgenerated_no_args(
model_name: &str,
field: &cratestack_core::Field,
) -> Result<(), SchemaError> {
let Some(attribute) = field
.attributes
.iter()
.find(|attribute| attribute.raw.starts_with("@default("))
else {
return Ok(());
};
let Some(inner) = attribute
.raw
.strip_prefix("@default(")
.and_then(|rest| rest.strip_suffix(')'))
else {
return Ok(());
};
let Some(args) = inner
.trim()
.strip_prefix("dbgenerated(")
.and_then(|rest| rest.strip_suffix(')'))
else {
return Ok(());
};
if !args.trim().is_empty() {
return Err(span_error(
format!(
"field `{}.{}` uses `@default(dbgenerated({}))`; cratestack's \
`dbgenerated()` takes no argument — it is a marker meaning the column \
already has a real Postgres-level default set some other way \
(hand-authored migration SQL, a trigger, `GENERATED ... AS IDENTITY`, \
etc). Remove the argument and use bare `dbgenerated()`.",
model_name,
field.name,
args.trim(),
),
field.span,
));
}
Ok(())
}
pub(super) fn validate_field_list_arity_support(
schema_has_datasource: bool,
model_name: &str,
model_names: &BTreeSet<&str>,
field: &Field,
) -> Result<(), SchemaError> {
if !schema_has_datasource {
return Ok(());
}
if field.ty.arity != TypeArity::List {
return Ok(());
}
if model_names.contains(field.ty.name.as_str()) {
return Ok(());
}
Err(span_error(
format!(
"model `{model_name}` field `{}`: list-valued type `{}[]` is not supported on a \
database-backed model — there is no SQL bind representation for a list-valued \
scalar or enum yet, so this schema would parse and emit valid DDL but panic at \
`include_server_schema!`/`include_embedded_schema!` expansion. Use a single \
`{}` value, model this as a `@relation` to another model, or drop the \
`datasource` block if this schema is only ever consumed via \
`include_client_schema!`.",
field.name, field.ty.name, field.ty.name,
),
field.span,
))
}