brick_build 0.0.3

  • Readme
  • Changelog
  • Installing
  • new47

Brick Build #

Code generator for Brick adapters, model dictionaries.

Install #

# pubspec.yaml
dev_dependencies:
  build_runner: any
  brick_build: any

All annotated models must be in lib/app/models.

Setup #

It's recommended to use watch when editing models.

(flutter) pub run build_runner watch

If you're not using watch, be sure to run build twice for the schema to detect new migrations.

(flutter) pub run build_runner build

An application directory must resemble the following:

| my-app
|--lib
|--|--app
|--|--|--adapters
|--|--|--db
|--|--|--models

This ensures a consistent path to access child data, such as models, by build generators.

Glossary #

  • generator - code producer. The output of a generator is most often a function that converts input to normalized data. The output of a generator does not always constitute a complete file (e.g. one generator is a serializer, another generator is a deserializer, and both generators are combined in a super adapter generator).
  • builder - a class that interfaces between source files and generator(s) before writing generated code to file(s). They are invoked and configured by build.yaml. Builders are primarly concerned with annotations that exist in the source (e.g. a Flutter app).
  • serdes - shorthand for serialize/deserialize
  • checker - an accessible utility that type checks analyzed type from a source. For example, isBool for a source of final bool isDeleted would return true. With a source of final String isDeleted, isBool would return false.
  • domain - the encompassing system. For example, the OfflineFirst domain builds REST serdes and SQLite serdes as well as its own annotation.

Creating a Domain Builder #

A new provider will likely require expected data to be massaged before creating a model.

Configurations and Annotations #

Before reading further, this process appears to require a lot of code. This is largely boilerplate required for type checking and Dart's analyzer. The majority of the custom code and logic will live in the adapter serdes.

Declaring Class-level Configuration #

A provider will likely require high-level information about a class that would be inappropriate to define on every instance of a class. And, because Dart's Type system can't infer static methods, this must be declared outside the class in an annotation:

// in this example, @ConnectOfflineFirstWithRest is our super or class-level annotation
@ConnectOfflineFirstWithRest(
  // RestSerializable is our configuration body.
  restConfig: RestSerializable(
    // a REST endpoint is inappropriate to define as an instance-level definition
    endpoint: '=> "/users";',
    // super annotations are also useful for setting a default for subsequent field-level definitions in the class
    fieldRename: FieldRename.snake,
  )
)
class MyModel

These configurations may be injected directly into the adapater (like endpoint) or may change behavior for generated code (like fieldRename).

Declaring Field-level Configuration #

Field-level annotations may be useful to override behavior at a finer level.

class MyModel
  @Rest(
    // a property here may override previously-specified behavior at the class-level
    name: "deleted"
  )
  final bool isDeleted;

⚠️ Annotation and configuration definitions must be declared outside of the build package if they depend on a package that conflicts with mirrors (Flutter conflicts with mirrors). As other packages may use these annotations (for example, OfflineFirst considers @Rest and @Sqlite annotations along with @OfflineFirst), it's safest to keep annotations and builders as independent packages.

Advanced Type Checking #

Most generators may not require an extension of basic type checking (e.g. is this a string, is this an int, is this a list). For advanced checking, say, for the discovery of a package-specific class, a new checker will have to be created:

final _serdesClassChecker = TypeChecker.fromRuntime(OfflineFirstSerdes);

class OfflineFirstChecker extends SharedChecker {
  bool get isSerdes => _serdesClassChecker.isSuperTypeOf(targetType);
}

For every new or removed type check, always update SharedChecker's computed getter isSerializable.

Interpreting Class-level Annotations #

Class-level annotations must be expanded from their constantized versions back to an easily-digestible Dart form:1

// RestSerializable is our previously-noted configuration class
class RestSerdes extends ProviderSerializable<RestSerializable> {
  RestSerdes(Element element, ConstantReader reader)
      : super(element, reader, configKey: "restConfig");

  get config {
    if (reader.read(configKey).isNull) return RestSerializable.defaults;

    return RestSerializable(
      // withinConfigKey safely navigates the constantized values, interpreting as digestible Dart code
      endpoint: withinConfigKey("endpoint")?.stringValue ?? RestSerializable.defaults.endpoint,
    );
  }
}

Discovering and Interpreting Field-level Annotations #

Field-level annotations must be expanded from their constantized versions back to an easily-digestible form. Brick provides a base class for this:

// @Rest is our annotation AND field-level configuration class, declared via AnnotationFinder<Rest>
class RestAnnotationFinder extends AnnotationFinder<Rest> {
  // this is the previously-defined class-level config
  final RestSerializable config;

  RestAnnotationFinder([this.config]);

  // element is the field, e.g. `final bool isDeleted`
  from(element) {
    // objectForField converts the analyzer's raw data into manageable code
    final obj = objectForField(element);

    // if this field is
    // final bool isDeleted
    // and not
    // @Rest(ignore:)
    // final bool isDeleted
    // then we generate the config with defaults
    if (obj == null) {
      return Rest(
        ignore: Rest.defaults.ignore
      );
    }

    // finally, we reconvert the annotation's configuration to digestible Dart code
    return Rest(
      ignore: obj.getField('ignore').toBoolValue() ?? Rest.defaults.ignore,
    );
  }
}

This reinitializes at the field level. However, a class will require that all fields go through the same process, and so a FieldsForClass class must be made.

// @Rest is still our annotation
// This class is boilerplate and can be safely copied with changes to the type
class RestFields extends FieldsForClass<Rest> {
  final RestAnnotationFinder finder;
  final RestSerializable config;

  RestFields(ClassElement element, [RestSerializable this.config])
      : finder = RestAnnotationFinder(config),
        super(element: element);
}

For providers that do not make use of a class-level config, the Fields interpreter can be adjusted:

class RestFields extends FieldsForClass<Rest> {
  final finder = RestAnnotationFinder();

  RestFields(ClassElement element) : super(element: element);
}

Discovering Class-level Annotations #

An AnnotationSuperGenerator manages sub generators. This generator is most likely the entrypoint for other builders. It should be simple, with most of its logic delegated to sub generators.

// @ConnectOfflineFirstWithRest is the annotation that decorates our models
class OfflineFirstGenerator extends AnnotationSuperGenerator<ConnectOfflineFirstWithRest> {
  final ConnectOfflineFirstWithRest config;

  const OfflineFirstGenerator({
    ConnectOfflineFirstWithRest argConfig,
  }) : config = argConfig ?? ConnectOfflineFirstWithRest.defaults;

  String generateAdapter(Element element, ConstantReader annotation, BuildStep buildStep) {
    final rest = RestSerdes(element, annotation);
    // generated code is returned (and discussed next)
  }
}

Adapter #

Adapter serdes generators should be as atomic as possible and not expect code from other adapter generators.

An adapter always includes serialization and deserialization methods. It can also include useful information such as schema data for a SQLite provider or a function to generate an endpoint for a REST provider. The provider can and should access generic (i.e. not related to a specific model instance) model information via the adapter.

Domains should subclass the SerdesGenerator to configure default generated code:

// FieldSerializable is a protocol for field-level annotations defined in brick_core
abstract class OfflineFirstSerdesGenerator extends SerdesGenerator<T extends FieldSerializable> {
  final repositoryName = "OfflineFirst";
}

Serializing and deserializing functions should live in separate classes for legibility:

// @Rest is our field-level annotation
class RestSerialize extends OfflineFirstGenerator<Rest> {
  final doesDeserialize = false;
}
class RestDeserialize extends OfflineFirstGenerator<Rest> {
  final doesDeserialize = true;
}

Every field of a model will be interpreted by the SerdesGenerator via addField:

