Docs / ArchitectureKit / Rust / Fundamentals / Defining Queries

Defining Queries

A query describes what someone wants to know. Define it as a struct, and answer it with a function that reads a view.

all hands out the items of a view as a stream, in which every element is an item or an error (see Defining Views). To filter and transform the items, and to stop after a number of them, use TryStreamExt and StreamExt of the futures crate, and to order them, to pass over some of them for a page, and to get the first, the single one, or their number, use QueryExt of the kit's query module. Add the futures crate, and import the traits, together with View and SingleError, which the examples below use:

cargo add futures
use architecturekit::{
  View,
  query::{QueryExt, SingleError},
};
use futures::{StreamExt, TryStreamExt, future};

To turn the items into a vector, call the try_collect method. If the view fails while it is read, try_collect returns its error instead:

struct ListBooks {
  only_available: bool,
  limit: Option<usize>,
}

async fn list_books<V: View<BookItem>>(
  catalog: &V,
  query: ListBooks,
) -> Result<Vec<BookItem>, V::Error> {
  let items = catalog.all();

  // ...

  items.try_collect().await
}

The function takes any view that implements View<BookItem>, the view in memory as well as one in a database, and returns the error of that view. Every step takes the stream and returns a stream again, so the steps can be combined without collecting anything in between. An error of the view ends the stream. try_filter and map_ok hand it on unchanged, without calling the closure you hand over for it, and so do the steps of QueryExt, up to the method that returns the result, such as try_collect, which returns it.

Note that try_collect returns no items along with an error, not even the ones it read before, so that a part of the result is never taken for all of it.

Note that the steps are for reading a view. Once the items are in a vector, for example because an answer groups the same snapshot of a view in several ways, work on the vector with its own methods and a loop, such as sort_by_key, which keeps the order of equal items. A vector can not fail, so handing it to the steps of a stream would only add errors that never occur.

Filtering and Transforming Items#

To keep only some items, call the try_filter method with a closure that selects them, and returns its answer as a future, such as one made with future::ready:

let items = items.try_filter(|item| future::ready(!query.only_available || !item.is_borrowed));

To turn every item into something else, call the map_ok method:

let titles = items.map_ok(|item| item.title);

Ordering Items#

To order items by a key, call the order_by method, or the order_by_descending method for the reverse order:

let items = items.order_by(|item| item.title.clone());

To order items by a comparison, for example by several fields, call the order_by_with method. It expects a closure that returns an Ordering, so cmp, then, and then_with work with it:

let items = items.order_by_with(|left, right| {
  left.author.cmp(&right.author).then_with(|| left.title.cmp(&right.title))
});

Note that ordering reads all items before it hands out the first one, so if the view fails, it hands out the error alone. Ordering is stable, so items that compare as equal keep their order.

Paging Items#

To skip a number of items, call the try_skip method. To stop after a number of items, call the take method:

let items = items.take(query.limit.unwrap_or(usize::MAX));

Both can be combined, for example to get the third page of ten items:

let page = items.try_skip(20).take(10);

take reads no further than its items, so it never sees an error that comes later, as first does. try_skip hands on an error among the items it skips, since it is not an item, so that a view that fails is not taken for one with fewer items. Page with try_skip rather than with skip of StreamExt, which counts an error like an item, and drops it along with the items it skips, so that the page of a view that fails would come out empty rather than failed.

Getting a Single Item#

To get the first item, call the first method. It returns None if there is none:

let first = items.first().await?;

To get the only item, call the single method. It fails with SingleError::NoItems if there is none, and with SingleError::TooManyItems if there are several, and with SingleError::View if the view fails:

struct GetBook {
  book_id: String,
}

async fn get_book<V: View<BookItem>>(
  catalog: &V,
  query: GetBook,
) -> Result<BookItem, SingleError<V::Error>> {
  catalog.all().try_filter(|item| future::ready(item.id == query.book_id)).single().await
}

Both stop reading as soon as they know the answer, and return an error of the view only if it comes before that. first reads no further than the first item, so it never sees an error that comes later. single reads up to the second item, and only knows that an item is the only one once the view has ended, so it also returns an error that comes after the first item.

A missing item and several items belong to no category, since only the application knows what they mean: whether a missing item is expected, and whether several are a mistake in the data or a race. In a handler, ? answers a missing item with 404 Not Found, and several with 500 Internal Server Error (see Mapping Errors to Status Codes).

Note that try_filter asks about every item it is handed. To get an item by its key, call the get method of a KeyedView instead, and to get items by the value of a secondary index, call the lookup method of the index (see Defining Views).

Counting Items#

To count items, call the try_count method. To check whether at least one item matches, call the try_any method, which stops at the first match:

let count = catalog.all().try_count().await?;

let has_borrowed_books = catalog.all().try_any(|item| future::ready(item.is_borrowed)).await?;

try_count reads all items, and if the view fails, it returns the error and no count. It is called try_count, since count of StreamExt counts the errors as well. try_any returns an error only if the view fails before the first match. A stream is read once, so each of them reads the view anew.