This is an automated email from the ASF dual-hosted git repository.

xiedeyantu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/calcite.git


The following commit(s) were added to refs/heads/main by this push:
     new e72d54dfa1 [CALCITE-7662] Add expression support for OFFSET
e72d54dfa1 is described below

commit e72d54dfa13aead34628606703352a5ff6eec6f3
Author: Kirill Tkalenko <[email protected]>
AuthorDate: Sun Jul 26 07:06:42 2026 +0300

    [CALCITE-7662] Add expression support for OFFSET
---
 core/src/main/codegen/templates/Parser.jj          |  25 ++-
 .../adapter/enumerable/EnumerableLimit.java        |  10 +-
 .../adapter/enumerable/EnumerableLimitSort.java    |   8 +-
 .../enumerable/EnumerableMergeUnionRule.java       |  20 +--
 .../calcite/rel/metadata/RelMdMinRowCount.java     |   6 +-
 .../org/apache/calcite/rel/metadata/RelMdUtil.java |   5 +-
 .../calcite/rel/rel2sql/RelToSqlConverter.java     |  11 +-
 .../calcite/rel/rules/SortJoinTransposeRule.java   |   5 +-
 .../calcite/rel/rules/SortUnionTransposeRule.java  |  24 ++-
 .../main/java/org/apache/calcite/rex/RexUtil.java  |  69 ++++++--
 .../apache/calcite/runtime/CalciteResource.java    |  17 +-
 .../java/org/apache/calcite/sql/SqlDialect.java    |  54 ++++--
 .../calcite/sql/dialect/MysqlSqlDialect.java       |  10 +-
 .../calcite/sql/dialect/SqliteSqlDialect.java      |   9 +-
 .../calcite/sql/validate/SqlValidatorImpl.java     |  39 +++--
 .../apache/calcite/sql2rel/RelDecorrelator.java    |   6 +-
 .../java/org/apache/calcite/tools/RelBuilder.java  |  48 +++---
 .../calcite/runtime/CalciteResource.properties     |   7 +-
 .../calcite/rel/rel2sql/RelToSqlConverterTest.java |  71 ++++++++
 .../org/apache/calcite/rex/RexProgramTest.java     |  14 ++
 .../java/org/apache/calcite/test/JdbcTest.java     | 184 +++++++++++++++++---
 .../org/apache/calcite/test/RelBuilderTest.java    |  78 +++++++++
 .../org/apache/calcite/test/RelMetadataTest.java   |  31 ++++
 .../org/apache/calcite/test/RelOptRulesTest.java   |  61 +++++++
 .../apache/calcite/test/SqlToRelConverterTest.java |   9 +
 .../org/apache/calcite/test/SqlValidatorTest.java  |  27 ++-
 .../test/enumerable/EnumerableMergeUnionTest.java  |  62 ++++++-
 .../org/apache/calcite/test/RelOptRulesTest.xml    | 117 +++++++++++++
 .../apache/calcite/test/SqlToRelConverterTest.xml  |  12 ++
 core/src/test/resources/sql/offset.iq              | 186 +++++++++++++++++++++
 .../java/org/apache/calcite/test/ServerTest.java   |  25 +++
 site/_docs/reference.md                            |  18 +-
 .../apache/calcite/sql/parser/SqlParserTest.java   |  21 ++-
 33 files changed, 1140 insertions(+), 149 deletions(-)

diff --git a/core/src/main/codegen/templates/Parser.jj 
b/core/src/main/codegen/templates/Parser.jj
index ce69124c4b..185a85f508 100644
--- a/core/src/main/codegen/templates/Parser.jj
+++ b/core/src/main/codegen/templates/Parser.jj
@@ -689,13 +689,13 @@ SqlNode ExprOrJoinOrOrderedQuery(ExprContext exprContext) 
:
  *
  * <blockquote><pre>
  *    [ LIMIT { count | ALL } ]
- *    [ OFFSET start ]</pre>
+ *    [ OFFSET { start | expression } ]</pre>
  * </blockquote>
  *
  * <p>Trino syntax for limit:
  *
  * <blockquote><pre>
- *    [ OFFSET start ]
+ *    [ OFFSET { start | expression } ]
  *    [ LIMIT { count | ALL } ]</pre>
  * </blockquote>
  *
@@ -708,7 +708,7 @@ SqlNode ExprOrJoinOrOrderedQuery(ExprContext exprContext) :
  * <p>SQL:2008 syntax for limit:
  *
  * <blockquote><pre>
- *    [ OFFSET start { ROW | ROWS } ]
+ *    [ OFFSET { start | expression } { ROW | ROWS } ]
  *    [ FETCH { FIRST | NEXT } [ count | (expression) ] { ROW | ROWS } ONLY 
]</pre>
  * </blockquote>
  */
@@ -783,7 +783,7 @@ void OffsetClause(Span s, SqlNode[] offsetFetch) :
     // ROW or ROWS is required in SQL:2008 but we make it optional
     // because it is not present in Postgres-style syntax.
     <OFFSET> { s.add(this); }
-    offsetFetch[0] = UnsignedNumericLiteralOrParam()
+    offsetFetch[0] = OffsetCount()
     [ <ROW> | <ROWS> ]
 }
 
@@ -800,6 +800,23 @@ void FetchClause(SqlNode[] offsetFetch) :
     ( <ROW> | <ROWS> ) <ONLY>
 }
 
+/**
+ * Parses the start value or expression of an OFFSET clause.
+ */
+SqlNode OffsetCount() :
+{
+    final SqlNode e;
+}
+{
+    // Unlike FETCH expressions, OFFSET expressions do not require parentheses
+    // and may start with a numeric literal or dynamic parameter. Therefore, a
+    // separate UnsignedNumericLiteralOrParam alternative would consume only
+    // the prefix of expressions such as "OFFSET 1 + 2" or "OFFSET ? + 1".
+    // Expression also covers standalone start values.
+    e = Expression(ExprContext.ACCEPT_NON_QUERY)
+    { return e; }
+}
+
 /**
  * Parses the row count of a FETCH clause. Expressions must be parenthesized.
  */
diff --git 
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableLimit.java 
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableLimit.java
index de1f94d562..96a04be1b8 100644
--- 
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableLimit.java
+++ 
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableLimit.java
@@ -107,14 +107,14 @@ public static EnumerableLimit create(final RelNode input, 
@Nullable RexNode offs
           builder.append("offset",
               Expressions.call(BuiltInMethod.SKIP_BIG_DECIMAL.method, v,
                   getExpression(offset, "OFFSET", implementor, builder,
-                      roundingPolicyExp, false)));
+                      roundingPolicyExp)));
     }
     if (fetch != null) {
       v =
           builder.append("fetch",
               Expressions.call(BuiltInMethod.TAKE_BIG_DECIMAL.method, v,
                   getExpression(fetch, "FETCH", implementor, builder,
-                      roundingPolicyExp, true)));
+                      roundingPolicyExp)));
     }
 
     builder.add(Expressions.return_(null, v));
@@ -123,7 +123,7 @@ public static EnumerableLimit create(final RelNode input, 
@Nullable RexNode offs
 
   static Expression getExpression(RexNode rexNode, String kind,
       EnumerableRelImplementor implementor, BlockBuilder builder,
-      Expression roundingPolicy, boolean translateExpression) {
+      Expression roundingPolicy) {
     final Expression value;
     if (rexNode instanceof RexDynamicParam) {
       final RexDynamicParam param = (RexDynamicParam) rexNode;
@@ -134,10 +134,6 @@ static Expression getExpression(RexNode rexNode, String 
kind,
     } else if (rexNode instanceof RexLiteral) {
       value = Expressions.constant(RexLiteral.bigDecimalValue(rexNode));
     } else {
-      if (!translateExpression) {
-        throw new IllegalArgumentException(kind + " must be a literal or 
dynamic parameter");
-      }
-
       value =
           RexToLixTranslator.forAggregation(implementor.getTypeFactory(),
               builder, null, implementor.getConformance())
diff --git 
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableLimitSort.java
 
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableLimitSort.java
index 97d9fd8169..68759436d0 100644
--- 
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableLimitSort.java
+++ 
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableLimitSort.java
@@ -105,7 +105,7 @@ public static EnumerableLimitSort create(
       fetchVal = Expressions.constant(BigDecimal.valueOf(Integer.MAX_VALUE));
     } else {
       fetchVal =
-          getExpression(this.fetch, "FETCH", implementor, builder, 
roundingPolicyExp, true);
+          getExpression(this.fetch, "FETCH", implementor, builder, 
roundingPolicyExp);
     }
 
     final Expression offsetVal;
@@ -113,7 +113,7 @@ public static EnumerableLimitSort create(
       offsetVal = Expressions.constant(BigDecimal.ZERO);
     } else {
       offsetVal =
-          getExpression(this.offset, "OFFSET", implementor, builder, 
roundingPolicyExp, false);
+          getExpression(this.offset, "OFFSET", implementor, builder, 
roundingPolicyExp);
     }
 
     builder.add(
@@ -125,10 +125,10 @@ public static EnumerableLimitSort create(
                         builder.appendIfNotNull("comparator", pair.right))
                     .appendIfNotNull(
                         builder.appendIfNotNull("offset",
-                            Expressions.constant(offsetVal)))
+                            offsetVal))
                     .appendIfNotNull(
                         builder.appendIfNotNull("fetch",
-                            Expressions.constant(fetchVal))))));
+                            fetchVal)))));
     return implementor.result(physType, builder.toBlock());
   }
 }
diff --git 
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableMergeUnionRule.java
 
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableMergeUnionRule.java
index 57f864794a..3f2bdc5fe1 100644
--- 
a/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableMergeUnionRule.java
+++ 
b/core/src/main/java/org/apache/calcite/adapter/enumerable/EnumerableMergeUnionRule.java
@@ -27,7 +27,6 @@
 import org.apache.calcite.rel.logical.LogicalUnion;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rel.type.RelDataTypeField;
-import org.apache.calcite.rex.RexLiteral;
 import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.rex.RexUtil;
 import org.apache.calcite.tools.RelBuilder;
