use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct TableNames {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub relationships: HashMap<String, String>,
#[serde(default)]
pub declared_relationships: Option<Vec<DeclaredRelationship>>,
#[serde(default)]
pub computed_fields: HashMap<String, String>,
#[serde(default)]
pub roots: HashMap<String, String>,
#[serde(default)]
pub comments: Comments,
#[serde(default)]
pub columns: HashMap<String, String>,
#[serde(default, rename = "enum")]
pub is_enum: bool,
#[serde(default)]
pub permissions: HashMap<String, RolePermissions>,
}
impl RolePermissions {
pub fn write_columns(&self, verb: crate::role::Verb) -> Option<&ColumnSet> {
match verb {
crate::role::Verb::Insert => self.insert.as_ref().map(|p| &p.columns),
crate::role::Verb::Update => self.update.as_ref().map(|p| &p.columns),
crate::role::Verb::Select | crate::role::Verb::Delete => None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct RolePermissions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub select: Option<SelectPermission>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub insert: Option<InsertPermission>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub update: Option<UpdatePermission>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delete: Option<DeletePermission>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct SelectPermission {
#[serde(default)]
pub columns: ColumnSet,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub filter: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u64>,
#[serde(default)]
pub allow_aggregations: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub computed_fields: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct InsertPermission {
#[serde(default)]
pub columns: ColumnSet,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub check: serde_json::Value,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub set: HashMap<String, serde_json::Value>,
#[serde(default)]
pub backend_only: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct UpdatePermission {
#[serde(default)]
pub columns: ColumnSet,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub filter: serde_json::Value,
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub check: serde_json::Value,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub set: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct DeletePermission {
#[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
pub filter: serde_json::Value,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub enum ColumnSet {
#[default]
All,
Named(Vec<String>),
}
impl ColumnSet {
pub fn allows(&self, column: &str) -> bool {
match self {
Self::All => true,
Self::Named(columns) => columns.iter().any(|name| name == column),
}
}
pub fn is_empty(&self) -> bool {
matches!(self, Self::Named(columns) if columns.is_empty())
}
}
impl Serialize for ColumnSet {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::All => serializer.serialize_str("*"),
Self::Named(columns) => columns.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for ColumnSet {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Either {
Wildcard(String),
Named(Vec<String>),
}
match Either::deserialize(deserializer)? {
Either::Named(columns) => Ok(Self::Named(columns)),
Either::Wildcard(text) if text == "*" => Ok(Self::All),
Either::Wildcard(text) => Err(serde::de::Error::custom(format!(
"\"{}\" is not a column set; write a list of columns, or \"*\" for all of them",
text
))),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Comments {
#[serde(default)]
pub table: Option<String>,
#[serde(default)]
pub columns: HashMap<String, String>,
#[serde(default)]
pub computed_fields: HashMap<String, String>,
#[serde(default)]
pub roots: HashMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct NameOverrides {
tables: HashMap<String, TableNames>,
functions: HashMap<String, FunctionNames>,
introspection_disabled_for: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
struct Sections {
#[serde(default)]
tables: HashMap<String, TableNames>,
#[serde(default)]
functions: HashMap<String, FunctionNames>,
#[serde(default)]
introspection_disabled_for: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct DeclaredRelationship {
pub name: String,
#[serde(default)]
pub using: Option<String>,
#[serde(default)]
pub table: Option<String>,
#[serde(default)]
pub columns: std::collections::BTreeMap<String, String>,
#[serde(default)]
pub to_one: bool,
#[serde(default)]
pub after_parent: bool,
}
impl DeclaredRelationship {
pub fn target(&self, default_schema: &str) -> Option<(String, String)> {
let written = self.table.as_deref()?;
Some(match written.split_once('.') {
Some((schema, table)) => (schema.to_string(), table.to_string()),
None => (default_schema.to_string(), written.to_string()),
})
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct FunctionNames {
#[serde(default)]
pub exposed_as: Option<String>,
#[serde(default)]
pub roles: Vec<String>,
}
impl NameOverrides {
pub fn parse(value: &str) -> Result<Self, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(Self::default());
}
let document = if trimmed.starts_with('{') {
trimmed.to_string()
} else {
std::fs::read_to_string(trimmed)
.map_err(|e| format!("cannot read GraphQL names from \"{}\": {}", trimmed, e))?
};
let parsed: serde_json::Value = serde_json::from_str(&document)
.map_err(|e| format!("GraphQL names are not valid JSON: {}", e))?;
let sectioned = parsed.as_object().is_some_and(|map| {
map.contains_key("tables")
|| map.contains_key("functions")
|| map.contains_key("introspection_disabled_for")
});
let (tables, functions, introspection_disabled_for) = match sectioned {
true => {
let document: Sections = serde_json::from_value(parsed)
.map_err(|e| format!("GraphQL names are not valid JSON: {}", e))?;
(
document.tables,
document.functions,
document.introspection_disabled_for,
)
}
false => (
serde_json::from_value(parsed)
.map_err(|e| format!("GraphQL names are not valid JSON: {}", e))?,
HashMap::new(),
Vec::new(),
),
};
for key in functions.keys() {
if !key.contains('.') {
return Err(format!(
"\"{}\" does not name a function; keys are \"schema.function\", \
so a function in the default schema is still \"public.{}\"",
key, key
));
}
}
for (key, names) in &tables {
if !key.contains('.') {
return Err(format!(
"\"{}\" does not name a table; keys are \"schema.table\", \
so a table in the default schema is still \"public.{}\"",
key, key
));
}
if names.name.as_deref() == Some("") {
return Err(format!("\"{}\" is given an empty name", key));
}
}
Ok(Self {
tables,
functions,
introspection_disabled_for,
})
}
pub fn is_empty(&self) -> bool {
self.tables.is_empty() && self.functions.is_empty()
}
pub fn exposed_as(&self, schema: &str, function: &str) -> Option<&str> {
self.functions
.get(&format!("{}.{}", schema, function))?
.exposed_as
.as_deref()
}
pub fn function_grants(&self, schema: &str, function: &str, role: &str) -> bool {
self.functions
.get(&format!("{}.{}", schema, function))
.is_some_and(|given| given.roles.iter().any(|named| named == role))
}
pub fn declared_relationships(
&self,
schema: &str,
table: &str,
) -> Option<&[DeclaredRelationship]> {
self.table(schema, table)?.declared_relationships.as_deref()
}
pub fn len(&self) -> usize {
self.tables.len()
}
pub fn placed_functions(&self) -> usize {
self.functions.len()
}
fn table(&self, schema: &str, table: &str) -> Option<&TableNames> {
self.tables.get(&format!("{}.{}", schema, table))
}
pub fn permissions(&self, schema: &str, table: &str, role: &str) -> Option<&RolePermissions> {
self.table(schema, table)?.permissions.get(role)
}
pub fn tables_with_permissions<'a>(
&'a self,
role: &'a str,
) -> impl Iterator<Item = &'a RolePermissions> + 'a {
self.tables
.values()
.filter_map(move |table| table.permissions.get(role))
}
pub fn introspection_disabled(&self, role: &str) -> bool {
self.introspection_disabled_for.iter().any(|it| it == role)
}
pub fn has_permissions(&self) -> bool {
self.tables
.values()
.any(|table| !table.permissions.is_empty())
}
pub fn roles(&self) -> Vec<&str> {
let mut roles: Vec<&str> = self
.tables
.values()
.flat_map(|table| table.permissions.keys())
.map(String::as_str)
.collect();
roles.sort_unstable();
roles.dedup();
roles
}
pub fn granted(&self) -> usize {
self.tables
.values()
.map(|table| table.permissions.len())
.sum()
}
pub fn is_enum(&self, schema: &str, table: &str) -> bool {
self.table(schema, table)
.map(|t| t.is_enum)
.unwrap_or(false)
}
pub fn enum_tables(&self) -> Vec<(String, String)> {
self.tables
.iter()
.filter(|(_, names)| names.is_enum)
.filter_map(|(key, _)| {
key.split_once('.')
.map(|(schema, table)| (schema.to_string(), table.to_string()))
})
.collect()
}
pub fn base_name(&self, schema: &str, table: &str) -> Option<&str> {
self.table(schema, table)?.name.as_deref()
}
pub fn relationship(&self, schema: &str, table: &str, source: &str) -> Option<&str> {
let names = self.table(schema, table)?;
names
.relationships
.get(source)
.or_else(|| names.computed_fields.get(source))
.map(String::as_str)
}
pub fn root(&self, schema: &str, table: &str, kind: &str) -> Option<&str> {
self.table(schema, table)?
.roots
.get(kind)
.map(String::as_str)
}
pub fn column(&self, schema: &str, table: &str, column: &str) -> Option<&str> {
self.table(schema, table)?
.columns
.get(column)
.map(String::as_str)
}
pub fn column_source(&self, schema: &str, table: &str, field: &str) -> Option<&str> {
let names = self.table(schema, table)?;
names
.columns
.iter()
.find(|(_, exposed)| exposed.as_str() == field)
.map(|(column, _)| column.as_str())
}
pub fn renames_columns(&self, schema: &str, table: &str) -> bool {
self.table(schema, table)
.map(|names| !names.columns.is_empty())
.unwrap_or(false)
}
pub fn table_comment(&self, schema: &str, table: &str) -> Option<&str> {
self.table(schema, table)?.comments.table.as_deref()
}
pub fn column_comment(&self, schema: &str, table: &str, column: &str) -> Option<&str> {
self.table(schema, table)?
.comments
.columns
.get(column)
.map(String::as_str)
}
pub fn computed_comment(&self, schema: &str, table: &str, function: &str) -> Option<&str> {
self.table(schema, table)?
.comments
.computed_fields
.get(function)
.map(String::as_str)
}
pub fn root_comment(&self, schema: &str, table: &str, kind: &str) -> Option<&str> {
self.table(schema, table)?
.comments
.roots
.get(kind)
.map(String::as_str)
}
pub fn computed_field(&self, schema: &str, table: &str, function: &str) -> Option<&str> {
let names = self.table(schema, table)?;
names
.computed_fields
.get(function)
.or_else(|| names.relationships.get(function))
.map(String::as_str)
}
pub fn computed_source(&self, schema: &str, table: &str, field: &str) -> Option<&str> {
let names = self.table(schema, table)?;
names
.computed_fields
.iter()
.chain(names.relationships.iter())
.find(|(_, exposed)| exposed.as_str() == field)
.map(|(function, _)| function.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
const SAMPLE: &str = r#"{
"public.author": {
"name": "Author",
"relationships": {"article_author_id_fkey": "posts",
"fetch_articles_plain": "get_articles"},
"computed_fields": {"author_upper_name": "upper_name"}
}
}"#;
#[test]
fn names_are_read_from_a_document() {
let names = NameOverrides::parse(SAMPLE).unwrap();
assert_eq!(names.base_name("public", "author"), Some("Author"));
assert_eq!(
names.relationship("public", "author", "article_author_id_fkey"),
Some("posts")
);
assert_eq!(
names.computed_field("public", "author", "author_upper_name"),
Some("upper_name")
);
}
#[test]
fn a_computed_field_can_be_looked_up_from_either_side() {
let names = NameOverrides::parse(SAMPLE).unwrap();
assert_eq!(
names.computed_source("public", "author", "upper_name"),
Some("author_upper_name")
);
assert_eq!(names.computed_source("public", "author", "nothing"), None);
}
#[test]
fn a_computed_field_is_found_in_either_map() {
let names = NameOverrides::parse(
r#"{"public.author": {"computed_fields": {"fetch_articles_plain": "get_articles"}}}"#,
)
.unwrap();
assert_eq!(
names.relationship("public", "author", "fetch_articles_plain"),
Some("get_articles")
);
let other = NameOverrides::parse(
r#"{"public.author": {"relationships": {"author_upper_name": "upper_name"}}}"#,
)
.unwrap();
assert_eq!(
other.computed_field("public", "author", "author_upper_name"),
Some("upper_name")
);
assert_eq!(
other.computed_source("public", "author", "upper_name"),
Some("author_upper_name")
);
}
#[test]
fn a_table_can_be_marked_as_a_set_of_values() {
let names = NameOverrides::parse(
r#"{"public.colors": {"enum": true}, "public.author": {"name": "Author"}}"#,
)
.unwrap();
assert!(names.is_enum("public", "colors"));
assert!(!names.is_enum("public", "author"));
assert_eq!(
names.enum_tables(),
vec![("public".to_string(), "colors".to_string())]
);
}
#[test]
fn declared_relationships_carry_a_join_and_not_just_a_name() {
let names = NameOverrides::parse(
r#"{"tables": {"public.message": {"declared_relationships": [
{"name": "members", "table": "public.channel_member",
"columns": {"channel_id": "channel_id"}},
{"name": "author", "using": "message_author_id_fkey", "to_one": true}
]}}}"#,
)
.unwrap();
let declared = names
.declared_relationships("public", "message")
.expect("the table declares some");
assert_eq!(declared.len(), 2);
assert_eq!(declared[0].name, "members");
assert_eq!(
declared[0].target("public"),
Some(("public".to_string(), "channel_member".to_string()))
);
assert_eq!(
declared[0].columns.get("channel_id").map(String::as_str),
Some("channel_id")
);
assert!(!declared[0].to_one, "an array relationship by default");
assert_eq!(declared[1].using.as_deref(), Some("message_author_id_fkey"));
assert!(declared[1].to_one);
assert_eq!(
crate::names::DeclaredRelationship {
table: Some("channel_member".to_string()),
..Default::default()
}
.target("other"),
Some(("other".to_string(), "channel_member".to_string()))
);
assert_eq!(names.declared_relationships("public", "author"), None);
}
#[test]
fn a_table_that_was_not_named_is_left_alone() {
let names = NameOverrides::parse(SAMPLE).unwrap();
assert_eq!(names.base_name("public", "article"), None);
assert_eq!(names.relationship("other", "author", "anything"), None);
}
#[test]
fn a_function_can_be_placed_on_a_root() {
let names = NameOverrides::parse(
r#"{"tables": {"public.author": {"name": "Authors"}},
"functions": {"public.volatile_func1": {"exposed_as": "query"}}}"#,
)
.unwrap();
assert_eq!(names.base_name("public", "author"), Some("Authors"));
assert_eq!(names.exposed_as("public", "volatile_func1"), Some("query"));
assert_eq!(names.exposed_as("public", "something_else"), None);
}
#[test]
fn the_flat_shape_is_still_read() {
let names = NameOverrides::parse(SAMPLE).unwrap();
assert!(!names.is_empty());
assert_eq!(names.exposed_as("public", "anything"), None);
}
#[test]
fn a_function_key_names_its_schema() {
let error = NameOverrides::parse(r#"{"functions": {"volatile_func1": {}}}"#)
.expect_err("a bare function name is not a key");
assert!(error.contains("schema.function"), "{}", error);
}
#[test]
fn nothing_given_is_not_an_error() {
assert!(NameOverrides::parse("").unwrap().is_empty());
assert!(NameOverrides::parse(" ").unwrap().is_empty());
assert!(NameOverrides::parse("{}").unwrap().is_empty());
}
#[test]
fn a_key_without_a_schema_says_so() {
let error = NameOverrides::parse(r#"{"author": {"name": "Author"}}"#).unwrap_err();
assert!(error.contains("public.author"), "unhelpful: {}", error);
}
#[test]
fn a_document_that_is_not_one_says_so() {
let error = NameOverrides::parse("{not json}").unwrap_err();
assert!(error.contains("valid JSON"), "unhelpful: {}", error);
}
#[test]
fn a_path_that_is_not_there_names_itself() {
let error = NameOverrides::parse("/no/such/names.json").unwrap_err();
assert!(
error.contains("/no/such/names.json"),
"unhelpful: {}",
error
);
}
const GRANTED: &str = r#"{
"tables": {
"public.article": {
"permissions": {
"user": {
"select": {
"columns": ["id", "title", "content"],
"filter": {"$or": [{"author_id": "X-HASURA-USER-ID"},
{"is_published": true}]},
"limit": 10,
"allow_aggregations": true,
"computed_fields": ["get_articles"]
},
"insert": {
"check": {"author_id": "X-Hasura-User-Id"},
"columns": ["title", "content"],
"set": {"author_id": "X-Hasura-User-Id"},
"backend_only": true
},
"update": {"columns": ["title"], "filter": {}},
"delete": {"filter": {"is_published": false}}
},
"anonymous": {
"select": {"columns": "*", "filter": {"is_published": true}}
}
}
}
}
}"#;
#[test]
fn what_a_role_may_do_is_read_from_the_document() {
let names = NameOverrides::parse(GRANTED).unwrap();
let user = names.permissions("public", "article", "user").unwrap();
let select = user.select.as_ref().unwrap();
assert!(select.columns.allows("title"));
assert!(!select.columns.allows("is_published"));
assert_eq!(select.limit, Some(10));
assert!(select.allow_aggregations);
assert_eq!(select.computed_fields, vec!["get_articles".to_string()]);
assert_eq!(
select.filter["$or"][1]["is_published"],
serde_json::json!(true)
);
let insert = user.insert.as_ref().unwrap();
assert!(insert.backend_only);
assert_eq!(
insert.set.get("author_id"),
Some(&serde_json::json!("X-Hasura-User-Id"))
);
assert!(user.update.is_some());
assert!(user.delete.is_some());
}
#[test]
fn a_wildcard_column_set_is_not_a_list_that_happens_to_be_long() {
let names = NameOverrides::parse(GRANTED).unwrap();
let anonymous = names.permissions("public", "article", "anonymous").unwrap();
let select = anonymous.select.as_ref().unwrap();
assert_eq!(select.columns, ColumnSet::All);
assert!(select.columns.allows("a_column_added_next_year"));
assert!(anonymous.insert.is_none());
}
#[test]
fn a_role_named_nothing_has_nothing() {
let names = NameOverrides::parse(GRANTED).unwrap();
assert!(names.permissions("public", "article", "stranger").is_none());
assert!(names.permissions("public", "author", "user").is_none());
}
#[test]
fn the_roles_come_back_sorted_and_once_each() {
let names = NameOverrides::parse(GRANTED).unwrap();
assert_eq!(names.roles(), vec!["anonymous", "user"]);
assert_eq!(names.granted(), 2);
}
#[test]
fn a_document_of_names_alone_leaves_the_layer_off() {
assert!(!NameOverrides::parse(SAMPLE).unwrap().has_permissions());
assert!(NameOverrides::parse(GRANTED).unwrap().has_permissions());
assert!(NameOverrides::parse("").unwrap().roles().is_empty());
}
#[test]
fn a_column_set_that_is_neither_says_so() {
let error = NameOverrides::parse(
r#"{"tables": {"public.a": {"permissions":
{"user": {"select": {"columns": "all"}}}}}}"#,
)
.unwrap_err();
assert!(error.contains("not a column set"), "unhelpful: {}", error);
}
#[test]
fn a_permission_survives_the_round_trip() {
let names = NameOverrides::parse(GRANTED).unwrap();
let written = serde_json::to_string(&Sections {
tables: names.tables.clone(),
functions: HashMap::new(),
introspection_disabled_for: Vec::new(),
})
.unwrap();
let again = NameOverrides::parse(&written).unwrap();
assert_eq!(again, names);
}
}