FrankChen021 commented on code in PR #19643:
URL: https://github.com/apache/druid/pull/19643#discussion_r3505744946


##########
extensions-contrib/openlineage-emitter/src/main/java/org/apache/druid/extensions/openlineage/OpenLineageRequestLogger.java:
##########
@@ -309,11 +347,275 @@ static String extractMsqOutputDatasource(String sql)
     }
   }
 
+  /**
+   * Column usage roles for the custom {@code druid_columnUsage} dataset 
facet. Names align with
+   * OpenLineage transformation subtypes where they exist (GROUP_BY, 
AGGREGATION, FILTER, JOIN);
+   * PROJECTION corresponds to OpenLineage IDENTITY/TRANSFORMATION (a 
selected/projected column).
+   */
+  enum ColumnRole
+  {
+    PROJECTION, GROUP_BY, AGGREGATION, FILTER, JOIN
+  }
+
+  /**
+   * Resolves the per-base-table column-usage map for a native query, used to 
attach the {@code schema}
+   * and {@code druid_columnUsage} dataset facets to input datasets. Handles 
all datasource shapes:
+   * tables, joins (attributing columns per side via the planner's join 
prefixes), unions, sub-queries
+   * (recursing into the sub-query's own columns), and datasource wrappers 
(restricted/filtered/unnest).
+   * Columns that have no base table (lookups, inline data) are dropped rather 
than fabricated.
+   *
+   * <p>Returns {@code null} (yielding table-level lineage only) when no 
base-table columns can be
+   * determined, or on any error -- it never fabricates or mis-attributes 
columns.
+   */
+  @Nullable
+  private Map<String, Map<String, EnumSet<ColumnRole>>> 
extractColumnsByTable(Query<?> query)
+  {
+    try {
+      Map<String, Map<String, EnumSet<ColumnRole>>> result = new TreeMap<>();
+      collectInto(result, query);
+      return result.isEmpty() ? null : result;
+    }
+    // StackOverflowError (an Error, not an Exception) is caught too so that a 
pathologically deep
+    // query plan degrades to table-level lineage rather than breaking the 
request-logging path.
+    catch (Exception | StackOverflowError e) {
+      log.debug(e, "Failed to extract column lineage; falling back to 
table-level lineage");
+      return null;
+    }
+  }
+
+  /**
+   * Walks {@code query}'s column-bearing parts and attributes the referenced 
columns to the base
+   * tables of its datasource. {@link UnionQuery} (whose {@code 
getDataSource()} is undefined) is
+   * handled by recursing into each of its branches.
+   */
+  private void collectInto(Map<String, Map<String, EnumSet<ColumnRole>>> 
result, Query<?> query)
+  {
+    if (query instanceof UnionQuery) {
+      for (DataSource branch : ((UnionQuery) query).getDataSources()) {
+        attribute(result, branch, Collections.emptyMap());
+      }
+      return;
+    }
+    attribute(result, query.getDataSource(), collectColumnRoles(query));
+  }
+
+  /**
+   * Attributes {@code roles} (column to roles, expressed in {@code 
dataSource}'s output namespace) to
+   * the underlying base tables, recursing through the datasource tree. 
Sub-queries are recursed into
+   * via {@link #collectInto} (their columns come from their own parts, not 
the outer references).
+   */
+  private void attribute(
+      Map<String, Map<String, EnumSet<ColumnRole>>> result,
+      DataSource dataSource,
+      Map<String, EnumSet<ColumnRole>> roles
+  )
+  {
+    if (dataSource instanceof TableDataSource) {
+      // Also covers GlobalTableDataSource (a TableDataSource subclass); it is 
a real named table, so
+      // handling it here is intentional. Keep this branch ahead of the 
wrapper branches below.
+      String table = ((TableDataSource) dataSource).getName();
+      for (Map.Entry<String, EnumSet<ColumnRole>> entry : roles.entrySet()) {
+        addRoles(result, table, entry.getKey(), entry.getValue());
+      }
+    } else if (dataSource instanceof RestrictedDataSource) {
+      attribute(result, ((RestrictedDataSource) dataSource).getBase(), roles);
+    } else if (dataSource instanceof FilteredDataSource) {
+      attribute(result, ((FilteredDataSource) dataSource).getBase(), roles);

Review Comment:
   [P2] FilteredDataSource filters are omitted from column lineage
   
   This wrapper is unwrapped without adding FilteredDataSource.getFilter() 
columns as FILTER roles. Druid creates this datasource for pushed-down filters, 
so a filtered datasource feeding another wrapper such as UNNEST can emit column 
facets that omit the filter column entirely unless it is also projected or 
aggregated.



##########
extensions-contrib/openlineage-emitter/src/main/java/org/apache/druid/extensions/openlineage/OpenLineageRequestLogger.java:
##########
@@ -309,11 +347,275 @@ static String extractMsqOutputDatasource(String sql)
     }
   }
 
+  /**
+   * Column usage roles for the custom {@code druid_columnUsage} dataset 
facet. Names align with
+   * OpenLineage transformation subtypes where they exist (GROUP_BY, 
AGGREGATION, FILTER, JOIN);
+   * PROJECTION corresponds to OpenLineage IDENTITY/TRANSFORMATION (a 
selected/projected column).
+   */
+  enum ColumnRole
+  {
+    PROJECTION, GROUP_BY, AGGREGATION, FILTER, JOIN
+  }
+
+  /**
+   * Resolves the per-base-table column-usage map for a native query, used to 
attach the {@code schema}
+   * and {@code druid_columnUsage} dataset facets to input datasets. Handles 
all datasource shapes:
+   * tables, joins (attributing columns per side via the planner's join 
prefixes), unions, sub-queries
+   * (recursing into the sub-query's own columns), and datasource wrappers 
(restricted/filtered/unnest).
+   * Columns that have no base table (lookups, inline data) are dropped rather 
than fabricated.
+   *
+   * <p>Returns {@code null} (yielding table-level lineage only) when no 
base-table columns can be
+   * determined, or on any error -- it never fabricates or mis-attributes 
columns.
+   */
+  @Nullable
+  private Map<String, Map<String, EnumSet<ColumnRole>>> 
extractColumnsByTable(Query<?> query)
+  {
+    try {
+      Map<String, Map<String, EnumSet<ColumnRole>>> result = new TreeMap<>();
+      collectInto(result, query);
+      return result.isEmpty() ? null : result;
+    }
+    // StackOverflowError (an Error, not an Exception) is caught too so that a 
pathologically deep
+    // query plan degrades to table-level lineage rather than breaking the 
request-logging path.
+    catch (Exception | StackOverflowError e) {
+      log.debug(e, "Failed to extract column lineage; falling back to 
table-level lineage");
+      return null;
+    }
+  }
+
+  /**
+   * Walks {@code query}'s column-bearing parts and attributes the referenced 
columns to the base
+   * tables of its datasource. {@link UnionQuery} (whose {@code 
getDataSource()} is undefined) is
+   * handled by recursing into each of its branches.
+   */
+  private void collectInto(Map<String, Map<String, EnumSet<ColumnRole>>> 
result, Query<?> query)
+  {
+    if (query instanceof UnionQuery) {
+      for (DataSource branch : ((UnionQuery) query).getDataSources()) {
+        attribute(result, branch, Collections.emptyMap());
+      }
+      return;
+    }
+    attribute(result, query.getDataSource(), collectColumnRoles(query));
+  }
+
+  /**
+   * Attributes {@code roles} (column to roles, expressed in {@code 
dataSource}'s output namespace) to
+   * the underlying base tables, recursing through the datasource tree. 
Sub-queries are recursed into
+   * via {@link #collectInto} (their columns come from their own parts, not 
the outer references).
+   */
+  private void attribute(
+      Map<String, Map<String, EnumSet<ColumnRole>>> result,
+      DataSource dataSource,
+      Map<String, EnumSet<ColumnRole>> roles
+  )
+  {
+    if (dataSource instanceof TableDataSource) {
+      // Also covers GlobalTableDataSource (a TableDataSource subclass); it is 
a real named table, so
+      // handling it here is intentional. Keep this branch ahead of the 
wrapper branches below.
+      String table = ((TableDataSource) dataSource).getName();
+      for (Map.Entry<String, EnumSet<ColumnRole>> entry : roles.entrySet()) {
+        addRoles(result, table, entry.getKey(), entry.getValue());
+      }
+    } else if (dataSource instanceof RestrictedDataSource) {
+      attribute(result, ((RestrictedDataSource) dataSource).getBase(), roles);
+    } else if (dataSource instanceof FilteredDataSource) {
+      attribute(result, ((FilteredDataSource) dataSource).getBase(), roles);
+    } else if (dataSource instanceof UnnestDataSource) {
+      UnnestDataSource unnest = (UnnestDataSource) dataSource;
+      VirtualColumn unnestColumn = unnest.getVirtualColumn();
+      Map<String, EnumSet<ColumnRole>> baseRoles = new TreeMap<>(roles);
+      // The unnest output column is synthetic (not a base column); drop it 
and instead record the
+      // underlying column(s) being unnested as projected from the base.
+      baseRoles.remove(unnestColumn.getOutputName());
+      for (String required : unnestColumn.requiredColumns()) {
+        baseRoles.computeIfAbsent(required, k -> 
EnumSet.noneOf(ColumnRole.class)).add(ColumnRole.PROJECTION);
+      }
+      attribute(result, unnest.getBase(), baseRoles);
+    } else if (dataSource instanceof UnionDataSource) {
+      // Union members share the same output schema, so the referenced columns 
apply to each.
+      for (DataSource member : dataSource.getChildren()) {
+        attribute(result, member, roles);
+      }
+    } else if (dataSource instanceof QueryDataSource) {
+      // The outer references are this sub-query's OUTPUT columns, not base 
columns; the sub-query's
+      // own base-table columns are captured by recursing into its parts.
+      collectInto(result, ((QueryDataSource) dataSource).getQuery());
+    } else if (dataSource instanceof JoinDataSource) {
+      attributeJoin(result, (JoinDataSource) dataSource, roles);
+    }
+    // LookupDataSource, InlineDataSource and any other shape have no base 
table: drop (never fabricate).
+  }
+
+  /**
+   * Splits {@code roles} (plus the join-condition columns, tagged {@link 
ColumnRole#JOIN}) across the
+   * base datasource and each joinable clause using the planner's join 
prefixes, then recurses. Clauses
+   * are matched longest-prefix-first; right-side columns arrive already 
prefixed and are un-prefixed
+   * before attribution to the clause's datasource (which may itself be a 
table, join, or sub-query).
+   */
+  private void attributeJoin(
+      Map<String, Map<String, EnumSet<ColumnRole>>> result,
+      JoinDataSource join,
+      Map<String, EnumSet<ColumnRole>> roles
+  )
+  {
+    JoinDataSourceAnalysis analysis = 
JoinDataSourceAnalysis.constructAnalysis(join);
+    DataSource base = analysis.getBaseDataSource();
+    List<PreJoinableClause> clauses = new 
ArrayList<>(analysis.getPreJoinableClauses());
+    // Longest prefix first so that, e.g., "j0.x" matches clause "j0." rather 
than a shorter prefix.
+    clauses.sort((a, b) -> Integer.compare(b.getPrefix().length(), 
a.getPrefix().length()));
+
+    Map<String, EnumSet<ColumnRole>> all = new TreeMap<>(roles);
+    for (PreJoinableClause clause : clauses) {
+      for (String column : clause.getCondition().getRequiredColumns()) {
+        all.computeIfAbsent(column, k -> 
EnumSet.noneOf(ColumnRole.class)).add(ColumnRole.JOIN);
+      }
+    }
+
+    Map<DataSource, Map<String, EnumSet<ColumnRole>>> partitioned = new 
LinkedHashMap<>();
+    for (Map.Entry<String, EnumSet<ColumnRole>> entry : all.entrySet()) {
+      String column = entry.getKey();
+      DataSource target = base;
+      String resolved = column;
+      for (PreJoinableClause clause : clauses) {
+        if (JoinPrefixUtils.isPrefixedBy(column, clause.getPrefix())) {
+          target = clause.getDataSource();
+          resolved = JoinPrefixUtils.unprefix(column, clause.getPrefix());
+          break;
+        }
+      }
+      partitioned.computeIfAbsent(target, k -> new TreeMap<>())
+                 .computeIfAbsent(resolved, k -> 
EnumSet.noneOf(ColumnRole.class))
+                 .addAll(entry.getValue());
+    }
+    for (Map.Entry<DataSource, Map<String, EnumSet<ColumnRole>>> entry : 
partitioned.entrySet()) {
+      attribute(result, entry.getKey(), entry.getValue());
+    }
+  }
+
+  private static void addRoles(
+      Map<String, Map<String, EnumSet<ColumnRole>>> result,
+      String table,
+      String column,
+      EnumSet<ColumnRole> roles
+  )
+  {
+    result.computeIfAbsent(table, k -> new TreeMap<>())
+          .computeIfAbsent(column, k -> EnumSet.noneOf(ColumnRole.class))
+          .addAll(roles);
+  }
+
+  /**
+   * Walks the query's column-bearing parts (projected columns, dimensions, 
aggregator inputs, filter
+   * columns) and records each referenced base column with the role(s) it was 
used in. Virtual columns
+   * are expanded transitively to their underlying base columns, carrying the 
consuming role. Only
+   * explicitly-referenced columns are captured (notably {@code __time} is 
included only when it
+   * actually appears in a part, never its implicit interval usage). Returns 
an empty map for
+   * unsupported query types and for a bare {@code SELECT *} (a Scan with 
neither explicit columns nor
+   * a filter); a {@code SELECT *} that carries a filter still contributes its 
filter columns.
+   */
+  private Map<String, EnumSet<ColumnRole>> collectColumnRoles(Query<?> query)
+  {
+    Map<String, EnumSet<ColumnRole>> roles = new TreeMap<>();
+    if (query instanceof ScanQuery) {
+      ScanQuery scan = (ScanQuery) query;
+      VirtualColumns vcs = scan.getVirtualColumns();
+      if (scan.getColumns() != null) {
+        for (String column : scan.getColumns()) {
+          addColumn(roles, vcs, column, ColumnRole.PROJECTION);
+        }
+      }
+      addFilterColumns(roles, vcs, scan.getFilter());
+    } else if (query instanceof GroupByQuery) {
+      GroupByQuery groupBy = (GroupByQuery) query;
+      VirtualColumns vcs = groupBy.getVirtualColumns();
+      for (DimensionSpec dimension : groupBy.getDimensions()) {
+        addColumn(roles, vcs, dimension.getDimension(), ColumnRole.GROUP_BY);
+      }
+      addAggregatorColumns(roles, vcs, groupBy.getAggregatorSpecs());
+      addFilterColumns(roles, vcs, groupBy.getDimFilter());
+    } else if (query instanceof TopNQuery) {
+      TopNQuery topN = (TopNQuery) query;
+      VirtualColumns vcs = topN.getVirtualColumns();
+      addColumn(roles, vcs, topN.getDimensionSpec().getDimension(), 
ColumnRole.GROUP_BY);
+      addAggregatorColumns(roles, vcs, topN.getAggregatorSpecs());
+      addFilterColumns(roles, vcs, topN.getFilter());
+    } else if (query instanceof TimeseriesQuery) {
+      TimeseriesQuery timeseries = (TimeseriesQuery) query;
+      VirtualColumns vcs = timeseries.getVirtualColumns();
+      addAggregatorColumns(roles, vcs, timeseries.getAggregatorSpecs());
+      addFilterColumns(roles, vcs, timeseries.getFilter());
+    } else {
+      // Unsupported query type: emit table-level lineage only.
+      return Collections.emptyMap();
+    }
+    return roles;
+  }
+
+  private void addAggregatorColumns(
+      Map<String, EnumSet<ColumnRole>> roles,
+      VirtualColumns vcs,
+      List<AggregatorFactory> aggregators
+  )
+  {
+    for (AggregatorFactory aggregator : aggregators) {
+      for (String column : aggregator.requiredFields()) {

Review Comment:
   [P2] Filtered aggregator predicates are labeled as aggregations
   
   FilteredAggregatorFactory.requiredFields() includes both the delegate 
aggregator inputs and the filter predicate columns, but this loop tags every 
required field as AGGREGATION. For an aggregate such as SUM(cnt) FILTER (WHERE 
dim2 = 'a'), dim2 is a filter predicate and should receive FILTER, not 
AGGREGATION.



##########
extensions-contrib/openlineage-emitter/src/main/java/org/apache/druid/extensions/openlineage/OpenLineageRequestLogger.java:
##########
@@ -309,11 +347,275 @@ static String extractMsqOutputDatasource(String sql)
     }
   }
 
+  /**
+   * Column usage roles for the custom {@code druid_columnUsage} dataset 
facet. Names align with
+   * OpenLineage transformation subtypes where they exist (GROUP_BY, 
AGGREGATION, FILTER, JOIN);
+   * PROJECTION corresponds to OpenLineage IDENTITY/TRANSFORMATION (a 
selected/projected column).
+   */
+  enum ColumnRole
+  {
+    PROJECTION, GROUP_BY, AGGREGATION, FILTER, JOIN
+  }
+
+  /**
+   * Resolves the per-base-table column-usage map for a native query, used to 
attach the {@code schema}
+   * and {@code druid_columnUsage} dataset facets to input datasets. Handles 
all datasource shapes:
+   * tables, joins (attributing columns per side via the planner's join 
prefixes), unions, sub-queries
+   * (recursing into the sub-query's own columns), and datasource wrappers 
(restricted/filtered/unnest).
+   * Columns that have no base table (lookups, inline data) are dropped rather 
than fabricated.
+   *
+   * <p>Returns {@code null} (yielding table-level lineage only) when no 
base-table columns can be
+   * determined, or on any error -- it never fabricates or mis-attributes 
columns.
+   */
+  @Nullable
+  private Map<String, Map<String, EnumSet<ColumnRole>>> 
extractColumnsByTable(Query<?> query)
+  {
+    try {
+      Map<String, Map<String, EnumSet<ColumnRole>>> result = new TreeMap<>();
+      collectInto(result, query);
+      return result.isEmpty() ? null : result;
+    }
+    // StackOverflowError (an Error, not an Exception) is caught too so that a 
pathologically deep
+    // query plan degrades to table-level lineage rather than breaking the 
request-logging path.
+    catch (Exception | StackOverflowError e) {
+      log.debug(e, "Failed to extract column lineage; falling back to 
table-level lineage");
+      return null;
+    }
+  }
+
+  /**
+   * Walks {@code query}'s column-bearing parts and attributes the referenced 
columns to the base
+   * tables of its datasource. {@link UnionQuery} (whose {@code 
getDataSource()} is undefined) is
+   * handled by recursing into each of its branches.
+   */
+  private void collectInto(Map<String, Map<String, EnumSet<ColumnRole>>> 
result, Query<?> query)
+  {
+    if (query instanceof UnionQuery) {
+      for (DataSource branch : ((UnionQuery) query).getDataSources()) {
+        attribute(result, branch, Collections.emptyMap());
+      }
+      return;
+    }
+    attribute(result, query.getDataSource(), collectColumnRoles(query));
+  }
+
+  /**
+   * Attributes {@code roles} (column to roles, expressed in {@code 
dataSource}'s output namespace) to
+   * the underlying base tables, recursing through the datasource tree. 
Sub-queries are recursed into
+   * via {@link #collectInto} (their columns come from their own parts, not 
the outer references).
+   */
+  private void attribute(
+      Map<String, Map<String, EnumSet<ColumnRole>>> result,
+      DataSource dataSource,
+      Map<String, EnumSet<ColumnRole>> roles
+  )
+  {
+    if (dataSource instanceof TableDataSource) {
+      // Also covers GlobalTableDataSource (a TableDataSource subclass); it is 
a real named table, so
+      // handling it here is intentional. Keep this branch ahead of the 
wrapper branches below.
+      String table = ((TableDataSource) dataSource).getName();
+      for (Map.Entry<String, EnumSet<ColumnRole>> entry : roles.entrySet()) {
+        addRoles(result, table, entry.getKey(), entry.getValue());
+      }
+    } else if (dataSource instanceof RestrictedDataSource) {
+      attribute(result, ((RestrictedDataSource) dataSource).getBase(), roles);
+    } else if (dataSource instanceof FilteredDataSource) {
+      attribute(result, ((FilteredDataSource) dataSource).getBase(), roles);
+    } else if (dataSource instanceof UnnestDataSource) {
+      UnnestDataSource unnest = (UnnestDataSource) dataSource;
+      VirtualColumn unnestColumn = unnest.getVirtualColumn();
+      Map<String, EnumSet<ColumnRole>> baseRoles = new TreeMap<>(roles);
+      // The unnest output column is synthetic (not a base column); drop it 
and instead record the
+      // underlying column(s) being unnested as projected from the base.
+      baseRoles.remove(unnestColumn.getOutputName());
+      for (String required : unnestColumn.requiredColumns()) {
+        baseRoles.computeIfAbsent(required, k -> 
EnumSet.noneOf(ColumnRole.class)).add(ColumnRole.PROJECTION);
+      }
+      attribute(result, unnest.getBase(), baseRoles);
+    } else if (dataSource instanceof UnionDataSource) {
+      // Union members share the same output schema, so the referenced columns 
apply to each.
+      for (DataSource member : dataSource.getChildren()) {
+        attribute(result, member, roles);
+      }
+    } else if (dataSource instanceof QueryDataSource) {
+      // The outer references are this sub-query's OUTPUT columns, not base 
columns; the sub-query's
+      // own base-table columns are captured by recursing into its parts.
+      collectInto(result, ((QueryDataSource) dataSource).getQuery());
+    } else if (dataSource instanceof JoinDataSource) {
+      attributeJoin(result, (JoinDataSource) dataSource, roles);
+    }
+    // LookupDataSource, InlineDataSource and any other shape have no base 
table: drop (never fabricate).
+  }
+
+  /**
+   * Splits {@code roles} (plus the join-condition columns, tagged {@link 
ColumnRole#JOIN}) across the
+   * base datasource and each joinable clause using the planner's join 
prefixes, then recurses. Clauses
+   * are matched longest-prefix-first; right-side columns arrive already 
prefixed and are un-prefixed
+   * before attribution to the clause's datasource (which may itself be a 
table, join, or sub-query).
+   */
+  private void attributeJoin(
+      Map<String, Map<String, EnumSet<ColumnRole>>> result,
+      JoinDataSource join,
+      Map<String, EnumSet<ColumnRole>> roles
+  )
+  {
+    JoinDataSourceAnalysis analysis = 
JoinDataSourceAnalysis.constructAnalysis(join);
+    DataSource base = analysis.getBaseDataSource();
+    List<PreJoinableClause> clauses = new 
ArrayList<>(analysis.getPreJoinableClauses());
+    // Longest prefix first so that, e.g., "j0.x" matches clause "j0." rather 
than a shorter prefix.
+    clauses.sort((a, b) -> Integer.compare(b.getPrefix().length(), 
a.getPrefix().length()));
+
+    Map<String, EnumSet<ColumnRole>> all = new TreeMap<>(roles);

Review Comment:
   [P2] Join left filters are dropped
   
   JoinDataSourceAnalysis exposes the base-table leftFilter through 
getJoinBaseTableFilter(), but attributeJoin only adds join-condition columns 
before partitioning roles. SQL plans that push a predicate into 
JoinDataSource.leftFilter will under-report that filtered base-table column in 
the emitted lineage.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to