use fraiseql_error::{FraiseQLError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldKind {
Text,
Native,
Composite,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectionField {
pub name: String,
pub kind: FieldKind,
pub sub_fields: Option<Vec<ProjectionField>>,
}
impl ProjectionField {
#[must_use]
pub fn scalar(name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: FieldKind::Text,
sub_fields: None,
}
}
#[must_use]
pub fn native(name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: FieldKind::Native,
sub_fields: None,
}
}
#[must_use]
pub fn composite(name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: FieldKind::Composite,
sub_fields: None,
}
}
#[must_use]
pub fn composite_with_sub_fields(name: impl Into<String>, sub_fields: Vec<Self>) -> Self {
Self {
name: name.into(),
kind: FieldKind::Composite,
sub_fields: Some(sub_fields),
}
}
#[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;
const MAX_PROJECTION_DEPTH: usize = 4;
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(format!("\"{}\"", self.jsonb_column));
}
let path = format!("\"{}\"", self.jsonb_column);
let field_pairs = fields
.iter()
.map(|field| Self::render_field(field, &path, 0))
.collect::<Result<Vec<_>>>()?;
Ok(format!("jsonb_build_object({})", field_pairs.join(",")))
}
fn render_field(field: &ProjectionField, path: &str, depth: usize) -> Result<String> {
let resp_key = Self::escape_sql_string(&field.name);
let jsonb_key = to_snake_case(&field.name);
let safe_jsonb_key = Self::escape_sql_string(&jsonb_key);
if depth < MAX_PROJECTION_DEPTH {
if let Some(subs) = &field.sub_fields {
if !subs.is_empty() {
let nested_path = format!("{}->'{}'", path, safe_jsonb_key);
let inner = subs
.iter()
.map(|sf| Self::render_field(sf, &nested_path, depth + 1))
.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()
}
}
pub struct MySqlProjectionGenerator {
json_column: String,
}
impl MySqlProjectionGenerator {
#[must_use]
pub fn new() -> Self {
Self::with_column("data")
}
#[must_use]
pub fn with_column(json_column: &str) -> Self {
Self {
json_column: json_column.to_string(),
}
}
pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
if fields.is_empty() {
return Ok(format!("`{}`", self.json_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 json_key = to_snake_case(field);
format!("'{}', JSON_EXTRACT(`{}`, '$.{}')", safe_field, self.json_column, json_key)
})
.collect();
Ok(format!("JSON_OBJECT({})", field_pairs.join(",")))
}
fn escape_sql_string(s: &str) -> String {
s.replace('\'', "''")
}
#[allow(dead_code)] fn escape_identifier(field: &str) -> String {
format!("`{}`", field.replace('`', "``"))
}
}
impl Default for MySqlProjectionGenerator {
fn default() -> Self {
Self::new()
}
}
pub struct SqliteProjectionGenerator {
json_column: String,
}
impl SqliteProjectionGenerator {
#[must_use]
pub fn new() -> Self {
Self::with_column("data")
}
#[must_use]
pub fn with_column(json_column: &str) -> Self {
Self {
json_column: json_column.to_string(),
}
}
pub fn generate_projection_sql(&self, fields: &[String]) -> Result<String> {
if fields.is_empty() {
return Ok(format!("\"{}\"", self.json_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 json_key = to_snake_case(field);
format!(
"'{}', json_extract(\"{}\", '$.{}')",
safe_field, self.json_column, json_key
)
})
.collect();
Ok(format!("json_object({})", field_pairs.join(",")))
}
fn escape_sql_string(s: &str) -> String {
s.replace('\'', "''")
}
#[allow(dead_code)] fn escape_identifier(field: &str) -> String {
format!("\"{}\"", field.replace('"', "\"\""))
}
}
impl Default for SqliteProjectionGenerator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests;