use topcoat::context::Cx;
use crate::{
ActionInputFault, DeclarationError, DeclarationErrorKind, ManyToManyFault, ModelColumns, Site,
TenancyFault,
form::RecordForm,
resource::{InputSpec, Mounted, Resource, is_tenant_model, require_mounted},
toasty_compat::{
join::JoinTable,
model::{self, AppSchema},
},
};
pub(crate) type ResourceCheck = fn(&Cx, &mut Vec<DeclarationError>);
pub(crate) fn check_resource<R: Resource>(cx: &Cx, errors: &mut Vec<DeclarationError>) {
let Ok(declared) = require_mounted::<R>(cx) else {
return;
};
let tenancy = &declared.tenancy;
if tenancy.is_undeclared() {
check_undeclared_tenancy::<R>(cx, errors);
}
if let Some(Err(kind)) = tenancy.column_field() {
errors.push(DeclarationError::of::<R>(Site::Tenancy, kind));
}
if tenancy.via_is_single() == Some(true) {
errors.push(tenancy_error::<R>(TenancyFault::ViaOwnColumn));
}
if let Some(Err(kind)) = declared.soft_delete.as_ref().map(|soft| soft.field()) {
errors.push(DeclarationError::of::<R>(Site::SoftDelete, kind.clone()));
}
let mut form_errors = declared.form.declaration_errors();
form_errors.extend(declared.form.empty_choices());
let form_is_sound = form_errors.is_empty();
for (site, kinds) in [
(Site::Table, declared.table.declaration_errors()),
(Site::Form, form_errors),
(Site::Detail, declared.detail.declaration_errors()),
] {
errors.extend(
kinds
.into_iter()
.map(|kind| DeclarationError::of::<R>(site.clone(), kind)),
);
}
for (site, kinds) in [
(Site::Table, declared.table.unavailable_sources(cx)),
(Site::Detail, declared.detail.unavailable_sources(cx)),
] {
errors.extend(
kinds
.into_iter()
.map(|kind| DeclarationError::of::<R>(site.clone(), kind)),
);
}
super::field_access::check(cx, &declared, errors);
super::field_access::check_columns(cx, &declared, errors);
super::field_access::check_filters::<R>(cx, &declared, errors);
super::field_access::check_choices::<R, _>(cx, &declared.form, Site::Form, errors);
check_actions(cx, &declared, errors);
if declared.imports && !declared.creates {
errors.push(DeclarationError::of::<R>(
Site::Registration,
DeclarationErrorKind::ImportWithoutCreate,
));
}
check_form_declaration(cx, &declared, form_is_sound, errors);
}
fn check_undeclared_tenancy<R: Resource>(cx: &Cx, errors: &mut Vec<DeclarationError>) {
let columns = <R::Model as ModelColumns>::TENANT_COLUMNS;
if !columns.is_empty() {
errors.push(tenancy_error::<R>(TenancyFault::Undeclared {
model: model_name::<R>(),
columns: columns.to_vec(),
}));
}
for parent in model::belongs_tos::<R::Model>() {
if is_tenant_model(cx, parent.target) {
errors.push(tenancy_error::<R>(TenancyFault::UndeclaredChild {
model: model_name::<R>(),
relation: parent.name,
}));
}
}
}
fn model_name<R: Resource>() -> &'static str {
let name = std::any::type_name::<R::Model>();
name.rsplit("::").next().unwrap_or(name)
}
fn tenancy_error<R: Resource>(fault: TenancyFault) -> DeclarationError {
DeclarationError::of::<R>(Site::Tenancy, DeclarationErrorKind::Tenancy { fault })
}
fn check_actions<R: Resource>(cx: &Cx, declared: &Mounted<R>, errors: &mut Vec<DeclarationError>) {
let actions = declared
.actions
.entries()
.iter()
.map(|action| (action.name, action.input))
.chain(
declared
.header_actions
.entries()
.iter()
.map(|action| (action.name, action.input)),
);
check_inputs::<R>(cx, actions, errors);
}
pub(crate) fn check_inputs<T: 'static>(
cx: &Cx,
actions: impl Iterator<Item = (&'static str, InputSpec)>,
errors: &mut Vec<DeclarationError>,
) {
let mut seen = std::collections::HashSet::new();
for (name, spec) in actions {
super::field_access::check_choices::<T, _>(
cx,
&(spec.schema)(),
Site::Registration,
errors,
);
if !seen.insert(name) {
errors.push(DeclarationError::of::<T>(
Site::Registration,
DeclarationErrorKind::DuplicateAction { name },
));
}
let input = (spec.schema)();
let mut kinds = input.declaration_errors();
kinds.extend(input.empty_choices());
let mut faults: Vec<ActionInputFault> = input
.fields()
.filter_map(|field| {
let name = field.name().to_string();
if crate::resource::RESERVED_KEYS.contains(&name.as_str()) {
Some(ActionInputFault::ReservedField(name))
} else if field.is_file() {
Some(ActionInputFault::FileField(name))
} else if field.parent_key().is_some() {
Some(ActionInputFault::DependentChoice(name))
} else {
None
}
})
.collect();
if !spec.takes_input && (spec.parse)(cx, &crate::form::FormValues::new()).is_err() {
faults.push(ActionInputFault::RefusesEmpty);
}
kinds.extend(
faults
.into_iter()
.map(|fault| DeclarationErrorKind::ActionInput {
action: name,
fault,
}),
);
errors.extend(
kinds
.into_iter()
.map(|kind| DeclarationError::of::<T>(Site::Registration, kind)),
);
}
}
fn form_error<R: Resource>(kind: DeclarationErrorKind) -> DeclarationError {
DeclarationError::of::<R>(Site::Form, kind)
}
fn check_form_declaration<R: Resource>(
cx: &Cx,
declared: &Mounted<R>,
form_is_sound: bool,
errors: &mut Vec<DeclarationError>,
) {
if !form_is_sound {
return;
}
if <R::Form as RecordForm>::HAS_FORM {
check_form_inner(cx, declared, errors);
}
}
fn check_form_inner<R: Resource>(
cx: &Cx,
declared: &Mounted<R>,
errors: &mut Vec<DeclarationError>,
) {
let (fields, form) = (declared.fields.as_slice(), &*declared.form);
for field in fields {
for key in &field.keys {
if !form.fields().any(|control| control.name() == key) {
errors.push(form_error::<R>(DeclarationErrorKind::MissingControl {
field: field.name.to_string(),
key: key.clone(),
}));
}
}
}
if let Some(column) = tenant_column(declared)
&& let Some(field) = fields.iter().find(|field| field.keys.contains(&column))
{
errors.push(form_error::<R>(
DeclarationErrorKind::FormClaimsTenantColumn {
field: field.name.to_string(),
column,
},
));
}
if let Some(Ok((_, column))) = declared.soft_delete.as_ref().map(|soft| soft.field())
&& let Some(field) = fields.iter().find(|field| field.keys.contains(column))
{
errors.push(form_error::<R>(
DeclarationErrorKind::FormClaimsSoftDeleteColumn {
field: field.name.to_string(),
column: column.clone(),
},
));
}
for field in form.fields().filter(|field| {
[crate::csrf::FIELD_NAME, crate::form::VERSION_FIELD].contains(&field.name())
}) {
errors.push(form_error::<R>(DeclarationErrorKind::ReservedFormField {
field: field.name().to_string(),
}));
}
for field in form.fields().filter(|field| {
field
.as_choice()
.is_some_and(|choice| choice.has_composite_source())
}) {
errors.push(form_error::<R>(DeclarationErrorKind::CompositeKeyChoice {
field: field.name().to_string(),
}));
}
for field in form.fields() {
if let Some(source) = field
.as_choice()
.and_then(|choice| choice.unavailable_source(cx))
{
errors.push(form_error::<R>(
DeclarationErrorKind::UnregisteredOptionSource {
field: field.name().to_string(),
source,
},
));
}
}
if declared.tenancy.via_is_single() == Some(false) {
check_via_foreign_keys(cx, declared, errors);
}
check_many_to_many(cx, declared, errors);
if declared.creates {
check_create_columns(declared, errors);
}
let columns = model::fields::<R::Model>();
for field in form.fields().filter(|field| field.is_unique()) {
let name = field.name();
let backed = columns
.iter()
.any(|column| column.name == name && column.unique);
if !backed {
errors.push(form_error::<R>(DeclarationErrorKind::UniqueWithoutIndex {
field: name.to_string(),
}));
}
if !field.is_required() && !field.is_nullable() {
errors.push(form_error::<R>(DeclarationErrorKind::OptionalUnique {
field: name.to_string(),
}));
}
}
}
fn check_many_to_many<R: Resource>(
cx: &Cx,
declared: &Mounted<R>,
errors: &mut Vec<DeclarationError>,
) {
let Some(schema) = AppSchema::of(cx) else {
return;
};
for field in declared.form.fields() {
let name = field.name();
let choice = field
.as_choice()
.filter(|choice| choice.is_multiple() && choice.is_relationship());
let fault = if model::is_via::<R::Model>(name) {
match (choice, JoinTable::of::<R::Model>(&schema, name)) {
(_, Err(fault)) => Some(fault),
(None, Ok(_)) => Some(ManyToManyFault::NotMultipleChoice),
(Some(choice), Ok(join)) => (choice.source_model() != Some(join.target_model()))
.then_some(ManyToManyFault::OtherSource),
}
} else {
field
.as_choice()
.is_some_and(|choice| choice.is_multiple())
.then_some(ManyToManyFault::NoJoin)
};
if let Some(fault) = fault {
errors.push(form_error::<R>(DeclarationErrorKind::ManyToMany {
field: name.to_string(),
fault,
}));
}
}
}
fn tenant_column<R: Resource>(declared: &Mounted<R>) -> Option<String> {
declared
.tenancy
.column_field()
.and_then(Result::ok)
.map(|field| field.name.clone())
}
fn check_create_columns<R: Resource>(declared: &Mounted<R>, errors: &mut Vec<DeclarationError>) {
let fields = &declared.fields;
let mut create_columns = Vec::new();
for column in &declared.create_columns {
match column {
Ok(name) => create_columns.push(name.as_str()),
Err(kind) => errors.push(DeclarationError::of::<R>(Site::Registration, kind.clone())),
}
}
let prefilled = model::prefilled_fields::<R::Model>();
let tenant = tenant_column(declared);
if let Some(column) = &tenant
&& create_columns.contains(&column.as_str())
{
errors.push(DeclarationError::of::<R>(
Site::Registration,
DeclarationErrorKind::CreateColumnsNameTenant {
column: column.clone(),
},
));
}
for field in &model::fields::<R::Model>() {
let name = field.name.as_str();
let filled = field.nullable
|| field.relation
|| prefilled.get(field.index).copied().unwrap_or(false)
|| tenant.as_deref() == Some(name)
|| fields.iter().any(|claim| claim.name == name)
|| create_columns.contains(&name);
if !filled {
errors.push(form_error::<R>(DeclarationErrorKind::UnwrittenColumn {
column: name.to_string(),
}));
}
}
}
fn check_via_foreign_keys<R: Resource>(
cx: &Cx,
declared: &Mounted<R>,
errors: &mut Vec<DeclarationError>,
) {
let form = &declared.form;
let Some(model::BelongsTo {
name: relation,
target: parent,
foreign_keys: keys,
}) = declared
.tenancy
.via_hop()
.and_then(model::belongs_to::<R::Model>)
else {
errors.push(tenancy_error::<R>(TenancyFault::ViaWithoutBelongsTo));
return;
};
let unguarded: Vec<String> = keys
.into_iter()
.filter(|key| {
!form.fields().any(|field| {
field.name() == key
&& field
.as_choice()
.and_then(|choice| choice.tenant_scoped_model(cx))
== Some(parent)
})
})
.collect();
if !unguarded.is_empty() {
errors.push(form_error::<R>(DeclarationErrorKind::Tenancy {
fault: TenancyFault::UnguardedForeignKey {
relation,
keys: unguarded,
},
}));
}
}