From 4bc50eca694b08d2ea167c9f5b4b9b4e50bd9c02 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Sat, 20 Apr 2019 17:30:48 -0400 Subject: [PATCH] orm_service tests --- angel_orm_generator/CHANGELOG.md | 3 + .../lib/src/orm_generator.dart | 6 +- angel_orm_service/LICENSE | 21 + angel_orm_service/lib/angel_orm_service.dart | 32 +- angel_orm_service/test/all_test.dart | 233 ++++++++++ angel_orm_service/test/pokemon.dart | 33 ++ angel_orm_service/test/pokemon.g.dart | 405 ++++++++++++++++++ 7 files changed, 726 insertions(+), 7 deletions(-) create mode 100644 angel_orm_service/LICENSE create mode 100644 angel_orm_service/test/all_test.dart create mode 100644 angel_orm_service/test/pokemon.dart create mode 100644 angel_orm_service/test/pokemon.g.dart diff --git a/angel_orm_generator/CHANGELOG.md b/angel_orm_generator/CHANGELOG.md index 38ac91ec..2b623383 100644 --- a/angel_orm_generator/CHANGELOG.md +++ b/angel_orm_generator/CHANGELOG.md @@ -1,3 +1,6 @@ +# 2.0.1 +* Gracefully handle `null` in enum fields. + # 2.0.0+2 * Widen `analyzer` dependency range. diff --git a/angel_orm_generator/lib/src/orm_generator.dart b/angel_orm_generator/lib/src/orm_generator.dart index 25a68ca4..61cfe087 100644 --- a/angel_orm_generator/lib/src/orm_generator.dart +++ b/angel_orm_generator/lib/src/orm_generator.dart @@ -192,7 +192,9 @@ class OrmGenerator extends GeneratorForAnnotation { .property('tryParse') .call([expr.property('toString').call([])]); } else if (fType is InterfaceType && fType.element.isEnum) { - expr = type.property('values').index(expr.asA(refer('int'))); + var isNull = expr.equalTo(literalNull); + expr = isNull.conditional(literalNull, + type.property('values').index(expr.asA(refer('int')))); } else expr = expr.asA(type); @@ -691,7 +693,7 @@ class OrmGenerator extends GeneratorForAnnotation { Expression value = refer('value'); if (fType is InterfaceType && fType.element.isEnum) { - value = value.property('index'); + value = CodeExpression(Code('value?.index')); } else if (const TypeChecker.fromRuntime(List) .isAssignableFromType(fType)) { value = refer('json').property('encode').call([value]); diff --git a/angel_orm_service/LICENSE b/angel_orm_service/LICENSE new file mode 100644 index 00000000..89074fd3 --- /dev/null +++ b/angel_orm_service/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 The Angel Framework + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/angel_orm_service/lib/angel_orm_service.dart b/angel_orm_service/lib/angel_orm_service.dart index 22c8071b..4e6ab6b1 100644 --- a/angel_orm_service/lib/angel_orm_service.dart +++ b/angel_orm_service/lib/angel_orm_service.dart @@ -63,7 +63,21 @@ class OrmService> await queryObj(query); } else if (queryObj is Map) { queryObj.forEach((k, v) { - if (k is String && v is! RequestContext && v is! ResponseContext) { + if (k == r'$sort') { + if (v is Map) { + v.forEach((key, value) { + var descending = false; + if (value is String) + descending = value == '-1'; + else if (value is num) descending = value.toInt() == -1; + query.orderBy(key.toString(), descending: descending); + }); + } else if (v is String) { + query.orderBy(v); + } + } else if (k is String && + v is! RequestContext && + v is! ResponseContext) { _apply(query, k, v); } }); @@ -74,6 +88,10 @@ class OrmService> @override Future> readMany(List ids, [Map params]) async { + if (ids.isEmpty) { + throw ArgumentError.value(ids, 'ids', 'cannot be empty'); + } + var query = await queryCreator(); var builder = _findBuilder(query, idField); @@ -134,7 +152,6 @@ class OrmService> @override Future modify(Id id, Data data, [Map params]) { - // TODO: Is there any way to make this an actual modify, and not an update? return update(id, data, params); } @@ -151,7 +168,10 @@ class OrmService> '${query.values.runtimeType} has no `copyFrom` method, but OrmService requires this for updates.'); } - return await query.updateOne(executor); + var result = await query.updateOne(executor); + if (result != null) return result; + throw new AngelHttpException.notFound( + message: 'No record found for ID $id'); } @override @@ -170,7 +190,9 @@ class OrmService> await _applyQuery(query, params); } - var deleted = await query.delete(executor); - return deleted.isEmpty ? null : deleted.first; + var result = await query.deleteOne(executor); + if (result != null) return result; + throw new AngelHttpException.notFound( + message: 'No record found for ID $id'); } } diff --git a/angel_orm_service/test/all_test.dart b/angel_orm_service/test/all_test.dart new file mode 100644 index 00000000..9e23770c --- /dev/null +++ b/angel_orm_service/test/all_test.dart @@ -0,0 +1,233 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_orm_postgres/angel_orm_postgres.dart'; +import 'package:angel_orm_service/angel_orm_service.dart'; +import 'package:logging/logging.dart'; +import 'package:postgres/postgres.dart'; +import 'package:test/test.dart'; +import 'pokemon.dart'; + +void main() { + Logger logger; + PostgreSqlExecutor executor; + Service pokemonService; + + setUp(() async { + var conn = PostgreSQLConnection('localhost', 5432, 'angel_orm_service_test', + username: Platform.environment['POSTGRES_USERNAME'] ?? 'postgres', + password: Platform.environment['POSTGRES_PASSWORD'] ?? 'password'); + hierarchicalLoggingEnabled = true; + logger = Logger.detached('orm_service'); + logger.level = Level.ALL; + if (Platform.environment['log'] == '1') logger.onRecord.listen(print); + executor = PostgreSqlExecutor(conn, logger: logger); + await conn.open(); + await conn.query(''' + CREATE TEMPORARY TABLE pokemons ( + id serial, + species varchar, + name varchar, + level integer, + type1 integer, + type2 integer, + created_at timestamp, + updated_at timestamp + ); + '''); + + pokemonService = OrmService(executor, () => PokemonQuery()); + }); + + tearDown(() async { + await executor.close(); + pokemonService.close(); + logger.clearListeners(); + }); + + test('create', () async { + var blaziken = await pokemonService.create(Pokemon( + species: 'Blaziken', + level: 100, + type1: PokemonType.fire, + type2: PokemonType.fighting)); + print(blaziken); + expect(blaziken.id, isNotNull); + expect(blaziken.species, 'Blaziken'); + expect(blaziken.level, 100); + expect(blaziken.type1, PokemonType.fire); + expect(blaziken.type2, PokemonType.fighting); + }); + + group('after create', () { + Pokemon giratina, pikachu; + + setUp(() async { + giratina = await pokemonService.create(Pokemon( + species: 'Giratina', + name: 'My First Legendary', + level: 54, + type1: PokemonType.ghost, + type2: PokemonType.dragon)); + pikachu = await pokemonService.create(Pokemon( + species: 'Pikachu', + level: 100, + type1: PokemonType.electric, + )); + }); + + group('index', () { + test('default', () async { + expect(await pokemonService.index(), contains(giratina)); + expect(await pokemonService.index(), contains(pikachu)); + }); + + test('with callback', () async { + var result = await pokemonService.index({ + 'query': (PokemonQuery query) async { + query.where.level.equals(pikachu.level); + }, + }); + + expect(result, [pikachu]); + }); + + test('search params', () async { + Future> searchByType1(PokemonType type1) async { + var query = {PokemonFields.type1: type1}; + var params = {'query': query}; + return await pokemonService.index(params); + } + + expect(await searchByType1(PokemonType.ghost), [giratina]); + expect(await searchByType1(PokemonType.electric), [pikachu]); + expect(await searchByType1(PokemonType.grass), []); + }); + + group(r'$sort', () { + test('by name', () async { + expect( + await pokemonService.index({ + 'query': {r'$sort': 'level'} + }), + [giratina, pikachu]); + }); + + test('map number', () async { + expect( + await pokemonService.index({ + 'query': { + r'$sort': {'type1': -1} + } + }), + [giratina, pikachu]); + expect( + await pokemonService.index({ + 'query': { + r'$sort': {'type1': 100} + } + }), + [pikachu, giratina]); + }); + + test('map string', () async { + expect( + await pokemonService.index({ + 'query': { + r'$sort': {'type1': '-1'} + } + }), + [giratina, pikachu]); + expect( + await pokemonService.index({ + 'query': { + r'$sort': {'type1': 'foo'} + } + }), + [pikachu, giratina]); + }); + }); + }); + + group('findOne', () { + test('default', () async { + expect( + await pokemonService.findOne({ + 'query': {PokemonFields.name: giratina.name} + }), + giratina); + expect( + await pokemonService.findOne({ + 'query': {PokemonFields.level: pikachu.level} + }), + pikachu); + expect( + () => pokemonService.findOne({ + 'query': {PokemonFields.level: pikachu.level * 3} + }), + throwsA(TypeMatcher())); + }); + + test('nonexistent throws 404', () { + expect( + () => pokemonService.findOne({ + 'query': {PokemonFields.type1: PokemonType.poison} + }), + throwsA(TypeMatcher())); + }); + }); + + group('read', () { + test('default', () async { + expect(await pokemonService.read(pikachu.idAsInt), pikachu); + expect(await pokemonService.read(giratina.idAsInt), giratina); + }); + + test('nonexistent throws 404', () { + expect(() => pokemonService.read(999), + throwsA(TypeMatcher())); + }); + }); + + test('readMany', () async { + expect(pokemonService.readMany([giratina.idAsInt, pikachu.idAsInt]), + completion([giratina, pikachu])); + expect( + pokemonService.readMany([giratina.idAsInt]), completion([giratina])); + expect(pokemonService.readMany([pikachu.idAsInt]), completion([pikachu])); + expect(() => pokemonService.readMany([]), throwsArgumentError); + }); + + group('update', () { + test('default', () async { + expect( + await pokemonService.update( + giratina.idAsInt, giratina.copyWith(name: 'Hello')), + giratina.copyWith(name: 'Hello')); + }); + + test('nonexistent throws 404', () { + expect( + () => pokemonService.update(999, giratina.copyWith(name: 'Hello')), + throwsA(TypeMatcher())); + }); + }); + + group('remove', () { + test('default', () async { + expect(pokemonService.read(giratina.idAsInt), completion(giratina)); + expect(pokemonService.read(pikachu.idAsInt), completion(pikachu)); + }); + + test('nonexistent throws 404', () { + expect(() => pokemonService.remove(999), + throwsA(TypeMatcher())); + }); + + test('cannot remove all unless explicitly set', () async { + expect(() => pokemonService.remove(null, {'provider': Providers.rest}), + throwsA(TypeMatcher())); + }); + }); + }); +} diff --git a/angel_orm_service/test/pokemon.dart b/angel_orm_service/test/pokemon.dart new file mode 100644 index 00000000..f8116cab --- /dev/null +++ b/angel_orm_service/test/pokemon.dart @@ -0,0 +1,33 @@ +import 'package:angel_migration/angel_migration.dart'; +import 'package:angel_serialize/angel_serialize.dart'; +import 'package:angel_orm/angel_orm.dart'; +part 'pokemon.g.dart'; + +enum PokemonType { + fire, + grass, + water, + dragon, + poison, + dark, + fighting, + electric, + ghost +} + +@serializable +@orm +abstract class _Pokemon extends Model { + @notNull + String get species; + + String get name; + + @notNull + int get level; + + @notNull + PokemonType get type1; + + PokemonType get type2; +} diff --git a/angel_orm_service/test/pokemon.g.dart b/angel_orm_service/test/pokemon.g.dart new file mode 100644 index 00000000..d6e49ed9 --- /dev/null +++ b/angel_orm_service/test/pokemon.g.dart @@ -0,0 +1,405 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'pokemon.dart'; + +// ************************************************************************** +// MigrationGenerator +// ************************************************************************** + +class PokemonMigration extends Migration { + @override + up(Schema schema) { + schema.create('pokemons', (table) { + table.serial('id')..primaryKey(); + table.varChar('species'); + table.varChar('name'); + table.integer('level'); + table.integer('type1'); + table.integer('type2'); + table.timeStamp('created_at'); + table.timeStamp('updated_at'); + }); + } + + @override + down(Schema schema) { + schema.drop('pokemons'); + } +} + +// ************************************************************************** +// OrmGenerator +// ************************************************************************** + +class PokemonQuery extends Query { + PokemonQuery({Set trampoline}) { + trampoline ??= Set(); + trampoline.add(tableName); + _where = PokemonQueryWhere(this); + } + + @override + final PokemonQueryValues values = PokemonQueryValues(); + + PokemonQueryWhere _where; + + @override + get casts { + return {}; + } + + @override + get tableName { + return 'pokemons'; + } + + @override + get fields { + return const [ + 'id', + 'species', + 'name', + 'level', + 'type1', + 'type2', + 'created_at', + 'updated_at' + ]; + } + + @override + PokemonQueryWhere get where { + return _where; + } + + @override + PokemonQueryWhere newWhereClause() { + return PokemonQueryWhere(this); + } + + static Pokemon parseRow(List row) { + if (row.every((x) => x == null)) return null; + var model = Pokemon( + id: row[0].toString(), + species: (row[1] as String), + name: (row[2] as String), + level: (row[3] as int), + type1: row[4] == null ? null : PokemonType.values[(row[4] as int)], + type2: row[5] == null ? null : PokemonType.values[(row[5] as int)], + createdAt: (row[6] as DateTime), + updatedAt: (row[7] as DateTime)); + return model; + } + + @override + deserialize(List row) { + return parseRow(row); + } +} + +class PokemonQueryWhere extends QueryWhere { + PokemonQueryWhere(PokemonQuery query) + : id = NumericSqlExpressionBuilder(query, 'id'), + species = StringSqlExpressionBuilder(query, 'species'), + name = StringSqlExpressionBuilder(query, 'name'), + level = NumericSqlExpressionBuilder(query, 'level'), + type1 = EnumSqlExpressionBuilder( + query, 'type1', (v) => v.index), + type2 = EnumSqlExpressionBuilder( + query, 'type2', (v) => v.index), + createdAt = DateTimeSqlExpressionBuilder(query, 'created_at'), + updatedAt = DateTimeSqlExpressionBuilder(query, 'updated_at'); + + final NumericSqlExpressionBuilder id; + + final StringSqlExpressionBuilder species; + + final StringSqlExpressionBuilder name; + + final NumericSqlExpressionBuilder level; + + final EnumSqlExpressionBuilder type1; + + final EnumSqlExpressionBuilder type2; + + final DateTimeSqlExpressionBuilder createdAt; + + final DateTimeSqlExpressionBuilder updatedAt; + + @override + get expressionBuilders { + return [id, species, name, level, type1, type2, createdAt, updatedAt]; + } +} + +class PokemonQueryValues extends MapQueryValues { + @override + get casts { + return {}; + } + + String get id { + return (values['id'] as String); + } + + set id(String value) => values['id'] = value; + String get species { + return (values['species'] as String); + } + + set species(String value) => values['species'] = value; + String get name { + return (values['name'] as String); + } + + set name(String value) => values['name'] = value; + int get level { + return (values['level'] as int); + } + + set level(int value) => values['level'] = value; + PokemonType get type1 { + return PokemonType.values[(values['type1'] as int)]; + } + + set type1(PokemonType value) => values['type1'] = value?.index; + PokemonType get type2 { + return PokemonType.values[(values['type2'] as int)]; + } + + set type2(PokemonType value) => values['type2'] = value?.index; + 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(Pokemon model) { + species = model.species; + name = model.name; + level = model.level; + type1 = model.type1; + type2 = model.type2; + createdAt = model.createdAt; + updatedAt = model.updatedAt; + } +} + +// ************************************************************************** +// JsonModelGenerator +// ************************************************************************** + +@generatedSerializable +class Pokemon extends _Pokemon { + Pokemon( + {this.id, + @required this.species, + this.name, + @required this.level, + @required this.type1, + this.type2, + this.createdAt, + this.updatedAt}); + + @override + final String id; + + @override + final String species; + + @override + final String name; + + @override + final int level; + + @override + final PokemonType type1; + + @override + final PokemonType type2; + + @override + final DateTime createdAt; + + @override + final DateTime updatedAt; + + Pokemon copyWith( + {String id, + String species, + String name, + int level, + PokemonType type1, + PokemonType type2, + DateTime createdAt, + DateTime updatedAt}) { + return new Pokemon( + id: id ?? this.id, + species: species ?? this.species, + name: name ?? this.name, + level: level ?? this.level, + type1: type1 ?? this.type1, + type2: type2 ?? this.type2, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt); + } + + bool operator ==(other) { + return other is _Pokemon && + other.id == id && + other.species == species && + other.name == name && + other.level == level && + other.type1 == type1 && + other.type2 == type2 && + other.createdAt == createdAt && + other.updatedAt == updatedAt; + } + + @override + int get hashCode { + return hashObjects( + [id, species, name, level, type1, type2, createdAt, updatedAt]); + } + + @override + String toString() { + return "Pokemon(id=$id, species=$species, name=$name, level=$level, type1=$type1, type2=$type2, createdAt=$createdAt, updatedAt=$updatedAt)"; + } + + Map toJson() { + return PokemonSerializer.toMap(this); + } +} + +// ************************************************************************** +// SerializerGenerator +// ************************************************************************** + +const PokemonSerializer pokemonSerializer = const PokemonSerializer(); + +class PokemonEncoder extends Converter { + const PokemonEncoder(); + + @override + Map convert(Pokemon model) => PokemonSerializer.toMap(model); +} + +class PokemonDecoder extends Converter { + const PokemonDecoder(); + + @override + Pokemon convert(Map map) => PokemonSerializer.fromMap(map); +} + +class PokemonSerializer extends Codec { + const PokemonSerializer(); + + @override + get encoder => const PokemonEncoder(); + @override + get decoder => const PokemonDecoder(); + static Pokemon fromMap(Map map) { + if (map['species'] == null) { + throw new FormatException("Missing required field 'species' on Pokemon."); + } + + if (map['level'] == null) { + throw new FormatException("Missing required field 'level' on Pokemon."); + } + + if (map['type1'] == null) { + throw new FormatException("Missing required field 'type1' on Pokemon."); + } + + return new Pokemon( + id: map['id'] as String, + species: map['species'] as String, + name: map['name'] as String, + level: map['level'] as int, + type1: map['type1'] is PokemonType + ? (map['type1'] as PokemonType) + : (map['type1'] is int + ? PokemonType.values[map['type1'] as int] + : null), + type2: map['type2'] is PokemonType + ? (map['type2'] as PokemonType) + : (map['type2'] is int + ? PokemonType.values[map['type2'] as int] + : null), + 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); + } + + static Map toMap(_Pokemon model) { + if (model == null) { + return null; + } + if (model.species == null) { + throw new FormatException("Missing required field 'species' on Pokemon."); + } + + if (model.level == null) { + throw new FormatException("Missing required field 'level' on Pokemon."); + } + + if (model.type1 == null) { + throw new FormatException("Missing required field 'type1' on Pokemon."); + } + + return { + 'id': model.id, + 'species': model.species, + 'name': model.name, + 'level': model.level, + 'type1': + model.type1 == null ? null : PokemonType.values.indexOf(model.type1), + 'type2': + model.type2 == null ? null : PokemonType.values.indexOf(model.type2), + 'created_at': model.createdAt?.toIso8601String(), + 'updated_at': model.updatedAt?.toIso8601String() + }; + } +} + +abstract class PokemonFields { + static const List allFields = [ + id, + species, + name, + level, + type1, + type2, + createdAt, + updatedAt + ]; + + static const String id = 'id'; + + static const String species = 'species'; + + static const String name = 'name'; + + static const String level = 'level'; + + static const String type1 = 'type1'; + + static const String type2 = 'type2'; + + static const String createdAt = 'created_at'; + + static const String updatedAt = 'updated_at'; +}