use crate::client::backend::{BackendError, BackendResult, QueryLanguage, TranslatedQuery};
use crate::client::query_builder::{
Condition, ConditionOperator, DeleteQuery, InsertQuery, OrderDirection, SelectQuery,
UpdateQuery,
};
use serde_json::Value;
use std::fmt::Write;
use supabase_rs::query::Query;
pub(crate) trait QueryTranslator {
fn translate_select(&self, query: &SelectQuery) -> BackendResult<TranslatedQuery>;
fn translate_insert(&self, query: &InsertQuery) -> BackendResult<TranslatedQuery>;
fn translate_update(&self, query: &UpdateQuery) -> BackendResult<TranslatedQuery>;
fn translate_delete(&self, query: &DeleteQuery) -> BackendResult<TranslatedQuery>;
}
pub struct SqlTranslator;
pub struct PostgrestTranslator;
pub struct CqlTranslator;
impl QueryTranslator for SqlTranslator {
fn translate_select(&self, query: &SelectQuery) -> BackendResult<TranslatedQuery> {
let select_nodes = parse_select(&query.raw_select, &query.columns)?;
let mut emitter = SqlEmitter::default();
emitter.emit_root(&query.table, select_nodes, query)
}
fn translate_insert(&self, query: &InsertQuery) -> BackendResult<TranslatedQuery> {
let (columns, values, params) = build_insert_fragments(&query.payload);
let sql = format!(
"INSERT INTO {} ({}) VALUES ({})",
query.table,
columns.join(", "),
values.join(", ")
);
Ok(TranslatedQuery::new(
sql,
QueryLanguage::Sql,
params,
Some(query.table.clone()),
))
}
fn translate_update(&self, query: &UpdateQuery) -> BackendResult<TranslatedQuery> {
let (assignments, mut params) = build_update_fragments(&query.payload);
let mut sql = format!("UPDATE {} SET {}", query.table, assignments.join(", "));
if let Some(row_id) = &query.row_id {
let literal = format_value(&Value::String(row_id.clone()));
sql.push_str(&format!(" WHERE id = {}", literal));
params.push(Value::String(row_id.clone()));
}
Ok(TranslatedQuery::new(
sql,
QueryLanguage::Sql,
params,
Some(query.table.clone()),
))
}
fn translate_delete(&self, query: &DeleteQuery) -> BackendResult<TranslatedQuery> {
let mut sql = format!("DELETE FROM {}", query.table);
let mut params = Vec::new();
if let Some(row_id) = &query.row_id {
let literal = format_value(&Value::String(row_id.clone()));
sql.push_str(&format!(" WHERE id = {}", literal));
params.push(Value::String(row_id.clone()));
}
Ok(TranslatedQuery::new(
sql,
QueryLanguage::Sql,
params,
Some(query.table.clone()),
))
}
}
impl QueryTranslator for CqlTranslator {
fn translate_select(&self, query: &SelectQuery) -> BackendResult<TranslatedQuery> {
let columns: String = if query.columns.is_empty() {
"*".to_string()
} else {
query.columns.join(", ")
};
let mut sql: String = format!("SELECT {} FROM {}", columns, query.table);
let (where_clause, params) = build_where_clause(&query.conditions);
sql.push_str(&where_clause);
if let Some(limit) = query.limit {
sql.push_str(&format!(" LIMIT {}", limit));
}
Ok(TranslatedQuery::new(
sql,
QueryLanguage::Cql,
params,
Some(query.table.clone()),
))
}
fn translate_insert(&self, query: &InsertQuery) -> BackendResult<TranslatedQuery> {
let (columns, values, params) = build_insert_fragments(&query.payload);
let sql: String = format!(
"INSERT INTO {} ({}) VALUES ({})",
query.table,
columns.join(", "),
values.join(", ")
);
Ok(TranslatedQuery::new(
sql,
QueryLanguage::Cql,
params,
Some(query.table.clone()),
))
}
fn translate_update(&self, query: &UpdateQuery) -> BackendResult<TranslatedQuery> {
let (assignments, mut params) = build_update_fragments(&query.payload);
let mut sql: String = format!("UPDATE {} SET {}", query.table, assignments.join(", "));
if let Some(row_id) = &query.row_id {
let literal = format_value(&Value::String(row_id.clone()));
sql.push_str(&format!(" WHERE id = {}", literal));
params.push(Value::String(row_id.clone()));
}
Ok(TranslatedQuery::new(
sql,
QueryLanguage::Cql,
params,
Some(query.table.clone()),
))
}
fn translate_delete(&self, query: &DeleteQuery) -> BackendResult<TranslatedQuery> {
let mut sql = format!("DELETE FROM {}", query.table);
let mut params = Vec::new();
if let Some(row_id) = &query.row_id {
let literal = format_value(&Value::String(row_id.clone()));
sql.push_str(&format!(" WHERE id = {}", literal));
params.push(Value::String(row_id.clone()));
}
Ok(TranslatedQuery::new(
sql,
QueryLanguage::Cql,
params,
Some(query.table.clone()),
))
}
}
impl QueryTranslator for PostgrestTranslator {
fn translate_select(&self, query: &SelectQuery) -> BackendResult<TranslatedQuery> {
let mut q = Query::new();
let select = if let Some(raw) = &query.raw_select {
raw.clone()
} else if query.columns.is_empty() {
"*".to_string()
} else {
query.columns.join(",")
};
q.add_param("select", &select);
for condition in &query.conditions {
if let Some(value) = condition.values.first() {
let encoded = postgrest_operator(condition.operator, value)?;
q.add_param(&condition.column, &encoded);
}
}
for (column, direction) in &query.order_by {
let dir_str = match direction {
OrderDirection::Asc => "asc",
OrderDirection::Desc => "desc",
};
q.add_param("order", &format!("{column}.{dir_str}"));
}
if let Some(limit) = query.limit {
q.add_param("limit", &limit.to_string());
}
if let Some(offset) = query.offset {
q.add_param("offset", &offset.to_string());
}
let query_string = q.build();
Ok(TranslatedQuery::new(
query_string,
QueryLanguage::Postgrest,
Vec::new(),
Some(query.table.clone()),
))
}
fn translate_insert(&self, _query: &InsertQuery) -> BackendResult<TranslatedQuery> {
Err(BackendError::Generic(
"Inserts are not supported via PostgREST translator".to_string(),
))
}
fn translate_update(&self, _query: &UpdateQuery) -> BackendResult<TranslatedQuery> {
Err(BackendError::Generic(
"Updates are not supported via PostgREST translator".to_string(),
))
}
fn translate_delete(&self, _query: &DeleteQuery) -> BackendResult<TranslatedQuery> {
Err(BackendError::Generic(
"Deletes are not supported via PostgREST translator".to_string(),
))
}
}
fn build_where_clause(conditions: &[Condition]) -> (String, Vec<Value>) {
if conditions.is_empty() {
return (String::new(), Vec::new());
}
let mut clauses: Vec<String> = Vec::new();
let mut params: Vec<Value> = Vec::new();
for condition in conditions {
match condition.operator {
ConditionOperator::In => {
let formatted: Vec<String> = condition.values.iter().map(format_value).collect();
clauses.push(format!(
"{} IN ({})",
condition.column,
formatted.join(", ")
));
params.extend(condition.values.clone());
}
_ => {
if let Some(value) = condition.values.first() {
let operator = match condition.operator {
ConditionOperator::Eq => "=",
ConditionOperator::Neq => "<>",
ConditionOperator::Gt => ">",
ConditionOperator::Lt => "<",
_ => "=",
};
clauses.push(format!(
"{} {} {}",
condition.column,
operator,
format_value(value)
));
params.extend(condition.values.clone());
}
}
}
}
(format!(" WHERE {}", clauses.join(" AND ")), params)
}
fn build_insert_fragments(payload: &Value) -> (Vec<String>, Vec<String>, Vec<Value>) {
if let Value::Object(map) = payload {
let mut columns = Vec::new();
let mut values = Vec::new();
let mut params = Vec::new();
for (column, value) in map {
columns.push(column.clone());
values.push(format_value(value));
params.push(value.clone());
}
(columns, values, params)
} else {
(Vec::new(), Vec::new(), Vec::new())
}
}
fn build_update_fragments(payload: &Value) -> (Vec<String>, Vec<Value>) {
if let Value::Object(map) = payload {
let mut assignments: Vec<String> = Vec::new();
let mut params: Vec<Value> = Vec::new();
for (column, value) in map {
assignments.push(format!("{} = {}", column, format_value(value)));
params.push(value.clone());
}
(assignments, params)
} else {
(Vec::new(), Vec::new())
}
}
fn format_value(value: &Value) -> String {
match value {
Value::String(text) => format!("'{}'", text.replace('\'', "''")),
Value::Number(num) => num.to_string(),
Value::Bool(flag) => flag.to_string(),
Value::Null => "NULL".to_string(),
other => serde_json::to_string(other).unwrap_or_else(|_| "NULL".to_string()),
}
}
fn postgrest_operator(op: ConditionOperator, value: &Value) -> BackendResult<String> {
let val = match value {
Value::String(s) => s.clone(),
other => other.to_string(),
};
let encoded = match op {
ConditionOperator::Eq => format!("eq.{val}"),
ConditionOperator::Neq => format!("neq.{val}"),
ConditionOperator::Gt => format!("gt.{val}"),
ConditionOperator::Lt => format!("lt.{val}"),
ConditionOperator::In => format!("in.({val})"),
};
Ok(encoded)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum JoinKind {
Left,
Inner,
}
#[derive(Debug, Clone)]
enum SelectNode {
Column(String),
Relation(RelationNode),
}
#[derive(Debug, Clone)]
struct RelationNode {
name: String,
alias: Option<String>,
join: JoinKind,
foreign_key: Option<String>,
children: Vec<SelectNode>,
conditions: Vec<Condition>,
}
impl RelationNode {
fn display_name(&self) -> &str {
self.alias.as_deref().unwrap_or(&self.name)
}
}
fn parse_select(raw: &Option<String>, columns: &[String]) -> BackendResult<Vec<SelectNode>> {
if let Some(raw) = raw {
parse_select_string(raw)
} else {
Ok(columns
.iter()
.map(|c| SelectNode::Column(c.clone()))
.collect())
}
}
fn parse_select_string(input: &str) -> BackendResult<Vec<SelectNode>> {
let mut chars = input.chars().peekable();
parse_nodes(&mut chars)
}
fn parse_nodes<I>(iter: &mut std::iter::Peekable<I>) -> BackendResult<Vec<SelectNode>>
where
I: Iterator<Item = char>,
{
let mut nodes = Vec::new();
loop {
skip_ws(iter);
if matches!(iter.peek(), Some(')')) || iter.peek().is_none() {
break;
}
nodes.push(parse_node(iter)?);
skip_ws(iter);
if matches!(iter.peek(), Some(',')) {
iter.next();
continue;
}
if matches!(iter.peek(), Some(')')) || iter.peek().is_none() {
break;
}
}
Ok(nodes)
}
fn parse_node<I>(iter: &mut std::iter::Peekable<I>) -> BackendResult<SelectNode>
where
I: Iterator<Item = char>,
{
let mut token = String::new();
while let Some(&ch) = iter.peek() {
if matches!(ch, '!' | ':' | '(' | ')' | ',') {
break;
}
token.push(ch);
iter.next();
}
let token = token.trim().to_string();
if token.is_empty() {
return Err(BackendError::Generic(
"invalid select syntax: empty token".to_string(),
));
}
let mut alias = None;
let mut name_owned = token.clone();
if matches!(iter.peek(), Some(':')) {
iter.next();
let mut relation = String::new();
while let Some(&ch) = iter.peek() {
if matches!(ch, '!' | '(' | ')' | ',') {
break;
}
relation.push(ch);
iter.next();
}
alias = Some(token.clone());
name_owned = relation;
}
let name = name_owned.as_str();
let mut join = JoinKind::Left;
let mut foreign_key = None;
if matches!(iter.peek(), Some('!')) {
iter.next();
let mut modifier = String::new();
while let Some(&ch) = iter.peek() {
if matches!(ch, '(' | ')' | ',') {
break;
}
modifier.push(ch);
iter.next();
}
let modifier = modifier.trim();
if modifier.eq_ignore_ascii_case("inner") {
join = JoinKind::Inner;
} else if !modifier.is_empty() {
foreign_key = Some(modifier.to_string());
}
}
if matches!(iter.peek(), Some('(')) {
iter.next();
let children = parse_nodes(iter)?;
if iter.next() != Some(')') {
return Err(BackendError::Generic(
"invalid select syntax: missing ')'".to_string(),
));
}
Ok(SelectNode::Relation(RelationNode {
name: name.to_string(),
alias,
join,
foreign_key,
children,
conditions: Vec::new(),
}))
} else {
Ok(SelectNode::Column(name.to_string()))
}
}
fn skip_ws<I>(iter: &mut std::iter::Peekable<I>)
where
I: Iterator<Item = char>,
{
while matches!(iter.peek(), Some(ch) if ch.is_whitespace()) {
iter.next();
}
}
fn distribute_conditions(nodes: &mut [SelectNode], conditions: &[Condition]) -> Vec<Condition> {
let mut remaining = Vec::new();
for cond in conditions {
if let Some((head, rest)) = cond.column.split_once('.')
&& let Some(node) = nodes.iter_mut().find_map(|n| match n {
SelectNode::Relation(r) if r.display_name() == head => Some(r),
_ => None,
})
{
push_condition(node, rest, cond.clone());
continue;
}
remaining.push(cond.clone());
}
remaining
}
fn push_condition(target: &mut RelationNode, path: &str, condition: Condition) {
if let Some((head, rest)) = path.split_once('.') {
if let Some(child) = target.children.iter_mut().find_map(|n| match n {
SelectNode::Relation(r) if r.display_name() == head => Some(r),
_ => None,
}) {
push_condition(child, rest, condition);
}
} else {
target.conditions.push(condition);
}
}
#[derive(Default)]
struct SqlEmitter {
alias_counter: usize,
}
impl SqlEmitter {
fn emit_root(
&mut self,
table: &str,
mut nodes: Vec<SelectNode>,
query: &SelectQuery,
) -> BackendResult<TranslatedQuery> {
let base_alias = "t0".to_string();
let base_conditions = distribute_conditions(&mut nodes, &query.conditions);
let mut select_exprs = Vec::new();
let mut joins = Vec::new();
for node in nodes {
self.emit_node(&node, &base_alias, table, &mut select_exprs, &mut joins)?;
}
if select_exprs.is_empty() {
select_exprs.push("*".to_string());
}
let mut sql = format!(
"SELECT {select} FROM {table} {alias}",
select = select_exprs.join(", "),
table = table,
alias = base_alias
);
if !joins.is_empty() {
sql.push('\n');
sql.push_str(&joins.join("\n"));
}
if !base_conditions.is_empty() {
let adapted: Vec<Condition> = base_conditions
.iter()
.cloned()
.map(|mut c| {
c.column = format!("{base_alias}.{}", c.column);
c
})
.collect();
let (where_clause, _) = build_where_clause(&adapted);
sql.push_str(&where_clause);
}
if !query.order_by.is_empty() {
let ordering: Vec<String> = query
.order_by
.iter()
.map(|(column, direction)| {
let dir_str = match direction {
OrderDirection::Asc => "ASC",
OrderDirection::Desc => "DESC",
};
if column.contains('.') {
format!("{column} {dir_str}")
} else {
format!("{base_alias}.{column} {dir_str}")
}
})
.collect();
sql.push_str(&format!(" ORDER BY {}", ordering.join(", ")));
}
if let Some(limit) = query.limit {
sql.push_str(&format!(" LIMIT {}", limit));
}
if let Some(offset) = query.offset {
sql.push_str(&format!(" OFFSET {}", offset));
}
Ok(TranslatedQuery::new(
sql,
QueryLanguage::Sql,
Vec::new(),
Some(table.to_string()),
))
}
fn emit_node(
&mut self,
node: &SelectNode,
parent_alias: &str,
parent_table: &str,
select_exprs: &mut Vec<String>,
joins: &mut Vec<String>,
) -> BackendResult<()> {
match node {
SelectNode::Column(col) => {
if col == "*" {
select_exprs.push(format!("{parent_alias}.*"));
} else {
select_exprs.push(format!("{parent_alias}.{col}"));
}
}
SelectNode::Relation(rel) => {
let alias = self.next_alias(&rel.name);
let agg_alias = format!("{alias}_agg");
let mut relation_clone = rel.clone();
let _ = distribute_conditions(&mut relation_clone.children, &rel.conditions);
let (row_expr, child_joins) = self.emit_relation_row(&relation_clone, &alias)?;
let (join_condition, many_to_one) =
relation_join_condition(&relation_clone, parent_alias, parent_table, &alias);
let mut subquery = String::new();
if many_to_one {
writeln!(
subquery,
"SELECT {row_expr} AS data FROM {table} {alias}",
table = relation_clone.name,
alias = alias,
row_expr = row_expr
)
.unwrap();
} else {
write!(
subquery,
"SELECT COALESCE(jsonb_agg({row_expr} ORDER BY {alias}.id) FILTER (WHERE {alias}.id IS NOT NULL), '[]'::jsonb) AS data\nFROM {table} {alias}\n",
row_expr = row_expr,
alias = alias,
table = relation_clone.name
)
.unwrap();
}
if !child_joins.is_empty() {
subquery.push_str(&child_joins.join("\n"));
subquery.push('\n');
}
write!(subquery, "WHERE {join_condition}").unwrap();
if !relation_clone.conditions.is_empty() {
let adapted: Vec<Condition> = relation_clone
.conditions
.iter()
.cloned()
.map(|mut c| {
c.column = format!("{alias}.{}", c.column);
c
})
.collect();
let (where_clause, _) = build_where_clause(&adapted);
subquery.push_str(&where_clause);
}
if many_to_one {
subquery.push_str(" LIMIT 1");
}
let join_type = match rel.join {
JoinKind::Left => "LEFT JOIN",
JoinKind::Inner => "JOIN",
};
let mut join_sql = format!("{join_type} LATERAL (\n{subquery}\n) {agg_alias} ON ");
if rel.join == JoinKind::Inner {
join_sql.push_str(&format!("{agg_alias}.data IS NOT NULL"));
} else {
join_sql.push_str("TRUE");
}
joins.push(join_sql);
select_exprs.push(format!("{} as {}", agg_alias, rel.display_name()));
}
}
Ok(())
}
fn emit_relation_row(
&mut self,
relation: &RelationNode,
alias: &str,
) -> BackendResult<(String, Vec<String>)> {
let mut field_pairs = Vec::new();
let mut joins = Vec::new();
for child in &relation.children {
match child {
SelectNode::Column(col) => {
field_pairs.push(format!("'{}', {}.{}", col, alias, col));
}
SelectNode::Relation(rel) => {
let child_alias = self.next_alias(&rel.name);
let mut rel_clone = rel.clone();
let _ = distribute_conditions(&mut rel_clone.children, &rel.conditions);
let (row_expr, nested_joins) =
self.emit_relation_row(&rel_clone, &child_alias)?;
let (join_condition, many_to_one) =
relation_join_condition(&rel_clone, alias, &relation.name, &child_alias);
let mut subquery = String::new();
if many_to_one {
writeln!(
subquery,
"SELECT {row_expr} AS data FROM {table} {child_alias}",
row_expr = row_expr,
table = rel_clone.name,
child_alias = child_alias
)
.unwrap();
} else {
write!(
subquery,
"SELECT COALESCE(jsonb_agg({row_expr} ORDER BY {child_alias}.id) FILTER (WHERE {child_alias}.id IS NOT NULL), '[]'::jsonb) AS data\nFROM {table} {child_alias}\n",
row_expr = row_expr,
child_alias = child_alias,
table = rel_clone.name
)
.unwrap();
}
if !nested_joins.is_empty() {
subquery.push_str(&nested_joins.join("\n"));
subquery.push('\n');
}
write!(subquery, "WHERE {join_condition}").unwrap();
if !rel_clone.conditions.is_empty() {
let adapted: Vec<Condition> = rel_clone
.conditions
.iter()
.cloned()
.map(|mut c| {
c.column = format!("{child_alias}.{}", c.column);
c
})
.collect();
let (where_clause, _) = build_where_clause(&adapted);
subquery.push_str(&where_clause);
}
if many_to_one {
subquery.push_str(" LIMIT 1");
}
let join_type = match rel.join {
JoinKind::Left => "LEFT JOIN",
JoinKind::Inner => "JOIN",
};
let agg_alias = format!("{child_alias}_agg");
let mut join_sql =
format!("{join_type} LATERAL (\n{subquery}\n) {agg_alias} ON ");
if rel.join == JoinKind::Inner {
join_sql.push_str(&format!("{agg_alias}.data IS NOT NULL"));
} else {
join_sql.push_str("TRUE");
}
joins.push(join_sql);
field_pairs.push(format!("'{}', {agg_alias}.data", rel.display_name()));
}
}
}
let row_expr = if field_pairs.is_empty() {
format!("to_jsonb({alias})")
} else {
format!("jsonb_build_object({})", field_pairs.join(", "))
};
Ok((row_expr, joins))
}
fn next_alias(&mut self, base: &str) -> String {
let alias = format!("{}_{}", base, self.alias_counter);
self.alias_counter += 1;
alias
}
}
fn relation_join_condition(
relation: &RelationNode,
parent_alias: &str,
parent_table: &str,
child_alias: &str,
) -> (String, bool) {
if let Some(fk) = &relation.foreign_key {
let fk_lower = fk.to_lowercase();
if let Some(stripped) = fk_lower.strip_prefix("parent.") {
return (
format!("{parent_alias}.{stripped} = {child_alias}.id"),
true,
);
}
if let Some(stripped) = fk_lower.strip_prefix("child.") {
return (
format!("{child_alias}.{stripped} = {parent_alias}.id"),
false,
);
}
let rel_singular = relation.name.trim_end_matches('s').to_lowercase();
let rel_lower = relation.name.to_lowercase();
let fk_suggests_parent_side =
fk_lower.contains(&rel_lower) || fk_lower.contains(&rel_singular);
return if fk_suggests_parent_side {
(format!("{parent_alias}.{fk} = {child_alias}.id"), true)
} else {
(format!("{child_alias}.{fk} = {parent_alias}.id"), false)
};
}
let child_fk = format!("{}_id", parent_table.trim_end_matches('s'));
let parent_fk = format!("{}_id", relation.name.trim_end_matches('s'));
let many_to_one = relation.name.len() >= parent_table.len();
if many_to_one {
(
format!("{parent_alias}.{parent_fk} = {child_alias}.id"),
true,
)
} else {
(
format!("{child_alias}.{child_fk} = {parent_alias}.id"),
false,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_nested_supabase_select() {
let select = "id,name,instruments!inner(id,name),start_scan:scans!scan_id_start(id)";
let nodes = parse_select(&Some(select.to_string()), &[]).expect("parse ok");
assert_eq!(nodes.len(), 4);
match &nodes[2] {
SelectNode::Relation(rel) => {
assert_eq!(rel.name, "instruments");
assert!(matches!(rel.join, JoinKind::Inner));
assert_eq!(rel.children.len(), 2);
}
_ => panic!("expected relation"),
}
}
#[test]
fn postgrest_translator_builds_query_string() {
let translator = PostgrestTranslator;
let query = SelectQuery {
table: "orchestral_sections".to_string(),
columns: vec![],
raw_select: Some("id,name,instruments(id,name)".to_string()),
conditions: vec![Condition::new(
"instruments.name",
ConditionOperator::Eq,
vec![Value::String("flute".to_string())],
)],
order_by: vec![],
limit: Some(10),
offset: Some(5),
};
let translated = translator.translate_select(&query).expect("translate ok");
assert_eq!(translated.language, QueryLanguage::Postgrest);
assert!(
translated
.sql
.contains("select=id,name,instruments(id,name)")
);
assert!(translated.sql.contains("instruments.name=eq.flute"));
assert!(translated.sql.contains("limit=10"));
assert!(translated.sql.contains("offset=5"));
}
#[test]
fn sql_translator_builds_lateral_join() {
let translator = SqlTranslator;
let query = SelectQuery {
table: "orchestral_sections".to_string(),
columns: vec![],
raw_select: Some("id,name,instruments(id,name)".to_string()),
conditions: vec![],
order_by: vec![],
limit: None,
offset: None,
};
let translated = translator.translate_select(&query).expect("translate ok");
assert_eq!(translated.language, QueryLanguage::Sql);
assert!(translated.sql.contains("jsonb_agg"));
assert!(translated.sql.contains("FROM instruments instruments_0"));
assert!(translated.sql.contains("instruments_0_agg as instruments"));
}
#[test]
fn sql_translator_child_side_fk_uses_jsonb_agg() {
let translator = SqlTranslator;
let query = SelectQuery {
table: "authors".to_string(),
columns: vec![],
raw_select: Some("id,name,posts!author_id(id,title)".to_string()),
conditions: vec![],
order_by: vec![],
limit: None,
offset: None,
};
let translated = translator.translate_select(&query).expect("translate ok");
assert_eq!(translated.language, QueryLanguage::Sql);
assert!(
translated.sql.contains("posts_0.author_id = t0.id"),
"expected child-side join condition, got: {}",
translated.sql
);
assert!(
translated.sql.contains("jsonb_agg"),
"expected jsonb_agg for one-to-many, got: {}",
translated.sql
);
assert!(
!translated.sql.contains("LIMIT 1"),
"one-to-many should not use LIMIT 1, got: {}",
translated.sql
);
}
}