Skip to content

Commit edc5a0a

Browse files
authored
Merge pull request #77 from sqlcj-dev/feature/subscripted-array-columns-as-expressions
feat(analysis): treat subscripted array columns as expressions
2 parents 8919f88 + 76d6a7b commit edc5a0a

5 files changed

Lines changed: 470 additions & 39 deletions

File tree

‎docs/postgresql.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,11 @@ generates `List<String> tags` and `List<StageSetting> past`.
368368
index.
369369
- A `LIKE`/`ILIKE` pattern placeholder still requires a scalar `VARCHAR` or
370370
`TEXT` column, so an array column is rejected there.
371+
- A reference written with a subscript or a slice, such as `tags[1]`,
372+
`tags[1:2]`, or `s.tags[1]`, reads one element or one range rather than the
373+
column, so it is not a direct column anywhere: it types no result column and
374+
no placeholder, and a cast states the type of either. See
375+
[Queries](queries.md#projections).
371376
- The `= ANY` list predicate is the one analyzed array operator: a placeholder
372377
written as `<column> = ANY($1)` binds a list of the compared column's type,
373378
which must be a non-array column of an element type above or of a declared

‎docs/queries.md‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ configuration entry.
128128

129129
- A direct column of a source is selected by its own name or qualified with the
130130
exposed source name.
131+
- A reference written with an array subscript or slice, such as `labels[1]`,
132+
`labels[1:2]`, or `s.labels[1]`, is not a direct column: it reads one element
133+
or one range of the array column rather than the column, so it is rejected
134+
like any other computed projection unless a cast states its type, as in
135+
`labels[1]::text AS first` or `labels[1:2]::varchar[] AS pair`.
131136
- `*` expands across the sources in their declared order, each source in its
132137
schema column order.
133138
- `qualifier.*` expands one source in its schema column order.
@@ -228,6 +233,16 @@ A `WHERE` clause may combine:
228233
- `= ANY` between a direct column and one `$N` list placeholder, such as
229234
`id = ANY($1)`.
230235

236+
A direct column is a column reference written without an array subscript or
237+
slice. A subscripted or sliced reference, such as `labels[1]`, `labels[1:2]`, or
238+
`u.labels[1]`, is an expression of the array column, so no placeholder beside
239+
one is typed from that column: `labels[1] = $1` is rejected as an unanalyzed
240+
placeholder location, in a comparison of either order, an `IN` list, a `LIKE`
241+
pattern, a `BETWEEN` bound, an `= ANY` list, a join `ON` condition, and a
242+
write's `WHERE` alike. A cast states the type of such a placeholder, as in
243+
`WHERE labels[1] = $1::text`, and the subscript itself reaches the database as
244+
written.
245+
231246
A `LIKE` or `ILIKE` pattern placeholder requires a `VARCHAR` or `TEXT` column
232247
and takes that column's type, so it is a `String` method parameter. sqlcj passes
233248
the pattern through unchanged, so the caller supplies the `%` and `_` wildcards
@@ -408,6 +423,15 @@ FROM authors
408423
WHERE id = $1;
409424
```
410425

426+
A written column may carry an array subscript or slice, as in
427+
`SET labels[1] = $1::text` or `INSERT INTO stages (id, labels[1])`. Such a
428+
target is not a direct column: it writes one element or one range, so its value
429+
is not typed from the array column and a placeholder value needs a cast, while
430+
the array column itself must exist in the written table and the subscript
431+
reaches the database as written. An uncast placeholder written there is rejected
432+
as an unanalyzed placeholder location, in an `UPDATE` or `DO UPDATE` assignment
433+
and in an `INSERT` value alike.
434+
411435
### Values that bind no placeholder
412436

413437
An `INSERT` value or an `UPDATE` assignment that contains no placeholder — such
@@ -565,7 +589,10 @@ is rejected as
565589
`Unsupported RETURNING item <expression>; return columns of the target table or *`,
566590
naming the expression as the query writes it, on one line. A cast does not make
567591
such an item supported, because a `RETURNING` clause returns columns of the
568-
target table.
592+
target table. A reference written with an array subscript or slice, such as
593+
`RETURNING labels[1]` or `RETURNING labels[1:2] AS pair`, is not a direct
594+
column of the target table either, so it is rejected as a computed item, with or
595+
without an alias.
569596

570597
Multi-row `VALUES`, `INSERT ... SELECT`, `UPDATE ... FROM`, and
571598
`DELETE ... USING` are rejected in every write, and common table expressions in
@@ -616,6 +643,11 @@ string literal, a quoted identifier, or a comment is not a parameter.
616643
Every other indexed cast placeholder is named `param<N>`
617644
after its own index, as in `param1` for `$1`, and colliding names take the
618645
usual numeric suffix.
646+
- A column written with an array subscript or slice names no parameter, because
647+
the parameter beside it is one element rather than the column's value, so an
648+
indexed cast placeholder there is `param<N>` and a named one keeps its own
649+
name: `labels[1] = $1::text` binds `param1`, where `labels = $1::text` would
650+
bind `labels`.
619651
- Every placeholder occurrence binds in textual order across the clauses and
620652
nesting depths above, whatever order sqlcj analyzes those clauses in, so a
621653
projected placeholder binds before a `WHERE` one and
@@ -943,6 +975,18 @@ query name, and its header line.
943975
`SELECT` for `SELECT $1 AS x`. A cast states the type of such a placeholder in
944976
every one of those locations, and it does not accept a placeholder that is not
945977
the direct operand of its cast, such as `(:x)::int`.
978+
- A column reference written with an array subscript or slice wherever a direct
979+
column is required, because it reads one element or one range rather than the
980+
column: `SELECT labels[1] AS first` and `SELECT labels[1:2] AS pair` are
981+
rejected as computed projections, `RETURNING labels[1]` as a computed returned
982+
item, and an uncast placeholder beside one — `labels[1] = $1`,
983+
`$1 = labels[1]`, `labels[1] IN ($1, $2)`, `labels[1] LIKE $1`,
984+
`scores[1] BETWEEN $1 AND $2`, `labels[1] = ANY($1)`, a join
985+
`ON u.labels[1] = $1`, a write's `WHERE labels[1] = $1`, a
986+
`DO UPDATE ... WHERE stages.labels[1] = $1`, `SET labels[1] = $1`, and
987+
`INSERT INTO stages (id, labels[1]) VALUES ($1, $2)` — as an unanalyzed
988+
placeholder location. A cast states the type in each of those locations, and
989+
the array column of a subscripted write target must still exist.
946990
- A cast type sqlcj does not map, such as `$1::interval` or a
947991
multi-dimensional `$1::int[][]`, which is rejected naming the placeholder and
948992
the declared type, or the result column and the declared type when the cast
@@ -1446,6 +1490,22 @@ Parameters:
14461490
`PostgresIntegrationTest.shouldExecuteGeneratedCastTypedOptionalFilterAgainstPostgres`
14471491
executes a named cast-typed optional filter against PostgreSQL 16 with a null
14481492
and a non-null argument.
1493+
- `QueryAnalyzerTest.shouldRejectASubscriptedProjection`,
1494+
`QueryAnalyzerTest.shouldAnalyzeACastSubscriptedProjection`,
1495+
`QueryAnalyzerTest.shouldRejectASubscriptedReturningItem`,
1496+
`QueryAnalyzerTest.shouldRejectAnUncastPlaceholderBesideASubscriptedColumn`,
1497+
`QueryAnalyzerTest.shouldNameACastPlaceholderBesideASubscriptedColumnAfterItself`,
1498+
`QueryAnalyzerTest.shouldNameACastPlaceholderOfASubscriptedWriteTargetAfterItself`,
1499+
`QueryAnalyzerTest.shouldRejectAnUnknownSubscriptedWriteTarget`, and
1500+
`QueryAnalyzerTest.shouldAnalyzeALiteralAssignedToASubscriptedColumn` cover a
1501+
subscripted and a sliced reference in qualified and unqualified spellings:
1502+
the projection and `RETURNING` rejections, the cast projection that states its
1503+
type, the untyped-placeholder diagnostic in each typing location, the name a
1504+
cast placeholder keeps beside one, the write target whose array column must
1505+
exist, and the non-binding value assigned to one, and
1506+
`PostgresIntegrationTest.shouldReadAndFilterASubscriptedArrayElement` executes
1507+
a cast subscript projection and a cast subscript filter against PostgreSQL 16
1508+
as one `String` component and one `String` method parameter.
14491509

14501510
Generated Java:
14511511

‎sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java‎

Lines changed: 75 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -669,8 +669,8 @@ private String resolveReturningRowTable(ReturningClause returningClause, Source
669669
/**
670670
* Resolves one returned item, which is either a bare {@code *} or a direct
671671
* column of the write target, whose alias names its result component. A
672-
* computed item has no schema type to generate, so it is rejected instead
673-
* of analyzed.
672+
* computed item, a subscripted reference included, has no schema type to
673+
* generate, so it is rejected instead of analyzed.
674674
*/
675675
private List<QueryColumn> resolveReturningItem(Expression expression, Alias alias, Source source) {
676676
if (expression instanceof AllTableColumns) {
@@ -695,8 +695,11 @@ private List<QueryColumn> resolveReturningItem(Expression expression, Alias alia
695695
return resolveAllColumns(source);
696696
}
697697

698-
if (expression instanceof net.sf.jsqlparser.schema.Column column) {
699-
dev.sqlcj.schema.Column schemaColumn = resolveColumn(column, List.of(source)).column();
698+
net.sf.jsqlparser.schema.Column returnedColumn = directColumn(expression);
699+
700+
if (returnedColumn != null) {
701+
dev.sqlcj.schema.Column schemaColumn = resolveColumn(returnedColumn, List.of(source))
702+
.column();
700703

701704
return List.of(
702705
new QueryColumn(
@@ -808,6 +811,10 @@ private void requireNoCommonTableExpressions(List<?> withItems) {
808811
* list with the single values row, which is also their textual order. A
809812
* value that binds no placeholder reaches the database as written, so only
810813
* its target column must exist.
814+
*
815+
* <p>A target written with a subscript inserts into one element of its
816+
* array column, so its value is not typed from that column, which must
817+
* still exist.
811818
*/
812819
private void resolveInsertParameters(
813820
Insert insert,
@@ -829,13 +836,12 @@ private void resolveInsertParameters(
829836
}
830837

831838
for (int index = 0; index < columns.size(); index++) {
832-
String columnName = columns.get(index).getUnquotedColumnName();
839+
net.sf.jsqlparser.schema.Column target = columns.get(index);
840+
dev.sqlcj.schema.Column column = findColumn(table, target.getUnquotedColumnName());
833841

834-
resolveColumnValue(
835-
values.get(index),
836-
findColumn(table, columnName),
837-
placeholders
838-
);
842+
if (directColumn(target) != null) {
843+
resolveColumnValue(values.get(index), column, placeholders);
844+
}
839845
}
840846
}
841847

@@ -857,6 +863,10 @@ private ParenthesedExpressionList<?> resolveInsertValues(Insert insert) {
857863
* parameter in the {@code WHERE} expression. An assigned value that binds
858864
* no placeholder reaches the database as written, so only its target
859865
* column must exist.
866+
*
867+
* <p>A target written with a subscript assigns one element of its array
868+
* column, so its value is not typed from that column, which must still
869+
* exist.
860870
*/
861871
private void resolveUpdateSetParameters(
862872
List<UpdateSet> updateSets,
@@ -870,13 +880,12 @@ private void resolveUpdateSetParameters(
870880
);
871881
}
872882

873-
String columnName = updateSet.getColumn(0).getUnquotedColumnName();
883+
net.sf.jsqlparser.schema.Column target = updateSet.getColumn(0);
884+
dev.sqlcj.schema.Column column = findColumn(table, target.getUnquotedColumnName());
874885

875-
resolveColumnValue(
876-
updateSet.getValue(0),
877-
findColumn(table, columnName),
878-
placeholders
879-
);
886+
if (directColumn(target) != null) {
887+
resolveColumnValue(updateSet.getValue(0), column, placeholders);
888+
}
880889
}
881890
}
882891

@@ -1724,7 +1733,9 @@ private void resolveParameters(
17241733
}
17251734

17261735
private void resolveInExpression(InExpression in, List<Source> sources, Placeholders placeholders) {
1727-
if (!(in.getLeftExpression() instanceof net.sf.jsqlparser.schema.Column column)) {
1736+
net.sf.jsqlparser.schema.Column column = directColumn(in.getLeftExpression());
1737+
1738+
if (column == null) {
17281739
return;
17291740
}
17301741

@@ -1854,11 +1865,13 @@ private void resolveLikeExpression(
18541865
if (pattern != null) {
18551866
requireSupportedLikePattern(like);
18561867

1857-
if (!(left instanceof net.sf.jsqlparser.schema.Column column)) {
1868+
net.sf.jsqlparser.schema.Column testedColumn = directColumn(left);
1869+
1870+
if (testedColumn == null) {
18581871
return;
18591872
}
18601873

1861-
dev.sqlcj.schema.Column schemaColumn = resolveColumn(column, sources).column();
1874+
dev.sqlcj.schema.Column schemaColumn = resolveColumn(testedColumn, sources).column();
18621875

18631876
requireSupportedType(schemaColumn);
18641877

@@ -1872,15 +1885,16 @@ private void resolveLikeExpression(
18721885
}
18731886

18741887
CastPlaceholder castPattern = placeholders.ofCast(right);
1888+
net.sf.jsqlparser.schema.Column testedColumn = directColumn(left);
18751889

18761890
if (
18771891
castPattern != null
18781892
&& unsupportedLikePatternReason(like) == null
1879-
&& left instanceof net.sf.jsqlparser.schema.Column column
1893+
&& testedColumn != null
18801894
) {
18811895
addCastParameter(
18821896
castPattern,
1883-
resolveColumn(column, sources).column().name(),
1897+
resolveColumn(testedColumn, sources).column().name(),
18841898
placeholders
18851899
);
18861900
}
@@ -1983,11 +1997,10 @@ private void resolveBetweenExpression(
19831997
return;
19841998
}
19851999

1986-
if (
1987-
left instanceof net.sf.jsqlparser.schema.Column column
1988-
&& (bindsValue(start, placeholders) || bindsValue(end, placeholders))
1989-
) {
1990-
dev.sqlcj.schema.Column schemaColumn = resolveColumn(column, sources).column();
2000+
net.sf.jsqlparser.schema.Column testedColumn = directColumn(left);
2001+
2002+
if (testedColumn != null && (bindsValue(start, placeholders) || bindsValue(end, placeholders))) {
2003+
dev.sqlcj.schema.Column schemaColumn = resolveColumn(testedColumn, sources).column();
19912004

19922005
resolveColumnValue(start, schemaColumn, placeholders);
19932006
resolveColumnValue(end, schemaColumn, placeholders);
@@ -2013,52 +2026,55 @@ private void resolveParameterComparison(
20132026
Placeholder leftPlaceholder = placeholders.of(left);
20142027
Placeholder rightPlaceholder = placeholders.of(right);
20152028

2016-
if (left instanceof net.sf.jsqlparser.schema.Column column && rightPlaceholder != null) {
2029+
net.sf.jsqlparser.schema.Column leftColumn = directColumn(left);
2030+
net.sf.jsqlparser.schema.Column rightColumn = directColumn(right);
2031+
2032+
if (leftColumn != null && rightPlaceholder != null) {
20172033
addParameter(
20182034
rightPlaceholder,
2019-
resolveColumn(column, sources).column(),
2035+
resolveColumn(leftColumn, sources).column(),
20202036
placeholders
20212037
);
20222038
return;
20232039
}
20242040

2025-
if (leftPlaceholder != null && right instanceof net.sf.jsqlparser.schema.Column column) {
2041+
if (leftPlaceholder != null && rightColumn != null) {
20262042
addParameter(
20272043
leftPlaceholder,
2028-
resolveColumn(column, sources).column(),
2044+
resolveColumn(rightColumn, sources).column(),
20292045
placeholders
20302046
);
20312047
return;
20322048
}
20332049

2034-
if (comparison instanceof EqualsTo && left instanceof net.sf.jsqlparser.schema.Column column) {
2050+
if (comparison instanceof EqualsTo && leftColumn != null) {
20352051
Expression listArgument = anyListArgument(right, placeholders);
20362052

20372053
if (listArgument != null) {
2038-
resolveAnyListParameter(listArgument, column, sources, placeholders);
2054+
resolveAnyListParameter(listArgument, leftColumn, sources, placeholders);
20392055
return;
20402056
}
20412057
}
20422058

20432059
if (isColumnTypedComparison(comparison)) {
2044-
if (left instanceof net.sf.jsqlparser.schema.Column column) {
2060+
if (leftColumn != null) {
20452061
CastPlaceholder cast = placeholders.ofCast(right);
20462062

20472063
if (cast != null) {
20482064
addCastParameter(
20492065
cast,
2050-
resolveColumn(column, sources).column().name(),
2066+
resolveColumn(leftColumn, sources).column().name(),
20512067
placeholders
20522068
);
20532069
return;
20542070
}
2055-
} else if (right instanceof net.sf.jsqlparser.schema.Column column) {
2071+
} else if (rightColumn != null) {
20562072
CastPlaceholder cast = placeholders.ofCast(left);
20572073

20582074
if (cast != null) {
20592075
addCastParameter(
20602076
cast,
2061-
resolveColumn(column, sources).column().name(),
2077+
resolveColumn(rightColumn, sources).column().name(),
20622078
placeholders
20632079
);
20642080
}
@@ -2372,6 +2388,9 @@ private Placeholders toPlaceholders(ParsedSql parsedSql, Schema schema) {
23722388
* <p>A bare placeholder projection is a placeholder a cast makes a
23732389
* supported result column, so it is rejected as an untyped placeholder
23742390
* rather than as an unsupported result expression.
2391+
*
2392+
* <p>A subscripted reference projects an element rather than the column, so
2393+
* it is rejected as any other computed item is, and a cast states its type.
23752394
*/
23762395
private List<QueryColumn> resolveColumns(
23772396
PlainSelect plainSelect,
@@ -2405,8 +2424,10 @@ private List<QueryColumn> resolveColumns(
24052424
continue;
24062425
}
24072426

2408-
if (expression instanceof net.sf.jsqlparser.schema.Column column) {
2409-
ResolvedColumn resolved = resolveColumn(column, sources);
2427+
net.sf.jsqlparser.schema.Column selectedColumn = directColumn(expression);
2428+
2429+
if (selectedColumn != null) {
2430+
ResolvedColumn resolved = resolveColumn(selectedColumn, sources);
24102431
dev.sqlcj.schema.Column schemaColumn = resolved.column();
24112432

24122433
columns.add(
@@ -2551,6 +2572,22 @@ private String selectedColumnName(Alias alias, dev.sqlcj.schema.Column schemaCol
25512572
: alias.getUnquotedName();
25522573
}
25532574

2575+
/**
2576+
* Reports the column one expression reads directly, and {@code null} when
2577+
* the expression reads none. A reference written with a subscript or a
2578+
* slice, such as {@code labels[1]} or {@code labels[1:2]}, reads one
2579+
* element or one range of the column's array value rather than the column,
2580+
* so it states neither the type nor the name of the column it reads, and
2581+
* every rule that takes a type or a name from a column leaves it to the
2582+
* rules of any other expression.
2583+
*/
2584+
private net.sf.jsqlparser.schema.Column directColumn(Expression expression) {
2585+
return expression instanceof net.sf.jsqlparser.schema.Column column
2586+
&& column.getArrayConstructor() == null
2587+
? column
2588+
: null;
2589+
}
2590+
25542591
/**
25552592
* Resolves one column reference against the ordered query sources. A
25562593
* qualified reference resolves through the exposed source name, and an

0 commit comments

Comments
 (0)