use std::sync::Arc;
use topcoat::context::Cx;
use super::{Mounted, Resource, is_mounted, is_restricted_model, model_rules, restricts_any};
use crate::{
DeclarationError, DeclarationErrorKind, FieldAccessFault, Site,
column::{BoxColumn, Related},
form::FormField,
schema::{FormMask, Schema},
toasty_compat::{model, pk},
};
type Rule = Arc<dyn Fn(&Cx) -> bool + Send + Sync>;
#[derive(Clone, Default)]
pub struct FieldAccess {
view: Option<Rule>,
edit: Option<Rule>,
}
impl FieldAccess {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn view(mut self, rule: impl Fn(&Cx) -> bool + Send + Sync + 'static) -> Self {
self.view = Some(Arc::new(rule));
self
}
#[must_use]
pub fn edit(mut self, rule: impl Fn(&Cx) -> bool + Send + Sync + 'static) -> Self {
self.edit = Some(Arc::new(rule));
self
}
fn views(&self, cx: &Cx) -> bool {
self.view.as_ref().is_none_or(|rule| rule(cx))
}
fn edits(&self, cx: &Cx) -> bool {
self.views(cx) && self.edit.as_ref().is_none_or(|rule| rule(cx))
}
}
impl std::fmt::Debug for FieldAccess {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FieldAccess")
.field("view", &self.view.is_some())
.field("edit", &self.edit.is_some())
.finish()
}
}
#[derive(Debug, Clone)]
pub(crate) struct FieldRule {
field: Result<String, (String, DeclarationErrorKind)>,
covers: Vec<String>,
inverse: bool,
access: FieldAccess,
}
impl FieldRule {
pub(crate) fn new<M, T>(path: &toasty::stmt::Path<M, T>, access: FieldAccess) -> Self
where
M: toasty::schema::Model,
{
let path = model::ModelPath::of(path);
let field =
model::field::<M>(&path).map_err(|error| (format!("{:?}", path.steps()), error));
let covers = match &field {
Ok(field) => paired::<M>(field),
Err(_) => Vec::new(),
};
let inverse = field
.as_ref()
.is_ok_and(|field| model::is_inverse::<M>(&field.name));
Self {
field: field.map(|field| field.name),
covers,
inverse,
access,
}
}
pub(crate) fn restricts_view(&self) -> bool {
self.access.view.is_some()
}
}
fn paired<M: toasty::schema::Model>(field: &model::FieldInfo) -> Vec<String> {
let mut covers = vec![field.name.clone()];
for relation in model::belongs_tos::<M>() {
let pairs = relation.name == field.name || relation.foreign_keys.contains(&field.name);
if pairs {
covers.push(relation.name);
covers.extend(relation.foreign_keys);
}
}
covers.sort();
covers.dedup();
covers
}
#[derive(Debug, Clone, Default)]
pub(crate) struct FieldMask {
hidden: Vec<String>,
locked: Vec<String>,
}
impl FieldMask {
pub(crate) fn of(cx: &Cx, rules: &[FieldRule]) -> Self {
let mut mask = Self::default();
for rule in rules {
if !rule.access.views(cx) {
mask.hidden.extend(rule.covers.iter().cloned());
} else if !rule.access.edits(cx) {
mask.locked.extend(rule.covers.iter().cloned());
}
}
mask
}
pub(crate) fn hidden(&self) -> &[String] {
&self.hidden
}
pub(crate) fn form<K>(&self, fields: &[FormField<K>]) -> FormMask {
let keys = |names: &[String]| -> Vec<String> {
fields
.iter()
.filter(|field| names.iter().any(|name| name == field.name))
.flat_map(|field| field.keys.iter().cloned())
.collect()
};
FormMask::new(keys(&self.hidden), keys(&self.locked))
}
}
pub(crate) fn check<R: Resource>(
cx: &Cx,
declared: &Mounted<R>,
errors: &mut Vec<DeclarationError>,
) {
let model = model_rules(
cx,
<R::Model as toasty::schema::Model>::id(),
&declared.field_rules,
);
let key_fields = pk::key_names::<R::Model>();
let mut seen = Vec::new();
for rule in &declared.field_rules {
let site = |field: &str| Site::FieldAccess(field.to_string());
let fault = |fault| DeclarationErrorKind::FieldAccess { fault };
let field = match &rule.field {
Ok(field) => field,
Err((placeholder, error)) => {
errors.push(DeclarationError::of::<R>(site(placeholder), error.clone()));
continue;
}
};
if seen.contains(&field) {
errors.push(DeclarationError::of::<R>(
site(field),
fault(FieldAccessFault::Duplicate),
));
}
seen.push(field);
if rule.inverse {
errors.push(DeclarationError::of::<R>(
site(field),
fault(FieldAccessFault::Inverse),
));
continue;
}
let trash = declared.soft_delete.as_ref();
if trash.is_some_and(|trash| trash.field().is_ok_and(|(_, name)| name == field)) {
errors.push(DeclarationError::of::<R>(
site(field),
fault(FieldAccessFault::SoftDelete),
));
}
if rule.restricts_view() && key_fields.contains(field) {
errors.push(DeclarationError::of::<R>(
site(field),
fault(FieldAccessFault::RecordKey),
));
}
}
let hiding = model.iter().filter(|rule| rule.restricts_view());
let title_hidden = declared.record_title_field().is_some_and(|title| {
hiding
.clone()
.any(|rule| rule.covers.iter().any(|covered| covered == title))
});
if title_hidden {
errors.push(DeclarationError::of::<R>(
Site::RecordTitle,
DeclarationErrorKind::FieldAccess {
fault: FieldAccessFault::RecordTitle,
},
));
}
let watchers = declared.form.watchers();
let watched = hiding.clone().any(|rule| {
let keys = FieldMask {
hidden: rule.covers.clone(),
locked: Vec::new(),
}
.form(&declared.fields);
watchers.iter().any(|(watched, driven)| {
keys.hides(watched) && driven.as_ref().is_none_or(|driven| !keys.hides(driven))
})
});
if watched {
errors.push(DeclarationError::of::<R>(
Site::Form,
DeclarationErrorKind::FieldAccess {
fault: FieldAccessFault::Watched,
},
));
}
if model.is_empty() {
return;
}
let undeclared = |what, name: &str| DeclarationErrorKind::FieldAccess {
fault: FieldAccessFault::UndeclaredReads {
what,
name: name.to_string(),
},
};
let mut faults = Vec::new();
for column in declared.table.declared_columns() {
if column.reads().is_none() {
faults.push((Site::Table, undeclared("column", column.name())));
}
}
for filter in declared.table.declared_filters() {
if filter.reads().is_none() {
faults.push((Site::Table, undeclared("filter", filter.name())));
}
}
for column in declared.detail.undeclared_reads() {
faults.push((Site::Detail, undeclared("column", column)));
}
errors.extend(
faults
.into_iter()
.map(|(site, kind)| DeclarationError::of::<R>(site, kind)),
);
}
pub(crate) fn check_columns<R: Resource>(
cx: &Cx,
declared: &Mounted<R>,
errors: &mut Vec<DeclarationError>,
) {
if !restricts_any(cx) {
return;
}
let table: Vec<_> = declared.table.declared_columns().iter().collect();
check_page::<R>(cx, Site::Table, &table, errors);
check_page::<R>(cx, Site::Detail, &declared.detail.columns(), errors);
}
fn check_page<R: Resource>(
cx: &Cx,
site: Site,
columns: &[&BoxColumn<R::Model>],
errors: &mut Vec<DeclarationError>,
) {
let loads: Vec<Option<Vec<(model::ModelId, String)>>> = columns
.iter()
.map(|column| model::included_models::<R::Model>(&column.includes().into_vec()))
.collect();
let unresolved = loads.iter().any(Option::is_none);
let page_restricted = loads
.iter()
.flatten()
.flatten()
.find(|(model, _)| is_restricted_model(cx, *model))
.map(|(_, name)| name.clone());
for (column, own) in columns.iter().zip(&loads) {
let name = column.name().to_string();
let fault = match column.related() {
Related::None => continue,
Related::Source(source) if is_mounted(cx, source) => continue,
Related::Row => match (unresolved, &page_restricted) {
(true, _) => FieldAccessFault::UnresolvedRelation { name },
(false, Some(model)) => FieldAccessFault::UnmaskedRelation {
name,
model: model.clone(),
},
(false, None) => continue,
},
Related::Source(_) | Related::Unmasked => match own {
None => FieldAccessFault::UnresolvedRelation { name },
Some(models) => {
match models
.iter()
.find(|(model, _)| is_restricted_model(cx, *model))
{
Some((_, model)) => FieldAccessFault::UnmaskedRelation {
name,
model: model.clone(),
},
None => continue,
}
}
},
};
errors.push(DeclarationError::of::<R>(
site.clone(),
DeclarationErrorKind::FieldAccess { fault },
));
}
}
pub(crate) fn restricts_field(cx: &Cx, model: model::ModelId, field: &str) -> bool {
model_rules(cx, model, &[])
.iter()
.any(|rule| rule.restricts_view() && rule.covers.iter().any(|covered| covered == field))
}
pub(crate) fn check_choices<T: 'static, F>(
cx: &Cx,
schema: &Schema<F>,
site: Site,
errors: &mut Vec<DeclarationError>,
) {
let mounted = |source| is_mounted(cx, source);
let restricted = |model: model::ModelId| is_restricted_model(cx, model);
for field in schema.fields() {
let Some(choice) = field.as_choice() else {
continue;
};
let name = field.name();
if let Some(model) = choice.unmasked_model(mounted, restricted) {
errors.push(DeclarationError::of::<T>(
site.clone(),
DeclarationErrorKind::FieldAccess {
fault: FieldAccessFault::UnmaskedRelation {
name: name.to_string(),
model: model.to_string(),
},
},
));
}
if let Some(column) = choice.narrowed_by(|model, column| restricts_field(cx, model, column))
{
errors.push(DeclarationError::of::<T>(
site.clone(),
DeclarationErrorKind::FieldAccess {
fault: FieldAccessFault::NarrowedBy {
field: column.to_string(),
},
},
));
}
}
}
pub(crate) fn check_filters<R: Resource>(
cx: &Cx,
declared: &Mounted<R>,
errors: &mut Vec<DeclarationError>,
) {
if !restricts_any(cx) {
return;
}
let mounted = |source| is_mounted(cx, source);
let restricted = |model: model::ModelId| is_restricted_model(cx, model);
for filter in declared.table.declared_filters() {
let name = filter.name().to_string();
let fault = if let Some(model) = filter.unmasked_model(&mounted, &restricted) {
Some(FieldAccessFault::UnmaskedRelation {
name,
model: model.to_string(),
})
} else {
match filter.reads() {
None => None,
Some(reads) => match model::read_relations::<R::Model>(reads.fields()) {
Some(models) if !reads.is_unresolved() => models
.into_iter()
.find(|(model, _)| is_restricted_model(cx, *model))
.map(|(_, model)| FieldAccessFault::UnmaskedRelation { name, model }),
_ => Some(FieldAccessFault::UnresolvedRelation { name }),
},
}
};
if let Some(fault) = fault {
errors.push(DeclarationError::of::<R>(
Site::Table,
DeclarationErrorKind::FieldAccess { fault },
));
}
}
}