Motivation
jao's query/filter API is fully typed — Polls.$.question.eq("test") gives compile-time safety, autocomplete, and refactor resilience. However, mutations (create, update), projections (values), aggregations, and raw queries still rely on Map<String, Object?> and List<String>. This creates a split where reads are safe but writes are stringly-typed and error-prone.
This RFC proposes closing that gap across 8 areas, ordered by impact.
1. Typed Create / Update / BulkCreate
Problem: create(), update(), and bulkCreate() accept Map<String, Object?>, meaning typos in column names and wrong value types are only caught at runtime.
Current API:
final poll = await Polls.objects.create({
'question': 'What is your favorite color?',
'pub_date': DateTime.now(),
});
await Polls.objects.filter(Polls.$.id.eq(1)).update({
'question': 'Updated question',
});
await Polls.objects.bulkCreate([
{'question': 'Q1', 'pub_date': DateTime.now()},
{'question': 'Q2', 'pub_date': DateTime.now()},
]);
Proposed API:
// Create — pass a model instance directly
final poll = await Polls.objects.create(
Poll(question: 'What is your favorite color?', pubDate: DateTime.now()),
);
// BulkCreate — list of model instances
await Polls.objects.bulkCreate([
Poll(question: 'Q1', pubDate: DateTime.now()),
Poll(question: 'Q2', pubDate: DateTime.now()),
]);
// Update — use copyWith to express partial changes
await Polls.objects
.filter(Polls.$.id.eq(1))
.updateModel(poll.copyWith(question: 'Updated question'));
Implementation: The model class is already generated with all fields. The key changes are:
-
Model constructor: Auto-generated fields (PK, autoNow, autoNowAdd, UUID) become optional parameters so callers don't need to provide them on create:
class Poll {
final int? id; // optional — auto-generated
final String question; // required
final DateTime? pubDate; // optional — has default
const Poll({this.id, required this.question, this.pubDate});
}
-
Generated copyWith: Enables partial updates idiomatically:
Poll copyWith({String? question, DateTime? pubDate}) =>
Poll(id: id, question: question ?? this.question, pubDate: pubDate ?? this.pubDate);
-
Manager signature changes:
Future<T> create(T model); // was Map<String, Object?>
Future<List<T>> bulkCreate(List<T> models);
Future<int> updateModel(T model); // update by PK using model instance
Internally, create() calls the existing toRow() to convert the model to a map, stripping auto-generated fields before insert. The executor already handles auto-fill for timestamps and UUIDs.
Migration path: The map-based overloads can be kept as createRaw() / updateRaw() during a deprecation period, then removed in the next major version.
2. Typed getOrCreate / updateOrCreate Defaults
Problem: defaults parameter is Map<String, Object?>.
Current API:
final (poll, created) = await Polls.objects.getOrCreate(
condition: Polls.$.question.eq('Favorite color?'),
defaults: {'pub_date': DateTime.now()},
);
Proposed API:
final (poll, created) = await Polls.objects.getOrCreate(
condition: Polls.$.question.eq('Favorite color?'),
defaults: Poll(question: 'Favorite color?', pubDate: DateTime.now()),
);
Implementation: The defaults parameter type changes from Map<String, Object?> to T (the model type). Internally calls toRow() to convert to a map. This follows naturally from Typed Create / Update / BulkCreate — once create() accepts model instances, getOrCreate and updateOrCreate defaults should too.
3. Typed values() Field Selection
Problem: values() takes List<String> — field names are unchecked strings, return type is List<Map<String, dynamic>>.
Current API:
final results = await Polls.objects.values(['question', 'pub_date']);
// returns List<Map<String, dynamic>> — no compile-time guarantee on keys or value types
Proposed API:
final results = await Polls.objects
.values([Polls.$.question, Polls.$.pubDate]);
// returns List<Map<String, dynamic>> but field names are validated at compile time
Implementation: Change values() signature to accept List<FieldRef> instead of List<String>:
ValuesQuerySet<T> values(List<FieldRef> fields) {
return ValuesQuerySet<T>._(
_executor,
_config.copyWith(selectFields: fields.map((f) => f.column).toList()),
);
}
This is a minimal change — field references already carry the column name via their column property. The return type stays List<Map<String, dynamic>> for now (full typed projections would require a more complex approach, see Future Work).
4. Typed valuesFlat() Without Explicit Type Parameter
Problem: valuesFlat<V>(String field) requires the caller to specify the type parameter and pass the field as a string.
Current API:
final questions = await Polls.objects.valuesFlat<String>('question');
Proposed API:
final questions = await Polls.objects.valuesFlat(Polls.$.question);
// V is inferred as String from StringFieldRef
Implementation: Change signature to accept a FieldRef<V>:
ValuesListQuerySet<T, V> valuesFlat<V>(FieldRef<V> field) {
return ValuesListQuerySet<T, V>._(
_executor,
_config.copyWith(selectFields: [field.column]),
);
}
Since FieldRef is generic (FieldRef<T>), Dart infers V automatically. Polls.$.question is a StringFieldRef which extends FieldRef<String>, so V resolves to String.
5. Typed Aggregation Results
Problem: aggregate() returns Map<String, dynamic> — callers must cast values and use string keys.
Current API:
final result = await Polls.objects.aggregate({
'total': Count(Polls.$.id),
'latest': Max(Polls.$.pubDate),
});
final total = result['total'] as int; // runtime cast
final latest = result['latest'] as DateTime; // runtime cast
Proposed API:
final stats = await Polls.objects.aggregate(
count: Count.all(),
avgAge: Avg(Polls.$.pubDate),
);
stats.count; // int — typed
stats.avgAge; // double — typed
For single aggregates, a simpler shorthand:
final total = await Polls.objects.aggregateValue(Count.all());
// returns int directly
Implementation: Two complementary approaches:
-
Single aggregate — aggregateValue<R>() returns a typed scalar:
Future<R> aggregateValue<R>(AggregateExpression<R> expr) async {
final result = await aggregate({'_v': expr});
return result['_v'] as R;
}
-
Multiple aggregates — named parameters returning a typed result object. This requires aggregate classes to carry their return type:
class Count extends AggregateExpression<int> { ... }
class Max<T> extends AggregateExpression<T> { ... }
class Sum extends AggregateExpression<num> { ... }
class Avg extends AggregateExpression<double> { ... }
The multi-aggregate API could use Dart records to avoid code generation:
final (:count, :avgAge) = await Polls.objects.aggregateRecord(
count: Count.all(),
avgAge: Avg(Polls.$.pubDate),
);
6. Typed Raw Queries
Problem: raw() returns List<Map<String, dynamic>> — no deserialization into model instances.
Current API:
final rows = await Polls.objects.raw(
'SELECT * FROM poll WHERE pub_date > \$1',
[DateTime(2025, 1, 1)],
);
// rows is List<Map<String, dynamic>>
final question = rows.first['question'] as String; // manual cast
Proposed API:
// Returns model instances using the existing fromRow() deserializer
final polls = await Polls.objects.rawAs(
'SELECT * FROM poll WHERE pub_date > \$1',
[DateTime(2025, 1, 1)],
);
// polls is List<Poll> — fully typed
// Keep raw() for non-model queries
final rows = await Polls.objects.raw('SELECT COUNT(*) as c FROM poll');
Implementation:
Future<List<T>> rawAs(String sql, [List<Object?>? params]) async {
final rows = await raw(sql, params);
return rows.map((row) => _fromRow(row)).toList();
}
This is straightforward since every model already has a generated fromRow(). The only caveat is the raw SQL must return columns matching the model's expected column names.
7. Partial Updates with Named Parameters
Problem: When updating a single field, you still construct a full map or update object. There's no way to express "update just this one field" concisely.
Current API:
await Choices.objects
.filter(Choices.$.id.eq(choiceId))
.update({'votes': 42});
Proposed API:
// Option A: updateFields with named parameters (generated per model)
await Choices.objects
.filter(Choices.$.id.eq(choiceId))
.updateFields(votes: 42);
// Option B: copyWith callback
await Choices.objects
.filter(Choices.$.id.eq(choiceId))
.update((c) => c.copyWith(votes: 42));
// Option C: single-field update with FieldRef (additive, works today)
await Choices.objects
.filter(Choices.$.id.eq(choiceId))
.updateField(Choices.$.votes, 42);
// Expression-based update (e.g., increment — Django F-expression equivalent):
await Choices.objects
.filter(Choices.$.id.eq(choiceId))
.updateField(Choices.$.votes, Choices.$.votes + 1);
Implementation:
Option C is the simplest and can be added immediately without code generation:
Future<int> updateField<V>(FieldRef<V> field, V value) {
return update({field.column: value});
}
This is type-safe because FieldRef<V> constrains the value type — you can't pass a String to an IntFieldRef.
Options A and B require generated model-specific code (updateFields with named params, or a copyWith method). These pair naturally with Typed Create / Update / BulkCreate — once copyWith is generated for models, Option B comes for free.
8. Typed values() + distinct()
Problem: distinct() combined with values() uses the same stringly-typed API.
Current API:
final uniqueQuestions = await Polls.objects
.values(['question'])
.distinct()
.toList();
// List<Map<String, dynamic>>
Proposed API:
final uniqueQuestions = await Polls.objects
.values([Polls.$.question])
.distinct()
.toList();
Implementation: This is effectively solved by Typed values() Field Selection. Once values() accepts List<FieldRef>, distinct() needs no changes — it already operates on the query configuration, not the field selection.
For the single-field distinct case, Typed valuesFlat() covers it cleanly:
final uniqueQuestions = await Polls.objects
.valuesFlat(Polls.$.question)
.distinct()
.toList();
// List<String> — fully typed
Implementation Priority
| Proposal |
Impact |
Effort |
Breaking? |
| Typed Create/Update/BulkCreate |
High |
High |
Yes (can migrate) |
| Typed valuesFlat() |
High |
Low |
Yes (minor) |
| Typed values() |
Medium |
Low |
Yes (minor) |
| Partial updateField() |
Medium |
Low |
No (additive) |
| Typed getOrCreate defaults |
Medium |
Low |
Yes (depends on Typed Create) |
| rawAs<T>() |
Medium |
Low |
No (additive) |
| Typed aggregations |
Medium |
Medium |
No (additive) |
| Typed values + distinct |
Low |
None |
Solved by Typed values/valuesFlat |
Suggested rollout:
- Phase 1: Typed valuesFlat, Typed values, Partial updateField, rawAs — low effort, additive or minor breaking changes
- Phase 2: Typed Create/Update/BulkCreate, Typed getOrCreate defaults — requires code generator changes and API design decisions
- Phase 3: Typed aggregations — requires typed aggregate expressions
Backward Compatibility
- Typed Aggregations, rawAs, Partial updateField are additive — new methods alongside existing ones, no breakage.
- Typed values(), Typed valuesFlat() change parameter types (
String → FieldRef). These are source-breaking but easy to migrate with a find-and-replace.
- Typed Create/Update/BulkCreate, Typed getOrCreate defaults change parameter types (
Map → model instance). These are source-breaking and require updating all call sites. A deprecation period with createRaw() / updateRaw() aliases is recommended.
All breaking changes should ship in a minor version with deprecation warnings, then remove the old API in the next major version.
Motivation
jao's query/filter API is fully typed —
Polls.$.question.eq("test")gives compile-time safety, autocomplete, and refactor resilience. However, mutations (create,update), projections (values), aggregations, and raw queries still rely onMap<String, Object?>andList<String>. This creates a split where reads are safe but writes are stringly-typed and error-prone.This RFC proposes closing that gap across 8 areas, ordered by impact.
1. Typed Create / Update / BulkCreate
Problem:
create(),update(), andbulkCreate()acceptMap<String, Object?>, meaning typos in column names and wrong value types are only caught at runtime.Current API:
Proposed API:
Implementation: The model class is already generated with all fields. The key changes are:
Model constructor: Auto-generated fields (PK,
autoNow,autoNowAdd, UUID) become optional parameters so callers don't need to provide them on create:Generated
copyWith: Enables partial updates idiomatically:Manager signature changes:
Internally,
create()calls the existingtoRow()to convert the model to a map, stripping auto-generated fields before insert. The executor already handles auto-fill for timestamps and UUIDs.Migration path: The map-based overloads can be kept as
createRaw()/updateRaw()during a deprecation period, then removed in the next major version.2. Typed getOrCreate / updateOrCreate Defaults
Problem:
defaultsparameter isMap<String, Object?>.Current API:
Proposed API:
Implementation: The
defaultsparameter type changes fromMap<String, Object?>toT(the model type). Internally callstoRow()to convert to a map. This follows naturally from Typed Create / Update / BulkCreate — oncecreate()accepts model instances,getOrCreateandupdateOrCreatedefaults should too.3. Typed values() Field Selection
Problem:
values()takesList<String>— field names are unchecked strings, return type isList<Map<String, dynamic>>.Current API:
Proposed API:
Implementation: Change
values()signature to acceptList<FieldRef>instead ofList<String>:This is a minimal change — field references already carry the column name via their
columnproperty. The return type staysList<Map<String, dynamic>>for now (full typed projections would require a more complex approach, see Future Work).4. Typed valuesFlat() Without Explicit Type Parameter
Problem:
valuesFlat<V>(String field)requires the caller to specify the type parameter and pass the field as a string.Current API:
Proposed API:
Implementation: Change signature to accept a
FieldRef<V>:Since
FieldRefis generic (FieldRef<T>), Dart infersVautomatically.Polls.$.questionis aStringFieldRefwhich extendsFieldRef<String>, soVresolves toString.5. Typed Aggregation Results
Problem:
aggregate()returnsMap<String, dynamic>— callers must cast values and use string keys.Current API:
Proposed API:
For single aggregates, a simpler shorthand:
Implementation: Two complementary approaches:
Single aggregate —
aggregateValue<R>()returns a typed scalar:Multiple aggregates — named parameters returning a typed result object. This requires aggregate classes to carry their return type:
The multi-aggregate API could use Dart records to avoid code generation:
6. Typed Raw Queries
Problem:
raw()returnsList<Map<String, dynamic>>— no deserialization into model instances.Current API:
Proposed API:
Implementation:
This is straightforward since every model already has a generated
fromRow(). The only caveat is the raw SQL must return columns matching the model's expected column names.7. Partial Updates with Named Parameters
Problem: When updating a single field, you still construct a full map or update object. There's no way to express "update just this one field" concisely.
Current API:
Proposed API:
Implementation:
Option C is the simplest and can be added immediately without code generation:
This is type-safe because
FieldRef<V>constrains the value type — you can't pass aStringto anIntFieldRef.Options A and B require generated model-specific code (
updateFieldswith named params, or acopyWithmethod). These pair naturally with Typed Create / Update / BulkCreate — oncecopyWithis generated for models, Option B comes for free.8. Typed values() + distinct()
Problem:
distinct()combined withvalues()uses the same stringly-typed API.Current API:
Proposed API:
Implementation: This is effectively solved by Typed values() Field Selection. Once
values()acceptsList<FieldRef>,distinct()needs no changes — it already operates on the query configuration, not the field selection.For the single-field distinct case, Typed valuesFlat() covers it cleanly:
Implementation Priority
Suggested rollout:
Backward Compatibility
String→FieldRef). These are source-breaking but easy to migrate with a find-and-replace.Map→ model instance). These are source-breaking and require updating all call sites. A deprecation period withcreateRaw()/updateRaw()aliases is recommended.All breaking changes should ship in a minor version with deprecation warnings, then remove the old API in the next major version.