Serialize base

This commit is contained in:
thosakwe 2017-06-16 21:26:19 -04:00
parent cbe4f40447
commit 94e45dccda
11 changed files with 740 additions and 0 deletions

2
.analysis-options Normal file
View file

@ -0,0 +1,2 @@
analyzer:
strong-mode: true

44
.gitignore vendored
View file

@ -10,3 +10,47 @@ pubspec.lock
# Directory created by dartdoc
# If you don't generate documentation locally you can remove this line.
doc/api/
### JetBrains template
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff:
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/dictionaries
# Sensitive or high-churn files:
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.xml
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
# Gradle:
.idea/**/gradle.xml
.idea/**/libraries
# Mongo Explorer plugin:
.idea/**/mongoSettings.xml
## File-based project format:
*.iws
## Plugin-specific files:
# IntelliJ
/out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties

19
lib/angel_serialize.dart Normal file
View file

@ -0,0 +1,19 @@
/// Excludes a field from being excluded.
class Exclude {
const Exclude();
}
const Exclude exclude = const Exclude();
/// Marks a class as eligible for serialization.
class Serializable {
const Serializable();
}
const Serializable serializable = const Serializable();
/// Specifies an alias for a field within its JSON representation.
class Alias {
final String name;
const Alias(this.name);
}

275
lib/builder.dart Normal file
View file

