Accessing External State with Queries
Your AFib state is stored in transient memory. When your app restarts, any state that is not hard-coded into the app (e.g. in const data structures), gets lost.
As a result, you will need to store persistent state somewhere else, and then load that state into your AFib in-memory state. For example, you might store the state in a sqlite database on the device, or in a firebase database in the cloud, or behind a REST API, or behind a third party or device API. All of these are examples of persistent external state.
AFib queries provide a simple, consistent way to interact with external state, and integrate it into your in-memory AFib state. You can both read from and write to external state using AFib queries.
The Essence of an AFib Query
An AFib query is a class with three important methods:
- The constructor
-
The constructor will often take parameters which configure the query itself. For example, a query that loads all the todo items for a user might take the user id as a parameter to its constructor. It might then use that user id to form the REST URI, SQL query, Firebase query, etc, that actually loads the data.
Similarly, a query that writes a todo item to a REST API, SQL query, etc might take the todo item to write as a constructor parameter.
- startAsync
-
This method gets called to start the (usually but not necessarily) asynchronous process of actually querying data from the external system. It waits for the result to come back, then parses it into a model object, and passes it to
context.onSuccess
(or thecontext.onError
method if an error occurs).This method should pass a model object to
context.onSuccess
because AFib makes it easy to specify test data as a query result, and it is typically easier to maintain test data as model objects than in a serial format. - finishAsyncWithResponse
-
This method gets called with the result that was passed to
context.onSuccess
. It's job is to integrate the result into your in-memory AFib state.
When your app starts, it will typically execute a number of queries, each of which will access some peristent external state, and integrate the result into your in-memory state, thus populating your UI. As your app executes, it will execute additional queries, reading and modifying external state, and then integrating the result into your in-memory state.
The Practical Purpose of AFib Queries
AFib queries simplify development and testing in two ways:
Purpose 1: Universal "Mocks" of External Data Access
When Afib runs in production or debug mode, it calls startAsync
as described above. In tests, however, AFib can omit calls to startAsync
, and call finishAsyncWithResponse
with a known test result. This allows your app to run in a test context without a backend. It allows you to simulate responses and errors from backend systems and device APIs in tests in a single easy, consistent way.
Implication: If You Think You Need a Mock, You Probably Need a Query
If you have developed some code, and while testing it feel the need to develop a custom mock interface (e.g. you wrote code that interacts directly with the devices purchase API, and now you are wondering how to test it), then you probably should have placed that code inside a query. The query provides a single, universal way to mock the results returned by calls to external data sources.
Implication: Queries extend prototyping into production development
Because your state test code (described in a subsequent section) can provide results to your queries, you can easily simulate interactions with backend systems and APIs before those systems actually exist. Because you can run state tests in the UI, you can prototyping a full working version of your app in many cases.
I routinely use state tests to prototype full app functionality before I implement back end APIs and data structures. I can iteratively refine this code much more quickly when I don't have to replicate each change in a persistent backend system, or worry about the state of test data in that system.
Purpose 2: Isolation of Asynchronous Code for Testing
Dart supports and async
/await
model. This model is very useful, but it can cause problems in testing. If your code calls an asynchronous method without chaining the await all the way up to the test, then your test code will often (but not necessarily!) continue executing before the asynchronous process completes. As a result, it will be validating a state that is inconsistent, and the test will be difficult to debug because the code executes in a non-intuitive order.
In AFib, you must put all asynchronous code within the startAsync
method of a query (technically, you can have asynchronous code in pure UI code as well, but there isn't a usually significant advantage to doing so). Recall that the startAsync
method does not get called in test contexts. As a result, you can write state-based tests that are purely synchronous, without the need to worry about async/await. These tests are simpler to debug, radically faster to execute, and do not suffer from the inconsistency associated with asynchronous code executing in a non-intuitive order.
Implication: Never put async
on any function except startAsync
The implication is that you should not have the async
modifier on any function that is not AFAsyncQuery.startAsync
.
An Example AFib Query
This code shows a simple example AFib query that loads todo list items from a local sqlite database for a specific userId
. Please read the comments in the code, as they are intended as part of this documentation.
class ReadTodoItemsQuery extends AFAsyncQuery<List<TodoItem>> {
// this query loads the items for a specific user.
final String userId;
StartupQuery({
AFID? id,
required this.userId,
AFOnResponseDelegate<AFUnused>? onSuccess,
AFOnErrorDelegate? onError,
AFPreExecuteResponseDelegate<AFUnused>? onPreExecuteResponse
}) : super(
id: id,
onSuccess: onSuccess,
onError: onError,
onPreExecuteResponse: onPreExecuteResponse,
);
@override
void startAsync(AFStartQueryContext<List<TodoItem>> context) async {
// this method hypthetically returns a sqlite db connection.
final db = await TDLESqlLite.instance.connectToDatabase();
// here is the actual query, which returns a list of results.
// note that it references the user id to form the query.
final results = await db.rawQuery("SELECT id, user_id, item_name, status, from todo_items where user_id = ?", [this.userId]);
// this static method converts our generic database results into a List<TodoItem>
// generally it will be easier if you parse your wire format into a business object in startAsync,
// as it allows you to construct your test data as business objects, not wire-format data.
final todoResults = TodoItem.parseDBResults(results);
// this is ultimately going to cause finishAsyncWithResponse to be called below, with the specified
// List<TodoItem> as the result.
context.onSuccess(todoResults);
}
@override
void finishAsyncWithResponse(AFFinishQuerySuccessContext<List<TodoItem>> context) {
// get the response passed to on-success, or specified via a state-test
final result = context.r; // or context.response if prefer something more verbose.
// get the state for this app.
final todoState = context.accessComponentState<TODOState>();
// revise the appropriate root objects on the state.
final revisedItems = todoState.todoItems.reviseUpdateItems(result);
// now, update the root state with the revised items.
context.updateComponentRootStateOne<TODOState>(revisedItems);
}
}
Executing a Query
Most Afib contexts provide an executeQuery
method, but there are two places where you will execute queries most frequently.
Executing a Query from the User Interface
First, you will execute queries from within an SPI in your user interface. For example, perhaps you have a button that adds a todo list item, like this:
Within the SPI method, you might have code that looks like this:
void onPressedAddItem() {
// a TodoItem which has been constructed as the user interacts with widgets.
final item = context.p.itemInProgress;
// a query that will write the item to your persistent external state.
final addQuery = WriteTodoItemQuery(item: item);
// this causes your query to be executed. It will call startAsync in
// production code, but will skip that method in favor of test code or data in a state test.
context.executeQuery(addQuery);
}
Finally, lets look at a hypothetical implementation of WriteTodoItemQuery
itself, as it will help you understand how executing the query will ultimately update both your persistant external state and your in-memory AFib state, causing your UI to re-render.
class WriteTodoItemQuery extends AFAsyncQuery<TodoItem> {
// Note that the item to save is a parameter to the constructor of the query.
final TodoItem item;
WriteTodoItemQuery({
AFID? id,
required this.item,
AFOnResponseDelegate<AFUnused>? onSuccess,
AFOnErrorDelegate? onError,
AFPreExecuteResponseDelegate<AFUnused>? onPreExecuteResponse
}) : super(
id: id,
onSuccess: onSuccess,
onError: onError,
onPreExecuteResponse: onPreExecuteResponse,
);
@override
void startAsync(AFStartQueryContext<List<TodoItem>> context) async {
// this method hypthetically returns a sqlite db connection.
final db = await TODOSqlLite.connectToDatabase();
// here is the actual query, which does the insertion
final newId = await db.rawInsert("INSERT INTO todo_items (user_id, item_name, status) values (?, ?, ?)", [this.item.userId, this.item.name, this.item.status]);
// we revise our immutable TodoItem to have an actual database identifier.
final result = this.item.reviseWithId(newId);
// we call onSuccess, which will ultimately cause `finishAsyncWithResponse` to integrate
// it into our in-memory AFib state.
context.onSuccess(result);
}
@override
void finishAsyncWithResponse(AFFinishQuerySuccessContext<TodoItem> context) {
final result = context.r; // this is the TodoItem with its new database id.
// get the state for this app.
final todoState = context.accessComponentState<TODOState>();
// revise the appropriate root objects on the state.
final revisedItems = todoState.todoItems.reviseAddItem(result);
// now, update the root state with the revised items.
context.updateComponentRootStateOne<TODOState>(revisedItems);
}
}
When context.updateComponentRootStateOne
is called with the new TodoItemsRoot value, any active user interface whose state view contains that root object (notably, one displaying a list of all the known todo items), will automatically re-render. Because its state view will now have one extra todo item, the item will show up in the UI as soon a the query completes.
Executing a Query from another Query
Executing a Query from finishAsyncWithResponse
You will also often execute one or more queries from another query's finishAsyncWithResponse
method. For example, you might have a query like this:
class IsUserSignedInQuery extends AFAsyncQuery<UserCredential?> {
// There are not query specific parameters here, the assumption is that you have somehow stored
// some kind of validated token on the filesystem if the user has a valid signin (or maybe a Firebase api hands it to you, etc)
IsUserSignedInQuery({
AFID? id,
AFOnResponseDelegate<UserCredential>? onSuccess,
AFOnErrorDelegate? onError,
AFPreExecuteResponseDelegate<UserCredential>? onPreExecuteResponse
}) : super(
id: id,
onSuccess: onSuccess,
onError: onError,
onPreExecuteResponse: onPreExecuteResponse,
);
@override
void startAsync(AFStartQueryContext<UserCredential?> context) async {
// a hypothetical method that retures a UserCredential if the user already has a valid
// login token, or otherwise returns null
final userCredential = await _loadUserCredential();
context.onSuccess(userCredential);
}
@override
void finishAsyncWithResponse(AFFinishQuerySuccessContext<UserCredential?> context) {
// result is your user credential, or null.
final result = context.r;
if(result == null) {
// the user is not signed in, lets navigate to the signin page.
context.navigateReplaceAll(SigninScreen.navigatePush().castToReplaceAll());
} else {
// the user is signed in, and the credential contains their user id.
// first, lets integrate this user credential into our state.
// get the state for this app.
final tdleState = context.accessComponentState<TODOState>();
// revise the appropriate root objects on the state.
final revisedUser = tdleState.activeUser.reviseCredential(result);
// now, update the root state with the revised items.
context.updateComponentRootStateOne<TODOState>(revisedUser);
// lets start loading in the user's data using the user id in their credential.
context.executeQuery(ReadUserSettingsQuery(userId: result.userId));
context.executeQuery(ReadTodoItemsQuery(userId: results.userId));
// as this is currently implemented, the HomeScreen needs to be tolerant of the
// user settings and todo items being in some initial state, as the two queries
// above are asynchronous and may not have completed when the navigation occurs.
// An example of waiting for both queries to complete before navigating is shown
// below using AFCompositeQuery
context.navigateReplaceAll(HomeScreen.navigatePush().castToReplaceAll());
}
}
}
Here, the IsUserSignedInQuery
loads a user credential in startAsync
, then if the user is signed in, executes several other queries
to load data associated with that user in finishAsyncWithResponse
.
Executing a Query from startAsync
In some cases, you may have several existing queries which you wish to execute within a startAsync
method. To do so, you can use context.executeQueryWithAwait
.
Doing so is useful because it allows you to re-use more atomic queries to produce higher level results. For example:
class RegisterNewUserQuery extends AFAsyncQuery<User> {
// Note that the item to save is a parameter to the constructor of the query.
final User newUser;
RegisterNewUserQuery({
AFID? id,
required this.newUser,
AFOnResponseDelegate<User>? onSuccess,
AFOnErrorDelegate? onError,
AFPreExecuteResponseDelegate<User>? onPreExecuteResponse
}) : super(
id: id,
onSuccess: onSuccess,
onError: onError,
onPreExecuteResponse: onPreExecuteResponse,
);
@override
void startAsync(AFStartQueryContext<User> context) async {
// first, create a 'team' object which may eventually be shared among multiple users
final team = Team.createNew("Default Team");
// this returns the same success context that you get in WriteTeamQuery finishAsyncWithResponse
// The main point here is that we will have other reasons to write the team using WriteTeam Query,
// so its nice to have a simple way to re-use that code here.
final teamSuccessCtx = await context.executeQueryWithAwait(WriteTeamQuery(team));
// make our user part of this default team, note that we can use the id of the newly
// created team object, linking it to our user.
final revisedUser = newUser.reviseTeamId(teamSuccessCtx.r.id);
// now, we write the user the same way, again, we are reusing a general write-user query here.
final userSuccessCtx = await context.executeQueryWithAwait(WriteUserQuery(revisedUser));
// also note that the WriteTeamQuery presumably integrates the updated team into a root
// state object (e.g. TeamsRoot), and the user query probably does the same. In this case,
// we are just sending the User to finishAsyncWithResponse, but we could pass then both via a
// small utility class if we wanted.
context.onSuccess(userSuccessCtx.r);
}
...
Note that you could have achieved the same effect using nested onSuccess
handlers for the queries. The await syntax is just cleaner and more linear.
Handling Results Inside finishAsyncWithResponse
As the finishAsyncWithResponse
method above suggests, you can do more within it than just integrating
results into your state. Specifically:
- context.navigate...
-
You can perform navigational actions, like navigating to a new screen. Although I find navigation is more likely to occur in the query's calling context, as described below.
- context.execute...
-
You can execute additional queries, which will often depend on data loaded in the current query.
- context.show...
-
You can technically show dialogs, bottom sheets, and other UI.
- context.access...
-
You can access your global state, but you can also access route parameters for any active screen or widget.
- context.update...
-
Your can update your global state, but you can also update route parameters for any active screen or widget.
Handling Query Results in the Calling Context
Sometimes, you may wish to process query results either exclusively or partly in the context that executed the query. We can do this using the onSuccess parameter to the query constructor.
In our UI example above, we might do this:
void onPressedAddItem() {
final item = context.p.itemInProgress;
final addQuery = WriteTodoItemQuery(item: item,
onSuccess: (successCtx) {
// this is called when the query completes. It is passed the exact same
// context that `finishAsyncWithResult` is.
// the same result that got passed to context.onSuccess in the query,
// but accessible from the UI
final result = successContext.r;
// Shows a dialog that displays the new identifier for the saved item.
successContext.showDialogInfoText(themeOrId: this.theme, title: "Saved item ${result.id}");
}
);
// this causes your query to be executed. It will call startAsync in
// production code, but will skip it in favor of test code or data in a state test.
context.executeQuery(addQuery);
}
Always access data from the closest context
Note that because your AFib state is immutable, and because the onSuccess
handler is called after an asynchronous delay, it is very important that you pull any state you use from the successCtx
context, and not the spi's original context member variable. The context member variable may contain stale state by the time onSuccess is called. This won't cause any crashes (everything is ref-counted in dart), but it might cause your app to display stale data that is confusing or conceptually incorrect.
Pre-executing a query response
In some cases, you may want to feed an initial, idempotent response to finishAsyncWithResponse
without waiting for the asynchronous process of a query to complete. You can do so using the onPreExecuteResponse
parameter for the query constructor. This parameter takes a closure, which returns a result of the type the query is expecting.
For example, if you are completing a todo item, then code like this:
final completedItem = this.currentItem.reviseComplete();
context.executeQuery(WriteTodoItemQuery(
completedItem: completedItem,
onPreExecuteResponse: () => completedItem
));
finishAsyncWithResponse
to be executed twice, each time with completedItem. The first time it executes immediately, as a result of the onPreExecuteResponse
call. The second time, it executes as a result of startAsync
calling context.onSuccess
, presumably also with the completedItem.
Note that this works most cleanly if the update performed in the query's finishAsyncWithSuccess
method is idempotent, but that you can also detect whether you are in a pre-execute using context.isPreExecute
.
Performing Multiple Asynchonous Queries
AFib provides a special query called AFCompositeQuery
, which allows you to execute several queries,
and then process their results when they all complete. This is often useful on startup, when you might
want to load in a significant part of your state before redirecting to your app's home screen.
In the example startup query above, we might instead have used:
...
final loadQueries = AFCompositeQuery.createList();
loadQueries.add(ReadUserSettingsQuery(userId: response.userId));
loadQueries.add(ReadTodoItemsQuery(userId: response.userId));
final compositeQuery = AFCompositeQuery.createFrom(queries: loadQueries, onSuccess: (successCtx) {
// you can use successCtx.response to access all the responses from the queries above,
// if you want, but here it isn't necessary.
// instead, those queries will have updated our global state with the settings and todo items,
// already, and now we are ready to navigate to the HomeScreen.
successCtx.navigateReplaceAll(HomeScreen.navigatePush().castToReplaceAll());
});
context.executeQuery(compositeQuery);
Kinds of Queries - Simple, Listener, Isolate, Deferred
So far, all the example queries have been simple queries. Simple queries perform their startAsync
method one time, process the results, and then are complete. The remaining query types have slightly
different properties.
Listener Queries
Listener queries are started, then repeatedly recieve some kind of asynchronous push notification.
They look very similar to the simple queries we have seen, except that they can call context.onSuccess
in startAsync
repeatedly. Each time you call it, finishAsyncWithResponse
gets called, integrating whatever the latest push update is into your AFib state.
For example, listener queries would be used if you were using a service like pusher.io, or had a Firebase query which listens for updates to a collection query on an ongoing basis.
AFib provides APIs for executing and terminating listener queries. While a listener query is active, it is tracked in the AFib public state, so you can see the set of active listener queries in the debugger.
When a listener query terminates, its shutdown
method is called, allowing you to shut down the listener channel if necessary.
Isolate Queries
Isolates are Dart's form of threads. AFib provides an isolate query, which runs code in a separate thread.
Calls to context.onSuccess
in the thread method send results from the thread to a finishAsyncWithResponse
method that has runs in your main UI thread and has access to all your normal
AFib context and state.
Deferred Queries
Deferred queries have no startAsync
method. Instead, they defer for a known period and call finishAsyncWithResponse
. The idea is that in that method, you can query your current state,
perform caching calculations on it, and then write your calculations back to it.
Executing Deferred Code Inline
Sometimes, you might want to defer some processing inline, but not actually create an entire query to do so. You can do this using context.executeDeferredCallback
. For example, this code is used to wait for a logout animation to complete before fully resetting the state.
context.executeDeferredCallback(AFFBQueryID.deferredSignout, const Duration(milliseconds: 500), (ctx) {
context.executeResetToInitialState();
AFFirebaseAuth.instanceNotNull.signOut();
});
Listener, Isolate, and Deferred Queries are Singletons by Default
By default, these three query types are stored internally by their type name. If you re-execute a query of that type, the current query is shut down, and the new one replaces it.
For example, suppose you have a deferred query of the
type CalculateTodoStatsDeferredQuery
, which executes 5 seconds after you call context.executeQuery
. You execute it anytime your listener query gets an update related to a todo item, maintaining some cached statistics. If you recieve 4 updates, one every second, and consequently execute the query 4 times, it will nonetheless only be executed once, after 5 seconds.
Generating Queries from the Command Line
You can generate a query from the command line using syntax like:
dart bin/xxx_afib.dart generate query WriteUserQuery --result-type User --member-variables "User userToWrite;"
The --result-type
specifies the type of the value that you will pass to context.onSuccess
. The --member-variables
flag creates member variables for you for convenience.
You can create listener, deferred, and isolate queries by using an appropriate suffix (e.g. ReadTodoItemsListenerQuery).
Do not cache state calculations within the startAsync
method
Perhaps I should have made all queries immutable, but it felt too heavy handed to me.
Nonetheless, you should not store values in member variables in the startAsync
method, as that method is not called in testing contexts. Consider this trivial example:
class WontWorkQuery extends AFAsyncQuery {
int count = 0;
...
@override
void startAsync(AFStartQueryContext<UserCredential?> context) async {
...
count++;
}
@override
void finishAsyncWithResponse(AFFinishQuerySuccessContext<UserCredential?> context) {
if(count == 0) {
throw AFException("startAsync was never called!");
}
}
This query with throw an exception in test contexts, because in those contexts the startAsync
method is never called. Instead, a response value from your test data, or generated dynamically by the test itself, is passed directly to finishAsyncWithResponse
.
More realistically, you might be tempted to calculate some information in startAsync
and pass it to finishAsyncWithResponse
via a member variable, rather than passing it through context.onSuccess
. That won't work. The correct technique is to pass any information from startAsync
to finishAsyncWithSuccess
as part of the information passed to context.onSuccess
. That way, the test must also pass a simulated version of that information.
The AFib Startup Query
Your app will start with a single query, the startup query in stored lib/queries/simple/startup_query.dart
. This query is automatically executed when your app starts. Because AFib itself executes this query, it cannot have parameters to its constructor. You will construct and execute most subsequent queries yourself, and they will usually have constructor parameters.
In many apps, the startup query will check whether the user is already logged in. If not, it will navigate to a signin page. If they are, it will issue one or more queries which begin loading data for the signed in user to your AFib state, and might navigate to the 'home' screen for your app.
Special Lifecycle Handlers
In lib/initialization/xxx_define_core.dart
, you can specify two additional event handlers:
An App Lifecycle Query
You can detect and respond to changes in your app's state using:
AFib will pass you the state, which is a Flutter AppLifecycleState
value. You can respond to the change
as you desire in the query itself.
Query Success Listener
You can perform special post-processing for all successful queries.
Error Handling
To record an error in a query to calling context.onError(error)
from within startAsync
.
finishAsyncWithError
Any query can optionally override the finishAsyncWithError
method. This method recieves
the error passed to context.onError. Handling errors within queries is most useful when
the error is an expected result, for example when the user enters an invalid password.
onError constructor parameter
In addition to onSuccess
, queries have an onError
constructor parameter which can be used to handle
errors in the context which executed the query.
The Global query failure handler
If your query does not override finishAsyncWithError
or provide an onError
constructor parameter, the error will be forwarded to your
global error handler. Your app configures the global error handler in lib/initialization/xxx_define_core.dart
:
void defineEventHandlers(AFCoreDefinitionContext context) {
context.addDefaultQueryErrorHandler((err) {
// this displays a dialog by default.
afDefaultQueryErrorHandler(err);
);
...
This is a great place to debug query exceptions
I have seen cases where an app is executing many Firestore queries, and one of them produces a 'not authorized' error and a resulting error dialog via the default error handler. It can be tedious to determine which query is causing the problem. Placing a breakpoint in the default query error closure above will allow you to quickly determine which query is causing the problem.
Executing Queries that Load Data During Navigation
AFib supports executing queries during navigation. This feature, which allows you to load data either prior to the navigation, or during and after the navigation animation has already started, is described in the navigation section.