class RestSerialize extends OfflineFirstGenerator<Rest> {
  // All discovered fields of the class pass through this function for generator output
  // Private fields, methods, static members, and computed setters are automatically ignored
  String addField(field, annotation) {
    // interpret the field's type:
    final checker = SharedChecker(field.type);

    if (checker.isString) {
      // annotation is our already-expanded field-level config
      final propertyName = annotation.name;
      // field comes from the analyzer and has a lot of useful information
      return "'$propertyName' : instance.${field.name}";
    }

    // falling through to an unsupported type, null won't add to the generated output
    return null;
  }
}

At a minimum, all primitive types should be evaluated by the checker and returned to the generator with appropriate serializing or deserializing code. Serdes generators come out as code spaghetti and that's OK. Explicit, verbose declarations - even when duplicated across generators - are reliable and easy to debug.

Associations #

Associations can require complex fetching. When a domain supports associations between providers, the class-level annotation should be used in a custom checker. For example, isSibling or isAssociation.

It is recommended to use a repository method dedicated to association fetching instead of the provider, as the repository may route the lookup to a different provider. For example, a User may have 1 Hat, and the repository may already have that Hat in a memory provider. By requesting the repository, the SqliteProvider is spared a potentially expensive query.

Generating Class-level Annotations #

The two adapter serdes classes are associated in the original serdes:

class RestSerdes extends ProviderSerializable<RestSerializable> {
  RestSerdes(Element element, ConstantReader reader)
      : super(element, reader, configKey: "restConfig");

  get generators {
    final classElement = element as ClassElement;
    // RestFields interprets all fields at the class level into our custom config (e.g. `name`, `ignore`)
    //
    // `config` comes from our expanded class-level annotation
    final fields = RestFields(classElement, config);
    return [RestDeserialize(classElement, fields), RestSerialize(classElement, fields)];
  }
}

Finally, the adapter code is ready to be sent to a builder.

class OfflineFirstGenerator extends AnnotationSuperGenerator<ConnectOfflineFirstWithRest> {
  final ConnectOfflineFirstWithRest config;

  const OfflineFirstGenerator({
    ConnectOfflineFirstWithRest argConfig,
  }) : config = argConfig ?? ConnectOfflineFirstWithRest.defaults;

  String generateAdapter(Element element, ConstantReader annotation, BuildStep buildStep) {
    final rest = RestSerdes(element, annotation);

    final adapterGenerator = AdapterGenerator(
      superAdapterName: 'OfflineFirst',
      className: element.name,
      // other provider serializing functions can be passed to the adapter generator,
      // allowing an adapter to interpret between providers
      generators: [rest.generators],
    );

    return adapterGenerator.generate();
  }
}

Model Dictionary #

The Model Dictionary generator must generate model dictionaries for each provider. Defining instructions - such as not committing generated code - and guiding code comments - such as the contents of a mapping - are important but not required.

As each model should extend/implement each provider model type, and each adapter should extend/implement each provider adapter type, the same dictionary is used for each provider mapping:

// this method is inherited from the super class
final dictionary = dictionaryFromFiles(classNamesToFileNames);

return """
/// REST mappings should only be used when initializing a [RestProvider]
final Map<Type, RestAdapter<RestModel>> restMappings = {
  $dictionary
};
final restModelDictionary = RestModelDictionary(restMappings);

/// Sqlite mappings should only be used when initializizing a [SqliteProvider]
final Map<Type, SqliteAdapter<SqliteModel>> sqliteMappings = {
  $dictionary
};
final sqliteModelDictionary = SqliteModelDictionary(sqliteMappings);
""";

To support the maps, every adapter must be included as a part and every model must be included as an import:

// These methods are inherited from the super class
final adapters = adaptersFromFiles(classNamesToFileNames);
final models = modelsFromFiles(classNamesToFileNames);

return """
$models

$adapters
""";

Any imports used within adapters must also be imported:

return """
import 'dart:convert';
import 'package:brick_sqlite/sqlite.dart' show SqliteModel, SqliteAdapter, SqliteModelDictionary;
import 'package:brick_rest/rest.dart' show RestProvider, RestModel, RestAdapter, RestModelDictionary;
// ignore: unused_import, unused_shown_name
import 'package:brick_core/core.dart' show Query, QueryAction;
// ignore: unused_import, unused_shown_name
import 'package:sqflite/sqflite.dart' show DatabaseExecutor;
""";

