diff --git a/sqlparser/CHANGELOG.md b/sqlparser/CHANGELOG.md index ebb0fe81..3c9c578a 100644 --- a/sqlparser/CHANGELOG.md +++ b/sqlparser/CHANGELOG.md @@ -1,3 +1,6 @@ +## unreleased +- Handle special `rowid`, `oid`, `__rowid__` references + ## 0.3.0 - parse compound select statements - scan comment tokens diff --git a/sqlparser/lib/src/analysis/schema/column.dart b/sqlparser/lib/src/analysis/schema/column.dart index dc10cbe6..3cec6e8b 100644 --- a/sqlparser/lib/src/analysis/schema/column.dart +++ b/sqlparser/lib/src/analysis/schema/column.dart @@ -30,6 +30,53 @@ class TableColumn extends Column { Table table; TableColumn(this.name, this.type, {this.definition}); + + /// Whether this column is an alias for the rowid, as defined in + /// https://www.sqlite.org/lang_createtable.html#rowid + /// + /// To summarize, a column is an alias for the rowid if all of the following + /// conditions are met: + /// - the table has a primary key that consists of exactly one (this) column + /// - the column is declared to be an integer + /// - if this column has a [PrimaryKeyColumn], the [OrderingMode] of that + /// constraint is not [OrderingMode.descending]. + bool isAliasForRowId() { + if (definition == null || + table == null || + type?.type != BasicType.int || + table.withoutRowId) { + return false; + } + + // We need to check whether this column is a primary key, which could happen + // because of a table or a column constraint + for (var tableConstraint in table.tableConstraints.whereType()) { + if (!tableConstraint.isPrimaryKey) continue; + + final columns = tableConstraint.indexedColumns; + if (columns.length == 1 && columns.single.columnName == name) { + return true; + } + } + + // option 2: This column has a primary key constraint + for (var primaryConstraint in constraints.whereType()) { + if (primaryConstraint.mode == OrderingMode.descending) return false; + + // additional restriction: Column type must be exactly "INTEGER" + return definition.typeName == 'INTEGER'; + } + + return false; + } +} + +/// Refers to the special "rowid", "oid" or "_rowid_" column defined for tables +/// that weren't created with an `WITHOUT ROWID` clause. +class RowId extends TableColumn { + // note that such alias is always called "rowid" in the result set - + // "SELECT oid FROM table" yields a sinle column called "rowid" + RowId() : super('rowid', const ResolvedType(type: BasicType.int)); } /// A column that is created by an expression. For instance, in the select @@ -44,6 +91,18 @@ class ExpressionColumn extends Column { ExpressionColumn({@required this.name, this.expression}); } +/// A column that is created by a reference expression. The difference to an +/// [ExpressionColumn] is that the correct case of the column name depends on +/// the resolved reference. +class ReferenceExpressionColumn extends ExpressionColumn { + Reference get reference => expression as Reference; + + @override + String get name => reference.resolvedColumn.name; + + ReferenceExpressionColumn(Reference ref) : super(name: null, expression: ref); +} + /// The result column of a [CompoundSelectStatement]. class CompoundSelectColumn extends Column { /// The column in [CompoundSelectStatement.base] each of the diff --git a/sqlparser/lib/src/analysis/schema/table.dart b/sqlparser/lib/src/analysis/schema/table.dart index bc0f89c8..2364180c 100644 --- a/sqlparser/lib/src/analysis/schema/table.dart +++ b/sqlparser/lib/src/analysis/schema/table.dart @@ -1,5 +1,9 @@ part of '../analysis.dart'; +/// The aliases which can be used to refer to the rowid of a table. See +/// https://www.sqlite.org/lang_createtable.html#rowid +const aliasesForRowId = ['rowid', 'oid', '_rowid_']; + /// Something that will resolve to an [ResultSet] when referred to via /// the [ReferenceScope]. abstract class ResolvesToResultSet with Referencable { @@ -39,6 +43,8 @@ class Table with ResultSet, VisibleToChildren, HasMetaMixin { /// The ast node that created this table final CreateTableStatement definition; + TableColumn _rowIdColumn; + /// Constructs a table from the known [name] and [resolvedColumns]. Table( {@required this.name, @@ -48,6 +54,22 @@ class Table with ResultSet, VisibleToChildren, HasMetaMixin { this.definition}) { for (var column in resolvedColumns) { column.table = this; + + if (_rowIdColumn == null && column.isAliasForRowId()) { + _rowIdColumn = column; + } } } + + @override + Column findColumn(String name) { + final defaultSearch = super.findColumn(name); + if (defaultSearch != null) return defaultSearch; + + // handle aliases to rowids, see https://www.sqlite.org/lang_createtable.html#rowid + if (aliasesForRowId.contains(name.toLowerCase()) && !withoutRowId) { + return _rowIdColumn ?? RowId(); + } + return null; + } } diff --git a/sqlparser/lib/src/analysis/steps/column_resolver.dart b/sqlparser/lib/src/analysis/steps/column_resolver.dart index facddc1f..16506971 100644 --- a/sqlparser/lib/src/analysis/steps/column_resolver.dart +++ b/sqlparser/lib/src/analysis/steps/column_resolver.dart @@ -126,14 +126,23 @@ class ColumnResolver extends RecursiveVisitor { usedColumns.addAll(availableColumns); } } else if (resultColumn is ExpressionResultColumn) { - final name = _nameOfResultColumn(resultColumn); - final column = - ExpressionColumn(name: name, expression: resultColumn.expression); + final expression = resultColumn.expression; + Column column; + String name; + + if (expression is Reference) { + column = ReferenceExpressionColumn(expression); + if (resultColumn.as != null) name = resultColumn.as; + } else { + name = _nameOfResultColumn(resultColumn); + column = + ExpressionColumn(name: name, expression: resultColumn.expression); + } usedColumns.add(column); // make this column available if there is no other with the same name - if (!availableColumns.any((c) => c.name == name)) { + if (name != null && !availableColumns.any((c) => c.name == name)) { availableColumns.add(column); } } diff --git a/sqlparser/lib/src/analysis/steps/reference_resolver.dart b/sqlparser/lib/src/analysis/steps/reference_resolver.dart index 28bb5043..c9b5a8f8 100644 --- a/sqlparser/lib/src/analysis/steps/reference_resolver.dart +++ b/sqlparser/lib/src/analysis/steps/reference_resolver.dart @@ -42,6 +42,16 @@ class ReferenceResolver extends RecursiveVisitor { e.resolved = column; } } + } else if (aliasesForRowId.contains(e.columnName.toLowerCase())) { + // special case for aliases to a rowid + final column = _resolveRowIdAlias(e); + + if (column == null) { + context.reportError(AnalysisError( + type: AnalysisErrorType.referencedUnknownColumn, relevantNode: e)); + } else { + e.resolved = column; + } } else { // find any column with the referenced name. // todo special case for USING (...) in joins? @@ -67,6 +77,24 @@ class ReferenceResolver extends RecursiveVisitor { visitChildren(e); } + Column _resolveRowIdAlias(Reference e) { + // to resolve those aliases when they're not bound to a table, the + // surrounding select statement may only read from one table + final select = e.parents.firstWhere((node) => node is SelectStatement, + orElse: () => null) as SelectStatement; + + if (select == null) return null; + if (select.from.length != 1 || select.from.single is! TableReference) { + return null; + } + + final table = (select.from.single as TableReference).resolved as Table; + if (table == null) return null; + + // table.findColumn contains logic to resolve row id aliases + return table.findColumn(e.columnName); + } + @override void visitAggregateExpression(AggregateExpression e) { if (e.windowName != null && e.resolved == null) { diff --git a/sqlparser/test/analysis/data.dart b/sqlparser/test/analysis/data.dart index 533ab11d..0ae0d5ef 100644 --- a/sqlparser/test/analysis/data.dart +++ b/sqlparser/test/analysis/data.dart @@ -1,6 +1,14 @@ import 'package:sqlparser/sqlparser.dart'; -final id = TableColumn('id', const ResolvedType(type: BasicType.int)); +final id = TableColumn( + 'id', + const ResolvedType(type: BasicType.int), + definition: ColumnDefinition( + columnName: 'id', + typeName: 'INTEGER', + constraints: [PrimaryKeyColumn(null)], + ), +); final content = TableColumn('content', const ResolvedType(type: BasicType.text)); diff --git a/sqlparser/test/analysis/reference_resolver_test.dart b/sqlparser/test/analysis/reference_resolver_test.dart index 2e49eac1..11522f25 100644 --- a/sqlparser/test/analysis/reference_resolver_test.dart +++ b/sqlparser/test/analysis/reference_resolver_test.dart @@ -10,7 +10,8 @@ void main() { final engine = SqlEngine()..registerTable(demoTable); final context = - engine.analyze('SELECT id, d.content, *, 3 + 4 FROM demo AS d'); + engine.analyze('SELECT id, d.content, *, 3 + 4 FROM demo AS d ' + 'WHERE _rowid_ = 3'); final select = context.root as SelectStatement; final resolvedColumns = select.resolvedColumns; @@ -35,6 +36,9 @@ void main() { expect((firstColumn.expression as Reference).resolved, id); expect((secondColumn.expression as Reference).resolved, content); expect(from.resolved, demoTable); + + final where = select.where as BinaryExpression; + expect((where.left as Reference).resolved, id); }); test('resolves the column for order by clauses', () { @@ -60,6 +64,28 @@ void main() { ); }); + group('reports correct column name for rowid aliases', () { + final engine = SqlEngine() + ..registerTable(demoTable) + ..registerTable(anotherTable); + + test('when virtual id', () { + final context = engine.analyze('SELECT oid, _rowid_ FROM tbl'); + final select = context.root as SelectStatement; + final resolvedColumns = select.resolvedColumns; + + expect(resolvedColumns.map((c) => c.name), ['rowid', 'rowid']); + }); + + test('when alias to actual column', () { + final context = engine.analyze('SELECT oid, _rowid_ FROM demo'); + final select = context.root as SelectStatement; + final resolvedColumns = select.resolvedColumns; + + expect(resolvedColumns.map((c) => c.name), ['id', 'id']); + }); + }); + test('resolves sub-queries', () { final engine = SqlEngine()..registerTable(demoTable); diff --git a/sqlparser/test/analysis/schema/column_test.dart b/sqlparser/test/analysis/schema/column_test.dart new file mode 100644 index 00000000..146f9421 --- /dev/null +++ b/sqlparser/test/analysis/schema/column_test.dart @@ -0,0 +1,30 @@ +import 'package:sqlparser/sqlparser.dart'; +import 'package:test/test.dart'; + +void main() { + test('isAliasForRowId', () { + final engine = SqlEngine(); + final schemaParser = SchemaFromCreateTable(); + + final isAlias = { + 'CREATE TABLE x (id INTEGER PRIMARY KEY)': true, + 'CREATE TABLE x (id INTEGER PRIMARY KEY) WITHOUT ROWID': false, + 'CREATE TABLE x (id BIGINT PRIMARY KEY)': false, + 'CREATE TABLE x (id INTEGER PRIMARY KEY DESC)': false, + 'CREATE TABLE x (id INTEGER)': false, + 'CREATE TABLE x (id INTEGER, PRIMARY KEY (id))': true, + }; + + isAlias.forEach((createTblString, isAlias) { + final parsed = + engine.parse(createTblString).rootNode as CreateTableStatement; + final table = schemaParser.read(parsed); + + expect( + (table.findColumn('id') as TableColumn).isAliasForRowId(), + isAlias, + reason: '$createTblString: id is an alias? $isAlias', + ); + }); + }); +} diff --git a/sqlparser/test/analysis/schema/from_create_table.dart b/sqlparser/test/analysis/schema/from_create_table_test.dart similarity index 100% rename from sqlparser/test/analysis/schema/from_create_table.dart rename to sqlparser/test/analysis/schema/from_create_table_test.dart diff --git a/sqlparser/test/analysis/schema/table_test.dart b/sqlparser/test/analysis/schema/table_test.dart new file mode 100644 index 00000000..f122f830 --- /dev/null +++ b/sqlparser/test/analysis/schema/table_test.dart @@ -0,0 +1,37 @@ +import 'package:sqlparser/sqlparser.dart'; +import 'package:test/test.dart'; + +void main() { + group('finds columns', () { + final engine = SqlEngine(); + final schemaParser = SchemaFromCreateTable(); + + Column findWith(String createTbl, String columnName) { + final stmt = (engine.parse(createTbl).rootNode) as CreateTableStatement; + final table = schemaParser.read(stmt); + return table.findColumn(columnName); + } + + test('when declared in table', () { + expect(findWith('CREATE TABLE x (__rowid__ VARCHAR)', '__rowid__'), + isA()); + }); + + test('when alias to rowid', () { + final column = findWith('CREATE TABLE x (id INTEGER PRIMARY KEY)', 'oid'); + expect(column.name, 'id'); + expect(column, isA()); + }); + + test('when virtual rowid column', () { + final column = findWith('CREATE TABLE x (id VARCHAR)', 'oid'); + expect(column, isA()); + }); + + test('when not found', () { + final column = findWith( + 'CREATE TABLE x (id INTEGER PRIMARY KEY) WITHOUT ROWID', 'oid'); + expect(column, isNull); + }); + }); +}