diff --git a/angel_orm/lib/angel_orm.dart b/angel_orm/lib/angel_orm.dart index b7931d80..91952278 100644 --- a/angel_orm/lib/angel_orm.dart +++ b/angel_orm/lib/angel_orm.dart @@ -1,5 +1,15 @@ export 'src/annotations.dart'; export 'src/builder.dart'; +export 'src/join_builder.dart'; +export 'src/join_on.dart'; +export 'src/map_query_values.dart'; export 'src/migration.dart'; -export 'src/relations.dart'; +export 'src/order_by.dart'; +export 'src/query_base.dart'; +export 'src/query_executor.dart'; +export 'src/query_values.dart'; +export 'src/query_where.dart'; export 'src/query.dart'; +export 'src/relations.dart'; +export 'src/union.dart'; +export 'src/util.dart'; diff --git a/angel_orm/lib/src/builder.dart b/angel_orm/lib/src/builder.dart index eac463d5..aaca4914 100644 --- a/angel_orm/lib/src/builder.dart +++ b/angel_orm/lib/src/builder.dart @@ -1,9 +1,9 @@ import 'dart:convert'; - import 'package:charcode/ascii.dart'; import 'package:intl/intl.dart' show DateFormat; import 'package:string_scanner/string_scanner.dart'; import 'query.dart'; +import 'util.dart'; final DateFormat dateYmd = DateFormat('yyyy-MM-dd'); final DateFormat dateYmdHms = DateFormat('yyyy-MM-dd HH:mm:ss'); diff --git a/angel_orm/lib/src/join_builder.dart b/angel_orm/lib/src/join_builder.dart new file mode 100644 index 00000000..5b591de6 --- /dev/null +++ b/angel_orm/lib/src/join_builder.dart @@ -0,0 +1,58 @@ +import 'annotations.dart'; +import 'query.dart'; + +/// 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 = 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(); + } +} \ No newline at end of file diff --git a/angel_orm/lib/src/join_on.dart b/angel_orm/lib/src/join_on.dart new file mode 100644 index 00000000..acf3ee11 --- /dev/null +++ b/angel_orm/lib/src/join_on.dart @@ -0,0 +1,8 @@ +import 'builder.dart'; + +class JoinOn { + final SqlExpressionBuilder key; + final SqlExpressionBuilder value; + + JoinOn(this.key, this.value); +} \ No newline at end of file diff --git a/angel_orm/lib/src/map_query_values.dart b/angel_orm/lib/src/map_query_values.dart new file mode 100644 index 00000000..e69de29b diff --git a/angel_orm/lib/src/order_by.dart b/angel_orm/lib/src/order_by.dart new file mode 100644 index 00000000..4501cf45 --- /dev/null +++ b/angel_orm/lib/src/order_by.dart @@ -0,0 +1,8 @@ +class OrderBy { + final String key; + final bool descending; + + const OrderBy(this.key, {this.descending = false}); + + String compile() => descending ? '$key DESC' : '$key ASC'; +} diff --git a/angel_orm/lib/src/query.dart b/angel_orm/lib/src/query.dart index 481f5702..5427ef18 100644 --- a/angel_orm/lib/src/query.dart +++ b/angel_orm/lib/src/query.dart @@ -1,115 +1,11 @@ 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 Union(this, other); - } - - Union unionAll(QueryBase other) { - return 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 = 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 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(); - } -} +import 'join_builder.dart'; +import 'order_by.dart'; +import 'query_base.dart'; +import 'query_executor.dart'; +import 'query_values.dart'; +import 'query_where.dart'; /// A SQL `SELECT` query builder. abstract class Query extends QueryBase { @@ -424,241 +320,3 @@ abstract class Query extends QueryBase { 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 = 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 = 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 = Set(); - final Set _not = Set(); - final Set _or = 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 = 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 = 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()); -} diff --git a/angel_orm/lib/src/query_base.dart b/angel_orm/lib/src/query_base.dart new file mode 100644 index 00000000..8e378636 --- /dev/null +++ b/angel_orm/lib/src/query_base.dart @@ -0,0 +1,50 @@ +import 'dart:async'; +import 'query_executor.dart'; +import 'union.dart'; + +/// 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 Union(this, other); + } + + Union unionAll(QueryBase other) { + return Union(this, other, all: true); + } +} diff --git a/angel_orm/lib/src/query_executor.dart b/angel_orm/lib/src/query_executor.dart new file mode 100644 index 00000000..558a4d9d --- /dev/null +++ b/angel_orm/lib/src/query_executor.dart @@ -0,0 +1,16 @@ +import 'dart:async'; + +/// 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()); +} diff --git a/angel_orm/lib/src/query_values.dart b/angel_orm/lib/src/query_values.dart new file mode 100644 index 00000000..d576060b --- /dev/null +++ b/angel_orm/lib/src/query_values.dart @@ -0,0 +1,67 @@ +import 'query.dart'; + +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 = 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 = 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; +} diff --git a/angel_orm/lib/src/query_where.dart b/angel_orm/lib/src/query_where.dart new file mode 100644 index 00000000..4006b170 --- /dev/null +++ b/angel_orm/lib/src/query_where.dart @@ -0,0 +1,59 @@ +import 'builder.dart'; + +/// Builds a SQL `WHERE` clause. +abstract class QueryWhere { + final Set _and = Set(); + final Set _not = Set(); + final Set _or = 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 = 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(); + } +} diff --git a/angel_orm/lib/src/union.dart b/angel_orm/lib/src/union.dart new file mode 100644 index 00000000..048ebf31 --- /dev/null +++ b/angel_orm/lib/src/union.dart @@ -0,0 +1,37 @@ +import 'query_base.dart'; + +/// 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)})'; + } +} diff --git a/angel_orm/lib/src/util.dart b/angel_orm/lib/src/util.dart new file mode 100644 index 00000000..cbe814ce --- /dev/null +++ b/angel_orm/lib/src/util.dart @@ -0,0 +1,57 @@ +import 'dart:async'; +import 'package:charcode/ascii.dart'; +import 'annotations.dart'; +import 'builder.dart'; +import 'query_base.dart'; + +bool isAscii(int ch) => ch >= $nul && ch <= $del; + +/// 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 = 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 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(); + } +}