From 400c948cbac502c6c821c7ea2f382c1733903a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Thu, 1 Oct 2026 20:32:34 +0300 Subject: [PATCH 1/3] refactor(schema): extract declared column type mapping --- .../schema/parser/ColumnTypeMapping.java | 204 ++++++++++++++++++ .../schema/parser/DefaultSchemaParser.java | 160 ++------------ 2 files changed, 220 insertions(+), 144 deletions(-) create mode 100644 sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/ColumnTypeMapping.java diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/ColumnTypeMapping.java b/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/ColumnTypeMapping.java new file mode 100644 index 0000000..e67fadc --- /dev/null +++ b/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/ColumnTypeMapping.java @@ -0,0 +1,204 @@ +package dev.sqlcj.schema.parser; + +import dev.sqlcj.schema.ColumnType; +import dev.sqlcj.schema.EnumType; +import net.sf.jsqlparser.schema.MultiPartName; +import net.sf.jsqlparser.statement.create.table.ColDataType; + +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Maps one declared SQL type to the type sqlcj models, against the enum types + * a schema declares. + * + *

A schema column states its type in a column definition and a query states + * it in a cast, so both resolve their type here and report an unmapped type + * with the same text. + */ +public final class ColumnTypeMapping { + + /** One parenthesized type argument group, such as {@code (10, 2)}. */ + private static final Pattern TYPE_ARGUMENTS = Pattern.compile("\\([^)]*\\)"); + + /** + * One declared SQL type and the type sqlcj maps it to. + * + * @param type the mapped type, and {@code null} for a declared + * type sqlcj does not map + * @param enumType the schema's declared name of the enum type the + * declaration names when {@code type} is + * {@link ColumnType#ENUM}, and {@code null} + * otherwise + * @param array whether the declaration is a one-dimensional + * array of {@code type} + * @param blankPadded whether the declared spelling is the blank-padded + * character type + * @param unsupportedType the canonical declared type text of a type sqlcj + * does not map, including its array dimensions, and + * {@code null} for a mapped type + * @param typeName the canonical spelling of the declared element + * type, without its array dimensions + */ + public record MappedType( + ColumnType type, + String enumType, + boolean array, + boolean blankPadded, + String unsupportedType, + String typeName + ) { + + /** Reports whether sqlcj maps the declared type. */ + public boolean mapped() { + return type != null; + } + } + + /** + * Maps one declared SQL type, recording a type sqlcj cannot map with its + * declared type text instead of failing. A declaration of exactly one array + * dimension is an array of its declared element type; a declaration of more + * dimensions, and an array whose element type has no array mapping, is + * recorded like any other unmapped type. + */ + public MappedType map(ColDataType declaredType, List enums) { + String typeName = typeName(declaredType); + int arrayDimensions = arrayDimensions(declaredType); + boolean array = arrayDimensions == 1; + + if (arrayDimensions <= 1) { + ColumnType type = columnType(typeName); + + if (type != null) { + if (!array || isArrayElementType(type)) { + return new MappedType( + type, + null, + array, + isBlankPadded(typeName), + null, + typeName + ); + } + } else { + EnumType enumType = declaredEnum(declaredType, enums); + + if (enumType != null) { + return new MappedType( + ColumnType.ENUM, + enumType.name(), + array, + false, + null, + typeName + ); + } + } + } + + return new MappedType( + null, + null, + false, + false, + typeName + "[]".repeat(arrayDimensions), + typeName + ); + } + + /** + * Reports whether a declared spelling is the blank-padded character type, + * which PostgreSQL names {@code bpchar} and maps like {@code VARCHAR}. The + * distinction is kept because the two names are not interchangeable where + * PostgreSQL resolves an array type from its element's name. + */ + private boolean isBlankPadded(String typeName) { + return switch (typeName) { + case "CHAR", "CHARACTER" -> true; + default -> false; + }; + } + + /** + * Reports whether an array of a mapped type is mapped as well. + * {@code BYTEA}, {@code JSON}, and {@code JSONB} arrays are recorded with + * their declared type instead, because their elements are bound and read as + * text or bytes rather than as a value of a mapped element type. + */ + private boolean isArrayElementType(ColumnType type) { + return type != ColumnType.BYTEA + && type != ColumnType.JSON + && type != ColumnType.JSONB; + } + + /** + * The enum type an unmapped declared type names, or {@code null} when the + * schema declares no such type. The declared type is matched without its + * SQL identifier delimiters and case-insensitively, as PostgreSQL resolves + * an unquoted type name. + */ + private EnumType declaredEnum(ColDataType declaredType, List enums) { + String typeName = MultiPartName.unquote(declaredType.getDataType()); + + return enums.stream() + .filter(enumType -> enumType.name().equalsIgnoreCase(typeName)) + .findFirst() + .orElse(null); + } + + /** + * Reports how many array dimensions a declaration states. The parser + * reports an array's dimensions separately from its element type, so a + * declaration with any dimension is an array of the reported type. + */ + private int arrayDimensions(ColDataType declaredType) { + List arrayData = declaredType.getArrayData(); + + return arrayData == null + ? 0 + : arrayData.size(); + } + + /** + * Returns the canonical spelling of a declared SQL type: upper case, + * without type arguments such as a length or a precision, and with single + * spaces between the remaining words. + */ + private String typeName(ColDataType declaredType) { + String dataType = declaredType + .getDataType() + .toUpperCase(Locale.ROOT); + + return TYPE_ARGUMENTS + .matcher(dataType) + .replaceAll(" ") + .replaceAll("\\s+", " ") + .trim(); + } + + /** The mapped type of declared spelling, or {@code null} if unmapped. */ + private ColumnType columnType(String typeName) { + return switch (typeName) { + case "INTEGER", "INT", "INT4", "SERIAL", "SERIAL4" -> ColumnType.INTEGER; + case "BIGINT", "INT8", "BIGSERIAL", "SERIAL8" -> ColumnType.BIGINT; + case "SMALLINT", "INT2", "SMALLSERIAL", "SERIAL2" -> ColumnType.SMALLINT; + case "BOOLEAN", "BOOL" -> ColumnType.BOOLEAN; + case "VARCHAR", "CHARACTER VARYING", "CHAR", "CHARACTER" -> ColumnType.VARCHAR; + case "TEXT" -> ColumnType.TEXT; + case "DATE" -> ColumnType.DATE; + case "TIME", "TIME WITHOUT TIME ZONE" -> ColumnType.TIME; + case "TIMESTAMP", "TIMESTAMP WITHOUT TIME ZONE" -> ColumnType.TIMESTAMP; + case "TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ" -> ColumnType.TIMESTAMP_WITH_TIME_ZONE; + case "DECIMAL", "NUMERIC" -> ColumnType.DECIMAL; + case "REAL", "FLOAT4" -> ColumnType.REAL; + case "DOUBLE PRECISION", "FLOAT8" -> ColumnType.DOUBLE_PRECISION; + case "UUID" -> ColumnType.UUID; + case "BYTEA" -> ColumnType.BYTEA; + case "JSON" -> ColumnType.JSON; + case "JSONB" -> ColumnType.JSONB; + default -> null; + }; + } +} diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java b/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java index c7b380b..1d581d3 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java @@ -1,7 +1,6 @@ package dev.sqlcj.schema.parser; import dev.sqlcj.schema.Column; -import dev.sqlcj.schema.ColumnType; import dev.sqlcj.schema.Constraint; import dev.sqlcj.schema.ConstraintType; import dev.sqlcj.schema.EnumType; @@ -51,9 +50,6 @@ public class DefaultSchemaParser implements SchemaParser { - /** One parenthesized type argument group, such as {@code (10, 2)}. */ - private static final Pattern TYPE_ARGUMENTS = Pattern.compile("\\([^)]*\\)"); - /** * The opening words of an {@code ALTER INDEX} statement, which the parser * reports only as an opaque unsupported statement. @@ -63,6 +59,9 @@ public class DefaultSchemaParser implements SchemaParser { Pattern.CASE_INSENSITIVE ); + /** Resolves the type a column declaration states. */ + private final ColumnTypeMapping columnTypeMapping = new ColumnTypeMapping(); + @Override public Schema parse(Schema schema, String sql) { AtomicReference parser = new AtomicReference<>(); @@ -817,111 +816,25 @@ private Table parseTable(CreateTable createTable, List enums) { } /** - * Parses one column, recording a column sqlcj cannot map with its declared - * type text instead of failing the schema. A column declared with exactly - * one array dimension is an array of its declared element type; a column of - * more dimensions, and an array whose element type has no array mapping, is - * recorded like any other unmapped column. + * Parses one column, whose declared type the shared column type mapping + * resolves or records with its declared type text instead of failing the + * schema. */ private Column parseColumn(ColumnDefinition definition, List enums) { - String typeName = typeName(definition); - int arrayDimensions = arrayDimensions(definition); - boolean nullable = !isSerial(typeName) && isNullable(definition); - boolean array = arrayDimensions == 1; - - ColumnType type = arrayDimensions <= 1 - ? columnType(typeName) - : null; - - if (type != null) { - if (!array || isArrayElementType(type)) { - return new Column( - columnName(definition), - type, - nullable, - null, - null, - array, - isBlankPadded(typeName) - ); - } - } else if (arrayDimensions <= 1) { - EnumType enumType = declaredEnum(definition, enums); - - if (enumType != null) { - return new Column( - columnName(definition), - ColumnType.ENUM, - nullable, - null, - enumType.name(), - array - ); - } - } + ColumnTypeMapping.MappedType mappedType = columnTypeMapping.map( + definition.getColDataType(), + enums + ); return new Column( columnName(definition), - null, - nullable, - typeName + "[]".repeat(arrayDimensions) - ); - } - - /** - * Reports whether a declared spelling is the blank-padded character type, - * which PostgreSQL names {@code bpchar} and maps like {@code VARCHAR}. The - * distinction is kept because the two names are not interchangeable where - * PostgreSQL resolves an array type from its element's name. - */ - private boolean isBlankPadded(String typeName) { - return switch (typeName) { - case "CHAR", "CHARACTER" -> true; - default -> false; - }; - } - - /** - * Reports whether an array of a mapped type is mapped as well. - * {@code BYTEA}, {@code JSON}, and {@code JSONB} arrays are recorded with - * their declared type instead, because their elements are bound and read as - * text or bytes rather than as a value of a mapped element type. - */ - private boolean isArrayElementType(ColumnType type) { - return type != ColumnType.BYTEA - && type != ColumnType.JSON - && type != ColumnType.JSONB; - } - - /** - * The enum type a column of an unmapped type names, or {@code null} when - * the schema declares no such type. The declared type is matched without - * its SQL identifier delimiters and case-insensitively, as PostgreSQL - * resolves an unquoted type name. - */ - private EnumType declaredEnum(ColumnDefinition definition, List enums) { - String declaredType = MultiPartName.unquote( - definition.getColDataType().getDataType() + mappedType.type(), + !isSerial(mappedType.typeName()) && isNullable(definition), + mappedType.unsupportedType(), + mappedType.enumType(), + mappedType.array(), + mappedType.blankPadded() ); - - int index = indexOfEnum(enums, declaredType); - - return index < 0 - ? null - : enums.get(index); - } - - /** - * Reports how many array dimensions a column declares. The parser reports - * an array's dimensions separately from its element type, so a column with - * any dimension is an array of the reported type. - */ - private int arrayDimensions(ColumnDefinition definition) { - List arrayData = definition.getColDataType().getArrayData(); - - return arrayData == null - ? 0 - : arrayData.size(); } /** @@ -931,47 +844,6 @@ private String columnName(ColumnDefinition definition) { return MultiPartName.unquote(definition.getColumnName()); } - /** - * Returns the canonical spelling of a declared SQL type: upper case, - * without type arguments such as a length or a precision, and with single - * spaces between the remaining words. - */ - private String typeName(ColumnDefinition definition) { - String dataType = definition.getColDataType() - .getDataType() - .toUpperCase(Locale.ROOT); - - return TYPE_ARGUMENTS - .matcher(dataType) - .replaceAll(" ") - .replaceAll("\\s+", " ") - .trim(); - } - - /** The mapped type of declared spelling, or {@code null} if unmapped. */ - private ColumnType columnType(String typeName) { - return switch (typeName) { - case "INTEGER", "INT", "INT4", "SERIAL", "SERIAL4" -> ColumnType.INTEGER; - case "BIGINT", "INT8", "BIGSERIAL", "SERIAL8" -> ColumnType.BIGINT; - case "SMALLINT", "INT2", "SMALLSERIAL", "SERIAL2" -> ColumnType.SMALLINT; - case "BOOLEAN", "BOOL" -> ColumnType.BOOLEAN; - case "VARCHAR", "CHARACTER VARYING", "CHAR", "CHARACTER" -> ColumnType.VARCHAR; - case "TEXT" -> ColumnType.TEXT; - case "DATE" -> ColumnType.DATE; - case "TIME", "TIME WITHOUT TIME ZONE" -> ColumnType.TIME; - case "TIMESTAMP", "TIMESTAMP WITHOUT TIME ZONE" -> ColumnType.TIMESTAMP; - case "TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ" -> ColumnType.TIMESTAMP_WITH_TIME_ZONE; - case "DECIMAL", "NUMERIC" -> ColumnType.DECIMAL; - case "REAL", "FLOAT4" -> ColumnType.REAL; - case "DOUBLE PRECISION", "FLOAT8" -> ColumnType.DOUBLE_PRECISION; - case "UUID" -> ColumnType.UUID; - case "BYTEA" -> ColumnType.BYTEA; - case "JSON" -> ColumnType.JSON; - case "JSONB" -> ColumnType.JSONB; - default -> null; - }; - } - /** * PostgreSQL defines the serial spellings as an integer type with a * sequence default and {@code NOT NULL}, so such a column is never From dd6d3d221d9ac9b1e5d54bd6e4bfa313ef3ecb18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Thu, 1 Oct 2026 20:33:02 +0300 Subject: [PATCH 2/3] feat: type query placeholders by their SQL cast A $N or :name placeholder that is the direct operand of ::type or CAST(... AS type) inside WHERE, INSERT values, or UPDATE assignments takes the cast's type, including declared enums and one-dimensional arrays. An indexed cast placeholder keeps the column name an uncast one would take and is otherwise named param; a named one keeps its own name. Unmapped cast types, casts in unanalyzed locations, and quoted, qualified, or &name cast operands are rejected. --- .../dev/sqlcj/analysis/QueryAnalyzer.java | 452 ++++++++++++++---- .../dev/sqlcj/sql/SqlParameterCompiler.java | 43 +- .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 397 ++++++++++++++- .../compiler/PostgresIntegrationTest.java | 44 ++ .../SqlcjCompilerIntegrationTest.java | 31 ++ .../sqlcj/sql/SqlParameterCompilerTest.java | 88 +++- 6 files changed, 956 insertions(+), 99 deletions(-) diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java index f063ea3..0401560 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -4,11 +4,14 @@ import dev.sqlcj.parser.QueryType; import dev.sqlcj.schema.ColumnType; import dev.sqlcj.schema.Schema; +import dev.sqlcj.schema.parser.ColumnTypeMapping; import dev.sqlcj.sql.ParsedSql; import dev.sqlcj.type.DefaultTypeResolver; import dev.sqlcj.type.TypeResolver; import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.CastExpression; import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; import net.sf.jsqlparser.expression.Function; import net.sf.jsqlparser.expression.JdbcNamedParameter; import net.sf.jsqlparser.expression.JdbcParameter; @@ -18,9 +21,14 @@ import net.sf.jsqlparser.expression.operators.relational.ComparisonOperator; import net.sf.jsqlparser.expression.operators.relational.EqualsTo; import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.expression.operators.relational.GreaterThan; +import net.sf.jsqlparser.expression.operators.relational.GreaterThanEquals; import net.sf.jsqlparser.expression.operators.relational.InExpression; import net.sf.jsqlparser.expression.operators.relational.IsNullExpression; import net.sf.jsqlparser.expression.operators.relational.LikeExpression; +import net.sf.jsqlparser.expression.operators.relational.MinorThan; +import net.sf.jsqlparser.expression.operators.relational.MinorThanEquals; +import net.sf.jsqlparser.expression.operators.relational.NotEqualsTo; import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList; import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.ReturningClause; @@ -74,12 +82,21 @@ public final class QueryAnalyzer { /** The parameter name of an {@code OFFSET} value placeholder. */ private static final String OFFSET_PARAMETER_NAME = "offset"; + /** + * The prefix of the name an indexed placeholder takes where no column of + * the query names it, which a cast types wherever it appears. + */ + private static final String INDEXED_PARAMETER_NAME_PREFIX = "param"; + /** * Resolves the Java type of parameter occurrence, which decides whether a * repeated placeholder index can share one generated parameter. */ private final TypeResolver typeResolver = new DefaultTypeResolver(); + /** Resolves the type a cast states, as a schema column declares its own. */ + private final ColumnTypeMapping columnTypeMapping = new ColumnTypeMapping(); + /** * One query source and the name it exposes to column references, which is * its alias when present and otherwise its table name. A left-joined @@ -109,6 +126,19 @@ private String nameOr(String clauseName) { ? clauseName : name; } + + /** The placeholder as the query spells it. */ + private String spelled() { + return name == null + ? "$" + index + : NAME_PREFIX + name; + } + } + + /** + * One placeholder occurrence a cast types, and the type that cast states. + */ + private record CastPlaceholder(Placeholder placeholder, ColumnTypeMapping.MappedType type) { } /** @@ -121,10 +151,22 @@ private static final class Placeholders { /** The compiled placeholder names in logical parameter order. */ private final List names; + /** The schema whose declared enum types a cast may name. */ + private final Schema schema; + + /** Resolves the type a cast states. */ + private final ColumnTypeMapping columnTypeMapping; + private final List occurrences = new ArrayList<>(); - private Placeholders(List names) { + private Placeholders( + List names, + Schema schema, + ColumnTypeMapping columnTypeMapping + ) { this.names = names; + this.schema = schema; + this.columnTypeMapping = columnTypeMapping; } /** @@ -150,6 +192,43 @@ private Placeholder of(Expression expression) { return null; } + /** + * Reports the placeholder a cast types, which is the placeholder the + * cast states as its direct operand, and {@code null} when the + * expression is not such a cast. A cast that declares a row type + * instead of a type name states no column type, so it types no + * placeholder. + * + *

The cast states the parameter's type, so a type the schema's + * column type mapping does not map is rejected here, where the + * placeholder it would have typed is known. + */ + private CastPlaceholder ofCast(Expression expression) { + if (!(expression instanceof CastExpression cast) || cast.getColDataType() == null) { + return null; + } + + Placeholder placeholder = of(cast.getLeftExpression()); + + if (placeholder == null) { + return null; + } + + ColumnTypeMapping.MappedType type = columnTypeMapping.map( + cast.getColDataType(), + schema.enums() + ); + + if (!type.mapped()) { + throw new UnsupportedOperationException( + "Placeholder %s has unsupported cast type %s" + .formatted(placeholder.spelled(), type.unsupportedType()) + ); + } + + return new CastPlaceholder(placeholder, type); + } + private int requireCompiledName(JdbcNamedParameter named) { int index = NAME_PREFIX.equals(named.getParameterCharacter()) ? names.indexOf(named.getName()) @@ -211,7 +290,7 @@ private QueryModel analyzeSelect(Query query, ParsedSql parsedSql, Select select List columns = resolveColumns(plainSelect, sources); - Placeholders placeholders = toPlaceholders(parsedSql); + Placeholders placeholders = toPlaceholders(parsedSql, schema); resolveBindingParameters(plainSelect, sources, placeholders); @@ -243,7 +322,7 @@ private QueryModel analyzeInsert(Query query, ParsedSql parsedSql, Insert insert rowTable = resolveReturningRowTable(returningClause, source); } - Placeholders placeholders = toPlaceholders(parsedSql); + Placeholders placeholders = toPlaceholders(parsedSql, schema); resolveInsertParameters(insert, source.table(), placeholders); @@ -275,7 +354,7 @@ private QueryModel analyzeUpdate(Query query, ParsedSql parsedSql, Update update rowTable = resolveReturningRowTable(returningClause, source); } - Placeholders placeholders = toPlaceholders(parsedSql); + Placeholders placeholders = toPlaceholders(parsedSql, schema); resolveUpdateSetParameters(update, source.table(), placeholders); @@ -311,7 +390,7 @@ private QueryModel analyzeDelete(Query query, ParsedSql parsedSql, Delete delete rowTable = resolveReturningRowTable(returningClause, source); } - Placeholders placeholders = toPlaceholders(parsedSql); + Placeholders placeholders = toPlaceholders(parsedSql, schema); if (delete.getWhere() != null) { resolveParameters(delete.getWhere(), List.of(source), placeholders); @@ -512,8 +591,8 @@ private void requireNoCommonTableExpressions(List withItems) { /** * Resolves the {@code INSERT} parameters by pairing the explicit column * list with the single values row, which is also their textual order. A - * value that is not a placeholder binds nothing and reaches the database as - * written, so only its target column must exist. + * value that binds no placeholder reaches the database as written, so only + * its target column must exist. */ private void resolveInsertParameters( Insert insert, @@ -536,14 +615,12 @@ private void resolveInsertParameters( for (int index = 0; index < columns.size(); index++) { String columnName = columns.get(index).getUnquotedColumnName(); - Placeholder placeholder = placeholders.of(values.get(index)); - - if (placeholder == null) { - findColumn(table, columnName); - continue; - } - addParameter(placeholder, columnName, table, placeholders); + resolveColumnValue( + values.get(index), + findColumn(table, columnName), + placeholders + ); } } @@ -562,8 +639,8 @@ private ParenthesedExpressionList resolveInsertValues(Insert insert) { /** * Resolves the {@code UPDATE} assignment parameters in source order, which * precedes any parameter in the {@code WHERE} expression. An assigned value - * that is not a placeholder binds nothing and reaches the database as - * written, so only its target column must exist. + * that binds no placeholder reaches the database as written, so only its + * target column must exist. */ private void resolveUpdateSetParameters( Update update, @@ -578,14 +655,12 @@ private void resolveUpdateSetParameters( } String columnName = updateSet.getColumn(0).getUnquotedColumnName(); - Placeholder placeholder = placeholders.of(updateSet.getValue(0)); - if (placeholder == null) { - findColumn(table, columnName); - continue; - } - - addParameter(placeholder, columnName, table, placeholders); + resolveColumnValue( + updateSet.getValue(0), + findColumn(table, columnName), + placeholders + ); } } @@ -1061,7 +1136,7 @@ private void resolveParameters( } if (expression instanceof IsNullExpression isNull) { - resolveIsNullExpression(isNull, sources); + resolveIsNullExpression(isNull, sources, placeholders); return; } @@ -1071,17 +1146,18 @@ private void resolveParameters( } if (expression instanceof ComparisonOperator comparison) { - resolveParameterComparison( - comparison.getLeftExpression(), - comparison.getRightExpression(), - sources, - placeholders - ); + resolveParameterComparison(comparison, sources, placeholders); + return; } + + resolveCastPlaceholders(expression, placeholders); } private void resolveInExpression(InExpression in, List sources, Placeholders placeholders) { if (!(in.getLeftExpression() instanceof net.sf.jsqlparser.schema.Column column)) { + resolveCastPlaceholders(in.getLeftExpression(), placeholders); + resolveCastPlaceholders(in.getRightExpression(), placeholders); + return; } @@ -1091,11 +1167,7 @@ private void resolveInExpression(InExpression in, List sources, Placehol if (rightExpression instanceof ExpressionList expressionList) { for (Expression expression : expressionList) { - Placeholder placeholder = placeholders.of(expression); - - if (placeholder != null) { - addParameter(placeholder, schemaColumn, placeholders); - } + resolveColumnValue(expression, schemaColumn, placeholders); } return; @@ -1172,7 +1244,18 @@ private void resolveInExpression( ); } } + + return; + } + + CastPlaceholder cast = placeholders.ofCast(expression); + + if (cast != null) { + addCastParameter(cast, schemaColumn.name(), placeholders); + return; } + + resolveCastPlaceholders(expression, placeholders); } /** @@ -1180,6 +1263,13 @@ private void resolveInExpression( * {@code ILIKE $N}, typed from the tested text column. A pattern * that binds no placeholder reaches the database as written, so a literal * pattern stays unanalyzed. + * + *

A cast pattern states its own type, so the pattern shape and the + * tested column's type restrict only an uncast pattern, which takes the + * tested column's type. A cast pattern keeps the tested column's name only + * in the shape that accepts an uncast pattern, because only there would an + * uncast pattern be named after that column; in every other shape it is + * named after its own placeholder. */ private void resolveLikeExpression( LikeExpression like, @@ -1197,25 +1287,44 @@ private void resolveLikeExpression( Placeholder pattern = placeholders.of(right); - if (pattern == null) { - return; - } + if (pattern != null) { + requireSupportedLikePattern(like); + + if (!(left instanceof net.sf.jsqlparser.schema.Column column)) { + return; + } - requireSupportedLikePattern(like); + dev.sqlcj.schema.Column schemaColumn = resolveColumn(column, sources).column(); + + requireSupportedType(schemaColumn); + + addParameter( + pattern, + requireTextColumn(schemaColumn), + placeholders + ); - if (!(left instanceof net.sf.jsqlparser.schema.Column column)) { return; } - dev.sqlcj.schema.Column schemaColumn = resolveColumn(column, sources).column(); + CastPlaceholder castPattern = placeholders.ofCast(right); - requireSupportedType(schemaColumn); + if ( + castPattern != null + && unsupportedLikePatternReason(like) == null + && left instanceof net.sf.jsqlparser.schema.Column column + ) { + addCastParameter( + castPattern, + resolveColumn(column, sources).column().name(), + placeholders + ); - addParameter( - pattern, - requireTextColumn(schemaColumn), - placeholders - ); + return; + } + + resolveCastPlaceholders(left, placeholders); + resolveCastPlaceholders(right, placeholders); } /** @@ -1224,31 +1333,39 @@ private void resolveLikeExpression( * pattern match. */ private void requireSupportedLikePattern(LikeExpression like) { + String reason = unsupportedLikePatternReason(like); + + if (reason != null) { + throw new UnsupportedOperationException(reason); + } + } + + /** + * Reports why a {@code LIKE} expression is not the pattern shape this + * subset types, and {@code null} when it is that shape. The reason is the + * rejection of an uncast pattern placeholder, and it is also what decides + * whether a cast pattern is named after the tested column. + */ + private String unsupportedLikePatternReason(LikeExpression like) { if (like.isNot()) { - throw new UnsupportedOperationException( - "A negated LIKE pattern placeholder is not supported." - ); + return "A negated LIKE pattern placeholder is not supported."; } LikeExpression.KeyWord keyword = like.getLikeKeyWord(); if (keyword != LikeExpression.KeyWord.LIKE && keyword != LikeExpression.KeyWord.ILIKE) { - throw new UnsupportedOperationException( - "Only LIKE and ILIKE pattern placeholders are supported, but was: " + keyword - ); + return "Only LIKE and ILIKE pattern placeholders are supported, but was: " + keyword; } if (like.getEscape() != null) { - throw new UnsupportedOperationException( - "A LIKE pattern placeholder must not have an ESCAPE clause." - ); + return "A LIKE pattern placeholder must not have an ESCAPE clause."; } if (like.isUseBinary()) { - throw new UnsupportedOperationException( - "A binary LIKE pattern placeholder is not supported." - ); + return "A binary LIKE pattern placeholder is not supported."; } + + return null; } /** @@ -1275,13 +1392,23 @@ private dev.sqlcj.schema.Column requireTextColumn(dev.sqlcj.schema.Column column /** * Resolves the tested column of {@code IS NULL} and {@code IS NOT NULL} - * against the query sources. The predicate binds no placeholder, so it - * contributes no parameter and reaches the database as written. + * against the query sources. The predicate names no parameter after its + * tested value, so a tested value that is not a direct column contributes + * only the parameters its own cast placeholders state. */ - private void resolveIsNullExpression(IsNullExpression isNull, List sources) { - if (isNull.getLeftExpression() instanceof net.sf.jsqlparser.schema.Column column) { + private void resolveIsNullExpression( + IsNullExpression isNull, + List sources, + Placeholders placeholders + ) { + Expression tested = isNull.getLeftExpression(); + + if (tested instanceof net.sf.jsqlparser.schema.Column column) { resolveColumn(column, sources); + return; } + + resolveCastPlaceholders(tested, placeholders); } /** @@ -1297,36 +1424,45 @@ private void resolveBetweenExpression( Placeholders placeholders ) { Expression left = between.getLeftExpression(); + Expression start = between.getBetweenExpressionStart(); + Expression end = between.getBetweenExpressionEnd(); - Placeholder tested = placeholders.of(left); - Placeholder start = placeholders.of(between.getBetweenExpressionStart()); - Placeholder end = placeholders.of(between.getBetweenExpressionEnd()); - - if (tested != null || (start == null && end == null)) { + if (placeholders.of(left) != null) { return; } - if (!(left instanceof net.sf.jsqlparser.schema.Column column)) { - return; - } + if ( + left instanceof net.sf.jsqlparser.schema.Column column + && (bindsValue(start, placeholders) || bindsValue(end, placeholders)) + ) { + dev.sqlcj.schema.Column schemaColumn = resolveColumn(column, sources).column(); - dev.sqlcj.schema.Column schemaColumn = resolveColumn(column, sources).column(); + resolveColumnValue(start, schemaColumn, placeholders); + resolveColumnValue(end, schemaColumn, placeholders); - if (start != null) { - addParameter(start, schemaColumn, placeholders); + return; } - if (end != null) { - addParameter(end, schemaColumn, placeholders); - } + resolveCastPlaceholders(left, placeholders); + resolveCastPlaceholders(start, placeholders); + resolveCastPlaceholders(end, placeholders); } + /** + * Resolves the operands of one comparison, which names a parameter after + * the column it compares. A placeholder compared with a column takes that + * column's type, and a cast placeholder states its own type and keeps the + * column's name. Every other operand contributes only the parameters its + * own cast placeholders state. + */ private void resolveParameterComparison( - Expression left, - Expression right, + ComparisonOperator comparison, List sources, Placeholders placeholders ) { + Expression left = comparison.getLeftExpression(); + Expression right = comparison.getRightExpression(); + Placeholder leftPlaceholder = placeholders.of(left); Placeholder rightPlaceholder = placeholders.of(right); @@ -1345,18 +1481,156 @@ private void resolveParameterComparison( resolveColumn(column, sources).column(), placeholders ); + return; + } + + if (isColumnTypedComparison(comparison)) { + if (left instanceof net.sf.jsqlparser.schema.Column column) { + CastPlaceholder cast = placeholders.ofCast(right); + + if (cast != null) { + addCastParameter( + cast, + resolveColumn(column, sources).column().name(), + placeholders + ); + return; + } + } else if (right instanceof net.sf.jsqlparser.schema.Column column) { + CastPlaceholder cast = placeholders.ofCast(left); + + if (cast != null) { + addCastParameter( + cast, + resolveColumn(column, sources).column().name(), + placeholders + ); + return; + } + } } + + resolveCastPlaceholders(left, placeholders); + resolveCastPlaceholders(right, placeholders); } - private void addParameter( - Placeholder placeholder, - String columnName, - dev.sqlcj.schema.Table table, + /** + * Reports whether a comparison is one of the supported predicate + * comparisons, whose operand is a value of the compared column's type and + * therefore names its parameter after that column. Another operator the + * parser reports as a comparison, such as the array overlap {@code &&}, + * compares something other than one column value, so a cast placeholder + * there is named after itself. + */ + private boolean isColumnTypedComparison(ComparisonOperator comparison) { + return comparison instanceof EqualsTo + || comparison instanceof NotEqualsTo + || comparison instanceof GreaterThan + || comparison instanceof GreaterThanEquals + || comparison instanceof MinorThan + || comparison instanceof MinorThanEquals; + } + + /** + * Resolves one value of a clause that names its parameter after a column: a + * placeholder typed from the column, a cast placeholder that states its own + * type and keeps the column's name, or an expression that is neither, which + * contributes only the parameters its own cast placeholders state. + */ + private void resolveColumnValue( + Expression value, + dev.sqlcj.schema.Column column, Placeholders placeholders ) { + Placeholder placeholder = placeholders.of(value); + + if (placeholder != null) { + addParameter(placeholder, column, placeholders); + return; + } + + CastPlaceholder cast = placeholders.ofCast(value); + + if (cast != null) { + addCastParameter(cast, column.name(), placeholders); + return; + } + + resolveCastPlaceholders(value, placeholders); + } + + /** + * Reports whether an expression is itself the value of one parameter, which + * is a placeholder or a cast of one. + */ + private boolean bindsValue(Expression value, Placeholders placeholders) { + return placeholders.of(value) != null || placeholders.ofCast(value) != null; + } + + /** + * Binds every cast placeholder an expression contains that no clause of the + * query names, each after its own placeholder, in textual order. The + * expression visitor reports an expression's parts in the order the query + * writes them and does not descend into a subquery, whose placeholders are + * not analyzed. + */ + private void resolveCastPlaceholders(Expression expression, Placeholders placeholders) { + if (expression == null) { + return; + } + + expression.accept( + new ExpressionVisitorAdapter() { + + @Override + public Void visit(CastExpression cast, S context) { + CastPlaceholder castPlaceholder = placeholders.ofCast(cast); + + if (castPlaceholder == null) { + return super.visit(cast, context); + } + + addCastParameter( + castPlaceholder, + indexedParameterName(castPlaceholder.placeholder()), + placeholders + ); + + return null; + } + }, + null + ); + } + + /** + * The name an indexed placeholder takes where no column of the query names + * it, which is the placeholder's own index. A named placeholder states its + * name itself, so this name is used only for an indexed one. + */ + private String indexedParameterName(Placeholder placeholder) { + return INDEXED_PARAMETER_NAME_PREFIX + placeholder.index(); + } + + /** + * Records one parameter occurrence a cast types, named after the column + * whose value it is where a clause names one, and otherwise after the + * placeholder itself. + */ + private void addCastParameter( + CastPlaceholder cast, + String name, + Placeholders placeholders + ) { + ColumnTypeMapping.MappedType type = cast.type(); + addParameter( - placeholder, - findColumn(table, columnName), + cast.placeholder(), + name, + type.type(), + type.enumType(), + type.array(), + type.blankPadded(), placeholders ); } @@ -1442,8 +1716,12 @@ private void requireSupportedPlaceholders(ParsedSql parsedSql) { * The compiled placeholders of one query, against which every analyzed * placeholder occurrence resolves to its logical parameter. */ - private Placeholders toPlaceholders(ParsedSql parsedSql) { - return new Placeholders(parsedSql.parameters().names()); + private Placeholders toPlaceholders(ParsedSql parsedSql, Schema schema) { + return new Placeholders( + parsedSql.parameters().names(), + schema, + columnTypeMapping + ); } /** diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParameterCompiler.java b/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParameterCompiler.java index 8e7ab3e..2e2e917 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParameterCompiler.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/sql/SqlParameterCompiler.java @@ -1,5 +1,7 @@ package dev.sqlcj.sql; +import net.sf.jsqlparser.expression.CastExpression; +import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.JdbcNamedParameter; import net.sf.jsqlparser.parser.CCJSqlParserConstants; import net.sf.jsqlparser.parser.Node; @@ -153,17 +155,17 @@ SqlParameters compile(String sql, Node astRoot) { /** * Collects the named placeholders of the parse tree that this compiler did * not replace, written as the source spells them, so that an accepted query - * never keeps such a placeholder in its executable SQL. A placeholder the - * parser reports without a parse-tree node of its own, such as one under a - * cast, has no node to collect here. + * never keeps such a placeholder in its executable SQL. + * + *

The parser gives the operand of a {@code ::} cast no parse-tree node + * of its own, so a cast reports the placeholder it casts, through any + * further cast of the same operand. */ private void collectUncompiledPlaceholders(Node node, List names, List uncompiled) { - if (node.jjtGetValue() instanceof JdbcNamedParameter named && !isCompiled(named, names)) { - String placeholder = named.getParameterCharacter() + named.getName(); + collectUncompiledPlaceholder(node.jjtGetValue(), names, uncompiled); - if (!uncompiled.contains(placeholder)) { - uncompiled.add(placeholder); - } + if (node.jjtGetValue() instanceof CastExpression cast) { + collectUncompiledPlaceholder(castOperand(cast), names, uncompiled); } for (int child = 0; child < node.jjtGetNumChildren(); child++) { @@ -171,6 +173,31 @@ private void collectUncompiledPlaceholders(Node node, List names, List names, List uncompiled) { + if (!(value instanceof JdbcNamedParameter named) || isCompiled(named, names)) { + return; + } + + String placeholder = named.getParameterCharacter() + named.getName(); + + if (!uncompiled.contains(placeholder)) { + uncompiled.add(placeholder); + } + } + /** * Reports whether a named placeholder the parser produced is one this * compiler replaced, which requires both its colon and its name to be the diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java index 78f27c8..7c958ac 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -2770,8 +2770,8 @@ void shouldRejectMixedPlaceholderForms() { /** * A qualified, quoted, or {@code &name} placeholder is rejected wherever it - * appears, including a clause this analyzer does not traverse, because the - * executable SQL would otherwise keep it. + * appears, including a clause this analyzer does not traverse and the + * operand of a cast, because the executable SQL would otherwise keep it. */ @ParameterizedTest @CsvSource( @@ -2785,7 +2785,12 @@ void shouldRejectMixedPlaceholderForms() { "SELECT id FROM users WHERE id = :id ORDER BY &x|&x", "SELECT id FROM users WHERE id = abs(:\"x\")|:\"x\"", "SELECT id FROM users WHERE id = abs(&x)|&x", - "SELECT id FROM users WHERE id = abs(:a.b)|:a.b" + "SELECT id FROM users WHERE id = abs(:a.b)|:a.b", + "SELECT id FROM users WHERE id = :\"x\"::bigint|:\"x\"", + "SELECT id FROM users WHERE id = &x::bigint|&x", + "SELECT id FROM users WHERE id = :a.b::bigint|:a.b", + "SELECT id FROM users WHERE id = :\"x\"::int::bigint|:\"x\"", + "SELECT id FROM users WHERE id = :id ORDER BY &x::int|&x" } ) void shouldRejectUnsupportedNamedPlaceholderForm(String sql, String placeholder) { @@ -2847,6 +2852,392 @@ void shouldRejectNamedPlaceholderWithConflictingTypes() { ); } + /** + * A cast types the placeholder it casts wherever that placeholder appears + * inside an analyzed clause, so the optional filter idiom is one parameter + * of the cast's type: the occurrence under the cast and the compared + * occurrence are one named placeholder. + */ + @Test + void shouldResolveNamedCastParameterOfOptionalFilter() { + String sql = "SELECT id FROM users WHERE (:name::text IS NULL OR name = :name)"; + + QueryModel model = analyzer.analyze( + new Query("ListUsers", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryParameter(1, "name", ColumnType.TEXT)), + model.parameters() + ); + + assertEquals(List.of(1, 1), model.bindingParameterIndexes()); + + assertEquals( + "SELECT id FROM users WHERE (?::text IS NULL OR name = ?)", + model.executableSql() + ); + } + + /** + * A computed {@code LIKE} pattern is not analyzed as a pattern, but the + * cast placeholder inside it is typed by its cast and named after itself. + */ + @Test + void shouldResolveNamedCastParameterInsideComputedLikePattern() { + String sql = "SELECT id FROM users WHERE name LIKE '%' || :term::text || '%'"; + + QueryModel model = analyzer.analyze( + new Query("SearchUsers", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryParameter(1, "term", ColumnType.TEXT)), + model.parameters() + ); + + assertEquals(List.of(1), model.bindingParameterIndexes()); + + assertEquals( + "SELECT id FROM users WHERE name LIKE '%' || ?::text || '%'", + model.executableSql() + ); + } + + /** + * A cast placeholder inside a computed assignment is typed by its cast, and + * the parameters stay in first-occurrence order with the assignment bound + * before the predicate. + */ + @Test + void shouldResolveNamedCastParameterInsideUpdateAssignment() { + String sql = "UPDATE users SET bio = COALESCE(:bio::text, bio) WHERE id = :id"; + + QueryModel model = analyzer.analyze( + new Query("UpdateUserBio", QueryType.EXEC, sql), + parser.parse(sql), + predicateSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "bio", ColumnType.TEXT), + new QueryParameter(2, "id", ColumnType.BIGINT) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2), model.bindingParameterIndexes()); + + assertEquals( + "UPDATE users SET bio = COALESCE(?::text, bio) WHERE id = ?", + model.executableSql() + ); + } + + /** + * The {@code CAST} spelling types a placeholder like {@code ::} does, and a + * cast placeholder compared with a column keeps that column's name. + */ + @Test + void shouldResolveCastKeywordParameterNamedAfterItsComparedColumn() { + String sql = "SELECT id FROM users WHERE id = CAST($1 AS bigint)"; + + QueryModel model = analyzer.analyze( + new Query("FindUser", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)), + model.parameters() + ); + + assertEquals( + "SELECT id FROM users WHERE id = CAST(? AS bigint)", + model.executableSql() + ); + } + + /** + * A cast placeholder that is the direct value of a location that names an + * uncast placeholder after its column keeps that column's name and takes + * the cast's type. + */ + @ParameterizedTest + @ValueSource( + strings = { + "SELECT id FROM users WHERE name = $1::text", + "SELECT id FROM users WHERE $1::text = name", + "SELECT id FROM users WHERE name <> $1::text", + "SELECT id FROM users WHERE name IN ($1::text, 'a')", + "SELECT id FROM users WHERE name BETWEEN $1::text AND 'z'", + "SELECT id FROM users WHERE name LIKE $1::text" + } + ) + void shouldNameCastParameterAfterItsColumn(String sql) { + QueryModel model = analyzer.analyze( + new Query("FindUsers", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryParameter(1, "name", ColumnType.TEXT)), + model.parameters() + ); + + assertEquals(List.of(1), model.bindingParameterIndexes()); + } + + /** + * A cast pattern is accepted in a pattern shape that rejects an uncast + * pattern placeholder, but no column names it there, so it is named after + * its own index while keeping the cast's type and its binding position. + */ + @ParameterizedTest + @ValueSource( + strings = { + "SELECT id FROM users WHERE name SIMILAR TO $1::text", + "SELECT id FROM users WHERE name NOT LIKE $1::text", + "SELECT id FROM users WHERE name NOT ILIKE $1::text", + "SELECT id FROM users WHERE name LIKE $1::text ESCAPE '!'" + } + ) + void shouldNameCastPatternAfterItsPlaceholderInAnUntypedPatternShape(String sql) { + QueryModel model = analyzer.analyze( + new Query("FindUsers", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryParameter(1, "param1", ColumnType.TEXT)), + model.parameters() + ); + + assertEquals(List.of(1), model.bindingParameterIndexes()); + } + + /** A named cast pattern keeps its own name in every pattern shape. */ + @Test + void shouldNameNamedCastPatternAfterItsPlaceholder() { + String sql = "SELECT id FROM users WHERE name NOT LIKE :p::text"; + + QueryModel model = analyzer.analyze( + new Query("FindUsers", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryParameter(1, "p", ColumnType.TEXT)), + model.parameters() + ); + + assertEquals(List.of(1), model.bindingParameterIndexes()); + + assertEquals( + "SELECT id FROM users WHERE name NOT LIKE ?::text", + model.executableSql() + ); + } + + /** A cast may name a declared enum type, which types its placeholder. */ + @Test + void shouldResolveEnumCastParameter() { + String sql = "SELECT id FROM stages WHERE setting = $1::stage_setting"; + + QueryModel model = analyzer.analyze( + new Query("ListStages", QueryType.MANY, sql), + parser.parse(sql), + enumSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "setting", ColumnType.ENUM, "stage_setting") + ), + model.parameters() + ); + } + + /** + * A cast of a one-dimensional array types its placeholder as a list of the + * element type. The array overlap operator is not one of the supported + * comparisons, so its placeholder is named after itself rather than after + * the column. + */ + @Test + void shouldResolveArrayCastParameterNamedAfterItsPlaceholder() { + String sql = "SELECT id FROM stages WHERE tags && $1::varchar[]"; + + QueryModel model = analyzer.analyze( + new Query("ListStagesByTags", QueryType.MANY, sql), + parser.parse(sql), + arraySchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "param1", ColumnType.VARCHAR, null, true) + ), + model.parameters() + ); + + assertEquals( + "SELECT id FROM stages WHERE tags && ?::varchar[]", + model.executableSql() + ); + } + + /** A blank-padded character cast keeps PostgreSQL's own name of the type. */ + @Test + void shouldResolveBlankPaddedCastParameter() { + String sql = "SELECT id FROM users WHERE bio = $1::char (3)"; + + QueryModel model = analyzer.analyze( + new Query("FindUsers", QueryType.MANY, sql), + parser.parse(sql), + predicateSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "bio", ColumnType.VARCHAR, null, false, true) + ), + model.parameters() + ); + } + + /** + * An indexed cast placeholder in a location that names no column is named + * after its own index, and a later occurrence of the index keeps that name + * and type. + */ + @Test + void shouldNameIndexedCastParameterAfterItsIndex() { + String sql = "SELECT id FROM users WHERE ($1::text IS NULL OR name = $1)"; + + QueryModel model = analyzer.analyze( + new Query("ListUsers", QueryType.MANY, sql), + parser.parse(sql), + schema + ); + + assertEquals( + List.of(new QueryParameter(1, "param1", ColumnType.TEXT)), + model.parameters() + ); + + assertEquals(List.of(1, 1), model.bindingParameterIndexes()); + } + + /** + * An inserted value that is a cast placeholder keeps its column's name, + * while a cast placeholder inside a computed value is named after itself. + */ + @Test + void shouldResolveCastParametersOfInsertValues() { + String sql = "INSERT INTO users (id, bio) VALUES ($1::bigint, COALESCE($2::text, ''))"; + + QueryModel model = analyzer.analyze( + new Query("CreateUser", QueryType.EXEC, sql), + parser.parse(sql), + writeSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "param2", ColumnType.TEXT) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2), model.bindingParameterIndexes()); + } + + /** + * A cast type sqlcj does not map, including an array of more than one + * dimension, is rejected naming the placeholder it would have typed and + * the declared type. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "SELECT id FROM users WHERE id = $1::interval|$1|INTERVAL", + "SELECT id FROM users WHERE id = $1::int[][]|$1|INT[][]", + "SELECT id FROM users WHERE id = :value::interval|:value|INTERVAL" + } + ) + void shouldRejectUnsupportedCastType(String sql, String placeholder, String type) { + Query query = new Query("FindUsers", QueryType.MANY, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, schema) + ); + + assertEquals( + "Placeholder %s has unsupported cast type %s".formatted(placeholder, type), + exception.getMessage() + ); + } + + /** + * One placeholder whose cast occurrence and column occurrence resolve to + * different Java types is rejected like any other conflicting placeholder. + */ + @Test + void shouldRejectCastOccurrenceWithConflictingType() { + String sql = "SELECT id FROM users WHERE (:value::text IS NULL OR id = :value)"; + + Query query = new Query("ListUsers", QueryType.MANY, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, schema) + ); + + assertEquals( + "Placeholder :value has conflicting types: String and Long", + exception.getMessage() + ); + } + + /** + * A cast does not make a placeholder analyzable in a clause this analyzer + * does not traverse, so such a placeholder stays rejected by the + * placeholder accounting. + */ + @Test + void shouldRejectCastPlaceholderInUnanalyzedLocation() { + String sql = "SELECT id FROM users WHERE id = $1 ORDER BY $2::int"; + + Query query = new Query("ListUsers", QueryType.MANY, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, schema) + ); + + assertEquals( + "SQL placeholders [1, 2] are not the analyzed parameters [1]; " + + "a placeholder is in an unsupported location", + exception.getMessage() + ); + } + @Test void shouldAnalyzeSelectDeclaredAsOptional() { String sql = "SELECT id, name FROM users WHERE id = $1"; diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java index 6cb5f06..2f21c17 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -882,6 +882,50 @@ INSERT INTO users (id, code, name, bio) } } + /** + * Covers the cast-typed optional filter end to end: one named placeholder + * typed by its cast both tests whether the filter is absent and compares + * the column, so a null argument returns every row and a value returns + * only the matching rows. + */ + @Test + void shouldExecuteGeneratedCastTypedOptionalFilterAgainstPostgres() throws Exception { + Path classesDirectory = generateAndCompile(""" + -- name: ListUsersByName :many + SELECT id, name + FROM users + WHERE (:name::text IS NULL OR name = :name) + ORDER BY id; + """); + + execute(""" + INSERT INTO users (id, code, name) + VALUES + (1, 1, 'Alice'), + (2, 2, 'Bob'), + (3, 3, 'Alice') + """); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Method listUsersByName = repository.getClass().getMethod( + "listUsersByName", + String.class + ); + + assertEquals( + List.of("Alice", "Bob", "Alice"), + names(listUsersByName.invoke(repository, new Object[] { null })) + ); + + assertEquals( + List.of("Alice", "Alice"), + names(listUsersByName.invoke(repository, "Alice")) + ); + } + } + /** * Covers the scalar count end to end: a {@code :one} count read is * generated, compiled, and executed against PostgreSQL, so its result diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java index a3c9446..0247c4d 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -602,6 +602,37 @@ void shouldExecuteGeneratedQueryWithOutOfOrderPlaceholders() throws Exception { } } + /** + * A repository generated from a cast placeholder compiles: the cast types + * its placeholder, the compared column names it, and the cast reaches the + * executable SQL as written beside its {@code ?} marker. + */ + @Test + void shouldGenerateCompilableJavaForCastPlaceholders() throws IOException { + generateAndCompile( + """ + -- name: FindUsers :many + SELECT id, name + FROM users + WHERE name = $1::text OR name = $2; + """ + ); + + String source = Files.readString( + tempDir.resolve("generated/generated/UsersRepository.java") + ); + + assertTrue( + source.contains( + "public List findUsers(String name1, String name2)" + ), + source + ); + + assertTrue(source.contains("WHERE name = ?::text OR name = ?"), source); + assertTrue(source.contains("java.util.Arrays.asList(name1, name2)"), source); + } + /** * A repository generated from named placeholders compiles and executes: its * method parameters follow first-occurrence order and carry the placeholder diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParameterCompilerTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParameterCompilerTest.java index 32b98c1..0038fde 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParameterCompilerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/sql/SqlParameterCompilerTest.java @@ -1,8 +1,12 @@ package dev.sqlcj.sql; +import net.sf.jsqlparser.expression.CastExpression; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.JdbcNamedParameter; import net.sf.jsqlparser.parser.CCJSqlParserConstants; import net.sf.jsqlparser.parser.Node; import net.sf.jsqlparser.parser.Token; +import net.sf.jsqlparser.statement.create.table.ColDataType; import org.junit.jupiter.api.Test; import java.util.List; @@ -152,7 +156,75 @@ void shouldRejectSpansThatAreNotInTextualOrder() { ); } + /** + * The parser gives the operand of a {@code ::} cast no parse-tree node of + * its own, so the cast reports the placeholder it casts, through a chained + * cast of the same operand. + */ + @Test + void shouldReportNamedParametersThatAreCastOperands() { + String sql = "SELECT * FROM users WHERE id = :\"x\"::bigint AND code = &y::int::bigint"; + + SqlParameters parameters = compile( + sql, + new Token[] { token(CCJSqlParserConstants.S_IDENTIFIER, "SELECT", 0) }, + cast(namedParameter(":", "\"x\"")), + cast(cast(namedParameter("&", "y"))) + ); + + assertEquals(sql, parameters.executableSql()); + assertTrue(parameters.indexes().isEmpty()); + + assertEquals( + List.of(":\"x\"", "&y"), + parameters.uncompiledPlaceholders() + ); + } + + /** A cast operand this compiler replaced is not reported again. */ + @Test + void shouldNotReportCompiledNamedParameterThatIsACastOperand() { + String sql = "SELECT * FROM users WHERE id = :x::bigint"; + + SqlParameters parameters = compile( + sql, + new Token[] { + token(CCJSqlParserConstants.DOUBLE_COLON, ":", 31), + token(CCJSqlParserConstants.S_IDENTIFIER, "x", 32) + }, + cast(namedParameter(":", "x")) + ); + + assertEquals("SELECT * FROM users WHERE id = ?::bigint", parameters.executableSql()); + assertEquals(List.of(1), parameters.indexes()); + assertEquals(List.of("x"), parameters.names()); + assertTrue(parameters.uncompiledPlaceholders().isEmpty()); + } + private SqlParameters compile(String sql, Token... tokens) { + return compiler.compile(sql, node(tokens)); + } + + /** + * Compiles a source whose parse tree holds the given expressions beside its + * tokens, which is how the compiler sees a placeholder the parser reports + * without a parse-tree node of its own. + */ + private SqlParameters compile(String sql, Token[] tokens, Expression... expressions) { + Node root = node(tokens); + + for (int index = 0; index < expressions.length; index++) { + Node child = new Node(index + 1); + + child.jjtSetValue(expressions[index]); + + root.jjtAddChild(child, index); + } + + return compiler.compile(sql, root); + } + + private Node node(Token... tokens) { for (int index = 0; index + 1 < tokens.length; index++) { tokens[index].next = tokens[index + 1]; } @@ -161,7 +233,21 @@ private SqlParameters compile(String sql, Token... tokens) { node.jjtSetFirstToken(tokens[0]); node.jjtSetLastToken(tokens[tokens.length - 1]); - return compiler.compile(sql, node); + return node; + } + + private CastExpression cast(Expression operand) { + CastExpression cast = new CastExpression(); + + cast.setLeftExpression(operand); + cast.setColDataType(new ColDataType("bigint")); + + return cast; + } + + private JdbcNamedParameter namedParameter(String parameterCharacter, String name) { + return new JdbcNamedParameter(name) + .setParameterCharacter(parameterCharacter); } /** From 9035e459831fb5ff814531f96b635d5498bef2e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Thu, 1 Oct 2026 20:33:09 +0300 Subject: [PATCH 3/3] docs: document cast-typed placeholders --- docs/queries.md | 117 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 6 deletions(-) diff --git a/docs/queries.md b/docs/queries.md index 3966219..bde4924 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -179,6 +179,38 @@ its placeholder, while a placeholder as the tested value, as in A comparison that binds no placeholder, such as `active = TRUE`, contributes no generated parameter and reaches the database as written. +A placeholder written as the direct operand of `::type` or `CAST(... AS type)` +is typed by that cast instead of by the clause it appears in, anywhere inside +the `WHERE` clause: beside a compared column, as the tested value of `IS NULL`, +inside a concatenation, as a function argument, as an `IN` element, as a range +bound, as a pattern, or as the operand of another operator such as the array +overlap `&&`. This makes the optional filter and the computed pattern idioms +compile: + +```sql +-- name: ListUsersByName :many +SELECT id, name +FROM users +WHERE (:name::text IS NULL OR name = :name) +ORDER BY id; + +-- name: SearchUsers :many +SELECT id, name +FROM users +WHERE name LIKE '%' || :term::text || '%'; +``` + +`ListUsersByName` generates `listUsersByName(String name)`, which returns every +row for a null argument, and `SearchUsers` generates +`searchUsers(String term)`. The cast itself reaches the database as written. A +cast pattern states its own type, so the pattern restrictions above apply only +to an uncast pattern: `name NOT LIKE $1::text`, `name SIMILAR TO $1::text`, and +`name LIKE $1::text ESCAPE '!'` are accepted and typed `text`, whatever the +tested column's type is. Such a pattern is named after its placeholder rather +than after the tested column, so an indexed one is `param`, as in `param1` +for `$1`. [Parameters](#parameters) states the accepted cast types and the name +a cast placeholder takes. + ### Ordering `ORDER BY` over direct columns is supported for a stable list order, as in @@ -272,6 +304,22 @@ value binds no placeholder, such as `UPDATE authors SET version = version + 1`, generates a method without parameters. +An `INSERT` value or an `UPDATE` assignment may also compute a value from a cast +placeholder, which is typed by its cast wherever it appears inside the value: + +```sql +-- name: UpdateAuthorBioOrKeep :exec +UPDATE authors +SET bio = COALESCE(:bio::text, bio) +WHERE id = :id; +``` + +`UpdateAuthorBioOrKeep` generates `updateAuthorBioOrKeep(String bio, Long id)` +and keeps the assignment bound before the predicate. A cast placeholder that is +the whole value, as in `SET bio = :bio::text`, is typed by its cast as well and +named after its column. A value that contains an uncast placeholder, such as +`COALESCE($2, bio)`, stays rejected. + ### `RETURNING` A supported `INSERT`, `UPDATE`, or `DELETE` declared `:one`, `:optional`, or @@ -314,6 +362,26 @@ string literal, a quoted identifier, or a comment is not a parameter. `pageSize` rather than `limit`. - The Java type of a placeholder is the type of the column it is compared with, assigned to, or inserted into. +- A placeholder written as the direct operand of `::type` or + `CAST(... AS type)` is typed by that cast instead, wherever it appears inside + a `WHERE` clause, an `INSERT` value, or an `UPDATE` assignment. The cast type + may be any type a column may declare and sqlcj maps, including a declared + enum name, written unquoted and matched case-insensitively, and a + one-dimensional array, which binds a `List`. A cast type sqlcj does not map, + such as `INTERVAL` or a multi-dimensional `INT[][]`, is rejected naming the + placeholder and the type. +- A named cast placeholder keeps its own name. An indexed cast placeholder + keeps the name of the column whose value it is — a compared column, the + tested column of `IN` or `BETWEEN`, the tested column of a plain `LIKE` or + `ILIKE` pattern, which is the shape that accepts an uncast pattern, or an + inserted or assigned column — so `name = $1::text` names `name` as + `name = $1` does. Every other indexed cast placeholder is named `param` + after its own index, as in `param1` for `$1`, and colliding names take the + usual numeric suffix. +- Occurrences of one placeholder may mix a cast and an uncast location, and the + parameter keeps the name and type of its first occurrence, so + `(:name::text IS NULL OR name = :name)` is one `String` parameter named + `name`. - Mixing `$N` and `:name` placeholders in one query is rejected, as are anonymous `?` placeholders, a qualified name such as `:a.b`, a quoted name such as `:"x"`, and an `&name` placeholder. @@ -586,22 +654,30 @@ name, and its header line. is not a plain inner or left join, a join predicate that is not one qualified equality, and a set operation such as `UNION`. - A placeholder in a location sqlcj does not analyze, including `ORDER BY $1`, a - named placeholder under a function such as `lower(name) = :name` or under a - cast such as `:name::text`, a computed `LIKE` pattern such as + named placeholder under a function such as `lower(name) = :name`, a computed + `LIKE` pattern such as `'%' || $1 || '%'`, a placeholder as the tested value of a range such as `$1 BETWEEN id AND id`, a computed range bound such as `id BETWEEN $1 + 1 AND $2`, a computed pagination value such as `LIMIT $1 + 1` or `OFFSET $1 + 1`, a `LIMIT a, b` row count such as `LIMIT 5, $1`, a placeholder inside a write value such as `COALESCE($2, bio)`, and a `FETCH FIRST $1 ROWS ONLY` clause, so dynamic `IN` - expansion is unavailable. + expansion is unavailable. A cast does not widen these locations: a cast + placeholder in a projection, `ORDER BY`, a join condition, or a pagination + value, such as `ORDER BY $1::int`, stays rejected, as does a placeholder that + is not the direct operand of its cast, such as `(:x)::int`. +- A cast type sqlcj does not map, such as `$1::interval` or a + multi-dimensional `$1::int[][]`, which is rejected naming the placeholder and + the declared type. - A `LIKE`-family pattern placeholder that is negated, uses another keyword such as `SIMILAR TO`, carries an `ESCAPE` clause or a `BINARY` modifier, tests a - non-text column, or stands as the tested value. + non-text column, or stands as the tested value. These restrict the uncast + pattern placeholder; a cast pattern states its own type. - Anonymous `?` placeholders, `$N` and `:name` placeholders mixed in one query, a qualified name such as `:a.b`, a quoted name such as `:"x"`, an `&name` - placeholder, one name whose occurrences have conflicting types, and - non-contiguous or non-positive placeholder indexes. + placeholder, each of them also as the operand of a cast, such as + `:"x"::text`, `&x::text`, or `:a.b::text`, one name whose occurrences have + conflicting types, and non-contiguous or non-positive placeholder indexes. - An `INSERT` without an explicit column list, with more than one `VALUES` row, or built from a `SELECT`; an `UPDATE` assignment that sets a column list, such as `SET (name, active) = ('a', TRUE)`; and a written column that the table does @@ -841,6 +917,35 @@ Parameters: and `SqlcjCompilerIntegrationTest.shouldExecuteGeneratedQueryWithProtectedPlaceholderText` execute the compiled binding order. +- `QueryAnalyzerTest.shouldResolveNamedCastParameterOfOptionalFilter`, + `QueryAnalyzerTest.shouldResolveNamedCastParameterInsideComputedLikePattern`, + `QueryAnalyzerTest.shouldResolveNamedCastParameterInsideUpdateAssignment`, + `QueryAnalyzerTest.shouldResolveCastKeywordParameterNamedAfterItsComparedColumn`, + `QueryAnalyzerTest.shouldNameCastParameterAfterItsColumn`, + `QueryAnalyzerTest.shouldNameCastPatternAfterItsPlaceholderInAnUntypedPatternShape`, + `QueryAnalyzerTest.shouldNameNamedCastPatternAfterItsPlaceholder`, + `QueryAnalyzerTest.shouldResolveEnumCastParameter`, + `QueryAnalyzerTest.shouldResolveArrayCastParameterNamedAfterItsPlaceholder`, + `QueryAnalyzerTest.shouldResolveBlankPaddedCastParameter`, + `QueryAnalyzerTest.shouldNameIndexedCastParameterAfterItsIndex`, and + `QueryAnalyzerTest.shouldResolveCastParametersOfInsertValues` cover the cast + types, the analyzed cast locations, and the name a cast placeholder takes, + while `QueryAnalyzerTest.shouldRejectUnsupportedCastType`, + `QueryAnalyzerTest.shouldRejectCastOccurrenceWithConflictingType`, + `QueryAnalyzerTest.shouldRejectCastPlaceholderInUnanalyzedLocation`, and the + cast rows of + `QueryAnalyzerTest.shouldRejectUnsupportedNamedPlaceholderForm` cover the + cast rejections. +- `SqlParameterCompilerTest.shouldReportNamedParametersThatAreCastOperands` and + `SqlParameterCompilerTest.shouldNotReportCompiledNamedParameterThatIsACastOperand` + cover the cast operand the parser reports without a parse-tree node of its + own. +- `SqlcjCompilerIntegrationTest.shouldGenerateCompilableJavaForCastPlaceholders` + compiles a repository whose cast and uncast placeholders of one column + generate two disambiguated parameters, and + `PostgresIntegrationTest.shouldExecuteGeneratedCastTypedOptionalFilterAgainstPostgres` + executes a named cast-typed optional filter against PostgreSQL 16 with a null + and a non-null argument. Generated Java: