Skip to content

# RFC: Enhance Type Safety Across Query API #37

Description

@Mastersam07

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:

  1. 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});
    }
  2. Generated copyWith: Enables partial updates idiomatically:

    Poll copyWith({String? question, DateTime? pubDate}) =>
        Poll(id: id, question: question ?? this.question, pubDate: pubDate ?? this.pubDate);
  3. 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:

  1. Single aggregateaggregateValue<R>() returns a typed scalar:

    Future<R> aggregateValue<R>(AggregateExpression<R> expr) async {
      final result = await aggregate({'_v': expr});
      return result['_v'] as R;
    }
  2. 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 (StringFieldRef). 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions