use rudb_common::{LogicalType, Provenance, Result, Stat};
use rudb_plan::{
BuildSide, ColumnBinding, CompareOp, Expr, ExprRef, JoinKind, Node, NodeRef, Plan, Slice,
};
use crate::estimate::{self, Facts};
use crate::pass::{Context, Pass, top_down};
use crate::tables::{TableSet, produced};
use crate::walk;
const WORTH_IT: u64 = 10;
#[derive(Debug, Clone, Copy)]
pub struct GroupKeyPushdown;
impl Pass for GroupKeyPushdown {
fn name(&self) -> &'static str {
"group_key_pushdown"
}
fn run(&self, plan: &mut Plan, context: &Context) -> Result<()> {
push(plan, context.facts());
Ok(())
}
}
pub fn push(plan: &mut Plan, stats: &Facts) {
for _ in 0..plan.node_count() {
let found = top_down(plan).into_iter().find_map(|node| matched(plan, node, stats));
let Some(found) = found else { return };
let Some(rebuilt) = written(plan, &found) else { return };
let mut changed = false;
let root = walk::restack(plan, plan.root(), &mut changed, &mut |_, here| {
(here == found.aggregate).then_some(rebuilt)
});
if !changed {
return;
}
plan.set_root(root);
}
}
struct Push {
aggregate: NodeRef,
source: NodeRef,
pairs: Vec<Pair>,
}
struct Pair {
group: ExprRef,
key: ExprRef,
op: CompareOp,
ty: LogicalType,
}
fn matched(plan: &Plan, node: NodeRef, stats: &Facts) -> Option<Push> {
let Node::Join { left, right, kind, conditions, .. } = *plan.node(node) else {
return None;
};
if !drops_unmatched(kind) {
return None;
}
let outer = produced(plan, left);
let mut aggregate = None;
let mut pairs = Vec::new();
for &condition in plan.expr_list(conditions) {
let Expr::Compare { op, left: one, right: other } = *plan.expr(condition) else {
continue;
};
if !matches!(op, CompareOp::Equal | CompareOp::NotDistinctFrom) {
continue;
}
for (inner, held) in [(one, other), (other, one)] {
let Expr::Column(binding) = *plan.expr(inner) else {
continue;
};
let Expr::Column(read) = *plan.expr(held) else {
continue;
};
if !outer.contains(read.table) {
continue;
}
let Some((at, position)) = traced(plan, right, binding) else {
continue;
};
if aggregate.is_some_and(|was| was != at) {
continue;
}
let Node::Aggregate { groups, .. } = *plan.node(at) else {
continue;
};
let Some(&group) = plan.expr_list(groups).get(position) else {
continue;
};
if !matches!(*plan.expr(group), Expr::Column(_))
|| plan.expr_type(group) != plan.expr_type(held)
{
continue;
}
aggregate = Some(at);
pairs.push(Pair { group, key: held, op, ty: plan.expr_type(condition).clone() });
break;
}
}
let aggregate = aggregate.filter(|_| !pairs.is_empty())?;
let Node::Aggregate { input, .. } = *plan.node(aggregate) else {
return None;
};
if matches!(*plan.node(input), Node::Join { kind: JoinKind::Semi, .. }) {
return None;
}
let mut wanted = TableSet::new();
for pair in &pairs {
walk::columns(plan, pair.key, &mut |binding| wanted.insert(binding.table));
}
let source = descent(plan, left, &wanted)
.into_iter()
.rev()
.find(|&at| copyable(plan, at) && worth_copying(plan, at, input, stats))?;
Some(Push { aggregate, source, pairs })
}
fn worth_copying(plan: &Plan, source: NodeRef, input: NodeRef, stats: &Facts) -> bool {
let Some(keys) = estimate::side(plan, source, stats) else { return false };
if keys.rows >= keys.base {
return false;
}
if joined(plan, source) && !measured(plan, source, stats) {
return true;
}
estimate::rows(plan, input, stats)
.is_some_and(|reads| keys.rows.saturating_mul(WORTH_IT) <= reads)
}
fn joined(plan: &Plan, at: NodeRef) -> bool {
matches!(*plan.node(at), Node::Join { .. })
|| plan.node(at).children().into_iter().flatten().any(|child| joined(plan, child))
}
fn measured(plan: &Plan, at: NodeRef, stats: &Facts) -> bool {
matches!(
estimate::rows_stat(plan, at, stats),
Stat::Known { provenance, .. } if provenance != Provenance::Default
)
}
fn descent(plan: &Plan, at: NodeRef, wanted: &TableSet) -> Vec<NodeRef> {
let mut path = Vec::new();
let mut here = at;
if !wanted.is_subset_of(&produced(plan, here)) {
return path;
}
loop {
path.push(here);
if produced(plan, here).is_subset_of(wanted) {
return path;
}
let below = plan
.node(here)
.children()
.into_iter()
.flatten()
.find(|&child| wanted.is_subset_of(&produced(plan, child)));
let Some(below) = below else { return path };
here = below;
}
}
const fn drops_unmatched(kind: JoinKind) -> bool {
match kind {
JoinKind::Inner
| JoinKind::Left
| JoinKind::Semi
| JoinKind::Anti
| JoinKind::Mark
| JoinKind::Single => true,
JoinKind::Right | JoinKind::Full | JoinKind::Positional => false,
}
}
fn traced(plan: &Plan, at: NodeRef, binding: ColumnBinding) -> Option<(NodeRef, usize)> {
match *plan.node(at) {
Node::Aggregate { index, groups, .. } if index == binding.table => {
let position = usize::try_from(binding.column).ok()?;
(position < plan.expr_list(groups).len()).then_some((at, position))
}
Node::Project { input, index, exprs, .. } if index == binding.table => {
let position = usize::try_from(binding.column).ok()?;
let &expr = plan.expr_list(exprs).get(position)?;
let Expr::Column(below) = *plan.expr(expr) else {
return None;
};
traced(plan, input, below)
}
Node::Filter { input, .. } => traced(plan, input, binding),
_ => None,
}
}
fn copyable(plan: &Plan, at: NodeRef) -> bool {
match *plan.node(at) {
Node::Get { .. } | Node::TableFunction { .. } => true,
Node::Filter { input, predicate } => {
!walk::volatile(plan, predicate) && copyable(plan, input)
}
Node::Project { input, exprs, .. } => {
plan.expr_list(exprs).iter().all(|&expr| !walk::volatile(plan, expr))
&& copyable(plan, input)
}
Node::Join { left, right, kind: JoinKind::Inner | JoinKind::Semi, conditions, .. } => {
plan.expr_list(conditions).iter().all(|&expr| !walk::volatile(plan, expr))
&& copyable(plan, left)
&& copyable(plan, right)
}
_ => false,
}
}
fn written(plan: &mut Plan, push: &Push) -> Option<NodeRef> {
let Node::Aggregate { input, index, groups, aggregates } = *plan.node(push.aggregate) else {
return None;
};
let mut renames = Vec::new();
let source = copied(plan, push.source, &mut renames)?;
let mut conditions = Vec::new();
for pair in &push.pairs {
let against = renamed(plan, pair.key, &renames);
if against == pair.key {
return None;
}
let compare = Expr::Compare { op: pair.op, left: pair.group, right: against };
conditions.push(plan.add_expr(compare, pair.ty.clone()));
}
let conditions = plan.add_expr_list(&conditions);
let filtered = plan.add_node(Node::Join {
left: input,
right: source,
kind: JoinKind::Semi,
conditions,
build: BuildSide::Right,
});
Some(plan.add_node(Node::Aggregate { input: filtered, index, groups, aggregates }))
}
fn copied(plan: &mut Plan, at: NodeRef, renames: &mut Vec<(u32, u32)>) -> Option<NodeRef> {
let span = plan.node_span(at);
match plan.node(at).clone() {
Node::Get { catalog, schema, table, alias, index, columns } => {
let fresh = walk::fresh_index(plan);
let copy = Node::Get { catalog, schema, table, alias, index: fresh, columns };
let node = plan.add_node_at(copy, span);
carry(plan, index, fresh, columns);
renames.push((index, fresh));
Some(node)
}
Node::TableFunction { index, function, args, options, settings, columns } => {
let fresh = walk::fresh_index(plan);
let copy =
Node::TableFunction { index: fresh, function, args, options, settings, columns };
let node = plan.add_node_at(copy, span);
carry(plan, index, fresh, columns);
renames.push((index, fresh));
Some(node)
}
Node::Filter { input, predicate } => {
let below = copied(plan, input, renames)?;
let predicate = renamed(plan, predicate, renames);
Some(plan.add_node_at(Node::Filter { input: below, predicate }, span))
}
Node::Project { input, index, exprs, names } => {
let below = copied(plan, input, renames)?;
let held = plan.expr_list(exprs).to_vec();
let rewritten: Vec<ExprRef> =
held.iter().map(|&expr| renamed(plan, expr, renames)).collect();
let exprs = plan.add_expr_list(&rewritten);
let fresh = walk::fresh_index(plan);
let copy = Node::Project { input: below, index: fresh, exprs, names };
let node = plan.add_node_at(copy, span);
renames.push((index, fresh));
Some(node)
}
Node::Join { left, right, kind, conditions, build } => {
let below = copied(plan, left, renames)?;
let beside = copied(plan, right, renames)?;
let held = plan.expr_list(conditions).to_vec();
let rewritten: Vec<ExprRef> =
held.iter().map(|&expr| renamed(plan, expr, renames)).collect();
let conditions = plan.add_expr_list(&rewritten);
let copy = Node::Join { left: below, right: beside, kind, conditions, build };
Some(plan.add_node_at(copy, span))
}
_ => None,
}
}
fn carry(plan: &mut Plan, was: u32, fresh: u32, columns: Slice) {
plan.measure(fresh, plan.measured(was));
if let Some(zones) = plan.zones(was).cloned() {
plan.set_zones(fresh, zones);
}
for name in plan.field_list(columns).iter().map(|field| field.name.clone()).collect::<Vec<_>>()
{
let distinct = plan.distinct_measured(was, &name);
if !matches!(distinct, Stat::Unknown) {
plan.measure_distinct(fresh, &name, distinct);
}
}
}
fn renamed(plan: &mut Plan, expr: ExprRef, renames: &[(u32, u32)]) -> ExprRef {
if let Expr::Column(binding) = *plan.expr(expr) {
let Some(&(_, fresh)) = renames.iter().find(|&&(was, _)| was == binding.table) else {
return expr;
};
let ty = plan.expr_type(expr).clone();
let span = plan.expr_span(expr);
let moved = Expr::Column(ColumnBinding::new(fresh, binding.column));
return plan.add_expr_at(moved, ty, span);
}
walk::rebuild(plan, expr, &mut |plan, inner| renamed(plan, inner, renames))
}
#[cfg(test)]
mod tests {
use rudb_plan::Plan;
use super::push;
use crate::estimate::Facts;
fn counts() -> Facts {
let mut counts = Facts::new();
for (table, rows) in [("t", 1000), ("u", 10), ("v", 100), ("w", 100_000)] {
counts.record("memory", "main", table, rows);
}
counts
}
fn pushed(text: &str) -> String {
let counts = counts();
let mut plan =
Plan::parse(text).unwrap_or_else(|error| panic!("{text} did not parse: {error}"));
push(&mut plan, &counts);
plan.validate().unwrap_or_else(|error| panic!("{text} did not stay valid: {error}"));
let once = plan.to_string();
push(&mut plan, &counts);
assert_eq!(plan.to_string(), once, "{text} pushed again on a second run");
once
}
#[test]
fn the_keys_of_a_filtered_source_reach_the_aggregate() {
assert_eq!(
pushed(concat!(
"Join SINGLE on=[(#1.0::INTEGER = #0.0::INTEGER)::BOOLEAN]\n",
" Filter (#0.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #0 [k::INTEGER, b::INTEGER]\n",
" Aggregate #1 groups=[#2.0::INTEGER] aggregates=[count_star()::BIGINT]\n",
" Get memory.main.w AS w #2 [k::INTEGER, v::INTEGER]\n",
)),
concat!(
"Join SINGLE on=[(#1.0::INTEGER = #0.0::INTEGER)::BOOLEAN]\n",
" Filter (#0.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #0 [k::INTEGER, b::INTEGER]\n",
" Aggregate #1 groups=[#2.0::INTEGER] aggregates=[count_star()::BIGINT]\n",
" Join SEMI on=[(#2.0::INTEGER = #3.0::INTEGER)::BOOLEAN]\n",
" Get memory.main.w AS w #2 [k::INTEGER, v::INTEGER]\n",
" Filter (#3.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #3 [k::INTEGER, b::INTEGER]\n",
)
);
}
#[test]
fn the_aggregate_is_found_through_the_projection_decorrelation_leaves_over_it() {
assert_eq!(
pushed(concat!(
"Join SINGLE on=[(#3.0::INTEGER = #0.0::INTEGER)::BOOLEAN]\n",
" Filter (#0.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #0 [k::INTEGER, b::INTEGER]\n",
" Project #3 [#1.0::INTEGER AS k, #1.1::BIGINT AS n]\n",
" Aggregate #1 groups=[#2.0::INTEGER] aggregates=[count_star()::BIGINT]\n",
" Get memory.main.w AS w #2 [k::INTEGER, v::INTEGER]\n",
)),
concat!(
"Join SINGLE on=[(#3.0::INTEGER = #0.0::INTEGER)::BOOLEAN]\n",
" Filter (#0.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #0 [k::INTEGER, b::INTEGER]\n",
" Project #3 [#1.0::INTEGER AS k, #1.1::BIGINT AS n]\n",
" Aggregate #1 groups=[#2.0::INTEGER] aggregates=[count_star()::BIGINT]\n",
" Join SEMI on=[(#2.0::INTEGER = #4.0::INTEGER)::BOOLEAN]\n",
" Get memory.main.w AS w #2 [k::INTEGER, v::INTEGER]\n",
" Filter (#4.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #4 [k::INTEGER, b::INTEGER]\n",
)
);
}
#[test]
fn a_semi_join_already_under_the_aggregate_is_left_where_it_is() {
let text = concat!(
"Join SINGLE on=[(#1.0::INTEGER = #0.0::INTEGER)::BOOLEAN]\n",
" Filter (#0.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #0 [k::INTEGER, b::INTEGER]\n",
" Aggregate #1 groups=[#2.0::INTEGER] aggregates=[count_star()::BIGINT]\n",
" Join SEMI on=[(#2.0::INTEGER = #3.0::INTEGER)::BOOLEAN]\n",
" Get memory.main.w AS w #2 [k::INTEGER, v::INTEGER]\n",
" Filter (#3.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #3 [k::INTEGER, b::INTEGER]\n",
);
assert_eq!(pushed(text), text);
}
#[test]
fn a_source_nothing_filters_is_refused() {
let text = concat!(
"Join INNER on=[(#1.0::INTEGER = #0.0::INTEGER)::BOOLEAN]\n",
" Get memory.main.u AS u #0 [k::INTEGER, b::INTEGER]\n",
" Aggregate #1 groups=[#2.0::INTEGER] aggregates=[count_star()::BIGINT]\n",
" Get memory.main.w AS w #2 [k::INTEGER, v::INTEGER]\n",
);
assert_eq!(pushed(text), text);
}
#[test]
fn the_keys_of_an_unfiltered_source_reach_the_aggregate_through_the_join_that_restricts_them() {
let text = concat!(
"Join SINGLE on=[(#1.0::INTEGER = #0.0::INTEGER)::BOOLEAN]\n",
" Join INNER on=[(#0.1::INTEGER = #4.0::INTEGER)::BOOLEAN]\n",
" Get memory.main.w AS w #0 [k::INTEGER, b::INTEGER]\n",
" Filter (#4.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #4 [k::INTEGER, c::INTEGER]\n",
" Aggregate #1 groups=[#2.0::INTEGER] aggregates=[count_star()::BIGINT]\n",
" Get memory.main.t AS t #2 [k::INTEGER, v::INTEGER]\n",
);
assert_eq!(
pushed(text),
concat!(
"Join SINGLE on=[(#1.0::INTEGER = #0.0::INTEGER)::BOOLEAN]\n",
" Join INNER on=[(#0.1::INTEGER = #4.0::INTEGER)::BOOLEAN]\n",
" Get memory.main.w AS w #0 [k::INTEGER, b::INTEGER]\n",
" Filter (#4.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #4 [k::INTEGER, c::INTEGER]\n",
" Aggregate #1 groups=[#2.0::INTEGER] aggregates=[count_star()::BIGINT]\n",
" Join SEMI on=[(#2.0::INTEGER = #5.0::INTEGER)::BOOLEAN]\n",
" Get memory.main.t AS t #2 [k::INTEGER, v::INTEGER]\n",
" Join INNER on=[(#5.1::INTEGER = #6.0::INTEGER)::BOOLEAN]\n",
" Get memory.main.w AS w #5 [k::INTEGER, b::INTEGER]\n",
" Filter (#6.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #6 [k::INTEGER, c::INTEGER]\n",
)
);
}
#[test]
fn a_source_too_big_against_what_the_aggregate_reads_is_refused() {
let text = concat!(
"Join SINGLE on=[(#1.0::INTEGER = #0.0::INTEGER)::BOOLEAN]\n",
" Filter (#0.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.w AS w #0 [k::INTEGER, b::INTEGER]\n",
" Aggregate #1 groups=[#2.0::INTEGER] aggregates=[count_star()::BIGINT]\n",
" Get memory.main.t AS t #2 [k::INTEGER, v::INTEGER]\n",
);
assert_eq!(pushed(text), text);
}
#[test]
fn a_source_the_copy_cannot_reproduce_is_refused() {
let text = concat!(
"Join SINGLE on=[(#1.0::INTEGER = #0.0::INTEGER)::BOOLEAN]\n",
" Filter (#0.0::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Aggregate #0 groups=[#4.0::INTEGER] aggregates=[]\n",
" Get memory.main.u AS u #4 [k::INTEGER]\n",
" Aggregate #1 groups=[#2.0::INTEGER] aggregates=[count_star()::BIGINT]\n",
" Get memory.main.w AS w #2 [k::INTEGER, v::INTEGER]\n",
);
assert_eq!(pushed(text), text);
}
#[test]
fn a_join_whose_unmatched_groups_reach_the_answer_is_refused() {
let text = concat!(
"Join RIGHT on=[(#1.0::INTEGER = #0.0::INTEGER)::BOOLEAN]\n",
" Filter (#0.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #0 [k::INTEGER, b::INTEGER]\n",
" Aggregate #1 groups=[#2.0::INTEGER] aggregates=[count_star()::BIGINT]\n",
" Get memory.main.w AS w #2 [k::INTEGER, v::INTEGER]\n",
);
assert_eq!(pushed(text), text);
}
#[test]
fn a_group_that_is_not_a_bare_column_is_refused() {
let text = concat!(
"Join SINGLE on=[(#1.0::INTEGER = #0.0::INTEGER)::BOOLEAN]\n",
" Filter (#0.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #0 [k::INTEGER, b::INTEGER]\n",
" Aggregate #1 groups=[\"+\"(#2.0::INTEGER, 1::INTEGER)::INTEGER] aggregates=[count_star()::BIGINT]\n",
" Get memory.main.w AS w #2 [k::INTEGER, v::INTEGER]\n",
);
assert_eq!(pushed(text), text);
}
#[test]
fn a_condition_over_something_other_than_a_group_is_refused() {
let text = concat!(
"Join SINGLE on=[(#1.1::BIGINT = #0.0::BIGINT)::BOOLEAN]\n",
" Filter (#0.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #0 [k::BIGINT, b::INTEGER]\n",
" Aggregate #1 groups=[#2.0::INTEGER] aggregates=[count_star()::BIGINT]\n",
" Get memory.main.w AS w #2 [k::INTEGER, v::INTEGER]\n",
);
assert_eq!(pushed(text), text);
}
#[test]
fn the_keys_of_the_twentieth_query_come_from_the_semi_join_and_not_the_scan_under_it() {
let text = concat!(
"Filter (#2.2::INTEGER > #8.0::INTEGER)::BOOLEAN\n",
" Join SINGLE on=[(#8.1::INTEGER = #2.0::INTEGER)::BOOLEAN, (#8.2::INTEGER = #2.1::INTEGER)::BOOLEAN]\n",
" Join SEMI on=[(#2.0::INTEGER = #5.0::INTEGER)::BOOLEAN]\n",
" Get memory.main.w AS w #2 [k::INTEGER, s::INTEGER, q::INTEGER]\n",
" Project #5 [#3.0::INTEGER AS k]\n",
" Filter (#3.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #3 [k::INTEGER, c::INTEGER]\n",
" Project #8 [#7.2::INTEGER AS a, #7.0::INTEGER AS k, #7.1::INTEGER AS s]\n",
" Aggregate #7 groups=[#6.0::INTEGER, #6.1::INTEGER] aggregates=[sum(#6.2::INTEGER)::INTEGER]\n",
" Filter (#6.3::INTEGER > 1::INTEGER)::BOOLEAN\n",
" Get memory.main.t AS t #6 [k::INTEGER, s::INTEGER, q::INTEGER, d::INTEGER]\n",
);
let mut counts = Facts::new();
for (table, rows) in [("t", 60_000_000), ("u", 2_000_000), ("w", 8_000_000)] {
counts.record("memory", "main", table, rows);
}
let mut plan = Plan::parse(text).unwrap();
push(&mut plan, &counts);
assert_eq!(
plan.to_string(),
concat!(
"Filter (#2.2::INTEGER > #8.0::INTEGER)::BOOLEAN\n",
" Join SINGLE on=[(#8.1::INTEGER = #2.0::INTEGER)::BOOLEAN, (#8.2::INTEGER = #2.1::INTEGER)::BOOLEAN]\n",
" Join SEMI on=[(#2.0::INTEGER = #5.0::INTEGER)::BOOLEAN]\n",
" Get memory.main.w AS w #2 [k::INTEGER, s::INTEGER, q::INTEGER]\n",
" Project #5 [#3.0::INTEGER AS k]\n",
" Filter (#3.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #3 [k::INTEGER, c::INTEGER]\n",
" Project #8 [#7.2::INTEGER AS a, #7.0::INTEGER AS k, #7.1::INTEGER AS s]\n",
" Aggregate #7 groups=[#6.0::INTEGER, #6.1::INTEGER] aggregates=[sum(#6.2::INTEGER)::INTEGER]\n",
" Join SEMI on=[(#6.0::INTEGER = #9.0::INTEGER)::BOOLEAN, (#6.1::INTEGER = #9.1::INTEGER)::BOOLEAN]\n",
" Filter (#6.3::INTEGER > 1::INTEGER)::BOOLEAN\n",
" Get memory.main.t AS t #6 [k::INTEGER, s::INTEGER, q::INTEGER, d::INTEGER]\n",
" Join SEMI on=[(#9.0::INTEGER = #11.0::INTEGER)::BOOLEAN]\n",
" Get memory.main.w AS w #9 [k::INTEGER, s::INTEGER, q::INTEGER]\n",
" Project #11 [#10.0::INTEGER AS k]\n",
" Filter (#10.1::INTEGER = 3::INTEGER)::BOOLEAN\n",
" Get memory.main.u AS u #10 [k::INTEGER, c::INTEGER]\n",
)
);
}
}