From 91e15b09c68a51b450f462609afcc29250e659f2 Mon Sep 17 00:00:00 2001 From: Arnaud Lacurie Date: Thu, 11 Jun 2026 07:45:35 +0100 Subject: [PATCH 1/2] Support DML through simple updatable SQL views --- .../relational/api/exceptions/ErrorCode.java | 1 + .../recordlayer/query/LogicalOperator.java | 13 + .../recordlayer/query/SemanticAnalyzer.java | 8 + .../query/ViewUpdatabilityAnalyzer.java | 176 +++++++++++ .../query/ViewUpdatabilityInfo.java | 87 ++++++ .../query/visitors/QueryVisitor.java | 72 ++++- .../recordlayer/query/UpdatableViewTest.java | 263 ++++++++++++++++ .../src/test/java/YamlIntegrationTests.java | 5 + yaml-tests/src/test/resources/view-dml.yamsql | 293 ++++++++++++++++++ 9 files changed, 909 insertions(+), 9 deletions(-) create mode 100644 fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/ViewUpdatabilityAnalyzer.java create mode 100644 fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/ViewUpdatabilityInfo.java create mode 100644 fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/UpdatableViewTest.java create mode 100644 yaml-tests/src/test/resources/view-dml.yamsql diff --git a/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/exceptions/ErrorCode.java b/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/exceptions/ErrorCode.java index 09d7a6242d..107f99675a 100644 --- a/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/exceptions/ErrorCode.java +++ b/fdb-relational-api/src/main/java/com/apple/foundationdb/relational/api/exceptions/ErrorCode.java @@ -152,6 +152,7 @@ public enum ErrorCode { UNION_INCORRECT_COLUMN_COUNT("42F64"), UNION_INCOMPATIBLE_COLUMNS("42F65"), INVALID_DATABASE("42F66"), + VIEW_NOT_UPDATABLE("42F67"), // Class 53 - Insufficient Resources TRANSACTION_TIMEOUT("53F00"), // Class 54 Program Limit Exceeded diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/LogicalOperator.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/LogicalOperator.java index b6476959bd..a614838acb 100644 --- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/LogicalOperator.java +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/LogicalOperator.java @@ -40,6 +40,7 @@ import com.apple.foundationdb.record.query.plan.cascades.expressions.SelectExpression; import com.apple.foundationdb.record.query.plan.cascades.expressions.TempTableInsertExpression; import com.apple.foundationdb.record.query.plan.cascades.expressions.TempTableScanExpression; +import com.apple.foundationdb.record.query.plan.cascades.predicates.QueryPredicate; 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.CountValue; @@ -464,12 +465,24 @@ public static LogicalOperator generateSimpleSelect(@Nonnull Expressions output, @Nonnull Optional alias, @Nonnull Set outerCorrelations, boolean isForDdl) { + return generateSimpleSelect(output, logicalOperators, where, alias, outerCorrelations, ImmutableList.of(), isForDdl); + } + + @Nonnull + public static LogicalOperator generateSimpleSelect(@Nonnull Expressions output, + @Nonnull LogicalOperators logicalOperators, + @Nonnull Optional where, + @Nonnull Optional alias, + @Nonnull Set outerCorrelations, + @Nonnull List additionalPredicates, + boolean isForDdl) { final var quantifiers = logicalOperators.getQuantifiers(); final var selectBuilder = GraphExpansion.builder().addAllQuantifiers(quantifiers); where.ifPresent(predicate -> { final var localAliases = quantifiers.stream().map(Quantifier::getAlias).collect(ImmutableSet.toImmutableSet()); selectBuilder.addPredicate(Expression.Utils.toUnderlyingPredicate(predicate, localAliases, isForDdl)); }); + selectBuilder.addAllPredicates(additionalPredicates); final var expandedOutput = output.expanded(); SelectExpression selectExpression; diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/SemanticAnalyzer.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/SemanticAnalyzer.java index 9c38167100..af04170032 100644 --- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/SemanticAnalyzer.java +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/SemanticAnalyzer.java @@ -1173,6 +1173,14 @@ public LogicalOperator resolveView(@Nonnull final Identifier viewIdentifier) { return recordLayerView.getCompilableViewSupplier().apply(isCaseSensitive); } + @Nonnull + public ViewUpdatabilityInfo resolveUpdatableView(@Nonnull final Identifier viewIdentifier) { + final LogicalOperator viewOp = resolveView(viewIdentifier); + return ViewUpdatabilityAnalyzer.analyze(viewOp, metadataCatalog) + .orElseThrow(() -> Assert.failUnchecked(ErrorCode.VIEW_NOT_UPDATABLE, + String.format(Locale.ROOT, "View '%s' is not updatable", viewIdentifier.getName()))); + } + // TODO: this will be removed once we unify both Java- and SQL-UDFs. public boolean isJavaCallFunction(@Nonnull final String functionName) { return functionCatalog.isJavaCallFunction(functionName); diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/ViewUpdatabilityAnalyzer.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/ViewUpdatabilityAnalyzer.java new file mode 100644 index 0000000000..59500498a9 --- /dev/null +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/ViewUpdatabilityAnalyzer.java @@ -0,0 +1,176 @@ +/* + * ViewUpdatabilityAnalyzer.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2025 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. + */ + +package com.apple.foundationdb.relational.recordlayer.query; + +import com.apple.foundationdb.annotation.API; +import com.apple.foundationdb.record.query.plan.cascades.Quantifier; +import com.apple.foundationdb.record.query.plan.cascades.expressions.GroupByExpression; +import com.apple.foundationdb.record.query.plan.cascades.expressions.LogicalDistinctExpression; +import com.apple.foundationdb.record.query.plan.cascades.expressions.LogicalSortExpression; +import com.apple.foundationdb.record.query.plan.cascades.expressions.LogicalTypeFilterExpression; +import com.apple.foundationdb.record.query.plan.cascades.expressions.LogicalUnionExpression; +import com.apple.foundationdb.record.query.plan.cascades.expressions.RelationalExpression; +import com.apple.foundationdb.record.query.plan.cascades.expressions.SelectExpression; +import com.apple.foundationdb.relational.api.exceptions.RelationalException; +import com.apple.foundationdb.relational.api.metadata.SchemaTemplate; +import com.apple.foundationdb.relational.recordlayer.metadata.RecordLayerTable; + +import com.google.common.collect.Iterables; + +import javax.annotation.Nonnull; +import java.util.Optional; + +/** + * Analyzes the compiled logical plan of a view to determine whether it is updatable. + * + *

A view is updatable when its defining query satisfies all of the following conditions: + *

    + *
  1. The top-level expression is a {@link SelectExpression} with exactly one child quantifier (no joins).
  2. + *
  3. There are no set operations ({@link LogicalUnionExpression}).
  4. + *
  5. There are no aggregations ({@link GroupByExpression}).
  6. + *
  7. There is no {@code DISTINCT} ({@link LogicalDistinctExpression}).
  8. + *
  9. The single child quantifier ranges over a {@link LogicalTypeFilterExpression} targeting + * exactly one base record type.
  10. + *
  11. The projection covers every column of the base table ({@code SELECT *} — no partial + * projections, no computed expressions, no {@code DISTINCT} without a GroupBy that would + * have been caught earlier).
  12. + *
+ * + *

When all conditions are met, {@link #analyze} returns a populated {@link ViewUpdatabilityInfo} + * containing the base table, its identifier, any view-level predicates (the view's own WHERE + * clause), and the quantifier alias that those predicates reference. + */ +@API(API.Status.EXPERIMENTAL) +public final class ViewUpdatabilityAnalyzer { + + private ViewUpdatabilityAnalyzer() { + } + + /** + * Returns {@link ViewUpdatabilityInfo} if {@code viewOp} represents an updatable view, + * or {@link Optional#empty()} if the view is read-only. + */ + @Nonnull + public static Optional analyze(@Nonnull final LogicalOperator viewOp, + @Nonnull final SchemaTemplate catalog) { + // The compiled view plan is always headed by a forEach quantifier whose target is the + // SelectExpression produced by the view's query. When the view is loaded from FDB and + // compiled via RoutineParser (a fresh BaseVisitor with no parent plan fragment), the + // isTopLevel flag is true, so generateSelect wraps the SelectExpression in a + // LogicalSortExpression.unsorted(). We look through that transparent wrapper here. + RelationalExpression topExpr = viewOp.getQuantifier().getRangesOver().get(); + if (topExpr instanceof LogicalSortExpression) { + final LogicalSortExpression sortExpr = (LogicalSortExpression) topExpr; + if (sortExpr.getQuantifiers().size() != 1) { + return Optional.empty(); + } + topExpr = Iterables.getOnlyElement(sortExpr.getQuantifiers()).getRangesOver().get(); + } + + // Rule 1: must be a simple SELECT (no GROUP BY, DISTINCT, UNION as top-level). + if (!(topExpr instanceof SelectExpression)) { + return Optional.empty(); + } + final SelectExpression selectExpr = (SelectExpression) topExpr; + + // Rule 2: exactly one source quantifier (no joins, no multi-table FROM). + if (selectExpr.getQuantifiers().size() != 1) { + return Optional.empty(); + } + + // Rule 3: no disqualifying expressions in the subtree. + final Quantifier innerQun = Iterables.getOnlyElement(selectExpr.getQuantifiers()); + if (containsDisqualifyingExpression(innerQun.getRangesOver().get())) { + return Optional.empty(); + } + + // Rule 4: the single inner quantifier must range over a LogicalTypeFilterExpression, + // which is the signature of a plain base-table scan. + final RelationalExpression innerExpr = innerQun.getRangesOver().get(); + if (!(innerExpr instanceof LogicalTypeFilterExpression)) { + return Optional.empty(); + } + final LogicalTypeFilterExpression typeFilter = (LogicalTypeFilterExpression) innerExpr; + + // Rule 5: the type filter must cover exactly one record type (single base table). + if (typeFilter.getRecordTypes().size() != 1) { + return Optional.empty(); + } + final String storageName = Iterables.getOnlyElement(typeFilter.getRecordTypes()); + + // Locate the corresponding RecordLayerTable in the catalog. + final Optional baseTableOpt = findTableByStorageName(catalog, storageName); + if (baseTableOpt.isEmpty()) { + return Optional.empty(); + } + final RecordLayerTable baseTable = baseTableOpt.get(); + + // Rule 6: the projection must cover all base-table columns — partial projections are + // non-updatable because the DML rewrite cannot determine default values for + // omitted columns. A SELECT * view expands to exactly as many result values as the + // base table has columns. + if (selectExpr.getResultValues().size() != baseTable.getColumns().size()) { + return Optional.empty(); + } + + return Optional.of(new ViewUpdatabilityInfo( + baseTable, + Identifier.of(baseTable.getName()), + selectExpr.getPredicates(), + innerQun.getAlias())); + } + + /** + * Returns {@code true} if {@code expr} or any expression reachable through its quantifiers + * is a disqualifying expression type (aggregation, DISTINCT, or set operation). + */ + private static boolean containsDisqualifyingExpression(@Nonnull final RelationalExpression expr) { + if (expr instanceof GroupByExpression || + expr instanceof LogicalDistinctExpression || + expr instanceof LogicalUnionExpression) { + return true; + } + for (final Quantifier qun : expr.getQuantifiers()) { + if (containsDisqualifyingExpression(qun.getRangesOver().get())) { + return true; + } + } + return false; + } + + @Nonnull + private static Optional findTableByStorageName(@Nonnull final SchemaTemplate catalog, + @Nonnull final String storageName) { + try { + for (final var table : catalog.getTables()) { + if (table instanceof RecordLayerTable) { + final RecordLayerTable rlt = (RecordLayerTable) table; + if (storageName.equals(rlt.getType().getStorageName())) { + return Optional.of(rlt); + } + } + } + return Optional.empty(); + } catch (RelationalException e) { + throw e.toUncheckedWrappedException(); + } + } +} diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/ViewUpdatabilityInfo.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/ViewUpdatabilityInfo.java new file mode 100644 index 0000000000..7ea7ace9ee --- /dev/null +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/ViewUpdatabilityInfo.java @@ -0,0 +1,87 @@ +/* + * ViewUpdatabilityInfo.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2025 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. + */ + +package com.apple.foundationdb.relational.recordlayer.query; + +import com.apple.foundationdb.annotation.API; +import com.apple.foundationdb.record.query.plan.cascades.CorrelationIdentifier; +import com.apple.foundationdb.record.query.plan.cascades.predicates.QueryPredicate; +import com.apple.foundationdb.relational.recordlayer.metadata.RecordLayerTable; + +import javax.annotation.Nonnull; +import java.util.List; + +/** + * Carries the information extracted from an updatable view's compiled plan that DML visitors need to + * rewrite INSERT/UPDATE/DELETE targeting the view into equivalent DML against the base table. + */ +@API(API.Status.EXPERIMENTAL) +public final class ViewUpdatabilityInfo { + + @Nonnull + private final RecordLayerTable baseTable; + + @Nonnull + private final Identifier baseTableIdentifier; + + /** + * Predicates from the view's SelectExpression (the view's own WHERE clause, if any). + * These reference the view's inner quantifier alias ({@link #viewInnerAlias}). + */ + @Nonnull + private final List viewPredicates; + + /** + * The alias of the quantifier that flows through the view's SelectExpression. View predicates + * reference this alias and must be translated before use in a different quantifier context. + */ + @Nonnull + private final CorrelationIdentifier viewInnerAlias; + + public ViewUpdatabilityInfo(@Nonnull final RecordLayerTable baseTable, + @Nonnull final Identifier baseTableIdentifier, + @Nonnull final List viewPredicates, + @Nonnull final CorrelationIdentifier viewInnerAlias) { + this.baseTable = baseTable; + this.baseTableIdentifier = baseTableIdentifier; + this.viewPredicates = viewPredicates; + this.viewInnerAlias = viewInnerAlias; + } + + @Nonnull + public RecordLayerTable baseTable() { + return baseTable; + } + + @Nonnull + public Identifier baseTableIdentifier() { + return baseTableIdentifier; + } + + @Nonnull + public List viewPredicates() { + return viewPredicates; + } + + @Nonnull + public CorrelationIdentifier viewInnerAlias() { + return viewInnerAlias; + } +} diff --git a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/visitors/QueryVisitor.java b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/visitors/QueryVisitor.java index 018a2c6864..4d2dff127c 100644 --- a/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/visitors/QueryVisitor.java +++ b/fdb-relational-core/src/main/java/com/apple/foundationdb/relational/recordlayer/query/visitors/QueryVisitor.java @@ -34,6 +34,7 @@ import com.apple.foundationdb.record.query.plan.cascades.predicates.CompatibleTypeEvolutionPredicate; import com.apple.foundationdb.record.query.plan.cascades.predicates.QueryPredicate; import com.apple.foundationdb.record.query.plan.cascades.typing.Type; +import com.apple.foundationdb.record.query.plan.cascades.values.translation.TranslationMap; 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; @@ -55,6 +56,7 @@ import com.apple.foundationdb.relational.recordlayer.query.QueryPlan; import com.apple.foundationdb.relational.recordlayer.query.SemanticAnalyzer; import com.apple.foundationdb.relational.recordlayer.query.StringTrieNode; +import com.apple.foundationdb.relational.recordlayer.query.ViewUpdatabilityInfo; import com.apple.foundationdb.relational.recordlayer.util.MemoizedFunction; import com.apple.foundationdb.relational.recordlayer.util.TypeUtils; import com.apple.foundationdb.relational.util.Assert; @@ -750,8 +752,15 @@ public Set visitIndexHint(@Nonnull RelationalParser.IndexHintContext ind @Override public LogicalOperator visitInsertStatement(@Nonnull RelationalParser.InsertStatementContext ctx) { final var table = visitTableName(ctx.tableName()); - final var tableType = getDelegate().getSemanticAnalyzer().getTable(table); - final var targetType = Assert.castUnchecked(tableType, RecordLayerTable.class).getType(); + final var semanticAnalyzer = getDelegate().getSemanticAnalyzer(); + final RecordLayerTable targetTable; + if (semanticAnalyzer.viewExists(table)) { + final ViewUpdatabilityInfo vui = semanticAnalyzer.resolveUpdatableView(table); + targetTable = vui.baseTable(); + } else { + targetTable = Assert.castUnchecked(semanticAnalyzer.getTable(table), RecordLayerTable.class); + } + final var targetType = targetTable.getType(); getDelegate().pushPlanFragment(); // TODO (Refactor insert parse rules) // (yhatem) leave it like this until the old plan generator is removed. @@ -771,7 +780,7 @@ public LogicalOperator visitInsertStatement(@Nonnull RelationalParser.InsertStat getDelegate().getCurrentPlanFragment().setState(stateBuilder.build()); insertSource = Assert.castUnchecked(ctx.insertStatementValue().accept(this), LogicalOperator.class); } - final var resultingInsert = LogicalOperator.generateInsert(insertSource, tableType); + final var resultingInsert = LogicalOperator.generateInsert(insertSource, targetTable); getDelegate().popPlanFragment(); return resultingInsert; } @@ -810,9 +819,23 @@ public LogicalOperator visitInsertStatementValueValues(@Nonnull RelationalParser public LogicalOperator visitUpdateStatement(@Nonnull RelationalParser.UpdateStatementContext ctx) { final Identifier tableId = visitFullId(ctx.tableName().fullId()); final SemanticAnalyzer semanticAnalyzer = getDelegate().getSemanticAnalyzer(); - final RecordLayerTable table = Assert.castUnchecked(semanticAnalyzer.getTable(tableId), RecordLayerTable.class); + final RecordLayerTable table; + final LogicalOperator tableAccess; + final List viewPredicates; + if (semanticAnalyzer.viewExists(tableId)) { + final ViewUpdatabilityInfo vui = semanticAnalyzer.resolveUpdatableView(tableId); + table = vui.baseTable(); + // Use the full base table access, aliased as the view name, so that column references + // like "myView.col" still resolve correctly. + tableAccess = getDelegate().getLogicalOperatorCatalog().lookupTableAccess( + vui.baseTableIdentifier(), Optional.of(tableId), ImmutableSet.of(), semanticAnalyzer); + viewPredicates = translateViewPredicates(vui, tableAccess); + } else { + table = Assert.castUnchecked(semanticAnalyzer.getTable(tableId), RecordLayerTable.class); + tableAccess = getDelegate().getLogicalOperatorCatalog().lookupTableAccess(tableId, semanticAnalyzer); + viewPredicates = ImmutableList.of(); + } final Type.Record tableType = table.getType(); - final LogicalOperator tableAccess = getDelegate().getLogicalOperatorCatalog().lookupTableAccess(tableId, semanticAnalyzer); getDelegate().pushPlanFragment().setOperator(tableAccess); // Note: doing an expansion here means that we don't have access to the pseudo-columns during the update @@ -821,7 +844,7 @@ public LogicalOperator visitUpdateStatement(@Nonnull RelationalParser.UpdateStat final var output = Expressions.ofSingle(semanticAnalyzer.expandStar(Optional.empty(), getDelegate().getLogicalOperators())); Optional whereMaybe = ctx.whereExpr() == null ? Optional.empty() : Optional.of(visitWhereExpr(ctx.whereExpr())); - final var updateSource = LogicalOperator.generateSimpleSelect(output, getDelegate().getLogicalOperators(), whereMaybe, Optional.of(tableId), ImmutableSet.of(), false); + final var updateSource = LogicalOperator.generateSimpleSelect(output, getDelegate().getLogicalOperators(), whereMaybe, Optional.of(tableId), ImmutableSet.of(), viewPredicates, false); getDelegate().getCurrentPlanFragment().setOperator(updateSource); final ImmutableMap.Builder transformMapBuilder = ImmutableMap.builder(); @@ -864,14 +887,26 @@ public LogicalOperator visitDeleteStatement(@Nonnull RelationalParser.DeleteStat Assert.thatUnchecked(ctx.limitClause() == null, "limit is not supported"); final Identifier tableId = visitFullId(ctx.tableName().fullId()); final SemanticAnalyzer semanticAnalyzer = getDelegate().getSemanticAnalyzer(); - final RecordLayerTable table = Assert.castUnchecked(semanticAnalyzer.getTable(tableId), RecordLayerTable.class); - final LogicalOperator tableAccess = getDelegate().getLogicalOperatorCatalog().lookupTableAccess(tableId, semanticAnalyzer); + final RecordLayerTable table; + final LogicalOperator tableAccess; + final List viewPredicates; + if (semanticAnalyzer.viewExists(tableId)) { + final ViewUpdatabilityInfo vui = semanticAnalyzer.resolveUpdatableView(tableId); + table = vui.baseTable(); + tableAccess = getDelegate().getLogicalOperatorCatalog().lookupTableAccess( + vui.baseTableIdentifier(), Optional.of(tableId), ImmutableSet.of(), semanticAnalyzer); + viewPredicates = translateViewPredicates(vui, tableAccess); + } else { + table = Assert.castUnchecked(semanticAnalyzer.getTable(tableId), RecordLayerTable.class); + tableAccess = getDelegate().getLogicalOperatorCatalog().lookupTableAccess(tableId, semanticAnalyzer); + viewPredicates = ImmutableList.of(); + } getDelegate().pushPlanFragment().setOperator(tableAccess); final var output = Expressions.ofSingle(semanticAnalyzer.expandStar(Optional.empty(), getDelegate().getLogicalOperators())); Optional whereMaybe = ctx.whereExpr() == null ? Optional.empty() : Optional.of(visitWhereExpr(ctx.whereExpr())); - final var deleteSource = LogicalOperator.generateSimpleSelect(output, getDelegate().getLogicalOperators(), whereMaybe, Optional.of(tableId), ImmutableSet.of(), false); + final var deleteSource = LogicalOperator.generateSimpleSelect(output, getDelegate().getLogicalOperators(), whereMaybe, Optional.of(tableId), ImmutableSet.of(), viewPredicates, false); final var deleteExpression = new DeleteExpression(Assert.castUnchecked(deleteSource.getQuantifier(), Quantifier.ForEach.class), table.getType().getStorageName()); final var deleteQuantifier = Quantifier.forEach(Reference.initialOf(deleteExpression)); @@ -893,6 +928,25 @@ public LogicalOperator visitDeleteStatement(@Nonnull RelationalParser.DeleteStat return result; } + /** + * Translates view-level predicates (the view's own WHERE clause) from the quantifier context + * used when the view was compiled to the quantifier context of the DML's base-table access. + * This is necessary because the view predicates reference the view's inner quantifier alias, + * while DML operates on a freshly-created base-table access quantifier. + */ + @Nonnull + private static List translateViewPredicates(@Nonnull final ViewUpdatabilityInfo vui, + @Nonnull final LogicalOperator tableAccess) { + if (vui.viewPredicates().isEmpty()) { + return ImmutableList.of(); + } + final var translationMap = TranslationMap.ofAliases( + vui.viewInnerAlias(), tableAccess.getQuantifier().getAlias()); + return vui.viewPredicates().stream() + .map(p -> p.translateCorrelations(translationMap, false)) + .collect(ImmutableList.toImmutableList()); + } + @Nonnull @Override public Object visitExecuteContinuationStatement(@Nonnull RelationalParser.ExecuteContinuationStatementContext ctx) { diff --git a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/UpdatableViewTest.java b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/UpdatableViewTest.java new file mode 100644 index 0000000000..c2bbcf84b1 --- /dev/null +++ b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/UpdatableViewTest.java @@ -0,0 +1,263 @@ +/* + * UpdatableViewTest.java + * + * This source file is part of the FoundationDB open source project + * + * Copyright 2015-2025 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. + */ + +package com.apple.foundationdb.relational.recordlayer.query; + +import com.apple.foundationdb.relational.api.RelationalConnection; +import com.apple.foundationdb.relational.api.exceptions.ErrorCode; +import com.apple.foundationdb.relational.recordlayer.EmbeddedRelationalExtension; +import com.apple.foundationdb.relational.utils.RelationalAssertions; +import com.apple.foundationdb.relational.utils.ResultSetAssert; +import com.apple.foundationdb.relational.utils.SchemaTemplateRule; +import com.apple.foundationdb.relational.utils.SimpleDatabaseRule; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.sql.DriverManager; +import java.sql.SQLException; + +/** + * Integration tests for DML (INSERT / UPDATE / DELETE) through SQL views. + */ +public class UpdatableViewTest { + + private static final class JoinViewSchema {} + + // Schema with a plain table, a row-filtered view, and a star view. + private static final String SCHEMA_TEMPLATE = + "CREATE TABLE employee (id BIGINT, name STRING, dept STRING, salary BIGINT, PRIMARY KEY(id))\n" + + "CREATE VIEW eng_employees AS SELECT * FROM employee WHERE dept = 'eng'\n" + + "CREATE VIEW all_employees AS SELECT * FROM employee"; + + // Schema with a join view to verify that DML on non-updatable views is rejected. + private static final String JOIN_VIEW_SCHEMA_TEMPLATE = + "CREATE TABLE emp (id BIGINT, name STRING, dept STRING, PRIMARY KEY(id))\n" + + "CREATE TABLE dept_info (code STRING, budget BIGINT, PRIMARY KEY(code))\n" + + "CREATE VIEW emp_dept AS SELECT e.id, e.name FROM emp e, dept_info d WHERE e.dept = d.code"; + + @RegisterExtension + @Order(0) + public final EmbeddedRelationalExtension relationalExtension = new EmbeddedRelationalExtension(); + + @RegisterExtension + @Order(1) + public final SimpleDatabaseRule database = new SimpleDatabaseRule( + UpdatableViewTest.class, + SCHEMA_TEMPLATE, + new SchemaTemplateRule.SchemaTemplateOptions(true, true)); + + @RegisterExtension + @Order(2) + public final SimpleDatabaseRule joinViewDatabase = new SimpleDatabaseRule( + JoinViewSchema.class, + JOIN_VIEW_SCHEMA_TEMPLATE, + new SchemaTemplateRule.SchemaTemplateOptions(true, true)); + + @BeforeEach + void setUp() throws SQLException { + try (final var conn = DriverManager.getConnection(database.getConnectionUri().toString()) + .unwrap(RelationalConnection.class)) { + conn.setSchema(database.getSchemaName()); + try (final var s = conn.createStatement()) { + s.execute("INSERT INTO employee (id, name, dept, salary) VALUES (1, 'Alice', 'eng', 90000)"); + s.execute("INSERT INTO employee (id, name, dept, salary) VALUES (2, 'Bob', 'eng', 80000)"); + s.execute("INSERT INTO employee (id, name, dept, salary) VALUES (3, 'Carol', 'mkt', 70000)"); + s.execute("INSERT INTO employee (id, name, dept, salary) VALUES (4, 'Dave', 'mkt', 60000)"); + } + } + } + + // ── DELETE ──────────────────────────────────────────────────────────────── + + @Test + void deleteRowVisibleThroughFilteredView() throws SQLException { + // DELETE through eng_employees only removes the row from the base table. + try (final var conn = DriverManager.getConnection(database.getConnectionUri().toString()) + .unwrap(RelationalConnection.class)) { + conn.setSchema(database.getSchemaName()); + conn.createStatement().execute("DELETE FROM eng_employees WHERE id = 1"); + // Alice (id=1) deleted; others remain. + try (final var rs = conn.prepareStatement("SELECT id FROM employee ORDER BY id").executeQuery()) { + ResultSetAssert.assertThat(rs) + .hasNextRow().hasColumn("ID", 2L) + .hasNextRow().hasColumn("ID", 3L) + .hasNextRow().hasColumn("ID", 4L) + .hasNoNextRow(); + } + } + } + + @Test + void deleteAllRowsVisibleThroughFilteredView() throws SQLException { + // DELETE without DML WHERE deletes only rows visible through the view's predicate. + try (final var conn = DriverManager.getConnection(database.getConnectionUri().toString()) + .unwrap(RelationalConnection.class)) { + conn.setSchema(database.getSchemaName()); + conn.createStatement().execute("DELETE FROM eng_employees"); + // Only mkt employees (Carol, Dave) remain. + try (final var rs = conn.prepareStatement("SELECT id, dept FROM employee ORDER BY id").executeQuery()) { + ResultSetAssert.assertThat(rs) + .hasNextRow().hasColumn("ID", 3L).hasColumn("DEPT", "mkt") + .hasNextRow().hasColumn("ID", 4L).hasColumn("DEPT", "mkt") + .hasNoNextRow(); + } + } + } + + @Test + void deleteRowThroughStarView() throws SQLException { + try (final var conn = DriverManager.getConnection(database.getConnectionUri().toString()) + .unwrap(RelationalConnection.class)) { + conn.setSchema(database.getSchemaName()); + conn.createStatement().execute("DELETE FROM all_employees WHERE id = 3"); + try (final var rs = conn.prepareStatement("SELECT id FROM employee ORDER BY id").executeQuery()) { + ResultSetAssert.assertThat(rs) + .hasNextRow().hasColumn("ID", 1L) + .hasNextRow().hasColumn("ID", 2L) + .hasNextRow().hasColumn("ID", 4L) + .hasNoNextRow(); + } + } + } + + // ── UPDATE ──────────────────────────────────────────────────────────────── + + @Test + void updateColumnThroughFilteredView() throws SQLException { + // View predicate restricts affected rows to the eng department. + try (final var conn = DriverManager.getConnection(database.getConnectionUri().toString()) + .unwrap(RelationalConnection.class)) { + conn.setSchema(database.getSchemaName()); + conn.createStatement().execute("UPDATE eng_employees SET salary = 99000"); + try (final var rs = conn.prepareStatement("SELECT id, salary FROM employee ORDER BY id").executeQuery()) { + ResultSetAssert.assertThat(rs) + .hasNextRow().hasColumn("ID", 1L).hasColumn("SALARY", 99000L) // Alice updated + .hasNextRow().hasColumn("ID", 2L).hasColumn("SALARY", 99000L) // Bob updated + .hasNextRow().hasColumn("ID", 3L).hasColumn("SALARY", 70000L) // Carol unchanged + .hasNextRow().hasColumn("ID", 4L).hasColumn("SALARY", 60000L) // Dave unchanged + .hasNoNextRow(); + } + } + } + + @Test + void updateColumnThroughFilteredViewWithAdditionalWhere() throws SQLException { + // View predicate AND DML WHERE are both applied. + try (final var conn = DriverManager.getConnection(database.getConnectionUri().toString()) + .unwrap(RelationalConnection.class)) { + conn.setSchema(database.getSchemaName()); + conn.createStatement().execute("UPDATE eng_employees SET salary = 55000 WHERE id = 2"); + try (final var rs = conn.prepareStatement("SELECT id, salary FROM employee ORDER BY id").executeQuery()) { + ResultSetAssert.assertThat(rs) + .hasNextRow().hasColumn("ID", 1L).hasColumn("SALARY", 90000L) // Alice unchanged + .hasNextRow().hasColumn("ID", 2L).hasColumn("SALARY", 55000L) // Bob updated + .hasNextRow().hasColumn("ID", 3L).hasColumn("SALARY", 70000L) // Carol unchanged + .hasNextRow().hasColumn("ID", 4L).hasColumn("SALARY", 60000L) // Dave unchanged + .hasNoNextRow(); + } + } + } + + @Test + void updateColumnThroughStarView() throws SQLException { + try (final var conn = DriverManager.getConnection(database.getConnectionUri().toString()) + .unwrap(RelationalConnection.class)) { + conn.setSchema(database.getSchemaName()); + conn.createStatement().execute("UPDATE all_employees SET name = 'Updated' WHERE id = 4"); + try (final var rs = conn.prepareStatement("SELECT id, name FROM employee WHERE id = 4").executeQuery()) { + ResultSetAssert.assertThat(rs) + .hasNextRow().hasColumn("ID", 4L).hasColumn("NAME", "Updated") + .hasNoNextRow(); + } + } + } + + // ── INSERT ──────────────────────────────────────────────────────────────── + + @Test + void insertThroughStarView() throws SQLException { + try (final var conn = DriverManager.getConnection(database.getConnectionUri().toString()) + .unwrap(RelationalConnection.class)) { + conn.setSchema(database.getSchemaName()); + conn.createStatement().execute( + "INSERT INTO all_employees (id, name, dept, salary) VALUES (5, 'Eve', 'eng', 85000)"); + try (final var rs = conn.prepareStatement("SELECT id, name FROM employee WHERE id = 5").executeQuery()) { + ResultSetAssert.assertThat(rs) + .hasNextRow().hasColumn("ID", 5L).hasColumn("NAME", "Eve") + .hasNoNextRow(); + } + } + } + + @Test + void insertThroughFilteredView() throws SQLException { + // INSERT through a filtered view: the row goes into the base table regardless of + // whether it satisfies the view's predicate (WITH CHECK OPTION is not yet supported). + try (final var conn = DriverManager.getConnection(database.getConnectionUri().toString()) + .unwrap(RelationalConnection.class)) { + conn.setSchema(database.getSchemaName()); + conn.createStatement().execute( + "INSERT INTO eng_employees (id, name, dept, salary) VALUES (10, 'Frank', 'sales', 50000)"); + try (final var rs = conn.prepareStatement("SELECT id, dept FROM employee WHERE id = 10").executeQuery()) { + ResultSetAssert.assertThat(rs) + .hasNextRow().hasColumn("ID", 10L).hasColumn("DEPT", "sales") + .hasNoNextRow(); + } + } + } + + // ── Non-updatable view rejection ────────────────────────────────────────── + + @Test + void deleteFromJoinViewIsRejected() throws SQLException { + try (final var conn = DriverManager.getConnection(joinViewDatabase.getConnectionUri().toString()) + .unwrap(RelationalConnection.class)) { + conn.setSchema(joinViewDatabase.getSchemaName()); + RelationalAssertions.assertThrowsSqlException( + () -> conn.createStatement().execute("DELETE FROM emp_dept WHERE id = 1")) + .hasErrorCode(ErrorCode.VIEW_NOT_UPDATABLE); + } + } + + @Test + void updateJoinViewIsRejected() throws SQLException { + try (final var conn = DriverManager.getConnection(joinViewDatabase.getConnectionUri().toString()) + .unwrap(RelationalConnection.class)) { + conn.setSchema(joinViewDatabase.getSchemaName()); + RelationalAssertions.assertThrowsSqlException( + () -> conn.createStatement().execute("UPDATE emp_dept SET name = 'x' WHERE id = 1")) + .hasErrorCode(ErrorCode.VIEW_NOT_UPDATABLE); + } + } + + @Test + void insertIntoJoinViewIsRejected() throws SQLException { + try (final var conn = DriverManager.getConnection(joinViewDatabase.getConnectionUri().toString()) + .unwrap(RelationalConnection.class)) { + conn.setSchema(joinViewDatabase.getSchemaName()); + RelationalAssertions.assertThrowsSqlException( + () -> conn.createStatement().execute("INSERT INTO emp_dept (id, name) VALUES (1, 'x')")) + .hasErrorCode(ErrorCode.VIEW_NOT_UPDATABLE); + } + } +} diff --git a/yaml-tests/src/test/java/YamlIntegrationTests.java b/yaml-tests/src/test/java/YamlIntegrationTests.java index ef3d79e3d9..7eec28df5d 100644 --- a/yaml-tests/src/test/java/YamlIntegrationTests.java +++ b/yaml-tests/src/test/java/YamlIntegrationTests.java @@ -489,6 +489,11 @@ public void viewsTest(YamlTest.Runner runner) throws Exception { runner.runYamsql("views.yamsql"); } + @TestTemplate + public void viewDmlTest(YamlTest.Runner runner) throws Exception { + runner.runYamsql("view-dml.yamsql"); + } + @TestTemplate public void simpleQueryWithDifferentDebuggersTest(YamlTest.Runner runner) throws Exception { runner.runYamsql("simple-query-with-different-debuggers.yamsql"); diff --git a/yaml-tests/src/test/resources/view-dml.yamsql b/yaml-tests/src/test/resources/view-dml.yamsql new file mode 100644 index 0000000000..d12e2bd920 --- /dev/null +++ b/yaml-tests/src/test/resources/view-dml.yamsql @@ -0,0 +1,293 @@ +# +# view-dml.yamsql +# +# This source file is part of the FoundationDB open source project +# +# Copyright 2015-2025 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. +# +# Integration tests for DML (INSERT / UPDATE / DELETE) through simple updatable views +# (single-table SELECT+filter+project, no aggregates or joins). +--- +options: + supported_version: !current_version +--- +schema_template: + create table employee(id bigint, name string, dept string, salary bigint, primary key(id)) + create table departments(code string, budget bigint, primary key(code)) + create view eng_view as select * from employee where dept = 'eng' + create view all_view as select * from employee + create view join_view as select e.id, e.name from employee e, departments d where e.dept = d.code + create view agg_view as select dept, count(*) as cnt from employee group by dept + create view distinct_view as select distinct dept from employee + create view partial_view as select id, name from employee + create view eng_senior_view as select * from eng_view where salary > 85000 + create view nested_agg_view as select dept, count(*) as cnt from eng_view group by dept +--- +test_block: + name: view-dml-tests + preset: single_repetition_ordered + options: + connection_lifecycle: block + tests: + # ── Non-updatable views must be rejected ──────────────────────────────── + - + - query: delete from join_view where id = 1 + - error: VIEW_NOT_UPDATABLE + - + - query: update join_view set name = 'x' where id = 1 + - error: VIEW_NOT_UPDATABLE + - + - query: insert into join_view (id, name) values (1, 'x') + - error: VIEW_NOT_UPDATABLE + - + - query: delete from agg_view where dept = 'eng' + - error: VIEW_NOT_UPDATABLE + - + - query: update agg_view set cnt = 0 where dept = 'eng' + - error: VIEW_NOT_UPDATABLE + - + - query: insert into agg_view (dept, cnt) values ('eng', 0) + - error: VIEW_NOT_UPDATABLE + - + - query: delete from distinct_view where dept = 'eng' + - error: VIEW_NOT_UPDATABLE + - + - query: update distinct_view set dept = 'x' where dept = 'eng' + - error: VIEW_NOT_UPDATABLE + - + - query: insert into distinct_view (dept) values ('eng') + - error: VIEW_NOT_UPDATABLE + - + - query: delete from partial_view where id = 1 + - error: VIEW_NOT_UPDATABLE + - + - query: update partial_view set name = 'x' where id = 1 + - error: VIEW_NOT_UPDATABLE + - + - query: insert into partial_view (id, name) values (99, 'x') + - error: VIEW_NOT_UPDATABLE + - + # nested_agg_view has GROUP BY — definitely non-updatable regardless of base chain + - query: delete from nested_agg_view where dept = 'eng' + - error: VIEW_NOT_UPDATABLE + - + - query: update nested_agg_view set cnt = 0 where dept = 'eng' + - error: VIEW_NOT_UPDATABLE + - + - query: insert into nested_agg_view (dept, cnt) values ('eng', 0) + - error: VIEW_NOT_UPDATABLE + - + # eng_senior_view = SELECT * FROM eng_view WHERE salary > 85000. + # Even though the chain ultimately reads from a single base table, the planner + # keeps the inner view reference opaque (does not inline the definition), so the + # compiled plan has a view scan rather than a LogicalTypeFilterExpression as its + # inner quantifier. DML through views-on-views is not yet supported. + - query: delete from eng_senior_view where id = 1 + - error: VIEW_NOT_UPDATABLE + - + - query: update eng_senior_view set salary = 99000 where id = 1 + - error: VIEW_NOT_UPDATABLE + - + - query: insert into eng_senior_view (id, name, dept, salary) values (99, 'X', 'eng', 90000) + - error: VIEW_NOT_UPDATABLE + + # ── DELETE through filtered view ──────────────────────────────────────── + - + - query: insert into employee values (1, 'Alice', 'eng', 90000), (2, 'Bob', 'eng', 80000), (3, 'Carol', 'mkt', 70000), (4, 'Dave', 'mkt', 60000) + - count: 4 + - + # DELETE with WHERE: only the matching eng row is removed + - query: delete from eng_view where id = 1 + - count: 1 + - + - query: select id from employee order by id + - result: [{id: 2}, {id: 3}, {id: 4}] + + # reset + - + - query: delete from employee + - count: 3 + - + - query: insert into employee values (1, 'Alice', 'eng', 90000), (2, 'Bob', 'eng', 80000), (3, 'Carol', 'mkt', 70000), (4, 'Dave', 'mkt', 60000) + - count: 4 + - + # DELETE without WHERE: view predicate limits deletion to dept='eng' rows only + - query: delete from eng_view + - count: 2 + - + - query: select id, dept from employee order by id + - result: [{id: 3, dept: 'mkt'}, {id: 4, dept: 'mkt'}] + + # ── DELETE through star view ───────────────────────────────────────────── + - + - query: delete from employee + - count: 2 + - + - query: insert into employee values (1, 'Alice', 'eng', 90000), (2, 'Bob', 'eng', 80000), (3, 'Carol', 'mkt', 70000), (4, 'Dave', 'mkt', 60000) + - count: 4 + - + - query: delete from all_view where id = 3 + - count: 1 + - + - query: select id from employee order by id + - result: [{id: 1}, {id: 2}, {id: 4}] + + # ── UPDATE through filtered view ───────────────────────────────────────── + - + - query: delete from employee + - count: 3 + - + - query: insert into employee values (1, 'Alice', 'eng', 90000), (2, 'Bob', 'eng', 80000), (3, 'Carol', 'mkt', 70000), (4, 'Dave', 'mkt', 60000) + - count: 4 + - + # View predicate (dept='eng') restricts affected rows — mkt employees unchanged + - query: update eng_view set salary = 99000 + - count: 2 + - + - query: select id, salary from employee order by id + - result: [{id: 1, salary: 99000}, {id: 2, salary: 99000}, {id: 3, salary: 70000}, {id: 4, salary: 60000}] + + # reset + - + - query: delete from employee + - count: 4 + - + - query: insert into employee values (1, 'Alice', 'eng', 90000), (2, 'Bob', 'eng', 80000), (3, 'Carol', 'mkt', 70000), (4, 'Dave', 'mkt', 60000) + - count: 4 + - + # Both view predicate and DML WHERE are applied — only Bob (id=2) is updated + - query: update eng_view set salary = 55000 where id = 2 + - count: 1 + - + - query: select id, salary from employee order by id + - result: [{id: 1, salary: 90000}, {id: 2, salary: 55000}, {id: 3, salary: 70000}, {id: 4, salary: 60000}] + + # ── UPDATE through star view ────────────────────────────────────────────── + - + - query: delete from employee + - count: 4 + - + - query: insert into employee values (1, 'Alice', 'eng', 90000), (2, 'Bob', 'eng', 80000), (3, 'Carol', 'mkt', 70000), (4, 'Dave', 'mkt', 60000) + - count: 4 + - + - query: update all_view set name = 'Updated' where id = 4 + - count: 1 + - + - query: select id, name from employee where id = 4 + - result: [{id: 4, name: 'Updated'}] + + # ── INSERT through star view ────────────────────────────────────────────── + - + - query: delete from employee + - count: 4 + - + - query: insert into all_view (id, name, dept, salary) values (5, 'Eve', 'eng', 85000) + - count: 1 + - + - query: select id, name from employee where id = 5 + - result: [{id: 5, name: 'Eve'}] + + # ── INSERT through filtered view ───────────────────────────────────────── + - + - query: delete from employee + - count: 1 + - + # Row goes into the base table regardless of the view predicate (WITH CHECK OPTION is not yet supported) + - query: insert into eng_view (id, name, dept, salary) values (10, 'Frank', 'sales', 50000) + - count: 1 + - + - query: select id, dept from employee where id = 10 + - result: [{id: 10, dept: 'sales'}] + + # ── Zero-rows-affected cases ────────────────────────────────────────────── + - + - query: delete from employee + - count: 1 + - + - query: insert into employee values (1, 'Alice', 'eng', 90000), (2, 'Bob', 'eng', 80000), (3, 'Carol', 'mkt', 70000), (4, 'Dave', 'mkt', 60000) + - count: 4 + - + # Carol (id=3) exists in the base table but is not visible through eng_view (dept='mkt') + - query: delete from eng_view where id = 3 + - count: 0 + - + - query: select id from employee order by id + - result: [{id: 1}, {id: 2}, {id: 3}, {id: 4}] + - + # View predicate (dept='eng') and WHERE (dept='mkt') are mutually exclusive — nothing deleted + - query: delete from eng_view where dept = 'mkt' + - count: 0 + - + - query: select id from employee order by id + - result: [{id: 1}, {id: 2}, {id: 3}, {id: 4}] + - + # Carol (id=3) exists in the base table but is invisible to eng_view — no rows updated + - query: update eng_view set salary = 0 where id = 3 + - count: 0 + - + - query: select id, salary from employee order by id + - result: [{id: 1, salary: 90000}, {id: 2, salary: 80000}, {id: 3, salary: 70000}, {id: 4, salary: 60000}] + - + # View predicate (dept='eng') and WHERE (dept='mkt') are mutually exclusive — nothing updated + - query: update eng_view set salary = 0 where dept = 'mkt' + - count: 0 + - + - query: select id, salary from employee order by id + - result: [{id: 1, salary: 90000}, {id: 2, salary: 80000}, {id: 3, salary: 70000}, {id: 4, salary: 60000}] + + # ── UPDATE violates view predicate (no WITH CHECK OPTION) ───────────────── + - + # Changing dept to 'sales' moves Alice outside the view's predicate zone. + # Without WITH CHECK OPTION the update succeeds; the row stays in the base + # table but is no longer visible through eng_view. + - query: update eng_view set dept = 'sales' where id = 1 + - count: 1 + - + - query: select id, dept from employee where id = 1 + - result: [{id: 1, dept: 'sales'}] + - + # Only Bob (id=2) still satisfies dept='eng' + - query: select id from eng_view order by id + - result: [{id: 2}] + - + # INSERT through eng_view with a row that satisfies the view predicate — + # the new row is immediately visible through the view + - query: insert into eng_view (id, name, dept, salary) values (20, 'Grace', 'eng', 75000) + - count: 1 + - + - query: select id from eng_view order by id + - result: [{id: 2}, {id: 20}] + + # ── UPDATE all rows through star view (no WHERE) ────────────────────────── + - + - query: delete from employee + - count: 5 + - + - query: insert into employee values (1, 'Alice', 'eng', 90000), (2, 'Bob', 'eng', 80000), (3, 'Carol', 'mkt', 70000), (4, 'Dave', 'mkt', 60000) + - count: 4 + - + - query: update all_view set salary = 0 + - count: 4 + - + - query: select id, salary from employee order by id + - result: [{id: 1, salary: 0}, {id: 2, salary: 0}, {id: 3, salary: 0}, {id: 4, salary: 0}] + + # ── DELETE all rows through star view (no WHERE) ────────────────────────── + - + - query: delete from all_view + - count: 4 + - + - query: select id from employee order by id + - result: [] From 5bb999b1207c75f95b53a915bd7cab3b02059d9e Mon Sep 17 00:00:00 2001 From: Arnaud Lacurie Date: Fri, 12 Jun 2026 16:52:23 +0100 Subject: [PATCH 2/2] Fix checks --- .../relational/recordlayer/query/UpdatableViewTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/UpdatableViewTest.java b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/UpdatableViewTest.java index c2bbcf84b1..96bce970d5 100644 --- a/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/UpdatableViewTest.java +++ b/fdb-relational-core/src/test/java/com/apple/foundationdb/relational/recordlayer/query/UpdatableViewTest.java @@ -41,7 +41,8 @@ */ public class UpdatableViewTest { - private static final class JoinViewSchema {} + private static final class JoinViewSchema { + } // Schema with a plain table, a row-filtered view, and a star view. private static final String SCHEMA_TEMPLATE =