diff --git a/isthmus/src/main/java/io/substrait/isthmus/SubstraitTypeSystem.java b/isthmus/src/main/java/io/substrait/isthmus/SubstraitTypeSystem.java index 0949584b0..849be1526 100644 --- a/isthmus/src/main/java/io/substrait/isthmus/SubstraitTypeSystem.java +++ b/isthmus/src/main/java/io/substrait/isthmus/SubstraitTypeSystem.java @@ -81,12 +81,31 @@ public static void requireSupportedPrecision( /** * Returns the maximum precision for the given SQL type. * + *

For the three types that carry a length across the Substrait boundary — {@link + * SqlTypeName#CHAR}, {@link SqlTypeName#VARCHAR} and {@link SqlTypeName#BINARY}, holding {@code + * fixedchar}, {@code varchar} and {@code fixedbinary} — this is Substrait's own limit: those + * lengths are 32-bit integers. Calcite's default of 65536 is narrower, and the type factory caps + * a converted type at it rather than reporting that it cannot represent the declared width. + * + *

{@link SqlTypeName#VARBINARY} is raised with them even though Substrait's {@code binary} + * carries no length of its own, because the cap bites inside Calcite's own type unification: + * {@link #shouldConvertRaggedUnionTypesToVarying()} is true here, so a union of fixed-width + * binaries of different widths is unified as a {@code VARBINARY} of the widest. Left at 65536, + * the least restrictive type of {@code BINARY(100000)} and {@code BINARY(5)} is {@code + * VARBINARY(65536)} -- narrower than one of its own inputs, and the cap this method removes + * reimposed. + * * @param typeName The {@link SqlTypeName} for which precision is requested. * @return Maximum precision for the type. */ @Override public int getMaxPrecision(final SqlTypeName typeName) { switch (typeName) { + case CHAR: + case VARCHAR: + case BINARY: + case VARBINARY: + return Integer.MAX_VALUE; case INTERVAL_DAY: case INTERVAL_YEAR: case INTERVAL_YEAR_MONTH: diff --git a/isthmus/src/main/java/io/substrait/isthmus/TypeConverter.java b/isthmus/src/main/java/io/substrait/isthmus/TypeConverter.java index 754387694..fb2779bd2 100644 --- a/isthmus/src/main/java/io/substrait/isthmus/TypeConverter.java +++ b/isthmus/src/main/java/io/substrait/isthmus/TypeConverter.java @@ -31,6 +31,9 @@ */ public class TypeConverter { + /** The widest precision the spec gives a decimal: {@code DECIMAL} puts P at 38 or less. */ + private static final int MAX_DECIMAL_PRECISION = 38; + private final UserTypeMapper userTypeMapper; /** @@ -143,7 +146,7 @@ private Type toSubstrait(RelDataType type, List names) { return creator.FP64; case DECIMAL: { - if (type.getPrecision() > 38) { + if (type.getPrecision() > MAX_DECIMAL_PRECISION) { throw new UnsupportedOperationException( "unsupported decimal precision " + type.getPrecision()); } @@ -226,6 +229,8 @@ private Type toSubstrait(RelDataType type, List names) { * @return Calcite relational type. * @throws UnsupportedOperationException if the expression contains unsupported precision or * user-defined types cannot be mapped. + * @throws IllegalArgumentException if a declared length or precision is negative, or the given + * factory cannot hold it. */ public RelDataType toCalcite( RelDataTypeFactory relDataTypeFactory, TypeExpression typeExpression) { @@ -242,6 +247,8 @@ public RelDataType toCalcite( * @return Calcite relational type. * @throws UnsupportedOperationException if the expression contains unsupported precision or * user-defined types cannot be mapped. + * @throws IllegalArgumentException if a declared length or precision is negative, or the given + * factory cannot hold it. */ public RelDataType toCalcite( RelDataTypeFactory relDataTypeFactory, @@ -376,21 +383,88 @@ public RelDataType visit(Type.IntervalDay expr) { @Override public RelDataType visit(Type.FixedChar expr) { - return t(n(expr), SqlTypeName.CHAR, expr.length()); + return withLength(n(expr), SqlTypeName.CHAR, expr.length()); } @Override public RelDataType visit(Type.VarChar expr) { - return t(n(expr), SqlTypeName.VARCHAR, expr.length()); + return withLength(n(expr), SqlTypeName.VARCHAR, expr.length()); } @Override public RelDataType visit(Type.FixedBinary expr) { - return t(n(expr), SqlTypeName.BINARY, expr.length()); + return withLength(n(expr), SqlTypeName.BINARY, expr.length()); + } + + /** + * Returns the type the given factory builds for a declared length, having checked that it holds + * it. A factory caps a width at its type system's maximum without saying so, and this + * conversion takes whatever factory it is handed, so a factory whose limits are not Substrait's + * would otherwise return a type narrower than the plan declares. + * + *

A negative length is refused before the factory is asked, because asking tells us nothing: + * with assertions off the factory stores the negative and reports it back, so the width below + * certifies itself, and with them on Calcite raises a bare {@code AssertionError} in place of + * this message. A length of -1 is Calcite's unspecified precision besides, so the factory + * answers with an unparameterised type whose precision equals what was asked for. A zero length + * is left to the factory: the spec puts a fixedchar's width at 1 or more, but Calcite types the + * empty character literal as a {@code CHAR(0)}, so plans carrying one exist. + * + * @param nullable whether the type is nullable + * @param typeName the Calcite type name to build + * @param length the declared length + * @return the built type + * @throws IllegalArgumentException if the length is negative, or the factory built a type of + * another length + */ + private RelDataType withLength(boolean nullable, SqlTypeName typeName, int length) { + if (length < 0) { + throw new IllegalArgumentException( + String.format( + "A %s cannot declare a negative length, and this one is %d", typeName, length)); + } + RelDataType type = t(nullable, typeName, length); + if (type.getPrecision() != length) { + throw new IllegalArgumentException( + String.format( + "The type factory cannot hold %s(%d), which it narrowed to %s; its type system" + + " allows up to %d", + typeName, length, type, typeFactory.getTypeSystem().getMaxPrecision(typeName))); + } + return type; } @Override public RelDataType visit(Type.Decimal expr) { + // Before the factory, for the reason the lengths are: -1 is Calcite's unspecified precision, + // so a negative one is answered with an unparameterised DECIMAL whose precision reads back as + // the type system's maximum. A zero or negative scale Calcite reports itself. + if (expr.precision() < 0) { + throw new IllegalArgumentException( + String.format( + "A decimal cannot declare a negative precision, and this one is %d", + expr.precision())); + } + // The spec's own ceiling, not just the factory's: handed a type system whose DECIMAL maximum + // is above it, the factory builds the type and the outbound conversion above then refuses it, + // so the type would convert in and have no way back. + if (expr.precision() > MAX_DECIMAL_PRECISION) { + throw new IllegalArgumentException( + String.format( + "A decimal cannot declare a precision of %d, above the %d the spec allows", + expr.precision(), MAX_DECIMAL_PRECISION)); + } + SubstraitTypeSystem.requireSupportedPrecision( + typeFactory.getTypeSystem(), SqlTypeName.DECIMAL, "decimal", expr.precision()); + // The spec puts a decimal's scale in [0..P]. No factory reports a scale above the precision: + // Calcite builds the type as asked, and its own maximum cannot catch it either, since both + // type systems here set maxScale equal to maxPrecision. + if (expr.scale() > expr.precision()) { + throw new IllegalArgumentException( + String.format( + "A decimal cannot declare a scale of %d above its precision of %d", + expr.scale(), expr.precision())); + } return t(n(expr), SqlTypeName.DECIMAL, expr.precision(), expr.scale()); } diff --git a/isthmus/src/test/java/io/substrait/isthmus/CalciteTypeTest.java b/isthmus/src/test/java/io/substrait/isthmus/CalciteTypeTest.java index 50199128f..167375636 100644 --- a/isthmus/src/test/java/io/substrait/isthmus/CalciteTypeTest.java +++ b/isthmus/src/test/java/io/substrait/isthmus/CalciteTypeTest.java @@ -15,6 +15,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; class CalciteTypeTest extends CalciteObjs { @@ -220,6 +221,44 @@ void aJavaCharColumnTakesCalcitesDefaultWidth() { TypeConverter.DEFAULT.toSubstrait(javaTypeFactory.createJavaType(Character.class))); } + /** + * A width above Calcite's default 65536 cap, for each type whose length crosses the Substrait + * boundary. The expected precision is asserted directly rather than through {@link #testType}, + * whose expectation is built with the same type factory and would be narrowed alongside the value + * under test. + */ + @ParameterizedTest + @CsvSource({ + "CHAR, 65537", + "CHAR, 2147483647", + "VARCHAR, 65537", + "VARCHAR, 2147483647", + "BINARY, 65537", + "BINARY, 2147483647" + }) + void wideLengthCarryingTypesKeepTheirLength(SqlTypeName typeName, int length) { + TypeExpression substrait; + switch (typeName) { + case CHAR: + substrait = TypeCreator.REQUIRED.fixedChar(length); + break; + case VARCHAR: + substrait = TypeCreator.REQUIRED.varChar(length); + break; + case BINARY: + substrait = TypeCreator.REQUIRED.fixedBinary(length); + break; + default: + throw new IllegalArgumentException("no Substrait type mapped for " + typeName); + } + + RelDataType calcite = TypeConverter.DEFAULT.toCalcite(type, substrait, null); + + assertEquals(typeName, calcite.getSqlTypeName()); + assertEquals(length, calcite.getPrecision()); + assertEquals(substrait, TypeConverter.DEFAULT.toSubstrait(calcite)); + } + @ParameterizedTest @ValueSource(booleans = {true, false}) void decimal(boolean nullable) { diff --git a/isthmus/src/test/java/io/substrait/isthmus/SubstraitTypeSystemTest.java b/isthmus/src/test/java/io/substrait/isthmus/SubstraitTypeSystemTest.java index 01e7bd0c7..03dd4c77f 100644 --- a/isthmus/src/test/java/io/substrait/isthmus/SubstraitTypeSystemTest.java +++ b/isthmus/src/test/java/io/substrait/isthmus/SubstraitTypeSystemTest.java @@ -1,10 +1,21 @@ package io.substrait.isthmus; import static io.substrait.isthmus.SubstraitTypeSystem.TYPE_FACTORY; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import io.substrait.isthmus.sql.SubstraitCreateStatementParser; +import io.substrait.plan.Plan; +import io.substrait.type.TypeCreator; +import java.util.List; +import org.apache.calcite.prepare.CalciteCatalogReader; import org.apache.calcite.rel.type.RelDataType; +import org.apache.calcite.rel.type.RelDataTypeFactory; import org.apache.calcite.rel.type.RelDataTypeSystem; +import org.apache.calcite.rel.type.RelDataTypeSystemImpl; +import org.apache.calcite.sql.type.SqlTypeFactoryImpl; import org.apache.calcite.sql.type.SqlTypeName; import org.junit.jupiter.api.Test; @@ -42,6 +53,114 @@ void timeMaxPrecision() { assertEquals(6, typeSystem.getMaxPrecision(SqlTypeName.TIME)); } + @Test + void lengthCarryingTypesMaxPrecisionIsSubstraitsOwnLimit() { + assertEquals(Integer.MAX_VALUE, typeSystem.getMaxPrecision(SqlTypeName.VARCHAR)); + assertEquals(Integer.MAX_VALUE, typeSystem.getMaxPrecision(SqlTypeName.CHAR)); + assertEquals(Integer.MAX_VALUE, typeSystem.getMaxPrecision(SqlTypeName.BINARY)); + // Substrait's binary carries no length of its own, but Calcite unifies a ragged binary union + // through this type, so a cap here caps the union. + assertEquals(Integer.MAX_VALUE, typeSystem.getMaxPrecision(SqlTypeName.VARBINARY)); + } + + /** + * Calcite's default caps a character type at 65536, which is narrower than the {@code int} length + * Substrait declares, so a wider converted type would be silently narrowed by the type factory. + */ + @Test + void lengthCarryingTypesMaxPrecisionDiffersFromDefaultTypeSystem() { + assertEquals(65536, RelDataTypeSystem.DEFAULT.getMaxPrecision(SqlTypeName.VARCHAR)); + assertEquals(65536, RelDataTypeSystem.DEFAULT.getMaxPrecision(SqlTypeName.CHAR)); + assertEquals(65536, RelDataTypeSystem.DEFAULT.getMaxPrecision(SqlTypeName.BINARY)); + } + + @Test + void canCreateCharacterTypesWiderThanTheCalciteDefault() { + assertEquals(100_000, TYPE_FACTORY.createSqlType(SqlTypeName.VARCHAR, 100_000).getPrecision()); + assertEquals(100_000, TYPE_FACTORY.createSqlType(SqlTypeName.CHAR, 100_000).getPrecision()); + assertEquals(100_000, TYPE_FACTORY.createSqlType(SqlTypeName.BINARY, 100_000).getPrecision()); + } + + /** + * The conversion takes whatever type factory it is handed, and one built on Calcite's default + * type system cannot hold these widths. Narrowing them is what this fix is about, so a factory + * that would narrow is reported rather than followed. + */ + @Test + void aFactoryThatCannotHoldTheDeclaredLengthIsReported() { + RelDataTypeFactory defaultFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + TypeConverter.DEFAULT.toCalcite( + defaultFactory, TypeCreator.REQUIRED.varChar(100_000), null)); + assertTrue(e.getMessage().contains("allows up to 65536"), e.getMessage()); + + assertEquals( + 100_000, + TypeConverter.DEFAULT + .toCalcite(TYPE_FACTORY, TypeCreator.REQUIRED.varChar(100_000), null) + .getPrecision()); + } + + /** + * Asking the factory first cannot tell a negative width from a width it holds: with assertions + * off it stores the negative and reports it back, and -1 is its unspecified precision besides, so + * the answer equals what was asked for either way. + */ + @Test + void aNegativeLengthIsRefusedBeforeTheFactoryIsAsked() { + assertAll( + () -> { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + TypeConverter.DEFAULT.toCalcite( + TYPE_FACTORY, TypeCreator.REQUIRED.varChar(-5), null)); + assertTrue( + e.getMessage().contains("negative length, and this one is -5"), e.getMessage()); + }, + () -> { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + TypeConverter.DEFAULT.toCalcite( + TYPE_FACTORY, TypeCreator.REQUIRED.fixedChar(-1), null)); + assertTrue( + e.getMessage().contains("negative length, and this one is -1"), e.getMessage()); + }, + () -> { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + TypeConverter.DEFAULT.toCalcite( + TYPE_FACTORY, TypeCreator.REQUIRED.fixedBinary(-5), null)); + assertTrue( + e.getMessage().contains("negative length, and this one is -5"), e.getMessage()); + }); + } + + /** + * A union of fixed-width binaries of different widths is unified as a VARBINARY, so leaving that + * type at Calcite's default would reimpose the cap the wide types are raised past -- on a type + * wider than one of the union's own inputs. + */ + @Test + void aRaggedBinaryUnionKeepsTheWidestWidth() { + RelDataType wide = TYPE_FACTORY.createSqlType(SqlTypeName.BINARY, 100_000); + RelDataType narrow = TYPE_FACTORY.createSqlType(SqlTypeName.BINARY, 5); + + RelDataType unified = TYPE_FACTORY.leastRestrictive(List.of(wide, narrow)); + + assertEquals(SqlTypeName.VARBINARY, unified.getSqlTypeName()); + assertEquals(100_000, unified.getPrecision()); + } + @Test void canCreateDecimalWithMaxPrecision() { RelDataType decimalType = TYPE_FACTORY.createSqlType(SqlTypeName.DECIMAL, 38, 10); @@ -60,4 +179,134 @@ void decimalMaxPrecisionAndScaleDifferentFromDefaultTypeSystem() { assertEquals(38, typeSystem.getMaxPrecision(SqlTypeName.DECIMAL)); assertEquals(38, typeSystem.getMaxScale(SqlTypeName.DECIMAL)); } + + /** + * A type system whose DECIMAL maximum is above the spec's 38 would let a wider decimal through + * the factory, and {@code toSubstrait} refuses it on the way back -- so the type would convert in + * with no way out. The bound is the spec's rather than the factory's for that reason. + */ + @Test + void aDecimalPrecisionAboveTheSpecsCeilingIsRefused() { + RelDataTypeFactory wideFactory = + new SqlTypeFactoryImpl( + new RelDataTypeSystemImpl() { + @Override + public int getMaxPrecision(SqlTypeName typeName) { + return typeName == SqlTypeName.DECIMAL ? 76 : super.getMaxPrecision(typeName); + } + + @Override + public int getMaxScale(SqlTypeName typeName) { + return typeName == SqlTypeName.DECIMAL ? 76 : super.getMaxScale(typeName); + } + }); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + TypeConverter.DEFAULT.toCalcite( + wideFactory, TypeCreator.REQUIRED.decimal(45, 2), null)); + + assertTrue(e.getMessage().contains("above the 38 the spec allows"), e.getMessage()); + } + + /** + * Two ends the factory does not report. A precision of -1 is Calcite's unspecified precision, so + * it answers with an unparameterised DECIMAL whose precision reads back as the type system's + * maximum. A scale above the precision is outside the spec's {@code 0 <= S <= P} and Calcite + * builds it anyway, where its own maximum would not catch it: both type systems here set {@code + * maxScale} equal to {@code maxPrecision}, so a scale within the precision is within that too. + * Calcite reports a zero or negative scale and a zero precision itself. + */ + @Test + void aDecimalParameterOutsideItsDeclaredBoundsIsRefused() { + assertAll( + () -> { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + TypeConverter.DEFAULT.toCalcite( + TYPE_FACTORY, TypeCreator.REQUIRED.decimal(-1, 0), null)); + assertTrue( + e.getMessage().contains("negative precision, and this one is -1"), e.getMessage()); + }, + () -> { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + TypeConverter.DEFAULT.toCalcite( + TYPE_FACTORY, TypeCreator.REQUIRED.decimal(19, 25), null)); + assertTrue(e.getMessage().contains("scale of 25 above its precision"), e.getMessage()); + }, + () -> + assertEquals( + 19, + TypeConverter.DEFAULT + .toCalcite(TYPE_FACTORY, TypeCreator.REQUIRED.decimal(19, 19), null) + .getScale())); + } + + /** + * The raised maximum is also Calcite's overflow threshold for concatenation, in {@code + * ReturnTypes.DYADIC_STRING_SUM_PRECISION}: a sum of widths past it falls back to an + * unparameterised type. So two columns nowhere near the old cap decide the result type between + * them, and the conversion emitted a {@code string} for them before. + */ + @Test + void concatenatingTwoVarcharsKeepsTheSumOfTheirWidths() throws Exception { + CalciteCatalogReader catalog = + SubstraitCreateStatementParser.processCreateStatementsToCatalog( + "CREATE TABLE t (a VARCHAR(40000), b VARCHAR(40000))"); + + Plan plan = new SqlToSubstrait().convert("SELECT a || b FROM t", catalog); + + assertEquals( + List.of(TypeCreator.NULLABLE.varChar(80000)), + plan.getRoots().get(0).getInput().getRecordType().fields()); + } + + /** + * A decimal loses its precision to a foreign factory the same silent way a length does: Calcite's + * default type system caps it at 19, and the narrowed type reads back as one the plan never + * declared. + */ + @Test + void aFactoryThatCannotHoldTheDeclaredPrecisionIsReported() { + RelDataTypeFactory defaultFactory = new SqlTypeFactoryImpl(RelDataTypeSystem.DEFAULT); + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + TypeConverter.DEFAULT.toCalcite( + defaultFactory, TypeCreator.REQUIRED.decimal(38, 10), null)); + assertTrue(e.getMessage().contains("is set to 19"), e.getMessage()); + + assertEquals( + 38, + TypeConverter.DEFAULT + .toCalcite(TYPE_FACTORY, TypeCreator.REQUIRED.decimal(38, 10), null) + .getPrecision()); + } + + /** + * The cap reaches the conversion from SQL as well. Calcite narrows a declared width to its + * maximum silently rather than reporting that it cannot hold it, so before this a cast wider than + * the default came out of the conversion as a {@code varchar<65536>}. + */ + @Test + void aWideVarcharDeclaredInSqlKeepsItsLength() throws Exception { + CalciteCatalogReader catalog = + SubstraitCreateStatementParser.processCreateStatementsToCatalog( + "CREATE TABLE t (a VARCHAR(10))"); + + Plan plan = new SqlToSubstrait().convert("SELECT CAST(a AS VARCHAR(100000)) FROM t", catalog); + + assertEquals( + List.of(TypeCreator.NULLABLE.varChar(100000)), + plan.getRoots().get(0).getInput().getRecordType().fields()); + } }