@ -0,0 +1,275 @@
import 'dart:async';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:build/build.dart';
import 'package:code_builder/code_builder.dart';
import 'package:code_builder/dart/core.dart';
import 'package:recase/recase.dart';
import 'package:source_gen/src/annotation.dart';
import 'package:source_gen/source_gen.dart';
import 'angel_serialize.dart';
class JsonModelGenerator extends GeneratorForAnnotation<Serializable> {
const JsonModelGenerator();
@override
Future<String> generateForAnnotatedElement(
Element element, Serializable annotation, BuildStep buildStep) async {
if (element.kind != ElementKind.CLASS)
throw 'Only classes can be annotated with a @Serializable() annotation.';
var lib = generateSerializerLibrary(element);
return prettyToSource(lib.buildAst());
}
LibraryBuilder generateSerializerLibrary(ClassElement clazz) {
var lib = new LibraryBuilder();
lib.addMember(generateBaseModelClass(clazz));
return lib;
}
ClassBuilder generateBaseModelClass(ClassElement clazz) {
if (!clazz.name.startsWith('_'))
throw 'Classes annotated with @Serializable() must have names starting with a leading underscore.';
var genClassName = clazz.name.substring(1);
var genClass =
new ClassBuilder(genClassName, asExtends: new TypeBuilder(clazz.name));
Map<String, DartType> fields = {};
Map<String, String> aliases = {};
// Find all fields
for (var field in clazz.fields) {
// Skip if annotated with @exclude
var excludeAnnotation = field.metadata.firstWhere(
(ann) => matchAnnotation(Exclude, ann),
orElse: () => null);
if (excludeAnnotation == null) {
// Register the field
fields[field.name] = field.type;
// Search for Alias
var aliasAnnotation = field.metadata.firstWhere(
(ann) => matchAnnotation(Alias, ann),
orElse: () => null);
if (aliasAnnotation != null) {
var alias = instantiateAnnotation(aliasAnnotation) as Alias;
aliases[field.name] = alias.name;
}
}
}
// Now, add all fields to the base class
clazz.fields.forEach((field) {
genClass.addField(
varField(field.name, type: new TypeBuilder(field.type.displayName))
..addAnnotation(reference('override')));
});
// Create convenience constructor
var convenienceConstructor = constructor(clazz.fields.map((field) {
return thisField(named(parameter(field.name)));
}));
genClass.addConstructor(convenienceConstructor);
// Create toJson
Map<String, ExpressionBuilder> toJsonFields = {};
fields.forEach((fieldName, type) {
var resolvedName =
aliases.containsKey(fieldName) ? aliases[fieldName] : fieldName;
ExpressionBuilder value;
// DateTime
if (type.name == 'DateTime') {
value = reference(fieldName).equals(literal(null)).ternary(
literal(null), reference(fieldName).invoke('toIso8601String', []));
}
// Anything else
else {
value = reference(fieldName);
}
toJsonFields[resolvedName] = value;
});
var toJson = new MethodBuilder('toJson',
returnType: new TypeBuilder('Map', genericTypes: [
new TypeBuilder('String'),
new TypeBuilder('dynamic')
]),
returns: map(toJsonFields));
genClass.addMethod(toJson);
// Create factory [name].fromJson
var fromJson = new ConstructorBuilder(name: 'fromJson', asFactory: true);
fromJson.addPositional(parameter('data', [new TypeBuilder('Map')]));
var namedParams =
fields.keys.fold<Map<String, ExpressionBuilder>>({}, (out, fieldName) {
var resolvedName =
aliases.containsKey(fieldName) ? aliases[fieldName] : fieldName;
var mapKey = reference('data')[literal(resolvedName)];
ExpressionBuilder value = mapKey;
var type = fields[fieldName];
// DateTime
if (type.name == 'DateTime') {
// map['foo'] is DateTime ? map['foo'] : (map['foo'] is String ? DateTime.parse(map['foo']) : null)
var dt = new TypeBuilder('DateTime');
value = mapKey.isInstanceOf(dt).ternary(
mapKey,
(mapKey.isInstanceOf(new TypeBuilder('String')).ternary(
new TypeBuilder('DateTime').invoke('parse', [mapKey]),
literal(null)))
.parentheses());
}
bool done = false;
// Handle List
if (type.toString().contains('List') && type is ParameterizedType) {
var listType = type.typeArguments.first;
if (listType.element is ClassElement) {
var genericClass = listType.element as ClassElement;
String fromJsonClassName;
bool hasFromJson =
genericClass.constructors.any((c) => c.name == 'fromJson');
if (hasFromJson) {
fromJsonClassName = genericClass.displayName;
} else {
// If it has a serializable annotation, act accordingly.
if (genericClass.metadata
.any((ann) => matchAnnotation(Serializable, ann))) {
fromJsonClassName = genericClass.displayName.substring(1);
hasFromJson = true;
}
}
// Check for fromJson
if (hasFromJson) {
var outputType = new TypeBuilder(fromJsonClassName);
var x = reference('x');
value = mapKey.isInstanceOf(lib$core.List).ternary(
mapKey.invoke('map', [
new MethodBuilder.closure(
returns: x.equals(literal(null)).ternary(
literal(null),
(x.isInstanceOf(outputType).ternary(
x,
outputType.newInstance([reference('x')],
constructor: 'fromJson')))
.parentheses()))
..addPositional(parameter('x'))
]).invoke('toList', []),
literal(null));
done = true;
}
}
}
// Check for fromJson
if (!done && type.element is ClassElement) {
String fromJsonClassName;
var genericClass = type.element as ClassElement;
bool hasFromJson =
genericClass.constructors.any((c) => c.name == 'fromJson');
if (hasFromJson) {
fromJsonClassName = type.displayName;
} else {
// If it has a serializable annotation, act accordingly.
if (genericClass.metadata
.any((ann) => matchAnnotation(Serializable, ann))) {
fromJsonClassName = type.displayName.substring(1);
hasFromJson = true;
}
}
// Check for fromJson
if (hasFromJson) {
var outputType = new TypeBuilder(fromJsonClassName);
value = mapKey.equals(literal(null)).ternary(
literal(null),
(mapKey.isInstanceOf(outputType).ternary(
mapKey,
outputType
.newInstance([mapKey], constructor: 'fromJson')))
.parentheses());
}
}
// Handle Map...
if (!done &&
type.toString().contains('Map') &&
type is ParameterizedType &&
type.typeArguments.length >= 2) {
var targetType = type.typeArguments[1];
if (targetType.element is ClassElement) {
String fromJsonClassName;
var genericClass = targetType.element as ClassElement;
bool hasFromJson =
genericClass.constructors.any((c) => c.name == 'fromJson');
if (hasFromJson) {
fromJsonClassName = targetType.displayName;
} else {
// If it has a serializable annotation, act accordingly.
if (genericClass.metadata
.any((ann) => matchAnnotation(Serializable, ann))) {
fromJsonClassName = targetType.displayName.substring(1);
hasFromJson = true;
}
}
// Check for fromJson
if (hasFromJson) {
var outputType = new TypeBuilder(fromJsonClassName);
var v = mapKey[reference('k')];
value = mapKey.isInstanceOf(lib$core.Map).ternary(
mapKey.property('keys').invoke('fold', [
map({}),
new MethodBuilder.closure()
..addStatements([
v
.equals(literal(null))
.ternary(
literal(null),
(v.isInstanceOf(outputType).ternary(
v,
outputType.newInstance([v],
constructor: 'fromJson')))
.parentheses())
.asAssign(reference('out')[reference('k')]),
reference('out').asReturn()
])
..addPositional(parameter('out'))
..addPositional(parameter('k'))
]),
literal(null));
} else {
value = mapKey
.isInstanceOf(lib$core.Map)
.ternary(mapKey, literal(null));
}
}
}
return out..[fieldName] = value;
});
fromJson.addStatement(new TypeBuilder(genClassName)
.newInstance([], named: namedParams).asReturn());
genClass.addConstructor(fromJson);
// Create `parse` to just forward
var parseMethod = new MethodBuilder('parse',
returnType: new TypeBuilder(genClassName),
returns: new TypeBuilder(genClassName)
.newInstance([reference('map')], constructor: 'fromJson'));
parseMethod.addPositional(parameter('map', [new TypeBuilder('Map')]));
genClass.addMethod(parseMethod, asStatic: true);
return genClass;
}
}

