dataforge 0.5.0-dev.0
dataforge: ^0.5.0-dev.0 copied to clipboard
Code generator for dataforge package using build_runner for data classes with JSON serialization.
Dataforge Generator #
Pure build_runner based code generator for creating immutable data classes in Dart with copyWith, ==, hashCode, toJson, fromJson, and more.
Built using source_gen and analyzer, dataforge seamlessly integrates with the Dart build system to generate boilerplate-free, type-safe data classes.
🏗️ Architecture Overview #
Dataforge is a pure build_runner implementation consisting of three main components:
Core Components #
-
DataforgeGenerator(lib/src/dataforge.dart)- Extends
GeneratorForAnnotation<Dataforge>fromsource_gen - Entry point for the build_runner pipeline
- Coordinates parsing and code generation
- Extends
-
GeneratorParser(lib/src/parser.dart)- Uses Dart analyzer API to inspect annotated classes
- Extracts metadata from
@Dataforgeand@JsonKeyannotations - Parses class structure, fields, generics, and type information
-
GeneratorWriter(lib/src/writer.dart)- Code generation engine
- Writes mixins with
copyWith,==,hashCode,toString - Generates JSON serialization (
toJson/fromJson) if enabled - Implements chained copyWith for nested objects
-
builder.dart(Build integration)- Exports
dataforgeBuilderforbuild_runner - Uses
PartBuilderto generate.data.dartpart files - Configured via
build.yaml
- Exports
Build Configuration #
The build.yaml file configures the code generator:
builders:
dataforge:
import: "package:dataforge/builder.dart"
builder_factories: ["dataforgeBuilder"]
build_extensions: {".dart": [".data.dart"]}
auto_apply: dependents
build_to: source
This ensures that for every .dart file with a @Dataforge annotation, a corresponding .data.dart part file is generated.
✨ Features #
- 📦 Complete Mixin Generation:
copyWith,==,hashCode,toJson,fromJson,toString - 🔗 Nested CopyWith: Flat accessor pattern with
$separator (e.g.,user$address$city) - 🔧 Flexible JSON Control: Custom field names, alternate names, converters,
readValue - 🌟 Type Safe: Full compile-time checking with generics and nullable types
- 🎯 Pure Build Runner: No runtime dependencies, all code generated at build time
- 🧩 Analyzer-Based: Leverages Dart analyzer for robust AST inspection
- ⚡ Incremental Builds: Only regenerates changed files via
build_runner watch
📦 Installation #
Add the following to your pubspec.yaml:
dependencies:
dataforge_annotation: ^0.4.0 # Runtime annotations
dev_dependencies:
build_runner: ^2.4.0 # Build system
dataforge: ^0.4.0 # Code generator
Then run:
dart pub get
🚀 Quick Start #
Step 1: Annotate Your Class #
import 'package:dataforge_annotation/dataforge_annotation.dart';
part 'user.data.dart'; // Part directive for generated code
@Dataforge()
class User with _User { // Mix in generated mixin
@override
final String name;
@override
final int age;
@override
final String? email;
const User({
required this.name,
required this.age,
this.email,
});
}
Step 2: Run Code Generator #
# One-time build
dart run build_runner build
# Watch mode (auto-rebuild on changes)
dart run build_runner watch
# Clean and rebuild
dart run build_runner build --delete-conflicting-outputs
This generates user.data.dart containing the _User mixin.
Step 3: Use Generated Code #
void main() {
// Create instance
final user = User(name: "Alice", age: 30, email: "alice@example.com");
// copyWith
final updated = user.copyWith(age: 31);
// Equality
print(user == updated); // false
// toString
print(user); // User(name: Alice, age: 30, email: alice@example.com)
// JSON (if enabled via @Dataforge(includeToJson: true, includeFromJson: true))
final json = user.toJson();
final fromJson = User.fromJson(json);
}
🔧 Annotation Reference #
@Dataforge #
Controls what code is generated for a class:
@Dataforge(
includeFromJson: true, // Generate static fromJson() method (default: true)
includeToJson: true, // Generate toJson() method (default: true)
deepCopyWith: true, // Enable nested field accessors (default: true)
)
class MyClass with _MyClass { ... }
Fields:
includeFromJson: Generatesstatic MyClass fromJson(Map<String, dynamic> json)includeToJson: GeneratesMap<String, dynamic> toJson()deepCopyWith: Enablesuser$name(...)syntax for nested Dataforge classes
@JsonKey #
Fine-grained control over field serialization:
class Product with _Product {
@JsonKey(name: 'product_id') // Custom JSON key
final String id;
@JsonKey(alternateNames: ['qty', 'count']) // Try multiple keys on fromJson
final int quantity;
@JsonKey(ignore: true) // Skip this field in JSON
final String? tempData;
@JsonKey(includeIfNull: false) // Omit if null in toJson
final String? description;
@JsonKey(readValue: _parseDate) // Custom pre-processor for fromJson
final DateTime createdAt;
@JsonKey(converter: MyConverter()) // Custom bi-directional converter
final CustomType data;
static Object? _parseDate(Map map, String key) {
final value = map[key];
return value is String ? DateTime.parse(value) : value;
}
}
Processing Priority (fromJson):
readValue- Extracts/transforms raw JSON value firstconverter.fromJson()- Custom type conversion- Auto-detection - Built-in converters for
DateTime, enums, etc.
Processing Priority (toJson):
converter.toJson()- Custom serialization- Auto-detection - Built-in converters for
DateTime, enums - Direct value (for basic types)
Built-in Converters:
DefaultDateTimeConverter- Auto-applied toDateTimefields (ISO 8601 / milliseconds)DefaultEnumConverter- Auto-applied to enum fields (name-based)
🔗 Chained CopyWith (Nested Updates) #
When deepCopyWith: true (default), the generator creates flat accessors for nested Dataforge classes using $ separator:
Example #
@Dataforge(deepCopyWith: true)
class Address with _Address {
final String city;
final String country;
const Address({required this.city, required this.country});
}
@Dataforge(deepCopyWith: true)
class Person with _Person {
final String name;
final Address address;
const Person({required this.name, required this.address});
}
Generated copyWith class includes:
class _PersonCopyWith<R> {
// Regular field accessors
R name(String value) { ... }
R address(Address value) { ... }
// Nested accessors (auto-generated for Dataforge fields)
R address$city(String value) {
return call(address: _instance.address.copyWith(city: value));
}
R address$country(String value) {
return call(address: _instance.address.copyWith(country: value));
}
}
🛡️ Null Safety #
If any field in the chain is nullable (e.g., Address? address), the generated code handles it gracefully. If a parent field is null, the update is safely ignored (the original object remains unchanged) instead of throwing a runtime error.
// If person.address is null, this call safely returns the original person
// correctly handling the null path without crashing.
final updated = person.copyWith.address$city('New York');
```}
Usage:
final person = Person(
name: 'Bob',
address: Address(city: 'NYC', country: 'USA'),
);
// Update nested field directly
final moved = person.copyWith.address$city('LA');
// Result: Person(name: Bob, address: Address(city: LA, country: USA))
// Chain multiple updates
final updated = person
.copyWith.name('Alice')
.copyWith.address$country('Canada');
Why $ separator?
- No naming conflicts:
user$namewon't clash with a field nameduserName - Clear hierarchy: Explicitly shows nesting level
- Type-safe: Compiler verifies nested types
- Auto-generated: No manual boilerplate for every nested class
🎯 Setting Null Values #
One of the key advantages of the single-field accessor pattern is the ability to explicitly set nullable fields to null, which is impossible with traditional copyWith:
The Problem with Traditional CopyWith #
class User {
final String name;
final String? email; // Nullable field
User copyWith({String? name, String? email}) {
return User(
name: name ?? this.name,
email: email ?? this.email, // ⚠️ Problem: Can't distinguish "not provided" from "set to null"
);
}
}
final user = User(name: 'Alice', email: 'alice@example.com');
// Trying to clear the email
final updated = user.copyWith(email: null);
print(updated.email); // ❌ Still 'alice@example.com'! The null was ignored by ??
The ?? operator cannot distinguish between:
- Not provided (parameter omitted) → keep original value
- Explicitly null (parameter is
null) → should set tonull
The Solution: Single-Field Accessors #
Dataforge generates individual accessor methods that accept the exact field type:
// Generated code
class _UserCopyWith<R> {
R call({String? name, String? email}) {
final res = User(
name: name ?? _instance.name,
email: email ?? _instance.email, // Traditional copyWith behavior
);
return _then != null ? _then!(res) : res as R;
}
// Single-field accessor - accepts exact type and assigns directly
R email(String? value) {
final res = User(
name: _instance.name,
email: value, // ✅ Direct assignment - accepts null!
);
return _then != null ? _then!(res) : res as R;
}
}
Usage Example #
@Dataforge()
class User with _User {
final String name;
final String? email;
final int? age;
const User({required this.name, this.email, this.age});
}
final user = User(name: 'Bob', email: 'bob@example.com', age: 30);
// ✅ Clear email using single-field accessor
final noEmail = user.copyWith.email(null);
print(noEmail.email); // null
// ✅ Clear age
final noAge = user.copyWith.age(null);
print(noAge.age); // null
// ✅ Chain multiple updates including nulls
final updated = user
.copyWith.name('Alice')
.copyWith.email(null)
.copyWith.age(25);
// Result: User(name: 'Alice', email: null, age: 25)
Benefits #
✅ Explicit null assignment: Use .fieldName(null) to clear nullable fields
✅ Backwards compatible: Traditional copyWith(...) still works for non-null updates
✅ Type-safe: Compiler enforces correct types
✅ Chainable: Combine with other updates fluently
This design solves the longstanding Dart limitation elegantly without requiring wrapper types like Optional<T>.
📋 Type Support #
The parser (GeneratorParser) automatically detects and handles:
- Primitives:
String,int,double,bool,num - Date/Time:
DateTime(auto-converts viaDefaultDateTimeConverter) - Enums: Auto-converts via
DefaultEnumConverter(by name) - Collections:
List<T>,Set<T>,Map<K, V>(with type-safe iteration) - Nullable:
String?,int?, etc. - Generics:
Result<T>,Container<K, V>(preserves type parameters) - Nested Dataforge: Classes annotated with
@Dataforge(enables chained copyWith)
🏗️ Build Process Internals #
How It Works #
- Build Runner scans for
.dartfiles in your project - For each file containing
@Dataforge,DataforgeGeneratoris invoked GeneratorParseruses theanalyzerpackage to:- Read the
ClassElementfrom the AST - Extract all fields, their types, nullability, and annotations
- Determine if fields are Dataforge classes (for chained copyWith)
- Parse generic type parameters
- Read the
GeneratorWritergenerates:- A mixin with abstract field declarations
copyWithmethods (callable or chained accessor class)==,hashCode,toStringimplementationstoJson/fromJsonif enabled
- Output is written to a
.data.dartpart file - You mix the generated mixin into your class via
with _ClassName
Generated Code Structure #
// user.data.dart (generated)
part of 'user.dart';
mixin _User {
abstract final String name;
abstract final int age;
_UserCopyWith<User> get copyWith => _UserCopyWith<User>._(this);
@override
bool operator ==(Object other) { ... }
@override
int get hashCode => Object.hashAll([name, age]);
@override
String toString() => 'User(name: $name, age: $age)';
Map<String, dynamic> toJson() { ... }
static User fromJson(Map<String, dynamic> json) { ... }
}
class _UserCopyWith<R> {
final _User _instance;
final R Function(User)? _then;
R call({String? name, int? age}) { ... }
R name(String value) { ... }
R age(int value) { ... }
}
🔄 Migration from json_serializable #
Dataforge provides a superset of json_serializable functionality with added copyWith and equality:
Before (json_serializable) #
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';
@JsonSerializable()
class User {
final String name;
final int age;
User({required this.name, required this.age});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
// Must manually implement copyWith, ==, hashCode, toString
}
After (Dataforge) #
import 'package:dataforge_annotation/dataforge_annotation.dart';
part 'user.data.dart';
@Dataforge()
class User with _User {
@override
final String name;
@override
final int age;
const User({required this.name, required this.age});
// copyWith, ==, hashCode, toString, toJson, fromJson auto-generated!
}
Advantages:
- ✅ No need to write factory constructors
- ✅
copyWithincluded by default - ✅ Equality and
toStringfor free - ✅ Chained accessors for nested objects
- ✅ Same
@JsonKeyannotations work
🛠️ Development #
Project Structure #
dataforge/
├── generator/
│ ├── lib/
│ │ ├── builder.dart # Build_runner entry point
│ │ └── src/
│ │ ├── dataforge.dart # GeneratorForAnnotation
│ │ ├── parser.dart # AST parsing logic
│ │ ├── writer.dart # Code generation logic
│ │ └── model.dart # Data models
│ ├── build.yaml # Builder configuration
│ ├── example/ # Usage examples
│ └── test/ # Unit tests
├── annotation/
│ └── lib/
│ └── dataforge_annotation.dart # @Dataforge, @JsonKey
└── view_model/ # (Separate package)
Running Tests #
cd generator
dart pub get
dart test
Debugging Generated Code #
Use --verbose flag to see detailed build logs:
dart run build_runner build --verbose
Or check the generated .data.dart files directly in your source tree.
📄 License #
MIT License - see LICENSE file for details.
🤝 Contributing #
Contributions welcome! Please:
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Submit a pull request
📞 Support #
- Issues: GitHub Issues
- Discussions: GitHub Discussions