import 'dart:async'; import 'package:charcode/ascii.dart'; import 'annotations.dart'; import 'builder.dart'; bool isAscii(int ch) => ch >= $nul && ch <= $del; /// A base class for objects that compile to SQL queries, typically within an ORM. abstract class QueryBase { /// Casts to perform when querying the database. Map get casts => {}; /// Values to insert into a prepared statement. final Map substitutionValues = {}; /// The table against which to execute this query. String get tableName; /// The list of fields returned by this query. /// /// If it's `null`, then this query will perform a `SELECT *`. List get fields; /// A String of all [fields], joined by a comma (`,`). String get fieldSet => fields.map((k) { var cast = casts[k]; return cast == null ? k : 'CAST ($k AS $cast)'; }).join(', '); String compile(Set trampoline, {bool includeTableName = false, String preamble, bool withFields = true}); T deserialize(List row); Future> get(QueryExecutor executor) async { var sql = compile(Set()); return executor .query(tableName, sql, substitutionValues) .then((it) => it.map(deserialize).toList()); } Future getOne(QueryExecutor executor) { return get(executor).then((it) => it.isEmpty ? null : it.first); } Union union(QueryBase other) { return new Union(this, other); } Union unionAll(QueryBase other) { return new Union(this, other, all: true); } } class OrderBy { final String key; final bool descending; const OrderBy(this.key, {this.descending = false}); String compile() => descending ? '$key DESC' : '$key ASC'; } /// The ORM prefers using substitution values, which allow for prepared queries, /// and prevent SQL injection attacks. @deprecated String toSql(Object obj, {bool withQuotes = true}) { if (obj is DateTime) { return withQuotes ? "'${dateYmdHms.format(obj)}'" : dateYmdHms.format(obj); } else if (obj is bool) { return obj ? 'TRUE' : 'FALSE'; } else if (obj == null) { return 'NULL'; } else if (obj is String) { var b = new StringBuffer(); var escaped = false; var it = obj.runes.iterator; while (it.moveNext()) { if (it.current == $nul) continue; // Skip null byte else if (it.current == $single_quote) { escaped = true; b.write('\\x'); b.write(it.current.toRadixString(16).padLeft(2, '0')); } else if (isAscii(it.current)) { b.writeCharCode(it.current); } else if (it.currentSize == 1) { escaped = true; b.write('\\u'); b.write(it.current.toRadixString(16).padLeft(4, '0')); } else if (it.currentSize == 2) { escaped = true; b.write('\\U'); b.write(it.current.toRadixString(16).padLeft(8, '0')); } else { throw new UnsupportedError( 'toSql() cannot encode a rune of size (${it.currentSize})'); } } if (!withQuotes) return b.toString(); else if (escaped) return "E'$b'"; else return "'$b'"; } else { return obj.toString(); } } /// A SQL `SELECT` query builder. abstract class Query extends QueryBase { final List _joins = []; final Map _names = {}; final List _orderBy = []; String _crossJoin, _groupBy; int _limit, _offset; /// A reference to an abstract query builder. /// /// This is usually a generated class. Where get where; /// A set of values, for an insertion or update. /// /// This is usually a generated class. QueryValues get values; /// Preprends the [tableName] to the [String], [s]. String adornWithTableName(String s) => '$tableName.$s'; /// Returns a unique version of [name], which will not produce a collision within /// the context of this [query]. String reserveName(String name) { var n = _names[name] ??= 0; _names[name]++; return n == 0 ? name : '${name}$n'; } /// Makes a new [Where] clause. Where newWhereClause() { throw new UnsupportedError( 'This instance does not support creating new WHERE clauses.'); } /// Determines whether this query can be compiled. /// /// Used to prevent ambiguities in joins. bool canCompile(Set trampoline) => true; /// Shorthand for calling [where].or with a new [Where] clause. void andWhere(void Function(Where) f) { var w = newWhereClause(); f(w); where.and(w); } /// Shorthand for calling [where].or with a new [Where] clause. void notWhere(void Function(Where) f) { var w = newWhereClause(); f(w); where.not(w); } /// Shorthand for calling [where].or with a new [Where] clause. void orWhere(void Function(Where) f) { var w = newWhereClause(); f(w); where.or(w); } /// Limit the number of rows to return. void limit(int n) { _limit = n; } /// Skip a number of rows in the query. void offset(int n) { _offset = n; } /// Groups the results by a given key. void groupBy(String key) { _groupBy = key; } /// Sorts the results by a key. void orderBy(String key, {bool descending = false}) { _orderBy.add(new OrderBy(key, descending: descending)); } /// Execute a `CROSS JOIN` (Cartesian product) against another table. void crossJoin(String tableName) { _crossJoin = tableName; } String _joinAlias(Set trampoline) { int i = _joins.length; while (true) { var a = 'a$i'; if (trampoline.add(a)) { return a; } else i++; } } String _compileJoin(tableName, Set trampoline) { if (tableName is String) return tableName; else if (tableName is Query) { var c = tableName.compile(trampoline); if (c == null) return c; return '($c)'; } else { throw ArgumentError.value( tableName, 'tableName', 'must be a String or Query'); } } void _makeJoin( tableName, Set trampoline, JoinType type, String localKey, String foreignKey, String op, List additionalFields) { trampoline ??= Set(); // Pivot tables guard against ambiguous fields by excluding tables // that have already been queried in this scope. if (trampoline.contains(tableName) && trampoline.contains(this.tableName)) { // ex. if we have {roles, role_users}, then don't join "roles" again. return; } var to = _compileJoin(tableName, trampoline); if (to != null) { _joins.add(new JoinBuilder(type, this, to, localKey, foreignKey, op: op, alias: _joinAlias(trampoline), additionalFields: additionalFields)); } } /// Execute an `INNER JOIN` against another table. void join(tableName, String localKey, String foreignKey, {String op = '=', List additionalFields = const [], Set trampoline}) { _makeJoin(tableName, trampoline, JoinType.inner, localKey, foreignKey, op, additionalFields); } /// Execute a `LEFT JOIN` against another table. void leftJoin(tableName, String localKey, String foreignKey, {String op = '=', List additionalFields = const [], Set trampoline}) { _makeJoin(tableName, trampoline, JoinType.left, localKey, foreignKey, op, additionalFields); } /// Execute a `RIGHT JOIN` against another table. void rightJoin(tableName, String localKey, String foreignKey, {String op = '=', List additionalFields = const [], Set trampoline}) { _makeJoin(tableName, trampoline, JoinType.right, localKey, foreignKey, op, additionalFields); } /// Execute a `FULL OUTER JOIN` against another table. void fullOuterJoin(tableName, String localKey, String foreignKey, {String op = '=', List additionalFields = const [], Set trampoline}) { _makeJoin(tableName, trampoline, JoinType.full, localKey, foreignKey, op, additionalFields); } /// Execute a `SELF JOIN`. void selfJoin(tableName, String localKey, String foreignKey, {String op = '=', List additionalFields = const [], Set trampoline}) { _makeJoin(tableName, trampoline, JoinType.self, localKey, foreignKey, op, additionalFields); } @override String compile(Set trampoline, {bool includeTableName = false, String preamble, bool withFields = true, String fromQuery}) { // One table MAY appear multiple times in a query. if (!canCompile(trampoline)) { return null; } includeTableName = includeTableName || _joins.isNotEmpty; var b = new StringBuffer(preamble ?? 'SELECT'); b.write(' '); List f; if (fields == null) { f = ['*']; } else { f = new List.from(fields.map((s) { var ss = includeTableName ? '$tableName.$s' : s; var cast = casts[s]; if (cast != null) ss = 'CAST ($ss AS $cast)'; return ss; })); _joins.forEach((j) { var additional = j.additionalFields.map(j.nameFor).toList(); // if (!additional.contains(j.fieldName)) // additional.insert(0, j.fieldName); f.addAll(additional); }); } if (withFields) b.write(f.join(', ')); fromQuery ??= tableName; b.write(' FROM $fromQuery'); // No joins if it's not a select. if (preamble == null) { if (_crossJoin != null) b.write(' CROSS JOIN $_crossJoin'); for (var join in _joins) { var c = join.compile(trampoline); if (c != null) b.write(' $c'); } } var whereClause = where.compile(tableName: includeTableName ? tableName : null); if (whereClause.isNotEmpty) b.write(' WHERE $whereClause'); if (_limit != null) b.write(' LIMIT $_limit'); if (_offset != null) b.write(' OFFSET $_offset'); if (_groupBy != null) b.write(' GROUP BY $_groupBy'); for (var item in _orderBy) b.write(' ORDER BY ${item.compile()}'); return b.toString(); } @override Future getOne(QueryExecutor executor) { //limit(1); return super.getOne(executor); } Future> delete(QueryExecutor executor) { var sql = compile(Set(), preamble: 'DELETE', withFields: false); if (_joins.isEmpty) { return executor .query(tableName, sql, substitutionValues, fields.map(adornWithTableName).toList()) .then((it) => it.map(deserialize).toList()); } else { return executor.transaction(() async { // TODO: Can this be done with just *one* query? var existing = await get(executor); //var sql = compile(preamble: 'SELECT $tableName.id', withFields: false); return executor .query(tableName, sql, substitutionValues) .then((_) => existing); }); } } Future deleteOne(QueryExecutor executor) { return delete(executor).then((it) => it.isEmpty ? null : it.first); } Future insert(QueryExecutor executor) { var insertion = values.compileInsert(this, tableName); if (insertion == null) { throw new StateError('No values have been specified for update.'); } else { // TODO: How to do this in a non-Postgres DB? var returning = fields.map(adornWithTableName).join(', '); var sql = compile(Set()); sql = 'WITH $tableName as ($insertion RETURNING $returning) ' + sql; return executor .query(tableName, sql, substitutionValues) .then((it) => it.isEmpty ? null : deserialize(it.first)); } } Future> update(QueryExecutor executor) async { var updateSql = new StringBuffer('UPDATE $tableName '); var valuesClause = values.compileForUpdate(this); if (valuesClause == null) { throw new StateError('No values have been specified for update.'); } else { updateSql.write(' $valuesClause'); var whereClause = where.compile(); if (whereClause.isNotEmpty) updateSql.write(' WHERE $whereClause'); if (_limit != null) updateSql.write(' LIMIT $_limit'); var returning = fields.map(adornWithTableName).join(', '); var sql = compile(Set()); sql = 'WITH $tableName as ($updateSql RETURNING $returning) ' + sql; return executor .query(tableName, sql, substitutionValues) .then((it) => it.map(deserialize).toList()); } } Future updateOne(QueryExecutor executor) { return update(executor).then((it) => it.isEmpty ? null : it.first); } } abstract class QueryValues { Map get casts => {}; Map toMap(); String applyCast(String name, String sub) { if (casts.containsKey(name)) { var type = casts[name]; return 'CAST ($sub as $type)'; } else { return sub; } } String compileInsert(Query query, String tableName) { var data = Map.from(toMap()); var keys = data.keys.toList(); keys.where((k) => !query.fields.contains(k)).forEach(data.remove); if (data.isEmpty) return null; var fieldSet = data.keys.join(', '); var b = new StringBuffer('INSERT INTO $tableName ($fieldSet) VALUES ('); int i = 0; for (var entry in data.entries) { if (i++ > 0) b.write(', '); var name = query.reserveName(entry.key); var s = applyCast(entry.key, '@$name'); query.substitutionValues[name] = entry.value; b.write(s); } b.write(')'); return b.toString(); } String compileForUpdate(Query query) { var data = toMap(); if (data.isEmpty) return null; var b = new StringBuffer('SET'); int i = 0; for (var entry in data.entries) { if (i++ > 0) b.write(','); b.write(' '); b.write(entry.key); b.write('='); var name = query.reserveName(entry.key); var s = applyCast(entry.key, '@$name'); query.substitutionValues[name] = entry.value; b.write(s); } return b.toString(); } } /// A [QueryValues] implementation that simply writes to a [Map]. class MapQueryValues extends QueryValues { final Map values = {}; @override Map toMap() => values; } /// Builds a SQL `WHERE` clause. abstract class QueryWhere { final Set _and = new Set(); final Set _not = new Set(); final Set _or = new Set(); Iterable get expressionBuilders; void and(QueryWhere other) { _and.add(other); } void not(QueryWhere other) { _not.add(other); } void or(QueryWhere other) { _or.add(other); } String compile({String tableName}) { var b = new StringBuffer(); int i = 0; for (var builder in expressionBuilders) { var key = builder.columnName; if (tableName != null) key = '$tableName.$key'; if (builder.hasValue) { if (i++ > 0) b.write(' AND '); if (builder is DateTimeSqlExpressionBuilder || (builder is JsonSqlExpressionBuilder && builder.hasRaw)) { if (tableName != null) b.write('$tableName.'); b.write(builder.compile()); } else { b.write('$key ${builder.compile()}'); } } } for (var other in _and) { var sql = other.compile(); if (sql.isNotEmpty) b.write(' AND $sql'); } for (var other in _not) { var sql = other.compile(); if (sql.isNotEmpty) b.write(' NOT $sql'); } for (var other in _or) { var sql = other.compile(); if (sql.isNotEmpty) b.write(' OR $sql'); } return b.toString(); } } /// Represents the `UNION` of two subqueries. class Union extends QueryBase { /// The subject(s) of this binary operation. final QueryBase left, right; /// Whether this is a `UNION ALL` operation. final bool all; @override final String tableName; Union(this.left, this.right, {this.all = false, String tableName}) : this.tableName = tableName ?? left.tableName { substitutionValues ..addAll(left.substitutionValues) ..addAll(right.substitutionValues); } @override List get fields => left.fields; @override T deserialize(List row) => left.deserialize(row); @override String compile(Set trampoline, {bool includeTableName = false, String preamble, bool withFields = true}) { var selector = all == true ? 'UNION ALL' : 'UNION'; var t1 = Set.from(trampoline); var t2 = Set.from(trampoline); return '(${left.compile(t1, includeTableName: includeTableName)}) $selector (${right.compile(t2, includeTableName: includeTableName)})'; } } /// Builds a SQL `JOIN` query. class JoinBuilder { final JoinType type; final Query from; final String to, key, value, op, alias; final List additionalFields; JoinBuilder(this.type, this.from, this.to, this.key, this.value, {this.op = '=', this.alias, this.additionalFields = const []}) { assert(to != null, 'computation of this join threw an error, and returned null.'); } String get fieldName { var right = '$to.$value'; if (alias != null) right = '$alias.$value'; return right; } String nameFor(String name) { var right = '$to.$name'; if (alias != null) right = '$alias.$name'; return right; } String compile(Set trampoline) { if (to == null) return null; var b = new StringBuffer(); var left = '${from.tableName}.$key'; var right = fieldName; switch (type) { case JoinType.inner: b.write(' INNER JOIN'); break; case JoinType.left: b.write(' LEFT JOIN'); break; case JoinType.right: b.write(' RIGHT JOIN'); break; case JoinType.full: b.write(' FULL OUTER JOIN'); break; case JoinType.self: b.write(' SELF JOIN'); break; } b.write(' $to'); if (alias != null) b.write(' $alias'); b.write(' ON $left$op$right'); return b.toString(); } } class JoinOn { final SqlExpressionBuilder key; final SqlExpressionBuilder value; JoinOn(this.key, this.value); } /// An abstract interface that performs queries. /// /// This class should be implemented. abstract class QueryExecutor { const QueryExecutor(); /// Executes a single query. Future> query( String tableName, String query, Map substitutionValues, [List returningFields]); /// Begins a database transaction. Future transaction(FutureOr f()); }