use fraiseql_error::{FraiseQLError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldKind {
Text,
Native,
Composite,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComputedExpr(String);
impl ComputedExpr {
pub(crate) const fn from_validated_parts(sql: String) -> Self {
Self(sql)
}
#[must_use]
pub fn as_sql(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectionField {
pub name: String,
pub source: String,
pub kind: FieldKind,
pub sub_fields: Option<Vec<ProjectionField>>,
pub computed: Option<ComputedExpr>,
}
impl ProjectionField {
#[must_use]
pub fn scalar(name: impl Into<String>) -> Self {
let name = name.into();
Self {
source: name.clone(),
name,
kind: FieldKind::Text,
sub_fields: None,
computed: None,
}
}
#[must_use]
pub fn native(name: impl Into<String>) -> Self {
let name = name.into();
Self {
source: name.clone(),
name,
kind: FieldKind::Native,
sub_fields: None,
computed: None,
}
}
#[must_use]
pub fn composite(name: impl Into<String>) -> Self {
let name = name.into();
Self {
source: name.clone(),
name,
kind: FieldKind::Composite,
sub_fields: None,
computed: None,
}
}
#[must_use]
pub fn composite_with_sub_fields(name: impl Into<String>, sub_fields: Vec<Self>) -> Self {
let name = name.into();
Self {
source: name.clone(),
name,
kind: FieldKind::Composite,
sub_fields: Some(sub_fields),
computed: None,
}
}
#[must_use]
pub fn computed(name: impl Into<String>, expr: ComputedExpr) -> Self {
let name = name.into();
Self {
source: name.clone(),
name,
kind: FieldKind::Native,
sub_fields: None,
computed: Some(expr),
}
}
#[must_use]
pub const fn is_composite(&self) -> bool {
matches!(self.kind, FieldKind::Composite)
}
}
impl From<String> for ProjectionField {
fn from(name: String) -> Self {
Self::scalar(name)
}
}
fn validate_field_name(field: &str) -> Result<()> {
if field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
Ok(())
} else {
Err(FraiseQLError::Validation {
message: format!(
"field name '{}' contains characters that cannot be safely projected; \
only ASCII alphanumeric characters and underscores are allowed",
field
),
path: None,
})
}
}
use crate::utils::to_snake_case;
pub struct PostgresProjectionGenerator {
jsonb_column: String,
}
impl PostgresProjectionGenerator {
#[must_use]
pub fn new() -> Self {
Self::with_column("data")
}
#[must_use]
pub fn with_column(jsonb_column: &str) -> Self {
Self {
jsonb_column: jsonb_column.to_string(),
}
}
pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
if fields.is_empty() {
return Ok(format!("\"{}\"", self.jsonb_column));
}
for field in fields {
validate_field_name(field)?;
}
let field_pairs: Vec<String> = fields
.iter()
.map(|field| {
let safe_field = Self::escape_sql_string(field);
let jsonb_key = to_snake_case(field);
let safe_jsonb_key = Self::escape_sql_string(&jsonb_key);
format!("'{}', \"{}\"->>'{}' ", safe_field, self.jsonb_column, safe_jsonb_key)
})
.collect();
Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
}
pub fn generate_typed_projection_sql(&self, fields: &[ProjectionField]) -> Result<String> {
if fields.is_empty() {
return Ok("jsonb_build_object()".to_string());
}
let path = format!("\"{}\"", self.jsonb_column);
let field_pairs = fields
.iter()
.map(|field| Self::render_field(field, &path))
.collect::<Result<Vec<_>>>()?;
Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
}
pub fn generate_merged_projection_sql(&self, computed: &[ProjectionField]) -> Result<String> {
if computed.is_empty() {
return Ok(format!("\"{}\"", self.jsonb_column));
}
let path = format!("\"{}\"", self.jsonb_column);
let pairs = computed
.iter()
.map(|field| Self::render_field(field, &path))
.collect::<Result<Vec<_>>>()?;
Ok(format!("{path} || jsonb_build_object({})", pairs.join(",")))
}
fn render_field(field: &ProjectionField, path: &str) -> Result<String> {
let resp_key = Self::escape_sql_string(&field.name);
if let Some(expr) = &field.computed {
return Ok(format!("'{}', ({})", resp_key, expr.as_sql()));
}
let jsonb_key = to_snake_case(&field.source);
let safe_jsonb_key = Self::escape_sql_string(&jsonb_key);
if let Some(subs) = &field.sub_fields {
let nested_path = format!("{}->'{}'", path, safe_jsonb_key);
let inner = subs
.iter()
.map(|sf| Self::render_field(sf, &nested_path))
.collect::<Result<Vec<_>>>()?;
return Ok(format!("'{}', jsonb_build_object({})", resp_key, inner.join(",")));
}
let op = if field.kind == FieldKind::Text {
"->>"
} else {
"->"
};
Ok(format!("'{}', {}{}'{}'", resp_key, path, op, safe_jsonb_key))
}
pub fn generate_select_clause(&self, table_alias: &str, fields: &[String]) -> Result<String> {
let projection = self.generate_projection_sql(fields)?;
Ok(format!(
"SELECT {} as \"{}\" FROM \"{}\" ",
projection, self.jsonb_column, table_alias
))
}
fn escape_sql_string(s: &str) -> String {
s.replace('\'', "''")
}
#[allow(dead_code)] fn escape_identifier(field: &str) -> String {
format!("\"{}\"", field.replace('"', "\"\""))
}
}
impl Default for PostgresProjectionGenerator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests;