diff --git a/angel_orm/CHANGELOG.md b/angel_orm/CHANGELOG.md index d92ba10f..bf3e0adc 100644 --- a/angel_orm/CHANGELOG.md +++ b/angel_orm/CHANGELOG.md @@ -1,3 +1,7 @@ +# 2.1.0-beta.2 +* Add `expressions` to `Query`, to support custom SQL expressions that are +read as normal fields. + # 2.1.0-beta.1 * Calls to `leftJoin`, etc. alias all fields in a child query, to prevent `ambiguous column a0.id` errors. diff --git a/angel_orm/lib/src/query.dart b/angel_orm/lib/src/query.dart index d38ea343..6dbe979f 100644 --- a/angel_orm/lib/src/query.dart +++ b/angel_orm/lib/src/query.dart @@ -17,6 +17,10 @@ abstract class Query extends QueryBase { // the parent's context. final Query parent; + /// A map of field names to explicit SQL expressions. The expressions will be aliased + /// to the given names. + final Map expressions = {}; + String _crossJoin, _groupBy; int _limit, _offset; @@ -36,7 +40,13 @@ abstract class Query extends QueryBase { QueryValues get values; /// Preprends the [tableName] to the [String], [s]. - String adornWithTableName(String s) => '$tableName.$s'; + String adornWithTableName(String s) { + if (expressions.containsKey(s)) { + return '(${expressions[s]} AS $s)'; + } else { + return '$tableName.$s'; + } + } /// Returns a unique version of [name], which will not produce a collision within /// the context of this [query]. @@ -233,6 +243,10 @@ abstract class Query extends QueryBase { } else { f = List.from(fields.map((s) { var ss = includeTableName ? '$tableName.$s' : s; + if (expressions.containsKey(s)) { + // ss = '(' + expressions[s] + ')'; + ss = expressions[s]; + } var cast = casts[s]; if (cast != null) ss = 'CAST ($ss AS $cast)'; if (aliases.containsKey(s)) { @@ -241,6 +255,15 @@ abstract class Query extends QueryBase { } else { ss = '$ss AS ${aliases[s]}'; } + if (expressions.containsKey(s)) { + ss = '($ss)'; + } + } else if (expressions.containsKey(s)) { + if (cast != null) { + ss = '(($ss) AS $s)'; + } else { + ss = '($ss AS $s)'; + } } return ss; })); diff --git a/angel_orm/pubspec.yaml b/angel_orm/pubspec.yaml index 409ebfb1..09b60aa3 100644 --- a/angel_orm/pubspec.yaml +++ b/angel_orm/pubspec.yaml @@ -1,5 +1,5 @@ name: angel_orm -version: 2.1.0-beta.1 +version: 2.1.0-beta.2 description: Runtime support for Angel's ORM. Includes base classes for queries. author: Tobe O homepage: https://github.com/angel-dart/orm diff --git a/angel_orm_generator/lib/src/migration_generator.dart b/angel_orm_generator/lib/src/migration_generator.dart index d54f0cba..4bf36e83 100644 --- a/angel_orm_generator/lib/src/migration_generator.dart +++ b/angel_orm_generator/lib/src/migration_generator.dart @@ -82,6 +82,9 @@ class MigrationGenerator extends GeneratorForAnnotation { List dup = []; ctx.columns.forEach((name, col) { + // Skip custom-expression columns. + if (col.hasExpression) return; + var key = ctx.buildContext.resolveFieldName(name); if (dup.contains(key)) { diff --git a/angel_orm_generator/lib/src/orm_build_context.dart b/angel_orm_generator/lib/src/orm_build_context.dart index 4ceea5a0..84aa15da 100644 --- a/angel_orm_generator/lib/src/orm_build_context.dart +++ b/angel_orm_generator/lib/src/orm_build_context.dart @@ -291,6 +291,17 @@ Future buildOrmContext( if (column?.type == null) { throw 'Cannot infer SQL column type for field "${ctx.buildContext.originalClassName}.${field.name}" with type "${field.type.displayName}".'; } + + // Expressions... + column = Column( + isNullable: column.isNullable, + length: column.length, + type: column.type, + indexType: column.indexType, + expression: + ConstantReader(columnAnnotation).peek('expression')?.stringValue, + ); + ctx.columns[field.name] = column; if (!ctx.effectiveFields.any((f) => f.name == field.name)) { @@ -372,6 +383,14 @@ class OrmBuildContext { final Map relations = {}; OrmBuildContext(this.buildContext, this.ormAnnotation, this.tableName); + + bool isNotCustomExprField(FieldElement field) { + var col = columns[field.name]; + return col?.hasExpression != true; + } + + Iterable get effectiveNormalFields => + effectiveFields.where(isNotCustomExprField); } class _ColumnType implements ColumnType { diff --git a/angel_orm_generator/lib/src/orm_generator.dart b/angel_orm_generator/lib/src/orm_generator.dart index d2e13637..5bf32ffc 100644 --- a/angel_orm_generator/lib/src/orm_generator.dart +++ b/angel_orm_generator/lib/src/orm_generator.dart @@ -275,6 +275,16 @@ class OrmGenerator extends GeneratorForAnnotation { Code('trampoline.add(tableName);'), ]); + // Add any manual SQL expressions. + ctx.columns.forEach((name, col) { + if (col != null && col.hasExpression) { + var lhs = refer('expressions').index( + literalString(ctx.buildContext.resolveFieldName(name))); + var rhs = literalString(col.expression); + b.addExpression(lhs.assign(rhs)); + } + }); + // Add a constructor that initializes _where b.addExpression( refer('_where') @@ -499,7 +509,8 @@ class OrmGenerator extends GeneratorForAnnotation { ..annotations.add(refer('override')) ..type = MethodType.getter ..body = Block((b) { - var references = ctx.effectiveFields.map((f) => refer(f.name)); + var references = + ctx.effectiveNormalFields.map((f) => refer(f.name)); b.addExpression(literalList(references).returned); }); })); @@ -507,7 +518,7 @@ class OrmGenerator extends GeneratorForAnnotation { var initializers = []; // Add builders for each field - for (var field in ctx.effectiveFields) { + for (var field in ctx.effectiveNormalFields) { var name = field.name; var args = []; DartType type; @@ -620,7 +631,7 @@ class OrmGenerator extends GeneratorForAnnotation { })); // Each field generates a getter and setter - for (var field in ctx.effectiveFields) { + for (var field in ctx.effectiveNormalFields) { var fType = field.type; var name = ctx.buildContext.resolveFieldName(field.name); var type = convertTypeReference(field.type); @@ -684,7 +695,7 @@ class OrmGenerator extends GeneratorForAnnotation { ..name = 'model' ..type = ctx.buildContext.modelClassType)) ..body = Block((b) { - for (var field in ctx.effectiveFields) { + for (var field in ctx.effectiveNormalFields) { if (isSpecialId(ctx, field) || field is RelationFieldImpl) { continue; } @@ -692,7 +703,7 @@ class OrmGenerator extends GeneratorForAnnotation { .assign(refer('model').property(field.name))); } - for (var field in ctx.effectiveFields) { + for (var field in ctx.effectiveNormalFields) { if (field is RelationFieldImpl) { var original = field.originalFieldName; var prop = refer('model').property(original); diff --git a/angel_orm_postgres/pubspec.yaml b/angel_orm_postgres/pubspec.yaml index 2015c692..857d2ed1 100644 --- a/angel_orm_postgres/pubspec.yaml +++ b/angel_orm_postgres/pubspec.yaml @@ -15,6 +15,6 @@ dev_dependencies: path: ../angel_orm_test pretty_logging: ^1.0.0 test: ^1.0.0 -# dependency_overrides: -# angel_orm: -# path: ../angel_orm \ No newline at end of file +dependency_overrides: + angel_orm: + path: ../angel_orm \ No newline at end of file diff --git a/angel_orm_postgres/test/all_test.dart b/angel_orm_postgres/test/all_test.dart index 467cd0f5..8aad64c4 100644 --- a/angel_orm_postgres/test/all_test.dart +++ b/angel_orm_postgres/test/all_test.dart @@ -12,6 +12,8 @@ void main() { group('postgresql', () { group('belongsTo', () => belongsToTests(pg(['author', 'book']), close: closePg)); + group('customExpr', + () => customExprTests(pg(['custom_expr']), close: closePg)); group( 'edgeCase', () => edgeCaseTests(pg(['unorthodox', 'weird_join', 'song', 'numba']), diff --git a/angel_orm_postgres/test/migrations/custom_expr.sql b/angel_orm_postgres/test/migrations/custom_expr.sql new file mode 100644 index 00000000..ea7830c1 --- /dev/null +++ b/angel_orm_postgres/test/migrations/custom_expr.sql @@ -0,0 +1,13 @@ +CREATE TEMPORARY TABLE "numbers" ( + id serial PRIMARY KEY, + created_at timestamp, + updated_at timestamp +); + +CREATE TEMPORARY TABLE "alphabets" ( + id serial PRIMARY KEY, + value TEXT, + numbers_id int, + created_at timestamp, + updated_at timestamp +); \ No newline at end of file diff --git a/angel_orm_test/lib/angel_orm_test.dart b/angel_orm_test/lib/angel_orm_test.dart index fec4592c..2f1878ee 100644 --- a/angel_orm_test/lib/angel_orm_test.dart +++ b/angel_orm_test/lib/angel_orm_test.dart @@ -1,4 +1,5 @@ export 'src/belongs_to_test.dart'; +export 'src/custom_expr_test.dart'; export 'src/edge_case_test.dart'; export 'src/enum_and_nested_test.dart'; export 'src/has_many_test.dart'; diff --git a/angel_orm_test/lib/src/custom_expr_test.dart b/angel_orm_test/lib/src/custom_expr_test.dart new file mode 100644 index 00000000..a9c97b63 --- /dev/null +++ b/angel_orm_test/lib/src/custom_expr_test.dart @@ -0,0 +1,43 @@ +import 'dart:async'; +import 'package:angel_orm/angel_orm.dart'; +import 'package:test/test.dart'; +import 'models/custom_expr.dart'; +import 'util.dart'; + +customExprTests(FutureOr Function() createExecutor, + {FutureOr Function(QueryExecutor) close}) { + QueryExecutor executor; + Numbers numbersModel; + + close ??= (_) => null; + + setUp(() async { + executor = await createExecutor(); + + var now = DateTime.now(); + var nQuery = NumbersQuery(); + nQuery.values + ..createdAt = now + ..updatedAt = now; + numbersModel = await nQuery.insert(executor); + }); + + tearDown(() => close(executor)); + + test('fetches correct result', () async { + expect(numbersModel.two, 2); + }); + + test('in relation', () async { + var abcQuery = AlphabetQuery(); + abcQuery.values + ..value = 'abc' + ..numbersId = numbersModel.idAsInt + ..createdAt = numbersModel.createdAt + ..updatedAt = numbersModel.updatedAt; + var abc = await abcQuery.insert(executor); + expect(abc.numbers, numbersModel); + expect(abc.numbers.two, 2); + expect(abc.value, 'abc'); + }); +} diff --git a/angel_orm_test/lib/src/models/custom_expr.dart b/angel_orm_test/lib/src/models/custom_expr.dart new file mode 100644 index 00000000..23f732a2 --- /dev/null +++ b/angel_orm_test/lib/src/models/custom_expr.dart @@ -0,0 +1,21 @@ +import 'package:angel_migration/angel_migration.dart'; +import 'package:angel_model/angel_model.dart'; +import 'package:angel_orm/angel_orm.dart'; +import 'package:angel_serialize/angel_serialize.dart'; +part 'custom_expr.g.dart'; + +@serializable +@orm +class _Numbers extends Model { + @Column(expression: 'SELECT 2') + int two; +} + +@serializable +@orm +class _Alphabet extends Model { + String value; + + @belongsTo + _Numbers numbers; +} diff --git a/angel_orm_test/lib/src/models/custom_expr.g.dart b/angel_orm_test/lib/src/models/custom_expr.g.dart new file mode 100644 index 00000000..c397d105 --- /dev/null +++ b/angel_orm_test/lib/src/models/custom_expr.g.dart @@ -0,0 +1,538 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'custom_expr.dart'; + +// ************************************************************************** +// MigrationGenerator +// ************************************************************************** + +class NumbersMigration extends Migration { + @override + up(Schema schema) { + schema.create('numbers', (table) { + table.serial('id')..primaryKey(); + table.timeStamp('created_at'); + table.timeStamp('updated_at'); + }); + } + + @override + down(Schema schema) { + schema.drop('numbers'); + } +} + +class AlphabetMigration extends Migration { + @override + up(Schema schema) { + schema.create('alphabets', (table) { + table.serial('id')..primaryKey(); + table.timeStamp('created_at'); + table.timeStamp('updated_at'); + table.varChar('value'); + table + .declare('numbers_id', ColumnType('serial')) + .references('numbers', 'id'); + }); + } + + @override + down(Schema schema) { + schema.drop('alphabets'); + } +} + +// ************************************************************************** +// OrmGenerator +// ************************************************************************** + +class NumbersQuery extends Query { + NumbersQuery({Query parent, Set trampoline}) : super(parent: parent) { + trampoline ??= Set(); + trampoline.add(tableName); + expressions['two'] = 'SELECT 2'; + _where = NumbersQueryWhere(this); + } + + @override + final NumbersQueryValues values = NumbersQueryValues(); + + NumbersQueryWhere _where; + + @override + get casts { + return {}; + } + + @override + get tableName { + return 'numbers'; + } + + @override + get fields { + return const ['id', 'created_at', 'updated_at', 'two']; + } + + @override + NumbersQueryWhere get where { + return _where; + } + + @override + NumbersQueryWhere newWhereClause() { + return NumbersQueryWhere(this); + } + + static Numbers parseRow(List row) { + if (row.every((x) => x == null)) return null; + var model = Numbers( + id: row[0].toString(), + createdAt: (row[1] as DateTime), + updatedAt: (row[2] as DateTime), + two: (row[3] as int)); + return model; + } + + @override + deserialize(List row) { + return parseRow(row); + } +} + +class NumbersQueryWhere extends QueryWhere { + NumbersQueryWhere(NumbersQuery query) + : id = NumericSqlExpressionBuilder(query, 'id'), + createdAt = DateTimeSqlExpressionBuilder(query, 'created_at'), + updatedAt = DateTimeSqlExpressionBuilder(query, 'updated_at'); + + final NumericSqlExpressionBuilder id; + + final DateTimeSqlExpressionBuilder createdAt; + + final DateTimeSqlExpressionBuilder updatedAt; + + @override + get expressionBuilders { + return [id, createdAt, updatedAt]; + } +} + +class NumbersQueryValues extends MapQueryValues { + @override + get casts { + return {}; + } + + String get id { + return (values['id'] as String); + } + + set id(String value) => values['id'] = value; + DateTime get createdAt { + return (values['created_at'] as DateTime); + } + + set createdAt(DateTime value) => values['created_at'] = value; + DateTime get updatedAt { + return (values['updated_at'] as DateTime); + } + + set updatedAt(DateTime value) => values['updated_at'] = value; + void copyFrom(Numbers model) { + createdAt = model.createdAt; + updatedAt = model.updatedAt; + } +} + +class AlphabetQuery extends Query { + AlphabetQuery({Query parent, Set trampoline}) + : super(parent: parent) { + trampoline ??= Set(); + trampoline.add(tableName); + _where = AlphabetQueryWhere(this); + leftJoin(_numbers = NumbersQuery(trampoline: trampoline, parent: this), + 'numbers_id', 'id', + additionalFields: const ['id', 'created_at', 'updated_at', 'two'], + trampoline: trampoline); + } + + @override + final AlphabetQueryValues values = AlphabetQueryValues(); + + AlphabetQueryWhere _where; + + NumbersQuery _numbers; + + @override + get casts { + return {}; + } + + @override + get tableName { + return 'alphabets'; + } + + @override + get fields { + return const ['id', 'created_at', 'updated_at', 'value', 'numbers_id']; + } + + @override + AlphabetQueryWhere get where { + return _where; + } + + @override + AlphabetQueryWhere newWhereClause() { + return AlphabetQueryWhere(this); + } + + static Alphabet parseRow(List row) { + if (row.every((x) => x == null)) return null; + var model = Alphabet( + id: row[0].toString(), + createdAt: (row[1] as DateTime), + updatedAt: (row[2] as DateTime), + value: (row[3] as String)); + if (row.length > 5) { + model = model.copyWith( + numbers: NumbersQuery.parseRow(row.skip(5).take(4).toList())); + } + return model; + } + + @override + deserialize(List row) { + return parseRow(row); + } + + NumbersQuery get numbers { + return _numbers; + } +} + +class AlphabetQueryWhere extends QueryWhere { + AlphabetQueryWhere(AlphabetQuery query) + : id = NumericSqlExpressionBuilder(query, 'id'), + createdAt = DateTimeSqlExpressionBuilder(query, 'created_at'), + updatedAt = DateTimeSqlExpressionBuilder(query, 'updated_at'), + value = StringSqlExpressionBuilder(query, 'value'), + numbersId = NumericSqlExpressionBuilder(query, 'numbers_id'); + + final NumericSqlExpressionBuilder id; + + final DateTimeSqlExpressionBuilder createdAt; + + final DateTimeSqlExpressionBuilder updatedAt; + + final StringSqlExpressionBuilder value; + + final NumericSqlExpressionBuilder numbersId; + + @override + get expressionBuilders { + return [id, createdAt, updatedAt, value, numbersId]; + } +} + +class AlphabetQueryValues extends MapQueryValues { + @override + get casts { + return {}; + } + + String get id { + return (values['id'] as String); + } + + set id(String value) => values['id'] = value; + DateTime get createdAt { + return (values['created_at'] as DateTime); + } + + set createdAt(DateTime value) => values['created_at'] = value; + DateTime get updatedAt { + return (values['updated_at'] as DateTime); + } + + set updatedAt(DateTime value) => values['updated_at'] = value; + String get value { + return (values['value'] as String); + } + + set value(String value) => values['value'] = value; + int get numbersId { + return (values['numbers_id'] as int); + } + + set numbersId(int value) => values['numbers_id'] = value; + void copyFrom(Alphabet model) { + createdAt = model.createdAt; + updatedAt = model.updatedAt; + value = model.value; + if (model.numbers != null) { + values['numbers_id'] = model.numbers.id; + } + } +} + +// ************************************************************************** +// JsonModelGenerator +// ************************************************************************** + +@generatedSerializable +class Numbers extends _Numbers { + Numbers({this.id, this.createdAt, this.updatedAt, this.two}); + + /// A unique identifier corresponding to this item. + @override + String id; + + /// The time at which this item was created. + @override + DateTime createdAt; + + /// The last time at which this item was updated. + @override + DateTime updatedAt; + + @override + int two; + + Numbers copyWith( + {String id, DateTime createdAt, DateTime updatedAt, int two}) { + return Numbers( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + two: two ?? this.two); + } + + bool operator ==(other) { + return other is _Numbers && + other.id == id && + other.createdAt == createdAt && + other.updatedAt == updatedAt && + other.two == two; + } + + @override + int get hashCode { + return hashObjects([id, createdAt, updatedAt, two]); + } + + @override + String toString() { + return "Numbers(id=$id, createdAt=$createdAt, updatedAt=$updatedAt, two=$two)"; + } + + Map toJson() { + return NumbersSerializer.toMap(this); + } +} + +@generatedSerializable +class Alphabet extends _Alphabet { + Alphabet({this.id, this.createdAt, this.updatedAt, this.value, this.numbers}); + + /// A unique identifier corresponding to this item. + @override + String id; + + /// The time at which this item was created. + @override + DateTime createdAt; + + /// The last time at which this item was updated. + @override + DateTime updatedAt; + + @override + String value; + + @override + _Numbers numbers; + + Alphabet copyWith( + {String id, + DateTime createdAt, + DateTime updatedAt, + String value, + _Numbers numbers}) { + return Alphabet( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + value: value ?? this.value, + numbers: numbers ?? this.numbers); + } + + bool operator ==(other) { + return other is _Alphabet && + other.id == id && + other.createdAt == createdAt && + other.updatedAt == updatedAt && + other.value == value && + other.numbers == numbers; + } + + @override + int get hashCode { + return hashObjects([id, createdAt, updatedAt, value, numbers]); + } + + @override + String toString() { + return "Alphabet(id=$id, createdAt=$createdAt, updatedAt=$updatedAt, value=$value, numbers=$numbers)"; + } + + Map toJson() { + return AlphabetSerializer.toMap(this); + } +} + +// ************************************************************************** +// SerializerGenerator +// ************************************************************************** + +const NumbersSerializer numbersSerializer = NumbersSerializer(); + +class NumbersEncoder extends Converter { + const NumbersEncoder(); + + @override + Map convert(Numbers model) => NumbersSerializer.toMap(model); +} + +class NumbersDecoder extends Converter { + const NumbersDecoder(); + + @override + Numbers convert(Map map) => NumbersSerializer.fromMap(map); +} + +class NumbersSerializer extends Codec { + const NumbersSerializer(); + + @override + get encoder => const NumbersEncoder(); + @override + get decoder => const NumbersDecoder(); + static Numbers fromMap(Map map) { + return Numbers( + id: map['id'] as String, + createdAt: map['created_at'] != null + ? (map['created_at'] is DateTime + ? (map['created_at'] as DateTime) + : DateTime.parse(map['created_at'].toString())) + : null, + updatedAt: map['updated_at'] != null + ? (map['updated_at'] is DateTime + ? (map['updated_at'] as DateTime) + : DateTime.parse(map['updated_at'].toString())) + : null, + two: map['two'] as int); + } + + static Map toMap(_Numbers model) { + if (model == null) { + return null; + } + return { + 'id': model.id, + 'created_at': model.createdAt?.toIso8601String(), + 'updated_at': model.updatedAt?.toIso8601String(), + 'two': model.two + }; + } +} + +abstract class NumbersFields { + static const List allFields = [id, createdAt, updatedAt, two]; + + static const String id = 'id'; + + static const String createdAt = 'created_at'; + + static const String updatedAt = 'updated_at'; + + static const String two = 'two'; +} + +const AlphabetSerializer alphabetSerializer = AlphabetSerializer(); + +class AlphabetEncoder extends Converter { + const AlphabetEncoder(); + + @override + Map convert(Alphabet model) => AlphabetSerializer.toMap(model); +} + +class AlphabetDecoder extends Converter { + const AlphabetDecoder(); + + @override + Alphabet convert(Map map) => AlphabetSerializer.fromMap(map); +} + +class AlphabetSerializer extends Codec { + const AlphabetSerializer(); + + @override + get encoder => const AlphabetEncoder(); + @override + get decoder => const AlphabetDecoder(); + static Alphabet fromMap(Map map) { + return Alphabet( + id: map['id'] as String, + createdAt: map['created_at'] != null + ? (map['created_at'] is DateTime + ? (map['created_at'] as DateTime) + : DateTime.parse(map['created_at'].toString())) + : null, + updatedAt: map['updated_at'] != null + ? (map['updated_at'] is DateTime + ? (map['updated_at'] as DateTime) + : DateTime.parse(map['updated_at'].toString())) + : null, + value: map['value'] as String, + numbers: map['numbers'] != null + ? NumbersSerializer.fromMap(map['numbers'] as Map) + : null); + } + + static Map toMap(_Alphabet model) { + if (model == null) { + return null; + } + return { + 'id': model.id, + 'created_at': model.createdAt?.toIso8601String(), + 'updated_at': model.updatedAt?.toIso8601String(), + 'value': model.value, + 'numbers': NumbersSerializer.toMap(model.numbers) + }; + } +} + +abstract class AlphabetFields { + static const List allFields = [ + id, + createdAt, + updatedAt, + value, + numbers + ]; + + static const String id = 'id'; + + static const String createdAt = 'created_at'; + + static const String updatedAt = 'updated_at'; + + static const String value = 'value'; + + static const String numbers = 'numbers'; +}