Support selecting custom expressions

This commit is contained in:
Simon Binder 2019-11-16 16:38:02 +01:00
parent 598fef750e
commit 7609df34f0
No known key found for this signature in database
GPG Key ID: 7891917E4147B8C0
5 changed files with 126 additions and 41 deletions

View File

@ -73,6 +73,11 @@ abstract class Expression<D, T extends SqlType<D>> implements Component {
"Expressions with unknown precedence shouldn't have inner expressions");
inner.writeAroundPrecedence(ctx, precedence);
}
/// Finds the runtime implementation of [T] in the provided [types].
SqlType<D> findType(SqlTypeSystem types) {
return types.forDartType<D>();
}
}
/// Used to order the precedence of sql expressions so that we can avoid

View File

@ -77,6 +77,10 @@ class SimpleSelectStatement<T extends Table, D extends DataClass>
return statement;
}
JoinedSelectStatement addColumns(List<Expression> expressions) {
return join(const [])..addColumns(expressions);
}
/// Orders the result by the given clauses. The clauses coming first in the
/// list have a higher priority, the later clauses are only considered if the
/// first clause considers two rows to be equal.
@ -116,9 +120,10 @@ String _beginOfSelect(bool distinct) {
/// multiple entities.
class TypedResult {
/// Creates the result from the parsed table data.
TypedResult(this._parsedData, this.rawData);
TypedResult(this._parsedData, this.rawData, [this._parsedExpressions]);
final Map<TableInfo, dynamic> _parsedData;
final Map<Expression, dynamic> _parsedExpressions;
/// The raw data contained in this row.
final QueryRow rawData;
@ -127,4 +132,13 @@ class TypedResult {
D readTable<T extends Table, D extends DataClass>(TableInfo<T, D> table) {
return _parsedData[table] as D;
}
/// Reads a single column from an [expr].
/// todo expand documentation
D read<D, T extends SqlType<D>>(Expression<D, T> expr) {
if (_parsedExpressions != null) {
return _parsedExpressions[expr] as D;
}
return null;
}
}

View File

@ -1,22 +1,42 @@
part of '../../query_builder.dart';
/// A `SELECT` statement that operates on more than one table.
// this is called JoinedSelectStatement for legacy reasons - we also use it
// when custom expressions are used as result columns. Basically, it stores
// queries that are more complex than SimpleSelectStatement
class JoinedSelectStatement<FirstT extends Table, FirstD extends DataClass>
extends Query<FirstT, FirstD>
with LimitContainerMixin, Selectable<TypedResult> {
/// Whether to generate a `SELECT DISTINCT` query that will remove duplicate
/// rows from the result set.
final bool distinct;
/// Used internally by moor, users should use [SimpleSelectStatement.join]
/// instead.
JoinedSelectStatement(
QueryEngine database, TableInfo<FirstT, FirstD> table, this._joins,
[this.distinct = false])
: super(database, table);
: super(database, table) {
// use all columns across all tables as result column for this query
_selectedColumns ??=
_tables.expand((t) => t.$columns).cast<Expression>().toList();
}
/// Whether to generate a `SELECT DISTINCT` query that will remove duplicate
/// rows from the result set.
final bool distinct;
final List<Join> _joins;
/// All columns that we're selecting from.
List<Expression> _selectedColumns;
/// The `AS` aliases generated for each column that isn't from a table.
///
/// Each table column can be uniquely identified by its (potentially aliased)
/// table and its name. So a column named `id` in a table called `users` would
/// be written as `users.id AS "users.id"`. These columns will NOT be written
/// into this map.
///
/// Other expressions used as columns will be included here. There just named
/// in increasing order, so something like `AS c3`.
final Map<Expression, String> _columnAliases = {};
/// The tables this select statement reads from
@visibleForOverriding
Set<TableInfo> get watchedTables => _tables.toSet();
@ -30,27 +50,22 @@ class JoinedSelectStatement<FirstT extends Table, FirstD extends DataClass>
ctx.hasMultipleTables = true;
ctx.buffer..write(_beginOfSelect(distinct))..write(' ');
var isFirst = true;
for (var table in _tables) {
for (var column in table.$columns) {
if (!isFirst) {
ctx.buffer.write(', ');
}
// We run into problems when two tables have a column with the same name
// as we then wouldn't know which column is which. So, we create a
// column alias that matches what is expected by the mapping function
// in _getWithQuery by prefixing the table name.
// We might switch to parsing via the index of the column in a row in
// the future, but that's the solution for now.
column.writeInto(ctx);
ctx.buffer.write(' AS "');
column.writeInto(ctx, ignoreEscape: true);
ctx.buffer.write('"');
isFirst = false;
for (var i = 0; i < _selectedColumns.length; i++) {
if (i != 0) {
ctx.buffer.write(', ');
}
final column = _selectedColumns[i];
String chosenAlias;
if (column is GeneratedColumn) {
chosenAlias = '${column.tableName}.${column.$name}';
} else {
chosenAlias = 'c$i';
_columnAliases[column] = chosenAlias;
}
column.writeInto(ctx);
ctx.buffer..write(' AS "')..write(chosenAlias)..write('"');
}
ctx.buffer.write(' FROM ${table.tableWithAlias}');
@ -94,6 +109,18 @@ class JoinedSelectStatement<FirstT extends Table, FirstD extends DataClass>
orderByExpr = OrderBy(terms);
}
/// Adds a custom expression to the query.
///
/// The database will evaluate the [Expression] for each row found for this
/// query. The value of the expression can be extracted from the [TypedResult]
/// by passing it to [TypedResult.read].
///
/// See also:
/// - The docs on expressions: https://moor.simonbinder.eu/docs/getting-started/expressions/
void addColumns(Iterable<Expression> expressions) {
_selectedColumns.addAll(expressions);
}
@override
Stream<List<TypedResult>> watch() {
final ctx = constructQuery();
@ -131,19 +158,28 @@ class JoinedSelectStatement<FirstT extends Table, FirstD extends DataClass>
final tables = _tables;
return results.map((row) {
final map = <TableInfo, dynamic>{};
final readTables = <TableInfo, dynamic>{};
final readColumns = <Expression, dynamic>{};
for (var table in tables) {
for (final table in tables) {
final prefix = '${table.$tableName}.';
// if all columns of this table are null, skip the table
if (table.$columns.any((c) => row[prefix + c.$name] != null)) {
map[table] = table.map(row, tablePrefix: table.$tableName);
readTables[table] = table.map(row, tablePrefix: table.$tableName);
} else {
map[table] = null;
readTables[table] = null;
}
}
return TypedResult(map, QueryRow(row, database));
for (final aliasedColumn in _columnAliases.entries) {
final expr = aliasedColumn.key;
final value = row[aliasedColumn.value];
final type = expr.findType(ctx.typeSystem);
readColumns[expr] = type.mapFromDatabaseResponse(value);
}
return TypedResult(readTables, QueryRow(row, database), readColumns);
}).toList();
}

View File

@ -116,6 +116,31 @@ void main() {
argThat(contains('WHERE t.id < ? ORDER BY t.title ASC')), [3]));
});
test('supports custom columns and results', () async {
final categories = db.alias(db.categories, 'c');
final descriptionLength = categories.description.length;
final query = db.select(categories).addColumns([descriptionLength]);
when(executor.runSelect(any, any)).thenAnswer((_) async {
return [
{'c.id': 3, 'c.desc': 'Description', 'c2': 11}
];
});
final result = await query.getSingle();
verify(executor.runSelect(
'SELECT c.id AS "c.id", c.`desc` AS "c.desc", LENGTH(c.`desc`) AS "c2" '
'FROM categories c;',
[],
));
expect(result.readTable(categories),
equals(Category(id: 3, description: 'Description')));
expect(result.read(descriptionLength), 11);
});
test('injects custom error message when a table is used multiple times',
() async {
when(executor.runSelect(any, any)).thenAnswer((_) => Future.error('nah'));

View File

@ -12,21 +12,26 @@ environment:
sdk: '>=2.2.0 <3.0.0'
dependencies:
# todo it looks like we're not using any apis removed in 0.40.0, but I couldn't verify that (neither analyzer_plugin
# nor build supports 0.40.0 yet)
analyzer: '>=0.36.4 <0.40.0'
analyzer_plugin: '>=0.1.0 <0.3.0'
collection: ^1.14.0
recase: ^2.0.1
source_gen: ^0.9.4
source_span: ^1.5.5
build: ^1.1.0
logging: '>=0.11.0 <1.0.0'
build_config: '>=0.3.1 <1.0.0'
moor: ^2.0.1
meta: ^1.1.0
path: ^1.6.0
logging: '>=0.11.0 <1.0.0'
# Moor-specific analysis
moor: ^2.0.1
sqlparser: ^0.4.0
# Dart analysis
analyzer: '>=0.36.4 <0.40.0'
analyzer_plugin: '>=0.1.0 <0.3.0'
source_span: ^1.5.5
# Build system
build: ^1.1.0
build_config: '>=0.3.1 <1.0.0'
source_gen: ^0.9.4
dev_dependencies:
test: ^1.6.0
test_core: ^0.2.0