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        .filter(|f| f.field_type.is_scalar())
34        .map(|f| {
35            let col_type = graphql_to_row_view_type(&f.field_type.to_graphql_string());
36            let expr =
37                dialect.row_view_column_expr(&type_def.jsonb_column, f.name.as_ref(), &col_type);
38            (f.name.to_string(), expr)
39        })
40        .collect();
41
42    dialect.create_row_view_ddl(&view_name, &source_table, &columns)
43}
44
45/// Generate DDL for all types in a compiled schema.
46///
47/// Returns one DDL statement per type, separated by blank lines.
48/// Non-scalar-only types (those with no scalar fields) are skipped.
49///
50/// # Arguments
51///
52/// * `dialect` — SQL dialect for type casting and DDL syntax.
53/// * `types` — Slice of type definitions to generate views for.
54/// * `include_types` — Whitelist of type names (empty = all).
55/// * `exclude_types` — Blacklist of type names.
56pub fn generate_all_row_views(
57    dialect: &dyn SqlDialect,
58    types: &[TypeDefinition],
59    include_types: &[String],
60    exclude_types: &[String],
61) -> String {
62    let mut ddl_parts = Vec::new();
63
64    for td in types {
65        let name: &str = td.name.as_ref();
66        if !include_types.is_empty() && !include_types.iter().any(|t| t == name) {
67            continue;
68        }
69        if exclude_types.iter().any(|t| t == name) {
70            continue;
71        }
72
73        let has_scalars = td.fields.iter().any(|f| f.field_type.is_scalar());
74        if !has_scalars {
75            continue;
76        }
77
78        ddl_parts.push(generate_row_view_sql(dialect, td));
79    }
80
81    ddl_parts.join("\n\n")
82}
83
84#[cfg(test)]
85mod tests {
86    use fraiseql_core::{
87        db::dialect::{MySqlDialect, PostgresDialect, SqlServerDialect, SqliteDialect},
88        schema::{FieldDefinition, FieldDenyPolicy, FieldType},
89    };
90
91    use super::*;
92
93    fn make_user_type() -> TypeDefinition {
94        TypeDefinition {
95            name:                "user".into(),
96            sql_source:          "user".into(),
97            jsonb_column:        "data".to_string(),
98            fields:              vec![
99                make_field("id", FieldType::Id, false),
100                make_field("name", FieldType::String, false),
101                make_field("email", FieldType::String, true),
102                make_field("created_at", FieldType::DateTime, false),
103            ],
104            description:         None,
105            sql_projection_hint: None,
106            implements:          vec![],
107            requires_role:       None,
108            is_error:            false,
109            relay:               false,
110            relationships:       Vec::new(),
111        }
112    }
113
114    fn make_field(name: &str, ft: FieldType, nullable: bool) -> FieldDefinition {
115        FieldDefinition {
116            name: name.into(),
117            field_type: ft,
118            nullable,
119            description: None,
120            default_value: None,
121            vector_config: None,
122            alias: None,
123            deprecation: None,
124            requires_scope: None,
125            on_deny: FieldDenyPolicy::default(),
126            encryption: None,
127        }
128    }
129
130    // ── PostgreSQL ──────────────────────────────────────────────────────
131
132    #[test]
133    fn test_postgres_row_view_ddl() {
134        let td = make_user_type();
135        let ddl = generate_row_view_sql(&PostgresDialect, &td);
136
137        assert!(ddl.contains("CREATE OR REPLACE VIEW \"vr_user\""));
138        assert!(ddl.contains("FROM \"tb_user\""));
139        assert!(ddl.contains("(data->>'id')::uuid AS \"id\""));
140        assert!(ddl.contains("(data->>'name')::text AS \"name\""));
141        assert!(ddl.contains("(data->>'email')::text AS \"email\""));
142        assert!(ddl.contains("(data->>'created_at')::timestamptz AS \"created_at\""));
143    }
144
145    // ── MySQL ───────────────────────────────────────────────────────────
146
147    #[test]
148    fn test_mysql_row_view_ddl() {
149        let td = make_user_type();
150        let ddl = generate_row_view_sql(&MySqlDialect, &td);
151
152        assert!(ddl.contains("CREATE OR REPLACE VIEW `vr_user`"));
153        assert!(ddl.contains("FROM `tb_user`"));
154        assert!(ddl.contains("CAST(JSON_UNQUOTE(JSON_EXTRACT(data, '$.id')) AS CHAR) AS `id`"));
155        assert!(ddl.contains("CAST(JSON_UNQUOTE(JSON_EXTRACT(data, '$.name')) AS CHAR) AS `name`"));
156    }
157
158    // ── SQLite ──────────────────────────────────────────────────────────
159
160    #[test]
161    fn test_sqlite_row_view_ddl() {
162        let td = make_user_type();
163        let ddl = generate_row_view_sql(&SqliteDialect, &td);
164
165        assert!(ddl.contains("DROP VIEW IF EXISTS \"vr_user\""));
166        assert!(ddl.contains("CREATE VIEW \"vr_user\""));
167        assert!(ddl.contains("FROM \"tb_user\""));
168        assert!(ddl.contains("CAST(json_extract(data, '$.id') AS TEXT) AS \"id\""));
169        assert!(ddl.contains("CAST(json_extract(data, '$.name') AS TEXT) AS \"name\""));
170    }
171
172    // ── SQL Server ──────────────────────────────────────────────────────
173
174    #[test]
175    fn test_sqlserver_row_view_ddl() {
176        let td = make_user_type();
177        let ddl = generate_row_view_sql(&SqlServerDialect, &td);
178
179        assert!(ddl.contains("CREATE OR ALTER VIEW [vr_user]"));
180        assert!(ddl.contains("FROM [tb_user]"));
181        assert!(ddl.contains("CAST(JSON_VALUE(data, '$.id') AS UNIQUEIDENTIFIER) AS [id]"));
182        assert!(ddl.contains("CAST(JSON_VALUE(data, '$.name') AS NVARCHAR(MAX)) AS [name]"));
183    }
184
185    // ── Scalar filter ───────────────────────────────────────────────────
186
187    #[test]
188    fn test_non_scalar_fields_excluded() {
189        let td = TypeDefinition {
190            name:                "post".into(),
191            sql_source:          "post".into(),
192            jsonb_column:        "data".to_string(),
193            fields:              vec![
194                make_field("id", FieldType::Id, false),
195                make_field("title", FieldType::String, false),
196                // Object reference — should be excluded from vr_* view
197                make_field("author", FieldType::Object("User".to_string()), false),
198                // List — should be excluded
199                make_field("tags", FieldType::List(Box::new(FieldType::String)), false),
200            ],
201            description:         None,
202            sql_projection_hint: None,
203            implements:          vec![],
204            requires_role:       None,
205            is_error:            false,
206            relay:               false,
207            relationships:       Vec::new(),
208        };
209
210        let ddl = generate_row_view_sql(&PostgresDialect, &td);
211
212        // Scalar fields included
213        assert!(ddl.contains("\"id\""));
214        assert!(ddl.contains("\"title\""));
215        // Non-scalar fields excluded
216        assert!(!ddl.contains("\"author\""));
217        assert!(!ddl.contains("\"tags\""));
218    }
219
220    // ── Custom jsonb_column ─────────────────────────────────────────────
221
222    #[test]
223    fn test_custom_jsonb_column() {
224        let mut td = make_user_type();
225        td.jsonb_column = "payload".to_string();
226
227        let ddl = generate_row_view_sql(&PostgresDialect, &td);
228
229        assert!(ddl.contains("(payload->>'id')::uuid"));
230        assert!(!ddl.contains("data"));
231    }
232
233    // ── generate_all_row_views ──────────────────────────────────────────
234
235    #[test]
236    fn test_generate_all_with_exclude() {
237        let types = vec![
238            make_user_type(),
239            TypeDefinition {
240                name:                "secret".into(),
241                sql_source:          "secret".into(),
242                jsonb_column:        "data".to_string(),
243                fields:              vec![make_field("id", FieldType::Id, false)],
244                description:         None,
245                sql_projection_hint: None,
246                implements:          vec![],
247                requires_role:       None,
248                is_error:            false,
249                relay:               false,
250                relationships:       Vec::new(),
251            },
252        ];
253
254        let ddl = generate_all_row_views(&PostgresDialect, &types, &[], &["secret".to_string()]);
255
256        assert!(ddl.contains("vr_user"));
257        assert!(!ddl.contains("vr_secret"));
258    }
259
260    // ── Source table is tb_*, not v_* ────────────────────────────────────
261
262    #[test]
263    fn test_source_table_is_command_side() {
264        let td = make_user_type();
265        let ddl = generate_row_view_sql(&PostgresDialect, &td);
266
267        // Must reference tb_user (command-side), not v_user (JSON-shaped view)
268        assert!(ddl.contains("tb_user"));
269        assert!(!ddl.contains("v_user"));
270    }
271}