Properly resolve RETURNING columns

This commit is contained in:
Simon Binder 2021-03-30 11:03:12 +02:00
parent 0842a47d31
commit 5ac0582280
No known key found for this signature in database
GPG Key ID: 7891917E4147B8C0
5 changed files with 90 additions and 25 deletions

View File

@ -1,6 +1,7 @@
## 0.15.1-dev ## 0.15.1-dev
- New analysis checks for `RETURNING`: Disallow `table.*` syntax and aggregate expressions - New analysis checks for `RETURNING`: Disallow `table.*` syntax and aggregate expressions
- Fix resolving columns when `RETURNING` is used in an `UPDATE FROM` statement
## 0.15.0 ## 0.15.0

View File

@ -48,12 +48,23 @@ class ReferenceScope {
} }
set availableColumns(List<Column>? value) { set availableColumns(List<Column>? value) {
_availableColumns = value; // guard against lists of subtype of column
if (value != null) {
_availableColumns = <Column>[...value];
} else {
_availableColumns = null;
}
} }
ReferenceScope(this.parent, ReferenceScope(this.parent,
{this.root, this.inheritAvailableColumns = false}); {this.root, this.inheritAvailableColumns = false});
void addAvailableColumn(Column column) {
// make sure _availableColumns is resolved and mutable
final ownColumns = _availableColumns ??= <Column>[...availableColumns];
ownColumns.add(column);
}
ReferenceScope createChild({bool? inheritAvailableColumns}) { ReferenceScope createChild({bool? inheritAvailableColumns}) {
// wonder why we're creating a linked list of reference scopes instead of // wonder why we're creating a linked list of reference scopes instead of
// just passing down a copy of [_references]? In sql, some variables can be // just passing down a copy of [_references]? In sql, some variables can be

View File

@ -90,35 +90,57 @@ class ColumnResolver extends RecursiveVisitor<void, void> {
if (child != e.table && child != e.from) visit(child, arg); if (child != e.table && child != e.from) visit(child, arg);
} }
_resolveReturningClause(e); _resolveReturningClause(e, baseTable);
} }
void _addIfResolved(AstNode node, TableReference ref) { ResultSet? _addIfResolved(AstNode node, TableReference ref) {
final table = _resolveTableReference(ref); final table = _resolveTableReference(ref);
if (table != null) { if (table != null) {
node.scope.availableColumns = table.resolvedColumns; node.scope.availableColumns = table.resolvedColumns;
} }
return table;
} }
@override @override
void visitInsertStatement(InsertStatement e, void arg) { void visitInsertStatement(InsertStatement e, void arg) {
_addIfResolved(e, e.table); final into = _addIfResolved(e, e.table);
visitChildren(e, arg); visitChildren(e, arg);
_resolveReturningClause(e); _resolveReturningClause(e, into);
} }
@override @override
void visitDeleteStatement(DeleteStatement e, void arg) { void visitDeleteStatement(DeleteStatement e, void arg) {
_addIfResolved(e, e.from!); final from = _addIfResolved(e, e.from);
visitChildren(e, arg); visitChildren(e, arg);
_resolveReturningClause(e); _resolveReturningClause(e, from);
} }
void _resolveReturningClause(StatementReturningColumns stmt) { /// Infers the result set of a `RETURNING` clause.
///
/// The behavior of `RETURNING` clauses is a bit weird when there are multiple
/// tables available (which can happen with `UPDATE FROM`). When a star column
/// is used, it only expands to columns from the main table:
/// ```sql
/// CREATE TABLE x (a, b);
/// -- here, the `*` in returning does not include columns from `old`.
/// UPDATE x SET a = x.a + 1 FROM (SELECT * FROM x) AS old RETURNING *;
/// ```
///
/// However, individual columns from other tables are available and supported:
/// ```sql
/// UPDATE x SET a = x.a + 1 FROM (SELECT * FROM x) AS old
/// RETURNING old.a, old.b;
/// ```
///
/// Note that `old.*` is forbidden by sqlite and not applicable here.
void _resolveReturningClause(
StatementReturningColumns stmt, ResultSet? mainTable) {
final clause = stmt.returning; final clause = stmt.returning;
if (clause == null) return; if (clause == null) return;
final columns = _resolveColumns(stmt.scope, clause.columns); final columns = _resolveColumns(stmt.scope, clause.columns,
columnsForStar: mainTable?.resolvedColumns);
stmt.returnedResultSet = CustomResultSet(columns); stmt.returnedResultSet = CustomResultSet(columns);
} }
@ -210,10 +232,10 @@ class ColumnResolver extends RecursiveVisitor<void, void> {
s.resolvedColumns = _resolveColumns(scope, s.columns); s.resolvedColumns = _resolveColumns(scope, s.columns);
} }
List<Column> _resolveColumns( List<Column> _resolveColumns(ReferenceScope scope, List<ResultColumn> columns,
ReferenceScope scope, List<ResultColumn> columns) { {List<Column>? columnsForStar}) {
final usedColumns = <Column>[]; final usedColumns = <Column>[];
final availableColumns = scope.availableColumns; final availableColumns = <Column>[...scope.availableColumns];
// a select statement can include everything from its sub queries as a // a select statement can include everything from its sub queries as a
// result, but also expressions that appear as result columns // result, but also expressions that appear as result columns
@ -234,9 +256,9 @@ class ColumnResolver extends RecursiveVisitor<void, void> {
visibleColumnsForStar = tableResolver.resultSet!.resolvedColumns; visibleColumnsForStar = tableResolver.resultSet!.resolvedColumns;
} else { } else {
// we have a * column without a table, that resolves to every columns // we have a * column without a table, that resolves to every column
// available // available
visibleColumnsForStar = availableColumns; visibleColumnsForStar = columnsForStar ?? availableColumns;
} }
usedColumns usedColumns
@ -262,6 +284,7 @@ class ColumnResolver extends RecursiveVisitor<void, void> {
final name = resultColumn.as; final name = resultColumn.as;
if (!availableColumns.any((c) => c.name == name)) { if (!availableColumns.any((c) => c.name == name)) {
availableColumns.add(column); availableColumns.add(column);
scope.addAvailableColumn(column);
} }
} }
} else if (resultColumn is NestedStarResultColumn) { } else if (resultColumn is NestedStarResultColumn) {

View File

@ -7,12 +7,12 @@ import 'statement.dart';
class DeleteStatement extends CrudStatement class DeleteStatement extends CrudStatement
implements StatementWithWhere, StatementReturningColumns, HasPrimarySource { implements StatementWithWhere, StatementReturningColumns, HasPrimarySource {
TableReference? from; TableReference from;
@override @override
Expression? where; Expression? where;
@override @override
TableReference? get table => from; TableReference get table => from;
@override @override
Returning? returning; Returning? returning;
@ -31,7 +31,7 @@ class DeleteStatement extends CrudStatement
@override @override
void transformChildren<A>(Transformer<A> transformer, A arg) { void transformChildren<A>(Transformer<A> transformer, A arg) {
withClause = transformer.transformNullableChild(withClause, this, arg); withClause = transformer.transformNullableChild(withClause, this, arg);
from = transformer.transformChild(from!, this, arg); from = transformer.transformChild(from, this, arg);
where = transformer.transformNullableChild(where, this, arg); where = transformer.transformNullableChild(where, this, arg);
returning = transformer.transformNullableChild(returning, this, arg); returning = transformer.transformNullableChild(returning, this, arg);
} }
@ -39,7 +39,7 @@ class DeleteStatement extends CrudStatement
@override @override
Iterable<AstNode> get childNodes => [ Iterable<AstNode> get childNodes => [
if (withClause != null) withClause!, if (withClause != null) withClause!,
from!, from,
if (where != null) where!, if (where != null) where!,
if (returning != null) returning!, if (returning != null) returning!,
]; ];

View File

@ -4,7 +4,7 @@ import 'package:test/test.dart';
import 'data.dart'; import 'data.dart';
void main() { void main() {
final engine = SqlEngine(); final engine = SqlEngine(EngineOptions(version: SqliteVersion.v3_35));
engine.registerTable(demoTable); engine.registerTable(demoTable);
group('CREATE TRIGGER statements', () { group('CREATE TRIGGER statements', () {
@ -158,12 +158,42 @@ INSERT INTO demo VALUES (?, ?)
expect(result.errors, isEmpty); expect(result.errors, isEmpty);
}); });
test('resolves RETURNING clause', () { group('resolves RETURNING clause', () {
final result = test('for simple inserts', () {
engine.analyze("INSERT INTO demo (content) VALUES ('hi') RETURNING *;"); final result = engine
final returning = (result.root as InsertStatement).returnedResultSet; .analyze("INSERT INTO demo (content) VALUES ('hi') RETURNING *;");
final returning = (result.root as InsertStatement).returnedResultSet;
expect(returning, isNotNull); expect(returning, isNotNull);
expect(returning!.resolvedColumns!.map((e) => e.name), ['id', 'content']); expect(returning!.resolvedColumns!.map((e) => e.name), ['id', 'content']);
});
test('for custom expressions', () {
final result = engine.analyze("INSERT INTO demo (content) VALUES ('hi') "
'RETURNING content || content AS x;');
final returning = (result.root as InsertStatement).returnedResultSet!;
expect(returning.resolvedColumns!.map((e) => e.name), ['x']);
});
test('star does not include other tables', () {
final result = engine.analyze('''
UPDATE demo SET content = ''
FROM (SELECT * FROM demo) AS old
RETURNING *;
''');
final returning = (result.root as UpdateStatement).returnedResultSet!;
expect(returning.resolvedColumns!.map((e) => e.name), ['id', 'content']);
});
test('can refer to columns from other tables', () {
final result = engine.analyze('''
UPDATE demo SET content = ''
FROM (SELECT * FROM demo) AS old
RETURNING old.id, old.content;
''');
expect(result.errors, isEmpty);
});
}); });
} }