Copilot commented on code in PR #2428:
URL: https://github.com/apache/phoenix/pull/2428#discussion_r3190887178


##########
phoenix-core-client/src/main/java/org/apache/phoenix/compile/StatementContext.java:
##########
@@ -309,6 +310,20 @@ public void setScanRanges(ScanRanges scanRanges) {
     scanRanges.initializeScan(scan);
   }
 
+  /**
+   * V2-owned metadata attached by the V2 scan-construction path; null under 
the V1 path
+   * ({@code WHERE_OPTIMIZER_V2_ENABLED=false}). Consumers (currently the 
explain-plan
+   * formatter) prefer this when present; others are unaffected.
+   */
+  public org.apache.phoenix.compile.keyspace.scan.V2ScanArtifact 
getV2ScanArtifact() {
+    return this.v2ScanArtifact;
+  }
+
+  public void setV2ScanArtifact(
+    org.apache.phoenix.compile.keyspace.scan.V2ScanArtifact artifact) {
+    this.v2ScanArtifact = artifact;

Review Comment:
   scanRanges and v2ScanArtifact can now diverge because setScanRanges() never 
clears or updates the artifact. Any later setScanRanges(...) on the same 
StatementContext—such as switching to EVERYTHING/NOTHING or replacing the scan 
shape during planning—will leave ExplainTable formatting key ranges from stale 
V2 metadata.



##########
phoenix-core-client/src/main/java/org/apache/phoenix/optimize/QueryOptimizer.java:
##########
@@ -792,6 +792,76 @@ public int compare(QueryPlan plan1, QueryPlan plan2) {
     return stopAtBestPlan ? bestCandidates.subList(0, 1) : bestCandidates;
   }
 
+  /**
+   * Variant of {@link 
org.apache.phoenix.compile.ScanRanges#getBoundPkColumnCount} for the cost
+   * comparator: only honors the extra span reported by {@code slotSpan[i]} 
when the slot's ranges
+   * genuinely span multiple PK columns via byte-level encoding. A slot whose 
only ranges are
+   * non-point finite-bounded scalars (e.g. {@code BETWEEN a AND b} on a 
single PK col) under V2
+   * can carry an artificial {@code slotSpan[i] > 0} set by
+   * {@link 
org.apache.phoenix.compile.keyspace.KeyRangeExtractor#emitV1Projection} as a
+   * V1-compat marker for {@link org.apache.phoenix.filter.SkipScanFilter}'s 
per-region cursor
+   * stepping. That marker is needed for SkipScanFilter correctness on 
CDC-style scans but
+   * would inflate the PK-col count the cost comparator ranks plans by, 
letting a plan with a
+   * narrow bounded range on one PK col tie with a plan whose compound range 
genuinely covers
+   * multiple PK cols. Tie on primary key falls through to weaker tiebreakers 
that flip the
+   * plan choice (see CostBasedDecisionIT.testCostOverridesStaticPlanOrdering3 
and its
+   * Upsert/Delete variants).
+   * <p>
+   * Rule: {@code slotSpan[i]} is honored unless every range in the slot is a 
non-point
+   * finite-bounded scalar. Point keys (single-key compound bytes encoding 
multiple PK cols,
+   * e.g. from RVC-IN or tuple equality) and ranges with an UNBOUND side 
(where trailing cols
+   * inherit the unbounded side's min/max at the byte level) both represent 
genuine multi-col
+   * narrowing and are counted as {@code slotSpan[i]+1}.
+   */
+  private static int effectiveBoundPkColumnCount(
+      org.apache.phoenix.compile.ScanRanges scanRanges) {
+    java.util.List<java.util.List<org.apache.phoenix.query.KeyRange>> ranges =
+      scanRanges.getRanges();
+    int[] slotSpan = scanRanges.getSlotSpans();
+    int count = 0;
+    boolean hasUnbound = false;
+    int nRanges = ranges.size();
+    for (int i = 0; i < nRanges && !hasUnbound; i++) {
+      java.util.List<org.apache.phoenix.query.KeyRange> orRanges = 
ranges.get(i);
+      boolean slotHasUnbound = false;
+      boolean slotAllBoundedNonPoint = true;
+      for (org.apache.phoenix.query.KeyRange range : orRanges) {
+        if (range == org.apache.phoenix.query.KeyRange.EVERYTHING_RANGE) {
+          return count;
+        }
+        if (range.isUnbound()) {
+          slotHasUnbound = true;
+          hasUnbound = true;
+        }
+        if (range.isSingleKey() || range.isUnbound()) {
+          slotAllBoundedNonPoint = false;
+        }
+      }
+      int span = (slotSpan != null && i < slotSpan.length) ? slotSpan[i] : 0;
+      // V2-only artificial extension: {@code 
KeyRangeExtractor.emitV1Projection} bumps
+      // {@code slotSpan[last]} to span trailing unconstrained PK cols for
+      // {@link SkipScanFilter} cursor stepping, even when the slot holds a 
single-column
+      // finite-bounded scalar range whose bytes don't actually encode 
trailing cols.
+      // For cost ranking we want to count only columns genuinely narrowed by 
the scan
+      // bounds, so discount that shape to 1.
+      //
+      // Discriminator: the extension path ALWAYS has >=2 slots (at least one 
leading
+      // concretely-narrowed slot plus the last bumped slot). A single-slot 
shape with
+      // {@code slotSpan > 0} is the compound-emission path — a genuine 
multi-col range
+      // whose bytes concatenate {@code span+1} columns — and keeps {@code 
span + 1}.
+      // See CostBasedDecisionIT.testCostOverridesStaticPlanOrdering3 
(multi-slot shape
+      // must discount) vs. 
RowTimestampIT.testAutomaticallySettingRowTimestampWith*
+      // (single-slot compound must not discount).
+      boolean isArtificialExtension = nRanges > 1 && i == nRanges - 1;

Review Comment:
   The 'ALWAYS has >=2 slots' assumption is too strong. The V1-compat slotSpan 
extension can also appear when there is only one emitted range slot followed by 
unconstrained trailing PK columns, so single-slot plans can still be artificial 
here. Those scans will be over-counted as genuinely multi-column and can still 
skew plan ranking.
   



##########
phoenix-core-client/src/main/java/org/apache/phoenix/compile/keyspace/oracle/Oracle.java:
##########
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.compile.keyspace.oracle;
+
+/**
+ * Reference implementation (oracle) of the key-space model's key-range 
extraction
+ * algorithm. Given an {@link AbstractExpression} tree over a schema with 
{@code nPk}
+ * primary-key dimensions, produces the {@link AbstractKeySpaceList} the 
algorithm
+ * should emit.
+ * <p>
+ * The purpose is differential testing: we compare the oracle's output against 
the
+ * production {@code WhereOptimizerV2} implementation's {@code KeySpaceList} 
to detect
+ * divergences. Any difference is either a production bug or an oracle bug — 
the oracle
+ * being shorter and directly derived from the design, the default suspect is 
production.

Review Comment:
   This package is explicitly described as differential-test scaffolding, but 
it is being added under src/main/java, which ships it in the client artifact 
and turns the reference model into production package surface. These classes 
should live under test sources or a dedicated test jar instead of the runtime 
module.



##########
phoenix-core-client/src/main/java/org/apache/phoenix/compile/keyspace/KeySpace.java:
##########
@@ -0,0 +1,382 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.compile.keyspace;
+
+import java.util.Arrays;
+import java.util.Optional;
+
+import org.apache.phoenix.query.KeyRange;
+
+/**
+ * An N-dimensional key space over a table's primary key columns. Each 
dimension is a
+ * {@link KeyRange} over the encoded byte representation of a single PK 
column. An
+ * expression node is modeled as a list of {@code KeySpace} instances; see
+ * {@link KeySpaceList} for the list-level algebra.
+ * <p>
+ * Instances are immutable. {@link #and(KeySpace)} is the per-dimension 
intersection;
+ * {@link #unionIfMergeable(KeySpace)} returns the union when either (a) one 
space contains
+ * the other or (b) the two spaces agree on all but one dimension and the 
differing dim's
+ * ranges are non-disjoint.
+ * <p>
+ * A single-value predicate like {@code PK2 >= 3} on a 3-PK table is 
represented as
+ * {@code [(*,*), [3,*), (*,*)]} — a singleton {@link KeySpaceList} containing 
a single
+ * {@code KeySpace} where every dim not mentioned in the predicate holds
+ * {@link KeyRange#EVERYTHING_RANGE}. RVC inequalities are pre-normalized by
+ * {@link ExpressionNormalizer} into lexicographic AND/OR of scalar 
comparisons so that
+ * per-dim intersection composes correctly with every other predicate; this 
class therefore
+ * never needs to model compound-byte concatenation directly.
+ */
+public final class KeySpace {
+
+  private final KeyRange[] dims;
+  private final boolean empty;
+
+  private KeySpace(KeyRange[] dims, boolean empty) {
+    this.dims = dims;
+    this.empty = empty;
+  }
+
+  public static KeySpace everything(int n) {
+    KeyRange[] dims = new KeyRange[n];
+    Arrays.fill(dims, KeyRange.EVERYTHING_RANGE);
+    return new KeySpace(dims, false);
+  }
+
+  public static KeySpace empty(int n) {
+    KeyRange[] dims = new KeyRange[n];
+    Arrays.fill(dims, KeyRange.EMPTY_RANGE);
+    return new KeySpace(dims, true);
+  }
+
+  public static KeySpace single(int dim, KeyRange r, int n) {
+    if (r == KeyRange.EMPTY_RANGE) {
+      return empty(n);
+    }
+    KeyRange[] dims = new KeyRange[n];
+    Arrays.fill(dims, KeyRange.EVERYTHING_RANGE);
+    dims[dim] = r;
+    return new KeySpace(dims, false);
+  }
+
+  public static KeySpace of(KeyRange[] dims) {
+    boolean empty = false;
+    for (KeyRange r : dims) {
+      if (r == KeyRange.EMPTY_RANGE) {
+        empty = true;
+        break;
+      }
+    }
+    return new KeySpace(dims.clone(), empty);
+  }
+
+  public int nDims() {
+    return dims.length;
+  }
+
+  public KeyRange get(int dim) {
+    return dims[dim];
+  }
+
+  /**
+   * Returns a new {@link KeySpace} identical to this one except with dim 
{@code dim}
+   * replaced by {@code r}. Used by {@link KeySpaceList}'s widening path to 
drop a
+   * trailing dim (by replacing it with {@link KeyRange#EVERYTHING_RANGE}). 
Allocates a
+   * fresh dims array; original is unchanged.
+   */
+  public KeySpace withDimReplaced(int dim, KeyRange r) {
+    if (dims[dim].equals(r)) {
+      return this;
+    }
+    KeyRange[] newDims = dims.clone();
+    newDims[dim] = r;
+    if (r == KeyRange.EMPTY_RANGE) {
+      return empty(dims.length);
+    }
+    return new KeySpace(newDims, false);
+  }
+
+  public boolean isEmpty() {
+    return empty;
+  }
+
+  public boolean isEverything() {
+    if (empty) {
+      return false;
+    }
+    for (KeyRange r : dims) {
+      if (r != KeyRange.EVERYTHING_RANGE) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Per-dimension intersection. If any dim collapses to {@link 
KeyRange#EMPTY_RANGE}, the
+   * result is {@link #empty(int)}.
+   */
+  public KeySpace and(KeySpace other) {
+    requireSameArity(other);
+    if (this.empty || other.empty) {
+      return empty(dims.length);
+    }
+    KeyRange[] newDims = new KeyRange[dims.length];
+    for (int i = 0; i < dims.length; i++) {
+      KeyRange a = this.dims[i];
+      KeyRange b = other.dims[i];
+      KeyRange inter = intersectRange(a, b);
+      if (inter == KeyRange.EMPTY_RANGE) {
+        return empty(dims.length);
+      }
+      newDims[i] = inter;
+    }
+    return new KeySpace(newDims, false);
+  }
+
+  /**
+   * Per-dim intersection that special-cases {@link KeyRange#EVERYTHING_RANGE} 
against
+   * {@link KeyRange#IS_NULL_RANGE} / {@link KeyRange#IS_NOT_NULL_RANGE}. Plain
+   * {@link KeyRange#intersect} treats EVERYTHING ∩ IS_NULL as EMPTY because 
IS_NULL uses
+   * an empty-byte-array sentinel that coincides with the EVERYTHING 
representation.
+   */
+  private static KeyRange intersectRange(KeyRange a, KeyRange b) {
+    if (a == KeyRange.EVERYTHING_RANGE) {
+      return b;
+    }
+    if (b == KeyRange.EVERYTHING_RANGE) {
+      return a;
+    }
+    return a.intersect(b);
+  }
+
+  /**
+   * Returns the union of {@code this} and {@code other} as a single {@code 
KeySpace} when
+   * one of the two merge rules applies; otherwise {@link Optional#empty()}.
+   * <ul>
+   * <li>Rule 1 (containment): one space is fully contained in the other; 
return the larger.</li>
+   * <li>Rule 2 (adjacent boxes): agreeing on all-but-one dim and the 
remaining dim's ranges
+   * overlap or are adjacent; return the space with the merged dim's 
range.</li>
+   * </ul>
+   */
+  public Optional<KeySpace> unionIfMergeable(KeySpace other) {
+    requireSameArity(other);
+    if (this.empty) {
+      return Optional.of(other);
+    }
+    if (other.empty) {
+      return Optional.of(this);
+    }
+    if (this.equals(other)) {
+      return Optional.of(this);
+    }
+    if (contains(other)) {
+      return Optional.of(this);
+    }
+    if (other.contains(this)) {
+      return Optional.of(other);
+    }
+    int diffDim = -1;
+    for (int i = 0; i < dims.length; i++) {
+      if (!this.dims[i].equals(other.dims[i])) {
+        if (diffDim != -1) {
+          return Optional.empty();
+        }
+        diffDim = i;
+      }
+    }
+    if (diffDim == -1) {
+      return Optional.of(this);
+    }
+    KeyRange a = this.dims[diffDim];
+    KeyRange b = other.dims[diffDim];
+    // Two distinct single-key points are disjoint and NOT adjacent (they're 
different
+    // values). KeyRange.intersect has a bug for inverted (DESC) singleton 
pairs where
+    // the intersection is computed as a non-empty backward range rather than
+    // EMPTY_RANGE, so the check below would incorrectly fall through to 
union. Detect
+    // this shape explicitly.
+    if (a.isSingleKey() && b.isSingleKey()
+      && !java.util.Arrays.equals(a.getLowerRange(), b.getLowerRange())) {
+      return Optional.empty();
+    }
+    if (a.intersect(b) == KeyRange.EMPTY_RANGE && !isAdjacent(a, b)) {
+      return Optional.empty();
+    }
+    KeyRange[] newDims = dims.clone();
+    newDims[diffDim] = a.union(b);
+    return Optional.of(new KeySpace(newDims, false));
+  }
+
+  /**
+   * Two 1-D ranges are adjacent when the upper bound of one equals the lower 
bound of the
+   * other and exactly one side is inclusive (so together they cover the 
shared endpoint
+   * exactly once).
+   */
+  private static boolean isAdjacent(KeyRange a, KeyRange b) {
+    return adjacentOneWay(a, b) || adjacentOneWay(b, a);
+  }
+
+  private static boolean adjacentOneWay(KeyRange a, KeyRange b) {
+    if (a.upperUnbound() || b.lowerUnbound()) {
+      return false;
+    }
+    if (!java.util.Arrays.equals(a.getUpperRange(), b.getLowerRange())) {
+      return false;
+    }
+    return a.isUpperInclusive() != b.isLowerInclusive();
+  }
+
+  /**
+   * {@code this} contains {@code other} iff every dim of {@code this} 
contains the dim of
+   * {@code other}.
+   */
+  public boolean contains(KeySpace other) {
+    requireSameArity(other);
+    if (other.empty) {
+      return true;
+    }
+    if (this.empty) {
+      return false;
+    }
+    for (int i = 0; i < dims.length; i++) {
+      KeyRange inter = this.dims[i].intersect(other.dims[i]);
+      if (!inter.equals(other.dims[i])) {
+        return false;
+      }
+    }

Review Comment:
   contains() still relies on raw KeyRange.intersect(), even though and() 
already had to special-case EVERYTHING with IS_NULL/IS_NOT_NULL because 
intersect() collapses that combination to EMPTY. A KeySpace containing 
EVERYTHING will therefore incorrectly report that it does not contain 
null/not-null ranges, which breaks OR-merge containment for those predicates.



##########
phoenix-core-client/src/main/java/org/apache/phoenix/query/QueryServicesOptions.java:
##########
@@ -214,6 +216,9 @@ public class QueryServicesOptions {
   public static final boolean DEFAULT_IS_NAMESPACE_MAPPING_ENABLED = false;
   public static final boolean DEFAULT_IS_SYSTEM_TABLE_MAPPED_TO_NAMESPACE = 
true;
   public static final int DEFAULT_MAX_IN_LIST_SKIP_SCAN_SIZE = 50000;
+  public static final boolean DEFAULT_WHERE_OPTIMIZER_V2_ENABLED = true;

Review Comment:
   This makes the new optimizer opt-out for every deployment, but the same PR 
is still disabling a large number of existing tests for known V2 gaps (explain 
parity, ORDER BY/GROUP BY optimization, and index-selection behavior). Enabling 
it by default will expose those known regressions in production; the default 
should stay false until those cases are no longer being skipped.
   



##########
phoenix-core-client/src/main/java/org/apache/phoenix/compile/keyspace/ExpressionNormalizer.java:
##########
@@ -0,0 +1,231 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.compile.keyspace;
+
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.hadoop.hbase.CompareOperator;
+import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
+import org.apache.phoenix.expression.AndExpression;
+import org.apache.phoenix.expression.ComparisonExpression;
+import org.apache.phoenix.expression.Expression;
+import org.apache.phoenix.expression.InListExpression;
+import org.apache.phoenix.expression.OrExpression;
+import org.apache.phoenix.expression.RowValueConstructorExpression;
+
+/**
+ * Rewrites a WHERE {@link Expression} into the canonical AND/OR form the v2 
key-space model
+ * operates on. Two rewrites are applied bottom-up:
+ * <ul>
+ * <li><b>RVC inequality</b>: {@code (c1,...,cK) OP (v1,...,vK)} for OP in
+ * {@code <, ≤, >, ≥} expands to the lexicographic OR of ANDs. Example:
+ * {@code (c1,c2,c3) > (v1,v2,v3)} becomes
+ * {@code (c1>v1) OR (c1=v1 AND c2>v2) OR (c1=v1 AND c2=v2 AND c3>v3)}. 
Equality is already
+ * expanded upstream by {@link ComparisonExpression#create}.</li>
+ * <li><b>IN list on a scalar</b>: {@code a IN (v1,...,vK)} expands to
+ * {@code a=v1 OR ... OR a=vK}. IN with an RVC LHS is left intact; the visitor 
handles
+ * that shape directly by producing one KeySpace per row value.</li>
+ * </ul>

Review Comment:
   The class-level contract no longer matches the implementation. Scalar IN 
lists are now left intact because rewriteScalarInList() is a documented no-op, 
but the Javadoc still says they are rewritten into OR-of-equalities.



##########
phoenix-core-client/src/main/java/org/apache/phoenix/compile/keyspace/scan/V2ExplainFormatter.java:
##########
@@ -0,0 +1,290 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.phoenix.compile.keyspace.scan;
+
+import java.text.Format;
+
+import org.apache.hadoop.hbase.io.ImmutableBytesWritable;
+import org.apache.phoenix.compile.StatementContext;
+import org.apache.phoenix.compile.keyspace.KeySpace;
+import org.apache.phoenix.compile.keyspace.KeySpaceList;
+import org.apache.phoenix.query.KeyRange;
+import org.apache.phoenix.schema.PColumn;
+import org.apache.phoenix.schema.PTable;
+import org.apache.phoenix.schema.SortOrder;
+import org.apache.phoenix.schema.TableRef;
+import org.apache.phoenix.schema.types.PDataType;
+import org.apache.phoenix.util.StringUtil;
+
+/**
+ * V2-owned explain-plan keyRanges formatter.
+ * <p>
+ * Reads from {@link V2ScanArtifact#list()} directly rather than re-decoding 
the byte-
+ * encoded {@code ScanRanges} that drives actual scan execution. The artifact 
carries the
+ * pre-encoding, mathematical form of the scan, so inclusive-upper displays as
+ * {@code [*, 1]} (matching V1) instead of {@code [*, 2)} (what V2's compound 
byte
+ * emission produces after {@code nextKey(1) = 2}).
+ * <p>
+ * Current scope: single-space KeySpaceList with point or range ranges per 
dim. For
+ * multi-space lists the caller falls back to the legacy byte-decoding path in
+ * {@code ExplainTable.appendKeyRanges}, which produces different but 
semantically
+ * equivalent output. A future extension will handle the multi-space case by 
computing
+ * per-dim unions and emitting SkipScanFilter-style displays.
+ */
+public final class V2ExplainFormatter {
+
+  private V2ExplainFormatter() {
+  }
+
+  /**
+   * Build the keyRanges display string for a scan whose V2 artifact is {@code 
artifact}.
+   * Returns {@code null} if this formatter does not handle the input shape — 
the caller
+   * should fall back to the legacy formatter.
+   */
+  public static String appendKeyRanges(StatementContext context, TableRef 
tableRef,
+    V2ScanArtifact artifact) {
+    KeySpaceList list = artifact.list();
+    if (list.isUnsatisfiable()) {
+      return "";
+    }
+    PTable table = tableRef.getTable();
+    int nPk = artifact.nPkColumns();
+    int prefixSlots = artifact.prefixSlots();
+
+    // KeySpaceList.isEverything() means no user-dim constraint, but when 
prefix slots
+    // (salt / viewIndexId / tenantId) are present the scan is still narrowed 
to the
+    // tenant partition — render those prefix values. Without this, 
tenant-only queries
+    // display as empty brackets while V1 shows {@code ['tenantId']}.
+    if (list.isEverything()) {
+      return renderPrefixOnly(context, table, prefixSlots);
+    }
+    if (list.size() != 1) {
+      // Multi-space path not implemented yet; fall back.
+      return null;
+    }
+    KeySpace space = list.spaces().get(0);
+    boolean isLocalIndex = 
org.apache.phoenix.util.ScanUtil.isLocalIndex(context.getScan());
+
+    // KeySpace indexing is by absolute PK position ({@link 
KeySpaceExpressionVisitor}
+    // emits ranges via {@code KeySpace.single(pkPos, ...)} with {@code pkPos} 
being the
+    // column's absolute index in the PK). So {@code space.get(d)} — where d 
is an
+    // absolute PK index — returns the dim for PK column d. Prefix dims 
(viewIndexId /
+    // tenantId / salt) are always EVERYTHING inside the KeySpaceList because 
the visitor
+    // doesn't know about them; prefix values are rendered from scanRanges 
below.
+    //
+    // Fall back when a dim's raw bytes don't match the PK column's full 
fixed-width size.
+    // Happens when a scalar function (SUBSTR, FLOOR, ...) creates a KeyRange 
with truncated
+    // bytes (e.g. 3-byte substr prefix on an 8-byte LONG column). V1's 
ExplainTable reads
+    // post-processing ScanRanges bytes which have been zero-padded to the 
field width; V2's
+    // artifact carries the raw (pre-processing) bytes. Rather than 
duplicating V1's padding
+    // logic, defer to the legacy byte-decoding formatter.
+    for (int d = prefixSlots; d < nPk && d < space.nDims(); d++) {
+      KeyRange kr = space.get(d);
+      if (kr == KeyRange.EVERYTHING_RANGE || kr == KeyRange.IS_NULL_RANGE
+        || kr == KeyRange.IS_NOT_NULL_RANGE) {
+        continue;
+      }
+      PColumn col = table.getPKColumns().get(d);
+      PDataType type = col.getDataType();
+      if (!type.isFixedWidth()) {
+        continue;
+      }
+      Integer maxLen = col.getMaxLength();
+      Integer typeSize = type.getByteSize();
+      if (maxLen == null && typeSize == null) {
+        continue;
+      }
+      int expected = maxLen != null ? maxLen : typeSize;
+      byte[] lb = kr.getRange(KeyRange.Bound.LOWER);
+      byte[] ub = kr.getRange(KeyRange.Bound.UPPER);
+      if ((lb != null && lb.length != 0 && lb.length != expected)
+        || (ub != null && ub.length != 0 && ub.length != expected)) {
+        return null;
+      }
+    }
+
+    // Last-dim-to-display index: highest dim with a non-EVERYTHING range. V1 
truncates
+    // at the first EVERYTHING past the prefix so the display doesn't end in 
`*,*,*`.
+    int lastConstrained = prefixSlots - 1;
+    for (int d = prefixSlots; d < nPk && d < space.nDims(); d++) {
+      KeyRange kr = space.get(d);
+      if (kr != KeyRange.EVERYTHING_RANGE) {
+        lastConstrained = d;
+      }
+    }
+    if (lastConstrained < prefixSlots) {
+      return "";
+    }
+
+    StringBuilder lower = new StringBuilder();
+    StringBuilder upper = new StringBuilder();
+    // Prefix columns (salt byte / viewIndexId / tenantId) aren't in the 
KeySpaceList —
+    // they're auto-populated slots. Read them from the ScanRanges's per-slot 
structure,
+    // which the caller already built. This keeps prefix display identical to 
V1's.
+    int prefixEmitted = 0;
+    if (context.getScanRanges() != null && 
!context.getScanRanges().getRanges().isEmpty()) {
+      java.util.List<java.util.List<KeyRange>> ranges = 
context.getScanRanges().getRanges();
+      for (int d = 0; d < prefixSlots && d < ranges.size(); d++) {
+        // Slot 0 on a salted table holds the full [0..nBuckets-1] salt-bucket 
range
+        // list after {@link ScanRanges}'s constructor populates it via
+        // {@link SaltingUtil#generateAllSaltingRanges}. Lower bound reads the 
first
+        // range, upper bound reads the last — so the scan displays as
+        // {@code [X'00', ...] - [X'03', ...]} for nBuckets=4. Reading the 
same entry
+        // for both bounds would display {@code [X'00', ...]} as both lower 
and upper,
+        // losing the span across salt buckets. Other prefix slots 
(viewIndexId, tenantId)
+        // have a single singleton range, so the choice doesn't matter there.
+        java.util.List<KeyRange> slot = ranges.get(d);
+        KeyRange lowerRange = slot.get(0);
+        KeyRange upperRange = slot.get(slot.size() - 1);
+        byte[] lb = lowerRange.getRange(KeyRange.Bound.LOWER);
+        byte[] ub = upperRange.getRange(KeyRange.Bound.UPPER);
+        // On a local-index scan, slot 0 holds the viewIndexId. Render it via 
V1's
+        // shifted-value format ({@code Short.MIN_VALUE + 1 → "1"}) so the 
explain-plan
+        // matches V1's output. Without this flag, the raw stored bytes 
display as
+        // {@code -32768}, diverging from V1 on every local-index explain 
assertion.
+        boolean changeViewIndexId = isLocalIndex && d == 0;
+        appendPKColumnValue(lower, context, table, lb, null, d, 
changeViewIndexId);

Review Comment:
   This assumes every local-index scan has the viewIndexId in prefix slot 0, 
but salted local indexes prepend the salt byte before it. On those tables the 
formatter will interpret the salt bucket as a viewIndexId and leave the real 
viewIndexId unshifted, so the explain output is wrong.



-- 
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]

Reply via email to