💡 To reduce analyzer errors, include // ignore: unused_import for imports used in part files.

Builder #

Generators are invoked by builders and builders are invoked by build.yaml using Dart's native task runner. As build.yaml can be opaque to the uninitiated and is not part of this repo, documentation about customization can be found on the package page. For basic, battle-tested usage, the build.yaml in this repo can be used as a base and modified appropriately for custom domains.

The primary build functions will be adapters and the model dictionary, as these are critical to the Brick system:

// RestGenerator is our AnnotationSuperGenerator
final restGenerator = RestGenerator();
Builder restAdaptersBuilder(options) => AdapterBuilder(restGenerator);
Builder restModelDictionaryBuilder(options) => ModelDictionaryBuilder(
  restGenerator,
  RestModelDictionaryGenerator(),
  // these files were only imported for our source code to interpret annotations
  // they're not required by adapters now that code has been generated
  expectedImportRemovals: [
    "import 'package:brick_rest/rest.dart';",
    'import "package:brick_rest/rest.dart";',
  ],
);

How does this work? #

End-to-end Case Study: @ConnectOfflineFirstWithRest

OfflineFirst Builder

  1. A class is discovered with the @ConnectOfflineFirstWithRest annotation.
       @ConnectOfflineFirstWithRest(
         sqliteConfig: SqliteSerializable(
           nullable: false
         ),
         restConfig: RestSerializable(
           endpoint: """=> '/my/path/to/classes'"""
         )
       )
       class MyClass extends OfflineFirstModel
    
  2. OfflineFirstGenerator expands respective sub configuration from the @ConnectOfflineFirstWithRest configuration.
  3. Instances of RestFields and SqliteFields are created and passed to their respective generators. This will expand all fields of the class into consumable code. Namely, the #sorted method ensures there are no duplicates and the fields are passed in the order they're declared in the class.
  4. RestSerialize, RestDeserialize, SqliteSerialize, and SqliteDeserialize generators are created from the previous configurations and the aforementioned fields. Since these generators inherit from the same base class, this documentation will continue with RestSerialize as the primary example.
  5. The fields are iterated through RestSerialize#coderForField to generate the transforming code. This function produces output by checking the field's type. For example, final List<Future<int>> futureNumbers may produce 'future_numbers': await Future.wait<int>(futureNumbers).
  6. The output is gathered via RestSerialize#generate and wrapped in a function such as MODELToRest(). All such functions from all generators are included in the output of the adapter generator. As some down-stream providers or repositories may require extra information in the adapter (such as restEndpoint or tableName), this data is also passed through #generate.
  7. Now with the complete adapter code, the AdapterBuilder saves adapters/MODELNAME.g.dart.
  8. Now with all annotated classes having adapter counterparts, a model dictionary is generated and saved to brick.g.dart with the ModelDictionaryBuilder.
  9. Concurrently, the super generator may produce a new schema that reflects the new data structure. SqliteSchemaGenerator generates a new schema. Using SchemaDifference, a new migration is created (this will be saved to db/migrations/VERSION_migration.dart). The new migration is logged and prepended to the generated code. This will be saved to db/schema.g.dart with the SqliteSchemaBuilder. A new migration will be saved to db/<INCREMENT_VERSION>.g.dart with the NewMigrationBuilder.

FAQ #

Why are all models hacked into a single file? #

Dart's build discovers one file at a time. Because Brick makes use of associations, it must be aware of all files, including similarly-annotated models that may not be in the same file. Therefore, one build step handles combining all files via a known directory (this is why folder organization is so important) and then combines them into a file. By writing that file, another build step listening for the extension kicks off the next build step to interpret each annotation.

Why doesn't this library use JsonSerializable?

