use std::sync::Arc;
use crate::php_type::PhpType;
use crate::types::{ClassInfo, ELOQUENT_COLLECTION_FQN};
use crate::util::{short_name, strip_fqn_prefix};
use super::helpers::{camel_to_snake, snake_to_camel};
pub(crate) const RELATION_QUERY_METHODS: &[&str] = &[
"has",
"orHas",
"doesntHave",
"orDoesntHave",
"whereHas",
"orWhereHas",
"withWhereHas",
"whereDoesntHave",
"orWhereDoesntHave",
"whereRelation",
];
const RELATIONSHIP_METHOD_FQN_MAP: &[(&str, &str)] = &[
(
"hasOne",
"Illuminate\\Database\\Eloquent\\Relations\\HasOne",
),
(
"hasMany",
"Illuminate\\Database\\Eloquent\\Relations\\HasMany",
),
(
"belongsTo",
"Illuminate\\Database\\Eloquent\\Relations\\BelongsTo",
),
(
"belongsToMany",
"Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany",
),
(
"morphOne",
"Illuminate\\Database\\Eloquent\\Relations\\MorphOne",
),
(
"morphMany",
"Illuminate\\Database\\Eloquent\\Relations\\MorphMany",
),
(
"morphTo",
"Illuminate\\Database\\Eloquent\\Relations\\MorphTo",
),
(
"morphToMany",
"Illuminate\\Database\\Eloquent\\Relations\\MorphToMany",
),
(
"morphedByMany",
"Illuminate\\Database\\Eloquent\\Relations\\MorphToMany",
),
(
"hasManyThrough",
"Illuminate\\Database\\Eloquent\\Relations\\HasManyThrough",
),
(
"hasOneThrough",
"Illuminate\\Database\\Eloquent\\Relations\\HasOneThrough",
),
];
const SINGULAR_RELATIONSHIPS: &[&str] = &["HasOne", "MorphOne", "BelongsTo", "HasOneThrough"];
const COLLECTION_RELATIONSHIPS: &[&str] = &[
"HasMany",
"MorphMany",
"BelongsToMany",
"HasManyThrough",
"MorphToMany",
];
const MORPH_TO: &str = "MorphTo";
const ELOQUENT_RELATIONS_NS: &str = "Illuminate\\Database\\Eloquent\\Relations\\";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RelationshipKind {
Singular,
Collection,
MorphTo,
}
pub(super) fn classify_relationship_typed(return_type: &PhpType) -> Option<RelationshipKind> {
let base = return_type.base_name()?;
let sname = short_name(base);
if base.contains('\\') && !base.starts_with(ELOQUENT_RELATIONS_NS) {
return None;
}
if SINGULAR_RELATIONSHIPS.contains(&sname) {
return Some(RelationshipKind::Singular);
}
if COLLECTION_RELATIONSHIPS.contains(&sname) {
return Some(RelationshipKind::Collection);
}
if sname == MORPH_TO {
return Some(RelationshipKind::MorphTo);
}
None
}
pub(super) fn extract_related_type_typed(return_type: &PhpType) -> Option<&PhpType> {
if let PhpType::Generic(_, args) = return_type {
let first = args.first()?;
if first.is_empty() {
return None;
}
return Some(first);
}
None
}
fn eloquent_model_type() -> PhpType {
PhpType::Named("Illuminate\\Database\\Eloquent\\Model".to_owned())
}
pub(super) fn build_property_type(
kind: RelationshipKind,
related_type: Option<&PhpType>,
custom_collection: Option<&str>,
) -> Option<PhpType> {
match kind {
RelationshipKind::Singular => related_type.cloned(),
RelationshipKind::Collection => {
let inner = related_type.cloned().unwrap_or_else(eloquent_model_type);
let collection_class = custom_collection.unwrap_or(ELOQUENT_COLLECTION_FQN);
Some(PhpType::Generic(collection_class.to_string(), vec![inner]))
}
RelationshipKind::MorphTo => Some(eloquent_model_type()),
}
}
pub(crate) fn count_property_to_relationship_method(
class: &ClassInfo,
property_name: &str,
) -> Option<String> {
let base = property_name.strip_suffix("_count")?;
if base.is_empty() {
return None;
}
let method_name = snake_to_camel(base);
let method = class.methods.iter().find(|m| m.name == method_name)?;
let return_type = method.return_type.as_ref()?;
if classify_relationship_typed(return_type).is_some() {
Some(method_name)
} else {
None
}
}
pub fn infer_relationship_from_body(body_text: &str) -> Option<PhpType> {
for &(method_name, fqn) in RELATIONSHIP_METHOD_FQN_MAP {
let needle = format!("$this->{method_name}(");
let Some(call_pos) = body_text.find(&needle) else {
continue;
};
if method_name == "morphTo" {
return Some(PhpType::Named(format!("\\{fqn}")));
}
let args_start = call_pos + needle.len();
let after_paren = &body_text[args_start..];
if let Some(class_arg) = extract_class_argument(after_paren) {
return Some(PhpType::Generic(
format!("\\{fqn}"),
vec![PhpType::Named(class_arg)],
));
}
return Some(PhpType::Named(format!("\\{fqn}")));
}
None
}
fn extract_class_argument(after_paren: &str) -> Option<String> {
let end = after_paren.find(')')?;
let args_region = &after_paren[..end];
let first_arg = args_region.split(',').next().unwrap_or(args_region);
let class_pos = first_arg.find("::class")?;
let before = first_arg[..class_pos].trim();
if before.is_empty() {
return None;
}
let name = strip_fqn_prefix(before);
let short_name = short_name(name);
if short_name.is_empty() {
return None;
}
Some(short_name.to_string())
}
pub(super) fn count_property_name(method_name: &str) -> String {
format!("{}_count", camel_to_snake(method_name))
}
pub(crate) fn resolve_relation_chain(
model: &ClassInfo,
chain: &str,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> Option<String> {
let segments: Vec<&str> = chain.split('.').collect();
if segments.is_empty() {
return None;
}
let mut current_class = resolve_class_with_inheritance(model, class_loader);
for segment in &segments {
let segment = segment.trim();
if segment.is_empty() {
return None;
}
let method = current_class.methods.iter().find(|m| m.name == segment)?;
let return_type = method.return_type.as_ref()?;
let related_type = extract_related_type_for_chain(return_type, ¤t_class)?;
let resolved = resolve_related_fqn(&related_type, ¤t_class, class_loader)?;
current_class = resolve_class_with_inheritance(&resolved, class_loader);
}
Some(current_class.fqn())
}
fn resolve_class_with_inheritance(
class: &ClassInfo,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> Arc<ClassInfo> {
crate::virtual_members::resolve_class_fully(class, class_loader)
}
fn extract_related_type_for_chain(
return_type: &PhpType,
declaring_class: &ClassInfo,
) -> Option<String> {
classify_relationship_typed(return_type)?;
if let PhpType::Generic(_, args) = return_type {
let first = args.first()?;
if first.is_self_ref() {
return Some(declaring_class.fqn());
}
}
extract_related_type_typed(return_type).and_then(|t| t.base_name().map(|s| s.to_string()))
}
fn resolve_related_fqn(
related_type: &str,
declaring_class: &ClassInfo,
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
) -> Option<Arc<ClassInfo>> {
let cleaned = related_type.trim_start_matches('\\');
if let Some(cls) = class_loader(cleaned) {
return Some(cls);
}
if let Some(ref ns) = declaring_class.file_namespace {
let fqn = format!("{}\\{}", ns, cleaned);
if let Some(cls) = class_loader(&fqn) {
return Some(cls);
}
}
None
}
#[cfg(test)]
#[path = "relationships_tests.rs"]
mod tests;