Skip to content

Read a field of a ROW column by descending it - #5794

Open
mch2 wants to merge 6 commits into
opensearch-project:mainfrom
mch2:struct-field-support
Open

mch2 wants to merge 6 commits into
opensearch-project:mainfrom
mch2:struct-field-support

Conversation

@mch2

@mch2 mch2 commented Sep 22, 2026

Copy link
Copy Markdown
Member

Description

The analytics engine stores an OpenSearch object as a native Parquet struct and declares it to the planner as a ROW, so fields city.geo.lat has to read into that column. Today it cannot: planning fails with "Cannot infer type of field 'geo.lat' within ROW type", which makes every object sub-field unreadable on that route.

The leftover path segments were joined into one ITEM key. That is right for the v2 path, which declares an object as MAP because it stores objects flattened, so a dotted key is genuinely one key there. A ROW has no field of that name.

Descend a ROW one segment at a time with makeFieldAccess. Anything that is not a ROW keeps the joined ITEM key, so the v2 path is unchanged.

Related Issues

Resolves #[Issue number to be closed when this PR is merged]

Check List

  • New functionality includes testing.
  • New functionality has been documented.
  • New functionality has javadoc added.
  • New functionality has a user manual doc added.
  • New PPL command checklist all confirmed.
  • API changes companion pull request created.
  • Commits are signed per the DCO using --signoff or -s.
  • Public documentation issue/PR created.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 4c0a18c)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
📝 TODO sections

🔀 No multiple PR themes
⚡ Recommended focus areas for review

Possible Issue

The split retry logic splits on dots without escaping, so a quoted identifier containing a literal dot (e.g., cluster\.name) will be incorrectly split into multiple segments. This breaks the intended behavior where a quoted identifier should be treated as a single atomic name. The issue arises when a user quotes a field name that legitimately contains a dot character, which should not be interpreted as a path separator.

List<String> split = new ArrayList<>(parts.size());
for (String part : parts) {
  for (String segment : part.split("\\.")) {
    // `a..b` or a trailing dot yields empty segments, which can never name a field.
    if (!segment.isEmpty()) {
      split.add(segment);
    }
  }
}

@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 4c0a18c

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Add null check for type

The loop doesn't validate that current.getType() is non-null before calling
isStruct(). If the type is null at any iteration, this will throw a
NullPointerException. Add a null check before accessing the type.

core/src/main/java/org/opensearch/sql/calcite/QualifiedNameResolver.java [379-387]

 RexNode current = field;
