Skip to main content

fraiseql_cli/commands/
generate_proto.rs

1//! `generate-proto` command: produce service.proto, vr_migrations.sql, and descriptor.binpb.
2
3use std::{fs, path::Path};
4
5use anyhow::Context;
6use fraiseql_core::{
7    db::dialect::{MySqlDialect, PostgresDialect, SqlDialect, SqlServerDialect, SqliteDialect},
8    schema::CompiledSchema,
9};
10
11use crate::{
12    codegen::{proto_gen, row_views},
13    output::OutputFormatter,
14};
15
16/// Resolve a SQL dialect from its CLI string name.
17///
18/// # Errors
19///
20/// Returns an error if the dialect name is not recognised.
21pub(crate) fn resolve_dialect(name: &str) -> anyhow::Result<Box<dyn SqlDialect>> {
22    match name {
23        "postgres" | "postgresql" => Ok(Box::new(PostgresDialect)),
24        "mysql" => Ok(Box::new(MySqlDialect)),
25        "sqlite" => Ok(Box::new(SqliteDialect)),
26        "sqlserver" => Ok(Box::new(SqlServerDialect)),
27        other => Err(anyhow::anyhow!(
28            "Unknown dialect '{other}'. Expected: postgres, mysql, sqlite, sqlserver"
29        )),
30    }
31}
32
33/// Build a serialized `FileDescriptorSet` from the generated proto source.
34///
35/// Constructs a [`prost_types::FileDescriptorProto`] with package, syntax,
36/// and dependency information, then encodes it into a binary protobuf that
37/// gRPC reflection servers can serve at runtime.
38///
39/// # Errors
40///
41/// Returns an error if protobuf encoding fails.
42pub(crate) fn build_file_descriptor_set(
43    proto_source: &str,
44    package: &str,
45) -> anyhow::Result<Vec<u8>> {
46    use prost::Message;
47    use prost_types::{FileDescriptorProto, FileDescriptorSet};
48
49    let mut file = FileDescriptorProto {
50        name: Some("service.proto".to_string()),
51        package: Some(package.to_string()),
52        syntax: Some("proto3".to_string()),
53        ..FileDescriptorProto::default()
54    };
55
56    // Add well-known type dependencies detected in the proto source.
57    if proto_source.contains("google/protobuf/timestamp.proto") {
58        file.dependency.push("google/protobuf/timestamp.proto".to_string());
59    }
60    if proto_source.contains("google/protobuf/struct.proto") {
61        file.dependency.push("google/protobuf/struct.proto".to_string());
62    }
63
64    let fds = FileDescriptorSet { file: vec![file] };
65
66    let mut buf = Vec::with_capacity(fds.encoded_len());
67    fds.encode(&mut buf).context("Failed to encode FileDescriptorSet")?;
68    Ok(buf)
69}
70
71/// Run the `generate-proto` command.
72///
73/// Reads a compiled schema and writes three files to the output directory:
74/// - `service.proto` — proto3 service definition
75/// - `vr_migrations.sql` — row-shaped view DDL for the gRPC transport
76/// - `descriptor.binpb` — serialized `FileDescriptorSet` for gRPC reflection
77///
78/// # Errors
79///
80/// Returns an error if the schema cannot be loaded, the dialect is unknown,
81/// or the output files cannot be written.
82pub fn run(
83    schema_path: &str,
84    output_dir: &str,
85    package: &str,
86    dialect_name: &str,
87    formatter: &OutputFormatter,
88) -> anyhow::Result<()> {
89    formatter.progress("Loading compiled schema...");
90
91    let content = fs::read_to_string(schema_path).context("Failed to read compiled schema file")?;
92    let schema: CompiledSchema =
93        serde_json::from_str(&content).context("Failed to parse compiled schema JSON")?;
94
95    let dialect = resolve_dialect(dialect_name)?;
96
97    // Resolve include/exclude from grpc config if present
98    let (include_types, exclude_types) = schema
99        .grpc_config
100        .as_ref()
101        .map(|g| (g.include_types.clone(), g.exclude_types.clone()))
102        .unwrap_or_default();
103
104    // 1. Generate service.proto
105    formatter.progress("Generating service.proto...");
106    let proto_source =
107        proto_gen::generate_proto_file(&schema, package, &include_types, &exclude_types);
108
109    // 2. Generate vr_migrations.sql
110    formatter.progress("Generating vr_migrations.sql...");
111    let row_view_ddl = row_views::generate_all_row_views(
112        dialect.as_ref(),
113        &schema.types,
114        &include_types,
115        &exclude_types,
116    );
117
118    // 3. Build descriptor.binpb
119    formatter.progress("Building descriptor.binpb...");
120    let descriptor_bytes = build_file_descriptor_set(&proto_source, package)?;
121
122    // Write output files
123    let out_path = Path::new(output_dir);
124    fs::create_dir_all(out_path).context("Failed to create output directory")?;
125
126    let proto_path = out_path.join("service.proto");
127    fs::write(&proto_path, &proto_source)
128        .with_context(|| format!("Failed to write {}", proto_path.display()))?;
129
130    let sql_path = out_path.join("vr_migrations.sql");
131    fs::write(&sql_path, &row_view_ddl)
132        .with_context(|| format!("Failed to write {}", sql_path.display()))?;
133
134    let desc_path = out_path.join("descriptor.binpb");
135    fs::write(&desc_path, &descriptor_bytes)
136        .with_context(|| format!("Failed to write {}", desc_path.display()))?;
137
138    formatter.section("Generated files");
139    formatter.progress(&format!("  {}", proto_path.display()));
140    formatter.progress(&format!("  {}", sql_path.display()));
141    formatter.progress(&format!("  {}", desc_path.display()));
142
143    Ok(())
144}