While JsonSerializable is an incredibly robust library, it is, in short, opinionated. Just like this library is opinionated. This prevents incorporation in a number of ways:

  • @JsonSerializable detects serializable models via a class method check. Since @ConnectOfflineFirstWithRest uses an abstracted builder, checking the source class is not effective.
  • @JsonSerializable only supports enums as strings, not as indexes. While this is admittedly more resilient, it can’t be retrofitted to enums passed as integers from an API.
  • Lastly, dynamically applying a configuration is an uphill battle with ConstantReader (the annotation would have to be converted into a digestable format). While ultimately this could be possible, the library is still unusable because of the aforementioned points.

JsonSerializable is an incredibly robust library and should be used for all other scenarios.

Unreleased #

0.0.3 #

  • Use ConnectOfflineFirstWithRest

0.0.2 #

  • Uses getDisplayString instead of deprecated name
  • Fix linter hints

Use this package as a library

1. Depend on it

Add this to your package's pubspec.yaml file:


dependencies:
  brick_build: ^0.0.3

2. Install it

You can install packages from the command line:

with pub:


$ pub get

Alternatively, your editor might support pub get. Check the docs for your editor to learn more.

3. Import it

Now in your Dart code, you can use:


import 'package:brick_build/build_offline_first.dart';
import 'package:brick_build/builders.dart';
import 'package:brick_build/generators.dart';
  
Popularity:
Describes how popular the package is relative to other packages. [more]
3
Health:
Code health derived from static analysis. [more]
100
Maintenance:
Reflects how tidy and up-to-date the package is. [more]
78
Overall:
Weighted score of the above. [more]
47
Learn more about scoring.

We analyzed this package on Jan 19, 2020, and provided a score, details, and suggestions below. Analysis was completed with status completed using:

  • Dart: 2.7.0
  • pana: 0.13.4

Health suggestions

Format lib/build_offline_first.dart.

Run dartfmt to format lib/build_offline_first.dart.

Format lib/src/adapter_generator.dart.

Run dartfmt to format lib/src/adapter_generator.dart.

Format lib/src/annotation_finder.dart.

Run dartfmt to format lib/src/annotation_finder.dart.

Fix additional 27 files with analysis or formatting issues.

Additional issues in the following files:

  • lib/src/annotation_super_generator.dart (Run dartfmt to format lib/src/annotation_super_generator.dart.)
  • lib/src/builders/adapter_builder.dart (Run dartfmt to format lib/src/builders/adapter_builder.dart.)
  • lib/src/builders/aggregate_builder.dart (Run dartfmt to format lib/src/builders/aggregate_builder.dart.)
  • lib/src/builders/base.dart (Run dartfmt to format lib/src/builders/base.dart.)
  • lib/src/builders/model_dictionary_builder.dart (Run dartfmt to format lib/src/builders/model_dictionary_builder.dart.)
  • lib/src/builders/new_migration_builder.dart (Run dartfmt to format lib/src/builders/new_migration_builder.dart.)
  • lib/src/builders/sqlite_base_builder.dart (Run dartfmt to format lib/src/builders/sqlite_base_builder.dart.)
  • lib/src/builders/sqlite_schema_builder.dart (Run dartfmt to format lib/src/builders/sqlite_schema_builder.dart.)
  • lib/src/model_dictionary_generator.dart (Run dartfmt to format lib/src/model_dictionary_generator.dart.)
  • lib/src/offline_first/offline_first_fields.dart (Run dartfmt to format lib/src/offline_first/offline_first_fields.dart.)
  • lib/src/offline_first/offline_first_generator.dart (Run dartfmt to format lib/src/offline_first/offline_first_generator.dart.)
  • lib/src/offline_first/offline_first_serdes_generator.dart (Run dartfmt to format lib/src/offline_first/offline_first_serdes_generator.dart.)
  • lib/src/offline_first/rest_serdes.dart (Run dartfmt to format lib/src/offline_first/rest_serdes.dart.)
  • lib/src/offline_first/sqlite_serdes.dart (Run dartfmt to format lib/src/offline_first/sqlite_serdes.dart.)
  • lib/src/provider_serializable.dart (Run dartfmt to format lib/src/provider_serializable.dart.)
  • lib/src/rest_serdes/rest_deserialize.dart (Run dartfmt to format lib/src/rest_serdes/rest_deserialize.dart.)
  • lib/src/rest_serdes/rest_fields.dart (Run dartfmt to format lib/src/rest_serdes/rest_fields.dart.)
  • lib/src/rest_serdes/rest_serialize.dart (Run dartfmt to format lib/src/rest_serdes/rest_serialize.dart.)
  • lib/src/serdes_generator.dart (Run dartfmt to format lib/src/serdes_generator.dart.)
  • lib/src/sqlite_schema/migration_generator.dart (Run dartfmt to format lib/src/sqlite_schema/migration_generator.dart.)
  • lib/src/sqlite_schema/sqlite_schema_generator.dart (Run dartfmt to format lib/src/sqlite_schema/sqlite_schema_generator.dart.)
  • lib/src/sqlite_serdes/sqlite_deserialize.dart (Run dartfmt to format lib/src/sqlite_serdes/sqlite_deserialize.dart.)
  • lib/src/sqlite_serdes/sqlite_fields.dart (Run dartfmt to format lib/src/sqlite_serdes/sqlite_fields.dart.)
  • lib/src/sqlite_serdes/sqlite_serialize.dart (Run dartfmt to format lib/src/sqlite_serdes/sqlite_serialize.dart.)
  • lib/src/utils/fields_for_class.dart (Run dartfmt to format lib/src/utils/fields_for_class.dart.)
  • lib/src/utils/shared_checker.dart (Run dartfmt to format lib/src/utils/shared_checker.dart.)
  • lib/src/utils/string_helpers.dart (Run dartfmt to format lib/src/utils/string_helpers.dart.)

