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 postrust_core::QualifiedIdentifier;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
#[non_exhaustive]
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 enable_federation: bool,
pub type_prefix: Option<String>,
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,
enable_federation: false,
type_prefix: 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 is_shared_entity(&self, table: &QualifiedIdentifier) -> bool {
self.enable_federation && self.names.federation_shared(&table.schema, &table.name)
}
pub fn table_type_name(&self, table: &QualifiedIdentifier, base_name: &str) -> String {
match self.type_prefix.as_deref() {
Some(prefix) if !prefix.is_empty() && !self.is_shared_entity(table) => {
format!("{prefix}_{base_name}")
}
_ => base_name.to_string(),
}
}
pub fn root_field_name(&self, base_name: &str) -> String {
match self.type_prefix.as_deref() {
Some(prefix) if !prefix.is_empty() => format!("{prefix}_{base_name}"),
_ => base_name.to_string(),
}
}
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::{Cardinality, Relationship};
if let Relationship::Computed { function, .. } = rel {
return vec![function.name.clone()];
}
let mut keys = Vec::new();
if let Relationship::ForeignKey {
cardinality: Cardinality::M2M(junction),
..
} = rel
{
keys.push(junction.table.name.clone());
keys.push(junction.constraint1.clone());
keys.push(junction.constraint2.clone());
}
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)]
#[non_exhaustive]
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>,
pub federation_entities: HashMap<String, FederationEntity>,
pub enable_federation: bool,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct FederationEntity {
pub type_name: String,
pub table: QualifiedIdentifier,
pub key_fields: Vec<String>,
pub shared: bool,
}
impl FederationEntity {
pub fn key_fields_sdl(&self) -> String {
self.key_fields.join(" ")
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct FunctionField {
pub name: String,
pub schema_name: String,
pub function_name: String,
pub returns: String,
pub local_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>,
}
impl FunctionField {
pub fn argument_signature(&self) -> String {
self.arguments
.iter()
.map(|(name, sql_type, _)| format!("{name} {sql_type}"))
.collect::<Vec<_>>()
.join(", ")
}
}
fn reflected_fields(
table: &Table,
schema_cache: &SchemaCache,
config: &SchemaConfig,
base_names: &HashMap<(String, String), String>,
type_names: &HashMap<(String, String), String>,
local_type_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_key = (foreign.schema.clone(), foreign.name.clone());
let target_base = base_names.get(&target_key)?;
let target_type = type_names.get(&target_key)?;
let target_local_type = local_type_names.get(&target_key)?;
let mut field = RelationshipField::from_relationship_typed(
rel,
target_base,
target_type,
target_local_type,
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>,
type_names: &HashMap<(String, String), String>,
local_type_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 target_key = (foreign.schema.clone(), foreign.name.clone());
let (Some(target_base), Some(target_type), Some(target_local_type)) = (
base_names.get(&target_key),
type_names.get(&target_key),
local_type_names.get(&target_key),
) else {
continue;
};
let mut field = RelationshipField::from_relationship_typed(
rel,
target_base,
target_type,
target_local_type,
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 target_key = (schema, name);
let (Some(target_base), Some(target_type), Some(target_local_type)) = (
base_names.get(&target_key),
type_names.get(&target_key),
local_type_names.get(&target_key),
) else {
continue;
};
match mapped_relationship_field(
entry,
table,
schema_cache,
config.default_schema(),
target_base,
target_type,
target_local_type,
) {
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 target_key = (foreign.schema.clone(), foreign.name.clone());
let (Some(target_base), Some(target_type), Some(target_local_type)) = (
base_names.get(&target_key),
type_names.get(&target_key),
local_type_names.get(&target_key),
) else {
continue;
};
let mut field = RelationshipField::from_relationship_typed(
rel,
target_base,
target_type,
target_local_type,
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,
target_type: &str,
target_local_type: &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_typed(
&rel,
target_base,
target_type,
target_local_type,
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 get_federation_entity(&self, type_name: &str) -> Option<&FederationEntity> {
self.federation_entities.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)]
#[non_exhaustive]
pub struct QueryField {
pub name: String,
pub table_name: String,
pub schema_name: String,
pub type_name: String,
pub local_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 {
Self::list_typed(table, base_name, base_name, base_name)
}
pub fn list_typed(
table: &Table,
root_base_name: &str,
type_name: &str,
local_type_name: &str,
) -> Self {
let name = root_base_name.to_string();
Self {
name,
table_name: table.name.clone(),
schema_name: table.schema.clone(),
type_name: type_name.to_string(),
local_type_name: local_type_name.to_string(),
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> {
Self::by_pk_typed(table, base_name, base_name, base_name)
}
pub fn by_pk_typed(
table: &Table,
root_base_name: &str,
type_name: &str,
local_type_name: &str,
) -> Option<Self> {
if !has_whole_key(table) {
return None;
}
let field_name = format!("{}_by_pk", root_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.to_string(),
local_type_name: local_type_name.to_string(),
return_type: type_name.to_string(),
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)]
#[non_exhaustive]
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 type_name: String,
pub local_type_name: 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> {
Self::insert_fields_typed(table, base_name, base_name)
}
pub fn insert_fields_typed(table: &Table, local_type_name: &str, type_name: &str) -> Vec<Self> {
if !is_insertable(table) {
return vec![];
}
let mut fields = vec![];
let name = format!("insert_{}", local_type_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", local_type_name),
type_name: type_name.to_string(),
local_type_name: local_type_name.to_string(),
description: Some(format!("insert data into the table: \"{}\"", table.name)),
});
let name = format!("insert_{}_one", local_type_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.to_string(),
type_name: type_name.to_string(),
local_type_name: local_type_name.to_string(),
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> {
Self::update_fields_typed(table, base_name, base_name)
}
pub fn update_fields_typed(table: &Table, local_type_name: &str, type_name: &str) -> Vec<Self> {
if !is_updatable(table) {
return vec![];
}
let mut fields = vec![];
let name = format!("update_{}", local_type_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", local_type_name),
type_name: type_name.to_string(),
local_type_name: local_type_name.to_string(),
description: Some(format!("update data of the table: \"{}\"", table.name)),
});
let name = format!("update_{}_many", local_type_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]", local_type_name),
type_name: type_name.to_string(),
local_type_name: local_type_name.to_string(),
description: Some(format!(
"update multiples rows of table: \"{}\"",
table.name
)),
});
if has_whole_key(table) {
let name = format!("update_{}_by_pk", local_type_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.to_string(),
type_name: type_name.to_string(),
local_type_name: local_type_name.to_string(),
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> {
Self::delete_fields_typed(table, base_name, base_name)
}
pub fn delete_fields_typed(table: &Table, local_type_name: &str, type_name: &str) -> Vec<Self> {
if !is_deletable(table) {
return vec![];
}
let mut fields = vec![];
let name = format!("delete_{}", local_type_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", local_type_name),
type_name: type_name.to_string(),
local_type_name: local_type_name.to_string(),
description: Some(format!("delete data from the table: \"{}\"", table.name)),
});
if has_whole_key(table) {
let name = format!("delete_{}_by_pk", local_type_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.to_string(),
type_name: type_name.to_string(),
local_type_name: local_type_name.to_string(),
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 federation_entities = 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 used_type_names: HashMap<String, u32> = HashMap::new();
let mut type_names: HashMap<(String, String), String> = HashMap::new();
let mut local_type_names: HashMap<(String, String), String> = HashMap::new();
for table in &tables {
let key = (table.schema.clone(), table.name.clone());
let base_name = base_names
.get(&key)
.expect("every visited table has a resolved base name");
let preferred = config.table_type_name(&table.qualified_identifier(), base_name);
let type_name = match used_type_names.get_mut(&preferred) {
None => {
used_type_names.insert(preferred.clone(), 1);
preferred
}
Some(count) => {
*count += 1;
let disambiguated = format!("{}_{}", preferred, count);
tracing::warn!(
"GraphQL type name collision: {}.{} would generate the same type as \
an earlier table; exposing it as \"{}\" instead",
table.schema,
table.name,
disambiguated
);
disambiguated
}
};
let local_type_name = config.root_field_name(base_name);
local_type_names.insert(key.clone(), local_type_name);
type_names.insert(key, type_name);
}
let mut readable_tables: HashSet<QualifiedIdentifier> = 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 type_name = type_names
.get(&(table.schema.clone(), table.name.clone()))
.expect("every visited table has a resolved type name")
.clone();
let local_type_name = local_type_names
.get(&(table.schema.clone(), table.name.clone()))
.expect("every visited table has a resolved local type name")
.clone();
let mut obj_type = TableObjectType::from_table_named(table, &type_name, &config.names);
obj_type.local_name = local_type_name.clone();
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_tables.insert(table.qualified_identifier());
}
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 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_typed(table, &base_name, &type_name, &local_type_name);
list.name = base_name.clone();
rename(&mut list, "select");
list.name = config.root_field_name(&list.name);
let aggregate_name = config
.names
.root(&table.schema, &table.name, "select_aggregate")
.map(str::to_string)
.unwrap_or_else(|| format!("{}_aggregate", base_name));
list.aggregate_name = Some(config.root_field_name(&aggregate_name));
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_typed(table, &base_name, &type_name, &local_type_name)
{
by_pk.name = format!("{}_by_pk", base_name);
rename(&mut by_pk, "select_by_pk");
by_pk.name = config.root_field_name(&by_pk.name);
query_fields.push(by_pk);
}
}
if config.enable_mutations {
let mut fields: Vec<MutationField> = Vec::new();
fields.extend(MutationField::insert_fields_typed(
table,
&local_type_name,
&type_name,
));
fields.extend(MutationField::update_fields_typed(
table,
&local_type_name,
&type_name,
));
fields.extend(MutationField::delete_fields_typed(
table,
&local_type_name,
&type_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",
};
field.name = match field.mutation_type {
MutationType::Insert => format!("insert_{base_name}"),
MutationType::InsertOne => format!("insert_{base_name}_one"),
MutationType::Update => format!("update_{base_name}"),
MutationType::UpdateByPk => format!("update_{base_name}_by_pk"),
MutationType::UpdateMany => format!("update_{base_name}_many"),
MutationType::Delete => format!("delete_{base_name}"),
MutationType::DeleteByPk => format!("delete_{base_name}_by_pk"),
};
if let Some(given) = config.names.root(&table.schema, &table.name, kind) {
field.name = given.to_string();
}
field.name = config.root_field_name(&field.name);
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,
&type_names,
&local_type_names,
),
None => reflected_fields(
table,
schema_cache,
config,
&base_names,
&type_names,
&local_type_names,
),
};
if !rels.is_empty() {
relationship_fields.insert(type_name.clone(), rels);
}
if config.enable_federation && readable && has_whole_key(table) {
let key_fields: Vec<String> = obj_type
.fields
.iter()
.filter(|field| field.is_pk)
.map(|field| field.name.clone())
.collect();
if key_fields.len() == table.pk_cols.len() {
federation_entities.insert(
type_name.clone(),
FederationEntity {
type_name: type_name.clone(),
table: table.qualified_identifier(),
key_fields,
shared: config.is_shared_entity(&table.qualified_identifier()),
},
);
}
}
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 table_key = (schema_name.clone(), table_name.clone());
let base = match type_names.get(&table_key) {
Some(found) => found.clone(),
None => {
let unprefixed = 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),
});
config.table_type_name(
&QualifiedIdentifier::new(&schema_name, &table_name),
&unprefixed,
)
}
};
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) = type_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), _)) = found else {
continue;
};
let target = QualifiedIdentifier::new(target_schema, target_table);
if !readable_tables.contains(&target) {
continue;
}
let Some(return_type) = type_names.get(&(target_schema.clone(), target_table.clone()))
else {
continue;
};
let Some(local_return_type) =
local_type_names.get(&(target_schema.clone(), target_table.clone()))
else {
continue;
};
let returns_table = (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: config.root_field_name(&routine.name),
schema_name: routine.schema.clone(),
function_name: routine.name.clone(),
returns: return_type.clone(),
local_returns: local_return_type.clone(),
returns_table,
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).then_with(|| {
a.arguments
.len()
.cmp(&b.arguments.len())
.then_with(|| a.argument_signature().cmp(&b.argument_signature()))
})
});
let mut seen: HashSet<String> = HashSet::new();
function_fields.retain(|function| {
if seen.insert(function.name.clone()) {
return true;
}
tracing::warn!(
"not exposing {}.{}({}) -- another overload of {} is already a root \
field, and GraphQL has no overloading",
function.schema_name,
function.function_name,
function.argument_signature(),
function.name,
);
false
});
GeneratedSchema {
object_types,
query_fields,
mutation_fields,
relationship_fields,
enum_types,
function_fields,
federation_entities,
enable_federation: config.enable_federation,
}
}
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 a_many_to_many_relationship_can_be_named_by_its_junction() {
use postrust_core::schema_cache::{Cardinality, Junction, Relationship};
let relationship = Relationship::ForeignKey {
table: QualifiedIdentifier::new("public", "products"),
foreign_table: QualifiedIdentifier::new("public", "product_research"),
is_self: false,
cardinality: Cardinality::M2M(Junction {
table: QualifiedIdentifier::new("public", "product_research_links"),
constraint1: "product_research_links_product_id_fkey".into(),
constraint2: "product_research_links_research_id_fkey".into(),
source_columns: vec![("id".into(), "product_id".into())],
target_columns: vec![("research_id".into(), "id".into())],
}),
table_is_view: false,
foreign_table_is_view: false,
constraint_name: String::new(),
};
assert_eq!(
relationship_keys(&relationship),
vec![
"product_research_links",
"product_research_links_product_id_fkey",
"product_research_links_research_id_fkey"
]
);
let mut cache = create_test_schema_cache();
let products = create_test_table("products", true, true, true);
let product_research = create_test_table("product_research", true, true, true);
let products_key = products.qualified_identifier();
cache.tables.insert(products_key.clone(), products);
cache
.tables
.insert(product_research.qualified_identifier(), product_research);
cache
.relationships
.insert((products_key, "public".into()), vec![relationship]);
let names = crate::names::NameOverrides::parse(
r#"{"tables": {"public.products": {
"relationships": {"product_research_links": "research"}
}}}"#,
)
.unwrap();
let schema = build_schema(
&cache,
&SchemaConfig {
names,
..SchemaConfig::default()
},
);
let field = schema
.get_relationship_fields("products")
.and_then(|fields| fields.iter().find(|field| field.name == "research"))
.expect("junction metadata renames the inferred relationship");
assert_eq!(field.target_type, "product_research");
assert!(field.is_list);
}
fn setof_users_routine(
name: &str,
args: &[(&str, &str)],
) -> postrust_core::schema_cache::Routine {
use postrust_core::schema_cache::{FuncVolatility, RetType, Routine, RoutineParam};
Routine {
schema: "public".into(),
name: name.into(),
description: None,
params: args
.iter()
.map(|(pname, ptype)| RoutineParam {
name: (*pname).into(),
param_type: (*ptype).into(),
type_max_length: (*ptype).into(),
required: true,
variadic: false,
})
.collect(),
return_type: RetType::SetOf("users".into()),
returns_composite: true,
volatility: FuncVolatility::Stable,
has_variadic: false,
isolation_level: None,
settings: Vec::new(),
is_procedure: false,
media_type: None,
media_base_type: None,
output_columns: Vec::new(),
}
}
#[test]
fn overloads_of_one_function_become_a_single_root_field() {
let mut cache = create_test_schema_cache();
let one = setof_users_routine("overloaded", &[("a", "integer")]);
let two = setof_users_routine("overloaded", &[("a", "integer"), ("b", "text")]);
cache
.routines
.insert(one.qualified_identifier(), vec![two, one]);
let generated = build_schema(&cache, &SchemaConfig::default());
let named: Vec<&str> = generated
.function_fields
.iter()
.filter(|f| f.name == "overloaded")
.map(|f| f.name.as_str())
.collect();
assert_eq!(named.len(), 1, "exactly one root field per function name");
}
#[test]
fn the_surviving_overload_is_the_one_taking_fewest_arguments() {
let long = setof_users_routine("overloaded", &[("a", "integer"), ("b", "text")]);
let short = setof_users_routine("overloaded", &[("a", "integer")]);
for order in [vec![long.clone(), short.clone()], vec![short, long]] {
let mut cache = create_test_schema_cache();
cache
.routines
.insert(order[0].qualified_identifier(), order);
let generated = build_schema(&cache, &SchemaConfig::default());
let kept = generated
.function_fields
.iter()
.find(|f| f.name == "overloaded")
.expect("the function is exposed");
assert_eq!(kept.argument_signature(), "a integer");
}
}
#[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 type_prefix_namespaces_generated_types_and_roots() {
let mut cache = create_test_schema_cache();
let routine = setof_users_routine("search_users", &[("name", "text")]);
cache
.routines
.insert(routine.qualified_identifier(), vec![routine]);
let config = SchemaConfig {
type_prefix: Some("test".into()),
..SchemaConfig::default()
};
let schema = build_schema(&cache, &config);
assert!(schema.get_object_type("test_users").is_some());
let users = schema
.query_fields
.iter()
.find(|field| field.table_name == "users" && field.is_list)
.expect("users list field");
assert_eq!(users.name, "test_users");
assert_eq!(users.type_name, "test_users");
assert_eq!(
users.aggregate_name.as_deref(),
Some("test_users_aggregate")
);
assert!(schema
.mutation_fields
.iter()
.any(|field| field.name == "test_insert_users"));
let function = schema
.function_fields
.iter()
.find(|field| field.function_name == "search_users")
.expect("set-returning function field");
assert_eq!(function.name, "test_search_users");
assert_eq!(function.returns, "test_users");
}
#[test]
fn shared_entities_keep_their_type_name_but_not_their_root_names() {
use postrust_core::schema_cache::{Cardinality, Relationship};
let mut cache = create_test_schema_cache();
let posts = QualifiedIdentifier::new("public", "posts");
cache.relationships.insert(
(posts.clone(), "public".into()),
vec![Relationship::ForeignKey {
table: posts,
foreign_table: QualifiedIdentifier::new("public", "users"),
is_self: false,
cardinality: Cardinality::M2O {
constraint: "posts_user_id_fkey".into(),
columns: vec![("id".into(), "id".into())],
},
table_is_view: false,
foreign_table_is_view: false,
constraint_name: "posts_user_id_fkey".into(),
}],
);
let config = SchemaConfig {
enable_federation: true,
type_prefix: Some("test".into()),
names: crate::names::NameOverrides::parse(
r#"{"tables": {"public.users": {"federation": {"shared": true}}}}"#,
)
.expect("metadata"),
..SchemaConfig::default()
};
let schema = build_schema(&cache, &config);
let users_object = schema.get_object_type("users").expect("users object");
assert_eq!(users_object.local_name, "test_users");
let posts_object = schema.get_object_type("test_posts").expect("posts object");
assert_eq!(posts_object.local_name, "test_posts");
let users = schema
.query_fields
.iter()
.find(|field| field.table_name == "users" && field.is_list)
.expect("users list field");
assert_eq!(users.name, "test_users");
assert_eq!(users.type_name, "users");
assert_eq!(users.local_type_name, "test_users");
let posts_relationships = schema
.get_relationship_fields("test_posts")
.expect("posts relationships");
assert_eq!(posts_relationships[0].name, "user");
assert_eq!(posts_relationships[0].target_type, "users");
assert_eq!(posts_relationships[0].target_local_name, "test_users");
}
#[test]
fn federation_entities_are_generated_only_when_enabled() {
let cache = create_test_schema_cache();
let disabled = build_schema(&cache, &SchemaConfig::default());
assert!(!disabled.enable_federation);
assert!(disabled.federation_entities.is_empty());
let enabled = build_schema(
&cache,
&SchemaConfig {
enable_federation: true,
type_prefix: Some("test".into()),
names: crate::names::NameOverrides::parse(
r#"{"tables": {"public.users": {"federation": {"shared": true}}}}"#,
)
.expect("metadata"),
..SchemaConfig::default()
},
);
assert!(enabled.enable_federation);
let users = enabled
.get_federation_entity("users")
.expect("shared users entity");
assert_eq!(users.type_name, "users");
assert_eq!(users.table, QualifiedIdentifier::new("public", "users"));
assert_eq!(users.key_fields_sdl(), "id");
assert!(users.shared);
let posts = enabled
.get_federation_entity("test_posts")
.expect("prefixed posts entity");
assert_eq!(posts.type_name, "test_posts");
assert_eq!(posts.key_fields_sdl(), "id");
assert!(!posts.shared);
}
#[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); }
}