pub mod aggregate;
pub mod object;
pub mod relationship;
use crate::input::mutation::{is_deletable, is_insertable, is_updatable};
use crate::schema::object::TableObjectType;
use crate::schema::relationship::RelationshipField;
use postrust_core::schema_cache::{SchemaCache, Table};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
pub struct SchemaConfig {
pub exposed_schemas: Vec<String>,
pub enable_mutations: bool,
pub enable_subscriptions: bool,
pub subscription_refresh_seconds: u64,
pub enum_values: HashMap<String, Vec<(String, Option<String>)>>,
pub names: crate::names::NameOverrides,
pub max_rows: Option<i64>,
pub role: Option<String>,
}
impl Default for SchemaConfig {
fn default() -> Self {
Self {
exposed_schemas: vec!["public".to_string()],
enum_values: HashMap::new(),
names: crate::names::NameOverrides::default(),
enable_mutations: true,
enable_subscriptions: false,
subscription_refresh_seconds: 30,
max_rows: None,
role: None,
}
}
}
impl SchemaConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_schemas(mut self, schemas: Vec<String>) -> Self {
self.exposed_schemas = schemas;
self
}
pub fn with_mutations(mut self, enable: bool) -> Self {
self.enable_mutations = enable;
self
}
pub fn with_subscriptions(mut self, enable: bool) -> Self {
self.enable_subscriptions = enable;
self
}
pub fn subscription_refresh(&self) -> std::time::Duration {
match self.subscription_refresh_seconds {
0 => std::time::Duration::from_secs(60 * 60 * 24 * 365),
seconds => std::time::Duration::from_secs(seconds),
}
}
pub fn is_schema_exposed(&self, schema: &str) -> bool {
self.exposed_schemas.iter().any(|s| s == schema)
}
pub fn default_schema(&self) -> &str {
self.exposed_schemas
.first()
.map(|s| s.as_str())
.unwrap_or("public")
}
}
fn has_whole_key(table: &Table) -> bool {
!table.pk_cols.is_empty()
&& table
.pk_cols
.iter()
.all(|column| table.get_column(column).is_some())
}
fn pk_columns_of(table: &Table) -> Vec<(String, String)> {
table
.pk_cols
.iter()
.map(|col_name| {
let pg_type = table
.get_column(col_name)
.map(|c| c.nominal_type.clone())
.unwrap_or_else(|| "text".to_string());
(col_name.clone(), pg_type)
})
.collect()
}
pub(crate) fn relationship_keys(rel: &postrust_core::schema_cache::Relationship) -> Vec<String> {
use postrust_core::schema_cache::Relationship;
if let Relationship::Computed { function, .. } = rel {
return vec![function.name.clone()];
}
let mut keys = Vec::new();
match rel.constraint_name() {
"" => {}
constraint => keys.push(constraint.to_string()),
}
if let Some(column) = rel.single_foreign_column() {
keys.push(format!("{}.{}", rel.foreign_table().name, column));
}
if !rel.is_one_to_many() {
if let Some(column) = rel.single_local_column() {
keys.push(column.to_string());
}
}
let joined = |mut columns: Vec<String>| {
columns.sort();
columns.join(",")
};
let columns = rel.join_columns();
if columns.len() > 1 {
keys.push(format!(
"{}.{}",
rel.foreign_table().name,
joined(columns.iter().map(|(_, foreign)| foreign.clone()).collect())
));
if !rel.is_one_to_many() {
keys.push(joined(
columns.iter().map(|(local, _)| local.clone()).collect(),
));
}
}
keys
}
fn type_name_of(rendered: &str) -> (Option<String>, String) {
let (schema, name) = match rendered.rsplit_once('.') {
Some((schema, name)) => (Some(schema.trim_matches('"').to_string()), name),
None => (None, rendered),
};
(schema, name.trim_matches('"').to_string())
}
fn base_name_for(table: &Table, config: &SchemaConfig) -> String {
if let Some(given) = config.names.base_name(&table.schema, &table.name) {
return given.to_string();
}
if table.schema == config.default_schema() {
table.name.clone()
} else {
format!("{}_{}", table.schema, table.name)
}
}
#[derive(Debug, Clone)]
pub struct GeneratedSchema {
pub object_types: HashMap<String, TableObjectType>,
pub query_fields: Vec<QueryField>,
pub mutation_fields: Vec<MutationField>,
pub relationship_fields: HashMap<String, Vec<RelationshipField>>,
pub enum_types: HashMap<String, Vec<(String, Option<String>)>>,
pub function_fields: Vec<FunctionField>,
}
#[derive(Debug, Clone)]
pub struct FunctionField {
pub name: String,
pub schema_name: String,
pub function_name: String,
pub returns: String,
pub returns_table: (String, String),
pub arguments: Vec<(String, String, bool)>,
pub session_argument: Option<String>,
pub volatile: bool,
pub description: Option<String>,
}
fn reflected_fields(
table: &Table,
schema_cache: &SchemaCache,
config: &SchemaConfig,
base_names: &HashMap<(String, String), String>,
) -> Vec<RelationshipField> {
schema_cache
.get_relationships(&table.qualified_identifier(), &table.schema)
.map(|relationships| {
relationships
.iter()
.filter_map(|rel| {
let foreign = rel.foreign_table();
let target_base =
base_names.get(&(foreign.schema.clone(), foreign.name.clone()))?;
let mut field = RelationshipField::from_relationship_named(
rel,
target_base,
relationship_keys(rel).iter().find_map(|key| {
config.names.relationship(&table.schema, &table.name, key)
}),
);
field.arguments = caller_arguments(rel, schema_cache);
Some(field)
})
.collect()
})
.unwrap_or_default()
}
fn declared_fields(
declared: &[crate::names::DeclaredRelationship],
table: &Table,
schema_cache: &SchemaCache,
config: &SchemaConfig,
base_names: &HashMap<(String, String), String>,
) -> Vec<RelationshipField> {
use postrust_core::schema_cache::Relationship;
let reflected = schema_cache
.get_relationships(&table.qualified_identifier(), &table.schema)
.map(|found| found.to_vec())
.unwrap_or_default();
let mut fields = Vec::with_capacity(declared.len());
for entry in declared {
if let Some(key) = entry.using.as_deref() {
let Some(rel) = reflected
.iter()
.find(|rel| relationship_keys(rel).iter().any(|found| found == key))
else {
tracing::warn!(
"GraphQL: {}.{} declares the relationship \"{}\" through \"{}\", \
which no foreign key carries; it is left out",
table.schema,
table.name,
entry.name,
key
);
continue;
};
let foreign = rel.foreign_table();
let Some(target_base) = base_names.get(&(foreign.schema.clone(), foreign.name.clone()))
else {
continue;
};
let mut field =
RelationshipField::from_relationship_named(rel, target_base, Some(&entry.name));
field.arguments = caller_arguments(rel, schema_cache);
fields.push(field);
continue;
}
let Some((schema, name)) = entry.target(config.default_schema()) else {
tracing::warn!(
"GraphQL: {}.{} declares the relationship \"{}\" with neither a key \
nor a table to join to; it is left out",
table.schema,
table.name,
entry.name
);
continue;
};
let Some(target_base) = base_names.get(&(schema, name)) else {
continue;
};
match mapped_relationship_field(
entry,
table,
schema_cache,
config.default_schema(),
target_base,
) {
Some(field) => fields.push(field),
None => tracing::warn!(
"GraphQL: {}.{} declares the relationship \"{}\" with no columns to \
join on; it is left out",
table.schema,
table.name,
entry.name
),
}
}
let already: std::collections::HashSet<String> =
fields.iter().map(|field| field.name.clone()).collect();
for rel in reflected
.iter()
.filter(|rel| matches!(rel, Relationship::Computed { .. }))
{
let foreign = rel.foreign_table();
let Some(target_base) = base_names.get(&(foreign.schema.clone(), foreign.name.clone()))
else {
continue;
};
let mut field = RelationshipField::from_relationship_named(
rel,
target_base,
relationship_keys(rel)
.iter()
.find_map(|key| config.names.relationship(&table.schema, &table.name, key)),
);
if already.contains(&field.name) {
continue;
}
field.arguments = caller_arguments(rel, schema_cache);
fields.push(field);
}
fields
}
pub(crate) fn mapped_relationship_field(
entry: &crate::names::DeclaredRelationship,
table: &Table,
schema_cache: &SchemaCache,
default_schema: &str,
target_base: &str,
) -> Option<RelationshipField> {
use postrust_core::schema_cache::{Cardinality, Relationship};
let (schema, name) = entry.target(default_schema)?;
if entry.columns.is_empty() {
return None;
}
let foreign_table = postrust_core::api_request::QualifiedIdentifier::new(&schema, &name);
let here = table.qualified_identifier();
let columns: Vec<(String, String)> = entry
.columns
.iter()
.map(|(mine, theirs)| (mine.clone(), theirs.clone()))
.collect();
let constraint = format!("{}.{}", table.name, entry.name);
let cardinality = match (entry.to_one, entry.after_parent) {
(true, false) => Cardinality::M2O {
constraint: constraint.clone(),
columns,
},
(true, true) => Cardinality::O2O {
constraint: constraint.clone(),
columns,
is_parent: true,
},
(false, _) => Cardinality::O2M {
constraint: constraint.clone(),
columns,
},
};
let rel = Relationship::ForeignKey {
is_self: here == foreign_table,
table: here,
foreign_table_is_view: schema_cache
.get_table(&foreign_table)
.is_some_and(|t| t.is_view),
foreign_table,
cardinality,
table_is_view: table.is_view,
constraint_name: constraint,
};
Some(RelationshipField::from_relationship_named(
&rel,
target_base,
Some(&entry.name),
))
}
fn caller_arguments(
relationship: &postrust_core::schema_cache::Relationship,
schema_cache: &SchemaCache,
) -> Vec<(String, String, bool)> {
use postrust_core::schema_cache::Relationship;
let Relationship::Computed {
function,
row_argument,
..
} = relationship
else {
return Vec::new();
};
let Some(row_argument) = row_argument else {
return Vec::new();
};
schema_cache
.routines
.get(function)
.into_iter()
.flatten()
.find(|routine| routine.name == function.name)
.map(|routine| {
routine
.params
.iter()
.filter(|param| ¶m.name != row_argument)
.filter(|param| !is_session_argument(param))
.filter(|param| !param.name.is_empty())
.map(|param| (param.name.clone(), param.param_type.clone(), param.required))
.collect()
})
.unwrap_or_default()
}
pub(crate) fn computed_caller_arguments(
computed: &postrust_core::schema_cache::ComputedColumn,
schema_cache: &SchemaCache,
) -> Vec<(String, String, bool)> {
let Some(row_argument) = &computed.row_argument else {
return Vec::new();
};
schema_cache
.routines
.get(&computed.function)
.into_iter()
.flatten()
.find(|routine| routine.name == computed.function.name)
.map(|routine| {
routine
.params
.iter()
.filter(|param| ¶m.name != row_argument)
.filter(|param| !is_session_argument(param))
.filter(|param| !param.name.is_empty())
.map(|param| (param.name.clone(), param.param_type.clone(), param.required))
.collect()
})
.unwrap_or_default()
}
fn is_session_argument(param: &postrust_core::schema_cache::RoutineParam) -> bool {
param.name == "hasura_session" && matches!(param.param_type.as_str(), "json" | "jsonb")
}
impl GeneratedSchema {
pub fn get_object_type(&self, name: &str) -> Option<&TableObjectType> {
self.object_types.get(name)
}
pub fn get_query_field(&self, table_name: &str) -> Option<&QueryField> {
self.query_fields
.iter()
.find(|f| f.table_name == table_name)
}
pub fn get_mutation_fields(&self, table_name: &str) -> Vec<&MutationField> {
self.mutation_fields
.iter()
.filter(|f| f.table_name == table_name)
.collect()
}
pub fn get_relationship_fields(&self, type_name: &str) -> Option<&Vec<RelationshipField>> {
self.relationship_fields.get(type_name)
}
pub fn table_names(&self) -> Vec<&str> {
self.object_types
.values()
.map(|t| t.table.name.as_str())
.collect()
}
pub fn type_names(&self) -> Vec<&str> {
self.object_types.keys().map(|s| s.as_str()).collect()
}
}
#[derive(Debug, Clone)]
pub struct QueryField {
pub name: String,
pub table_name: String,
pub schema_name: String,
pub type_name: String,
pub return_type: String,
pub is_list: bool,
pub is_by_pk: bool,
pub pk_columns: Vec<(String, String)>,
pub description: Option<String>,
pub aggregate_name: Option<String>,
pub aggregate_description: Option<String>,
pub aggregates: bool,
pub rows: bool,
}
impl QueryField {
pub fn list(table: &Table) -> Self {
Self::list_named(table, &table.name)
}
pub fn list_named(table: &Table, base_name: &str) -> Self {
let type_name = base_name.to_string();
let name = base_name.to_string();
Self {
name,
table_name: table.name.clone(),
schema_name: table.schema.clone(),
type_name: type_name.clone(),
return_type: format!("[{}!]!", type_name),
is_list: true,
is_by_pk: false,
pk_columns: Vec::new(),
description: Some(format!("fetch data from the table: \"{}\"", table.name)),
aggregate_name: None,
aggregate_description: None,
aggregates: true,
rows: true,
}
}
pub fn by_pk(table: &Table) -> Option<Self> {
Self::by_pk_named(table, &table.name)
}
pub fn by_pk_named(table: &Table, base_name: &str) -> Option<Self> {
if !has_whole_key(table) {
return None;
}
let type_name = base_name.to_string();
let field_name = format!("{}_by_pk", base_name);
let pk_columns = pk_columns_of(table);
Some(Self {
name: field_name,
table_name: table.name.clone(),
schema_name: table.schema.clone(),
type_name: type_name.clone(),
return_type: type_name,
is_list: false,
is_by_pk: true,
pk_columns,
description: Some(format!(
"fetch data from the table: \"{}\" using primary key columns",
table.name
)),
aggregate_name: None,
aggregate_description: None,
aggregates: true,
rows: true,
})
}
}
#[derive(Debug, Clone)]
pub struct MutationField {
pub name: String,
pub table_name: String,
pub schema_name: String,
pub mutation_type: MutationType,
pub pk_columns: Vec<(String, String)>,
pub return_type: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MutationType {
Insert,
InsertOne,
Update,
UpdateByPk,
UpdateMany,
Delete,
DeleteByPk,
}
impl MutationField {
pub fn insert_fields(table: &Table) -> Vec<Self> {
Self::insert_fields_named(table, &table.name)
}
pub fn insert_fields_named(table: &Table, base_name: &str) -> Vec<Self> {
if !is_insertable(table) {
return vec![];
}
let type_name = base_name.to_string();
let mut fields = vec![];
let name = format!("insert_{}", base_name);
fields.push(Self {
name,
table_name: table.name.clone(),
schema_name: table.schema.clone(),
mutation_type: MutationType::Insert,
pk_columns: Vec::new(),
return_type: format!("{}_mutation_response", type_name),
description: Some(format!("insert data into the table: \"{}\"", table.name)),
});
let name = format!("insert_{}_one", base_name);
fields.push(Self {
name,
table_name: table.name.clone(),
schema_name: table.schema.clone(),
mutation_type: MutationType::InsertOne,
pk_columns: Vec::new(),
return_type: type_name.clone(),
description: Some(format!(
"insert a single row into the table: \"{}\"",
table.name
)),
});
fields
}
pub fn update_fields(table: &Table) -> Vec<Self> {
Self::update_fields_named(table, &table.name)
}
pub fn update_fields_named(table: &Table, base_name: &str) -> Vec<Self> {
if !is_updatable(table) {
return vec![];
}
let type_name = base_name.to_string();
let mut fields = vec![];
let name = format!("update_{}", base_name);
fields.push(Self {
name,
table_name: table.name.clone(),
schema_name: table.schema.clone(),
mutation_type: MutationType::Update,
pk_columns: Vec::new(),
return_type: format!("{}_mutation_response", type_name),
description: Some(format!("update data of the table: \"{}\"", table.name)),
});
let name = format!("update_{}_many", base_name);
fields.push(Self {
name,
table_name: table.name.clone(),
schema_name: table.schema.clone(),
mutation_type: MutationType::UpdateMany,
pk_columns: Vec::new(),
return_type: format!("[{}_mutation_response]", type_name),
description: Some(format!(
"update multiples rows of table: \"{}\"",
table.name
)),
});
if has_whole_key(table) {
let name = format!("update_{}_by_pk", base_name);
fields.push(Self {
name,
table_name: table.name.clone(),
schema_name: table.schema.clone(),
mutation_type: MutationType::UpdateByPk,
pk_columns: pk_columns_of(table),
return_type: type_name,
description: Some(format!(
"update single row of the table: \"{}\"",
table.name
)),
});
}
fields
}
pub fn delete_fields(table: &Table) -> Vec<Self> {
Self::delete_fields_named(table, &table.name)
}
pub fn delete_fields_named(table: &Table, base_name: &str) -> Vec<Self> {
if !is_deletable(table) {
return vec![];
}
let type_name = base_name.to_string();
let mut fields = vec![];
let name = format!("delete_{}", base_name);
fields.push(Self {
name,
table_name: table.name.clone(),
schema_name: table.schema.clone(),
mutation_type: MutationType::Delete,
pk_columns: Vec::new(),
return_type: format!("{}_mutation_response", type_name),
description: Some(format!("delete data from the table: \"{}\"", table.name)),
});
if has_whole_key(table) {
let name = format!("delete_{}_by_pk", base_name);
fields.push(Self {
name,
table_name: table.name.clone(),
schema_name: table.schema.clone(),
mutation_type: MutationType::DeleteByPk,
pk_columns: pk_columns_of(table),
return_type: type_name,
description: Some(format!(
"delete single row from the table: \"{}\"",
table.name
)),
});
}
fields
}
}
pub fn build_schema(schema_cache: &SchemaCache, config: &SchemaConfig) -> GeneratedSchema {
let mut object_types = HashMap::new();
let mut query_fields = Vec::new();
let mut mutation_fields = Vec::new();
let mut relationship_fields = HashMap::new();
let mut tables: Vec<&Table> = schema_cache
.tables
.values()
.filter(|table| config.is_schema_exposed(&table.schema))
.collect();
tables.sort_by(|a, b| (&a.schema, &a.name).cmp(&(&b.schema, &b.name)));
let mut used_base_names: HashMap<String, u32> = HashMap::new();
let mut base_names: HashMap<(String, String), String> = HashMap::new();
for table in &tables {
let preferred = base_name_for(table, config);
let base_name = match used_base_names.get_mut(&preferred) {
None => {
used_base_names.insert(preferred.clone(), 1);
preferred
}
Some(count) => {
*count += 1;
let disambiguated = format!("{}_{}", preferred, count);
tracing::warn!(
"GraphQL name collision: {}.{} would generate the same names as \
an earlier table; exposing it as \"{}\" instead",
table.schema,
table.name,
disambiguated
);
disambiguated
}
};
base_names.insert(
(table.schema.clone(), table.name.clone()),
base_name.clone(),
);
}
let mut readable_bases: HashSet<String> = HashSet::new();
for table in &tables {
let base_name = base_names
.get(&(table.schema.clone(), table.name.clone()))
.expect("every visited table has a resolved base name")
.clone();
let mut obj_type = TableObjectType::from_table_named(table, &base_name, &config.names);
let readable = config.role.as_deref().is_none_or(|role| {
config
.names
.permissions(&table.schema, &table.name, role)
.is_none_or(|granted| crate::role::reads_anything(granted, table))
});
if readable {
readable_bases.insert(base_name.clone());
}
for field in &mut obj_type.fields {
let Some(function) = config
.names
.computed_source(&table.schema, &table.name, &field.name)
.or(Some(field.name.as_str()))
else {
continue;
};
let Some(computed) = table.get_computed_column(function) else {
continue;
};
field.arguments = computed_caller_arguments(computed, schema_cache);
}
let type_name = obj_type.name.clone();
let rename = |field: &mut QueryField, kind: &str| {
if let Some(given) = config.names.root(&table.schema, &table.name, kind) {
field.name = given.to_string();
}
if let Some(comment) = config.names.root_comment(&table.schema, &table.name, kind) {
field.description = Some(comment.to_string()).filter(|c| !c.is_empty());
}
};
let mut list = QueryField::list_named(table, &base_name);
rename(&mut list, "select");
list.aggregate_name = config
.names
.root(&table.schema, &table.name, "select_aggregate")
.map(str::to_string);
list.aggregate_description = config
.names
.root_comment(&table.schema, &table.name, "select_aggregate")
.map(str::to_string);
if let Some(role) = &config.role {
list.aggregates =
crate::role::allows_aggregations(&config.names, &table.schema, &table.name, role);
}
list.rows = readable;
if readable || list.aggregates {
query_fields.push(list);
}
if readable {
if let Some(mut by_pk) = QueryField::by_pk_named(table, &base_name) {
rename(&mut by_pk, "select_by_pk");
query_fields.push(by_pk);
}
}
if config.enable_mutations {
let mut fields: Vec<MutationField> = Vec::new();
fields.extend(MutationField::insert_fields_named(table, &base_name));
fields.extend(MutationField::update_fields_named(table, &base_name));
fields.extend(MutationField::delete_fields_named(table, &base_name));
if !readable {
fields.retain(|field| {
!matches!(
field.mutation_type,
MutationType::InsertOne
| MutationType::UpdateByPk
| MutationType::DeleteByPk
)
});
}
for field in &mut fields {
let kind = match field.mutation_type {
MutationType::Insert => "insert",
MutationType::InsertOne => "insert_one",
MutationType::Update => "update",
MutationType::UpdateByPk => "update_by_pk",
MutationType::UpdateMany => "update_many",
MutationType::Delete => "delete",
MutationType::DeleteByPk => "delete_by_pk",
};
if let Some(given) = config.names.root(&table.schema, &table.name, kind) {
field.name = given.to_string();
}
if let Some(comment) = config.names.root_comment(&table.schema, &table.name, kind) {
field.description = Some(comment.to_string()).filter(|c| !c.is_empty());
}
}
mutation_fields.extend(fields);
}
let rels = match config
.names
.declared_relationships(&table.schema, &table.name)
{
Some(declared) => declared_fields(declared, table, schema_cache, config, &base_names),
None => reflected_fields(table, schema_cache, config, &base_names),
};
if !rels.is_empty() {
relationship_fields.insert(type_name.clone(), rels);
}
object_types.insert(type_name, obj_type);
}
let mut enum_types: HashMap<String, Vec<(String, Option<String>)>> = HashMap::new();
let mut enum_type_of: HashMap<(String, String), String> = HashMap::new();
for (schema_name, table_name) in config.names.enum_tables() {
let base = match base_names.get(&(schema_name.clone(), table_name.clone())) {
Some(found) => found.clone(),
None => config
.names
.base_name(&schema_name, &table_name)
.map(str::to_string)
.unwrap_or_else(|| match schema_name == config.default_schema() {
true => table_name.clone(),
false => format!("{}_{}", schema_name, table_name),
}),
};
let key = format!("{}.{}", schema_name, table_name);
let members: Vec<(String, Option<String>)> = config
.enum_values
.get(&key)
.map(|values| {
values
.iter()
.filter(|(value, _)| is_graphql_name(value))
.cloned()
.collect()
})
.unwrap_or_default();
if members.is_empty() {
tracing::warn!(
"{}.{} is marked as an enumeration but has no values that can name an \
enum member; exposing it as an ordinary table",
schema_name,
table_name
);
continue;
}
let type_name = format!("{}_enum", base);
enum_type_of.insert((schema_name.clone(), table_name.clone()), type_name.clone());
enum_types.insert(type_name, members);
}
if !enum_type_of.is_empty() {
for table in &tables {
let Some(type_name) = base_names.get(&(table.schema.clone(), table.name.clone()))
else {
continue;
};
let found = schema_cache
.get_relationships(&table.qualified_identifier(), &table.schema)
.map(|found| found.to_vec())
.unwrap_or_default();
for relationship in &found {
if !relationship.is_to_one() {
continue;
}
let foreign = relationship.foreign_table();
let Some(enum_type) =
enum_type_of.get(&(foreign.schema.clone(), foreign.name.clone()))
else {
continue;
};
let Some(column) = relationship.single_local_column() else {
continue;
};
if let Some(object) = object_types.get_mut(type_name) {
let exposed = config
.names
.column(&object.table.schema, &object.table.name, column)
.unwrap_or(column)
.to_string();
for field in &mut object.fields {
if field.name == exposed {
field.graphql_type =
crate::types::GraphQLType::Custom(enum_type.clone());
}
}
}
}
}
}
if !enum_type_of.is_empty() {
for relationships in relationship_fields.values_mut() {
relationships.retain(|relationship| {
let foreign = relationship.relationship.foreign_table();
!enum_type_of.contains_key(&(foreign.schema.clone(), foreign.name.clone()))
});
}
}
for object in object_types.values_mut() {
object.writable_fields = object.fields.clone();
if let Some(role) = &config.role {
let (schema_name, table_name) =
(object.table.schema.clone(), object.table.name.clone());
let Some(granted) = config.names.permissions(&schema_name, &table_name, role) else {
continue;
};
let table = object.table.clone();
object.fields.retain(|field| {
let column = config
.names
.column_source(&schema_name, &table_name, &field.name)
.unwrap_or(&field.name);
match table.get_column(column) {
None => true,
Some(_) => granted
.select
.as_ref()
.is_some_and(|select| select.columns.allows(column)),
}
});
}
}
let mut function_fields = Vec::new();
for routines in schema_cache.routines.values() {
for routine in routines {
if !config.is_schema_exposed(&routine.schema) || routine.is_procedure {
continue;
}
let postrust_core::schema_cache::RetType::SetOf(returned) = &routine.return_type else {
continue;
};
if routine.params.iter().any(|param| {
let (_, named) = type_name_of(¶m.param_type);
base_names.keys().any(|(_, table)| table == &named)
}) {
continue;
}
let (returned_schema, returned_table) = type_name_of(returned);
let found = base_names
.iter()
.find(|((schema, table), _)| {
table == &returned_table
&& schema == returned_schema.as_ref().unwrap_or(&routine.schema)
})
.or_else(|| {
base_names
.iter()
.find(|((_, table), _)| table == &returned_table)
});
let Some(((target_schema, target_table), base)) = found else {
continue;
};
if !readable_bases.contains(base) {
continue;
}
let target = (target_schema.clone(), target_table.clone());
let volatile = match config.names.exposed_as(&routine.schema, &routine.name) {
Some("query") => false,
Some("mutation") => true,
_ => matches!(
routine.volatility,
postrust_core::schema_cache::FuncVolatility::Volatile
),
};
if volatile {
if let Some(role) = config.role.as_deref() {
if !config
.names
.function_grants(&routine.schema, &routine.name, role)
{
continue;
}
}
}
function_fields.push(FunctionField {
name: routine.name.clone(),
schema_name: routine.schema.clone(),
function_name: routine.name.clone(),
returns: base.clone(),
returns_table: target,
arguments: routine
.params
.iter()
.filter(|param| !param.name.is_empty())
.filter(|param| !is_session_argument(param))
.map(|param| (param.name.clone(), param.param_type.clone(), param.required))
.collect(),
session_argument: routine
.params
.iter()
.find(|param| is_session_argument(param))
.map(|param| param.name.clone()),
volatile,
description: routine.description.clone(),
});
}
}
function_fields.sort_by(|a, b| a.name.cmp(&b.name));
GeneratedSchema {
object_types,
query_fields,
mutation_fields,
relationship_fields,
enum_types,
function_fields,
}
}
fn is_graphql_name(value: &str) -> bool {
let mut chars = value.chars();
match chars.next() {
Some(first) if first == '_' || first.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}
#[cfg(test)]
mod tests {
use super::*;
use indexmap::IndexMap;
use postrust_core::schema_cache::Column;
use pretty_assertions::assert_eq;
fn create_test_table(name: &str, insertable: bool, updatable: bool, deletable: bool) -> Table {
let mut columns = IndexMap::new();
columns.insert(
"id".into(),
Column {
name: "id".into(),
description: None,
nullable: false,
data_type: "integer".into(),
nominal_type: "int4".into(),
max_len: None,
default: Some("nextval('id_seq')".into()),
enum_values: vec![],
is_pk: true,
position: 1,
always_generated: false,
domain_type: None,
},
);
columns.insert(
"name".into(),
Column {
name: "name".into(),
description: None,
nullable: false,
data_type: "text".into(),
nominal_type: "text".into(),
max_len: None,
default: None,
enum_values: vec![],
is_pk: false,
position: 2,
always_generated: false,
domain_type: None,
},
);
Table {
schema: "public".into(),
name: name.into(),
description: None,
is_view: false,
insertable,
updatable,
deletable,
pk_cols: vec!["id".into()],
unique_constraints: Vec::new(),
columns,
computed_columns: Default::default(),
is_partitioned: false,
}
}
fn create_test_schema_cache() -> SchemaCache {
use std::collections::{HashMap, HashSet};
let mut tables = HashMap::new();
let users = create_test_table("users", true, true, true);
let posts = create_test_table("posts", true, true, true);
let comments = create_test_table("comments", true, false, false);
tables.insert(users.qualified_identifier(), users);
tables.insert(posts.qualified_identifier(), posts);
tables.insert(comments.qualified_identifier(), comments);
SchemaCache {
tables,
relationships: HashMap::new(),
routines: HashMap::new(),
timezones: HashSet::new(),
media_handlers: HashMap::new(),
pg_version: 150000,
representations: Default::default(),
}
}
#[test]
fn test_schema_config_default() {
let config = SchemaConfig::default();
assert!(config.is_schema_exposed("public"));
assert!(!config.is_schema_exposed("private"));
assert!(config.enable_mutations);
assert!(!config.enable_subscriptions);
}
#[test]
fn test_schema_config_with_schemas() {
let config =
SchemaConfig::new().with_schemas(vec!["api".to_string(), "public".to_string()]);
assert!(config.is_schema_exposed("api"));
assert!(config.is_schema_exposed("public"));
assert!(!config.is_schema_exposed("private"));
}
#[test]
fn test_schema_config_mutations_disabled() {
let config = SchemaConfig::new().with_mutations(false);
assert!(!config.enable_mutations);
}
#[test]
fn test_query_field_list() {
let table = create_test_table("users", true, true, true);
let field = QueryField::list(&table);
assert_eq!(field.name, "users");
assert_eq!(field.return_type, "[users!]!");
assert!(field.is_list);
assert!(!field.is_by_pk);
}
#[test]
fn test_query_field_by_pk() {
let table = create_test_table("users", true, true, true);
let field = QueryField::by_pk(&table).unwrap();
assert_eq!(field.name, "users_by_pk");
assert_eq!(field.return_type, "users");
assert!(!field.is_list);
assert!(field.is_by_pk);
}
#[test]
fn test_query_field_by_pk_no_pk() {
let mut table = create_test_table("users", true, true, true);
table.pk_cols = vec![];
let field = QueryField::by_pk(&table);
assert!(field.is_none());
}
#[test]
fn test_mutation_field_insert() {
let table = create_test_table("users", true, true, true);
let fields = MutationField::insert_fields(&table);
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].name, "insert_users");
assert_eq!(fields[0].mutation_type, MutationType::Insert);
assert_eq!(fields[1].name, "insert_users_one");
assert_eq!(fields[1].mutation_type, MutationType::InsertOne);
}
#[test]
fn test_mutation_field_insert_not_insertable() {
let table = create_test_table("users", false, true, true);
let fields = MutationField::insert_fields(&table);
assert!(fields.is_empty());
}
#[test]
fn test_mutation_field_update() {
let table = create_test_table("users", true, true, true);
let fields = MutationField::update_fields(&table);
assert_eq!(fields.len(), 3);
assert_eq!(fields[0].name, "update_users");
assert_eq!(fields[0].mutation_type, MutationType::Update);
assert_eq!(fields[1].name, "update_users_many");
assert_eq!(fields[1].mutation_type, MutationType::UpdateMany);
assert_eq!(fields[2].name, "update_users_by_pk");
assert_eq!(fields[2].mutation_type, MutationType::UpdateByPk);
}
#[test]
fn test_mutation_field_update_not_updatable() {
let table = create_test_table("users", true, false, true);
let fields = MutationField::update_fields(&table);
assert!(fields.is_empty());
}
#[test]
fn test_mutation_field_delete() {
let table = create_test_table("users", true, true, true);
let fields = MutationField::delete_fields(&table);
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].name, "delete_users");
assert_eq!(fields[0].mutation_type, MutationType::Delete);
assert_eq!(fields[1].name, "delete_users_by_pk");
assert_eq!(fields[1].mutation_type, MutationType::DeleteByPk);
}
#[test]
fn test_mutation_field_delete_not_deletable() {
let table = create_test_table("users", true, true, false);
let fields = MutationField::delete_fields(&table);
assert!(fields.is_empty());
}
#[test]
fn test_build_schema_object_types() {
let cache = create_test_schema_cache();
let config = SchemaConfig::default();
let schema = build_schema(&cache, &config);
assert_eq!(schema.object_types.len(), 3);
assert!(schema.get_object_type("users").is_some());
assert!(schema.get_object_type("posts").is_some());
assert!(schema.get_object_type("comments").is_some());
}
#[test]
fn test_build_schema_query_fields() {
let cache = create_test_schema_cache();
let config = SchemaConfig::default();
let schema = build_schema(&cache, &config);
assert_eq!(schema.query_fields.len(), 6);
let users_field = schema.get_query_field("users").unwrap();
assert_eq!(users_field.name, "users");
assert!(users_field.is_list);
}
#[test]
fn test_build_schema_mutation_fields() {
let cache = create_test_schema_cache();
let config = SchemaConfig::default();
let schema = build_schema(&cache, &config);
assert_eq!(schema.mutation_fields.len(), 16);
let users_mutations = schema.get_mutation_fields("users");
assert_eq!(users_mutations.len(), 7);
}
#[test]
fn test_build_schema_mutations_disabled() {
let cache = create_test_schema_cache();
let config = SchemaConfig::new().with_mutations(false);
let schema = build_schema(&cache, &config);
assert!(schema.mutation_fields.is_empty());
}
#[test]
fn test_build_schema_table_names() {
let cache = create_test_schema_cache();
let config = SchemaConfig::default();
let schema = build_schema(&cache, &config);
let names = schema.table_names();
assert_eq!(names.len(), 3);
assert!(names.contains(&"users"));
assert!(names.contains(&"posts"));
assert!(names.contains(&"comments"));
}
#[test]
fn test_build_schema_type_names() {
let cache = create_test_schema_cache();
let config = SchemaConfig::default();
let schema = build_schema(&cache, &config);
let names = schema.type_names();
assert_eq!(names.len(), 3);
assert!(names.contains(&"users"));
assert!(names.contains(&"posts"));
assert!(names.contains(&"comments"));
}
#[test]
fn test_build_schema_exposed_schemas() {
let mut cache = create_test_schema_cache();
let private_table = Table {
schema: "private".into(),
name: "secrets".into(),
description: None,
is_view: false,
insertable: true,
updatable: true,
deletable: true,
pk_cols: vec!["id".into()],
unique_constraints: Vec::new(),
columns: indexmap::IndexMap::new(),
computed_columns: Default::default(),
is_partitioned: false,
};
cache
.tables
.insert(private_table.qualified_identifier(), private_table);
let config = SchemaConfig::default(); let schema = build_schema(&cache, &config);
assert_eq!(schema.object_types.len(), 3);
assert!(schema.get_object_type("Secrets").is_none());
}
#[test]
fn test_generated_schema_get_object_type() {
let cache = create_test_schema_cache();
let config = SchemaConfig::default();
let schema = build_schema(&cache, &config);
let users = schema.get_object_type("users").unwrap();
assert_eq!(users.table.name, "users");
}
#[test]
fn test_generated_schema_get_query_field() {
let cache = create_test_schema_cache();
let config = SchemaConfig::default();
let schema = build_schema(&cache, &config);
let field = schema.get_query_field("posts").unwrap();
assert_eq!(field.table_name, "posts");
}
#[test]
fn test_generated_schema_get_mutation_fields() {
let cache = create_test_schema_cache();
let config = SchemaConfig::default();
let schema = build_schema(&cache, &config);
let fields = schema.get_mutation_fields("comments");
assert_eq!(fields.len(), 2); }
}