Skip to content

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 the context.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:

    FlatButton(
        child: Text("Add Item"),
        onPressed: spi.onPressedAddItem
    );

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    
  ));
Will cause 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:

context.addLifecycleQueryAction((state) => YourStateChangeHandlerQuery(state: state));

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.

  context.addQuerySuccessListener(querySuccessListenerDelegate);

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);
  );
...
The afDefaultQueryErrorHandler shows a dialog warning of the error, but you can replace it with your own function.

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.