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)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
#[test]
fn test_postgres_projection_single_field() {
let generator = PostgresProjectionGenerator::new();
let fields = vec!["id".to_string()];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert_eq!(sql, "jsonb_build_object('id', \"data\"->>'id' )");
}
#[test]
fn test_postgres_projection_multiple_fields() {
let generator = PostgresProjectionGenerator::new();
let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert!(sql.contains("jsonb_build_object("));
assert!(sql.contains("'id', \"data\"->>'id'"));
assert!(sql.contains("'name', \"data\"->>'name'"));
assert!(sql.contains("'email', \"data\"->>'email'"));
}
#[test]
fn test_postgres_projection_empty_fields() {
let generator = PostgresProjectionGenerator::new();
let fields: Vec<String> = vec![];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert_eq!(sql, "\"data\"");
}
#[test]
fn test_postgres_projection_custom_column() {
let generator = PostgresProjectionGenerator::with_column("metadata");
let fields = vec!["id".to_string()];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert_eq!(sql, "jsonb_build_object('id', \"metadata\"->>'id' )");
}
#[test]
fn test_postgres_select_clause() {
let generator = PostgresProjectionGenerator::new();
let fields = vec!["id".to_string(), "name".to_string()];
let sql = generator.generate_select_clause("users", &fields).unwrap();
assert!(sql.starts_with("SELECT jsonb_build_object("));
assert!(sql.contains("as \"data\""));
assert!(sql.contains("FROM \"users\""));
}
#[test]
fn test_escape_identifier_quoting() {
assert_eq!(PostgresProjectionGenerator::escape_identifier("id"), "\"id\"");
assert_eq!(PostgresProjectionGenerator::escape_identifier("user_id"), "\"user_id\"");
assert_eq!(PostgresProjectionGenerator::escape_identifier("field-name"), "\"field-name\"");
assert_eq!(PostgresProjectionGenerator::escape_identifier("field.name"), "\"field.name\"");
assert_eq!(
PostgresProjectionGenerator::escape_identifier("col\"inject"),
"\"col\"\"inject\""
);
}
#[test]
fn test_mysql_projection_single_field() {
let generator = MySqlProjectionGenerator::new();
let fields = vec!["id".to_string()];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`data`, '$.id'))");
}
#[test]
fn test_mysql_projection_multiple_fields() {
let generator = MySqlProjectionGenerator::new();
let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert!(sql.contains("JSON_OBJECT("));
assert!(sql.contains("'id', JSON_EXTRACT(`data`, '$.id')"));
assert!(sql.contains("'name', JSON_EXTRACT(`data`, '$.name')"));
assert!(sql.contains("'email', JSON_EXTRACT(`data`, '$.email')"));
}
#[test]
fn test_mysql_projection_empty_fields() {
let generator = MySqlProjectionGenerator::new();
let fields: Vec<String> = vec![];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert_eq!(sql, "`data`");
}
#[test]
fn test_mysql_projection_custom_column() {
let generator = MySqlProjectionGenerator::with_column("metadata");
let fields = vec!["id".to_string()];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert_eq!(sql, "JSON_OBJECT('id', JSON_EXTRACT(`metadata`, '$.id'))");
}
#[test]
fn test_sqlite_projection_single_field() {
let generator = SqliteProjectionGenerator::new();
let fields = vec!["id".to_string()];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert_eq!(sql, "json_object('id', json_extract(\"data\", '$.id'))");
}
#[test]
fn test_sqlite_projection_multiple_fields() {
let generator = SqliteProjectionGenerator::new();
let fields = vec!["id".to_string(), "name".to_string(), "email".to_string()];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert!(sql.contains("json_object("));
assert!(sql.contains("'id', json_extract(\"data\", '$.id')"));
assert!(sql.contains("'name', json_extract(\"data\", '$.name')"));
assert!(sql.contains("'email', json_extract(\"data\", '$.email')"));
}
#[test]
fn test_sqlite_projection_empty_fields() {
let generator = SqliteProjectionGenerator::new();
let fields: Vec<String> = vec![];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert_eq!(sql, "\"data\"");
}
#[test]
fn test_sqlite_projection_custom_column() {
let generator = SqliteProjectionGenerator::with_column("metadata");
let fields = vec!["id".to_string()];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert_eq!(sql, "json_object('id', json_extract(\"metadata\", '$.id'))");
}
#[test]
fn test_to_snake_case_conversion() {
assert_eq!(super::to_snake_case("id"), "id");
assert_eq!(super::to_snake_case("firstName"), "first_name");
assert_eq!(super::to_snake_case("createdAt"), "created_at");
assert_eq!(super::to_snake_case("userId"), "user_id");
assert_eq!(super::to_snake_case("updatedAtTimestamp"), "updated_at_timestamp");
}
#[test]
fn test_postgres_projection_with_field_mapping_snake_case() {
let generator = PostgresProjectionGenerator::new();
let graphql_fields = vec![
"id".to_string(),
"firstName".to_string(),
"createdAt".to_string(),
];
let sql = generator.generate_projection_sql(&graphql_fields).unwrap();
eprintln!("Generated SQL: {}", sql);
assert!(
!sql.contains("->>'firstName'") && !sql.contains("->>'createdAt'"),
"Regression: SQL is using camelCase keys for JSONB access. \
JSONB has snake_case keys ('first_name', 'created_at'). SQL: {}",
sql
);
}
#[test]
fn test_postgres_projection_sql_injection_in_field_name() {
let generator = PostgresProjectionGenerator::new();
let fields = vec!["user'name".to_string()];
let result = generator.generate_projection_sql(&fields);
assert!(result.is_err(), "Field name with single quote must be rejected");
}
#[test]
fn test_postgres_projection_rejects_field_with_semicolon() {
let generator = PostgresProjectionGenerator::new();
let fields = vec!["id; DROP TABLE users--".to_string()];
let result = generator.generate_projection_sql(&fields);
assert!(result.is_err(), "Field name with SQL injection characters must be rejected");
}
#[test]
fn test_mysql_projection_rejects_unsafe_field_name() {
let generator = MySqlProjectionGenerator::new();
let fields = vec!["field`hack".to_string()];
let result = generator.generate_projection_sql(&fields);
assert!(result.is_err(), "Field name with backtick must be rejected");
}
#[test]
fn test_sqlite_projection_rejects_unsafe_field_name() {
let generator = SqliteProjectionGenerator::new();
let fields = vec!["field\"inject".to_string()];
let result = generator.generate_projection_sql(&fields);
assert!(result.is_err(), "Field name with double-quote must be rejected");
}
#[test]
fn test_validate_field_name_accepts_valid_names() {
assert!(super::validate_field_name("id").is_ok());
assert!(super::validate_field_name("user_id").is_ok());
assert!(super::validate_field_name("firstName").is_ok());
assert!(super::validate_field_name("createdAt").is_ok());
assert!(super::validate_field_name("field123").is_ok());
assert!(super::validate_field_name("_private").is_ok());
}
#[test]
fn test_validate_field_name_rejects_unsafe_chars() {
assert!(super::validate_field_name("user'name").is_err());
assert!(super::validate_field_name("field-name").is_err());
assert!(super::validate_field_name("field.name").is_err());
assert!(super::validate_field_name("field;inject").is_err());
assert!(super::validate_field_name("field\"inject").is_err());
assert!(super::validate_field_name("field`hack").is_err());
}
#[test]
fn test_mysql_projection_sql_contains_json_object() {
let generator = MySqlProjectionGenerator::new();
let fields = vec!["email".to_string(), "name".to_string()];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert!(sql.starts_with("JSON_OBJECT("), "MySQL projection must start with JSON_OBJECT");
}
#[test]
fn test_sqlite_projection_custom_column_appears_in_sql() {
let generator = SqliteProjectionGenerator::with_column("payload");
let fields = vec!["id".to_string()];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert!(sql.contains("\"payload\""), "Custom column name must appear in SQLite SQL");
}
#[test]
fn test_postgres_projection_camel_to_snake_in_jsonb_key() {
let generator = PostgresProjectionGenerator::new();
let fields = vec!["updatedAt".to_string()];
let sql = generator.generate_projection_sql(&fields).unwrap();
assert!(
sql.contains("'updated_at'"),
"updatedAt must be mapped to updated_at for JSONB key"
);
assert!(sql.contains("'updatedAt'"), "Response key must remain camelCase");
}
#[test]
fn test_postgres_select_clause_contains_from() {
let generator = PostgresProjectionGenerator::new();
let fields = vec!["id".to_string()];
let sql = generator.generate_select_clause("orders", &fields).unwrap();
assert!(
sql.contains("FROM \"orders\""),
"SELECT clause must include FROM clause with table name"
);
assert!(sql.contains("SELECT"), "SELECT clause must start with SELECT");
}
#[test]
fn test_typed_projection_empty_fields_returns_data_column() {
let generator = PostgresProjectionGenerator::new();
let result = generator.generate_typed_projection_sql(&[]).unwrap();
assert_eq!(result, "\"data\"");
}
#[test]
fn test_typed_projection_text_field_uses_text_extraction() {
let generator = PostgresProjectionGenerator::new();
let fields = vec![ProjectionField::scalar("name")];
let sql = generator.generate_typed_projection_sql(&fields).unwrap();
assert!(sql.contains("->>'name'"), "text field must use ->> operator, got: {sql}");
assert!(!sql.contains("->'name'"), "text field must NOT use -> operator, got: {sql}");
}
#[test]
fn test_typed_projection_composite_field_uses_jsonb_extraction() {
let generator = PostgresProjectionGenerator::new();
let fields = vec![ProjectionField::composite("address")];
let sql = generator.generate_typed_projection_sql(&fields).unwrap();
assert!(sql.contains("->'address'"), "composite field must use -> operator, got: {sql}");
}
#[test]
fn test_typed_projection_mixed_text_native_and_composite() {
let generator = PostgresProjectionGenerator::new();
let fields = vec![
ProjectionField::scalar("id"),
ProjectionField::native("age"),
ProjectionField::composite("address"),
ProjectionField::composite("tags"),
ProjectionField::scalar("email"),
];
let sql = generator.generate_typed_projection_sql(&fields).unwrap();
assert!(sql.contains("->>'id'"), "id (text) must use ->>, got: {sql}");
assert!(sql.contains("->>'email'"), "email (text) must use ->>, got: {sql}");
assert!(sql.contains("->'age'"), "age (native) must use ->, got: {sql}");
assert!(sql.contains("->'address'"), "address (composite) must use ->, got: {sql}");
assert!(sql.contains("->'tags'"), "tags (composite) must use ->, got: {sql}");
assert!(
sql.starts_with("jsonb_build_object("),
"must wrap in jsonb_build_object, got: {sql}"
);
}
#[test]
fn test_typed_projection_camel_case_maps_to_snake_case_jsonb_key() {
let generator = PostgresProjectionGenerator::new();
let fields = vec![ProjectionField::scalar("firstName")];
let sql = generator.generate_typed_projection_sql(&fields).unwrap();
assert!(
sql.contains("'firstName'"),
"response key must be camelCase 'firstName', got: {sql}"
);
assert!(
sql.contains("->>'first_name'"),
"JSONB key must be snake_case 'first_name', got: {sql}"
);
}
#[test]
fn test_typed_projection_single_quote_in_field_name_escaped() {
let generator = PostgresProjectionGenerator::new();
let fields = vec![ProjectionField::scalar("it's")];
let sql = generator.generate_typed_projection_sql(&fields).unwrap();
assert!(
sql.contains("'it''s'"),
"single quote in field name must be escaped, got: {sql}"
);
}
#[test]
fn test_native_field_uses_jsonb_extraction() {
let generator = PostgresProjectionGenerator::new();
let fields = vec![
ProjectionField::native("isActive"),
ProjectionField::scalar("name"),
];
let sql = generator.generate_typed_projection_sql(&fields).unwrap();
assert!(sql.contains("->'is_active'"), "native field must use -> operator, got: {sql}");
assert!(sql.contains("->>'name'"), "text scalar field must use ->> operator, got: {sql}");
}
#[test]
fn test_native_field_mixed_with_composite() {
let generator = PostgresProjectionGenerator::new();
let fields = vec![
ProjectionField::native("isActive"),
ProjectionField::composite("address"),
ProjectionField::scalar("email"),
];
let sql = generator.generate_typed_projection_sql(&fields).unwrap();
assert!(sql.contains("->'is_active'"), "native uses ->, got: {sql}");
assert!(sql.contains("->'address'"), "composite uses ->, got: {sql}");
assert!(sql.contains("->>'email'"), "text scalar uses ->>, got: {sql}");
}
#[test]
fn test_native_int_and_float_use_jsonb_extraction() {
let generator = PostgresProjectionGenerator::new();
let fields = vec![
ProjectionField::native("age"),
ProjectionField::native("price"),
ProjectionField::scalar("name"),
ProjectionField::scalar("id"),
];
let sql = generator.generate_typed_projection_sql(&fields).unwrap();
assert!(sql.contains("->'age'"), "int (native) must use ->, got: {sql}");
assert!(sql.contains("->'price'"), "float (native) must use ->, got: {sql}");
assert!(sql.contains("->>'name'"), "string (text) must use ->>, got: {sql}");
assert!(sql.contains("->>'id'"), "id (text) must use ->>, got: {sql}");
}
#[test]
fn test_typed_projection_nested_sub_fields_generate_nested_jsonb_build_object() {
let generator = PostgresProjectionGenerator::new();
let fields = vec![
ProjectionField::scalar("id"),
ProjectionField::scalar("content"),
ProjectionField::composite_with_sub_fields(
"author",
vec![
ProjectionField::scalar("id"),
ProjectionField::scalar("username"),
ProjectionField::scalar("fullName"),
],
),
];
let sql = generator.generate_typed_projection_sql(&fields).unwrap();
assert!(
sql.contains("'author', jsonb_build_object("),
"author must produce nested jsonb_build_object, got: {sql}"
);
assert!(
sql.contains("'author'->>'id'"),
"nested 'id' must use path \"data\"->'author'->>'id', got: {sql}"
);
assert!(
sql.contains("'author'->>'username'"),
"nested 'username' must use correct path, got: {sql}"
);
assert!(
sql.contains("->>'full_name'"),
"fullName sub-field must map to snake_case 'full_name', got: {sql}"
);
assert!(
sql.contains("\"data\"->>'id'"),
"root id must use top-level data path, got: {sql}"
);
}
#[test]
fn test_typed_projection_composite_without_sub_fields_returns_full_blob() {
let generator = PostgresProjectionGenerator::new();
let fields = vec![ProjectionField::composite("author")];
let sql = generator.generate_typed_projection_sql(&fields).unwrap();
assert!(
sql.contains("\"data\"->'author'"),
"composite without sub_fields must return full blob, got: {sql}"
);
assert!(
!sql.contains("'author', jsonb_build_object("),
"must NOT produce nested jsonb_build_object for author when sub_fields is None, got: {sql}"
);
}
#[test]
fn test_typed_projection_depth_2_recursion() {
let generator = PostgresProjectionGenerator::new();
let fields = vec![
ProjectionField::scalar("id"),
ProjectionField::composite_with_sub_fields(
"author",
vec![
ProjectionField::scalar("id"),
ProjectionField::composite_with_sub_fields(
"profile",
vec![ProjectionField::scalar("bio")],
),
],
),
];
let sql = generator.generate_typed_projection_sql(&fields).unwrap();
assert!(
sql.contains("'author', jsonb_build_object("),
"author must be nested, got: {sql}"
);
assert!(
sql.contains("'profile', jsonb_build_object("),
"profile must be nested inside author, got: {sql}"
);
assert!(sql.contains("'profile'->>'bio'"), "bio must use depth-2 path, got: {sql}");
}
}