@@ -90,15 +89,16 @@ public EnumerableMergeUnionRule(Config config) {
     RexNode inputFetch = null;
     if (sort.fetch != null) {
       final boolean safeToReevaluate =
-          RexUtil.isDeterministic(sort.fetch);
-      if (sort.offset == null && safeToReevaluate) {
-        inputFetch = sort.fetch;
-      } else if (safeToReevaluate
-          && sort.fetch instanceof RexLiteral
-          && sort.offset instanceof RexLiteral) {
-        inputFetch =
-            call.builder().literal(RexLiteral.bigDecimalValue(sort.fetch)
-                .add(RexLiteral.bigDecimalValue(sort.offset)));
+          RexUtil.isDeterministic(sort.fetch)
+              && (sort.offset == null || RexUtil.isDeterministic(sort.offset));
+      if (safeToReevaluate) {
+        if (sort.offset == null) {
+          inputFetch = sort.fetch;
+        } else {
+          inputFetch =
+              RexUtil.makeOffsetFetchSum(
+                  sort.getCluster().getRexBuilder(), sort.offset, sort.fetch);
+        }
       }
     }
 
diff --git 
a/core/src/main/java/org/apache/calcite/rel/metadata/RelMdMinRowCount.java 
b/core/src/main/java/org/apache/calcite/rel/metadata/RelMdMinRowCount.java
index 2cb710f398..298e77f2d8 100644
--- a/core/src/main/java/org/apache/calcite/rel/metadata/RelMdMinRowCount.java
+++ b/core/src/main/java/org/apache/calcite/rel/metadata/RelMdMinRowCount.java
@@ -117,7 +117,8 @@ public Double getMinRowCount(Sort rel, RelMetadataQuery mq) 
{
     }
 
     final double offset =
-        literalValueApproximatedByDouble(rel.offset, 0D);
+        literalValueApproximatedByDouble(rel.offset,
+            rel.offset == null ? 0D : rowCount);
     rowCount = Math.max(rowCount - offset, 0D);
 
     final double limit =
@@ -133,7 +134,8 @@ public Double getMinRowCount(EnumerableLimit rel, 
RelMetadataQuery mq) {
     }
 
     final double offset =
-        literalValueApproximatedByDouble(rel.offset, 0D);
+        literalValueApproximatedByDouble(rel.offset,
+            rel.offset == null ? 0D : rowCount);
     rowCount = Math.max(rowCount - offset, 0D);
 
     final double limit =
diff --git a/core/src/main/java/org/apache/calcite/rel/metadata/RelMdUtil.java 
b/core/src/main/java/org/apache/calcite/rel/metadata/RelMdUtil.java
index 5b09628938..ab7dab016b 100644
--- a/core/src/main/java/org/apache/calcite/rel/metadata/RelMdUtil.java
+++ b/core/src/main/java/org/apache/calcite/rel/metadata/RelMdUtil.java
@@ -29,7 +29,6 @@
 import org.apache.calcite.rel.core.Union;
 import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.rex.RexCall;
-import org.apache.calcite.rex.RexDynamicParam;
 import org.apache.calcite.rex.RexInputRef;
 import org.apache.calcite.rex.RexLiteral;
 import org.apache.calcite.rex.RexLocalRef;
@@ -1057,7 +1056,9 @@ private static boolean alreadySmaller(RelMetadataQuery 
mq, RelNode input,
       }
     }
     final Double rowCount = mq.getMaxRowCount(input);
-    if (rowCount == null || offset instanceof RexDynamicParam || !(fetch 
instanceof RexLiteral)) {
+    if (rowCount == null
+        || (offset != null && !(offset instanceof RexLiteral))
+        || !(fetch instanceof RexLiteral)) {
       // Cannot be determined
       return false;
     }
diff --git 
a/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java 
b/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
index 2f96e8f73b..a5b8858a3a 100644
--- a/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
+++ b/core/src/main/java/org/apache/calcite/rel/rel2sql/RelToSqlConverter.java
@@ -1328,7 +1328,7 @@ public Result visit(Sort e) {
         SqlNodeList sortExps = exprList(builder.context, e.getSortExps());
         sqlSelect.setOrderBy(sortExps);
         if (e.offset != null) {
-          SqlNode offset = builder.context.toSql(null, e.offset);
+          SqlNode offset = toSqlOffset(e, builder.context);
           sqlSelect.setOffset(offset);
         }
         if (e.fetch != null) {
@@ -1393,10 +1393,17 @@ void offsetFetch(Sort e, Builder builder) {
       builder.setFetch(toSqlFetch(e, builder.context));
     }
     if (e.offset != null) {
-      builder.setOffset(builder.context.toSql(null, e.offset));
+      builder.setOffset(toSqlOffset(e, builder.context));
     }
   }
 
+  private static SqlNode toSqlOffset(Sort sort, Context context) {
+    final RexNode offset = requireNonNull(sort.offset, "offset");
+    final @Nullable RexLiteral reduced =
+        RexUtil.reduceOffsetToLiteral(sort.getCluster(), offset);
+    return context.toSql(null, reduced == null ? offset : reduced);
+  }
+
   private static SqlNode toSqlFetch(Sort sort, Context context) {
     final RexNode fetch = requireNonNull(sort.fetch, "fetch");
     final @Nullable RexLiteral reduced =
diff --git 
a/core/src/main/java/org/apache/calcite/rel/rules/SortJoinTransposeRule.java 
b/core/src/main/java/org/apache/calcite/rel/rules/SortJoinTransposeRule.java
index df967e56aa..dfb2d8401d 100644
--- a/core/src/main/java/org/apache/calcite/rel/rules/SortJoinTransposeRule.java
+++ b/core/src/main/java/org/apache/calcite/rel/rules/SortJoinTransposeRule.java
@@ -30,7 +30,6 @@
 import org.apache.calcite.rel.metadata.RelMdUtil;
 import org.apache.calcite.rel.metadata.RelMetadataQuery;
 import org.apache.calcite.rex.RexBuilder;
-import org.apache.calcite.rex.RexDynamicParam;
 import org.apache.calcite.rex.RexLiteral;
 import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.tools.RelBuilderFactory;
@@ -106,8 +105,8 @@ public SortJoinTransposeRule(Class<? extends Sort> 
sortClass,
     final Join join = call.rel(1);
 
     // The pushed fetch is calculated from literal offset and fetch values.
-    if (sort.offset instanceof RexDynamicParam
-        || sort.fetch != null && !(sort.fetch instanceof RexLiteral)) {
+    if ((sort.offset != null && !(sort.offset instanceof RexLiteral))
+        || (sort.fetch != null && !(sort.fetch instanceof RexLiteral))) {
       return false;
     }
 
diff --git 
a/core/src/main/java/org/apache/calcite/rel/rules/SortUnionTransposeRule.java 
b/core/src/main/java/org/apache/calcite/rel/rules/SortUnionTransposeRule.java
index 93b6af657c..21ec54ec60 100644
--- 
a/core/src/main/java/org/apache/calcite/rel/rules/SortUnionTransposeRule.java
+++ 
b/core/src/main/java/org/apache/calcite/rel/rules/SortUnionTransposeRule.java
@@ -23,6 +23,7 @@
 import org.apache.calcite.rel.core.Union;
 import org.apache.calcite.rel.metadata.RelMdUtil;
 import org.apache.calcite.rel.metadata.RelMetadataQuery;
+import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.rex.RexUtil;
 import org.apache.calcite.tools.RelBuilderFactory;
 
@@ -67,12 +68,14 @@ public SortUnionTransposeRule(
   @Override public boolean matches(RelOptRuleCall call) {
     final Sort sort = call.rel(0);
     final Union union = call.rel(1);
-    // Re-evaluating a non-deterministic FETCH in every branch can produce a
-    // different limit from the top Sort.
+    // Re-evaluating a non-deterministic OFFSET or FETCH in every branch can
+    // produce a different limit from the top Sort.
     // There is a flag indicating if this rule should be applied when
     // Sort.fetch is null.
     return union.all
-        && sort.offset == null
+        && (sort.offset == null
+            || sort.fetch != null
+                && RexUtil.isDeterministic(sort.offset))
         && (sort.fetch == null
             || RexUtil.isDeterministic(sort.fetch))
         && (config.matchNullFetch() || sort.fetch != null);
@@ -81,6 +84,17 @@ public SortUnionTransposeRule(
   @Override public void onMatch(RelOptRuleCall call) {
     final Sort sort = call.rel(0);
     final Union union = call.rel(1);
+    // OFFSET cannot be pushed into each input independently. However, only
+    // the first OFFSET + FETCH rows of an input can contribute to the final
+    // result, so use that value as the input FETCH and retain the original
+    // OFFSET and FETCH in the top Sort.
+    final RexNode inputFetch;
+    if (sort.fetch == null || sort.offset == null) {
+      inputFetch = sort.fetch;
+    } else {
+      inputFetch =
+          RexUtil.makeOffsetFetchSum(sort.getCluster().getRexBuilder(), 
sort.offset, sort.fetch);
+    }
     List<RelNode> inputs = new ArrayList<>();
     // Thus we use 'ret' as a flag to identify if we have finished pushing the
     // sort past a union.
@@ -88,11 +102,11 @@ public SortUnionTransposeRule(
     final RelMetadataQuery mq = call.getMetadataQuery();
     for (RelNode input : union.getInputs()) {
       if (!RelMdUtil.checkInputForCollationAndLimit(mq, input,
-          sort.getCollation(), sort.offset, sort.fetch)) {
+          sort.getCollation(), null, inputFetch)) {
         ret = false;
         Sort branchSort =
             sort.copy(sort.getTraitSet(), input,
-                sort.getCollation(), sort.offset, sort.fetch);
+                sort.getCollation(), null, inputFetch);
         inputs.add(branchSort);
       } else {
         inputs.add(input);
diff --git a/core/src/main/java/org/apache/calcite/rex/RexUtil.java 
b/core/src/main/java/org/apache/calcite/rex/RexUtil.java
index b592093a5a..b7c30b7608 100644
--- a/core/src/main/java/org/apache/calcite/rex/RexUtil.java
+++ b/core/src/main/java/org/apache/calcite/rex/RexUtil.java
@@ -66,6 +66,7 @@
 import org.checkerframework.checker.nullness.qual.Nullable;
 
 import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -881,12 +882,49 @@ public static boolean containsDynamicParam(RexNode e) {
 
   /** Converts a FETCH expression result to its validated canonical 
representation. */
   public static BigDecimal validateFetchValue(@Nullable Number value) {
+    return validateOffsetFetchValue(value, "FETCH");
+  }
+
+  /** Creates the FETCH needed when OFFSET and FETCH are pushed into an input.
+   *
+   * <p>Enumerable execution rounds OFFSET and FETCH independently to whole
+   * row counts. Therefore, the input must fetch
+   * {@code CEIL(offset) + CEIL(fetch)} rows rather than
+   * {@code CEIL(offset + fetch)} rows. The latter can be one row smaller when
+   * both values have a fractional part. */
+  public static RexNode makeOffsetFetchSum(RexBuilder rexBuilder,
+      RexNode offset, RexNode fetch) {
+    if (offset instanceof RexLiteral && fetch instanceof RexLiteral) {
+      return rexBuilder.makeExactLiteral(
+          RexLiteral.bigDecimalValue(offset).setScale(0, RoundingMode.CEILING)
+              .add(RexLiteral.bigDecimalValue(fetch)
+                  .setScale(0, RoundingMode.CEILING)));
+    }
+    return rexBuilder.makeCall(SqlStdOperatorTable.PLUS,
+        ceil(rexBuilder, offset), ceil(rexBuilder, fetch));
+  }
+
+  private static RexNode ceil(RexBuilder rexBuilder, RexNode node) {
+    if (node instanceof RexLiteral) {
+      return rexBuilder.makeExactLiteral(
+          RexLiteral.bigDecimalValue(node).setScale(0, RoundingMode.CEILING));
+    }
+    return rexBuilder.makeCall(SqlStdOperatorTable.CEIL, node);
+  }
+
+  /** Converts an OFFSET expression result to its validated canonical 
representation. */
+  public static BigDecimal validateOffsetValue(@Nullable Number value) {
+    return validateOffsetFetchValue(value, "OFFSET");
+  }
+
+  private static BigDecimal validateOffsetFetchValue(@Nullable Number value,
+      String kind) {
     if (value == null) {
-      throw new IllegalArgumentException("FETCH expression evaluated to NULL");
+      throw new IllegalArgumentException(kind + " expression evaluated to 
NULL");
     }
     final BigDecimal decimal = NumberUtil.toBigDecimal(value);
     if (decimal.signum() < 0) {
-      throw new IllegalArgumentException("FETCH value " + value
+      throw new IllegalArgumentException(kind + " value " + value
           + " is out of range; expected a non-negative value");
     }
     return decimal;
@@ -895,28 +933,39 @@ public static BigDecimal validateFetchValue(@Nullable 
Number value) {
   /** Reduces a constant FETCH expression to a validated literal. */
   public static @Nullable RexLiteral reduceFetchToLiteral(
       RelOptCluster cluster, RexNode fetch) {
+    return reduceOffsetFetchToLiteral(cluster, fetch, "FETCH");
+  }
+
+  /** Reduces a constant OFFSET expression to a validated literal. */
+  public static @Nullable RexLiteral reduceOffsetToLiteral(
+      RelOptCluster cluster, RexNode offset) {
+    return reduceOffsetFetchToLiteral(cluster, offset, "OFFSET");
+  }
+
+  private static @Nullable RexLiteral reduceOffsetFetchToLiteral(
+      RelOptCluster cluster, RexNode node, String kind) {
     final RexLiteral literal;
-    if (fetch instanceof RexLiteral) {
-      literal = (RexLiteral) fetch;
+    if (node instanceof RexLiteral) {
+      literal = (RexLiteral) node;
     } else {
-      if (!isConstant(fetch)
-          || !isDeterministic(fetch)
-          || containsDynamicFunction(fetch)
-          || containsDynamicParam(fetch)) {
+      if (!isConstant(node)
+          || !isDeterministic(node)
+          || containsDynamicFunction(node)
+          || containsDynamicParam(node)) {
         return null;
       }
       final RexExecutor executor =
           Util.first(cluster.getPlanner().getExecutor(), EXECUTOR);
       final List<RexNode> reducedValues = new ArrayList<>(1);
       executor.reduce(cluster.getRexBuilder(),
-          Collections.singletonList(fetch), reducedValues);
+          Collections.singletonList(node), reducedValues);
       final RexNode reduced = reducedValues.get(0);
       if (!(reduced instanceof RexLiteral)) {
         return null;
       }
       literal = (RexLiteral) reduced;
     }
-    validateFetchValue(literal.getValueAs(Number.class));
+    validateOffsetFetchValue(literal.getValueAs(Number.class), kind);
     return literal;
   }
 
diff --git a/core/src/main/java/org/apache/calcite/runtime/CalciteResource.java 
b/core/src/main/java/org/apache/calcite/runtime/CalciteResource.java
index 452c2bf84a..2e5056da1d 100644
--- a/core/src/main/java/org/apache/calcite/runtime/CalciteResource.java
+++ b/core/src/main/java/org/apache/calcite/runtime/CalciteResource.java
@@ -164,14 +164,19 @@ ExInstWithCause<CalciteContextException> 
validatorContext(int a0, int a1,
   @BaseMessage("Values passed to {0} operator must have compatible types")
   ExInst<SqlValidatorException> incompatibleValueType(String a0);
 
-  @BaseMessage("FETCH expression must have a numeric type; actual type is 
''{0}''")
-  ExInst<SqlValidatorException> fetchExpressionMustBeNumeric(String type);
+  @BaseMessage("{0} expression must have a numeric type; actual type is 
''{1}''")
+  ExInst<SqlValidatorException> offsetFetchExpressionMustBeNumeric(String kind,
+      String type);
 
-  @BaseMessage("FETCH expression cannot reference table column ''{0}''")
-  ExInst<SqlValidatorException> fetchExpressionCannotReferenceColumn(String 
column);
+  @BaseMessage("{0} expression cannot reference table column ''{1}''")
+  ExInst<SqlValidatorException> offsetFetchExpressionCannotReferenceColumn(
+      String kind, String column);
 
-  @BaseMessage("FETCH expression evaluated to NULL")
-  ExInst<SqlValidatorException> fetchExpressionEvaluatedToNull();
+  @BaseMessage("{0} expression evaluated to NULL")
+  ExInst<SqlValidatorException> offsetFetchExpressionEvaluatedToNull(String 
kind);
+
+  @BaseMessage("{0} must not be negative")
+  ExInst<SqlValidatorException> offsetFetchValueMustNotBeNegative(String kind);
 
   @BaseMessage("Values in expression list must have compatible types")
   ExInst<SqlValidatorException> incompatibleTypesInList();
diff --git a/core/src/main/java/org/apache/calcite/sql/SqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/SqlDialect.java
index 164f212c6c..eef1c386d5 100644
--- a/core/src/main/java/org/apache/calcite/sql/SqlDialect.java
+++ b/core/src/main/java/org/apache/calcite/sql/SqlDialect.java
@@ -1078,7 +1078,7 @@ protected static void unparseFetchUsingAnsi(SqlWriter 
writer, @Nullable SqlNode
       final SqlWriter.Frame offsetFrame =
           writer.startList(SqlWriter.FrameTypeEnum.OFFSET);
       writer.keyword("OFFSET");
-      offset.unparse(writer, -1, -1);
+      unparseOffsetExpression(writer, offset);
       writer.keyword("ROWS");
       writer.endList(offsetFrame);
     }
@@ -1088,24 +1088,36 @@ protected static void unparseFetchUsingAnsi(SqlWriter 
writer, @Nullable SqlNode
           writer.startList(SqlWriter.FrameTypeEnum.FETCH);
       writer.keyword("FETCH");
       writer.keyword("NEXT");
-      if (fetch instanceof SqlLiteral
-          || fetch instanceof SqlDynamicParam) {
-        fetch.unparse(writer, -1, -1);
-      } else {
-        final SqlWriter.Frame expressionFrame = writer.startList("(", ")");
-        if (fetch instanceof SqlCall) {
-          writer.getDialect().unparseCall(writer, (SqlCall) fetch, 0, 0);
-        } else {
-          fetch.unparse(writer, 0, 0);
-        }
-        writer.endList(expressionFrame);
-      }
+      unparseFetchExpression(writer, fetch);
       writer.keyword("ROWS");
       writer.keyword("ONLY");
       writer.endList(fetchFrame);
     }
   }
 
+  private static void unparseOffsetExpression(SqlWriter writer, SqlNode 
offset) {
+    unparseExpression(writer, offset);
+  }
+
+  private static void unparseFetchExpression(SqlWriter writer, SqlNode fetch) {
+    if (fetch instanceof SqlLiteral
+        || fetch instanceof SqlDynamicParam) {
+      fetch.unparse(writer, -1, -1);
+      return;
+    }
+    final SqlWriter.Frame expressionFrame = writer.startList("(", ")");
+    unparseExpression(writer, fetch);
+    writer.endList(expressionFrame);
+  }
+
+  private static void unparseExpression(SqlWriter writer, SqlNode node) {
+    if (node instanceof SqlCall) {
+      writer.getDialect().unparseCall(writer, (SqlCall) node, 0, 0);
+    } else {
+      node.unparse(writer, 0, 0);
+    }
+  }
+
   /** Unparses offset/fetch using "LIMIT fetch OFFSET offset" syntax. */
   protected static void unparseFetchUsingLimit(SqlWriter writer, @Nullable 
SqlNode offset,
       @Nullable SqlNode fetch) {
@@ -1113,12 +1125,12 @@ protected static void unparseFetchUsingLimit(SqlWriter 
writer, @Nullable SqlNode
   }
 
   /** Unparses offset/fetch using "LIMIT fetch OFFSET offset" syntax,
-   * optionally allowing a scalar expression as fetch. */
+   * optionally allowing scalar expressions as fetch and offset. */
   protected static void unparseFetchUsingLimit(SqlWriter writer, @Nullable 
SqlNode offset,
       @Nullable SqlNode fetch, boolean allowExpression) {
     checkArgument(fetch != null || offset != null);
     unparseLimit(writer, fetch, allowExpression);
-    unparseOffset(writer, offset);
+    unparseOffset(writer, offset, allowExpression);
   }
 
   protected static void unparseLimit(SqlWriter writer, @Nullable SqlNode 
fetch) {
@@ -1145,7 +1157,19 @@ private static void unparseLimit(SqlWriter writer, 
@Nullable SqlNode fetch,
   }
 
   protected static void unparseOffset(SqlWriter writer, @Nullable SqlNode 
offset) {
+    unparseOffset(writer, offset, false);
+  }
+
+  private static void unparseOffset(SqlWriter writer, @Nullable SqlNode offset,
+      boolean allowExpression) {
     if (offset != null) {
+      if (!allowExpression
+          && !(offset instanceof SqlLiteral)
+          && !(offset instanceof SqlDynamicParam)) {
+        throw new IllegalArgumentException(
+            "LIMIT dialect does not support OFFSET expressions that cannot "
+                + "be reduced to a literal");
+      }
       writer.newlineAndIndent();
       final SqlWriter.Frame offsetFrame =
           writer.startList(SqlWriter.FrameTypeEnum.OFFSET);
diff --git 
a/core/src/main/java/org/apache/calcite/sql/dialect/MysqlSqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/dialect/MysqlSqlDialect.java
index 03d4d2c504..1f114a1553 100644
--- a/core/src/main/java/org/apache/calcite/sql/dialect/MysqlSqlDialect.java
+++ b/core/src/main/java/org/apache/calcite/sql/dialect/MysqlSqlDialect.java
@@ -147,7 +147,15 @@ public MysqlSqlDialect(Context context) {
 
   @Override public void unparseOffsetFetch(SqlWriter writer, @Nullable SqlNode 
offset,
       @Nullable SqlNode fetch) {
-    unparseFetchUsingLimit(writer, offset, fetch);
+    if (offset != null && fetch == null) {
+      // MySQL has no OFFSET-only syntax. Its documented unlimited-row form
+      // uses the maximum unsigned BIGINT value as LIMIT.
+      final SqlNode unlimited =
+          SqlLiteral.createExactNumeric("18446744073709551615", 
SqlParserPos.ZERO);
+      unparseFetchUsingLimit(writer, offset, unlimited);
+    } else {
+      unparseFetchUsingLimit(writer, offset, fetch);
+    }
   }
 
   @Override public @Nullable SqlNode emulateNullDirection(SqlNode node,
diff --git 
a/core/src/main/java/org/apache/calcite/sql/dialect/SqliteSqlDialect.java 
b/core/src/main/java/org/apache/calcite/sql/dialect/SqliteSqlDialect.java
index f312764136..833ac5c10d 100644
--- a/core/src/main/java/org/apache/calcite/sql/dialect/SqliteSqlDialect.java
+++ b/core/src/main/java/org/apache/calcite/sql/dialect/SqliteSqlDialect.java
@@ -90,7 +90,14 @@ public SqliteSqlDialect(SqlDialect.Context context) {
 
   @Override public void unparseOffsetFetch(SqlWriter writer, @Nullable SqlNode 
offset,
       @Nullable SqlNode fetch) {
-    unparseFetchUsingLimit(writer, offset, fetch, true);
+    if (offset != null && fetch == null) {
+      // SQLite has no OFFSET-only syntax. LIMIT -1 means no upper bound.
+      final SqlNode unlimited =
+          SqlLiteral.createExactNumeric("-1", SqlParserPos.ZERO);
+      unparseFetchUsingLimit(writer, offset, unlimited, true);
+    } else {
+      unparseFetchUsingLimit(writer, offset, fetch, true);
+    }
   }
 
   @Override public void unparseCall(SqlWriter writer, SqlCall call,
diff --git 
a/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java 
b/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
index 0ea01a3514..8a5d3dfe9c 100644
--- a/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
+++ b/core/src/main/java/org/apache/calcite/sql/validate/SqlValidatorImpl.java
@@ -70,6 +70,7 @@
 import org.apache.calcite.sql.SqlMerge;
 import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.sql.SqlNodeList;
+import org.apache.calcite.sql.SqlNumericLiteral;
 import org.apache.calcite.sql.SqlOperator;
 import org.apache.calcite.sql.SqlOperatorTable;
 import org.apache.calcite.sql.SqlOrderBy;
@@ -1771,31 +1772,40 @@ private void handleOffsetFetch(@Nullable SqlNode 
offset, @Nullable SqlNode fetch
     }
   }
 
-  private void validateFetchExpression(@Nullable SqlNode fetch) {
-    if (fetch == null || fetch instanceof SqlDynamicParam) {
+  private void validateOffsetFetchExpression(@Nullable SqlNode node,
+      String kind) {
+    if (node == null || node instanceof SqlDynamicParam) {
       return;
     }
-    if (SqlUtil.isNullLiteral(fetch, true)) {
-      throw newValidationError(fetch,
-          RESOURCE.fetchExpressionEvaluatedToNull());
+    if (SqlUtil.isNullLiteral(node, true)) {
+      throw newValidationError(node,
+          RESOURCE.offsetFetchExpressionEvaluatedToNull(kind));
+    }
+    if (node instanceof SqlNumericLiteral
+        && requireNonNull(((SqlNumericLiteral) node).bigDecimalValue())
+            .signum() < 0) {
+      throw newValidationError(node,
+          RESOURCE.offsetFetchValueMustNotBeNegative(kind));
     }
-    validateNoAggs(aggOrOverFinder, fetch, "FETCH");
-    fetch.accept(new SqlBasicVisitor<Void>() {
+    validateNoAggs(aggOrOverFinder, node, kind);
+    node.accept(new SqlBasicVisitor<Void>() {
       @Override public Void visit(SqlIdentifier id) {
         if (makeNullaryCall(id) != null) {
           return null;
         }
         throw newValidationError(id,
-            RESOURCE.fetchExpressionCannotReferenceColumn(id.toString()));
+            RESOURCE.offsetFetchExpressionCannotReferenceColumn(kind,
+                id.toString()));
       }
     });
     final SqlValidatorScope scope = getEmptyScope();
-    inferUnknownTypes(typeFactory.createSqlType(SqlTypeName.DECIMAL), scope, 
fetch);
-    validateExpr(fetch, scope);
-    final RelDataType type = getValidatedNodeType(fetch);
+    inferUnknownTypes(typeFactory.createSqlType(SqlTypeName.DECIMAL), scope, 
node);
+    validateExpr(node, scope);
+    final RelDataType type = getValidatedNodeType(node);
     if (!SqlTypeUtil.isNumeric(type)) {
-      throw newValidationError(fetch,
-          RESOURCE.fetchExpressionMustBeNumeric(type.getFullTypeString()));
+      throw newValidationError(node,
+          RESOURCE.offsetFetchExpressionMustBeNumeric(kind,
+              type.getFullTypeString()));
     }
   }
 
@@ -4527,7 +4537,8 @@ protected void validateSelect(
     validateWindowClause(select);
     validateQualifyClause(select);
     handleOffsetFetch(select.getOffset(), select.getFetch());
-    validateFetchExpression(select.getFetch());
+    validateOffsetFetchExpression(select.getOffset(), "OFFSET");
+    validateOffsetFetchExpression(select.getFetch(), "FETCH");
 
     // Validate the SELECT clause late, because a select item might
     // depend on the GROUP BY list, or the window function might reference
diff --git a/core/src/main/java/org/apache/calcite/sql2rel/RelDecorrelator.java 
b/core/src/main/java/org/apache/calcite/sql2rel/RelDecorrelator.java
index 4e4104ad48..daabe37b89 100644
--- a/core/src/main/java/org/apache/calcite/sql2rel/RelDecorrelator.java
+++ b/core/src/main/java/org/apache/calcite/sql2rel/RelDecorrelator.java
@@ -1142,10 +1142,14 @@ private static void shiftMapping(Map<Integer, Integer> 
mapping, int startIndex,
   }
 
   static boolean canDecorrelateOffsetFetch(Sort sort) {
+    final @Nullable RexLiteral offset = sort.offset == null
+        ? null
+        : RexUtil.reduceOffsetToLiteral(sort.getCluster(), sort.offset);
     final @Nullable RexLiteral fetch = sort.fetch == null
         ? null
         : RexUtil.reduceFetchToLiteral(sort.getCluster(), sort.fetch);
-    return isNonNegativeIntegralLiteral(sort.offset)
+    return (sort.offset == null
+            || offset != null && isNonNegativeIntegralLiteral(offset))
         && (sort.fetch == null
             || fetch != null && isNonNegativeIntegralLiteral(fetch));
   }
diff --git a/core/src/main/java/org/apache/calcite/tools/RelBuilder.java 
b/core/src/main/java/org/apache/calcite/tools/RelBuilder.java
index 2309102ff8..92c5a41415 100644
--- a/core/src/main/java/org/apache/calcite/tools/RelBuilder.java
+++ b/core/src/main/java/org/apache/calcite/tools/RelBuilder.java
@@ -3801,29 +3801,14 @@ public RelBuilder sortLimit(Number offset, Number fetch,
 
   /** Creates a {@link Sort} by a list of expressions, with limitNode and 
offsetNode.
    *
-   * @param offsetNode RexLiteral means number of rows to skip is 
deterministic,
-   *                   RexDynamicParam means number of rows to skip is dynamic.
+   * @param offsetNode Number of rows to skip
    * @param fetchNode  Maximum number of rows to fetch
    * @param nodes      Sort expressions
    */
   public RelBuilder sortLimit(@Nullable RexNode offsetNode, @Nullable RexNode 
fetchNode,
       Iterable<? extends RexNode> nodes) {
-    if (offsetNode != null) {
-      if (!(offsetNode instanceof RexLiteral || offsetNode instanceof 
RexDynamicParam)) {
-        throw new IllegalArgumentException("OFFSET node must be RexLiteral or 
RexDynamicParam");
-      }
-    }
-    if (fetchNode != null && !isValidFetchExpression(fetchNode)) {
-      throw new IllegalArgumentException(
-          "FETCH node must not reference input fields or contain aggregate 
functions, "
-              + "window functions, or subqueries");
-    }
-    if (fetchNode != null
-        && !SqlTypeUtil.isNumeric(fetchNode.getType())) {
-      throw new IllegalArgumentException(
-          "FETCH node must have a numeric type; actual type is "
-              + fetchNode.getType().getFullTypeString());
-    }
+    validateOffsetFetchExpression(offsetNode, "OFFSET");
+    validateOffsetFetchExpression(fetchNode, "FETCH");
     final Registrar registrar = new Registrar(fields(), ImmutableList.of());
     final List<RelFieldCollation> fieldCollations =
         registrar.registerFieldCollations(nodes);
@@ -3890,14 +3875,31 @@ public RelBuilder sortLimit(@Nullable RexNode 
offsetNode, @Nullable RexNode fetc
     return this;
   }
 
-  private static boolean isValidFetchExpression(RexNode node) {
-    return Boolean.TRUE.equals(node.accept(new FetchExpressionVisitor()));
+  private static void validateOffsetFetchExpression(@Nullable RexNode node,
+      String kind) {
+    if (node == null) {
+      return;
+    }
+    if (!isValidOffsetFetchExpression(node)) {
+      throw new IllegalArgumentException(
+          kind + " node must not reference input fields or contain aggregate 
functions, "
+              + "window functions, or subqueries");
+    }
+    if (!SqlTypeUtil.isNumeric(node.getType())) {
+      throw new IllegalArgumentException(
+          kind + " node must have a numeric type; actual type is "
+              + node.getType().getFullTypeString());
+    }
+  }
+
+  private static boolean isValidOffsetFetchExpression(RexNode node) {
+    return Boolean.TRUE.equals(node.accept(new 
OffsetFetchExpressionVisitor()));
   }
 
-  /** Visitor that validates FETCH expressions. */
-  private static class FetchExpressionVisitor
+  /** Visitor that validates OFFSET and FETCH expressions. */
+  private static class OffsetFetchExpressionVisitor
       extends RexVisitorImpl<@Nullable Boolean> {
-    FetchExpressionVisitor() {
+    OffsetFetchExpressionVisitor() {
       super(false);
     }
 
diff --git 
a/core/src/main/resources/org/apache/calcite/runtime/CalciteResource.properties 
b/core/src/main/resources/org/apache/calcite/runtime/CalciteResource.properties
index 703536de88..636e117c7e 100644
--- 
a/core/src/main/resources/org/apache/calcite/runtime/CalciteResource.properties
+++ 
b/core/src/main/resources/org/apache/calcite/runtime/CalciteResource.properties
@@ -61,9 +61,10 @@ ValidatorContext=From line {0,number,#}, column {1,number,#} 
to line {2,number,#
 CannotCastValue=Cast function cannot convert value of type {0} to type {1}
 UnknownDatatypeName=Unknown datatype name ''{0}''
 IncompatibleValueType=Values passed to {0} operator must have compatible types
-FetchExpressionMustBeNumeric=FETCH expression must have a numeric type; actual 
type is ''{0}''
-FetchExpressionCannotReferenceColumn=FETCH expression cannot reference table 
column ''{0}''
-FetchExpressionEvaluatedToNull=FETCH expression evaluated to NULL
+OffsetFetchExpressionMustBeNumeric={0} expression must have a numeric type; 
actual type is ''{1}''
+OffsetFetchExpressionCannotReferenceColumn={0} expression cannot reference 
table column ''{1}''
+OffsetFetchExpressionEvaluatedToNull={0} expression evaluated to NULL
+OffsetFetchValueMustNotBeNegative={0} must not be negative
 IncompatibleTypesInList=Values in expression list must have compatible types
 IncompatibleCharset=Cannot apply operation ''{0}'' to strings with different 
charsets ''{1}'' and ''{2}''
 InvalidOrderByPos=ORDER BY is only allowed on top-level SELECT
diff --git 
a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java 
b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java
index 83a4f87b28..9877bd5779 100644
--- 
a/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java
+++ 
b/core/src/test/java/org/apache/calcite/rel/rel2sql/RelToSqlConverterTest.java
@@ -5060,6 +5060,20 @@ private SqlDialect nonOrdinalDialect() {
     sql(query).withMysql().ok(expected);
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testOffsetExpressionWithLimitDialect() {
+    final String query = "select \"product_id\"\n"
+        + "from \"product\"\n"
+        + "offset 1 + 2 rows";
+    final String expected = "SELECT `product_id`\n"
+        + "FROM `foodmart`.`product`\n"
+        + "LIMIT 18446744073709551615\n"
+        + "OFFSET 3";
+    sql(query).withMysql().ok(expected);
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
@@ -5074,6 +5088,20 @@ private SqlDialect nonOrdinalDialect() {
     sql(query).withSQLite().throws_(error);
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testNegativeOffsetExpressionIsRejectedBeforeSqlGeneration() {
+    final String query = "select \"product_id\"\n"
+        + "from \"product\"\n"
+        + "offset 0 - 1 rows";
+    final String error =
+        "OFFSET value -1 is out of range; expected a non-negative value";
+    sql(query).throws_(error);
+    sql(query).withMysql().throws_(error);
+    sql(query).withSQLite().throws_(error);
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
@@ -5086,6 +5114,18 @@ private SqlDialect nonOrdinalDialect() {
             + "be reduced to a literal");
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testParameterizedOffsetExpressionWithLimitDialect() {
+    final String query = "select \"product_id\"\n"
+        + "from \"product\"\n"
+        + "offset ? + 1 rows";
+    sql(query).withMysql().throws_(
+        "LIMIT dialect does not support OFFSET expressions that cannot "
+            + "be reduced to a literal");
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
@@ -5099,6 +5139,37 @@ private SqlDialect nonOrdinalDialect() {
     sql(query).withSQLite().ok(expected);
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testParameterizedOffsetExpressionWithSQLite() {
+    final String query = "select \"product_id\"\n"
+        + "from \"product\"\n"
+        + "offset ? + 1 rows";
+    final String expected = "SELECT \"product_id\"\n"
+        + "FROM \"foodmart\".\"product\"\n"
+        + "LIMIT -1\n"
+        + "OFFSET ? + 1";
+    sql(query).withSQLite().ok(expected);
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testDynamicOffsetExpressionIsNotReduced() {
+    final String query = "select \"product_id\"\n"
+        + "from \"product\"\n"
+        + "offset extract(day from current_date) rows";
+    final String expected = "SELECT \"product_id\"\n"
+        + "FROM \"foodmart\".\"product\"\n"
+        + "OFFSET EXTRACT(DAY FROM CURRENT_DATE) ROWS";
+    sql(query).ok(expected);
+    sql(query).withPostgresql().ok(expected);
+    sql(query).withMysql().throws_(
+        "LIMIT dialect does not support OFFSET expressions that cannot "
+            + "be reduced to a literal");
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
diff --git a/core/src/test/java/org/apache/calcite/rex/RexProgramTest.java 
b/core/src/test/java/org/apache/calcite/rex/RexProgramTest.java
index 5f8b8edfb1..332f1865a7 100644
--- a/core/src/test/java/org/apache/calcite/rex/RexProgramTest.java
+++ b/core/src/test/java/org/apache/calcite/rex/RexProgramTest.java
@@ -3661,6 +3661,20 @@ private void assertTypeAndToString(
         containsString("FETCH value -1.5 is out of range"));
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testValidateOffsetValueAllowsFractionalBigDecimal() {
+    assertThat(RexUtil.validateOffsetValue(new BigDecimal("1.5")),
+        is(new BigDecimal("1.5")));
+
+    final IllegalArgumentException e =
+        assertThrows(IllegalArgumentException.class,
+            () -> RexUtil.validateOffsetValue(new BigDecimal("-1.5")));
+    assertThat(e.getMessage(),
+        containsString("OFFSET value -1.5 is out of range"));
+  }
+
   @Test void testConstantMap() {
     final RelDataType intType = typeFactory.createSqlType(SqlTypeName.INTEGER);
     final RelDataType bigintType = 
typeFactory.createSqlType(SqlTypeName.BIGINT);
diff --git a/core/src/test/java/org/apache/calcite/test/JdbcTest.java 
b/core/src/test/java/org/apache/calcite/test/JdbcTest.java
index f213b12dcb..d235fa327c 100644
--- a/core/src/test/java/org/apache/calcite/test/JdbcTest.java
+++ b/core/src/test/java/org/apache/calcite/test/JdbcTest.java
@@ -3593,6 +3593,20 @@ public void checkOrderBy(final boolean desc,
             + "X=3\n");
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testOffsetExpression() {
+    final CalciteAssert.AssertThat with = CalciteAssert.that();
+    final String values = "select * from (values (1), (2), (3), (4)) as 
t(x)\n";
+    with.query(values + "offset 1 + abs(-1) rows")
+        .returns("X=3\n"
+            + "X=4\n");
+    with.query(values + "order by x desc offset 1 + abs(-1) rows")
+        .returns("X=2\n"
+            + "X=1\n");
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
@@ -3618,6 +3632,20 @@ public void checkOrderBy(final boolean desc,
     }
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testBindableOffsetExpression() {
+    try (Hook.Closeable ignored = 
Hook.ENABLE_BINDABLE.addThread(Hook.propertyJ(true))) {
+      CalciteAssert.that()
+          .query("select * from (values (1), (2), (3), (4)) as t(x)\n"
+              + "offset rand_integer(1) + 2 rows")
+          .explainContains("BindableSort(offset=[+(RAND_INTEGER(1), 2)])")
+          .returns("X=3\n"
+              + "X=4\n");
+    }
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
@@ -3632,6 +3660,18 @@ public void checkOrderBy(final boolean desc,
             + "X=2\n");
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testOffsetExpressionFunctionArguments() {
+    final CalciteAssert.AssertThat with = CalciteAssert.that();
+    final String values = "select * from (values (1), (2), (3)) as t(x)\n";
+    with.query(values + "offset abs(2) rows")
+        .returns("X=3\n");
+    with.query(values + "offset abs(-2) rows")
+        .returns("X=3\n");
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
@@ -3647,6 +3687,20 @@ public void checkOrderBy(final boolean desc,
         .throws_("FETCH expression evaluated to NULL");
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testOffsetExpressionInvalidValue() {
+    final CalciteAssert.AssertThat with = CalciteAssert.that();
+    final String values = "select * from (values (1), (2), (3)) as t(x)\n";
+    with.query(values + "offset 0 - 1 rows")
+        .throws_("OFFSET must not be negative");
+    with.query(values + "offset -1 rows")
+        .throws_("OFFSET must not be negative");
+    with.query(values + "offset cast(null as integer) rows")
+        .throws_("OFFSET expression evaluated to NULL");
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
@@ -3655,13 +3709,13 @@ public void checkOrderBy(final boolean desc,
         + "from \"hr\".\"depts\" d,\n"
         + "lateral (select \"name\" from \"hr\".\"emps\"\n"
         + "  where \"deptno\" = d.\"deptno\"\n";
-    for (String fetch : new String[] {"(0 - 1)", "(-1)"}) {
-      for (boolean topDown : new boolean[] {false, true}) {
-        CalciteAssert.hr()
-            
.with(CalciteConnectionProperty.TOPDOWN_GENERAL_DECORRELATION_ENABLED, topDown)
-            .query(sqlPrefix + "  fetch next " + fetch + " rows only) e")
-            .throws_("FETCH value -1 is out of range");
-      }
+    for (boolean topDown : new boolean[] {false, true}) {
+      final CalciteAssert.AssertThat with = CalciteAssert.hr()
+          
.with(CalciteConnectionProperty.TOPDOWN_GENERAL_DECORRELATION_ENABLED, topDown);
+      with.query(sqlPrefix + "  fetch next (0 - 1) rows only) e")
+          .throws_("FETCH value -1 is out of range");
+      with.query(sqlPrefix + "  fetch next (-1) rows only) e")
+          .throws_("FETCH must not be negative");
     }
   }
 
@@ -3681,6 +3735,9 @@ public void checkOrderBy(final boolean desc,
       with.query(sqlPrefix + "fetch next (0.5 + 1) rows only" + sqlSuffix)
           .returns("DNAME=Sales; ENAME=Bill\n"
               + "DNAME=Sales; ENAME=Theodore\n");
+      with.query(sqlPrefix + "offset 0.5 + 1 rows fetch next 1 row only"
+              + sqlSuffix)
+          .returns("DNAME=Sales; ENAME=Sebastian\n");
       with.query(sqlPrefix + "offset 1.5 rows fetch next 1 row only" + 
sqlSuffix)
           .returns("DNAME=Sales; ENAME=Sebastian\n");
     }
@@ -3690,20 +3747,24 @@ public void checkOrderBy(final boolean desc,
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
   @Test void testCorrelatedPreparedFractionalOffset() throws Exception {
-    final String sql = "select d.\"name\" as dname, e.\"name\" as ename\n"
-        + "from \"hr\".\"depts\" d,\n"
-        + "lateral (select \"empid\", \"name\" from \"hr\".\"emps\"\n"
-        + "  where \"deptno\" = d.\"deptno\"\n"
-        + "  order by \"empid\" offset ? rows fetch next 1 row only) e\n"
-        + "order by e.\"empid\"";
-    for (boolean topDown : new boolean[] {false, true}) {
-      CalciteAssert.hr()
-          
.with(CalciteConnectionProperty.TOPDOWN_GENERAL_DECORRELATION_ENABLED, topDown)
-          .doWithConnection(connection -> {
-            checkPreparedBigDecimalParameter(connection, sql,
-                new BigDecimal("1.5"),
-                "DNAME=Sales; ENAME=Sebastian\n");
-          });
+    for (String offset
+        : new String[] {"?", "cast(? as decimal(2, 1)) + 0"}) {
+      final String sql = "select d.\"name\" as dname, e.\"name\" as ename\n"
+          + "from \"hr\".\"depts\" d,\n"
+          + "lateral (select \"empid\", \"name\" from \"hr\".\"emps\"\n"
+          + "  where \"deptno\" = d.\"deptno\"\n"
+          + "  order by \"empid\" offset " + offset
+          + " rows fetch next 1 row only) e\n"
+          + "order by e.\"empid\"";
+      for (boolean topDown : new boolean[] {false, true}) {
+        CalciteAssert.hr()
+            
.with(CalciteConnectionProperty.TOPDOWN_GENERAL_DECORRELATION_ENABLED, topDown)
+            .doWithConnection(connection -> {
+              checkPreparedBigDecimalParameter(connection, sql,
+                  new BigDecimal("1.5"),
+                  "DNAME=Sales; ENAME=Sebastian\n");
+            });
+      }
     }
   }
 
@@ -3739,6 +3800,37 @@ public void checkOrderBy(final boolean desc,
     }
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testCorrelatedPreparedOffsetExpression() throws Exception {
+    for (String offset : new String[] {"?", "? + 0"}) {
+      final String sql = "select d.\"name\" as dname, e.\"name\" as ename\n"
+          + "from \"hr\".\"depts\" d,\n"
+          + "lateral (select \"empid\", \"name\" from \"hr\".\"emps\"\n"
+          + "  where \"deptno\" = d.\"deptno\"\n"
+          + "  order by \"empid\" offset " + offset + " rows) e\n"
+          + "order by e.\"empid\"";
+      for (boolean topDown : new boolean[] {false, true}) {
+        CalciteAssert.hr()
+            
.with(CalciteConnectionProperty.TOPDOWN_GENERAL_DECORRELATION_ENABLED, topDown)
+            .doWithConnection(connection -> {
+              checkPreparedFetchRepeated(connection, sql,
+                  new int[] {1, 2},
+                  new String[] {
+                      "DNAME=Sales; ENAME=Theodore\n"
+                          + "DNAME=Sales; ENAME=Sebastian\n",
+                      "DNAME=Sales; ENAME=Sebastian\n"
+                  });
+              checkPreparedParameterFails(connection, sql, -1,
+                  "OFFSET must not be negative");
+              checkPreparedParameterNullFails(connection, sql,
+                  "OFFSET expression evaluated to NULL");
+            });
+      }
+    }
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
@@ -3756,6 +3848,22 @@ public void checkOrderBy(final boolean desc,
         .returns(expected);
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testOffsetExpressionBeyondLong() {
+    final CalciteAssert.AssertThat with = CalciteAssert.that();
+    final String values = "select * from (values (1), (2), (3), (4)) as 
t(x)\n";
+    with.query(values + "offset 9223372036854775808 rows")
+        .returns("");
+    with.query(values + "offset "
+        + "cast(9223372036854775808 as decimal(20, 0)) + 1 rows")
+        .returns("");
+    with.query(values + "order by x offset "
+        + "cast(9223372036854775808 as decimal(20, 0)) + 1 rows")
+        .returns("");
+  }
+
   /** Tests ORDER BY ... OFFSET ... FETCH. */
   @Test void testOrderByOffsetFetch() {
     CalciteAssert.that()
@@ -6277,6 +6385,37 @@ private CalciteAssert.AssertQuery withEmpDept(String 
sql) {
         });
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testPreparedOffsetExpression() throws Exception {
+    CalciteAssert.that()
+        .doWithConnection(connection -> {
+          final String values =
+              "select * from (values (1), (2), (3), (4)) as t(x)\n";
+          checkPreparedFetch(connection, values + "offset ? + 1 rows",
+              1, "X=3\nX=4\n");
+          checkPreparedFetch(connection,
+              values + "order by x desc offset ? + 1 rows",
+              1, "X=2\nX=1\n");
+          checkPreparedBigDecimalParameter(connection,
+              values + "offset cast(? as decimal(2, 1)) + 0 rows",
+              new BigDecimal("1.5"), "X=3\nX=4\n");
+          checkPreparedFetch(connection,
+              values + "offset abs(cast(? as integer)) rows",
+              -2, "X=3\nX=4\n");
+          checkPreparedBigDecimalParameter(connection,
+              values + "offset cast(? as decimal(20, 0)) rows",
+              new BigDecimal("9223372036854775808"), "");
+          checkPreparedParameterFails(connection,
+              values + "offset ? + 1 rows", -2,
+              "OFFSET must not be negative");
+          checkPreparedParameterNullFails(connection,
+              values + "offset ? + 1 rows",
+              "OFFSET expression evaluated to NULL");
+        });
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
@@ -6311,6 +6450,9 @@ private CalciteAssert.AssertQuery withEmpDept(String sql) 
{
           .doWithConnection(connection -> {
             final String values =
                 "select * from (values (1), (2), (3), (4)) as t(x)\n";
+            checkPreparedFetch(connection,
+                values + "offset ? + 1 rows",
+                1, "X=3\nX=4\n");
             final String offset = values + "offset ? rows";
             checkPreparedBigDecimalParameter(connection, offset,
                 new BigDecimal("1.5"),
diff --git a/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java 
b/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java
index 7ad9a4d733..cce1aea309 100644
--- a/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java
+++ b/core/src/test/java/org/apache/calcite/test/RelBuilderTest.java
@@ -5670,6 +5670,22 @@ private static RelNode 
buildCorrelateWithJoin(JoinRelType type, RelBuilder build
             ImmutableList.of()));
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testOffsetExpressionCannotReferenceInputField() {
+    final RelBuilder builder = RelBuilder.create(config().build());
+    builder.scan("DEPT");
+    final RexNode field = builder.field("DEPTNO");
+
+    assertThrows(IllegalArgumentException.class,
+        () -> builder.sortLimit(field, null, ImmutableList.of()));
+    assertThrows(IllegalArgumentException.class,
+        () -> builder.sortLimit(
+            builder.call(SqlStdOperatorTable.PLUS, builder.literal(1), field),
+            null, ImmutableList.of()));
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
@@ -5683,6 +5699,19 @@ private static RelNode 
buildCorrelateWithJoin(JoinRelType type, RelBuilder build
         ImmutableList.of());
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testOffsetExpressionMustHaveNumericType() {
+    final RelBuilder builder = RelBuilder.create(config().build());
+    builder.scan("DEPT");
+
+    assertThrows(IllegalArgumentException.class,
+        () -> builder.sortLimit(builder.literal("x"), null, 
ImmutableList.of()));
+    builder.sortLimit(builder.literal(new BigDecimal("1.5")), null,
+        ImmutableList.of());
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
@@ -5702,6 +5731,25 @@ private static RelNode 
buildCorrelateWithJoin(JoinRelType type, RelBuilder build
         + "  LogicalTableScan(table=[[scott, DEPT]])\n"));
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testOffsetExpressionAllowsScalarCallAndDynamicParameter() {
+    final RelBuilder builder = RelBuilder.create(config().build());
+    final RelDataType intType =
+        builder.getTypeFactory().createSqlType(SqlTypeName.INTEGER);
+    builder.scan("DEPT")
+        .sortLimit(
+            builder.call(SqlStdOperatorTable.PLUS,
+                builder.getRexBuilder().makeDynamicParam(intType, 0),
+                builder.literal(1)),
+            null, ImmutableList.of());
+
+    assertThat(
+        builder.build(), hasTree("LogicalSort(offset=[+(?0, 1)])\n"
+        + "  LogicalTableScan(table=[[scott, DEPT]])\n"));
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
@@ -5732,6 +5780,36 @@ private static RelNode 
buildCorrelateWithJoin(JoinRelType type, RelBuilder build
         () -> builder.sortLimit(null, subQuery, ImmutableList.of()));
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testOffsetExpressionCannotContainAggregateWindowOrSubQuery() {
+    final RelBuilder builder = RelBuilder.create(config().build());
+    final RelDataType intType =
+        builder.getTypeFactory().createSqlType(SqlTypeName.INTEGER);
+    builder.scan("DEPT");
+    final RexNode aggregate =
+        builder.call(SqlStdOperatorTable.SUM, builder.literal(1));
+    assertThrows(IllegalArgumentException.class,
+        () -> builder.sortLimit(aggregate, null, ImmutableList.of()));
+
+    final RexNode over =
+        builder.getRexBuilder().makeOver(intType,
+            SqlStdOperatorTable.ROW_NUMBER, ImmutableList.of(),
+            ImmutableList.of(), ImmutableList.of(),
+            RexWindowBounds.UNBOUNDED_PRECEDING,
+            RexWindowBounds.UNBOUNDED_FOLLOWING,
+            true, true, false, false, false);
+    assertThrows(IllegalArgumentException.class,
+        () -> builder.sortLimit(over, null, ImmutableList.of()));
+
+    final RelBuilder subQueryBuilder = RelBuilder.create(config().build());
+    final RexNode subQuery =
+        RexSubQuery.scalar(subQueryBuilder.values(new String[] {"N"}, 
1).build());
+    assertThrows(IllegalArgumentException.class,
+        () -> builder.sortLimit(subQuery, null, ImmutableList.of()));
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7592";>[CALCITE-7592]
    * Add expression support for FETCH</a>. */
diff --git a/core/src/test/java/org/apache/calcite/test/RelMetadataTest.java 
b/core/src/test/java/org/apache/calcite/test/RelMetadataTest.java
index 460e066051..a40927c37a 100644
--- a/core/src/test/java/org/apache/calcite/test/RelMetadataTest.java
+++ b/core/src/test/java/org/apache/calcite/test/RelMetadataTest.java
@@ -1527,6 +1527,37 @@ void testColumnOriginsUnion() {
         .assertThatRowCount(is(2D), is(0D), is(2D));
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testMinRowCountOffsetExpression() {
+    final String sql = "select * from (values (1), (2)) as t(x)\n"
+        + "offset 2 - 2 rows";
+    final RelMetadataFixture fixture = sql(sql);
+    fixture.assertThatRowCount(is(2D), is(0D), is(2D));
+
+    fixture
+        .withCluster(cluster -> {
+          final RelOptPlanner planner = new VolcanoPlanner();
+          planner.addRule(EnumerableRules.ENUMERABLE_VALUES_RULE);
+          planner.addRule(EnumerableRules.ENUMERABLE_PROJECT_RULE);
+          planner.addRule(EnumerableRules.ENUMERABLE_LIMIT_RULE);
+          planner.addRelTraitDef(ConventionTraitDef.INSTANCE);
+          return RelOptCluster.create(planner, cluster.getRexBuilder());
+        })
+        .withRelTransform(rel -> {
+          final RelOptPlanner planner = rel.getCluster().getPlanner();
+          planner.setRoot(rel);
+          final RelTraitSet requiredOutputTraits =
+              
rel.getCluster().traitSet().replace(EnumerableConvention.INSTANCE);
+          final RelNode root = planner.changeTraits(rel, requiredOutputTraits);
+          planner.setRoot(root);
+          return planner.findBestExp();
+        })
+        .assertThatRel(is(instanceOf(EnumerableLimit.class)))
+        .assertThatRowCount(is(2D), is(0D), is(2D));
+  }
+
   @Test void testRowCountSortLimitOffset() {
     final String sql = "select * from emp order by ename limit 10 offset 5";
     /* 14 - 5 */
diff --git a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java 
b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
index bad13247c3..aafd9429d2 100644
--- a/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
+++ b/core/src/test/java/org/apache/calcite/test/RelOptRulesTest.java
@@ -1766,6 +1766,48 @@ private void 
checkJoinProjectTransposeDoesNotMatch(JoinRelType type) {
         .check();
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testSortUnionTransposePushesLiteralOffset() {
+    final String sql = "select a.name from dept a\n"
+        + "union all\n"
+        + "select b.name from dept b\n"
+        + "order by name offset 2 rows fetch next 3 rows only";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_SET_OP_TRANSPOSE)
+        .withRule(CoreRules.SORT_UNION_TRANSPOSE)
+        .check();
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testSortUnionTransposePushesParameterizedOffsetExpression() {
+    final String sql = "select a.name from dept a\n"
+        + "union all\n"
+        + "select b.name from dept b\n"
+        + "order by name offset ? + 1 rows fetch next 2 rows only";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_SET_OP_TRANSPOSE)
+        .withRule(CoreRules.SORT_UNION_TRANSPOSE)
+        .check();
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testSortUnionTransposeWithNonDeterministicOffset() {
+    final String sql = "select a.name from dept a\n"
+        + "union all\n"
+        + "select b.name from dept b\n"
+        + "order by name offset rand_integer(10) rows fetch next 2 rows only";
+    sql(sql)
+        .withPreRule(CoreRules.PROJECT_SET_OP_TRANSPOSE)
+        .withRule(CoreRules.SORT_UNION_TRANSPOSE)
+        .checkUnchanged();
+  }
+
   @Test void testSortRemovalAllKeysConstant() {
     final String sql = "select count(*) as c\n"
         + "from sales.emp\n"
@@ -12551,6 +12593,25 @@ private void 
checkNondeterministicFetchPreventsDecorrelation(boolean enableTopDo
         .check();
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testTopDownGeneralDecorrelateForSubqueryWithOffsetExpression() {
+    final String sql = "select empno from emp where "
+        + "sal > SOME(select sal from emp_b where emp.deptno = emp_b.deptno "
+        + "order by emp_b.sal offset 1 + 1 rows "
+        + "fetch next (1 + 1) rows only)";
+
+    sql(sql)
+        .withRule(
+            CoreRules.FILTER_SUB_QUERY_TO_MARK_CORRELATE,
+            CoreRules.PROJECT_MERGE,
+            CoreRules.PROJECT_REMOVE)
+        .withLateDecorrelate(true)
+        .withTopDownGeneralDecorrelate(true)
+        .check();
+  }
+
   @Test void testTopDownGeneralDecorrelateForSubqueryWithCube() {
     final String sql = "select empno from emp where "
         + "sal < SOME(select avg(sal) from emp_b where emp.job = emp_b.job 
group by cube(deptno))";
diff --git 
a/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java 
b/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java
index 2f4ba89194..a3cae00b94 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlToRelConverterTest.java
@@ -1253,6 +1253,15 @@ public static void checkActualAndReferenceFiles() {
     sql(sql).ok();
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testOffsetWithExpression() {
+    final String sql =
+        "select empno from emp offset 1 + abs(-2) rows";
+    sql(sql).ok();
+  }
+
   @Test void testFetch() {
     final String sql = "select empno from emp fetch next 5 rows only";
     sql(sql).ok();
diff --git a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java 
b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
index 4088b0556b..eaa4b17a4b 100644
--- a/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
+++ b/core/src/test/java/org/apache/calcite/test/SqlValidatorTest.java
@@ -10788,6 +10788,25 @@ void testGroupExpressionEquivalenceParams() {
         .fails("Windowed aggregate expression is illegal in FETCH clause");
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testOffsetExpressionType() {
+    sql("select name from dept offset ^upper('x')^ rows")
+        .fails("OFFSET expression must have a numeric type; "
+            + "actual type is 'CHAR\\(1\\) NOT NULL'");
+    sql("select name from dept offset ^'x'^ rows")
+        .fails("OFFSET expression must have a numeric type; "
+            + "actual type is 'CHAR\\(1\\) NOT NULL'");
+    sql("select name from dept offset 1.5 rows").ok();
+    sql("select name from dept offset ^deptno^ rows")
+        .fails("OFFSET expression cannot reference table column 'DEPTNO'");
+    sql("select name from dept offset ^cast(null as integer)^ rows")
+        .fails("OFFSET expression evaluated to NULL");
+    sql("select name from dept offset ^row_number() over ()^ rows")
+        .fails("Windowed aggregate expression is illegal in OFFSET clause");
+  }
+
   @Test void testRewriteWithOffsetWithoutOrderBy() {
     final String sql = "select name from dept offset 2";
     final String expected = "SELECT `NAME`\n"
@@ -10804,14 +10823,14 @@ void testGroupExpressionEquivalenceParams() {
   @Test void testNegativeFetchOffsetLimit() {
     sql("select name from dept limit ^-^1")
         .fails("(?s).*Encountered \"-\".*");
-    sql("select name from dept offset ^-^1")
-        .fails("(?s).*Encountered \"-\".*");
+    sql("select name from dept offset ^-1^")
+        .fails("OFFSET must not be negative");
     sql("select name from dept fetch next ^-^1 rows only")
         .fails("(?s).*Encountered \"-\".*");
     sql("select name from dept order by name limit ^-^1")
         .fails("(?s).*Encountered \"-\".*");
-    sql("select name from dept order by name offset ^-^1")
-        .fails("(?s).*Encountered \"-\".*");
+    sql("select name from dept order by name offset ^-1^")
+        .fails("OFFSET must not be negative");
     sql("select name from dept order by name fetch next ^-^1 rows only")
         .fails("(?s).*Encountered \"-\".*");
   }
diff --git 
a/core/src/test/java/org/apache/calcite/test/enumerable/EnumerableMergeUnionTest.java
 
b/core/src/test/java/org/apache/calcite/test/enumerable/EnumerableMergeUnionTest.java
index 44055f7074..5ca85c0777 100644
--- 
a/core/src/test/java/org/apache/calcite/test/enumerable/EnumerableMergeUnionTest.java
+++ 
b/core/src/test/java/org/apache/calcite/test/enumerable/EnumerableMergeUnionTest.java
@@ -27,6 +27,7 @@
 
 import org.junit.jupiter.api.Test;
 
+import java.math.BigDecimal;
 import java.util.function.Consumer;
 
 /**
@@ -105,7 +106,66 @@ class EnumerableMergeUnionTest {
         .explainContains("EnumerableLimit(fetch=[+(?0, 1)])\n"
             + "  EnumerableMergeUnion(all=[true])\n"
             + "    EnumerableLimitSort(sort0=[$0], dir0=[ASC], "
-            + "fetch=[+(?0, 1)])\n");
+            + "fetch=[+(?0, 1)])\n")
+        .consumesPreparedStatement(p -> p.setInt(1, 1))
+        .returnsOrdered(
+            "empid=1; name=Bill",
+            "empid=1; name=Bill");
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void mergeUnionPushesParameterizedOffsetExpression() {
+    tester(false,
+        new HrSchemaBig(),
+        "select * from (select empid, name from emps "
+            + "union all select empid, name from emps) "
+            + "order by empid offset ? + 1 rows fetch next 2 rows only")
+        .explainContains("EnumerableLimit(offset=[+(?0, 1)], fetch=[2])\n"
+            + "  EnumerableMergeUnion(all=[true])\n"
+            + "    EnumerableLimitSort(sort0=[$0], dir0=[ASC], "
+            + "fetch=[+(CEIL(+(?0, 1)), 2)])\n")
+        .consumesPreparedStatement(p -> p.setInt(1, 1))
+        .returnsOrdered(
+            "empid=2; name=Eric",
+            "empid=2; name=Eric");
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void mergeUnionRoundsOffsetAndFetchSeparatelyWhenPushingLimit() {
+    tester(false,
+        new HrSchemaBig(),
+        "select * from (select empid from emps where empid <= 3 "
+            + "union all select empid from emps where empid >= 40) "
+            + "order by empid offset ? rows fetch next ? rows only")
+        .explainContains("EnumerableLimit(offset=[?0], fetch=[?1])\n"
+            + "  EnumerableMergeUnion(all=[true])\n"
+            + "    EnumerableLimitSort(sort0=[$0], dir0=[ASC], "
+            + "fetch=[+(CEIL(?0), CEIL(?1))])\n")
+        .consumesPreparedStatement(p -> {
+          p.setBigDecimal(1, new BigDecimal("0.5"));
+          p.setBigDecimal(2, new BigDecimal("0.5"));
+        })
+        .returnsOrdered("empid=2");
+  }
+
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void mergeUnionDoesNotPushNonDeterministicOffset() {
+    tester(false,
+        new HrSchemaBig(),
+        "select * from (select empid, name from emps "
+            + "union all select empid, name from emps) "
+            + "order by empid offset rand_integer(10) rows "
+            + "fetch next 2 rows only")
+        .explainContains("EnumerableLimitSort(sort0=[$0], dir0=[ASC], "
+            + "offset=[RAND_INTEGER(10)], fetch=[2])\n"
+            + "  EnumerableMergeUnion(all=[true])\n"
+            + "    EnumerableSort(sort0=[$0], dir0=[ASC])\n");
   }
 
   @Test void mergeUnionAllOrderByName() {
diff --git 
a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml 
b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
index c623e63038..ba303c062b 100644
--- a/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
+++ b/core/src/test/resources/org/apache/calcite/test/RelOptRulesTest.xml
@@ -20106,6 +20106,36 @@ LogicalSort(sort0=[$0], dir0=[ASC], fetch=[0])
     LogicalSort(sort0=[$0], dir0=[ASC], fetch=[0])
       LogicalProject(NAME=[$1])
         LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testSortUnionTransposePushesLiteralOffset">
+    <Resource name="sql">
+      <![CDATA[select a.name from dept a
+union all
+select b.name from dept b
+order by name offset 2 rows fetch next 3 rows only]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalSort(sort0=[$0], dir0=[ASC], offset=[2], fetch=[3])
+  LogicalUnion(all=[true])
+    LogicalProject(NAME=[$1])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+    LogicalProject(NAME=[$1])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalSort(sort0=[$0], dir0=[ASC], offset=[2], fetch=[3])
+  LogicalUnion(all=[true])
+    LogicalSort(sort0=[$0], dir0=[ASC], fetch=[5])
+      LogicalProject(NAME=[$1])
+        LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+    LogicalSort(sort0=[$0], dir0=[ASC], fetch=[5])
+      LogicalProject(NAME=[$1])
+        LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
 ]]>
     </Resource>
   </TestCase>
@@ -20136,6 +20166,36 @@ LogicalSort(sort0=[$0], dir0=[ASC], fetch=[+(?0, 1)])
     LogicalSort(sort0=[$0], dir0=[ASC], fetch=[+(?0, 1)])
       LogicalProject(NAME=[$1])
         LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testSortUnionTransposePushesParameterizedOffsetExpression">
+    <Resource name="sql">
+      <![CDATA[select a.name from dept a
+union all
+select b.name from dept b
+order by name offset ? + 1 rows fetch next 2 rows only]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalSort(sort0=[$0], dir0=[ASC], offset=[+(?0, 1)], fetch=[2])
+  LogicalUnion(all=[true])
+    LogicalProject(NAME=[$1])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+    LogicalProject(NAME=[$1])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalSort(sort0=[$0], dir0=[ASC], offset=[+(?0, 1)], fetch=[2])
+  LogicalUnion(all=[true])
+    LogicalSort(sort0=[$0], dir0=[ASC], fetch=[+(CEIL(+(?0, 1)), 2)])
+      LogicalProject(NAME=[$1])
+        LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+    LogicalSort(sort0=[$0], dir0=[ASC], fetch=[+(CEIL(+(?0, 1)), 2)])
+      LogicalProject(NAME=[$1])
+        LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
 ]]>
     </Resource>
   </TestCase>
@@ -20154,6 +20214,24 @@ LogicalSort(sort0=[$0], dir0=[ASC], 
fetch=[RAND_INTEGER(10)])
       LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
     LogicalProject(NAME=[$1])
       LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testSortUnionTransposeWithNonDeterministicOffset">
+    <Resource name="sql">
+      <![CDATA[select a.name from dept a
+union all
+select b.name from dept b
+order by name offset rand_integer(10) rows fetch next 2 rows only]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalSort(sort0=[$0], dir0=[ASC], offset=[RAND_INTEGER(10)], fetch=[2])
+  LogicalUnion(all=[true])
+    LogicalProject(NAME=[$1])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
+    LogicalProject(NAME=[$1])
+      LogicalTableScan(table=[[CATALOG, SALES, DEPT]])
 ]]>
     </Resource>
   </TestCase>
@@ -21169,6 +21247,45 @@ LogicalProject(EMPNO=[$0])
         LogicalTableScan(table=[[CATALOG, SALES, EMPNULLABLES]])
         LogicalProject(EMPNO=[$0], DEPTNO=[$7])
           LogicalTableScan(table=[[CATALOG, SALES, EMP_B]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase 
name="testTopDownGeneralDecorrelateForSubqueryWithOffsetExpression">
+    <Resource name="sql">
+      <![CDATA[select empno from emp where sal > SOME(select sal from emp_b 
where emp.deptno = emp_b.deptno order by emp_b.sal offset 1 + 1 rows fetch next 
(1 + 1) rows only)]]>
+    </Resource>
+    <Resource name="planBefore">
+      <![CDATA[
+LogicalProject(EMPNO=[$0])
+  LogicalFilter(condition=[> SOME($5, {
+LogicalSort(sort0=[$0], dir0=[ASC], offset=[+(1, 1)], fetch=[+(1, 1)])
+  LogicalProject(SAL=[$5])
+    LogicalFilter(condition=[=($cor0.DEPTNO, $7)])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP_B]])
+})], variablesSet=[[$cor0]])
+    LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+]]>
+    </Resource>
+    <Resource name="planMid">
+      <![CDATA[
+LogicalProject(EMPNO=[$0])
+  LogicalFilter(condition=[$9])
+    LogicalConditionalCorrelate(correlation=[$cor0], joinType=[left_mark], 
requiredColumns=[{7}], condition=[>($5, $9)])
+      LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+      LogicalSort(sort0=[$0], dir0=[ASC], offset=[+(1, 1)], fetch=[+(1, 1)])
+        LogicalProject(SAL=[$5])
+          LogicalFilter(condition=[=($cor0.DEPTNO, $7)])
+            LogicalTableScan(table=[[CATALOG, SALES, EMP_B]])
+]]>
+    </Resource>
+    <Resource name="planAfter">
+      <![CDATA[
+LogicalProject(EMPNO=[$0])
+  LogicalJoin(condition=[AND(>($5, $9), IS NOT DISTINCT FROM($7, $10))], 
joinType=[semi])
+    LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+    LogicalFilter(condition=[AND(>($2, +(1, 1)), <=($2, +(+(1, 1), +(1, 1))))])
+      LogicalProject(SAL=[$5], DEPTNO=[$7], $f2=[ROW_NUMBER() OVER (PARTITION 
BY $7 ORDER BY $5 NULLS LAST)])
+        LogicalTableScan(table=[[CATALOG, SALES, EMP_B]])
 ]]>
     </Resource>
   </TestCase>
diff --git 
a/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml 
b/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml
index a8da739aa9..ea4e81d044 100644
--- a/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml
+++ b/core/src/test/resources/org/apache/calcite/test/SqlToRelConverterTest.xml
@@ -6451,6 +6451,18 @@ LogicalSort(offset=[?0], fetch=[?1])
 LogicalSort(offset=[?0])
   LogicalProject(EMPNO=[$0])
     LogicalTableScan(table=[[CATALOG, SALES, EMP]])
+]]>
+    </Resource>
+  </TestCase>
+  <TestCase name="testOffsetWithExpression">
+    <Resource name="sql">
+      <![CDATA[select empno from emp offset 1 + abs(-2) rows]]>
+    </Resource>
+    <Resource name="plan">
+      <![CDATA[
+LogicalSort(offset=[+(1, ABS(-2))])
+  LogicalProject(EMPNO=[$0])
+    LogicalTableScan(table=[[CATALOG, SALES, EMP]])
 ]]>
     </Resource>
   </TestCase>
diff --git a/core/src/test/resources/sql/offset.iq 
b/core/src/test/resources/sql/offset.iq
new file mode 100644
index 0000000000..3f47691321
--- /dev/null
+++ b/core/src/test/resources/sql/offset.iq
@@ -0,0 +1,186 @@
+# offset.iq
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to you under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+!use post
+!set outputformat mysql
+
+# OFFSET accepts an arithmetic expression without parentheses.
+select *
+from (values (1), (2), (3), (4)) as t(x)
+offset 1 + abs(-1) rows;
++---+
+| X |
++---+
+| 3 |
+| 4 |
++---+
+(2 rows)
+
+!ok
+
+# OFFSET also accepts a parenthesized scalar expression.
+select *
+from (values (1), (2), (3), (4)) as t(x)
+offset (abs(2)) rows;
++---+
+| X |
++---+
+| 3 |
+| 4 |
++---+
+(2 rows)
+
+!ok
+
+# OFFSET values are not restricted to the BIGINT range.
+select *
+from (values (1), (2), (3), (4)) as t(x)
+offset cast(9223372036854775808 as decimal(20, 0)) + 1 rows;
++---+
+| X |
++---+
++---+
+(0 rows)
+
+!ok
+
+# OFFSET expression cannot be negative.
+select *
+from (values (1), (2), (3)) as t(x)
+offset 0 - 1 rows;
+OFFSET must not be negative
+!error
+
+# OFFSET expression cannot evaluate to NULL.
+select *
+from (values (1), (2), (3)) as t(x)
+offset cast(null as integer) rows;
+OFFSET expression evaluated to NULL
+!error
+
+# OFFSET expression may have a fractional numeric type.
+select *
+from (values (1), (2), (3)) as t(x)
+offset 1.5 rows;
++---+
+| X |
++---+
+| 3 |
++---+
+(1 row)
+
+!ok
+
+# OFFSET expression cannot reference input columns.
+select *
+from (values (1), (2), (3)) as t(x)
+offset x rows;
+OFFSET expression cannot reference table column 'X'
+!error
+
+# Parentheses are not required around an OFFSET expression.
+select *
+from (values (1), (2), (3)) as t(x)
+offset 1 + 1 rows;
++---+
+| X |
++---+
+| 3 |
++---+
+(1 row)
+
+!ok
+
+# OFFSET expression works with a table source.
+select deptno, dname
+from dept
+order by deptno
+offset 1 + 1 rows;
++--------+-------------+
+| DEPTNO | DNAME       |
++--------+-------------+
+|     30 | Engineering |
+|     40 | Empty       |
++--------+-------------+
+(2 rows)
+
+!ok
+
+# OFFSET expression works together with FETCH on a table source.
+select deptno, dname
+from dept
+order by deptno
+offset 1 + 1 rows
+fetch next (1 + 1) rows only;
++--------+-------------+
+| DEPTNO | DNAME       |
++--------+-------------+
+|     30 | Engineering |
+|     40 | Empty       |
++--------+-------------+
+(2 rows)
+
+!ok
+
+# OFFSET expression may contain a scalar function on a table source.
+select deptno
+from dept
+order by deptno
+offset abs(-2) rows;
++--------+
+| DEPTNO |
++--------+
+|     30 |
+|     40 |
++--------+
+(2 rows)
+
+!ok
+
+# OFFSET expression cannot reference columns of a table source.
+select deptno, dname
+from dept
+order by deptno
+offset deptno rows;
+OFFSET expression cannot reference table column 'DEPTNO'
+!error
+
+# OFFSET expression cannot reference columns even inside a larger expression.
+select deptno, dname
+from dept
+order by deptno
+offset deptno + 1 rows;
+OFFSET expression cannot reference table column 'DEPTNO'
+!error
+
+# OFFSET expression may be zero on a table source.
+select deptno
+from dept
+order by deptno
+offset 2 - 2 rows;
++--------+
+| DEPTNO |
++--------+
+|     10 |
+|     20 |
+|     30 |
+|     40 |
++--------+
+(4 rows)
+
+!ok
diff --git a/server/src/test/java/org/apache/calcite/test/ServerTest.java 
b/server/src/test/java/org/apache/calcite/test/ServerTest.java
index 39d434f23a..0be7fb16ef 100644
--- a/server/src/test/java/org/apache/calcite/test/ServerTest.java
+++ b/server/src/test/java/org/apache/calcite/test/ServerTest.java
@@ -489,6 +489,31 @@ static Connection connect() throws SQLException {
     }
   }
 
+  /** Test case for
+   * <a 
href="https://issues.apache.org/jira/browse/CALCITE-7662";>[CALCITE-7662]
+   * Add expression support for OFFSET</a>. */
+  @Test void testOffsetExpressionCannotReferenceInputColumn() throws Exception 
{
+    try (Connection c = connect();
+         Statement s = c.createStatement()) {
+      s.execute("create table person (id int not null, name varchar(20))");
+      try (PreparedStatement p =
+               c.prepareStatement("insert into person (id, name) values (?, 
?)")) {
+        p.setInt(1, 1);
+        p.setString(2, "foo");
+        assertThat(p.executeUpdate(), is(1));
+      }
+
+      for (String offset : new String[] {"id", "(id)", "1 + id"}) {
+        final SQLException e =
+            assertThrows(
+                SQLException.class, () -> s.executeQuery("select * from person 
"
+                + "offset " + offset + " rows"));
+        assertThat(e.getMessage(),
+            containsString("OFFSET expression cannot reference table column 
'ID'"));
+      }
+    }
+  }
+
   /** Test case for
    * <a 
href="https://issues.apache.org/jira/browse/CALCITE-6022";>[CALCITE-6022]
    * Support "CREATE TABLE ... LIKE" DDL in server module</a>. */
diff --git a/site/_docs/reference.md b/site/_docs/reference.md
index fda50d5bfc..befae3b624 100644
--- a/site/_docs/reference.md
+++ b/site/_docs/reference.md
@@ -193,8 +193,8 @@ ## Grammar
       }
       [ ORDER BY { ALL [ ASC | DESC ] [ NULLS FIRST | NULLS LAST ] | orderItem 
[, orderItem]* } ]
       [ LIMIT [ start, ] { count | ALL } ]
-      [ OFFSET start { ROW | ROWS } ]
-      [ FETCH { FIRST | NEXT } [ count ] { ROW | ROWS } ONLY ]
+      [ OFFSET { start | expression } { ROW | ROWS } ]
+      [ FETCH { FIRST | NEXT } [ count | '(' expression ')' ] { ROW | ROWS } 
ONLY ]
 
 withItem:
       name
@@ -215,7 +215,7 @@ ## Grammar
       [ WINDOW windowName AS windowSpec [, windowName AS windowSpec ]* ]
       [ QUALIFY booleanExpression ]
       [ ORDER BY orderItem [, orderItem ]* ]
-      [ LIMIT expression [ OFFSET expression ] ]
+      [ LIMIT expression [ OFFSET { start | expression } ] ]
 
 The optional, non-standard `BY` clause groups and orders the query by
 the specified expressions, and automatically adds them to the SELECT list
@@ -429,11 +429,13 @@ ## Grammar
 
 In *query*, *start* may be either an unsigned numeric literal or a dynamic
 parameter whose value is numeric. The *count* in a LIMIT clause may be either
-an unsigned numeric literal or a dynamic parameter whose value is numeric. The
-*count* in a FETCH clause may be an unsigned numeric literal, a dynamic
-parameter whose value is numeric, or a scalar expression enclosed in
-parentheses. A FETCH *count* expression cannot reference columns from the query
-input, and cannot contain aggregate functions, window functions, or 
sub-queries.
+an unsigned numeric literal or a dynamic parameter whose value is numeric. An
+OFFSET clause may contain an unsigned numeric literal, a dynamic parameter 
whose
+value is numeric, or a scalar expression with optional parentheses. The *count*
+in a FETCH clause may also be a scalar expression, but it must be enclosed in
+parentheses. An OFFSET expression or FETCH *count* expression cannot reference
+columns from the query input, and cannot contain aggregate functions, window
+functions, or sub-queries.
 Support for decimal or non-integer values is adapter-dependent.
 
 An aggregate query is a query that contains a GROUP BY or a HAVING
diff --git 
a/testkit/src/main/java/org/apache/calcite/sql/parser/SqlParserTest.java 
b/testkit/src/main/java/org/apache/calcite/sql/parser/SqlParserTest.java
index 3fbe5af9be..5e4271e4c8 100644
--- a/testkit/src/main/java/org/apache/calcite/sql/parser/SqlParserTest.java
+++ b/testkit/src/main/java/org/apache/calcite/sql/parser/SqlParserTest.java
@@ -4104,7 +4104,16 @@ void checkPeriodPredicate(Checker checker) {
             + "FROM `FOO`\n"
             + "OFFSET ? ROWS\n"
             + "FETCH NEXT ? ROWS ONLY");
-    // CALCITE-7592: Arithmetic and scalar expressions are allowed within 
parentheses.
+    // Arithmetic and scalar expressions are allowed within parentheses.
+    sql("select a from foo offset 1 + abs(-2) rows")
+        .ok("SELECT `A`\n"
+            + "FROM `FOO`\n"
+            + "OFFSET 1 + ABS(-2) ROWS");
+    // Parentheses remain optional in OFFSET.
+    sql("select a from foo offset (1 + abs(-2)) rows")
+        .ok("SELECT `A`\n"
+            + "FROM `FOO`\n"
+            + "OFFSET 1 + ABS(-2) ROWS");
     sql("select a from foo fetch next (1 + abs(-2)) rows only")
         .ok("SELECT `A`\n"
             + "FROM `FOO`\n"
@@ -4120,7 +4129,9 @@ void checkPeriodPredicate(Checker checker) {
     // FETCH before OFFSET is illegal
     sql("select a from foo fetch next 3 rows only ^offset^ 1")
         .fails("(?s).*Encountered \"offset\" at .*");
-    // Subqueries are not allowed in FETCH
+    // Subqueries are not allowed in OFFSET or FETCH
+    sql("select a from foo offset ^(^select 2) rows")
+        .fails("Query expression encountered in illegal context");
     sql("select a from foo fetch next ^select^ 2 rows only")
         .fails("(?s).*Encountered \"select\" at .*");
     sql("select a from foo fetch next (^select^ 2) rows only")
@@ -4137,6 +4148,12 @@ void checkPeriodPredicate(Checker checker) {
    * SQL:2008.
    */
   @Test void testLimit() {
+    sql("select a from foo order by b, c limit 2 offset 1 + abs(-2)")
+        .ok("SELECT `A`\n"
+            + "FROM `FOO`\n"
+            + "ORDER BY `B`, `C`\n"
+            + "OFFSET 1 + ABS(-2) ROWS\n"
+            + "FETCH NEXT 2 ROWS ONLY");
     sql("select a from foo order by b, c limit 2 offset 1")
         .ok("SELECT `A`\n"
             + "FROM `FOO`\n"

Reply via email to