Maintenance suggestions

Maintain an example. (-10 points)

Create a short demo in the example/ directory to show how to use this package.

Common filename patterns include main.dart, example.dart, and brick_build.dart. Packages with multiple examples should provide example/README.md.

For more information see the pub package layout conventions.

Package is pre-v0.1 release. (-10 points)

While nothing is inherently wrong with versions of 0.0.*, it might mean that the author is still experimenting with the general direction of the API.

The package description is too short. (-2 points)

Add more detail to the description field of pubspec.yaml. Use 60 to 180 characters to describe the package, what it does, and its target use case.

Dependencies

Package Constraint Resolved Available
Direct dependencies
Dart SDK >=2.4.0 <3.0.0
analyzer ^0.39.0 0.39.4
brick_core >=0.0.2 <1.0.0 0.0.3+1
brick_offline_first_abstract >=0.0.3 <1.0.0 0.0.3
brick_rest >=0.0.2 <1.0.0 0.0.2
brick_sqlite_abstract >=0.0.2 <1.0.0 0.0.2
build >=1.2.0 <2.0.0 1.2.2
build_config >=0.4.0 <1.0.0 0.4.1+1
dart_style >=1.2.4 <2.0.0 1.3.3
glob >=1.2.0 <2.0.0 1.2.0
logging ^0.11.3+2 0.11.4
meta >=1.1.6 <2.0.0 1.1.8
path >=1.6.3 <2.0.0 1.6.4
source_gen ^0.9.0 0.9.4+7
Transitive dependencies
_fe_analyzer_shared 1.0.3
args 1.5.2
async 2.4.0
charcode 1.1.2
checked_yaml 1.0.2
collection 1.14.12
convert 2.1.1
crypto 2.1.4
csslib 0.16.1
html 0.14.0+3
http 0.12.0+4
http_parser 3.1.3
js 0.6.1+1
json_annotation 3.0.1
node_interop 1.0.3
node_io 1.0.1+2
package_config 1.1.0
pedantic 1.9.0
pub_semver 1.4.2
pubspec_parse 0.1.5
source_span 1.6.0
string_scanner 1.0.5
term_glyph 1.1.0
typed_data 1.1.6
watcher 0.9.7+13
yaml 2.2.0
Dev dependencies
build_verify ^1.1.0
source_gen_test ^0.1.0+6
test >=1.9.4 <2.0.0