Batch lookups
When a page of results needs related entities for every row (a GraphQL dataloader, for example), calling a lookup once per row costs one round trip per row. The repo generates two batch primitives that answer the whole page in a single query:
| Function | Keyed by | Returns |
|---|---|---|
find_all | entity id | HashMap<EntityId, Entity> |
find_all_for_<column>s | a list_for column | HashMap<ColumnTy, Vec<Entity>> |
find_all is the 1:1 lookup. find_all_for_<column>s is its 1:n sibling: it takes a slice of column values and returns every entity whose column matches one of them.
fn find_all_for_user_ids<Out: From<Entity>>(
&self,
user_ids: &[UserId],
direction: es_entity::ListDirection,
) -> Result<HashMap<UserId, Vec<Out>>, es_entity::RepoFault>
fn find_all_for_user_ids_in_op<'a, Out: From<Entity>>(
&self,
op: impl IntoOneTimeExecutor<'a>,
user_ids: &[UserId],
direction: es_entity::ListDirection,
) -> Result<HashMap<UserId, Vec<Out>>, es_entity::RepoFault>
Which columns get one
A list_for column gets a find_all_for_<column>s function only when it opts in with the find_all_for flag next to its by(..) columns. Without the flag the repo expands exactly as it would without this feature.
user_id(ty = "UserId", list_for(by(created_at), find_all_for))
user_id(
ty = "UserId",
list_for(by(created_at), find_all_for),
update(persist = false, accessor = "values().user_id")
)
The flag is an error, naming the column, on:
Option<T>andnullablecolumns, since= ANYcan never match aNULLkeyForgettable<T>columnsvirtualcolumns- a
list_forwithout aby(..)column, since theby(..)column orders each group
Opting in adds two requirements that list_for alone does not have:
tymust implementHash + Eq + Clone, because it keys the returned map.- The key must be readable from the entity (see Key).
The name is the column name pluralised (account_holder_id becomes find_all_for_account_holder_ids, currency becomes find_all_for_currencies). A repo with delete = "soft" also gets the _include_deleted variants, and an in_op_only repo only gets the _in_op functions.
A column declared with several by(..) columns gets one function, ordered by the first one.
Semantics
- One statement. The whole key slice is answered by a single
WHERE <column> = ANY($1)query. There is no page size and no cursor, because this is a lookup and not a listing. - Absent keys stay absent. A key with no matching entity is not in the map, exactly like
find_all. If a consumer needs an emptyVecfor every requested key, it fills the map itself withentry(key).or_default(). - Order. Inside each
Vecthe entities follow the firstby(..)column in the givenListDirection, withidas the tiebreak, the same orderlist_for_<column>_by_<by>would return for that single key. - Soft delete. Deleted entities are hidden, as in
list_for. The_include_deletedvariants return them as well. - Empty slice. An empty key slice returns an empty map.
- Key. The key of each entity is read from the hydrated entity with the column’s
update(accessor = "<expr>"), the same accessor alist_bycursor uses, and it applies even withupdate(persist = false). Without one, the key is the entity field named after the column.create(accessor = ..)is never used, because it reads theNew*struct, not the entity. The key is cloned, so its type must implementHash + Eq + Clone. The slice type must also be usable as a Postgres array bind (sqlx::postgres::PgHasArrayType).
Scoped repositories
On a scoped repo the functions take the scope as their first argument, and the generated Scoped{Repo} view has matching delegates that capture it. The scope adds its usual conjunct next to the ANY predicate, so rows from another scope behave like missing rows.
If the lookup column is also the scope column (an Owner-style scope on the same column), the scope is redundant: when the scope value is in the key list the result is the same as without it, and when it is not, the result is empty.
Example
extern crate es_entity;
extern crate sqlx;
extern crate serde;
extern crate tokio;
extern crate anyhow;
extern crate uuid;
use serde::{Deserialize, Serialize};
es_entity::entity_id! { UserId }
use es_entity::*;
es_entity::entity_id! { UserDocumentId }
#[derive(EsEvent, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[es_event(id = "UserDocumentId")]
pub enum UserDocumentEvent {
Initialized { id: UserDocumentId, owner_id: UserId },
}
impl IntoEvents<UserDocumentEvent> for NewUserDocument {
type IdSource = FromRepo;
fn into_events(self, builder: EntityEventsBuilder<UserDocumentEvent, FromRepo>) -> EntityEvents<UserDocumentEvent> {
let id = builder.id();
builder.initial_events([UserDocumentEvent::Initialized {
id,
owner_id: self.owner_id,
}])
}
}
impl TryFromEvents<UserDocumentEvent> for UserDocument {
fn try_from_events(events: EntityEvents<UserDocumentEvent>) -> Result<Self, EntityHydrationError> {
let mut h = UserDocument::hydrator();
for event in events.iter_all() {
match event {
UserDocumentEvent::Initialized { id, owner_id } => {
h.id(*id).owner_id(*owner_id);
}
}
}
h.build(events)
}
}
pub struct NewUserDocument { owner_id: UserId }
#[derive(EsEntity)]
pub struct UserDocument {
pub id: UserDocumentId,
owner_id: UserId,
events: EntityEvents<UserDocumentEvent>,
}
#[derive(EsRepo)]
#[es_repo(
entity = "UserDocument",
columns(
user_id(
ty = "UserId",
list_for(by(created_at), find_all_for),
create(accessor = "owner_id"),
update(persist = false, accessor = "owner_id")
)
)
)]
pub struct UserDocuments {
pool: sqlx::PgPool
}
async fn init_pool() -> anyhow::Result<sqlx::PgPool> {
let pg_con = std::env::var("PG_CON").unwrap_or_else(|_| "postgres://user:password@localhost:5432/pg".to_string());
Ok(sqlx::PgPool::connect(&pg_con).await?)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let docs = UserDocuments { pool: init_pool().await? };
let (alice, bob, carol) = (UserId::new(), UserId::new(), UserId::new());
docs.create_all(vec![
NewUserDocument { owner_id: alice },
NewUserDocument { owner_id: alice },
NewUserDocument { owner_id: bob },
])
.await?;
// One query for all three users
let found: std::collections::HashMap<UserId, Vec<UserDocument>> = docs
.find_all_for_user_ids(&[alice, bob, carol], ListDirection::Descending)
.await?;
assert_eq!(found[&alice].len(), 2);
assert_eq!(found[&bob].len(), 1);
// Carol owns nothing, so she is absent rather than mapped to an empty `Vec`
assert!(!found.contains_key(&carol));
Ok(())
}