-while (remaining < parts.size() && current.getType().isStruct()) {
+while (remaining < parts.size() && current.getType() != null && current.getType().isStruct()) {
   RelDataTypeField child = current.getType().getField(parts.get(remaining), false, false);
   if (child == null) {
     break;
   }
   current = context.rexBuilder.makeFieldAccess(current, child.getIndex());
   remaining++;
 }
Suggestion importance[1-10]: 3

__

Why: While adding a null check for current.getType() could prevent potential NPEs, the suggestion doesn't account for the fact that RexNode.getType() typically never returns null in Calcite's design. The check adds defensive programming but may be unnecessary given the framework's guarantees.

Low
Prevent NPE on null type

After the while loop, current.getType() is accessed without null checking. If the
type becomes null during iteration or after the loop, the subsequent isStruct() and
supportsItemAccess() calls will throw NullPointerException. Add a null check before
these operations.

core/src/main/java/org/opensearch/sql/calcite/QualifiedNameResolver.java [388-404]

 if (remaining == parts.size()) {
   return current;
+}
+if (current.getType() == null) {
+  return null;
 }
 if (current.getType().isStruct()) {
   ...
   return null;
 }
 if (!supportsItemAccess(current.getType())) {
   ...
   return null;
 }
Suggestion importance[1-10]: 3

__

Why: Similar to suggestion 2, this adds a null check for current.getType() after the loop. While defensive, RexNode.getType() is not expected to return null in Calcite's type system. The suggestion provides marginal safety improvement but may be redundant given the framework's design.

Low

Previous suggestions

Suggestions up to commit ffe79d1
CategorySuggestion                                                                                                                                    Impact
General
Prevent fallback for split quoted names

When structDescentOnly is true and resolution fails, the method continues to try
shorter prefixes. However, this could lead to unexpected behavior where a shorter
prefix matches a different column when the intended longer path should fail.
Consider whether continuing the loop is the desired behavior in all cases.

core/src/main/java/org/opensearch/sql/calcite/QualifiedNameResolver.java [234-238]

 RexNode resolved =
     resolveFieldAccess(context, parts, 0, length, fieldNode, structDescentOnly);
 if (resolved != null) {
   return Optional.of(resolved);
 }
+if (structDescentOnly) {
+  // When retrying a split quoted name, don't fall back to shorter prefixes
+  return Optional.empty();
+}
Suggestion importance[1-10]: 7

__

Why: This suggestion addresses a potential semantic issue where structDescentOnly mode (used for split quoted names) should not fall back to shorter prefixes. The PR's test testQuotedDottedNameOverAMapIsNotSplit expects quoted names to fail rather than resolve to shorter matches, and this change would enforce that behavior more strictly in the resolveFromParts method.

Medium
Handle empty strings from split

The regex pattern \. will split on every dot, including consecutive dots, which
could produce empty strings in the result. Consider using split("\.", -1) to
preserve empty strings or add validation to filter them out to prevent potential
issues with empty path segments.

core/src/main/java/org/opensearch/sql/calcite/QualifiedNameResolver.java [204-207]

 List<String> split = new ArrayList<>(parts.size());
 for (String part : parts) {
-  split.addAll(List.of(part.split("\\.")));
+  String[] segments = part.split("\\.");
+  for (String segment : segments) {
+    if (!segment.isEmpty()) {
+      split.add(segment);
+    }
+  }
 }
Suggestion importance[1-10]: 3

__

Why: While filtering empty strings is a defensive practice, the split("\\.") method only produces empty strings when there are consecutive dots or leading/trailing dots. The PR context suggests parts contains qualified name segments that are unlikely to have this pattern, making this a minor robustness improvement rather than a critical fix.

Low
Suggestions up to commit 25d44a2
CategorySuggestion                                                                                                                                    Impact
General
Handle empty strings from split

The regex pattern \. will split on every dot, including consecutive dots, which
could produce empty strings in the result. Consider using split("\.", -1) to
preserve empty strings or add validation to filter them out to prevent potential
issues with empty path segments.

core/src/main/java/org/opensearch/sql/calcite/QualifiedNameResolver.java [205-208]

 List<String> split = new ArrayList<>(parts.size());
 for (String part : parts) {
-  split.addAll(List.of(part.split("\\.")));
+  String[] segments = part.split("\\.");
+  for (String segment : segments) {
+    if (!segment.isEmpty()) {
+      split.add(segment);
+    }
+  }
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion correctly identifies that split("\\.") could produce empty strings from consecutive dots. However, the impact is limited since field names with consecutive dots are likely invalid in practice, and the code would naturally fail to resolve such paths later in the process.

Low
Prevent fallback for split quoted names

When structDescentOnly is true and resolution fails, the code continues to try
shorter prefixes. However, this may lead to incorrect matches where a shorter prefix
matches a different column when the intended longer path should have been rejected.
Consider whether continuing the loop is the desired behavior in all cases.

core/src/main/java/org/opensearch/sql/calcite/QualifiedNameResolver.java [235-239]

 RexNode resolved =
     resolveFieldAccess(context, parts, 0, length, fieldNode, structDescentOnly);
 if (resolved != null) {
   return Optional.of(resolved);
 }
+if (structDescentOnly) {
+  // When retrying a split quoted name, don't fall back to shorter prefixes
+  return Optional.empty();
+}
Suggestion importance[1-10]: 3

__

Why: The suggestion raises a valid concern about fallback behavior when structDescentOnly is true. However, the current implementation appears intentional based on the comment at line 240-241 which states "A shorter prefix may still match a different column, so keep walking." The suggestion contradicts this design choice without strong justification.

Low
Suggestions up to commit 680d0e4
CategorySuggestion                                                                                                                                    Impact
General
Add null check for type

Add a null check for current.getType() before calling isStruct() to prevent
potential NullPointerException if the type is null. This defensive check ensures
robustness when processing field access chains.

core/src/main/java/org/opensearch/sql/calcite/QualifiedNameResolver.java [347-354]

-while (remaining < parts.size() && current.getType().isStruct()) {
+while (remaining < parts.size() && current.getType() != null && current.getType().isStruct()) {
   RelDataTypeField child = current.getType().getField(parts.get(remaining), false, false);
   if (child == null) {
     break;
   }
   current = context.rexBuilder.makeFieldAccess(current, child.getIndex());
   remaining++;
 }
Suggestion importance[1-10]: 6

__

Why: Adding a null check for current.getType() before calling isStruct() is a reasonable defensive programming practice. However, in the Calcite framework, RexNode.getType() typically should not return null, making this a precautionary measure rather than addressing a likely bug.

Low
Filter empty strings after splitting

The regex pattern \. will split on every dot, which may cause issues with empty
strings if consecutive dots exist or if a part starts/ends with a dot. Consider
adding validation to filter out empty strings after splitting to prevent potential
issues with malformed identifiers.

core/src/main/java/org/opensearch/sql/calcite/QualifiedNameResolver.java [198-201]

 List<String> split = new ArrayList<>(parts.size());
 for (String part : parts) {
-  split.addAll(List.of(part.split("\\.")));
+  String[] segments = part.split("\\.");
+  for (String segment : segments) {
+    if (!segment.isEmpty()) {
+      split.add(segment);
+    }
+  }
 }
Suggestion importance[1-10]: 4

__

Why: While filtering empty strings could prevent edge cases with malformed identifiers containing consecutive dots, the current implementation using List.of(part.split("\\.")) should handle normal cases correctly. The suggestion adds defensive programming but may not be critical unless there's evidence of such malformed input.

Low
Suggestions up to commit 9d2e6e0
CategorySuggestion                                                                                                                                    Impact
General
Handle edge cases in dot splitting

The regex pattern \. will split on every dot, which may not handle edge cases like
empty strings between consecutive dots or leading/trailing dots. Consider using
split("\.", -1) to preserve empty strings, or add validation to ensure the split
produces valid identifiers.

core/src/main/java/org/opensearch/sql/calcite/QualifiedNameResolver.java [198-201]

 List<String> split = new ArrayList<>(parts.size());
 for (String part : parts) {
-  split.addAll(List.of(part.split("\\.")));
+  String[] segments = part.split("\\.", -1);
+  for (String segment : segments) {
+    if (!segment.isEmpty()) {
+      split.add(segment);
+    }
+  }
 }
Suggestion importance[1-10]: 4

__

Why: The suggestion addresses potential edge cases with empty strings from consecutive dots, but the improved code changes behavior by filtering out empty segments. This may or may not be desired depending on requirements. The original code using split("\\.") is standard and likely sufficient for typical identifier parsing.

Low
Add null safety for type checking

Add a null check for current.getType() before calling isStruct() to prevent
potential NullPointerException if the type is unexpectedly null during field
resolution.

core/src/main/java/org/opensearch/sql/calcite/QualifiedNameResolver.java [347-354]

-while (remaining < parts.size() && current.getType().isStruct()) {
+while (remaining < parts.size() && current.getType() != null && current.getType().isStruct()) {
   RelDataTypeField child = current.getType().getField(parts.get(remaining), false, false);
   if (child == null) {
     break;
   }
   current = context.rexBuilder.makeFieldAccess(current, child.getIndex());
   remaining++;
 }
Suggestion importance[1-10]: 3

__

Why: Adding a null check for current.getType() is defensive programming, but RexNode.getType() typically never returns null in Calcite's API. The suggestion is overly cautious and unlikely to prevent real issues in practice.

Low
Suggestions up to commit 02f905d
CategorySuggestion                                                                                                                                    Impact
Possible issue
Filter empty strings after splitting

The split() method can produce empty strings when consecutive dots exist or when a
part starts/ends with a dot. This could lead to incorrect field resolution. Add
validation to filter out empty strings after splitting.

core/src/main/java/org/opensearch/sql/calcite/QualifiedNameResolver.java [198-201]

 List<String> split = new ArrayList<>(parts.size());
 for (String part : parts) {
-  split.addAll(List.of(part.split("\\.")));
+  for (String segment : part.split("\\.")) {
+    if (!segment.isEmpty()) {
+      split.add(segment);
+    }
+  }
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that split("\\.") can produce empty strings with consecutive dots or leading/trailing dots, which could cause incorrect field resolution. The improved code properly filters empty segments, improving robustness.

Medium
Add null check for type

The loop doesn't verify that current.getType() is non-null before calling isStruct()
and getField(). If getType() returns null, this will throw a NullPointerException.
Add a null check for current.getType().

core/src/main/java/org/opensearch/sql/calcite/QualifiedNameResolver.java [339-346]

-while (remaining < parts.size() && current.getType().isStruct()) {
+while (remaining < parts.size() && current.getType() != null && current.getType().isStruct()) {
   RelDataTypeField child = current.getType().getField(parts.get(remaining), false, false);
   if (child == null) {
     break;
   }
   current = context.rexBuilder.makeFieldAccess(current, child.getIndex());
   remaining++;
 }
Suggestion importance[1-10]: 6

__

Why: While adding a null check for current.getType() is a defensive programming practice, the likelihood of getType() returning null in this context appears low given the Calcite framework's typical behavior. However, it does prevent potential NullPointerException issues.

Low

@codecov

codecov Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 48 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.18%. Comparing base (3f7048b) to head (4c0a18c).

Files with missing lines Patch % Lines
.../opensearch/sql/calcite/QualifiedNameResolver.java 0.00% 47 Missing ⚠️
.../opensearch/sql/calcite/CalciteRelNodeVisitor.java 0.00% 1 Missing ⚠️

❌ Your project check has failed because the head coverage (63.18%) is below the target coverage (99.00%). You can increase the head coverage or adjust the target coverage.

Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5794      +/-   ##
============================================
- Coverage     63.23%   63.18%   -0.06%     
  Complexity     8823     8823              
============================================
  Files           938      938              
  Lines         40236    40272      +36     
  Branches       4537     4550      +13     
============================================
  Hits          25445    25445              
- Misses        13966    14002      +36     
  Partials        825      825              
Flag Coverage Δ
sql-engine 63.18% <0.00%> (-0.06%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mch2 mch2 closed this Sep 24, 2026
@mch2 mch2 reopened this Sep 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 02f905d

@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 9d2e6e0

@ahkcs ahkcs added the enhancement New feature or request label Sep 24, 2026
The analytics engine stores an OpenSearch `object` as a native Parquet
struct and declares it to the planner as a ROW, so `fields city.geo.lat`
has to read into that column. Today it cannot: planning fails with "Cannot
infer type of field 'geo.lat' within ROW type", which makes every object
sub-field unreadable on that route.

The leftover path segments were joined into one ITEM key. That is right for
the v2 path, which declares an object as MAP because it stores objects
flattened, so a dotted key is genuinely one key there. A ROW has no field
of that name.

Descend a ROW one segment at a time with makeFieldAccess. Anything that is
not a ROW keeps the joined ITEM key, so the v2 path is unchanged.

Signed-off-by: Marc Handalian <handalm@amazon.com>
Descending a dotted path into a ROW one segment at a time left the miss case
unguarded. When a segment names no field of the ROW, the descent loop breaks
with part of the path unconsumed, and the leftover was then joined into an
ITEM key regardless of what it was applied to.

ITEM(<ROW>, 'x') is precisely the shape that descending exists to avoid.
SqlItemOperator looks the key up as a field of the ROW, does not find it, and
throws AssertionError "Cannot infer type of field 'x' within ROW type".
AssertionError is an Error, not an Exception, so it escapes the
catch (Exception) around field resolution and reaches the client as a 500.

The effect was that a misspelled field under an object, such as
`city.nonexistent`, failed with an internal error instead of the ordinary
"Field [...] not found" that every other unresolvable name produces.

Fall back to ITEM only when the node being indexed is not a struct. When
descent stops inside a ROW, report the path as unresolved: the prefix walk
then continues to shorter prefixes, since a shorter one may match a
different column, and an exhausted walk ends at the normal not-found with
its available-field suggestions.

Tests, in the existing CalcitePPLStructFieldTest fixture:
  - an undeclared field of a ROW is a not-found, not an AssertionError
    (fails without this change)
  - a ROW path used in a predicate, not only a projection
  - a backtick-quoted dotted path, which is one part with nothing to walk,
    reaching the same field access as the unquoted form

Signed-off-by: Marc Handalian <handalm@amazon.com>
@mch2
mch2 force-pushed the struct-field-support branch from 9d2e6e0 to 680d0e4 Compare September 24, 2026 23:30
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 680d0e4

The split-and-retry added for ROW columns also applied to MAP columns, which
resolved names that are meant to be gone.

When a container column is rebuilt its dotted subtree is shed, so a literal
dotted column created in between becomes unreachable on purpose -- a second
`spath input=body output=data` drops a `data.custom` evaluated after the
first. Looking `data.custom` up literally then fails, which is the intended
answer. Splitting it into [data, custom] and retrying found the rebuilt MAP
and produced ITEM(data, 'custom'), so the query quietly returned null instead
of reporting the field as missing, and
CalcitePPLSpathCollisionIT.testRepeatedSpathShadowsInterveningLiteralDottedColumn
stopped throwing.

Splitting exists to reach into a ROW, where a dotted name really is a path
and no ITEM key can express it. Over anything else the literal lookup was
already the right question. So the retry now accepts a resolution only when
the leftover path is consumed by descending a ROW, and reports the name
unresolved when it would become an ITEM key. The first, unsplit attempt is
unchanged, so an unquoted `data.custom` over a MAP still becomes one ITEM key
as the v2 path needs.

Tests: a quoted dotted name over a MAP stays unresolved (fails without this
change, in the same "nothing was thrown" shape as the IT), an unquoted one
over the same MAP still yields ITEM, and the quoted form over a ROW still
descends.

Signed-off-by: Marc Handalian <handalm@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 25d44a2

Signed-off-by: Marc Handalian <handalm@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit ffe79d1

ahkcs
ahkcs previously approved these changes Sep 25, 2026
@ahkcs
ahkcs dismissed their stale review September 25, 2026 17:48

comments

@ahkcs ahkcs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two notes on failed-descent handling and on nullability, inline.

Optional<RexNode> fieldNode = tryToResolveField(alias, field, context, inputCount);
if (fieldNode.isPresent()) {
return Optional.of(resolveFieldAccess(context, parts, 1, length, fieldNode.get()));
return Optional.of(resolveFieldAccess(context, parts, 1, length, fieldNode.get(), false));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed descent has three exits and only one is handled.

(a) resolveFieldAccess is now nullable (375), but this call is still Optional.of(...) → NPE on source=docs | where docs.city.nope = 1: resolveFieldWithAlias runs before resolveFieldWithoutAlias (79–81) and first in join conditions (61), alias docs matches field city at length=1, descent breaks on nope, and the guard returns null. Optional.ofNullable here, as resolveFromParts already does.

(b) The guard at 375 only fires when descent stops while still on a ROW. If it ends on a scalar child with segments left — fields city.name.bogus, city.name VARCHAR — isStruct() is false, the guard is skipped, and 388 builds ITEM(VARCHAR, 'bogus'). SqlItemOperator.inferReturnType's switch only covers ARRAY/MAP/ROW/ANY/DYNAMIC_STAR/VARIANT, so VARCHAR hits default: throw new AssertionError() — no message, and as an Error it escapes the catch (Exception) this method's own javadoc describes. Rejecting any leftover path once a ROW has been entered would cover both exits.

if (child == null) {
break;
}
current = context.rexBuilder.makeFieldAccess(current, child.getIndex());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makeFieldAccess loses the container's nullability. It delegates to makeFieldAccessInternal, which special-cases only RexRangeRef, so the result is a RexFieldAccess typed exactly field.getType(). The ITEM path it replaces did propagate: SqlItemOperator.inferReturnType's ROW branch ends with if (operandType.isNullable()) enforceTypeWithNullability(type, true).

The fixture in this PR has the shape that breaks: geo is a reference field so JavaType makes it nullable, lat is a primitive double so it is NOT NULL — $1.geo.lat is therefore DOUBLE NOT NULL under a nullable parent. RexSimplify.simplifyIsNull folds is null on a non-nullable safe expression to literal FALSE (and SafeRexVisitor.visitFieldAccess returns true unconditionally), so where city.geo.lat is null silently drops every document missing the object. Suggest re-applying nullability when any ancestor in the path is nullable.

Worth an executing test for this — nothing here calls verifyResult, so a row with a null struct is never evaluated.

`fields sourceNode.keyAttributes` returned the right values under the wrong
names: the response schema said `$f0`, `$f1`, ... instead of the paths asked
for, so anything reading a column by name found nothing.

visitProject re-applies the user-visible name after resolving a dotted path,
because the resolved node carries none of its own -- but it only did so for
SqlKind.ITEM, which is what a flattened object resolves to. An object stored as
a struct resolves to a chain of field accesses instead, so nothing re-applied
the name and Calcite derived one. Alias anything that is not already a plain
column reference.

Resolution was always correct, which is why the rows were right and only the
schema was wrong. The ITEM-only condition predates struct support, so the
flattened-object path is unaffected.

Signed-off-by: Marc Handalian <handalm@amazon.com>
Two ways a path into an object failed hard rather than being reported:

  fields city.name.bogus          -> 500
  source=docs as d | fields d.city.nope -> NPE

The first ran past the end of the object. `city.name` is a VARCHAR, so descent
stopped having already left the ROW, and the leftover was turned into
ITEM(VARCHAR, 'bogus'). SqlItemOperator accepts only ARRAY / MAP / ROW / ANY /
VARIANT and otherwise throws a bare AssertionError, which is an Error and so
escapes the catch (Exception) around resolution. A leftover is now rejected
unless the type can take an ITEM key at all.

That is narrower than rejecting any leftover once a ROW was entered: a
flat_object inside an object is a MAP child of the struct, so `city.meta.region`
legitimately descends the ROW and then keys into the map. Tested both ways.

The second is the alias branch of resolveFieldWithAlias, which wrapped the
result in Optional.of even though resolveFieldAccess became nullable for a
failed descent. It now keeps walking shorter prefixes, as resolveFromParts
does. The alias branch runs first, so it is what a qualified miss hits.

Also skip empty segments when splitting a quoted dotted name -- `a..b` and a
trailing dot cannot name a field.

Signed-off-by: Marc Handalian <handalm@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 4c0a18c

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

analytic-engine enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants