@@ -1969,4 +1969,243 @@ void ForwardPacSettingsIfLoaded(ClientContext &context, Connection &con) {
19691969 }
19701970}
19711971
1972+ static bool SubtreeContainsComparisonJoin (LogicalOperator *op) {
1973+ if (!op) {
1974+ return false ;
1975+ }
1976+ if (op->type == LogicalOperatorType::LOGICAL_COMPARISON_JOIN || op->type == LogicalOperatorType::LOGICAL_ANY_JOIN ||
1977+ op->type == LogicalOperatorType::LOGICAL_DELIM_JOIN ) {
1978+ return true ;
1979+ }
1980+ for (auto &child : op->children ) {
1981+ if (SubtreeContainsComparisonJoin (child.get ())) {
1982+ return true ;
1983+ }
1984+ }
1985+ return false ;
1986+ }
1987+
1988+ // LEFT-JOIN pipeline secondary-delta INSERT (Larson & Zhou). Returns "" if unsupported (caller falls back
1989+ // to current behavior — no regression). Fixes the deepest LEFT JOIN's preserved-side aggregate undercount
1990+ // when an inner-table change flips a join key's match-count across zero. Self-contained SQL: it re-reads the
1991+ // per-source delta timestamp via subquery, so refresh runs it verbatim (no substitution, no plan walk).
1992+ string BuildLeftJoinSecondaryDeltaSQL (ClientContext &context, const CreateMVPlanFacts &facts,
1993+ const vector<string> &output_names, const string &view_name,
1994+ vector<string> &preserved_cols) {
1995+ preserved_cols.clear ();
1996+ if (facts.aggregates .size () != 1 ) {
1997+ return " " ;
1998+ }
1999+ auto *oj = facts.first_comparison_join ;
2000+ if (!oj || oj->join_type != JoinType::LEFT || oj->conditions .empty () || oj->children .size () != 2 ) {
2001+ return " " ;
2002+ }
2003+ // Only PIPELINES need the secondary: for a single LEFT JOIN the primary delta already emits the
2004+ // null-padded reappearance. Require the preserved (left) side to itself contain a join.
2005+ if (!SubtreeContainsComparisonJoin (oj->children [0 ].get ())) {
2006+ return " " ;
2007+ }
2008+ auto &agg = *facts.aggregates [0 ];
2009+ size_t G = agg.groups .size ();
2010+ if (G == 0 || output_names.size () < G + agg.expressions .size ()) {
2011+ return " " ;
2012+ }
2013+ // Deepest-join keys: condition.left = preserved side, condition.right = inner (null) side.
2014+ auto *pres_ref = GetColumnRefThroughCasts (oj->conditions [0 ].left .get ());
2015+ auto *inner_ref = GetColumnRefThroughCasts (oj->conditions [0 ].right .get ());
2016+ if (!pres_ref || !inner_ref) {
2017+ return " " ;
2018+ }
2019+ ColumnBinding key_pres = pres_ref->binding ;
2020+ LogicalGet *inner_get = nullptr ;
2021+ string inner_col;
2022+ if (!ResolveBindingToGetColumn (inner_ref->binding , facts, inner_get, inner_col) || !inner_get ||
2023+ !inner_get->GetTable ().get ()) {
2024+ return " " ;
2025+ }
2026+ idx_t inner_tidx = inner_get->table_index ;
2027+ string inner_table = inner_get->GetTable ().get ()->name ;
2028+ auto sti = facts.source_table_info .find (inner_table);
2029+ if (sti == facts.source_table_info .end ()) {
2030+ return " " ;
2031+ }
2032+ string cat = sti->second .catalog_name ;
2033+ string sch = sti->second .schema_name ;
2034+ string qual;
2035+ if (!cat.empty ()) {
2036+ qual += KeywordHelper::WriteOptionallyQuoted (cat) + " ." ;
2037+ }
2038+ if (!sch.empty ()) {
2039+ qual += KeywordHelper::WriteOptionallyQuoted (sch) + " ." ;
2040+ }
2041+ string inner_base = qual + KeywordHelper::WriteOptionallyQuoted (inner_table);
2042+ string delta_inner_bare = SqlUtils::DeltaName (inner_table);
2043+ string delta_inner = qual + KeywordHelper::WriteOptionallyQuoted (delta_inner_bare);
2044+ string qcol = KeywordHelper::WriteOptionallyQuoted (inner_col);
2045+
2046+ // The correction below assumes a dangling (null-padded) row for this key is ALREADY materialized
2047+ // in the view -- true only if the preserved-side row (e.g. the order) existed before this refresh
2048+ // batch. If the preserved-side row is itself brand new in this same batch (e.g. a freshly inserted
2049+ // order that immediately gets its first line), no dangling row was ever materialized, and applying
2050+ // this correction would spuriously subtract a row that never existed. Resolve the preserved side's
2051+ // own base table/column so we can gate on "did this key exist before this batch's delta."
2052+ LogicalGet *pres_get = nullptr ;
2053+ string pres_col;
2054+ if (!ResolveBindingToGetColumn (key_pres, facts, pres_get, pres_col) || !pres_get || !pres_get->GetTable ().get ()) {
2055+ return " " ;
2056+ }
2057+ string pres_table = pres_get->GetTable ().get ()->name ;
2058+ auto pres_sti = facts.source_table_info .find (pres_table);
2059+ if (pres_sti == facts.source_table_info .end ()) {
2060+ return " " ;
2061+ }
2062+ string pres_qual;
2063+ if (!pres_sti->second .catalog_name .empty ()) {
2064+ pres_qual += KeywordHelper::WriteOptionallyQuoted (pres_sti->second .catalog_name ) + " ." ;
2065+ }
2066+ if (!pres_sti->second .schema_name .empty ()) {
2067+ pres_qual += KeywordHelper::WriteOptionallyQuoted (pres_sti->second .schema_name ) + " ." ;
2068+ }
2069+ string pres_base = pres_qual + KeywordHelper::WriteOptionallyQuoted (pres_table);
2070+ string delta_pres_bare = SqlUtils::DeltaName (pres_table);
2071+ string delta_pres = pres_qual + KeywordHelper::WriteOptionallyQuoted (delta_pres_bare);
2072+ string pres_qcol = KeywordHelper::WriteOptionallyQuoted (pres_col);
2073+
2074+ // Classify each aggregate output column's contribution to a null-padded row of the deepest join.
2075+ // output_names layout: [group cols (G)] [visible aggregates (agg.expressions, in order)] [hidden helpers].
2076+ // Visible: resolve the arg's base table — inner-side count/sum -> 0, preserved-side count -> 1
2077+ // (preserved-side sum needs the value: unsupported for now -> bail). count_star (no arg) -> 1.
2078+ // Hidden: openivm_count_star -> 1, openivm_match_count / openivm_nonnull_sum_count* -> 0 (all inner-derived).
2079+ size_t num_agg = output_names.size () - G;
2080+ vector<string> contribs;
2081+ for (size_t j = 0 ; j < num_agg; j++) {
2082+ if (j < agg.expressions .size ()) {
2083+ auto &e = agg.expressions [j];
2084+ if (e->expression_class != ExpressionClass::BOUND_AGGREGATE ) {
2085+ return " " ;
2086+ }
2087+ auto &ba = e->Cast <BoundAggregateExpression>();
2088+ string fn = StringUtil::Lower (ba.function .name );
2089+ if (ba.children .empty ()) {
2090+ contribs.push_back (" 1" ); // count_star: null-padded row still counts
2091+ continue ;
2092+ }
2093+ auto *cref = GetColumnRefThroughCasts (ba.children [0 ].get ());
2094+ if (!cref) {
2095+ return " " ;
2096+ }
2097+ LogicalGet *g = nullptr ;
2098+ string c;
2099+ bool resolved = ResolveBindingToGetColumn (cref->binding , facts, g, c);
2100+ bool is_inner = resolved && g && g->table_index == inner_tidx;
2101+ if (fn == " count" ) {
2102+ contribs.push_back (is_inner ? " 0" : " 1" );
2103+ if (!is_inner) {
2104+ // Preserved-side COUNT: the MERGE must NOT gate it by the (inner-side) match_count.
2105+ preserved_cols.push_back (output_names[G + j]);
2106+ }
2107+ } else if (fn == " sum" ) {
2108+ if (is_inner) {
2109+ contribs.push_back (" 0" );
2110+ } else {
2111+ return " " ; // preserved-side SUM needs the value; defer to a later generalization
2112+ }
2113+ } else {
2114+ return " " ;
2115+ }
2116+ } else {
2117+ const string &col = output_names[G + j];
2118+ if (col.find (" count_star" ) != string::npos) {
2119+ contribs.push_back (" 1" );
2120+ } else if (col.find (" match_count" ) != string::npos || col.find (" nonnull_sum_count" ) != string::npos) {
2121+ contribs.push_back (" 0" );
2122+ } else {
2123+ return " " ;
2124+ }
2125+ }
2126+ }
2127+
2128+ // Build the preserved-side subquery X via LPTS, naming the join key "__k" and each group col "__g<i>".
2129+ auto x_plan = oj->children [0 ]->Copy (context);
2130+ x_plan->ResolveOperatorTypes ();
2131+ auto x_bindings = x_plan->GetColumnBindings ();
2132+ vector<ColumnBinding> group_bindings;
2133+ for (auto &ge : agg.groups ) {
2134+ auto *gr = GetColumnRefThroughCasts (ge.get ());
2135+ if (!gr) {
2136+ return " " ;
2137+ }
2138+ group_bindings.push_back (gr->binding );
2139+ }
2140+ vector<string> x_names (x_bindings.size ());
2141+ int key_pos = -1 ;
2142+ vector<int > group_pos (G, -1 );
2143+ for (size_t i = 0 ; i < x_bindings.size (); i++) {
2144+ x_names[i] = " __x" + std::to_string (i);
2145+ if (x_bindings[i] == key_pres) {
2146+ x_names[i] = " __k" ;
2147+ key_pos = static_cast <int >(i);
2148+ }
2149+ for (size_t gi = 0 ; gi < G; gi++) {
2150+ if (x_bindings[i] == group_bindings[gi]) {
2151+ x_names[i] = " __g" + std::to_string (gi);
2152+ group_pos[gi] = static_cast <int >(i);
2153+ }
2154+ }
2155+ }
2156+ if (key_pos < 0 ) {
2157+ return " " ;
2158+ }
2159+ for (size_t gi = 0 ; gi < G; gi++) {
2160+ if (group_pos[gi] < 0 ) {
2161+ return " " ;
2162+ }
2163+ }
2164+ string x_sql;
2165+ try {
2166+ SqlDialect dialect = SqlDialect::DUCKDB ;
2167+ auto ast = LogicalPlanToAst (context, x_plan, dialect);
2168+ auto cte_list = AstToCteList (*ast, dialect);
2169+ x_sql = cte_list->ToQuery (true , x_names);
2170+ if (!x_sql.empty () && x_sql.back () == ' ;' ) {
2171+ x_sql.pop_back ();
2172+ }
2173+ } catch (...) {
2174+ return " " ;
2175+ }
2176+ if (x_sql.empty ()) {
2177+ return " " ;
2178+ }
2179+
2180+ // delta_mv columns: group cols + aggregate cols + openivm_multiplicity (matches the primary INSERT).
2181+ string col_list;
2182+ string sel;
2183+ for (size_t gi = 0 ; gi < G; gi++) {
2184+ col_list += KeywordHelper::WriteOptionallyQuoted (output_names[gi]) + " , " ;
2185+ sel += " X.\" __g" + std::to_string (gi) + " \" , " ;
2186+ }
2187+ for (size_t j = 0 ; j < contribs.size (); j++) {
2188+ col_list += KeywordHelper::WriteOptionallyQuoted (output_names[G + j]) + " , " ;
2189+ sel += contribs[j] + " , " ;
2190+ }
2191+ col_list += " openivm_multiplicity" ;
2192+ sel += " CASE WHEN (__newc - __t.dsum) > 0 AND __newc = 0 THEN 1 ELSE -1 END" ;
2193+
2194+ string dmv = KeywordHelper::WriteOptionallyQuoted (SqlUtils::DeltaName (view_name));
2195+ string sql = " INSERT INTO " + dmv + " (" + col_list + " )\n SELECT " + sel + " \n FROM (SELECT " + qcol +
2196+ " AS __k, SUM(openivm_multiplicity) AS dsum FROM " + delta_inner +
2197+ " WHERE openivm_timestamp >= (SELECT last_update FROM " + string (openivm::DELTA_TABLES_TABLE ) +
2198+ " WHERE view_name = '" + SqlUtils::EscapeValue (view_name) + " ' AND table_name = '" +
2199+ SqlUtils::EscapeValue (delta_inner_bare) + " ') GROUP BY " + qcol + " ) __t\n JOIN (" + x_sql +
2200+ " ) X ON X.\" __k\" = __t.__k,\n LATERAL (SELECT (SELECT COUNT(*) FROM " + inner_base + " ib WHERE ib." +
2201+ qcol + " = __t.__k) AS __newc) __n,\n LATERAL (SELECT (SELECT COUNT(*) FROM " + pres_base +
2202+ " pb WHERE pb." + pres_qcol + " = __t.__k) - COALESCE((SELECT SUM(openivm_multiplicity) FROM " +
2203+ delta_pres + " WHERE " + pres_qcol + " = __t.__k AND openivm_timestamp >= (SELECT last_update FROM " +
2204+ string (openivm::DELTA_TABLES_TABLE ) + " WHERE view_name = '" + SqlUtils::EscapeValue (view_name) +
2205+ " ' AND table_name = '" + SqlUtils::EscapeValue (delta_pres_bare) +
2206+ " ')), 0) AS __old_pres_count) __op\n WHERE (__newc > 0) <> ((__newc - __t.dsum) > 0) AND "
2207+ " __old_pres_count > 0;" ;
2208+ return sql;
2209+ }
2210+
19722211} // namespace duckdb
0 commit comments