use std::{
collections::{BTreeMap, BTreeSet, HashMap},
sync::Arc,
};
use fraiseql_db::ViewName;
use serde::Deserialize;
use serde_json::Value;
use super::{
QueryResultCache,
collections::{CollectionMap, CollectionTagging},
};
use crate::{db::DatabaseAdapter, error::Result, schema::CompiledSchema};
pub const CHANNEL: &str = "tviews_changes";
const VERSION: u64 = 2;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Collection {
pub child_type: String,
pub column: String,
}
impl Collection {
#[must_use]
pub fn new(child_type: impl Into<String>, column: impl Into<String>) -> Self {
Self {
child_type: child_type.into(),
column: column.into(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TviewsChange {
pub rows: BTreeMap<String, BTreeSet<String>>,
pub collections: BTreeMap<Collection, BTreeSet<String>>,
pub types: BTreeSet<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TviewsMessage {
Change(TviewsChange),
Everything,
}
#[derive(Deserialize)]
struct Wire {
v: Option<u64>,
#[serde(default)]
u: BTreeMap<String, Vec<Value>>,
#[serde(default)]
d: BTreeMap<String, Vec<Value>>,
#[serde(default)]
c: BTreeMap<String, Vec<Value>>,
#[serde(default)]
t: Vec<String>,
}
#[must_use]
pub fn decode(payload: &str) -> TviewsMessage {
let Ok(wire) = serde_json::from_str::<Wire>(payload) else {
return TviewsMessage::Everything;
};
if wire.v != Some(VERSION) {
return TviewsMessage::Everything;
}
let mut change = TviewsChange {
types: wire.t.into_iter().collect(),
..TviewsChange::default()
};
for (type_name, ids) in wire.u.into_iter().chain(wire.d) {
match ids_of(&ids) {
Some(ids) => change.rows.entry(type_name).or_default().extend(ids),
None => {
change.types.insert(type_name);
},
}
}
for (key, parents) in wire.c {
let Some(collection) = collection_of(&key) else {
return TviewsMessage::Everything;
};
match ids_of(&parents) {
Some(ids) => change.collections.entry(collection).or_default().extend(ids),
None => {
change.types.insert(collection.child_type);
},
}
}
TviewsMessage::Change(change)
}
fn ids_of(values: &[Value]) -> Option<Vec<String>> {
values.iter().map(id_text).collect()
}
pub(super) fn id_text(value: &Value) -> Option<String> {
match value {
Value::String(id) => Some(id.clone()),
Value::Number(id) => Some(id.to_string()),
_ => None,
}
}
fn collection_of(key: &str) -> Option<Collection> {
let (child_type, column) = key.split_once('.')?;
(!child_type.is_empty() && !column.is_empty()).then(|| Collection::new(child_type, column))
}
#[derive(Debug, Clone, Default)]
pub struct FeedCatalog {
pub type_views: HashMap<String, Vec<ViewName>>,
pub collections: CollectionMap,
}
impl FeedCatalog {
#[must_use]
pub fn from_rows<'a>(
type_rows: impl IntoIterator<Item = (&'a str, &'a str)>,
collection_rows: impl IntoIterator<Item = (&'a str, &'a str, &'a str, &'a str, &'a str)>,
) -> Self {
let mut type_views: HashMap<String, Vec<ViewName>> = HashMap::new();
for (type_name, view) in type_rows {
let views = type_views.entry(type_name.to_string()).or_default();
let view = ViewName::from(view);
if !views.contains(&view) {
views.push(view);
}
}
let collections = CollectionMap::from_levels(collection_rows.into_iter().map(
|(child, parent, child_type, column, key)| {
((child, parent, column, key), Collection::new(child_type, column))
},
));
Self {
type_views,
collections,
}
}
}
const PUBLISHING_PROBE: &str = "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_settings \
WHERE name = 'pg_tviews.publish_changes' AND setting = 'rows' \
AND source IN ('configuration file', 'database', 'global')) \
AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_db_role_setting s, \
LATERAL unnest(s.setconfig) c WHERE s.setrole <> 0 \
AND s.setdatabase IN (0, (SELECT oid FROM pg_catalog.pg_database \
WHERE datname = current_database())) \
AND c LIKE 'pg_tviews.publish\\_changes=%' AND c <> 'pg_tviews.publish_changes=rows') \
AND EXISTS (SELECT 1 FROM pg_attribute \
WHERE attrelid = to_regclass('tviews.registry') AND attname = 'typename' \
AND NOT attisdropped) AS publishing";
fn registry() -> String {
format!(
"tv AS (SELECT {}::oid AS rel, g.entity, g.typename, g.\"references\" AS refs \
FROM tviews.registry g)",
fraiseql_db::usable_relation("g.schema", "g.name")
)
}
pub async fn read_feed_catalog<A: DatabaseAdapter + ?Sized>(
adapter: &A,
schema: &CompiledSchema,
) -> Result<Option<FeedCatalog>> {
let publishing = adapter
.execute_raw_query(PUBLISHING_PROBE)
.await?
.first()
.and_then(|row| row.get("publishing"))
.and_then(Value::as_bool)
.unwrap_or(false);
if !publishing {
return Ok(None);
}
let names = super::swept_view_names(schema);
if names.is_empty() {
return Ok(Some(FeedCatalog::default()));
}
let reads = super::embedding_graph::reads_cte(&names);
let registry = registry();
let types = adapter
.execute_raw_query(&format!(
"{reads}, {registry} \
SELECT DISTINCT tv.typename AS type_name, s.name AS view \
FROM reads x JOIN roots s ON s.rel = x.root JOIN tv ON tv.rel = x.rel \
ORDER BY 1, 2"
))
.await?;
let collections = adapter
.execute_raw_query(&format!(
"{reads}, {registry} \
SELECT c.name AS child, p.name AS parent, ct.typename AS child_type, \
ref.key AS column_name, ref.value ->> 'column' AS key_name \
FROM roots c JOIN tv ct ON ct.rel = c.rel \
CROSS JOIN LATERAL jsonb_each(ct.refs) ref \
JOIN tv pt ON pt.entity = ref.value ->> 'entity' \
JOIN roots p ON p.rel = pt.rel"
))
.await?;
let text = |row: &HashMap<String, Value>, key: &str| {
row.get(key).and_then(Value::as_str).map(str::to_owned)
};
let types: Vec<(String, String)> = types
.iter()
.filter_map(|row| Some((text(row, "type_name")?, text(row, "view")?)))
.collect();
let collections: Vec<[String; 5]> = collections
.iter()
.filter_map(|row| {
Some([
text(row, "child")?,
text(row, "parent")?,
text(row, "child_type")?,
text(row, "column_name")?,
text(row, "key_name")?,
])
})
.collect();
Ok(Some(FeedCatalog::from_rows(
types.iter().map(|(t, v)| (t.as_str(), v.as_str())),
collections
.iter()
.map(|[c, p, t, k, r]| (c.as_str(), p.as_str(), t.as_str(), k.as_str(), r.as_str())),
)))
}
pub struct TviewsEvictor {
cache: Arc<QueryResultCache>,
type_views: HashMap<String, Vec<ViewName>>,
}
impl TviewsEvictor {
#[must_use]
pub const fn new(
cache: Arc<QueryResultCache>,
type_views: HashMap<String, Vec<ViewName>>,
) -> Self {
Self { cache, type_views }
}
pub fn apply(&self, message: &TviewsMessage) -> Result<()> {
let TviewsMessage::Change(change) = message else {
return self.cache.clear();
};
let views: BTreeSet<&ViewName> = change
.rows
.keys()
.chain(&change.types)
.filter_map(|type_name| self.type_views.get(type_name))
.flatten()
.collect();
if !views.is_empty() {
let views: Vec<ViewName> = views.into_iter().cloned().collect();
self.cache.invalidate_views_except_collections(&views)?;
}
for (type_name, ids) in &change.rows {
for id in ids {
self.cache.invalidate_by_id(type_name, id)?;
}
}
for type_name in &change.types {
self.cache.invalidate_by_id_type(type_name)?;
}
for (collection, parents) in &change.collections {
for parent in parents {
self.cache.invalidate_collection(collection, parent)?;
}
}
for type_name in &change.types {
self.cache.invalidate_collections_of(type_name)?;
}
Ok(())
}
}
pub struct TviewsListener {
evictor: TviewsEvictor,
tagging: CollectionTagging,
collections: CollectionMap,
}
impl TviewsListener {
#[must_use]
pub fn new(
cache: Arc<QueryResultCache>,
tagging: CollectionTagging,
catalog: FeedCatalog,
) -> Self {
Self {
evictor: TviewsEvictor::new(cache, catalog.type_views),
tagging,
collections: catalog.collections,
}
}
pub fn listening(&self) {
let _ = self.evictor.cache.clear();
self.tagging.hear(self.collections.clone());
}
pub fn lost(&self) {
self.tagging.deafen();
let _ = self.evictor.cache.clear();
}
pub fn missed(&self) {
let _ = self.evictor.cache.clear();
}
pub fn receive(&self, payload: &str) {
if self.evictor.apply(&decode(payload)).is_err() {
let _ = self.evictor.cache.clear();
}
}
}
#[cfg(test)]
#[path = "tviews_feed_tests.rs"]
mod tviews_feed_tests;