in_app_query
A powerful, Firestore-inspired in-memory query engine for Dart & Flutter. Filter, sort, paginate, and reactively observe in-memory collections with a familiar, composable API — no network required.
Table of Contents
- Features
- Installation
- Quick Start
- QueryBuilder
- Filter
- FieldPath
- IndexedSource
- Collection
- ReactiveQuery
- Error Handling
- Performance
Features
- ✅ Firestore-compatible query API (
where,orderBy,limit,startAt, …) - ✅ Composite
AND/ORfilters with arbitrary nesting - ✅ Dot-notation nested field access (
address.city) - ✅ Array operators (
arrayContains,arrayContainsAny, …) - ✅ Cursor-based pagination (
startAt,startAfter,endAt,endBefore) - ✅ Aggregations (
count,sum,average,min,max) - ✅
groupBy,distinct, andtransformprojections - ✅ Live
Collectionwith CRUD, atomic batch writes, and change streams - ✅
ReactiveQueryfor auto-updating derived views - ✅
IndexedSourcefor O(1) field lookups on hot paths - ✅ Fully synchronous build path; async helpers (
execute,stream,paginate) available - ✅ Immutable result lists
Installation
dependencies:
in_app_query: ^1.1.0
import 'package:in_app_query/in_app_query.dart';
Quick Start
final users = [
{'id': 'u1', 'name': 'Alice', 'age': 28, 'role': 'admin', 'active': true},
{'id': 'u2', 'name': 'Bob', 'age': 34, 'role': 'user', 'active': true},
{'id': 'u3', 'name': 'Eve', 'age': 22, 'role': 'guest', 'active': false},
];
final results = QueryBuilder(users)
.where('active', isEqualTo: true)
.where('age', isGreaterThan: 25)
.orderBy('age')
.limit(10)
.build();
QueryBuilder
QueryBuilder is the main entry point. It is immutable and reusable — every method returns a new builder instance, leaving the original unchanged.
QueryBuilder(List<Map<String, dynamic>> source)
QueryBuilder.empty() // empty source
QueryBuilder.fromIndexed(IndexedSource source)
Filtering
// Equality
.where('role', isEqualTo: 'admin')
.where('role', isNotEqualTo: 'guest')
// Comparison — works on num, String, DateTime
.where('age', isLessThan: 30)
.where('age', isLessThanOrEqualTo: 30)
.where('age', isGreaterThan: 25)
.where('age', isGreaterThanOrEqualTo: 25)
// Set membership
.where('role', whereIn: ['admin', 'user'])
.where('role', whereNotIn: ['guest'])
// Null checks
.where('score', isNull: true)
.where('score', isNull: false)
// Array operators
.where('tags', arrayContains: 'flutter')
.where('tags', arrayNotContains: 'flutter')
.where('tags', arrayContainsAny: ['rust', 'go'])
.where('tags', arrayNotContainsAny: ['dart', 'python'])
// Custom predicate
.whereCustom((doc) => (doc['name'] as String).startsWith('A'))
// Chain multiple conditions (implicit AND)
.where('active', isEqualTo: true)
.where('role', isEqualTo: 'admin')
Nested fields are accessed with dot notation:
.where('address.city', isEqualTo: 'Tokyo')
.where('address.country', whereIn: ['USA', 'UK'])
Composite Filters
Pass a Filter object to .where() or .whereFilter() for AND / OR logic:
.whereFilter(
Filter.and([
const Filter('active', isEqualTo: true),
Filter.or([
const Filter('role', isEqualTo: 'admin'),
const Filter('age', isGreaterThan: 40),
]),
]),
)
Filter.and([])keeps all documents.Filter.or([])drops all documents.
Sorting
.orderBy('age') // ascending (default)
.orderBy('age', descending: true) // descending
// Multi-field: primary then secondary
.orderBy('role').orderBy('age', descending: true)
null values are always sorted last in ascending order and first in descending order.
Cursors
Cursors require an orderBy to be set first. Values correspond positionally to the ordered fields.
.orderBy('age').startAt([28]) // age >= 28 (inclusive)
.orderBy('age').startAfter([28]) // age > 28 (exclusive)
.orderBy('age').endAt([34]) // age <= 34 (inclusive)
.orderBy('age').endBefore([34]) // age < 34 (exclusive)
// Range
.orderBy('age').startAt([28]).endAt([34])
// Start from a specific document
.orderBy('age').startAtDocument(myDoc)
Pagination
.limit(10) // take the first N results
.limitToLast(10) // take the last N results (requires orderBy)
.offset(20) // skip the first N results
.offset(20).limit(10) // classic page = offset / limit
// Async streaming pages
await for (final page in builder.paginate(pageSize: 20)) {
// page is List<Map<String, dynamic>>
}
Aggregations
Aggregations are terminal — they consume the builder and return a value directly without calling .build().
builder.count() // int
builder.sum('age') // num? (null if no documents)
builder.average('age') // num? (null if no documents)
builder.min('age') // dynamic
builder.max('age') // dynamic
builder.first() // Map<String, dynamic>?
builder.last() // Map<String, dynamic>?
builder.isEmpty // bool
builder.isNotEmpty // bool
Grouping & Distinct
// Returns Map<dynamic, List<Map<String, dynamic>>>
final byRole = QueryBuilder(users).groupBy('role');
// { 'admin': [...], 'user': [...], 'guest': [...] }
// Dot-notation supported
final byCountry = QueryBuilder(users).groupBy('address.country');
// Keep only the first document for each unique value of a field
final uniqueRoles = QueryBuilder(users).distinct('role').build();
Transform
Project documents into a new shape before returning results:
final result = QueryBuilder(users)
.transform((doc) => {
'name': doc['name'],
'isAdult': (doc['age'] as int) >= 18,
})
.build();
transform can be combined with filters and sorting applied before it.
Stream & Async API
// Emit each document individually as a stream
await for (final doc in builder.stream()) { ... }
// Return all results as a Future
final results = await builder.execute();
// With an artificial delay (useful for testing loaders)
final results = await builder.execute(delay: const Duration(milliseconds: 200));
Filter
A standalone, reusable filter object. Accepts the same named parameters as .where().
const Filter('role', isEqualTo: 'admin')
const Filter('age', isGreaterThan: 25)
const Filter('tags', arrayContains: 'flutter')
const Filter('role', whereIn: ['admin', 'user'])
Filter.and([filter1, filter2, ...])
Filter.or([filter1, filter2, ...])
FieldPath
Use FieldPath as a typed alternative to dot-notation strings:
QueryBuilder(users)
.where(FieldPath('address.country'), isEqualTo: 'Japan')
.build();
IndexedSource
Pre-build hash-map indexes for fields that are queried repeatedly. Lookups against indexed fields run in O(1) instead of O(n).
final indexed = IndexedSource(
users,
indexedFields: ['role', 'active'],
);
indexed.length; // int
indexed.hasIndex('role'); // true
indexed.hasIndex('age'); // false
indexed.lookup('role', 'admin'); // List<Map<String, dynamic>>?
indexed.indexedKeys('role'); // Set of distinct values for the field
// Use with QueryBuilder
final qb = QueryBuilder.fromIndexed(indexed);
Collection
A live, mutable store that wraps a list of documents and emits change events.
final col = Collection(); // empty
final col = Collection.from(existing); // seeded with existing docs
// Remember to dispose when done
await col.dispose();
CRUD
Every document must have an 'id' field.
col.add({'id': 'u1', 'name': 'Alice'}); // throws if id exists
col.update('u1', {'name': 'Alicia'}); // shallow merge; throws if missing
col.set('u1', {'name': 'Alicia', 'age': 30}); // full replace
col.remove('u1'); // returns bool
col.contains('u1'); // bool
col.doc('u1'); // Map? — null if missing
col.length; // int
Batch Operations
Execute multiple mutations atomically. If any operation throws, all changes are rolled back and no change events are emitted.
col.batch((scope) {
scope.add({'id': 'u6', 'name': 'Frank'});
scope.update('u1', {'role': 'superadmin'});
scope.remove('u3');
});
Reactive Snapshots
// Full snapshot after every mutation
col.snapshots().listen((List<Map<String, dynamic>> all) { ... });
// Granular change events
col.changes.listen((List<CollectionChange> changes) {
for (final change in changes) {
print('${change.type}: ${change.id}');
}
});
ReactiveQuery
Combines a Collection with a QueryBuilder query to produce a self-updating view.
final reactive = ReactiveQuery(
source: col,
query: (qb) => qb.where('role', isEqualTo: 'admin').orderBy('age'),
);
// Synchronous snapshot of the current result
final current = reactive.now(); // List<Map<String, dynamic>>
// Stream that re-emits whenever the underlying collection changes
reactive.watch().listen((List<Map<String, dynamic>> results) { ... });
// Convenience: stream of result counts
reactive.watchCount().listen((int count) { ... });
The stream is debounced — rapid synchronous mutations to the source collection are coalesced into a single emission.
Error Handling
| Situation | Exception |
|---|---|
limit or offset called with a negative value |
InvalidQueryException |
limitToLast called without orderBy |
InvalidQueryException |
startAt / startAfter / endAt / endBefore called without orderBy |
CursorException |
| Cursor values list is empty or has more entries than ordered fields | CursorException |
Collection.add called with a document missing an 'id' key |
InvalidQueryException |
Collection.add called with a duplicate id |
InvalidQueryException |
Collection.update called with a non-existent id |
InvalidQueryException |
Performance
in_app_query is optimised for in-memory workloads:
QueryBuilder.fromIndexedskips O(n) scans for equality filters on indexed fields.- The compiled filter path (
whereFilter) fuses all conditions into a single pass over the source list. - Benchmark on a 50 000-document collection with a compiled
AND(whereIn, whereNotIn)filter typically completes in < 50 ms on a mid-range device.
For very large datasets, prefer IndexedSource on high-cardinality equality fields and avoid rebuilding QueryBuilder instances in hot loops — builders are reusable by design.
.