14
pubspec.yaml Normal file
View file

@ -0,0 +1,14 @@
name: angel_serialize
version: 0.0.0
description: Model serialization generators.
author: Tobe O <thosakwe@gmail.com>
homepage: https://github.com/angel-dart/serialize
dependencies:
code_builder: ^1.0.0
id: ^1.0.0
recase: ^1.0.0
source_gen: ^0.5.8
dev_dependencies:
angel_framework: ^1.0.0
build_runner: ^0.3.0
test: ">= 0.12.13 < 0.13.0"

114
test/book_test.dart Normal file
View file

@ -0,0 +1,114 @@
import 'dart:convert';
import 'package:test/test.dart';
import 'models/book.dart';
const String DEATHLY_HALLOWS_ISBN = '0-545-01022-5';
main() {
var deathlyHallows = new Book(
id: '0',
author: 'J.K. Rowling',
title: 'Harry Potter and the Deathly Hallows',
description: 'The 7th book.',
pageCount: 759,
updatedAt: new DateTime.now());
var serializedDeathlyHallows = deathlyHallows.toJson();
print('Deathly Hallows: $serializedDeathlyHallows');
var jkRowling = new Author(
id: '1',
name: 'J.K. Rowling',
age: 51,
books: [deathlyHallows],
newestBook: deathlyHallows);
Map serializedJkRowling = JSON.decode(JSON.encode(jkRowling.toJson()));
Map deathlyHallowsMap = JSON.decode(JSON.encode(serializedDeathlyHallows));
print('J.K. Rowling: $serializedJkRowling');
var library = new Library(collection: {DEATHLY_HALLOWS_ISBN: deathlyHallows});
var serializedLibrary = JSON.decode(JSON.encode(library.toJson()));
print('Library: $serializedLibrary');
group('serialization', () {
test('serialization sets proper fields', () {
expect(serializedDeathlyHallows['id'], deathlyHallows.id);
expect(serializedDeathlyHallows['author'], deathlyHallows.author);
expect(
serializedDeathlyHallows['description'], deathlyHallows.description);
expect(serializedDeathlyHallows['page_count'], deathlyHallows.pageCount);
expect(serializedDeathlyHallows['createdAt'], isNull);
expect(serializedDeathlyHallows['updatedAt'],
deathlyHallows.updatedAt.toIso8601String());
});
test('heeds @Alias', () {
expect(serializedDeathlyHallows['page_count'], deathlyHallows.pageCount);
expect(serializedDeathlyHallows.keys, isNot(contains('pageCount')));
});
test('heeds @exclude', () {
expect(serializedJkRowling.keys, isNot(contains('secret')));
});
test('nested @serializable class is serialized', () {
expect(serializedJkRowling['newest_book'], deathlyHallowsMap);
});
test('list of nested @serializable class is serialized', () {
expect(serializedJkRowling['books'], [deathlyHallowsMap]);
});
test('map with @serializable class as second key is serialized', () {
expect(serializedLibrary['collection'],
{DEATHLY_HALLOWS_ISBN: deathlyHallowsMap});
});
});
group('deserialization', () {
test('deserialization sets proper fields', () {
var book = new Book.fromJson(deathlyHallowsMap);
expect(book.id, deathlyHallows.id);
expect(book.author, deathlyHallows.author);
expect(book.description, deathlyHallows.description);
expect(book.pageCount, deathlyHallows.pageCount);
expect(book.createdAt, isNull);
expect(book.updatedAt, deathlyHallows.updatedAt);
});
group('nested @serializable', () {
var author = new Author.fromJson(serializedJkRowling);
test('nested @serializable class is deserialized', () {
var newestBook = author.newestBook;
expect(newestBook, isNotNull);
expect(newestBook.id, deathlyHallows.id);
expect(newestBook.pageCount, deathlyHallows.pageCount);
expect(newestBook.updatedAt, deathlyHallows.updatedAt);
});
test('list of nested @serializable class is deserialized', () {
expect(author.books, allOf(isList, isNotEmpty, hasLength(1)));
var book = author.books.first;
expect(book.id, deathlyHallows.id);
expect(book.author, deathlyHallows.author);
expect(book.description, deathlyHallows.description);
expect(book.pageCount, deathlyHallows.pageCount);
expect(book.createdAt, isNull);
expect(book.updatedAt, deathlyHallows.updatedAt);
});
test('map with @serializable class as second key is deserialized', () {
var lib = new Library.fromJson(serializedLibrary);
expect(lib.collection, allOf(isNotEmpty, hasLength(1)));
expect(lib.collection.keys.first, DEATHLY_HALLOWS_ISBN);
var book = lib.collection[DEATHLY_HALLOWS_ISBN];
expect(book.id, deathlyHallows.id);
expect(book.author, deathlyHallows.author);
expect(book.description, deathlyHallows.description);
expect(book.pageCount, deathlyHallows.pageCount);
expect(book.createdAt, isNull);
expect(book.updatedAt, deathlyHallows.updatedAt);
});
});
});
}

50
test/models/book.dart Normal file
View file

@ -0,0 +1,50 @@
library angel_serialize.test.models.book;
import 'package:angel_framework/common.dart';
import 'package:angel_serialize/angel_serialize.dart';
part 'book.g.dart';
@serializable
abstract class _Book extends Model {
@override
String id;
String author, title, description;
@Alias('page_count')
int pageCount;
@override
DateTime createdAt, updatedAt;
}
@serializable
abstract class _Author extends Model {
@override
String id;
String name;
int age;
@override
DateTime createdAt, updatedAt;
List<_Book> books;
@Alias('newest_book')
_Book newestBook;
@exclude
String secret;
}
@serializable
abstract class _Library extends Model {
@override
String id;
@override
DateTime createdAt, updatedAt;
Map<String, _Book> collection;
}

207
test/models/book.g.dart Normal file
View file

@ -0,0 +1,207 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of angel_serialize.test.models.book;
// **************************************************************************
// Generator: JsonModelGenerator
// Target: abstract class _Book
// **************************************************************************
class Book extends _Book {
@override
String id;
@override
String author;
@override
String title;
@override
String description;
@override
int pageCount;
@override
DateTime createdAt;
@override
DateTime updatedAt;
Book(
{this.id,
this.author,
this.title,
this.description,
this.pageCount,
this.createdAt,
this.updatedAt});
factory Book.fromJson(Map data) {
return new Book(
id: data['id'],
author: data['author'],
title: data['title'],
description: data['description'],
pageCount: data['page_count'],
createdAt: data['createdAt'] is DateTime
? data['createdAt']
: (data['createdAt'] is String
? DateTime.parse(data['createdAt'])
: null),
updatedAt: data['updatedAt'] is DateTime
? data['updatedAt']
: (data['updatedAt'] is String
? DateTime.parse(data['updatedAt'])
: null));
}
Map<String, dynamic> toJson() => {
'id': id,
'author': author,
'title': title,
'description': description,
'page_count': pageCount,
'createdAt': createdAt == null ? null : createdAt.toIso8601String(),
'updatedAt': updatedAt == null ? null : updatedAt.toIso8601String()
};
static Book parse(Map map) => new Book.fromJson(map);
}
// **************************************************************************
// Generator: JsonModelGenerator
// Target: abstract class _Author
// **************************************************************************
class Author extends _Author {
@override
String id;
@override
String name;
@override
int age;
@override
DateTime createdAt;
@override
DateTime updatedAt;
@override
List<_Book> books;
@override
_Book newestBook;
@override
String secret;
Author(
{this.id,
this.name,
this.age,
this.createdAt,
this.updatedAt,
this.books,
this.newestBook,
this.secret});
factory Author.fromJson(Map data) {
return new Author(
id: data['id'],
name: data['name'],
age: data['age'],
createdAt: data['createdAt'] is DateTime
? data['createdAt']
: (data['createdAt'] is String
? DateTime.parse(data['createdAt'])
: null),
updatedAt: data['updatedAt'] is DateTime
? data['updatedAt']
: (data['updatedAt'] is String
? DateTime.parse(data['updatedAt'])
: null),
books: data['books'] is List
? data['books']
.map((x) =>
x == null ? null : (x is Book ? x : new Book.fromJson(x)))
.toList()
: null,
newestBook: data['newest_book'] == null
? null
: (data['newest_book'] is Book
? data['newest_book']
: new Book.fromJson(data['newest_book'])));
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'age': age,
'createdAt': createdAt == null ? null : createdAt.toIso8601String(),
'updatedAt': updatedAt == null ? null : updatedAt.toIso8601String(),
'books': books,
'newest_book': newestBook
};
static Author parse(Map map) => new Author.fromJson(map);
}
// **************************************************************************
// Generator: JsonModelGenerator
// Target: abstract class _Library
// **************************************************************************
class Library extends _Library {
@override
String id;
@override
DateTime createdAt;
@override
DateTime updatedAt;
@override
Map<String, _Book> collection;
Library({this.id, this.createdAt, this.updatedAt, this.collection});
factory Library.fromJson(Map data) {
return new Library(
id: data['id'],
createdAt: data['createdAt'] is DateTime
? data['createdAt']
: (data['createdAt'] is String
? DateTime.parse(data['createdAt'])
: null),
updatedAt: data['updatedAt'] is DateTime
? data['updatedAt']
: (data['updatedAt'] is String
? DateTime.parse(data['updatedAt'])
: null),
collection: data['collection'] is Map
? data['collection'].keys.fold({}, (out, k) {
out[k] = data['collection'][k] == null
? null
: (data['collection'][k] is Book
? data['collection'][k]
: new Book.fromJson(data['collection'][k]));
return out;
})
: null);
}
Map<String, dynamic> toJson() => {
'id': id,
'createdAt': createdAt == null ? null : createdAt.toIso8601String(),
'updatedAt': updatedAt == null ? null : updatedAt.toIso8601String(),
'collection': collection
};
static Library parse(Map map) => new Library.fromJson(map);
}

4
tool/build.dart Normal file
View file

@ -0,0 +1,4 @@
import 'package:build_runner/build_runner.dart';
import 'phases.dart';
main() => build(PHASES, deleteFilesByDefault: true);

7
tool/phases.dart Normal file
View file

@ -0,0 +1,7 @@
import 'package:build_runner/build_runner.dart';
import 'package:source_gen/source_gen.dart';
import 'package:angel_serialize/builder.dart';
final PhaseGroup PHASES = new PhaseGroup.singleAction(
new GeneratorBuilder([const JsonModelGenerator()]),
new InputSet('angel_serialize', const ['test/models/*.dart']));

4
tool/watch.dart Normal file
View file

@ -0,0 +1,4 @@
import 'package:build_runner/build_runner.dart';
import 'phases.dart';
main() => watch(PHASES, deleteFilesByDefault: true);