diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/KeyExpressionExpansionVisitor.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/KeyExpressionExpansionVisitor.java index 156a010289..6c88564fe5 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/KeyExpressionExpansionVisitor.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/KeyExpressionExpansionVisitor.java @@ -41,6 +41,7 @@ import com.apple.foundationdb.record.query.plan.cascades.values.EmptyValue; import com.apple.foundationdb.record.query.plan.cascades.values.ExplodeValue; import com.apple.foundationdb.record.query.plan.cascades.values.FieldValue; +import com.apple.foundationdb.record.query.plan.cascades.values.RecordConstructorValue; import com.apple.foundationdb.record.query.plan.cascades.values.Value; import com.apple.foundationdb.record.util.ProtoUtils; import com.google.common.base.Verify; @@ -482,7 +483,37 @@ public GraphExpansion visitExpression(@Nonnull final ThenKeyExpression thenKeyEx @Nonnull @Override public GraphExpansion visitExpression(@Nonnull final ListKeyExpression listKeyExpression) { - throw new UnsupportedOperationException("visitor method for this key expression is not implemented"); + final ImmutableList.Builder expandedPredicatesBuilder = ImmutableList.builder(); + final VisitorState state = getCurrentState(); + int currentOrdinal = state.getCurrentOrdinal(); + for (KeyExpression child : listKeyExpression.getChildren()) { + final VisitorState childState = state.withCurrentOrdinal(currentOrdinal); + final GraphExpansion childExpansion = pop(child.expand(push(childState.forFunctionalExpansion()))); + if (!childExpansion.getQuantifiers().isEmpty()) { + throw new UnsupportedOperationException("cannot expand a list whose child introduces quantifiers"); + } + final var childValues = childExpansion.getResultColumns() + .stream() + .map(Column::getValue) + .collect(ImmutableList.toImmutableList()); + // a list places its child in a nested tuple, so the value of the position is the record of the child's + // column values -- a record of one for a single-column child, as that tuple is nested all the same + final var value = childState.registerValue(RecordConstructorValue.ofUnnamed(childValues)); + // the child's position contributes what a scalar field at that position would + final var isSargable = childState.isKey() && !childState.isInternalExpansion(); + if (childState.isSelectStar()) { + expandedPredicatesBuilder.add(isSargable + ? GraphExpansion.ofPlaceholder(value.asPlaceholder(newParameterAlias())) + : GraphExpansion.empty()); + } else { + expandedPredicatesBuilder.add(isSargable + ? GraphExpansion.ofResultColumnAndPlaceholder(Column.unnamedOf(value), + value.asPlaceholder(newParameterAlias())) + : GraphExpansion.ofResultColumn(Column.unnamedOf(value))); + } + currentOrdinal++; + } + return GraphExpansion.ofOthers(expandedPredicatesBuilder.build()); } /** diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ScalarTranslationVisitor.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ScalarTranslationVisitor.java index ece0240d9e..7846fa7511 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ScalarTranslationVisitor.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ScalarTranslationVisitor.java @@ -34,6 +34,7 @@ import com.apple.foundationdb.record.query.plan.cascades.values.EmptyValue; import com.apple.foundationdb.record.query.plan.cascades.values.FieldValue; import com.apple.foundationdb.record.query.plan.cascades.values.QuantifiedObjectValue; +import com.apple.foundationdb.record.query.plan.cascades.values.RecordConstructorValue; import com.apple.foundationdb.record.query.plan.cascades.values.Value; import com.apple.foundationdb.record.util.ProtoUtils; import com.google.common.collect.ImmutableList; @@ -206,7 +207,21 @@ public Value visitExpression(@Nonnull final ThenKeyExpression thenKeyExpression) @Nonnull @Override public Value visitExpression(@Nonnull final ListKeyExpression listKeyExpression) { - throw new UnsupportedOperationException("visitor method for this key expression is not implemented"); + if (listKeyExpression.getColumnSize() > 1) { + throw new RecordCoreException("cannot expand ListKeyExpression in scalar expansion"); + } + + final ScalarVisitorState state = getCurrentState(); + final KeyExpression child = Iterables.getOnlyElement(listKeyExpression.getChildren()); + + // The child occupies a single position whose value is the nested tuple of the child's own columns, so it is a + // record of their values -- a record of one for a single-column child, as that tuple is nested all the same. + final List positions = child.normalizeKeyForPositions(); + final ImmutableList.Builder valuesBuilder = ImmutableList.builder(); + for (final KeyExpression position : positions) { + valuesBuilder.add(pop(position.expand(push(state)))); + } + return RecordConstructorValue.ofUnnamed(valuesBuilder.build()); } @Nonnull diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ScanWithFetchMatchCandidate.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ScanWithFetchMatchCandidate.java index 19901f4662..3dceecbb15 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ScanWithFetchMatchCandidate.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ScanWithFetchMatchCandidate.java @@ -150,17 +150,20 @@ static Optional computeIn for (int i = 0; i < indexKeyValues.size(); i++) { final Value keyValue = indexKeyValues.get(i); - final var extractFromIndexEntryPairOptional = - keyValue.extractFromIndexEntryMaybe(baseObjectValue, EvaluationContext.empty(), AliasMap.emptyMap(), - ImmutableSet.of(), IndexKeyValueToPartialRecord.TupleSource.KEY, ImmutableIntArray.of(i)); - if (extractFromIndexEntryPairOptional.isPresent()) { - final var extractFromIndexEntryPair = extractFromIndexEntryPairOptional.get(); - if (!addCoveringField(builder, extractFromIndexEntryPair.getKey(), - extractFromIndexEntryPair.getValue())) { - return Optional.empty(); + for (final var datum : entryData(keyValue, ImmutableIntArray.of(i))) { + final var extractFromIndexEntryPairOptional = + datum.getLeft().extractFromIndexEntryMaybe(baseObjectValue, EvaluationContext.empty(), + AliasMap.emptyMap(), ImmutableSet.of(), + IndexKeyValueToPartialRecord.TupleSource.KEY, datum.getRight()); + if (extractFromIndexEntryPairOptional.isPresent()) { + final var extractFromIndexEntryPair = extractFromIndexEntryPairOptional.get(); + if (!addCoveringField(builder, extractFromIndexEntryPair.getKey(), + extractFromIndexEntryPair.getValue())) { + return Optional.empty(); + } + recordCoveredField(covered, extractFromIndexEntryPair); + logicalKeyValuesBuilder.add(extractFromIndexEntryPair.getLeft()); } - recordCoveredField(covered, extractFromIndexEntryPair); - logicalKeyValuesBuilder.add(extractFromIndexEntryPair.getLeft()); } } @@ -192,6 +195,27 @@ static Optional computeIn indexEntryToRecordValue(baseType, covered))); } + /** + * Decomposes one column of the index entry into the data it holds that can be read individually. + * + * @param value the value registered for the column + * @param ordinalPath the path to the column + * @return the data that column holds, each datum paired with the path at which it sits in the entry's tuple + */ + @Nonnull + private static List> entryData(@Nonnull final Value value, + @Nonnull final ImmutableIntArray ordinalPath) { + if (!(value instanceof RecordConstructorValue)) { + return ImmutableList.of(NonnullPair.of(value, ordinalPath)); + } + final var columns = ((RecordConstructorValue)value).getColumns(); + final var dataBuilder = ImmutableList.>builder(); + for (int i = 0; i < columns.size(); i++) { + dataBuilder.addAll(entryData(columns.get(i).getValue(), ImmutableIntArray.builder().addAll(ordinalPath).add(i).build())); + } + return dataBuilder.build(); + } + /** * Records what the given extraction covers, descending a node per field of the path it fills, which may run into * nested messages. Every name is present here, {@code addCoveringField} having already refused the extraction diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ValueIndexExpansionVisitor.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ValueIndexExpansionVisitor.java index 163274cc4d..fdff7654ff 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ValueIndexExpansionVisitor.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ValueIndexExpansionVisitor.java @@ -24,6 +24,7 @@ import com.apple.foundationdb.record.metadata.Index; import com.apple.foundationdb.record.metadata.IndexTypes; import com.apple.foundationdb.record.metadata.RecordType; +import com.apple.foundationdb.record.metadata.SyntheticRecordType; import com.apple.foundationdb.record.metadata.expressions.GroupingKeyExpression; import com.apple.foundationdb.record.metadata.expressions.KeyExpression; import com.apple.foundationdb.record.metadata.expressions.KeyWithValueExpression; @@ -32,9 +33,12 @@ import com.apple.foundationdb.record.query.plan.cascades.predicates.Placeholder; import com.apple.foundationdb.record.query.plan.cascades.predicates.PredicateWithValueAndRanges; import com.apple.foundationdb.record.query.plan.cascades.typing.Type; +import com.apple.foundationdb.record.query.plan.cascades.values.RecordConstructorValue; import com.apple.foundationdb.record.query.plan.cascades.values.Value; +import com.apple.foundationdb.record.query.plan.cascades.values.translation.TranslationMap; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; import com.google.common.collect.Lists; import javax.annotation.Nonnull; @@ -43,6 +47,7 @@ import java.util.Collection; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.Set; import static com.apple.foundationdb.record.metadata.Key.Expressions.concat; @@ -72,6 +77,62 @@ public ValueIndexExpansionVisitor(@Nonnull Index index, @Nonnull Collection> syntheticRecordTypeMaybe( + @Nonnull final Collection indexedRecordTypes) { + if (indexedRecordTypes.size() != 1) { + return Optional.empty(); + } + final RecordType indexedRecordType = Iterables.getOnlyElement(indexedRecordTypes); + return indexedRecordType instanceof final SyntheticRecordType syntheticRecordType + ? Optional.of(syntheticRecordType) + : Optional.empty(); + } + + /** + * Merges the expansion of an index key into the expansion of the synthetic type the index is defined on, if it is + * defined on one. An index on a stored type has no base expansion, and its key expansion is returned as it stands. + * + *

The key's values are written over {@code baseQuantifier}, which does not survive into the merged graph, so + * each is re-expressed over the record the base expansion assembles and simplified: + * {@code base.unnesting_0.reviewer} over {@code (parent AS parent, explode._0 AS unnesting_0, ...)} becomes + * {@code explode._0.reviewer}, the very value a query navigating the same unnesting flows. + * + *

Predicates and placeholders are translated separately because a placeholder is held in both lists and + * {@link GraphExpansion#seal()} pairs them up by value; translating one alone would leave a parameter whose + * predicate the select does not hold. + * + * @param baseExpansion the expansion assembling the synthetic type's records, or {@code null} if there is none + * @param keyExpansion the expansion of the index key, expressed over {@code baseQuantifier} + * @param baseQuantifier the quantifier {@code keyExpansion} is expressed over + * @return the key expansion merged into the base expansion, or the key expansion itself if there is no base + */ + @Nonnull + private static GraphExpansion mergeWithBaseIfNeeded(@Nullable final GraphExpansion baseExpansion, + @Nonnull final GraphExpansion keyExpansion, + @Nonnull final Quantifier.ForEach baseQuantifier) { + if (baseExpansion == null) { + return keyExpansion; + } + final var ontoBaseRecord = TranslationMap.regularBuilder() + .when(baseQuantifier.getAlias()) + .then((sourceAlias, leafValue) -> RecordConstructorValue.ofColumns(baseExpansion.getResultColumns())) + .build(); + return GraphExpansion.ofOthers( + baseExpansion.toBuilder().removeAllResultColumns().build(), + keyExpansion.toBuilder() + .removeAllPredicates() + .addAllPredicates(keyExpansion.getPredicates().stream() + .map(predicate -> predicate.translateCorrelations(ontoBaseRecord, true)) + .collect(ImmutableList.toImmutableList())) + .replacePlaceholder(placeholder -> + placeholder.translateLeafPredicate(ontoBaseRecord, true)) + .build()); + } + @Nonnull @Override public MatchCandidate expand(@Nonnull final Set availableRecordTypeNames, @@ -87,12 +148,22 @@ public MatchCandidate expand(@Nonnull final Set availableRecordTypeNames // the instantiation of the type filter below to create a placeholder for the record type key parameter // alias and reuse it here. Similar to what we currently do for primary scans. // - final var baseQuantifier = Quantifier.forEach(ExpansionVisitor.createBaseRef(availableRecordTypeNames, - queriedRecordTypeNames, baseType, null, accessHint)); + @Nullable final GraphExpansion baseExpansion = + syntheticRecordTypeMaybe(queriedRecordTypes).map(recordType -> recordType.expand(accessHint)).orElse(null); final var allExpansionsBuilder = ImmutableList.builder(); - - // add the value for the flow of records - allExpansionsBuilder.add(GraphExpansion.ofQuantifier(baseQuantifier)); + final Quantifier.ForEach baseQuantifier; + final Value baseObjectValue; + if (baseExpansion == null) { + baseQuantifier = Quantifier.forEach(ExpansionVisitor.createBaseRef(availableRecordTypeNames, + queriedRecordTypeNames, baseType, null, accessHint)); + baseObjectValue = baseQuantifier.getFlowedObjectValue(); + // add the value for the flow of records + allExpansionsBuilder.add(GraphExpansion.ofQuantifier(baseQuantifier)); + } else { + // the base expansion's own quantifiers reach the graph when the key expansion is merged into it + baseQuantifier = Quantifier.forEach(Reference.initialOf(baseExpansion.seal().buildSelect())); + baseObjectValue = RecordConstructorValue.ofColumns(baseExpansion.getResultColumns()); + } var rootExpression = index.getRootExpression(); @@ -105,8 +176,7 @@ public MatchCandidate expand(@Nonnull final Set availableRecordTypeNames } final int keyValueSplitPoint; - if (rootExpression instanceof KeyWithValueExpression) { - final KeyWithValueExpression keyWithValueExpression = (KeyWithValueExpression)rootExpression; + if (rootExpression instanceof final KeyWithValueExpression keyWithValueExpression) { keyValueSplitPoint = keyWithValueExpression.getSplitPoint(); rootExpression = keyWithValueExpression.getInnerKey(); } else { @@ -190,15 +260,18 @@ public MatchCandidate expand(@Nonnull final Set availableRecordTypeNames } } - final var completeExpansion = GraphExpansion.ofOthers(allExpansionsBuilder.build()); - final var sealedExpansion = completeExpansion.seal(); + final var sealedExpansion = mergeWithBaseIfNeeded( + baseExpansion, + GraphExpansion.ofOthers(allExpansionsBuilder.build()), + baseQuantifier) + .seal(); final var parameters = sealedExpansion.getPlaceholders() .stream() .map(Placeholder::getParameterAlias) .collect(ImmutableList.toImmutableList()); final var matchableSortExpression = new MatchableSortExpression(parameters, isReverse, - sealedExpansion.buildSelectWithResultValue(baseQuantifier.getFlowedObjectValue())); + sealedExpansion.buildSelectWithResultValue(baseObjectValue)); return new ValueIndexScanMatchCandidate(index, queriedRecordTypes, Traversal.withRoot(Reference.initialOf(matchableSortExpression)), diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ValueIndexScanMatchCandidate.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ValueIndexScanMatchCandidate.java index a7e3d90f5d..99a5ebd06e 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ValueIndexScanMatchCandidate.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/ValueIndexScanMatchCandidate.java @@ -21,6 +21,7 @@ package com.apple.foundationdb.record.query.plan.cascades; import com.apple.foundationdb.record.RecordCoreException; +import com.apple.foundationdb.record.logging.LogMessageKeys; import com.apple.foundationdb.record.metadata.Index; import com.apple.foundationdb.record.metadata.RecordType; @@ -239,7 +240,7 @@ public RecordQueryPlan toEquivalentPlan(@Nonnull final PartialMatch partialMatch primaryKey, IndexScanComparisons.byValue(toScanComparisons(comparisonRanges)), planContext.getPlannerConfiguration().getIndexFetchMethod(), - RecordQueryFetchFromPartialRecordPlan.FetchIndexRecords.PRIMARY_KEY, + resolveFetchIndexRecords(), reverseScanOrder, false, partialMatch.getMatchCandidate(), @@ -266,7 +267,7 @@ private Optional tryFetchCoveringIndexScan(@Nonnull final Parti primaryKey, scanParameters, planContext.getPlannerConfiguration().getIndexFetchMethod(), - RecordQueryFetchFromPartialRecordPlan.FetchIndexRecords.PRIMARY_KEY, + resolveFetchIndexRecords(), isReverse, false, partialMatch.getMatchCandidate(), @@ -279,7 +280,7 @@ private Optional tryFetchCoveringIndexScan(@Nonnull final Parti indexEntryToLogicalRecord.indexKeyValueToPartialRecord()); return Optional.of(new RecordQueryFetchFromPartialRecordPlan(Quantifier.physical(memoizer.memoizePlan(coveringIndexPlan)), - coveringIndexPlan::pushValueThroughFetch, baseRecordType, RecordQueryFetchFromPartialRecordPlan.FetchIndexRecords.PRIMARY_KEY)); + coveringIndexPlan::pushValueThroughFetch, baseRecordType, resolveFetchIndexRecords())); } @Nonnull @@ -298,6 +299,18 @@ public Optional pushValueThroughFetch(@Nonnull final Value toBePushedValu indexEntryToLogicalRecord.logicalValueValues())); } + @Nonnull + private RecordQueryFetchFromPartialRecordPlan.FetchIndexRecords resolveFetchIndexRecords() { + final var anySynthetic = queriedRecordTypes.stream().anyMatch(RecordType::isSynthetic); + if (anySynthetic && !queriedRecordTypes.stream().allMatch(RecordType::isSynthetic)) { + throw new RecordCoreException("cannot mix regular and synthetic record types in index candidate") + .addLogInfo(LogMessageKeys.INDEX_NAME, index.getName()); + } + return anySynthetic + ? RecordQueryFetchFromPartialRecordPlan.FetchIndexRecords.SYNTHETIC_CONSTITUENTS + : RecordQueryFetchFromPartialRecordPlan.FetchIndexRecords.PRIMARY_KEY; + } + @Override public boolean isScopedToSingleType() { return queriedRecordTypes.size() == 1 || hasAndOrderedByRecordTypeKey(); diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/predicates/Placeholder.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/predicates/Placeholder.java index a43f08c848..edd544d06f 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/predicates/Placeholder.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/predicates/Placeholder.java @@ -113,7 +113,7 @@ public Placeholder withExtraRanges(@Nonnull final Set ranges) @Nonnull @Override public Placeholder translateLeafPredicate(@Nonnull final TranslationMap translationMap, final boolean shouldSimplifyValues) { - return new Placeholder(getValue().translateCorrelations(translationMap), + return new Placeholder(getValue().translateCorrelations(translationMap, shouldSimplifyValues), getRanges().stream() .map(range -> range.translateCorrelations(translationMap, shouldSimplifyValues)) .collect(ImmutableSet.toImmutableSet()), getParameterAlias()); diff --git a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/rules/AbstractDataAccessRule.java b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/rules/AbstractDataAccessRule.java index 2a2c48846c..a8b7143134 100644 --- a/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/rules/AbstractDataAccessRule.java +++ b/fdb-record-layer-core/src/main/java/com/apple/foundationdb/record/query/plan/cascades/rules/AbstractDataAccessRule.java @@ -68,7 +68,6 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSetMultimap; -import com.google.common.collect.Iterables; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import org.slf4j.Logger; @@ -157,7 +156,7 @@ public void onMatch(@Nonnull final CascadesRuleCall call) { final var aliasToQuantifierMap = Quantifiers.aliasToQuantifierMap(expression.getQuantifiers()); final var aliases = aliasToQuantifierMap.keySet(); - // group all successful matches by their sets of compensated aliases + // group all successful matches by the set of for-each quantifiers they compensate final var matchPartitionByMatchAliasMap = completeMatches .stream() @@ -171,10 +170,11 @@ public void onMatch(@Nonnull final CascadesRuleCall call) { .filter(matchedAlias -> Objects.requireNonNull(aliasToQuantifierMap.get(matchedAlias)) instanceof Quantifier.ForEach) .collect(ImmutableSet.toImmutableSet()); - if (matchedForEachAliases.size() == 1) { - return Stream.of(NonnullPair.of(Iterables.getOnlyElement(matchedForEachAliases), match)); + if (matchedForEachAliases.isEmpty()) { + // only existential quantifiers... + return Stream.empty(); } - return Stream.empty(); + return Stream.of(NonnullPair.of(matchedForEachAliases, match)); }) .collect(Collectors.groupingBy( Pair::getLeft, @@ -203,11 +203,11 @@ public void onMatch(@Nonnull final CascadesRuleCall call) { ImmutableList.toImmutableList())); // - // Note that this works because there is only one for-each and potentially 0 - n existential quantifiers - // that are covered by the match partition. Even though that logically forms a join, the existential - // quantifiers do not mutate the result of the join, they only cause filtering, that is, the resulting - // record is exactly what the for each quantifier produced filtered by the predicates expressed on the - // existential quantifiers. + // Note that the matches in this partition all compensate the same set of for-each quantifiers, plus + // potentially 0 - n existential quantifiers. A match may therefore stand in for a join, which is the case + // for a candidate over a synthetic record type. That is sound because a match reaching here compensates + // every quantifier the expression owns, so the data access accounts for the whole expression rather than + // for one of its legs; existential quantifiers only cause filtering and do not contribute to the result. // for (final var matchPartitionEntry : matchPartitionsForAliasesByPredicates.entrySet()) { final var matchPartition = matchPartitionEntry.getValue(); diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/metadata/SyntheticRecordTypeExpansionTest.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/metadata/SyntheticRecordTypeExpansionTest.java index 8df12b0276..58f046b204 100644 --- a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/metadata/SyntheticRecordTypeExpansionTest.java +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/metadata/SyntheticRecordTypeExpansionTest.java @@ -40,6 +40,7 @@ import com.apple.foundationdb.record.query.plan.cascades.expressions.SelectExpression; import com.apple.foundationdb.record.query.plan.cascades.expressions.TableFunctionExpression; import com.apple.foundationdb.record.query.plan.cascades.predicates.ValuePredicate; +import com.apple.foundationdb.record.query.plan.cascades.typing.PseudoField; import com.apple.foundationdb.record.query.plan.cascades.typing.Type; import com.apple.foundationdb.record.query.plan.cascades.values.ExplodeValue; import com.apple.foundationdb.record.query.plan.cascades.values.FieldValue; @@ -75,6 +76,8 @@ class SyntheticRecordTypeExpansionTest { @Nonnull private static final String OUTER = "OuterRecord"; @Nonnull + private static final String OTHER = "OtherRecord"; + @Nonnull private static final String PARENT = "parent"; @Nonnull private static final String UNNESTED_MAP = "UnnestedMap"; @@ -395,6 +398,56 @@ void expandOnAnOuterJoinedConstituentIsUnsupported() { assertEquals("cannot expand an outer-joined constituent of a JoinedRecordType", exception.getMessage()); } + @Test + void getPlannerTypeForRecordTypeDescribesASyntheticType() { + final RecordMetaData metaData = mapMetaData(addTwoMapsType()); + final UnnestedRecordType type = unnestedType(metaData, TWO_UNNESTED_MAPS); + + // A synthetic type's name cannot be resolved against the stored types, so only the type-taking overload can + // describe it. + assertThrows(MetaDataException.class, () -> metaData.getPlannerType(TWO_UNNESTED_MAPS)); + final Type.Record plannerType = metaData.getPlannerTypeForRecordType(type); + assertEquals(Type.Record.fromDescriptor(type.getDescriptor()), plannerType); + assertThat(plannerType.getFields().stream().map(Type.Record.Field::getFieldName).collect(Collectors.toList()), + contains(PARENT, "entry_one", "entry_two", UnnestedRecordType.POSITIONS_FIELD)); + } + + @Test + void getPlannerTypeForRecordTypeMatchesTheNameTakingOverloads() { + final RecordMetaData metaData = mapMetaData(metaDataBuilder -> metaDataBuilder.setStoreRecordVersions(true)); + + assertEquals(metaData.getPlannerType(OUTER), + metaData.getPlannerTypeForRecordType(metaData.getRecordType(OUTER))); + assertEquals(metaData.getPlannerType(List.of(OUTER)), + metaData.getPlannerTypeForRecordTypes(List.of(metaData.getRecordType(OUTER)))); + assertEquals(metaData.getPlannerType(List.of(OUTER, OTHER)), + metaData.getPlannerTypeForRecordTypes( + List.of(metaData.getRecordType(OUTER), metaData.getRecordType(OTHER)))); + + // Storing record versions adds the pseudo field to every stored type, including the union of several of them. + final String versionField = PseudoField.ROW_VERSION.getFieldName(); + assertTrue(metaData.getPlannerTypeForRecordType(metaData.getRecordType(OUTER)) + .getFieldNameFieldMap().containsKey(versionField)); + assertTrue(metaData.getPlannerTypeForRecordTypes( + List.of(metaData.getRecordType(OUTER), metaData.getRecordType(OTHER))) + .getFieldNameFieldMap().containsKey(versionField)); + } + + @Test + void getPlannerTypeForRecordTypesUnionsTheirFields() { + final RecordMetaData metaData = mapMetaData(metaDataBuilder -> { }); + final Type.Record unionType = metaData.getPlannerTypeForRecordTypes( + List.of(metaData.getRecordType(OUTER), metaData.getRecordType(OTHER))); + + // `rec_id` and `other_id` are shared, so the union has them once; the remaining fields come from one type each. + assertThat(unionType.getFields().stream().map(Type.Record.Field::getFieldName).collect(Collectors.toList()), + contains("rec_id", "other_id", "map", "other_value")); + + // A single type is described exactly as the type-taking overload would describe it on its own. + assertEquals(metaData.getPlannerTypeForRecordType(metaData.getRecordType(OUTER)), + metaData.getPlannerTypeForRecordTypes(List.of(metaData.getRecordType(OUTER)))); + } + @Nonnull private static RecordMetaData mapMetaData(@Nonnull Consumer hook) { final RecordMetaDataBuilder metaDataBuilder = RecordMetaData.newBuilder() diff --git a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/IndexEntryTranslatorEquivalenceTest.java b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/IndexEntryTranslatorEquivalenceTest.java index e11547f03b..2b98c33390 100644 --- a/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/IndexEntryTranslatorEquivalenceTest.java +++ b/fdb-record-layer-core/src/test/java/com/apple/foundationdb/record/query/plan/cascades/IndexEntryTranslatorEquivalenceTest.java @@ -46,6 +46,7 @@ import static com.apple.foundationdb.record.metadata.Key.Expressions.concat; import static com.apple.foundationdb.record.metadata.Key.Expressions.concatenateFields; import static com.apple.foundationdb.record.metadata.Key.Expressions.field; +import static com.apple.foundationdb.record.metadata.Key.Expressions.list; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -151,6 +152,65 @@ void bothTranslatorsKeepTheFirstColumnOfARepeatedField() { .findFirst().orElseThrow().getValue().toString()); } + /** + * A {@link com.apple.foundationdb.record.metadata.expressions.ListKeyExpression} position holds its child in a + * nested tuple instead of flattening it, the way the primary key of a synthetic record type holds each of its + * constituents. The data of such a position therefore sit one level deeper, one element per column of the child: + *

+     *   primary key : list(header.(path, num), header.rec_no)
+     *   index str   : str_value
+     *   entry key   : ( "abc", ("/a", 1), (7) )   with the primary key appended
+     *   record      : MyRecord { str_value: "abc", header: { path: "/a", num: 1, rec_no: 7 } }
+     * 
+ * Each of the three data of the two list positions is read on its own, at {@code [1, 0]}, {@code [1, 1]} and + * {@code [2, 0]} respectively. + */ + @Test + void bothTranslatorsDescendIntoAListPosition() { + final var metaDataBuilder = RecordMetaData.newBuilder() + .setRecords(TestRecordsWithHeaderProto.getDescriptor()); + metaDataBuilder.getRecordType("MyRecord").setPrimaryKey(list( + field("header").nest(concatenateFields("path", "num")), + field("header").nest(field("rec_no")))); + metaDataBuilder.addIndex("MyRecord", new Index("str", field("str_value"))); + final var metaData = metaDataBuilder.build(); + final var recordType = metaData.getRecordType("MyRecord"); + final var descriptor = recordType.getDescriptor(); + final var baseType = Type.Record.fromDescriptor(descriptor); + final var baseAlias = Quantifier.current(); + final var baseObjectValue = QuantifiedObjectValue.of(baseAlias, baseType); + + // One value per entry key column, as the index expansion produces them. A list position holds its child in a + // nested tuple, so its value is the record of the child's column values, however many there are. + final var indexKeyValues = ImmutableList.of( + FieldValue.ofFieldNames(baseObjectValue, List.of("str_value")), + RecordConstructorValue.ofUnnamed(ImmutableList.of( + FieldValue.ofFieldNames(baseObjectValue, List.of("header", "path")), + FieldValue.ofFieldNames(baseObjectValue, List.of("header", "num")))), + RecordConstructorValue.ofUnnamed(ImmutableList.of( + FieldValue.ofFieldNames(baseObjectValue, List.of("header", "rec_no"))))); + + final var translators = ScanWithFetchMatchCandidate.computeIndexEntryToLogicalRecord(List.of(recordType), + baseAlias, baseType, indexKeyValues, ImmutableList.of()).orElseThrow(); + final var recordValue = translators.indexEntryToRecordValue(); + assertNotNull(recordValue, "a list position should still yield a value based translator"); + + final var entry = new IndexEntry(metaData.getIndex("str"), + Tuple.from("abc", Tuple.from("/a", 1L), Tuple.from(7L)), TupleHelpers.EMPTY); + + final var byCopiers = translators.indexKeyValueToPartialRecord().toRecord(descriptor, entry); + final var byValue = evaluate(recordValue, entry); + + // Non-vacuous: the three columns nested inside the two list positions are all recovered. + final var header = (Message)byCopiers.getField(descriptor.findFieldByName("header")); + final var headerDescriptor = descriptor.findFieldByName("header").getMessageType(); + assertEquals("/a", header.getField(headerDescriptor.findFieldByName("path"))); + assertEquals(1, header.getField(headerDescriptor.findFieldByName("num"))); + assertEquals(7L, header.getField(headerDescriptor.findFieldByName("rec_no"))); + + assertSameRecord(descriptor, byCopiers, byValue); + } + @Nonnull private static Message evaluate(@Nonnull final Value recordValue, @Nonnull final IndexEntry entry) { final var typeRepository = TypeRepository.newBuilder().addTypeIfNeeded(recordValue.getResultType()).build(); diff --git a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/TransactionBoundQueryTest.java b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/TransactionBoundQueryTest.java index d430de7b52..b01909c2c4 100644 --- a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/TransactionBoundQueryTest.java +++ b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/TransactionBoundQueryTest.java @@ -202,7 +202,7 @@ void canUseRecordLayerIndex() throws SQLException, RelationalException { } @Test - void ignoreSyntheticIndexes() throws SQLException, RelationalException { + void canUseUnnestedSyntheticRecordType() throws SQLException, RelationalException { RecordMetaData metaDataOrig = getUpdatedMetaData(metaDataBuilder -> { }); RecordMetaData metaData = getUpdatedMetaData(metaDataBuilder -> { @@ -240,7 +240,6 @@ void ignoreSyntheticIndexes() throws SQLException, RelationalException { .isPresent() .get() .satisfies(table -> - // The table should contain only the index that Relational can process Assertions.assertThat(table.getIndexes()) .map(Metadata::getName) .contains("aIndex") @@ -249,8 +248,6 @@ void ignoreSyntheticIndexes() throws SQLException, RelationalException { ); try (RelationalStatement statement = connection.createStatement()) { - // This query could use the unnested index if we planned against it correctly. In particular, - // it could be executed as scan on the first two columns of the index String sqlQuery = "SELECT e.value " + "FROM t2, (SELECT key, value FROM t2.d) e " + "WHERE t2.c = 'foo' AND e.key = 'bar'"; @@ -258,8 +255,9 @@ void ignoreSyntheticIndexes() throws SQLException, RelationalException { RelationalResultSetAssert.assertThat(resultSet) .hasNextRow(); Assertions.assertThat(resultSet.getString("plan")) - .doesNotContain("unnestedIndex") - .contains("ISCAN(cIndex"); + .as("Planner should select the index on the unnested synthetic record type and bind both equalities") + .containsPattern("COVERING\\(unnestedIndex \\[EQUALS [^,]+, EQUALS [^]]+\\]") + .doesNotContain("ISCAN(cIndex"); RelationalResultSetAssert.assertThat(resultSet) .hasNoNextRow(); } diff --git a/yaml-tests/src/test/java/YamlIntegrationTests.java b/yaml-tests/src/test/java/YamlIntegrationTests.java index 104b7984a8..21b106ad1d 100644 --- a/yaml-tests/src/test/java/YamlIntegrationTests.java +++ b/yaml-tests/src/test/java/YamlIntegrationTests.java @@ -498,6 +498,11 @@ public void unionEmptyTables(YamlTest.Runner runner) throws Exception { runner.runYamsql("union-empty-tables.yamsql"); } + @TestTemplate + public void unnestedRecordTypeIndexes(YamlTest.Runner runner) throws Exception { + runner.runYamsql("unnested-record-type-indexes.yamsql"); + } + @TestTemplate public void updateDeleteReturning(YamlTest.Runner runner) throws Exception { runner.runYamsql("update-delete-returning.yamsql"); diff --git a/yaml-tests/src/test/resources/unnested-record-type-indexes.metrics.binpb b/yaml-tests/src/test/resources/unnested-record-type-indexes.metrics.binpb new file mode 100644 index 0000000000..53a069294b Binary files /dev/null and b/yaml-tests/src/test/resources/unnested-record-type-indexes.metrics.binpb differ diff --git a/yaml-tests/src/test/resources/unnested-record-type-indexes.metrics.yaml b/yaml-tests/src/test/resources/unnested-record-type-indexes.metrics.yaml new file mode 100644 index 0000000000..1a9986a443 --- /dev/null +++ b/yaml-tests/src/test/resources/unnested-record-type-indexes.metrics.yaml @@ -0,0 +1,714 @@ +unnamed-3: +- query: EXPLAIN SELECT SQ."reviewer", "restaurant"."region", SQ."rating" FROM "restaurant", + (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ ORDER BY SQ."reviewer", + "restaurant"."region", SQ."rating" + ref: unnested-record-type-indexes.yamsql:146 + explain: 'COVERING(mv_split <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: + [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: + KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, + _.unnesting_0.rating AS rating)' + task_count: 261 + task_total_time_ms: 181 + transform_count: 78 + transform_time_ms: 111 + transform_yield_count: 24 + insert_time_ms: 13 + insert_new_count: 26 + insert_reused_count: 0 +- query: EXPLAIN SELECT SQ."reviewer", "restaurant"."region", SQ."rating" FROM "restaurant", + (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + ref: unnested-record-type-indexes.yamsql:157 + explain: 'COVERING(mv_split <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: + [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: + KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, + _.unnesting_0.rating AS rating)' + task_count: 290 + task_total_time_ms: 190 + transform_count: 85 + transform_time_ms: 115 + transform_yield_count: 27 + insert_time_ms: 13 + insert_new_count: 31 + insert_reused_count: 0 +- query: EXPLAIN SELECT SQ."reviewer" FROM "restaurant", (SELECT "reviewer", "rating" + FROM "restaurant"."reviews") AS SQ ORDER BY SQ."reviewer" + ref: unnested-record-type-indexes.yamsql:169 + explain: 'COVERING(mv_split <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: + [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: + KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer)' + task_count: 268 + task_total_time_ms: 177 + transform_count: 82 + transform_time_ms: 113 + transform_yield_count: 25 + insert_time_ms: 13 + insert_new_count: 27 + insert_reused_count: 0 +- query: EXPLAIN SELECT "r"."reviewer", "restaurant"."region", "r"."rating" FROM + "restaurant", "restaurant"."reviews" AS "r" ORDER BY "r"."reviewer", "restaurant"."region", + "r"."rating" + ref: unnested-record-type-indexes.yamsql:182 + explain: 'COVERING(mv_split <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: + [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: + KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, + _.unnesting_0.rating AS rating)' + task_count: 236 + task_total_time_ms: 43 + transform_count: 73 + transform_time_ms: 22 + transform_yield_count: 22 + insert_time_ms: 0 + insert_new_count: 21 + insert_reused_count: 0 +- query: EXPLAIN SELECT SQ."reviewer", "restaurant"."region", SQ."rating" FROM "restaurant", + (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ WHERE SQ."reviewer" + = 'Alice' + ref: unnested-record-type-indexes.yamsql:195 + explain: 'COVERING(mv_split [EQUALS promote(@c32 AS STRING)] -> [__positions: + [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], + unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer + AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)' + task_count: 397 + task_total_time_ms: 189 + transform_count: 129 + transform_time_ms: 110 + transform_yield_count: 35 + insert_time_ms: 7 + insert_new_count: 47 + insert_reused_count: 1 +- query: EXPLAIN SELECT SQ."reviewer", "restaurant"."region", SQ."rating" FROM "restaurant", + (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ WHERE SQ."reviewer" + = 'Alice' AND "restaurant"."region" = 'north' + ref: unnested-record-type-indexes.yamsql:204 + explain: 'COVERING(mv_split [EQUALS promote(@c32 AS STRING), EQUALS promote(@c38 + AS STRING)] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], + rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) + | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating + AS rating)' + task_count: 467 + task_total_time_ms: 193 + transform_count: 149 + transform_time_ms: 112 + transform_yield_count: 37 + insert_time_ms: 8 + insert_new_count: 53 + insert_reused_count: 3 +- query: EXPLAIN SELECT "r"."reviewer", "restaurant"."region", "r"."rating" FROM + "restaurant", "restaurant"."reviews" AS "r" WHERE "r"."reviewer" = 'Alice' + ref: unnested-record-type-indexes.yamsql:213 + explain: 'COVERING(mv_split [EQUALS promote(@c25 AS STRING)] -> [__positions: + [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], + unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer + AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)' + task_count: 362 + task_total_time_ms: 190 + transform_count: 123 + transform_time_ms: 115 + transform_yield_count: 32 + insert_time_ms: 5 + insert_new_count: 39 + insert_reused_count: 1 +- query: EXPLAIN SELECT SQ."reviewer", "restaurant"."region", SQ."rating" FROM "restaurant", + (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ WHERE SQ."reviewer" + > 'Bob' + ref: unnested-record-type-indexes.yamsql:223 + explain: 'COVERING(mv_split [[GREATER_THAN promote(@c32 AS STRING)]] -> [__positions: + [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], + unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer + AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)' + task_count: 397 + task_total_time_ms: 96 + transform_count: 129 + transform_time_ms: 42 + transform_yield_count: 35 + insert_time_ms: 4 + insert_new_count: 47 + insert_reused_count: 1 +- query: EXPLAIN SELECT SQ."reviewer", "restaurant"."region", SQ."rating" FROM "restaurant", + (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ WHERE SQ."reviewer" + < 'Carol' + ref: unnested-record-type-indexes.yamsql:231 + explain: 'COVERING(mv_split [[LESS_THAN promote(@c32 AS STRING)]] -> [__positions: + [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], + unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer + AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)' + task_count: 397 + task_total_time_ms: 54 + transform_count: 129 + transform_time_ms: 27 + transform_yield_count: 35 + insert_time_ms: 3 + insert_new_count: 47 + insert_reused_count: 1 +- query: EXPLAIN SELECT SQ."reviewer", "restaurant"."region", SQ."rating" FROM "restaurant", + (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ WHERE SQ."reviewer" + > 'Alice' AND SQ."reviewer" < 'Dave' + ref: unnested-record-type-indexes.yamsql:241 + explain: 'COVERING(mv_split [[GREATER_THAN promote(@c32 AS STRING) && LESS_THAN + promote(@c38 AS STRING)]] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: + [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: + KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, + _.unnesting_0.rating AS rating)' + task_count: 397 + task_total_time_ms: 143 + transform_count: 129 + transform_time_ms: 66 + transform_yield_count: 35 + insert_time_ms: 7 + insert_new_count: 47 + insert_reused_count: 1 +- query: EXPLAIN SELECT SQ."reviewer", "restaurant"."region", SQ."rating" FROM "restaurant", + (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ WHERE SQ."reviewer" + = 'Alice' AND "restaurant"."region" < 'south' + ref: unnested-record-type-indexes.yamsql:251 + explain: 'COVERING(mv_split [EQUALS promote(@c32 AS STRING), [LESS_THAN promote(@c38 + AS STRING)]] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: + KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) + | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating + AS rating)' + task_count: 467 + task_total_time_ms: 147 + transform_count: 149 + transform_time_ms: 67 + transform_yield_count: 37 + insert_time_ms: 7 + insert_new_count: 53 + insert_reused_count: 3 +- query: EXPLAIN SELECT SQ."reviewer", "restaurant"."region", SQ."rating" FROM "restaurant", + (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ WHERE SQ."reviewer" + = 'Alice' AND "restaurant"."region" = 'north' AND SQ."rating" > 4 + ref: unnested-record-type-indexes.yamsql:261 + explain: 'COVERING(mv_split [EQUALS promote(@c32 AS STRING), EQUALS promote(@c38 + AS STRING), [GREATER_THAN promote(@c44 AS LONG)]] -> [__positions: [unnesting_0: + KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: + [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, + _.parent.region AS region, _.unnesting_0.rating AS rating)' + task_count: 467 + task_total_time_ms: 152 + transform_count: 149 + transform_time_ms: 68 + transform_yield_count: 37 + insert_time_ms: 8 + insert_new_count: 53 + insert_reused_count: 3 +- query: EXPLAIN SELECT SQ."reviewer", "restaurant"."region", SQ."rating" FROM "restaurant", + (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ WHERE SQ."reviewer" + = 'Alice' AND "restaurant"."region" = 'north' AND SQ."rating" < 5 + ref: unnested-record-type-indexes.yamsql:267 + explain: 'COVERING(mv_split [EQUALS promote(@c32 AS STRING), EQUALS promote(@c38 + AS STRING), [LESS_THAN promote(@c44 AS LONG)]] -> [__positions: [unnesting_0: + KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: + [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, + _.parent.region AS region, _.unnesting_0.rating AS rating)' + task_count: 467 + task_total_time_ms: 150 + transform_count: 149 + transform_time_ms: 66 + transform_yield_count: 37 + insert_time_ms: 8 + insert_new_count: 53 + insert_reused_count: 3 +escaped-identifiers: +- query: EXPLAIN SELECT SQ."re.viewer", "resta.urant$t"."re.gion", SQ."ra$ting" + FROM "resta.urant$t", (SELECT "re.viewer", "ra$ting" FROM "resta.urant$t"."re.views") + AS SQ ORDER BY SQ."re.viewer", "resta.urant$t"."re.gion", SQ."ra$ting" + ref: unnested-record-type-indexes.yamsql:280 + explain: 'COVERING(mv.split$esc <,> -> [__positions: [unnesting_0: KEY:[5, 0]], + parent: [re__2gion: KEY:[1], rest__2no: KEY:[4, 1]], unnesting_0: [ra__1ting: + KEY:[2], re__2viewer: KEY:[0]]]) | MAP (_.unnesting_0.re.viewer AS re.viewer, + _.parent.re.gion AS re.gion, _.unnesting_0.ra$ting AS ra$ting)' + task_count: 261 + task_total_time_ms: 63 + transform_count: 78 + transform_time_ms: 27 + transform_yield_count: 24 + insert_time_ms: 1 + insert_new_count: 26 + insert_reused_count: 0 +- query: EXPLAIN SELECT "r"."re.viewer", "resta.urant$t"."re.gion", "r"."ra$ting" + FROM "resta.urant$t", "resta.urant$t"."re.views" AS "r" ORDER BY "r"."re.viewer", + "resta.urant$t"."re.gion", "r"."ra$ting" + ref: unnested-record-type-indexes.yamsql:290 + explain: 'COVERING(mv.split$esc <,> -> [__positions: [unnesting_0: KEY:[5, 0]], + parent: [re__2gion: KEY:[1], rest__2no: KEY:[4, 1]], unnesting_0: [ra__1ting: + KEY:[2], re__2viewer: KEY:[0]]]) | MAP (_.unnesting_0.re.viewer AS re.viewer, + _.parent.re.gion AS re.gion, _.unnesting_0.ra$ting AS ra$ting)' + task_count: 236 + task_total_time_ms: 62 + transform_count: 73 + transform_time_ms: 25 + transform_yield_count: 22 + insert_time_ms: 1 + insert_new_count: 21 + insert_reused_count: 0 +- query: EXPLAIN SELECT SQ."re.viewer", "resta.urant$t"."re.gion", SQ."ra$ting" + FROM "resta.urant$t", (SELECT "re.viewer", "ra$ting" FROM "resta.urant$t"."re.views") + AS SQ WHERE SQ."re.viewer" = 'Alice' + ref: unnested-record-type-indexes.yamsql:301 + explain: 'COVERING(mv.split$esc [EQUALS promote(@c32 AS STRING)] -> [__positions: + [unnesting_0: KEY:[5, 0]], parent: [re__2gion: KEY:[1], rest__2no: KEY:[4, + 1]], unnesting_0: [ra__1ting: KEY:[2], re__2viewer: KEY:[0]]]) | MAP (_.unnesting_0.re.viewer + AS re.viewer, _.parent.re.gion AS re.gion, _.unnesting_0.ra$ting AS ra$ting)' + task_count: 397 + task_total_time_ms: 99 + transform_count: 129 + transform_time_ms: 45 + transform_yield_count: 35 + insert_time_ms: 4 + insert_new_count: 47 + insert_reused_count: 1 +deeply-nested-array: +- query: EXPLAIN SELECT SQ."label", "item"."kind", SQ."weight" FROM "item", (SELECT + "label", "weight" FROM "item"."meta"."tag_set"."tags") AS SQ ORDER BY SQ."label", + "item"."kind", SQ."weight" + ref: unnested-record-type-indexes.yamsql:313 + explain: 'COVERING(mv_deep_array <,> -> [__positions: [unnesting_0: KEY:[5, 0]], + parent: [item_no: KEY:[4, 1], kind: KEY:[1]], unnesting_0: [label: KEY:[0], + weight: KEY:[2]]]) | MAP (_.unnesting_0.label AS label, _.parent.kind AS kind, + _.unnesting_0.weight AS weight)' + task_count: 261 + task_total_time_ms: 58 + transform_count: 78 + transform_time_ms: 24 + transform_yield_count: 24 + insert_time_ms: 1 + insert_new_count: 26 + insert_reused_count: 0 +- query: EXPLAIN SELECT "t"."label", "item"."kind", "t"."weight" FROM "item", "item"."meta"."tag_set"."tags" + AS "t" ORDER BY "t"."label", "item"."kind", "t"."weight" + ref: unnested-record-type-indexes.yamsql:322 + explain: 'COVERING(mv_deep_array <,> -> [__positions: [unnesting_0: KEY:[5, 0]], + parent: [item_no: KEY:[4, 1], kind: KEY:[1]], unnesting_0: [label: KEY:[0], + weight: KEY:[2]]]) | MAP (_.unnesting_0.label AS label, _.parent.kind AS kind, + _.unnesting_0.weight AS weight)' + task_count: 236 + task_total_time_ms: 53 + transform_count: 73 + transform_time_ms: 22 + transform_yield_count: 22 + insert_time_ms: 0 + insert_new_count: 21 + insert_reused_count: 0 +- query: EXPLAIN SELECT SQ."label", "item"."kind", SQ."weight" FROM "item", (SELECT + "label", "weight" FROM "item"."meta"."tag_set"."tags") AS SQ WHERE SQ."label" + = 'hammer' + ref: unnested-record-type-indexes.yamsql:331 + explain: 'COVERING(mv_deep_array [EQUALS promote(@c36 AS STRING)] -> [__positions: + [unnesting_0: KEY:[5, 0]], parent: [item_no: KEY:[4, 1], kind: KEY:[1]], unnesting_0: + [label: KEY:[0], weight: KEY:[2]]]) | MAP (_.unnesting_0.label AS label, _.parent.kind + AS kind, _.unnesting_0.weight AS weight)' + task_count: 397 + task_total_time_ms: 85 + transform_count: 129 + transform_time_ms: 38 + transform_yield_count: 35 + insert_time_ms: 3 + insert_new_count: 47 + insert_reused_count: 1 +deeply-nested-element: +- query: EXPLAIN SELECT "a"."name"."last" AS "last", "book"."title", "a"."score" + FROM "book", "book"."authors" AS "a" ORDER BY "a"."name"."last", "book"."title", + "a"."score" + ref: unnested-record-type-indexes.yamsql:343 + explain: 'COVERING(mv_deep_element <,> -> [__positions: [unnesting_0: KEY:[5, + 0]], parent: [book_no: KEY:[4, 1], title: KEY:[1]], unnesting_0: [score: KEY:[2], + name: [last: KEY:[0]]]]) | MAP (_.unnesting_0.name.last AS last, _.parent.title + AS title, _.unnesting_0.score AS score)' + task_count: 236 + task_total_time_ms: 50 + transform_count: 73 + transform_time_ms: 22 + transform_yield_count: 22 + insert_time_ms: 0 + insert_new_count: 21 + insert_reused_count: 0 +- query: EXPLAIN SELECT SQ."name"."last" AS "last", "book"."title", SQ."score" FROM + "book", (SELECT "name", "score" FROM "book"."authors") AS SQ ORDER BY SQ."name"."last", + "book"."title", SQ."score" + ref: unnested-record-type-indexes.yamsql:353 + explain: 'COVERING(mv_deep_element <,> -> [__positions: [unnesting_0: KEY:[5, + 0]], parent: [book_no: KEY:[4, 1], title: KEY:[1]], unnesting_0: [score: KEY:[2], + name: [last: KEY:[0]]]]) | MAP (_.unnesting_0.name.last AS last, _.parent.title + AS title, _.unnesting_0.score AS score)' + task_count: 261 + task_total_time_ms: 53 + transform_count: 78 + transform_time_ms: 22 + transform_yield_count: 24 + insert_time_ms: 1 + insert_new_count: 26 + insert_reused_count: 0 +- query: EXPLAIN SELECT "a"."name"."last" AS "last", "book"."title", "a"."score" + FROM "book", "book"."authors" AS "a" WHERE "a"."name"."last" = 'Herbert' ORDER + BY "a"."name"."last", "book"."title", "a"."score" + ref: unnested-record-type-indexes.yamsql:363 + explain: 'COVERING(mv_deep_element [EQUALS promote(@c31 AS STRING)] -> [__positions: + [unnesting_0: KEY:[5, 0]], parent: [book_no: KEY:[4, 1], title: KEY:[1]], + unnesting_0: [score: KEY:[2], name: [last: KEY:[0]]]]) | MAP (_.unnesting_0.name.last + AS last, _.parent.title AS title, _.unnesting_0.score AS score)' + task_count: 322 + task_total_time_ms: 70 + transform_count: 113 + transform_time_ms: 39 + transform_yield_count: 29 + insert_time_ms: 1 + insert_new_count: 31 + insert_reused_count: 0 +primary-key-in-index-key: +- query: EXPLAIN SELECT SQ."day", "venue"."venue_no", SQ."seats" FROM "venue", (SELECT + "day", "seats" FROM "venue"."slots") AS SQ ORDER BY SQ."day", "venue"."venue_no", + SQ."seats" + ref: unnested-record-type-indexes.yamsql:380 + explain: 'COVERING(mv_pk_in_key <,> -> [__positions: [unnesting_0: KEY:[5, 0]], + parent: [venue_no: KEY:[1]], unnesting_0: [day: KEY:[0], seats: KEY:[2]]]) + | MAP (_.unnesting_0.day AS day, _.parent.venue_no AS venue_no, _.unnesting_0.seats + AS seats)' + task_count: 268 + task_total_time_ms: 57 + transform_count: 82 + transform_time_ms: 25 + transform_yield_count: 25 + insert_time_ms: 1 + insert_new_count: 27 + insert_reused_count: 0 +- query: EXPLAIN SELECT SQ."day", "venue"."venue_no", SQ."seats" FROM "venue", (SELECT + "day", "seats" FROM "venue"."slots") AS SQ WHERE SQ."day" = 'mon' AND "venue"."venue_no" + = 1 + ref: unnested-record-type-indexes.yamsql:391 + explain: 'COVERING(mv_pk_in_key [EQUALS promote(@c32 AS STRING), EQUALS promote(@c38 + AS LONG)] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [venue_no: KEY:[1]], + unnesting_0: [day: KEY:[0], seats: KEY:[2]]]) | MAP (_.unnesting_0.day AS + day, _.parent.venue_no AS venue_no, _.unnesting_0.seats AS seats)' + task_count: 479 + task_total_time_ms: 55 + transform_count: 149 + transform_time_ms: 24 + transform_yield_count: 39 + insert_time_ms: 2 + insert_new_count: 55 + insert_reused_count: 2 +- query: EXPLAIN SELECT SQ."seats" FROM "venue", (SELECT "day", "seats" FROM "venue"."slots") + AS SQ ORDER BY SQ."day", "venue"."venue_no", SQ."seats" + ref: unnested-record-type-indexes.yamsql:399 + explain: 'COVERING(mv_pk_in_key <,> -> [__positions: [unnesting_0: KEY:[5, 0]], + parent: [venue_no: KEY:[1]], unnesting_0: [day: KEY:[0], seats: KEY:[2]]]) + | MAP (_.unnesting_0.seats AS seats, _.unnesting_0.day AS day, _.parent.venue_no + AS venue_no) | MAP (_.seats AS seats)' + task_count: 334 + task_total_time_ms: 66 + transform_count: 98 + transform_time_ms: 25 + transform_yield_count: 27 + insert_time_ms: 1 + insert_new_count: 32 + insert_reused_count: 0 +index-scan-with-fetch: +- query: EXPLAIN SELECT "venue"."city", SQ."day", SQ."seats" FROM "venue", (SELECT + "day", "seats" FROM "venue"."slots") AS SQ WHERE SQ."day" = 'mon' + ref: unnested-record-type-indexes.yamsql:410 + explain: ISCAN(mv_pk_in_key [EQUALS promote(@c32 AS STRING)]) | MAP (_.parent.city + AS city, _.unnesting_0.day AS day, _.unnesting_0.seats AS seats) + task_count: 369 + task_total_time_ms: 39 + transform_count: 125 + transform_time_ms: 19 + transform_yield_count: 33 + insert_time_ms: 2 + insert_new_count: 44 + insert_reused_count: 1 +- query: EXPLAIN SELECT "venue"."city", SQ."day" FROM "venue", (SELECT "day", "seats" + FROM "venue"."slots") AS SQ ORDER BY SQ."day" + ref: unnested-record-type-indexes.yamsql:416 + explain: ISCAN(mv_pk_in_key <,>) | MAP (_.parent.city AS city, _.unnesting_0.day + AS day) + task_count: 240 + task_total_time_ms: 35 + transform_count: 78 + transform_time_ms: 15 + transform_yield_count: 23 + insert_time_ms: 0 + insert_new_count: 24 + insert_reused_count: 0 +- query: EXPLAIN SELECT "a"."name"."first" AS "first", "a"."name"."last" AS "last", + "book"."title" FROM "book", "book"."authors" AS "a" WHERE "a"."name"."last" + = 'Herbert' ORDER BY "a"."name"."last", "book"."title", "a"."score" + ref: unnested-record-type-indexes.yamsql:423 + explain: ISCAN(mv_deep_element [EQUALS promote(@c35 AS STRING)]) | MAP (_.unnesting_0.name.first + AS first, _.unnesting_0.name.last AS last, _.parent.title AS title, _.unnesting_0.score + AS score) | MAP (_.first AS first, _.last AS last, _.title AS title) + task_count: 354 + task_total_time_ms: 27 + transform_count: 125 + transform_time_ms: 16 + transform_yield_count: 29 + insert_time_ms: 0 + insert_new_count: 32 + insert_reused_count: 0 +- query: EXPLAIN SELECT "item"."meta"."owner" AS "owner", SQ."label", SQ."weight" + FROM "item", (SELECT "label", "weight" FROM "item"."meta"."tag_set"."tags") + AS SQ WHERE SQ."label" = 'hammer' + ref: unnested-record-type-indexes.yamsql:431 + explain: ISCAN(mv_deep_array [EQUALS promote(@c40 AS STRING)]) | MAP (_.parent.meta.owner + AS owner, _.unnesting_0.label AS label, _.unnesting_0.weight AS weight) + task_count: 369 + task_total_time_ms: 60 + transform_count: 125 + transform_time_ms: 27 + transform_yield_count: 33 + insert_time_ms: 2 + insert_new_count: 44 + insert_reused_count: 1 +aggregations: +- query: EXPLAIN SELECT COUNT(*) AS "n" FROM "restaurant", (SELECT "reviewer", "rating" + FROM "restaurant"."reviews") AS SQ + ref: unnested-record-type-indexes.yamsql:443 + explain: ISCAN(mv_split <,>) | MAP (_.parent AS _0, _.unnesting_0 AS _1) | AGG + (count_star(*) AS _0) GROUP BY () | ON EMPTY NULL | MAP (coalesce_long(_._0._0, + 0l) AS n) + task_count: 365 + task_total_time_ms: 35 + transform_count: 102 + transform_time_ms: 10 + transform_yield_count: 29 + insert_time_ms: 0 + insert_new_count: 39 + insert_reused_count: 0 +- query: EXPLAIN SELECT SUM(SQ."rating") AS "total" FROM "restaurant", (SELECT "reviewer", + "rating" FROM "restaurant"."reviews") AS SQ + ref: unnested-record-type-indexes.yamsql:450 + explain: ISCAN(mv_split <,>) | MAP (_.parent AS _0, _.unnesting_0 AS _1) | AGG + (sum_l(_._1.rating) AS _0) GROUP BY () | ON EMPTY NULL | MAP (_._0._0 AS total) + task_count: 365 + task_total_time_ms: 38 + transform_count: 102 + transform_time_ms: 12 + transform_yield_count: 29 + insert_time_ms: 1 + insert_new_count: 39 + insert_reused_count: 0 +- query: EXPLAIN SELECT SQ."reviewer" AS "reviewer", COUNT(*) AS "n" FROM "restaurant", + (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ GROUP BY SQ."reviewer" + ref: unnested-record-type-indexes.yamsql:458 + explain: ISCAN(mv_split <,>) | MAP (_.parent AS _0, _.unnesting_0 AS _1) | AGG + (count_star(*) AS _0) GROUP BY (_._1.reviewer AS _0) | MAP (_._0._0 AS reviewer, + _._1._0 AS n) + task_count: 329 + task_total_time_ms: 30 + transform_count: 99 + transform_time_ms: 13 + transform_yield_count: 27 + insert_time_ms: 1 + insert_new_count: 32 + insert_reused_count: 0 +- query: EXPLAIN SELECT SQ."reviewer" AS "reviewer", SUM(SQ."rating") AS "total", + MAX(SQ."rating") AS "best" FROM "restaurant", (SELECT "reviewer", "rating" + FROM "restaurant"."reviews") AS SQ GROUP BY SQ."reviewer" + ref: unnested-record-type-indexes.yamsql:469 + explain: ISCAN(mv_split <,>) | MAP (_.parent AS _0, _.unnesting_0 AS _1) | AGG + (sum_l(_._1.rating) AS _0, max_l(_._1.rating) AS _1) GROUP BY (_._1.reviewer + AS _0) | MAP (_._0._0 AS reviewer, _._1._0 AS total, _._1._1 AS best) + task_count: 329 + task_total_time_ms: 38 + transform_count: 99 + transform_time_ms: 14 + transform_yield_count: 27 + insert_time_ms: 0 + insert_new_count: 32 + insert_reused_count: 0 +- query: EXPLAIN SELECT SUM(SQ."rating") AS "total" FROM "restaurant", (SELECT "reviewer", + "rating" FROM "restaurant"."reviews") AS SQ WHERE SQ."reviewer" = 'Alice' + ref: unnested-record-type-indexes.yamsql:485 + explain: ISCAN(mv_split [EQUALS promote(@c29 AS STRING)]) | MAP (_.parent AS _0, + _.unnesting_0 AS _1) | AGG (sum_l(_._1.rating) AS _0) GROUP BY () | ON EMPTY + NULL | MAP (_._0._0 AS total) + task_count: 472 + task_total_time_ms: 59 + transform_count: 146 + transform_time_ms: 26 + transform_yield_count: 37 + insert_time_ms: 2 + insert_new_count: 55 + insert_reused_count: 1 +- query: EXPLAIN SELECT "a"."name"."last" AS "last", MAX("a"."score") AS "best" + FROM "book", "book"."authors" AS "a" GROUP BY "a"."name"."last" + ref: unnested-record-type-indexes.yamsql:492 + explain: ISCAN(mv_deep_element <,>) | MAP (_.parent AS _0, (_.unnesting_0 AS _0) + AS _1) | AGG (max_l(_._1._0.score) AS _0) GROUP BY (_._1._0.name.last AS _0) + | MAP (_._0._0 AS last, _._1._0 AS best) + task_count: 304 + task_total_time_ms: 36 + transform_count: 94 + transform_time_ms: 13 + transform_yield_count: 25 + insert_time_ms: 0 + insert_new_count: 27 + insert_reused_count: 0 +- query: EXPLAIN SELECT "venue"."venue_no" AS "venue_no", SUM(SQ."seats") AS "total" + FROM "venue", (SELECT "day", "seats" FROM "venue"."slots") AS SQ GROUP BY + "venue"."venue_no" + ref: unnested-record-type-indexes.yamsql:498 + explain: SCAN([IS venue]) | FLATMAP q0 -> { TF explode(q0.slots) AS q1 RETURN + (q0 AS _0, q1._0 AS _1) } | AGG (sum_l(_._1.seats) AS _0) GROUP BY (_._0.venue_no + AS _0) | MAP (_._0._0 AS venue_no, _._1._0 AS total) + task_count: 283 + task_total_time_ms: 35 + transform_count: 81 + transform_time_ms: 12 + transform_yield_count: 25 + insert_time_ms: 0 + insert_new_count: 28 + insert_reused_count: 0 +stored-table-index-vs-synthetic-type-index: +- query: EXPLAIN SELECT "kiosk"."kiosk_no", SQ."sku", SQ."qty" FROM "kiosk", (SELECT + "sku", "qty" FROM "kiosk"."lines") AS SQ WHERE "kiosk"."city" = 'SF' + ref: unnested-record-type-indexes.yamsql:519 + explain: ISCAN(kiosk_city [EQUALS promote(@c32 AS STRING)]) | FLATMAP q0 -> { + TF explode(q0.lines) AS q1 RETURN (q0.kiosk_no AS kiosk_no, q1._0.sku AS sku, + q1._0.qty AS qty) } + task_count: 468 + task_total_time_ms: 78 + transform_count: 127 + transform_time_ms: 32 + transform_yield_count: 38 + insert_time_ms: 4 + insert_new_count: 62 + insert_reused_count: 3 +- query: EXPLAIN SELECT "kiosk"."kiosk_no", SQ."qty" FROM "kiosk", (SELECT "sku", + "qty" FROM "kiosk"."lines") AS SQ WHERE "kiosk"."city" = 'SF' AND SQ."sku" + = 'apple' + ref: unnested-record-type-indexes.yamsql:531 + explain: ISCAN(kiosk_city [EQUALS promote(@c28 AS STRING)]) | FLATMAP q0 -> { + TF explode(q0.lines) | FILTER _._0.sku EQUALS promote(@c34 AS STRING) AS q1 + RETURN (q0.kiosk_no AS kiosk_no, q1._0.qty AS qty) } + task_count: 520 + task_total_time_ms: 69 + transform_count: 140 + transform_time_ms: 27 + transform_yield_count: 39 + insert_time_ms: 4 + insert_new_count: 66 + insert_reused_count: 3 +- query: EXPLAIN SELECT "shop"."shop_no", SQ."sku", SQ."qty" FROM "shop", (SELECT + "sku", "qty" FROM "shop"."lines") AS SQ WHERE "shop"."city" = 'SF' + ref: unnested-record-type-indexes.yamsql:539 + explain: ISCAN(shop_city [EQUALS promote(@c32 AS STRING)]) | FLATMAP q0 -> { TF + explode(q0.lines) AS q1 RETURN (q0.shop_no AS shop_no, q1._0.sku AS sku, q1._0.qty + AS qty) } + task_count: 480 + task_total_time_ms: 81 + transform_count: 136 + transform_time_ms: 41 + transform_yield_count: 44 + insert_time_ms: 4 + insert_new_count: 63 + insert_reused_count: 3 +- query: EXPLAIN SELECT "shop"."city", SQ."qty" FROM "shop", (SELECT "sku", "qty" + FROM "shop"."lines") AS SQ WHERE SQ."sku" = 'apple' + ref: unnested-record-type-indexes.yamsql:550 + explain: 'COVERING(shop_sku_city_qty [EQUALS promote(@c28 AS STRING)] -> [__positions: + [unnesting_0: KEY:[5, 0]], parent: [city: KEY:[1], shop_no: KEY:[4, 1]], unnesting_0: + [qty: KEY:[2], sku: KEY:[0]]]) | MAP (_.parent.city AS city, _.unnesting_0.qty + AS qty)' + task_count: 441 + task_total_time_ms: 64 + transform_count: 139 + transform_time_ms: 27 + transform_yield_count: 42 + insert_time_ms: 2 + insert_new_count: 55 + insert_reused_count: 0 +- query: EXPLAIN SELECT SQ."qty" FROM "shop", (SELECT "sku", "qty" FROM "shop"."lines") + AS SQ WHERE SQ."sku" = 'apple' AND "shop"."city" = 'SF' + ref: unnested-record-type-indexes.yamsql:557 + explain: 'COVERING(shop_sku_city_qty [EQUALS promote(@c24 AS STRING), EQUALS promote(@c30 + AS STRING)] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [city: KEY:[1], + shop_no: KEY:[4, 1]], unnesting_0: [qty: KEY:[2], sku: KEY:[0]]]) | MAP (_.unnesting_0.qty + AS qty)' + task_count: 629 + task_total_time_ms: 102 + transform_count: 179 + transform_time_ms: 46 + transform_yield_count: 52 + insert_time_ms: 5 + insert_new_count: 77 + insert_reused_count: 3 +- query: EXPLAIN SELECT "shop"."owner", SQ."sku" FROM "shop", (SELECT "sku", "qty" + FROM "shop"."lines") AS SQ WHERE "shop"."city" = 'SF' + ref: unnested-record-type-indexes.yamsql:564 + explain: ISCAN(shop_city [EQUALS promote(@c28 AS STRING)]) | FLATMAP q0 -> { TF + explode(q0.lines) AS q1 RETURN (q0.owner AS owner, q1._0.sku AS sku) } + task_count: 480 + task_total_time_ms: 85 + transform_count: 136 + transform_time_ms: 40 + transform_yield_count: 44 + insert_time_ms: 7 + insert_new_count: 63 + insert_reused_count: 3 +- query: EXPLAIN SELECT "shop"."owner", SQ."qty" FROM "shop", (SELECT "sku", "qty" + FROM "shop"."lines") AS SQ WHERE SQ."sku" = 'apple' + ref: unnested-record-type-indexes.yamsql:574 + explain: ISCAN(shop_sku_city_qty [EQUALS promote(@c28 AS STRING)]) | MAP (_.parent.owner + AS owner, _.unnesting_0.qty AS qty) + task_count: 413 + task_total_time_ms: 72 + transform_count: 135 + transform_time_ms: 35 + transform_yield_count: 40 + insert_time_ms: 2 + insert_new_count: 52 + insert_reused_count: 0 +- query: EXPLAIN SELECT "shop"."owner", SQ."qty" FROM "shop", (SELECT "sku", "qty" + FROM "shop"."lines") AS SQ WHERE "shop"."city" = 'SF' AND SQ."sku" = 'apple' + ref: unnested-record-type-indexes.yamsql:580 + explain: ISCAN(shop_sku_city_qty [EQUALS promote(@c34 AS STRING), EQUALS promote(@c28 + AS STRING)]) | MAP (_.parent.owner AS owner, _.unnesting_0.qty AS qty) + task_count: 601 + task_total_time_ms: 86 + transform_count: 175 + transform_time_ms: 36 + transform_yield_count: 50 + insert_time_ms: 3 + insert_new_count: 74 + insert_reused_count: 3 +- query: EXPLAIN SELECT "stall"."stall_no", "stall"."owner", SQ."sku", SQ."qty" + FROM "stall", (SELECT "sku", "qty" FROM "stall"."lines") AS SQ WHERE "stall"."city" + = 'SF' + ref: unnested-record-type-indexes.yamsql:587 + explain: 'COVERING(stall_city_sku_owner_qty [EQUALS promote(@c36 AS STRING)] -> + [__positions: [unnesting_0: KEY:[6, 0]], parent: [city: KEY:[0], owner: KEY:[2], + stall_no: KEY:[5, 1]], unnesting_0: [qty: KEY:[3], sku: KEY:[1]]]) | MAP (_.parent.stall_no + AS stall_no, _.parent.owner AS owner, _.unnesting_0.sku AS sku, _.unnesting_0.qty + AS qty)' + task_count: 575 + task_total_time_ms: 64 + transform_count: 161 + transform_time_ms: 28 + transform_yield_count: 50 + insert_time_ms: 3 + insert_new_count: 73 + insert_reused_count: 3 +- query: EXPLAIN SELECT "stall_no", "owner" FROM "stall" WHERE "city" = 'SF' + ref: unnested-record-type-indexes.yamsql:596 + explain: ISCAN(stall_city [EQUALS promote(@c9 AS STRING)]) | MAP (_.stall_no AS + stall_no, _.owner AS owner) + task_count: 396 + task_total_time_ms: 40 + transform_count: 107 + transform_time_ms: 20 + transform_yield_count: 34 + insert_time_ms: 3 + insert_new_count: 42 + insert_reused_count: 3 +- query: EXPLAIN SELECT "booth_no", "owner" FROM "booth" WHERE "city" = 'SF' + ref: unnested-record-type-indexes.yamsql:601 + explain: SCAN([IS booth]) | FILTER _.city EQUALS promote(@c9 AS STRING) | MAP + (_.booth_no AS booth_no, _.owner AS owner) + task_count: 222 + task_total_time_ms: 26 + transform_count: 66 + transform_time_ms: 11 + transform_yield_count: 19 + insert_time_ms: 2 + insert_new_count: 22 + insert_reused_count: 2 diff --git a/yaml-tests/src/test/resources/unnested-record-type-indexes.yamsql b/yaml-tests/src/test/resources/unnested-record-type-indexes.yamsql new file mode 100644 index 0000000000..d8d560d3ad --- /dev/null +++ b/yaml-tests/src/test/resources/unnested-record-type-indexes.yamsql @@ -0,0 +1,603 @@ +# +# unnested-record-type-indexes.yamsql +# +# This source file is part of the FoundationDB open source project +# +# Copyright 2021-2026 Apple Inc. and the FoundationDB project authors +# +# Licensed 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. + +--- +options: + supported_version: !current_version +--- +# `mv_split` requires a synthetic record type: `reviewer` and `rating` both come from one review, but `region` (a +# column of the parent row) sits between them. +# +# The other three tables repeat that shape over an unnesting that is harder to reach: +# `resta.urant$t` has non-protobuf compliant names +# `item` holds its array under a chain of non-repeated structs +# `book` holds structs inside the elements of its array +# `venue` names its own primary key in the index key +# +# `kiosk`, `shop`, `stall` and `booth` hold the same rows under different combinations of an index on the stored table +# and an index on the synthetic type, to test which of the two the planner picks. +schema_template: + CREATE TYPE AS STRUCT "review" ("reviewer" STRING, "rating" BIGINT) + + CREATE TABLE "restaurant" ("rest_no" BIGINT, "region" STRING, "reviews" "review" ARRAY, PRIMARY KEY ("rest_no")) + CREATE INDEX "mv_split" AS + SELECT SQ."reviewer", "restaurant"."region", SQ."rating" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + ORDER BY SQ."reviewer", "restaurant"."region", SQ."rating" + + CREATE TYPE AS STRUCT "re.view$t" ("re.viewer" STRING, "ra$ting" BIGINT) + CREATE TABLE "resta.urant$t" ("rest.no" BIGINT, "re.gion" STRING, "re.views" "re.view$t" ARRAY, + PRIMARY KEY ("rest.no")) + CREATE INDEX "mv.split$esc" AS + SELECT SQ."re.viewer", "resta.urant$t"."re.gion", SQ."ra$ting" + FROM "resta.urant$t", (SELECT "re.viewer", "ra$ting" FROM "resta.urant$t"."re.views") AS SQ + ORDER BY SQ."re.viewer", "resta.urant$t"."re.gion", SQ."ra$ting" + + CREATE TYPE AS STRUCT "tag" ("label" STRING, "weight" BIGINT) + CREATE TYPE AS STRUCT "tag_set" ("tags" "tag" ARRAY, "note" STRING) + CREATE TYPE AS STRUCT "item_meta" ("tag_set" "tag_set", "owner" STRING) + + CREATE TABLE "item" ("item_no" BIGINT, "kind" STRING, "meta" "item_meta", PRIMARY KEY ("item_no")) + CREATE INDEX "mv_deep_array" AS + SELECT SQ."label", "item"."kind", SQ."weight" + FROM "item", (SELECT "label", "weight" FROM "item"."meta"."tag_set"."tags") AS SQ + ORDER BY SQ."label", "item"."kind", SQ."weight" + + CREATE TYPE AS STRUCT "author_name" ("first" STRING, "last" STRING) + CREATE TYPE AS STRUCT "author" ("name" "author_name", "score" BIGINT) + + CREATE TABLE "book" ("book_no" BIGINT, "title" STRING, "authors" "author" ARRAY, PRIMARY KEY ("book_no")) + CREATE INDEX "mv_deep_element" AS + SELECT "a"."name"."last", "book"."title", "a"."score" + FROM "book", "book"."authors" AS "a" + ORDER BY "a"."name"."last", "book"."title", "a"."score" + + CREATE TYPE AS STRUCT "slot" ("day" STRING, "seats" BIGINT) + CREATE TABLE "venue" ("venue_no" BIGINT, "city" STRING, "slots" "slot" ARRAY, PRIMARY KEY ("venue_no")) + CREATE INDEX "mv_pk_in_key" AS + SELECT SQ."day", "venue"."venue_no", SQ."seats" + FROM "venue", (SELECT "day", "seats" FROM "venue"."slots") AS SQ + ORDER BY SQ."day", "venue"."venue_no", SQ."seats" + + CREATE TYPE AS STRUCT "line" ("sku" STRING, "qty" BIGINT) + + CREATE TABLE "kiosk" ("kiosk_no" BIGINT, "city" STRING, "lines" "line" ARRAY, PRIMARY KEY ("kiosk_no")) + CREATE INDEX "kiosk_city" AS SELECT "city" FROM "kiosk" ORDER BY "city" + + CREATE TABLE "shop" ("shop_no" BIGINT, "city" STRING, "owner" STRING, "lines" "line" ARRAY, PRIMARY KEY ("shop_no")) + CREATE INDEX "shop_city" AS SELECT "city" FROM "shop" ORDER BY "city" + CREATE INDEX "shop_sku_city_qty" AS + SELECT SQ."sku", "shop"."city", SQ."qty" + FROM "shop", (SELECT "sku", "qty" FROM "shop"."lines") AS SQ + ORDER BY SQ."sku", "shop"."city", SQ."qty" + + CREATE TABLE "stall" ("stall_no" BIGINT, "city" STRING, "owner" STRING, "lines" "line" ARRAY, PRIMARY KEY ("stall_no")) + CREATE INDEX "stall_city" AS SELECT "city" FROM "stall" ORDER BY "city" + CREATE INDEX "stall_city_sku_owner_qty" AS + SELECT "stall"."city", SQ."sku", "stall"."owner", SQ."qty" + FROM "stall", (SELECT "sku", "qty" FROM "stall"."lines") AS SQ + ORDER BY "stall"."city", SQ."sku", "stall"."owner", SQ."qty" + + CREATE TABLE "booth" ("booth_no" BIGINT, "city" STRING, "owner" STRING, "lines" "line" ARRAY, PRIMARY KEY ("booth_no")) + CREATE INDEX "booth_city_sku_owner_qty" AS + SELECT "booth"."city", SQ."sku", "booth"."owner", SQ."qty" + FROM "booth", (SELECT "sku", "qty" FROM "booth"."lines") AS SQ + ORDER BY "booth"."city", SQ."sku", "booth"."owner", SQ."qty" +--- +setup: + steps: + - query: INSERT INTO "restaurant" + VALUES (1, 'north', [('Alice', 5), ('Bob', 3)]), + (2, 'south', [('Carol', 4)]), + (3, 'north', [('Dave', 2), ('Alice', 4)]) + - query: INSERT INTO "resta.urant$t" + VALUES (1, 'north', [('Alice', 5), ('Bob', 3)]), + (2, 'south', [('Carol', 4)]) + - query: INSERT INTO "item" + VALUES (1, 'tool', (([('hammer', 7), ('nail', 2)], 'n1'), 'ann')), + (2, 'toy', (([('kite', 4)], 'n2'), 'bob')) + - query: INSERT INTO "book" + VALUES (1, 'Dune', [(('Frank', 'Herbert'), 9), (('Brian', 'Herbert'), 5)]), + (2, 'Emma', [(('Jane', 'Austen'), 8)]) + - query: INSERT INTO "venue" + VALUES (1, 'SF', [('mon', 10), ('tue', 20)]), + (2, 'LA', [('wed', 5)]) + - query: INSERT INTO "kiosk" + VALUES (1, 'SF', [('apple', 2), ('pear', 1), ('apple', 2)]), + (2, 'LA', [('apple', 5)]), + (3, 'SF', [('fig', 7)]) + - query: INSERT INTO "shop" + VALUES (1, 'SF', 'ann', [('apple', 2), ('pear', 1), ('apple', 2)]), + (2, 'LA', 'bob', [('apple', 5)]), + (3, 'SF', 'cat', [('fig', 7)]) + - query: INSERT INTO "stall" + VALUES (1, 'SF', 'ann', [('apple', 2), ('pear', 1), ('apple', 2)]), + (2, 'LA', 'bob', [('apple', 5)]), + (3, 'SF', 'cat', [('fig', 7)]) + - query: INSERT INTO "booth" + VALUES (1, 'SF', 'ann', [('apple', 2), ('pear', 1), ('apple', 2)]), + (2, 'LA', 'bob', [('apple', 5)]), + (3, 'SF', 'cat', [('fig', 7)]) +--- +test_block: + tests: + - + # The query the index was defined for. The index supplies the requested ordering, so no sort is needed, and + # every selected column is in the index key, so the scan is covering. + - query: SELECT SQ."reviewer", "restaurant"."region", SQ."rating" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + ORDER BY SQ."reviewer", "restaurant"."region", SQ."rating" + - explain: "COVERING(mv_split <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)" + - result: [ + {reviewer: 'Alice', region: 'north', rating: 4}, + {reviewer: 'Alice', region: 'north', rating: 5}, + {reviewer: 'Bob', region: 'north', rating: 3}, + {reviewer: 'Carol', region: 'south', rating: 4}, + {reviewer: 'Dave', region: 'north', rating: 2}] + - + # The same unnesting without an ORDER BY. The index still answers it; the result order is not guaranteed. + - query: SELECT SQ."reviewer", "restaurant"."region", SQ."rating" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + - explain: "COVERING(mv_split <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)" + - unorderedResult: [ + {reviewer: 'Alice', region: 'north', rating: 5}, + {reviewer: 'Bob', region: 'north', rating: 3}, + {reviewer: 'Carol', region: 'south', rating: 4}, + {reviewer: 'Dave', region: 'north', rating: 2}, + {reviewer: 'Alice', region: 'north', rating: 4}] + - + # Selecting a prefix of the key rather than all of it. + - query: SELECT SQ."reviewer" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + ORDER BY SQ."reviewer" + - explain: "COVERING(mv_split <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer)" + - result: [ + {reviewer: 'Alice'}, + {reviewer: 'Alice'}, + {reviewer: 'Bob'}, + {reviewer: 'Carol'}, + {reviewer: 'Dave'}] + - + # PartiQL-style unnesting binds the array element directly rather than packing it into a subquery tuple. The + # index is defined with the subquery spelling, but both describe the same unnesting. + - query: SELECT "r"."reviewer", "restaurant"."region", "r"."rating" + FROM "restaurant", "restaurant"."reviews" AS "r" + ORDER BY "r"."reviewer", "restaurant"."region", "r"."rating" + - explain: "COVERING(mv_split <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)" + - result: [ + {reviewer: 'Alice', region: 'north', rating: 4}, + {reviewer: 'Alice', region: 'north', rating: 5}, + {reviewer: 'Bob', region: 'north', rating: 3}, + {reviewer: 'Carol', region: 'south', rating: 4}, + {reviewer: 'Dave', region: 'north', rating: 2}] + - + # An equality on the leading key column. The index's placeholders live on the same select as the constituents' + # quantifiers, so a predicate over the unnesting can be matched against them and bind the scan. + - query: SELECT SQ."reviewer", "restaurant"."region", SQ."rating" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + WHERE SQ."reviewer" = 'Alice' + - explain: "COVERING(mv_split [EQUALS promote(@c32 AS STRING)] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)" + - result: [ + {reviewer: 'Alice', region: 'north', rating: 4}, + {reviewer: 'Alice', region: 'north', rating: 5}] + - + # A full key prefix, spanning the unnesting and the parent row, binds both key columns. + - query: SELECT SQ."reviewer", "restaurant"."region", SQ."rating" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + WHERE SQ."reviewer" = 'Alice' AND "restaurant"."region" = 'north' + - explain: "COVERING(mv_split [EQUALS promote(@c32 AS STRING), EQUALS promote(@c38 AS STRING)] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)" + - result: [ + {reviewer: 'Alice', region: 'north', rating: 4}, + {reviewer: 'Alice', region: 'north', rating: 5}] + - + # An equality on the unnesting reached through the PartiQL spelling of the same unnesting. + - query: SELECT "r"."reviewer", "restaurant"."region", "r"."rating" + FROM "restaurant", "restaurant"."reviews" AS "r" + WHERE "r"."reviewer" = 'Alice' + - explain: "COVERING(mv_split [EQUALS promote(@c25 AS STRING)] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)" + - result: [ + {reviewer: 'Alice', region: 'north', rating: 4}, + {reviewer: 'Alice', region: 'north', rating: 5}] + - + # A range on the leading key column rather than an equality. The placeholder reserved for that column takes the + # inequality, so the scan is a one-sided range over the index instead of a scan of all of it with a filter. + - query: SELECT SQ."reviewer", "restaurant"."region", SQ."rating" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + WHERE SQ."reviewer" > 'Bob' + - explain: "COVERING(mv_split [[GREATER_THAN promote(@c32 AS STRING)]] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)" + - result: [ + {reviewer: 'Carol', region: 'south', rating: 4}, + {reviewer: 'Dave', region: 'north', rating: 2}] + - + - query: SELECT SQ."reviewer", "restaurant"."region", SQ."rating" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + WHERE SQ."reviewer" < 'Carol' + - explain: "COVERING(mv_split [[LESS_THAN promote(@c32 AS STRING)]] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)" + - result: [ + {reviewer: 'Alice', region: 'north', rating: 4}, + {reviewer: 'Alice', region: 'north', rating: 5}, + {reviewer: 'Bob', region: 'north', rating: 3}] + - + # Both sides of the same column, which bound one range rather than two. + - query: SELECT SQ."reviewer", "restaurant"."region", SQ."rating" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + WHERE SQ."reviewer" > 'Alice' AND SQ."reviewer" < 'Dave' + - explain: "COVERING(mv_split [[GREATER_THAN promote(@c32 AS STRING) && LESS_THAN promote(@c38 AS STRING)]] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)" + - result: [ + {reviewer: 'Bob', region: 'north', rating: 3}, + {reviewer: 'Carol', region: 'south', rating: 4}] + - + # An equality on the leading column followed by a range on the next one. The equality fixes the prefix and the + # range bounds the column after it, so the two columns still make a single scan range. + - query: SELECT SQ."reviewer", "restaurant"."region", SQ."rating" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + WHERE SQ."reviewer" = 'Alice' AND "restaurant"."region" < 'south' + - explain: "COVERING(mv_split [EQUALS promote(@c32 AS STRING), [LESS_THAN promote(@c38 AS STRING)]] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)" + - result: [ + {reviewer: 'Alice', region: 'north', rating: 4}, + {reviewer: 'Alice', region: 'north', rating: 5}] + - + # Equalities on both leading columns -- one from the unnesting, one from the parent row -- followed by a range on + # the third. + - query: SELECT SQ."reviewer", "restaurant"."region", SQ."rating" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + WHERE SQ."reviewer" = 'Alice' AND "restaurant"."region" = 'north' AND SQ."rating" > 4 + - explain: "COVERING(mv_split [EQUALS promote(@c32 AS STRING), EQUALS promote(@c38 AS STRING), [GREATER_THAN promote(@c44 AS LONG)]] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)" + - result: [{reviewer: 'Alice', region: 'north', rating: 5}] + - + - query: SELECT SQ."reviewer", "restaurant"."region", SQ."rating" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + WHERE SQ."reviewer" = 'Alice' AND "restaurant"."region" = 'north' AND SQ."rating" < 5 + - explain: "COVERING(mv_split [EQUALS promote(@c32 AS STRING), EQUALS promote(@c38 AS STRING), [LESS_THAN promote(@c44 AS LONG)]] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [region: KEY:[1], rest_no: KEY:[4, 1]], unnesting_0: [rating: KEY:[2], reviewer: KEY:[0]]]) | MAP (_.unnesting_0.reviewer AS reviewer, _.parent.region AS region, _.unnesting_0.rating AS rating)" + - result: [{reviewer: 'Alice', region: 'north', rating: 4}] +--- +# Identifiers a protobuf field cannot be named after. Every name on the path to the unnesting -- the table, the array, +# and the columns of its elements -- needs escaping, and the index only matches if the candidate and the query agree on +# the escaped names. +test_block: + name: escaped-identifiers + tests: + - + - query: SELECT SQ."re.viewer", "resta.urant$t"."re.gion", SQ."ra$ting" + FROM "resta.urant$t", (SELECT "re.viewer", "ra$ting" FROM "resta.urant$t"."re.views") AS SQ + ORDER BY SQ."re.viewer", "resta.urant$t"."re.gion", SQ."ra$ting" + - explain: "COVERING(mv.split$esc <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [re__2gion: KEY:[1], rest__2no: KEY:[4, 1]], unnesting_0: [ra__1ting: KEY:[2], re__2viewer: KEY:[0]]]) | MAP (_.unnesting_0.re.viewer AS re.viewer, _.parent.re.gion AS re.gion, _.unnesting_0.ra$ting AS ra$ting)" + - result: [ + {'re.viewer': 'Alice', 're.gion': 'north', 'ra$ting': 5}, + {'re.viewer': 'Bob', 're.gion': 'north', 'ra$ting': 3}, + {'re.viewer': 'Carol', 're.gion': 'south', 'ra$ting': 4}] + - + # The PartiQL spelling of the same unnesting, with the array reached through an escaped name. + - query: SELECT "r"."re.viewer", "resta.urant$t"."re.gion", "r"."ra$ting" + FROM "resta.urant$t", "resta.urant$t"."re.views" AS "r" + ORDER BY "r"."re.viewer", "resta.urant$t"."re.gion", "r"."ra$ting" + - explain: "COVERING(mv.split$esc <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [re__2gion: KEY:[1], rest__2no: KEY:[4, 1]], unnesting_0: [ra__1ting: KEY:[2], re__2viewer: KEY:[0]]]) | MAP (_.unnesting_0.re.viewer AS re.viewer, _.parent.re.gion AS re.gion, _.unnesting_0.ra$ting AS ra$ting)" + - result: [ + {'re.viewer': 'Alice', 're.gion': 'north', 'ra$ting': 5}, + {'re.viewer': 'Bob', 're.gion': 'north', 'ra$ting': 3}, + {'re.viewer': 'Carol', 're.gion': 'south', 'ra$ting': 4}] + - + # An equality on the leading key column binds the scan, so the escaped names also have to line up between the + # query's predicate value and the candidate's placeholder. + - query: SELECT SQ."re.viewer", "resta.urant$t"."re.gion", SQ."ra$ting" + FROM "resta.urant$t", (SELECT "re.viewer", "ra$ting" FROM "resta.urant$t"."re.views") AS SQ + WHERE SQ."re.viewer" = 'Alice' + - explain: "COVERING(mv.split$esc [EQUALS promote(@c32 AS STRING)] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [re__2gion: KEY:[1], rest__2no: KEY:[4, 1]], unnesting_0: [ra__1ting: KEY:[2], re__2viewer: KEY:[0]]]) | MAP (_.unnesting_0.re.viewer AS re.viewer, _.parent.re.gion AS re.gion, _.unnesting_0.ra$ting AS ra$ting)" + - result: [{'re.viewer': 'Alice', 're.gion': 'north', 'ra$ting': 5}] +--- +# An array under a chain of non-repeated structs: the constituent's nesting expression is `meta.tag_set.tags` rather +# than a single field, so the explode's collection value is a path of field accesses. +test_block: + name: deeply-nested-array + tests: + - + - query: SELECT SQ."label", "item"."kind", SQ."weight" + FROM "item", (SELECT "label", "weight" FROM "item"."meta"."tag_set"."tags") AS SQ + ORDER BY SQ."label", "item"."kind", SQ."weight" + - explain: "COVERING(mv_deep_array <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [item_no: KEY:[4, 1], kind: KEY:[1]], unnesting_0: [label: KEY:[0], weight: KEY:[2]]]) | MAP (_.unnesting_0.label AS label, _.parent.kind AS kind, _.unnesting_0.weight AS weight)" + - result: [ + {label: 'hammer', kind: 'tool', weight: 7}, + {label: 'kite', kind: 'toy', weight: 4}, + {label: 'nail', kind: 'tool', weight: 2}] + - + - query: SELECT "t"."label", "item"."kind", "t"."weight" + FROM "item", "item"."meta"."tag_set"."tags" AS "t" + ORDER BY "t"."label", "item"."kind", "t"."weight" + - explain: "COVERING(mv_deep_array <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [item_no: KEY:[4, 1], kind: KEY:[1]], unnesting_0: [label: KEY:[0], weight: KEY:[2]]]) | MAP (_.unnesting_0.label AS label, _.parent.kind AS kind, _.unnesting_0.weight AS weight)" + - result: [ + {label: 'hammer', kind: 'tool', weight: 7}, + {label: 'kite', kind: 'toy', weight: 4}, + {label: 'nail', kind: 'tool', weight: 2}] + - + - query: SELECT SQ."label", "item"."kind", SQ."weight" + FROM "item", (SELECT "label", "weight" FROM "item"."meta"."tag_set"."tags") AS SQ + WHERE SQ."label" = 'hammer' + - explain: "COVERING(mv_deep_array [EQUALS promote(@c36 AS STRING)] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [item_no: KEY:[4, 1], kind: KEY:[1]], unnesting_0: [label: KEY:[0], weight: KEY:[2]]]) | MAP (_.unnesting_0.label AS label, _.parent.kind AS kind, _.unnesting_0.weight AS weight)" + - result: [{label: 'hammer', kind: 'tool', weight: 7}] +--- +# An array whose elements carry a struct of their own, with a key column reached through it: `a.name.last` is a path +# into the exploded element rather than one of its fields. +test_block: + name: deeply-nested-element + tests: + - + - query: SELECT "a"."name"."last" AS "last", "book"."title", "a"."score" + FROM "book", "book"."authors" AS "a" + ORDER BY "a"."name"."last", "book"."title", "a"."score" + - explain: "COVERING(mv_deep_element <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [book_no: KEY:[4, 1], title: KEY:[1]], unnesting_0: [score: KEY:[2], name: [last: KEY:[0]]]]) | MAP (_.unnesting_0.name.last AS last, _.parent.title AS title, _.unnesting_0.score AS score)" + - result: [ + {last: 'Austen', title: 'Emma', score: 8}, + {last: 'Herbert', title: 'Dune', score: 5}, + {last: 'Herbert', title: 'Dune', score: 9}] + - + # The subquery spelling of the same unnesting, flowing the element's struct and reaching into it outside. + - query: SELECT SQ."name"."last" AS "last", "book"."title", SQ."score" + FROM "book", (SELECT "name", "score" FROM "book"."authors") AS SQ + ORDER BY SQ."name"."last", "book"."title", SQ."score" + - explain: "COVERING(mv_deep_element <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [book_no: KEY:[4, 1], title: KEY:[1]], unnesting_0: [score: KEY:[2], name: [last: KEY:[0]]]]) | MAP (_.unnesting_0.name.last AS last, _.parent.title AS title, _.unnesting_0.score AS score)" + - result: [ + {last: 'Austen', title: 'Emma', score: 8}, + {last: 'Herbert', title: 'Dune', score: 5}, + {last: 'Herbert', title: 'Dune', score: 9}] + - + - query: SELECT "a"."name"."last" AS "last", "book"."title", "a"."score" + FROM "book", "book"."authors" AS "a" + WHERE "a"."name"."last" = 'Herbert' + ORDER BY "a"."name"."last", "book"."title", "a"."score" + - explain: "COVERING(mv_deep_element [EQUALS promote(@c31 AS STRING)] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [book_no: KEY:[4, 1], title: KEY:[1]], unnesting_0: [score: KEY:[2], name: [last: KEY:[0]]]]) | MAP (_.unnesting_0.name.last AS last, _.parent.title AS title, _.unnesting_0.score AS score)" + - result: [ + {last: 'Herbert', title: 'Dune', score: 5}, + {last: 'Herbert', title: 'Dune', score: 9}] +--- +# The parent table's primary key named in the index key, where the synthetic type's primary key already carries it. An +# index over an unnesting does not trim the redundant component, so the column is simply stored twice. +test_block: + name: primary-key-in-index-key + tests: + - + - query: SELECT SQ."day", "venue"."venue_no", SQ."seats" + FROM "venue", (SELECT "day", "seats" FROM "venue"."slots") AS SQ + ORDER BY SQ."day", "venue"."venue_no", SQ."seats" + # `unnesting_0` sits at KEY:[5, 0] rather than KEY:[4, 0], so position 4 still holds the parent's primary key -- + # `venue_no` really is in the entry twice. The covering map reads it from KEY:[1], the index key position, as the + # decoder keeps the first occurrence it finds and so never descends into the nested tuple for it. + - explain: "COVERING(mv_pk_in_key <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [venue_no: KEY:[1]], unnesting_0: [day: KEY:[0], seats: KEY:[2]]]) | MAP (_.unnesting_0.day AS day, _.parent.venue_no AS venue_no, _.unnesting_0.seats AS seats)" + - result: [ + {day: 'mon', venue_no: 1, seats: 10}, + {day: 'tue', venue_no: 1, seats: 20}, + {day: 'wed', venue_no: 2, seats: 5}] + - + - query: SELECT SQ."day", "venue"."venue_no", SQ."seats" + FROM "venue", (SELECT "day", "seats" FROM "venue"."slots") AS SQ + WHERE SQ."day" = 'mon' AND "venue"."venue_no" = 1 + # Being carried twice costs the column nothing: the occurrence in the index key is an ordinary scalar position, so + # it still has a placeholder of its own and both predicates bind the scan. + - explain: "COVERING(mv_pk_in_key [EQUALS promote(@c32 AS STRING), EQUALS promote(@c38 AS LONG)] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [venue_no: KEY:[1]], unnesting_0: [day: KEY:[0], seats: KEY:[2]]]) | MAP (_.unnesting_0.day AS day, _.parent.venue_no AS venue_no, _.unnesting_0.seats AS seats)" + - result: [{day: 'mon', venue_no: 1, seats: 10}] + - + - query: SELECT SQ."seats" + FROM "venue", (SELECT "day", "seats" FROM "venue"."slots") AS SQ + ORDER BY SQ."day", "venue"."venue_no", SQ."seats" + # The sort columns are not selected, so the covering scan flows them for the sort and a second MAP drops them + # again. `venue_no` is read from KEY:[1] here too, even though it is only wanted for the ordering. + - explain: "COVERING(mv_pk_in_key <,> -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [venue_no: KEY:[1]], unnesting_0: [day: KEY:[0], seats: KEY:[2]]]) | MAP (_.unnesting_0.seats AS seats, _.unnesting_0.day AS day, _.parent.venue_no AS venue_no) | MAP (_.seats AS seats)" + - result: [{seats: 10}, {seats: 20}, {seats: 5}] +--- +# Queries that select a column the index does not store. +test_block: + name: index-scan-with-fetch + tests: + - + - query: SELECT "venue"."city", SQ."day", SQ."seats" + FROM "venue", (SELECT "day", "seats" FROM "venue"."slots") AS SQ + WHERE SQ."day" = 'mon' + - explain: "ISCAN(mv_pk_in_key [EQUALS promote(@c32 AS STRING)]) | MAP (_.parent.city AS city, _.unnesting_0.day AS day, _.unnesting_0.seats AS seats)" + - result: [{city: 'SF', day: 'mon', seats: 10}] + - + - query: SELECT "venue"."city", SQ."day" + FROM "venue", (SELECT "day", "seats" FROM "venue"."slots") AS SQ + ORDER BY SQ."day" + - explain: "ISCAN(mv_pk_in_key <,>) | MAP (_.parent.city AS city, _.unnesting_0.day AS day)" + - result: [{city: 'SF', day: 'mon'}, {city: 'SF', day: 'tue'}, {city: 'LA', day: 'wed'}] + - + - query: SELECT "a"."name"."first" AS "first", "a"."name"."last" AS "last", "book"."title" + FROM "book", "book"."authors" AS "a" + WHERE "a"."name"."last" = 'Herbert' + ORDER BY "a"."name"."last", "book"."title", "a"."score" + - explain: "ISCAN(mv_deep_element [EQUALS promote(@c35 AS STRING)]) | MAP (_.unnesting_0.name.first AS first, _.unnesting_0.name.last AS last, _.parent.title AS title, _.unnesting_0.score AS score) | MAP (_.first AS first, _.last AS last, _.title AS title)" + - result: [ + {first: 'Brian', last: 'Herbert', title: 'Dune'}, + {first: 'Frank', last: 'Herbert', title: 'Dune'}] + - + - query: SELECT "item"."meta"."owner" AS "owner", SQ."label", SQ."weight" + FROM "item", (SELECT "label", "weight" FROM "item"."meta"."tag_set"."tags") AS SQ + WHERE SQ."label" = 'hammer' + - explain: "ISCAN(mv_deep_array [EQUALS promote(@c40 AS STRING)]) | MAP (_.parent.meta.owner AS owner, _.unnesting_0.label AS label, _.unnesting_0.weight AS weight)" + - result: [{owner: 'ann', label: 'hammer', weight: 7}] +--- +# Aggregates over an unnesting whose index is a plain (non-aggregate) index. +test_block: + name: aggregations + tests: + - + # no grouping + # TODO Issue #4720: every column read is in the index, so this should be a covering scan. + - query: SELECT COUNT(*) AS "n" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + - explain: "ISCAN(mv_split <,>) | MAP (_.parent AS _0, _.unnesting_0 AS _1) | AGG (count_star(*) AS _0) GROUP BY () | ON EMPTY NULL | MAP (coalesce_long(_._0._0, 0l) AS n)" + - result: [{n: 5}] + - + # no grouping + # TODO Issue #4720: every column read is in the index, so this should be a covering scan. + - query: SELECT SUM(SQ."rating") AS "total" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + - explain: "ISCAN(mv_split <,>) | MAP (_.parent AS _0, _.unnesting_0 AS _1) | AGG (sum_l(_._1.rating) AS _0) GROUP BY () | ON EMPTY NULL | MAP (_._0._0 AS total)" + - result: [{total: 18}] + - + # Grouping on the leading key column, which the index already orders by. + # TODO Issue #4720: every column read is in the index, so this should be a covering scan. + - query: SELECT SQ."reviewer" AS "reviewer", COUNT(*) AS "n" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + GROUP BY SQ."reviewer" + - explain: "ISCAN(mv_split <,>) | MAP (_.parent AS _0, _.unnesting_0 AS _1) | AGG (count_star(*) AS _0) GROUP BY (_._1.reviewer AS _0) | MAP (_._0._0 AS reviewer, _._1._0 AS n)" + - unorderedResult: [ + {reviewer: 'Alice', n: 2}, + {reviewer: 'Bob', n: 1}, + {reviewer: 'Carol', n: 1}, + {reviewer: 'Dave', n: 1}] + - + # TODO Issue #4720: every column read is in the index, so this should be a covering scan. + - query: SELECT SQ."reviewer" AS "reviewer", SUM(SQ."rating") AS "total", MAX(SQ."rating") AS "best" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + GROUP BY SQ."reviewer" + - explain: "ISCAN(mv_split <,>) | MAP (_.parent AS _0, _.unnesting_0 AS _1) | AGG (sum_l(_._1.rating) AS _0, max_l(_._1.rating) AS _1) GROUP BY (_._1.reviewer AS _0) | MAP (_._0._0 AS reviewer, _._1._0 AS total, _._1._1 AS best)" + - unorderedResult: [ + {reviewer: 'Alice', total: 9, best: 5}, + {reviewer: 'Bob', total: 3, best: 3}, + {reviewer: 'Carol', total: 4, best: 4}, + {reviewer: 'Dave', total: 2, best: 2}] + - + - query: SELECT "restaurant"."region" AS "region", COUNT(*) AS "n" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + GROUP BY "restaurant"."region" + - error: "0AF02" + - + # TODO Issue #4720: every column read is in the index, so this should be a covering scan. + - query: SELECT SUM(SQ."rating") AS "total" + FROM "restaurant", (SELECT "reviewer", "rating" FROM "restaurant"."reviews") AS SQ + WHERE SQ."reviewer" = 'Alice' + - explain: "ISCAN(mv_split [EQUALS promote(@c29 AS STRING)]) | MAP (_.parent AS _0, _.unnesting_0 AS _1) | AGG (sum_l(_._1.rating) AS _0) GROUP BY () | ON EMPTY NULL | MAP (_._0._0 AS total)" + - result: [{total: 9}] +# - +# # TODO Issue #4720: every column read is in the index, so this should be a covering scan. +# - query: SELECT "a"."name"."last" AS "last", MAX("a"."score") AS "best" +# FROM "book", "book"."authors" AS "a" +# GROUP BY "a"."name"."last" +# - explain: "ISCAN(mv_deep_element <,>) | MAP (_.parent AS _0, (_.unnesting_0 AS _0) AS _1) | AGG (max_l(_._1._0.score) AS _0) GROUP BY (_._1._0.name.last AS _0) | MAP (_._0._0 AS last, _._1._0 AS best)" +# - unorderedResult: [{last: 'Austen', best: 8}, {last: 'Herbert', best: 9}] + - + - query: SELECT "venue"."venue_no" AS "venue_no", SUM(SQ."seats") AS "total" + FROM "venue", (SELECT "day", "seats" FROM "venue"."slots") AS SQ + GROUP BY "venue"."venue_no" + - explain: "SCAN([IS venue]) | FLATMAP q0 -> { TF explode(q0.slots) AS q1 RETURN (q0 AS _0, q1._0 AS _1) } | AGG (sum_l(_._1.seats) AS _0) GROUP BY (_._0.venue_no AS _0) | MAP (_._0._0 AS venue_no, _._1._0 AS total)" + - unorderedResult: [{venue_no: 1, total: 30}, {venue_no: 2, total: 5}] +--- +# These tests focus on which index the planner picks when a query that unnests could use either an index on the stored +# table or an index on the synthetic type. +# +# An index on the stored table only serves the parent leg (explode is nested-loop joined): +# ISCAN() | FLATMAP q0 -> { EXPLODE q0.lines ... } +# On the other hand, an index on the synthetic record type serves both legs together, so it applies only to a query +# that unnests. +# +# The cost model, hence, play a decisive role - prefer plan with fewer residual predicates, and only on a tie the plan +# with fewer fetches. +test_block: + name: stored-table-index-vs-synthetic-type-index + tests: + - + # only simple index. + - query: SELECT "kiosk"."kiosk_no", SQ."sku", SQ."qty" + FROM "kiosk", (SELECT "sku", "qty" FROM "kiosk"."lines") AS SQ + WHERE "kiosk"."city" = 'SF' + - explain: "ISCAN(kiosk_city [EQUALS promote(@c32 AS STRING)]) | FLATMAP q0 -> { TF explode(q0.lines) AS q1 RETURN (q0.kiosk_no AS kiosk_no, q1._0.sku AS sku, q1._0.qty AS qty) }" + - unorderedResult: [ + {kiosk_no: 1, sku: 'apple', qty: 2}, + {kiosk_no: 1, sku: 'pear', qty: 1}, + {kiosk_no: 1, sku: 'apple', qty: 2}, + {kiosk_no: 3, sku: 'fig', qty: 7}] + - + # simple index with binding, and residual `sku = 'apple'` filters on explode + # produces. + - query: SELECT "kiosk"."kiosk_no", SQ."qty" + FROM "kiosk", (SELECT "sku", "qty" FROM "kiosk"."lines") AS SQ + WHERE "kiosk"."city" = 'SF' AND SQ."sku" = 'apple' + - explain: "ISCAN(kiosk_city [EQUALS promote(@c28 AS STRING)]) | FLATMAP q0 -> { TF explode(q0.lines) | FILTER _._0.sku EQUALS promote(@c34 AS STRING) AS q1 RETURN (q0.kiosk_no AS kiosk_no, q1._0.qty AS qty) }" + - unorderedResult: [{kiosk_no: 1, qty: 2}, {kiosk_no: 1, qty: 2}] + - + # both index considered. simple index has lesser number of predicates. The index on the synthetic type covers the + # query but cannot seek on `city`. + - query: SELECT "shop"."shop_no", SQ."sku", SQ."qty" + FROM "shop", (SELECT "sku", "qty" FROM "shop"."lines") AS SQ + WHERE "shop"."city" = 'SF' + - explain: "ISCAN(shop_city [EQUALS promote(@c32 AS STRING)]) | FLATMAP q0 -> { TF explode(q0.lines) AS q1 RETURN (q0.shop_no AS shop_no, q1._0.sku AS sku, q1._0.qty AS qty) }" + - unorderedResult: [ + {shop_no: 1, sku: 'apple', qty: 2}, + {shop_no: 1, sku: 'pear', qty: 1}, + {shop_no: 1, sku: 'apple', qty: 2}, + {shop_no: 3, sku: 'fig', qty: 7}] + - + # `sku` only. index on the synthetic type is covering and absorbs the predicate. + - query: SELECT "shop"."city", SQ."qty" + FROM "shop", (SELECT "sku", "qty" FROM "shop"."lines") AS SQ + WHERE SQ."sku" = 'apple' + - explain: "COVERING(shop_sku_city_qty [EQUALS promote(@c28 AS STRING)] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [city: KEY:[1], shop_no: KEY:[4, 1]], unnesting_0: [qty: KEY:[2], sku: KEY:[0]]]) | MAP (_.parent.city AS city, _.unnesting_0.qty AS qty)" + - unorderedResult: [{city: 'SF', qty: 2}, {city: 'SF', qty: 2}, {city: 'LA', qty: 5}] + - + # 2 predicates. index on the synthetic type absorbs more predicates (both). + - query: SELECT SQ."qty" + FROM "shop", (SELECT "sku", "qty" FROM "shop"."lines") AS SQ + WHERE SQ."sku" = 'apple' AND "shop"."city" = 'SF' + - explain: "COVERING(shop_sku_city_qty [EQUALS promote(@c24 AS STRING), EQUALS promote(@c30 AS STRING)] -> [__positions: [unnesting_0: KEY:[5, 0]], parent: [city: KEY:[1], shop_no: KEY:[4, 1]], unnesting_0: [qty: KEY:[2], sku: KEY:[0]]]) | MAP (_.unnesting_0.qty AS qty)" + - unorderedResult: [{qty: 2}, {qty: 2}] + - + # both index would need to fetch. index on the stored table applies it. + - query: SELECT "shop"."owner", SQ."sku" + FROM "shop", (SELECT "sku", "qty" FROM "shop"."lines") AS SQ + WHERE "shop"."city" = 'SF' + - explain: "ISCAN(shop_city [EQUALS promote(@c28 AS STRING)]) | FLATMAP q0 -> { TF explode(q0.lines) AS q1 RETURN (q0.owner AS owner, q1._0.sku AS sku) }" + - unorderedResult: [ + {owner: 'ann', sku: 'apple'}, + {owner: 'ann', sku: 'pear'}, + {owner: 'ann', sku: 'apple'}, + {owner: 'cat', sku: 'fig'}] + - + - query: SELECT "shop"."owner", SQ."qty" + FROM "shop", (SELECT "sku", "qty" FROM "shop"."lines") AS SQ + WHERE SQ."sku" = 'apple' + - explain: "ISCAN(shop_sku_city_qty [EQUALS promote(@c28 AS STRING)]) | MAP (_.parent.owner AS owner, _.unnesting_0.qty AS qty)" + - unorderedResult: [{owner: 'ann', qty: 2}, {owner: 'ann', qty: 2}, {owner: 'bob', qty: 5}] + - + - query: SELECT "shop"."owner", SQ."qty" + FROM "shop", (SELECT "sku", "qty" FROM "shop"."lines") AS SQ + WHERE "shop"."city" = 'SF' AND SQ."sku" = 'apple' + - explain: "ISCAN(shop_sku_city_qty [EQUALS promote(@c34 AS STRING), EQUALS promote(@c28 AS STRING)]) | MAP (_.parent.owner AS owner, _.unnesting_0.qty AS qty)" + - unorderedResult: [{owner: 'ann', qty: 2}, {owner: 'ann', qty: 2}] + - + # Both indexes absorb predicate. Covering index (on synthetic record type) wins. + - query: SELECT "stall"."stall_no", "stall"."owner", SQ."sku", SQ."qty" + FROM "stall", (SELECT "sku", "qty" FROM "stall"."lines") AS SQ + WHERE "stall"."city" = 'SF' + - explain: "COVERING(stall_city_sku_owner_qty [EQUALS promote(@c36 AS STRING)] -> [__positions: [unnesting_0: KEY:[6, 0]], parent: [city: KEY:[0], owner: KEY:[2], stall_no: KEY:[5, 1]], unnesting_0: [qty: KEY:[3], sku: KEY:[1]]]) | MAP (_.parent.stall_no AS stall_no, _.parent.owner AS owner, _.unnesting_0.sku AS sku, _.unnesting_0.qty AS qty)" + - unorderedResult: [ + {stall_no: 1, owner: 'ann', sku: 'apple', qty: 2}, + {stall_no: 1, owner: 'ann', sku: 'pear', qty: 1}, + {stall_no: 1, owner: 'ann', sku: 'apple', qty: 2}, + {stall_no: 3, owner: 'cat', sku: 'fig', qty: 7}] + - + # no unnesting, the index on the synthetic type does not apply. + - query: SELECT "stall_no", "owner" FROM "stall" WHERE "city" = 'SF' + - explain: "ISCAN(stall_city [EQUALS promote(@c9 AS STRING)]) | MAP (_.stall_no AS stall_no, _.owner AS owner)" + - unorderedResult: [{stall_no: 1, owner: 'ann'}, {stall_no: 3, owner: 'cat'}] + - + # no index on the stored table and the index on the synthetic type does not apply. Fall back to primary scan. + - query: SELECT "booth_no", "owner" FROM "booth" WHERE "city" = 'SF' + - explain: "SCAN([IS booth]) | FILTER _.city EQUALS promote(@c9 AS STRING) | MAP (_.booth_no AS booth_no, _.owner AS owner)" + - unorderedResult: [{booth_no: 1, owner: 'ann'}, {booth_no: 3, owner: 'cat'}] +...