Skip to main content

fraiseql_cli/codegen/
row_views.rs

1//! Row-shaped SQL view (`vr_*`) DDL generation for the gRPC transport.
2//!
3//! Generates `CREATE VIEW vr_<entity>` statements that extract individual
4//! scalar fields from the JSONB `data` column into typed SQL columns.
5//! These views are CQRS read projections optimized for protobuf wire encoding —
6//! the database returns native typed columns instead of JSON.
7
8use fraiseql_core::{db::dialect::SqlDialect, schema::TypeDefinition};
9
10use super::proto_gen::graphql_to_row_view_type;
11
12/// Generate the DDL for a row-shaped view from a type definition.
13///
14/// The view selects from the command-side table (`tb_<sql_source>`) and
15/// extracts each scalar field as a typed column using the dialect's
16/// `row_view_column_expr()` method.
17///
18/// # Arguments
19///
20/// * `dialect` — SQL dialect for type casting and DDL syntax.
21/// * `type_def` — The GraphQL type definition containing field metadata.
22///
23/// # Returns
24///
25/// A complete DDL string (e.g., `CREATE OR REPLACE VIEW "vr_user" AS ...`).
26pub fn generate_row_view_sql(dialect: &dyn SqlDialect, type_def: &TypeDefinition) -> String {
27    let source_table = format!("tb_{}", type_def.sql_source);
28    let view_name = format!("vr_{}", type_def.sql_source);
29
30    let columns: Vec<(String, String)> = type_def
31        .fields
32        .iter()
33        // Each field name is interpolated into the JSONB extraction path
34        // (`data->>'{name}'`) by the dialect, so a name containing a single quote
35        // would break out of that literal. A compiled schema should only carry
36        // valid GraphQL identifiers; drop anything else defensively.
37        .filter(|f| f.field_type.is_scalar() && is_safe_field_name(f.name.as_ref()))
38        .map(|f| {
39            let col_type = graphql_to_row_view_type(&f.field_type.to_graphql_string());
40            let expr =
41                dialect.row_view_column_expr(&type_def.jsonb_column, f.name.as_ref(), &col_type);
42            (f.name.to_string(), expr)
43        })
44        .collect();
45
46    dialect.create_row_view_ddl(&view_name, &source_table, &columns)
47}
48
49/// Generate DDL for all types in a compiled schema.
50///
51/// Returns one DDL statement per type, separated by blank lines.
52/// Non-scalar-only types (those with no scalar fields) are skipped.
53///
54/// # Arguments
55///
56/// * `dialect` — SQL dialect for type casting and DDL syntax.
57/// * `types` — Slice of type definitions to generate views for.
58/// * `include_types` — Whitelist of type names (empty = all).
59/// * `exclude_types` — Blacklist of type names.
60pub fn generate_all_row_views(
61    dialect: &dyn SqlDialect,
62    types: &[TypeDefinition],
63    include_types: &[String],
64    exclude_types: &[String],
65) -> String {
66    let mut ddl_parts = Vec::new();
67
68    for td in types {
69        let name: &str = td.name.as_ref();
70        if !include_types.is_empty() && !include_types.iter().any(|t| t == name) {
71            continue;
72        }
73        if exclude_types.iter().any(|t| t == name) {
74            continue;
75        }
76
77        let has_scalars = td.fields.iter().any(|f| f.field_type.is_scalar());
78        if !has_scalars {
79            continue;
80        }
81
82        ddl_parts.push(generate_row_view_sql(dialect, td));
83    }
84
85    ddl_parts.join("\n\n")
86}
87
88/// A field name is safe to interpolate into a row-view JSONB extraction path only
89/// if it is a plain SQL identifier (`[A-Za-z0-9_]`, non-empty, ≤128 chars).
90fn is_safe_field_name(name: &str) -> bool {
91    !name.is_empty()
92        && name.len() <= 128
93        && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
94}
95
96#[cfg(test)]
97mod field_name_tests {
98    use super::is_safe_field_name;
99
100    #[test]
101    fn is_safe_field_name_accepts_identifiers_rejects_injection() {
102        assert!(is_safe_field_name("email"));
103        assert!(is_safe_field_name("created_at"));
104        assert!(is_safe_field_name("_private"));
105
106        assert!(!is_safe_field_name(""));
107        assert!(!is_safe_field_name("evil'; DROP VIEW vr_user; --"));
108        assert!(!is_safe_field_name("a'b"));
109        assert!(!is_safe_field_name("with space"));
110        assert!(!is_safe_field_name("dotted.path"));
111    }
112}