db_dsl_lints
The analyzer plugin of db_dsl. It reads
each model's toJson, checks every table against it while you type, and
writes the typed fields of a table from its model, so that queries read
t.done.eq(false) with the name and type the model stores.
No code generation, no macros: the fields are plain code in your file, written by a quick fix, and kept in step with the model by the diagnostics below.
Setup
Requires Dart 3.11 or later (the analyzer it builds on needs it). In the
analysis_options.yaml at the root of your package (not in
pubspec.yaml):
plugins:
db_dsl_lints: ^0.1.0
Restart the analysis server after changing the plugins section. The
diagnostics then appear in the IDE and in dart analyze, so CI checks them
too. In a Flutter project run dart analyze as well: flutter analyze does
not report plugin diagnostics yet (checked with Flutter 3.47).
From a model to typed queries
The model carries its table:
class Task {
const Task(this.id, this.done, this.updatedAt);
factory Task.fromJson(Map<String, dynamic> json) => Task(
json['id'] as String,
json['done'] as bool,
DateTime.parse(json['updated_at'] as String),
);
static final table = DbTable<Task>('tasks', key: 'id', fromJson: Task.fromJson);
final String id;
final bool done;
final DateTime updatedAt;
Map<String, dynamic> toJson() => {
'id': id,
'done': done,
'updated_at': updatedAt.toIso8601String(),
};
}
DbTable<Task> gets the warning missing_query_fields: 'Task' stores
fields its table cannot query: id, done, updatedAt. Its quick fix, Write
the query fields from the model (Ctrl+. or ⌘. in VS Code, Alt+Enter in
IntelliJ and Android Studio), writes this right after the model:
/// The fields of `Task` for queries, read from its `toJson`.
extension TaskFields on DbTable<Task> {
/// The stored `id`.
Field<String> get id => field('id');
/// The stored `done`.
Field<bool> get done => field('done');
/// The stored `updated_at`.
Field<DateTime> get updatedAt => field('updated_at');
}
and queries are checked by the compiler:
final t = Task.table;
await t.filter(t.done.eq(false).and(t.updatedAt.gt(since))).order(t.updatedAt.desc());
t.dne; // does not compile
t.done.eq(1); // does not compile: Field<bool>.eq(bool)
When the model changes, the plugin says so where it matters:
- a field is added →
missing_query_fieldson the table again; the same quick fix rewrites the extension; - a field is renamed or removed →
unknown_fieldon the getter that still names it, with Write the query fields from the model, and every query using that getter stops compiling once it is rewritten; - a type changes →
field_type_mismatch, with Use Field.
The assist Write the query fields of the table, on DbTable<Task>(...)
or on its extension, does the same on demand.
Diagnostics
| Code | Severity | When | Quick fixes |
|---|---|---|---|
missing_query_fields |
warning | the model stores fields its table does not expose as getters: no <T>Fields extension, or the model gained a field |
Write the query fields from the model |
unknown_field |
error | field('dne'), or a name in Index([...]), is not a field the model stores; a nested path ('address.cty') is checked in the nested model |
Use 'done' (the closest name), Write the query fields from the model |
field_type_mismatch |
error | Field<int> get done => field('done') while the model stores a bool, or an untyped field('done') |
Use Field |
unknown_key |
error | the key: of the table is not a stored field |
Use 'id' |
What it reads
- A
toJsonthat returns a map literal:=> {'id': id, ...}, or a block whose only statement isreturn {...}; entries may be'key': ?valueorif (x case final v?) 'key': v. - json_serializable's generated
_$TaskToJsonin a part file, with the keys@JsonKey(name: ...)gave them. - A
toJson:passed to the table, which then decides what is stored. - Nested models (
'address.city'), each through its owntoJson. - A
DateTimestored astoIso8601String(), or asmillisecondsSinceEpoch/microsecondsSinceEpoch; for the epoch forms the written field gets itsencodeanddecode.
A field with its own encode or decode stores its value its own way, so
its type is not checked.
How it works
The analysis server loads the plugin in an isolate of its own (Dart's
analyzer plugin system, analysis_server_plugin), and runs it on every
resolved file:
- Find the tables. For each
DbTable<T>(...)and eachfield(...)on aDbTable<T>— inside anextension ... on DbTable<T>or on an explicit table — it resolvesT, and only for theDbTableandFielddeclared bypackage:db_dsl. - Read what
Tstores. It parses the library that declaresTand reads the map thattoJsonreturns: each key, the member ofTit comes from, that member's static type, and whether aDateTimeis written as ISO 8601 or as epoch milliseconds or microseconds. A nested model is read the same way through its owntoJson. AtoJson:passed to the table replaces the model's. - Compare. The table's
key:, the names inIndex([...]), eachfield('path')and itsField<V>type are checked against what is stored; the getters of every visible extension onDbTable<T>are checked for completeness. - Write. The quick fixes and the assist build their edits from the same reading, so the fields they write always pass the checks.
Nothing runs in your app: the plugin only exists in the analysis server, and the extension it writes is ordinary code that compiles without it.
Limits
- Never a false positive. When a model's
toJsonis built at run time (Map.of(values), a loop), the plugin cannot know what is stored and reports nothing for that model. - Extensions are looked up wherever they are visible: fields written in another file count. The quick fix writes in the file where it is used (after the model when the model is there), and never adds a second extension when one already lives in another file.
- Fixes run from the IDE only. Dart's plugin system does not apply
plugin fixes in bulk yet, so neither
dart fixnor fix-all-on-save runs them; the diagnostics are what tells you, the moment the model changes.
License
Apache License 2.0. Redistributions must keep the NOTICE file, which credits JhonaCodes as the author.
Libraries
- main
- The entry point the analysis server loads.