#![allow(clippy::manual_async_fn)]
use crate::DjogiError;
use crate::context::DjogiContext;
use crate::model::Model;
use crate::pg::accumulator::{SqlAccumulator, as_params};
use crate::pg::decode::{FromPgRow, try_get_scalar};
#[cfg(test)]
use crate::query::field::FieldRef;
use crate::query::order::OrderExpr;
use crate::query::portable::SqlEmitContext;
use crate::query::q::Q;
use crate::query::sql::{emit_q, q_is_vacuously_true};
use crate::query::terminal::auto_set_tenant;
use crate::relation::path::{RelationKind, RelationPath};
use postgres_types::ToSql;
use std::future::Future;
use std::marker::PhantomData;
const SEARCH_SEQ_COL: &str = "__djogi_search_seq";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecursiveDirection {
Descendants,
Ancestors,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SearchMode {
Breadth(&'static str),
Depth(&'static str),
}
impl SearchMode {
fn keyword(self) -> &'static str {
match self {
SearchMode::Breadth(_) => " SEARCH BREADTH FIRST BY ",
SearchMode::Depth(_) => " SEARCH DEPTH FIRST BY ",
}
}
fn column(self) -> &'static str {
match self {
SearchMode::Breadth(c) | SearchMode::Depth(c) => c,
}
}
}
pub struct RecursiveQuerySet<T: Model> {
pub(crate) direction: RecursiveDirection,
pub(crate) edges: Vec<RelationPath<T, T>>,
pub(crate) root_id: Box<dyn ToSql + Sync + Send>,
pub(crate) condition: Q<T>,
pub(crate) ordering: Vec<OrderExpr>,
pub(crate) max_depth: Option<u32>,
pub(crate) search_mode: Option<SearchMode>,
_model: PhantomData<fn() -> T>,
}
impl<T: Model> std::fmt::Debug for RecursiveQuerySet<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let edge_columns: Vec<&'static str> =
self.edges.iter().map(|e| e.source_column()).collect();
f.debug_struct("RecursiveQuerySet")
.field("table", &T::table_name())
.field("direction", &self.direction)
.field("edge_columns", &edge_columns)
.field("condition", &self.condition)
.field("ordering", &self.ordering)
.field("max_depth", &self.max_depth)
.field("search_mode", &self.search_mode)
.finish_non_exhaustive()
}
}
impl<T: Model> RecursiveQuerySet<T> {
pub(crate) fn from_path(
path: RelationPath<T, T>,
root_id: T::Pk,
direction: RecursiveDirection,
) -> Self
where
T::Pk: ToSql + Sync + Send + 'static,
{
debug_assert!(
matches!(
path.kind(),
RelationKind::ForeignKey | RelationKind::OneToOne
),
"RecursiveQuerySet requires a ForeignKey or OneToOne self-FK; got {:?}",
path.kind()
);
Self {
direction,
edges: vec![path],
root_id: Box::new(root_id),
condition: Q::always_true(),
ordering: Vec::new(),
max_depth: None,
search_mode: None,
_model: PhantomData,
}
}
pub(crate) fn from_paths(
paths: Vec<RelationPath<T, T>>,
root_id: T::Pk,
direction: RecursiveDirection,
) -> Self
where
T::Pk: ToSql + Sync + Send + 'static,
{
debug_assert!(
paths
.iter()
.all(|p| matches!(p.kind(), RelationKind::ForeignKey | RelationKind::OneToOne)),
"RecursiveQuerySet requires ForeignKey / OneToOne self-FKs only",
);
Self {
direction,
edges: paths,
root_id: Box::new(root_id),
condition: Q::always_true(),
ordering: Vec::new(),
max_depth: None,
search_mode: None,
_model: PhantomData,
}
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn filter<F, P>(mut self, f: F) -> Self
where
F: FnOnce(T::Fields) -> P,
P: crate::query::IntoQ<T>,
{
self.condition = and_q_into_q(self.condition, f(T::Fields::default()));
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn filter_expr<F>(mut self, f: F) -> Self
where
F: FnOnce(T::Fields) -> crate::expr::Expr<bool>,
{
self.condition = and_q_into_q(self.condition, f(T::Fields::default()));
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn order_by<F, O>(mut self, f: F) -> Self
where
F: FnOnce(T::Fields) -> O,
O: Into<Vec<OrderExpr>>,
{
let exprs: Vec<OrderExpr> = f(T::Fields::default()).into();
self.ordering.extend(exprs);
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn with_max_depth(mut self, n: u32) -> Self {
self.max_depth = Some(n);
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn search_breadth_first_by<V, S>(mut self, field: S) -> Self
where
S: crate::query::field::IntoSqlField<T, V>,
{
self.search_mode = Some(SearchMode::Breadth(field.into_sql_field().column()));
self
}
#[must_use = "querysets are lazy — dropping one silently omits the query"]
pub fn search_depth_first_by<V, S>(mut self, field: S) -> Self
where
S: crate::query::field::IntoSqlField<T, V>,
{
self.search_mode = Some(SearchMode::Depth(field.into_sql_field().column()));
self
}
}
fn and_q_into_q<T: Model, A: crate::query::IntoQ<T>>(current: Q<T>, addition: A) -> Q<T> {
let addition = addition.into_q();
if q_is_vacuously_true(¤t) {
addition
} else if q_is_vacuously_true(&addition) {
current
} else {
current & addition
}
}
fn push_qualified_columns<T: FromPgRow>(acc: &mut SqlAccumulator, alias: &'static str) {
for (i, col) in <T as FromPgRow>::COLUMNS.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
acc.push_sql(alias);
acc.push_sql(".");
acc.push_sql(col);
}
}
pub(crate) fn build_recursive_select<T: Model + FromPgRow>(
qs: RecursiveQuerySet<T>,
) -> Result<SqlAccumulator, DjogiError> {
build_recursive_inner::<T>(qs, RecursiveProjection::Rows)
}
pub(crate) fn build_recursive_count<T: Model + FromPgRow>(
qs: RecursiveQuerySet<T>,
) -> Result<SqlAccumulator, DjogiError> {
build_recursive_inner::<T>(qs, RecursiveProjection::Count)
}
pub(crate) fn build_recursive_exists<T: Model + FromPgRow>(
qs: RecursiveQuerySet<T>,
) -> Result<SqlAccumulator, DjogiError> {
build_recursive_inner::<T>(qs, RecursiveProjection::Exists)
}
pub(crate) fn build_recursive_select_with_paths<T: Model + FromPgRow>(
qs: RecursiveQuerySet<T>,
) -> Result<SqlAccumulator, DjogiError> {
build_recursive_inner::<T>(qs, RecursiveProjection::RowsWithDepthAndPath)
}
#[derive(Debug, Clone, Copy)]
enum RecursiveProjection {
Rows,
RowsWithDepthAndPath,
Count,
Exists,
}
fn build_recursive_inner<T: Model + FromPgRow>(
qs: RecursiveQuerySet<T>,
projection: RecursiveProjection,
) -> Result<SqlAccumulator, DjogiError> {
let mut acc = SqlAccumulator::new("");
match projection {
RecursiveProjection::Rows | RecursiveProjection::RowsWithDepthAndPath => {}
RecursiveProjection::Count => acc.push_sql("SELECT COUNT(*) FROM ("),
RecursiveProjection::Exists => acc.push_sql("SELECT EXISTS ("),
}
acc.push_sql("WITH RECURSIVE __djogi_tree (depth, path, ");
acc.push_sql(<T as FromPgRow>::COLUMN_LIST);
acc.push_sql(") AS (");
acc.push_sql("SELECT 0, ARRAY[]::text[], ");
push_qualified_columns::<T>(&mut acc, T::table_name());
acc.push_sql(" FROM ");
acc.push_sql(T::table_name());
acc.push_sql(" WHERE ");
acc.push_sql(T::table_name());
acc.push_sql(".id = ");
acc.push_bind(DynBind(qs.root_id));
let direction = qs.direction;
let max_depth = qs.max_depth;
let condition = qs.condition;
let ordering = qs.ordering;
let search_mode = qs.search_mode;
let edges = qs.edges;
let has_user_filter = !q_is_vacuously_true(&condition);
let has_depth_cap = max_depth.is_some();
acc.push_sql(
" UNION ALL SELECT parent.depth + 1, parent.path || ARRAY[child.__djogi_edge_label], ",
);
push_qualified_columns::<T>(&mut acc, "child");
acc.push_sql(" FROM __djogi_tree parent JOIN LATERAL (");
for (i, edge) in edges.iter().enumerate() {
if i > 0 {
acc.push_sql(" UNION ALL ");
}
acc.push_sql("SELECT ");
push_qualified_columns::<T>(&mut acc, "t");
acc.push_sql(", '");
acc.push_sql(edge.source_column());
acc.push_sql("'::text AS __djogi_edge_label FROM ");
acc.push_sql(T::table_name());
acc.push_sql(" t WHERE ");
match direction {
RecursiveDirection::Descendants => {
acc.push_sql("t.");
acc.push_sql(edge.source_column());
acc.push_sql(" = parent.id");
}
RecursiveDirection::Ancestors => {
acc.push_sql("parent.");
acc.push_sql(edge.source_column());
acc.push_sql(" = t.id");
}
}
}
acc.push_sql(") child ON TRUE");
if has_user_filter || has_depth_cap {
acc.push_sql(" WHERE ");
if has_user_filter {
emit_q::<T>(&mut acc, &condition, SqlEmitContext::joined("child"))
.map_err(crate::DjogiError::from)?;
}
if has_depth_cap {
if has_user_filter {
acc.push_sql(" AND ");
}
acc.push_sql("parent.depth < ");
let n = max_depth.expect("has_depth_cap implies max_depth is Some");
let n_i32 = i32::try_from(n).unwrap_or(i32::MAX);
acc.push_bind(n_i32);
}
}
acc.push_sql(")");
if let Some(mode) = search_mode {
acc.push_sql(mode.keyword());
acc.push_sql(mode.column());
acc.push_sql(" SET ");
acc.push_sql(SEARCH_SEQ_COL);
}
acc.push_sql(" CYCLE id SET is_cycle USING cycle_path");
match projection {
RecursiveProjection::Rows => {
acc.push_sql(" SELECT ");
acc.push_sql(<T as FromPgRow>::COLUMN_LIST);
acc.push_sql(" FROM __djogi_tree WHERE NOT is_cycle");
push_outer_order_by(&mut acc, search_mode.is_some(), &ordering);
}
RecursiveProjection::RowsWithDepthAndPath => {
acc.push_sql(" SELECT ");
acc.push_sql(<T as FromPgRow>::COLUMN_LIST);
acc.push_sql(", depth, path FROM __djogi_tree WHERE NOT is_cycle");
push_outer_order_by(&mut acc, search_mode.is_some(), &ordering);
}
RecursiveProjection::Count => {
acc.push_sql(" SELECT 1 FROM __djogi_tree WHERE NOT is_cycle) AS sub");
}
RecursiveProjection::Exists => {
acc.push_sql(" SELECT 1 FROM __djogi_tree WHERE NOT is_cycle LIMIT 1)");
}
}
Ok(acc)
}
fn push_outer_order_by(acc: &mut SqlAccumulator, has_search_order: bool, ordering: &[OrderExpr]) {
let has_user_order = !ordering.is_empty();
if !has_search_order && !has_user_order {
return;
}
acc.push_sql(" ORDER BY ");
if has_search_order {
acc.push_sql(SEARCH_SEQ_COL);
}
if has_user_order {
if has_search_order {
acc.push_sql(", ");
}
for (i, o) in ordering.iter().enumerate() {
if i > 0 {
acc.push_sql(", ");
}
o.emit(acc, None);
}
}
}
impl<T: Model> RecursiveQuerySet<T>
where
T: FromPgRow + Send + Unpin,
{
pub fn fetch_all<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Vec<T>, DjogiError>> + Send + 'ctx
where
T: 'ctx,
{
async move {
check_edges_present::<T>(&self.edges)?;
auto_set_tenant::<T>(ctx).await?;
let acc = build_recursive_select(self)?;
let (sql, binds) = acc.into_parts();
let params = as_params(&binds);
let rows = ctx.query_all(&sql, ¶ms).await?;
rows.iter()
.map(|r| T::from_pg_row(r))
.collect::<Result<Vec<T>, _>>()
}
}
pub fn fetch_all_with_paths<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Vec<(T, i32, Vec<String>)>, DjogiError>> + Send + 'ctx
where
T: 'ctx,
{
async move {
check_edges_present::<T>(&self.edges)?;
auto_set_tenant::<T>(ctx).await?;
let acc = build_recursive_select_with_paths(self)?;
let (sql, binds) = acc.into_parts();
let params = as_params(&binds);
let rows = ctx.query_all(&sql, ¶ms).await?;
rows.iter()
.map(|r| {
let model = T::from_pg_row(r)?;
let depth: i32 = r.try_get("depth").map_err(DjogiError::from)?;
let path: Vec<String> = r.try_get("path").map_err(DjogiError::from)?;
Ok((model, depth, path))
})
.collect::<Result<Vec<_>, DjogiError>>()
}
}
pub fn first<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<Option<T>, DjogiError>> + Send + 'ctx
where
T: 'ctx,
{
async move {
check_edges_present::<T>(&self.edges)?;
auto_set_tenant::<T>(ctx).await?;
let mut acc = build_recursive_select(self)?;
acc.push_sql(" LIMIT 1");
let (sql, binds) = acc.into_parts();
let params = as_params(&binds);
let opt = ctx.query_opt(&sql, ¶ms).await?;
opt.as_ref().map(|r| T::from_pg_row(r)).transpose()
}
}
}
impl<T: Model> RecursiveQuerySet<T>
where
T: FromPgRow,
{
pub fn count<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<i64, DjogiError>> + Send + 'ctx
where
T: 'ctx,
{
async move {
check_edges_present::<T>(&self.edges)?;
auto_set_tenant::<T>(ctx).await?;
let acc = build_recursive_count(self)?;
let (sql, binds) = acc.into_parts();
let params = as_params(&binds);
let row = ctx.query_one(&sql, ¶ms).await?;
try_get_scalar::<i64>(&row, 0)
}
}
pub fn exists<'ctx>(
self,
ctx: &'ctx mut DjogiContext,
) -> impl Future<Output = Result<bool, DjogiError>> + Send + 'ctx
where
T: 'ctx,
{
async move {
check_edges_present::<T>(&self.edges)?;
auto_set_tenant::<T>(ctx).await?;
let acc = build_recursive_exists(self)?;
let (sql, binds) = acc.into_parts();
let params = as_params(&binds);
let row = ctx.query_one(&sql, ¶ms).await?;
try_get_scalar::<bool>(&row, 0)
}
}
}
fn check_edges_present<T: Model>(edges: &[RelationPath<T, T>]) -> Result<(), DjogiError> {
if edges.is_empty() {
return Err(DjogiError::Validation(format!(
"model '{}' has no self-FK; full_ancestors requires at least one",
T::table_name(),
)));
}
Ok(())
}
struct DynBind(Box<dyn ToSql + Sync + Send>);
impl std::fmt::Debug for DynBind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("DynBind(<root_id>)")
}
}
impl ToSql for DynBind {
fn to_sql(
&self,
ty: &postgres_types::Type,
out: &mut bytes::BytesMut,
) -> Result<postgres_types::IsNull, Box<dyn std::error::Error + Sync + Send>> {
self.0.to_sql_checked(ty, out)
}
fn accepts(_ty: &postgres_types::Type) -> bool {
true
}
postgres_types::to_sql_checked!();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::ModelDescriptor;
use crate::pg::decode::FromPgRow;
use crate::query::field::DjogiField;
use crate::types::HeerId;
struct MiniTree;
#[derive(Clone, Copy, Default)]
struct MiniTreeFields;
impl MiniTreeFields {
fn label(&self) -> DjogiField<MiniTree, String> {
crate::__private::query::__make_djogi_field("label", |_| {
static LABEL: std::sync::OnceLock<String> = std::sync::OnceLock::new();
LABEL.get_or_init(String::new)
})
}
}
impl crate::model::__sealed::Sealed for MiniTree {}
impl crate::model::Model for MiniTree {
type Pk = HeerId;
type Fields = MiniTreeFields;
fn table_name() -> &'static str {
"mini_trees"
}
fn pk_value(&self) -> &Self::Pk {
unreachable!()
}
fn descriptor() -> &'static ModelDescriptor {
unreachable!()
}
fn get(
_ctx: &mut crate::context::DjogiContext,
_id: Self::Pk,
) -> impl std::future::Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn create(
_ctx: &mut crate::context::DjogiContext,
_v: Self,
) -> impl std::future::Future<Output = Result<Self, DjogiError>> + Send {
async { unreachable!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
fn delete(
self,
_ctx: &mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), DjogiError>> + Send {
async { unreachable!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<Self, DjogiError>> + Send + 'ctx {
async { unreachable!() }
}
fn __djogi_emit_field_predicate(
acc: &mut crate::pg::accumulator::SqlAccumulator,
field: &crate::types::FieldPredicate<Self>,
ctx: crate::query::SqlEmitContext,
) -> Result<(), crate::query::PortablePredicateError> {
match (field.field_name(), field.op()) {
("label", crate::types::LookupOp::Eq) => {
crate::query::portable::emit::emit_value::<Self, String>(
acc, ctx, "label", " = ", field,
)
}
(field_name, _) => Err(crate::query::PortablePredicateError::UnsupportedField {
field: field_name,
}),
}
}
}
impl FromPgRow for MiniTree {
const COLUMNS: &'static [&'static str] = &["id", "parent_id", "label"];
const COLUMN_LIST: &'static str = "id, parent_id, label";
fn from_pg_row(_row: &tokio_postgres::Row) -> Result<Self, DjogiError> {
unreachable!()
}
}
fn root() -> RecursiveQuerySet<MiniTree> {
RecursiveQuerySet::<MiniTree>::from_path(
crate::relation::__macro_support::__make_relation_path::<MiniTree, MiniTree>(
"parent_id",
"mini_trees",
RelationKind::ForeignKey,
),
HeerId::from_i64(1).unwrap(),
RecursiveDirection::Descendants,
)
}
fn ancestors_root() -> RecursiveQuerySet<MiniTree> {
RecursiveQuerySet::<MiniTree>::from_path(
crate::relation::__macro_support::__make_relation_path::<MiniTree, MiniTree>(
"parent_id",
"mini_trees",
RelationKind::ForeignKey,
),
HeerId::from_i64(1).unwrap(),
RecursiveDirection::Ancestors,
)
}
#[test]
fn descendants_emits_union_all_with_child_join() {
let qs = root();
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
sql.contains("UNION ALL"),
"recursive term must use UNION ALL, got: {sql}"
);
assert!(
!sql.contains(" UNION SELECT"),
"recursive term must not use plain UNION (multiplicity loss): {sql}"
);
assert!(
sql.contains("t.parent_id = parent.id"),
"descendants lateral alternative must filter t.parent_id = parent.id: {sql}"
);
}
#[test]
fn ancestors_flips_join_direction() {
let qs = ancestors_root();
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
sql.contains("parent.parent_id = t.id"),
"ancestors lateral alternative must filter parent.parent_id = t.id: {sql}"
);
}
#[test]
fn cycle_clause_is_always_emitted() {
let qs = root();
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
sql.contains("CYCLE id SET is_cycle USING cycle_path"),
"CYCLE clause must always be emitted: {sql}"
);
assert!(
sql.contains("WHERE NOT is_cycle"),
"outer SELECT must filter cycle sentinel rows: {sql}"
);
}
#[test]
fn outer_projection_uses_canonical_column_list() {
let qs = root();
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
sql.contains(" SELECT id, parent_id, label FROM __djogi_tree"),
"outer SELECT must project canonical columns: {sql}"
);
assert!(
!sql.contains(", depth, path FROM __djogi_tree"),
"depth/path columns must not leak into the plain Rows projection: {sql}"
);
assert!(
!sql.contains("__djogi_search_seq FROM __djogi_tree"),
"__djogi_search_seq must not leak into outer projection: {sql}"
);
}
#[test]
fn no_max_depth_no_predicate() {
let qs = root();
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
!sql.contains("parent.depth <"),
"no depth cap must not emit `parent.depth <`: {sql}"
);
}
#[test]
fn filter_accepts_portable_root_predicate_without_condition_bridge_panic() {
let qs = root().filter(|f| f.label().eq("branch".to_string()));
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
sql.contains("child.label = $2"),
"recursive portable filter must qualify through child alias and bind after root id: {sql}"
);
assert_eq!(acc.bind_count(), 2);
}
#[test]
fn with_max_depth_emits_depth_predicate_and_binds_n() {
let qs = root().with_max_depth(5);
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
sql.contains("parent.depth < $2"),
"max_depth must bind as $2 (root_id is $1): {sql}"
);
assert_eq!(
acc.bind_count(),
2,
"max_depth + root_id = 2 binds, got {}",
acc.bind_count()
);
}
#[test]
fn search_breadth_first_emits_clause_and_orders_outer() {
let qs = root();
let qs = qs.search_breadth_first_by(FieldRef::<MiniTree, String>::new("label"));
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
sql.contains("SEARCH BREADTH FIRST BY label SET __djogi_search_seq"),
"BFS clause must be emitted on the CTE: {sql}"
);
assert!(
sql.contains("ORDER BY __djogi_search_seq"),
"outer SELECT must order by the search seq column: {sql}"
);
}
#[test]
fn search_depth_first_emits_dfs_keyword() {
let qs = root().search_depth_first_by(FieldRef::<MiniTree, String>::new("label"));
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
sql.contains("SEARCH DEPTH FIRST BY label SET __djogi_search_seq"),
"DFS clause must be emitted on the CTE: {sql}"
);
}
#[test]
fn search_modes_are_mutually_exclusive_last_wins() {
let qs = root()
.search_breadth_first_by(FieldRef::<MiniTree, String>::new("label"))
.search_depth_first_by(FieldRef::<MiniTree, String>::new("label"));
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
!sql.contains("BREADTH FIRST"),
"DFS after BFS must drop the BFS clause: {sql}"
);
assert!(
sql.contains("DEPTH FIRST"),
"last search-mode call must win: {sql}"
);
}
#[test]
fn count_terminal_wraps_in_count_subquery() {
let qs = root();
let acc = build_recursive_count(qs).expect("recursive count");
let sql = acc.sql();
assert!(
sql.starts_with("SELECT COUNT(*) FROM ("),
"count terminal wraps the recursive CTE: {sql}"
);
assert!(
sql.ends_with(") AS sub"),
"count subquery must close with `) AS sub`: {sql}"
);
}
#[test]
fn exists_terminal_wraps_with_limit_one() {
let qs = root();
let acc = build_recursive_exists(qs).expect("recursive exists");
let sql = acc.sql();
assert!(
sql.starts_with("SELECT EXISTS ("),
"exists terminal wraps the recursive CTE: {sql}"
);
assert!(
sql.contains("LIMIT 1)"),
"exists subquery must include LIMIT 1: {sql}"
);
}
#[test]
fn first_terminal_appends_outer_limit_one() {
let qs = root();
let mut acc = build_recursive_select(qs).expect("recursive select");
acc.push_sql(" LIMIT 1");
let sql = acc.sql();
assert!(
sql.trim_end().ends_with(" LIMIT 1"),
"first terminal must append outer LIMIT 1 so query_opt does not \
error on multi-row recursive walks: {sql}"
);
assert!(
!sql.contains("EXISTS"),
"first must use the row projection (not the EXISTS wrap): {sql}"
);
}
#[test]
fn anchor_binds_root_id_as_dollar_one() {
let qs = root();
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
sql.contains("mini_trees.id = $1"),
"anchor must bind root_id as $1: {sql}"
);
assert!(
acc.bind_count() >= 1,
"at least one bind expected (root_id), got {}",
acc.bind_count()
);
}
#[test]
fn cte_column_list_includes_depth_then_path_then_model_columns() {
let qs = root();
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
sql.contains("__djogi_tree (depth, path, id, parent_id, label)"),
"CTE column list must be (depth, path, <model cols...>): {sql}"
);
}
#[test]
fn anchor_initialises_path_as_empty_text_array() {
let qs = root();
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
sql.contains("SELECT 0, ARRAY[]::text[], "),
"anchor must initialise depth=0 and path=ARRAY[]::text[]: {sql}"
);
}
#[test]
fn recursive_term_appends_edge_name_to_path() {
let qs = root();
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
sql.contains("parent.path || ARRAY[child.__djogi_edge_label]"),
"recursive term must append the lateral's edge label: {sql}"
);
assert!(
sql.contains("'parent_id'::text AS __djogi_edge_label"),
"lateral must tag rows with the edge name as __djogi_edge_label: {sql}"
);
assert!(
!sql.contains("child.id::text"),
"B2's placeholder `child.id::text` must be gone: {sql}"
);
}
#[test]
fn fetch_all_with_paths_projects_depth_and_path_columns() {
let qs = root();
let acc = build_recursive_select_with_paths(qs).expect("recursive select with paths");
let sql = acc.sql();
assert!(
sql.contains("SELECT id, parent_id, label, depth, path FROM __djogi_tree"),
"with-paths projection must trail depth and path columns: {sql}"
);
}
fn full_ancestors_two_edges() -> RecursiveQuerySet<MiniTree> {
let edges = vec![
crate::relation::__macro_support::__make_relation_path::<MiniTree, MiniTree>(
"mother_id",
"mini_trees",
RelationKind::ForeignKey,
),
crate::relation::__macro_support::__make_relation_path::<MiniTree, MiniTree>(
"father_id",
"mini_trees",
RelationKind::ForeignKey,
),
];
RecursiveQuerySet::<MiniTree>::from_paths(
edges,
HeerId::from_i64(1).unwrap(),
RecursiveDirection::Ancestors,
)
}
#[test]
fn full_ancestors_two_edges_consolidates_into_single_recursive_term() {
let qs = full_ancestors_two_edges();
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
let union_count = sql.matches("UNION ALL").count();
assert_eq!(
union_count, 2,
"two-edge ancestors: 1 UNION ALL between anchor + recursive term, \
plus 1 inside the LATERAL between the two per-edge SELECTs: {sql}"
);
assert!(
sql.contains("parent.mother_id = t.id"),
"first edge alternative must filter parent.mother_id = t.id: {sql}"
);
assert!(
sql.contains("parent.father_id = t.id"),
"second edge alternative must filter parent.father_id = t.id: {sql}"
);
assert!(
sql.contains("'mother_id'::text AS __djogi_edge_label"),
"first edge alternative must tag with mother_id label: {sql}"
);
assert!(
sql.contains("'father_id'::text AS __djogi_edge_label"),
"second edge alternative must tag with father_id label: {sql}"
);
}
#[test]
fn single_edge_emits_one_union_all_branch() {
let qs = root();
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
let union_count = sql.matches("UNION ALL").count();
assert_eq!(
union_count, 1,
"single-edge walk must emit exactly 1 UNION ALL keyword: {sql}"
);
}
#[test]
fn empty_edges_errors_at_terminal_time() {
let edges: Vec<RelationPath<MiniTree, MiniTree>> = Vec::new();
let result = check_edges_present::<MiniTree>(&edges);
let err = result.expect_err("empty edges must error");
let msg = err.to_string();
assert!(
msg.contains("mini_trees"),
"error must name the model's table: {msg}"
);
assert!(
msg.contains("full_ancestors") && msg.contains("self-FK"),
"error must point at full_ancestors and the self-FK requirement: {msg}"
);
}
#[test]
fn full_ancestors_two_edges_with_max_depth_binds_once() {
let qs = full_ancestors_two_edges().with_max_depth(3);
let acc = build_recursive_select(qs).expect("recursive select");
let sql = acc.sql();
assert!(
sql.contains("parent.depth < $2"),
"single recursive term => single depth bind at $2: {sql}"
);
assert!(
!sql.contains("parent.depth < $3"),
"second depth bind must not appear — only one recursive term: {sql}"
);
assert_eq!(
acc.bind_count(),
2,
"expected 2 binds (root + 1 depth cap), got {}",
acc.bind_count()
);
}
}