1#![warn(clippy::all)]
4#![warn(clippy::pedantic)]
5#![allow(clippy::format_push_string)]
6#![allow(clippy::option_if_let_else)]
7#![allow(clippy::needless_pass_by_value)]
8#![allow(clippy::must_use_candidate)]
9#![allow(clippy::module_name_repetitions)]
10#![allow(clippy::missing_errors_doc)]
11#![allow(clippy::doc_markdown)]
12#![allow(clippy::too_many_lines)]
13#![allow(clippy::unnecessary_wraps)]
14#![allow(clippy::match_same_arms)]
15#![allow(clippy::similar_names)]
16#![allow(clippy::struct_excessive_bools)]
17#![allow(clippy::derive_partial_eq_without_eq)]
18#![allow(clippy::missing_const_for_fn)] pub mod commands;
21pub mod config;
22pub mod introspection;
23pub mod output;
24pub mod output_schemas;
25pub mod schema;
26
27use std::{env, process, str::FromStr};
28
29use clap::{CommandFactory, Parser, Subcommand};
30use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
31
32const EXIT_CODES_HELP: &str = "\
34EXIT CODES:
35 0 Success - Command completed successfully
36 1 Error - Command failed with an error
37 2 Validation failed - Schema or input validation failed";
38
39#[derive(Parser)]
41#[command(name = "fraiseql")]
42#[command(author, version, about, long_about = None)]
43#[command(propagate_version = true)]
44#[command(after_help = EXIT_CODES_HELP)]
45struct Cli {
46 #[arg(short, long, global = true)]
48 verbose: bool,
49
50 #[arg(short, long, global = true)]
52 debug: bool,
53
54 #[arg(long, global = true)]
56 json: bool,
57
58 #[arg(short, long, global = true)]
60 quiet: bool,
61
62 #[command(subcommand)]
63 command: Commands,
64}
65
66#[derive(Subcommand)]
67enum Commands {
68 #[command(after_help = "\
75EXAMPLES:
76 fraiseql compile fraiseql.toml
77 fraiseql compile fraiseql.toml --types types.json
78 fraiseql compile schema.json -o schema.compiled.json
79 fraiseql compile fraiseql.toml --check")]
80 Compile {
81 #[arg(value_name = "INPUT")]
83 input: String,
84
85 #[arg(long, value_name = "TYPES")]
87 types: Option<String>,
88
89 #[arg(long, value_name = "DIR")]
91 schema_dir: Option<String>,
92
93 #[arg(long = "type-file", value_name = "FILE")]
96 type_files: Vec<String>,
97
98 #[arg(long = "query-file", value_name = "FILE")]
100 query_files: Vec<String>,
101
102 #[arg(long = "mutation-file", value_name = "FILE")]
104 mutation_files: Vec<String>,
105
106 #[arg(
108 short,
109 long,
110 value_name = "OUTPUT",
111 default_value = "schema.compiled.json"
112 )]
113 output: String,
114
115 #[arg(long)]
117 check: bool,
118
119 #[arg(long, value_name = "DATABASE_URL")]
122 database: Option<String>,
123 },
124
125 #[command(after_help = "\
130EXAMPLES:
131 fraiseql extract schema/schema.py
132 fraiseql extract schema/ --recursive
133 fraiseql extract schema.rs --language rust -o schema.json")]
134 Extract {
135 #[arg(value_name = "INPUT")]
137 input: Vec<String>,
138
139 #[arg(short, long)]
142 language: Option<String>,
143
144 #[arg(short, long)]
146 recursive: bool,
147
148 #[arg(short, long, default_value = "schema.json")]
150 output: String,
151 },
152
153 #[command(after_help = "\
157EXAMPLES:
158 fraiseql explain '{ users { id name } }'
159 fraiseql explain '{ user(id: 1) { posts { title } } }' --json")]
160 Explain {
161 #[arg(value_name = "QUERY")]
163 query: String,
164 },
165
166 #[command(after_help = "\
170EXAMPLES:
171 fraiseql cost '{ users { id name } }'
172 fraiseql cost '{ deeply { nested { query { here } } } }' --json")]
173 Cost {
174 #[arg(value_name = "QUERY")]
176 query: String,
177 },
178
179 #[command(after_help = "\
184EXAMPLES:
185 fraiseql analyze schema.compiled.json
186 fraiseql analyze schema.compiled.json --json")]
187 Analyze {
188 #[arg(value_name = "SCHEMA")]
190 schema: String,
191 },
192
193 #[command(after_help = "\
198EXAMPLES:
199 fraiseql dependency-graph schema.compiled.json
200 fraiseql dependency-graph schema.compiled.json -f dot > graph.dot
201 fraiseql dependency-graph schema.compiled.json -f mermaid
202 fraiseql dependency-graph schema.compiled.json --json")]
203 DependencyGraph {
204 #[arg(value_name = "SCHEMA")]
206 schema: String,
207
208 #[arg(short, long, value_name = "FORMAT", default_value = "json")]
210 format: String,
211 },
212
213 #[command(after_help = "\
217EXAMPLES:
218 fraiseql federation graph schema.compiled.json
219 fraiseql federation graph schema.compiled.json -f dot
220 fraiseql federation graph schema.compiled.json -f mermaid")]
221 Federation {
222 #[command(subcommand)]
224 command: FederationCommands,
225 },
226
227 #[command(after_help = "\
232EXAMPLES:
233 fraiseql lint schema.json
234 fraiseql lint schema.compiled.json --federation
235 fraiseql lint schema.json --fail-on-critical
236 fraiseql lint schema.json --json")]
237 Lint {
238 #[arg(value_name = "SCHEMA")]
240 schema: String,
241
242 #[arg(long)]
244 federation: bool,
245
246 #[arg(long)]
248 cost: bool,
249
250 #[arg(long)]
252 cache: bool,
253
254 #[arg(long)]
256 auth: bool,
257
258 #[arg(long)]
260 compilation: bool,
261
262 #[arg(long)]
264 fail_on_critical: bool,
265
266 #[arg(long)]
268 fail_on_warning: bool,
269
270 #[arg(long)]
272 verbose: bool,
273 },
274
275 #[command(after_help = "\
277EXAMPLES:
278 fraiseql generate-views -s schema.json -e User --view va_users
279 fraiseql generate-views -s schema.json -e Order --view tv_orders --refresh-strategy scheduled")]
280 GenerateViews {
281 #[arg(short, long, value_name = "SCHEMA")]
283 schema: String,
284
285 #[arg(short, long, value_name = "NAME")]
287 entity: String,
288
289 #[arg(long, value_name = "NAME")]
291 view: String,
292
293 #[arg(long, value_name = "STRATEGY", default_value = "trigger-based")]
295 refresh_strategy: String,
296
297 #[arg(short, long, value_name = "PATH")]
299 output: Option<String>,
300
301 #[arg(long, default_value = "true")]
303 include_composition_views: bool,
304
305 #[arg(long, default_value = "true")]
307 include_monitoring: bool,
308
309 #[arg(long)]
311 validate: bool,
312
313 #[arg(long, action = clap::ArgAction::SetTrue)]
315 gen_verbose: bool,
316 },
317
318 #[command(after_help = "\
326EXAMPLES:
327 fraiseql validate schema.json
328 fraiseql validate schema.json --check-unused
329 fraiseql validate schema.json --strict
330 fraiseql validate facts -s schema.json -d postgres://localhost/db")]
331 Validate {
332 #[command(subcommand)]
333 command: Option<ValidateCommands>,
334
335 #[arg(value_name = "INPUT")]
337 input: Option<String>,
338
339 #[arg(long, default_value = "true")]
341 check_cycles: bool,
342
343 #[arg(long)]
345 check_unused: bool,
346
347 #[arg(long)]
349 strict: bool,
350
351 #[arg(long, value_name = "TYPES", value_delimiter = ',')]
353 types: Vec<String>,
354 },
355
356 #[command(after_help = "\
358EXAMPLES:
359 fraiseql introspect facts -d postgres://localhost/db
360 fraiseql introspect facts -d postgres://localhost/db -f json")]
361 Introspect {
362 #[command(subcommand)]
363 command: IntrospectCommands,
364 },
365
366 #[command(after_help = "\
371EXAMPLES:
372 fraiseql generate schema.json --language python
373 fraiseql generate schema.json --language rust -o schema.rs
374 fraiseql generate schema.json --language typescript")]
375 Generate {
376 #[arg(value_name = "INPUT")]
378 input: String,
379
380 #[arg(short, long)]
382 language: String,
383
384 #[arg(short, long)]
386 output: Option<String>,
387 },
388
389 #[command(after_help = "\
394EXAMPLES:
395 fraiseql init my-app
396 fraiseql init my-app --language typescript --database postgres
397 fraiseql init my-app --size xs --no-git")]
398 Init {
399 #[arg(value_name = "PROJECT_NAME")]
401 project_name: String,
402
403 #[arg(short, long, default_value = "python")]
405 language: String,
406
407 #[arg(long, default_value = "postgres")]
409 database: String,
410
411 #[arg(long, default_value = "s")]
413 size: String,
414
415 #[arg(long)]
417 no_git: bool,
418 },
419
420 #[command(after_help = "\
425EXAMPLES:
426 fraiseql migrate up --database postgres://localhost/mydb
427 fraiseql migrate down --steps 1
428 fraiseql migrate status
429 fraiseql migrate create add_posts_table")]
430 Migrate {
431 #[command(subcommand)]
432 command: MigrateCommands,
433 },
434
435 #[command(after_help = "\
439EXAMPLES:
440 fraiseql sbom
441 fraiseql sbom --format spdx
442 fraiseql sbom --format cyclonedx --output sbom.json")]
443 Sbom {
444 #[arg(short, long, default_value = "cyclonedx")]
446 format: String,
447
448 #[arg(short, long, value_name = "FILE")]
450 output: Option<String>,
451 },
452
453 #[command(after_help = "\
458EXAMPLES:
459 fraiseql run
460 fraiseql run fraiseql.toml --database postgres://localhost/mydb
461 fraiseql run --port 3000 --watch
462 fraiseql run schema.json --introspection")]
463 Run {
464 #[arg(value_name = "INPUT")]
466 input: Option<String>,
467
468 #[arg(short, long, value_name = "DATABASE_URL")]
470 database: Option<String>,
471
472 #[arg(short, long, default_value = "8080")]
474 port: u16,
475
476 #[arg(long, default_value = "0.0.0.0")]
478 bind: String,
479
480 #[arg(short, long)]
482 watch: bool,
483
484 #[arg(long)]
486 introspection: bool,
487 },
488
489 #[command(hide = true)] Serve {
492 #[arg(value_name = "SCHEMA")]
494 schema: String,
495
496 #[arg(short, long, default_value = "8080")]
498 port: u16,
499 },
500}
501
502#[derive(Subcommand)]
503enum ValidateCommands {
504 Facts {
506 #[arg(short, long, value_name = "SCHEMA")]
508 schema: String,
509
510 #[arg(short, long, value_name = "DATABASE_URL")]
512 database: String,
513 },
514}
515
516#[derive(Subcommand)]
517enum FederationCommands {
518 Graph {
520 #[arg(value_name = "SCHEMA")]
522 schema: String,
523
524 #[arg(short, long, value_name = "FORMAT", default_value = "json")]
526 format: String,
527 },
528}
529
530#[derive(Subcommand)]
531enum IntrospectCommands {
532 Facts {
534 #[arg(short, long, value_name = "DATABASE_URL")]
536 database: String,
537
538 #[arg(short, long, value_name = "FORMAT", default_value = "python")]
540 format: String,
541 },
542}
543
544#[derive(Subcommand)]
545enum MigrateCommands {
546 Up {
548 #[arg(short, long, value_name = "DATABASE_URL")]
550 database: Option<String>,
551
552 #[arg(long, value_name = "DIR")]
554 dir: Option<String>,
555 },
556
557 Down {
559 #[arg(short, long, value_name = "DATABASE_URL")]
561 database: Option<String>,
562
563 #[arg(long, value_name = "DIR")]
565 dir: Option<String>,
566
567 #[arg(long, default_value = "1")]
569 steps: u32,
570 },
571
572 Status {
574 #[arg(short, long, value_name = "DATABASE_URL")]
576 database: Option<String>,
577
578 #[arg(long, value_name = "DIR")]
580 dir: Option<String>,
581 },
582
583 Create {
585 #[arg(value_name = "NAME")]
587 name: String,
588
589 #[arg(long, value_name = "DIR")]
591 dir: Option<String>,
592 },
593}
594
595pub async fn run() {
597 use crate::{commands, output};
598
599 if let Some(code) = handle_introspection_flags() {
600 process::exit(code);
601 }
602
603 let cli = Cli::parse();
604
605 init_logging(cli.verbose, cli.debug);
606
607 let result = match cli.command {
608 Commands::Compile {
609 input,
610 types,
611 schema_dir,
612 type_files,
613 query_files,
614 mutation_files,
615 output,
616 check,
617 database,
618 } => {
619 commands::compile::run(
620 &input,
621 types.as_deref(),
622 schema_dir.as_deref(),
623 type_files,
624 query_files,
625 mutation_files,
626 &output,
627 check,
628 database.as_deref(),
629 )
630 .await
631 },
632
633 Commands::Extract {
634 input,
635 language,
636 recursive,
637 output,
638 } => commands::extract::run(&input, language.as_deref(), recursive, &output),
639
640 Commands::Explain { query } => match commands::explain::run(&query) {
641 Ok(result) => {
642 println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
643 Ok(())
644 },
645 Err(e) => Err(e),
646 },
647
648 Commands::Cost { query } => match commands::cost::run(&query) {
649 Ok(result) => {
650 println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
651 Ok(())
652 },
653 Err(e) => Err(e),
654 },
655
656 Commands::Analyze { schema } => match commands::analyze::run(&schema) {
657 Ok(result) => {
658 println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
659 Ok(())
660 },
661 Err(e) => Err(e),
662 },
663
664 Commands::DependencyGraph { schema, format } => {
665 match commands::dependency_graph::GraphFormat::from_str(&format) {
666 Ok(fmt) => match commands::dependency_graph::run(&schema, fmt) {
667 Ok(result) => {
668 println!(
669 "{}",
670 output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
671 );
672 Ok(())
673 },
674 Err(e) => Err(e),
675 },
676 Err(e) => Err(anyhow::anyhow!(e)),
677 }
678 },
679
680 Commands::Lint {
681 schema,
682 federation: _,
683 cost: _,
684 cache: _,
685 auth: _,
686 compilation: _,
687 fail_on_critical,
688 fail_on_warning,
689 verbose: _,
690 } => {
691 let opts = commands::lint::LintOptions {
692 fail_on_critical,
693 fail_on_warning,
694 };
695 match commands::lint::run(&schema, opts) {
696 Ok(result) => {
697 println!(
698 "{}",
699 output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
700 );
701 Ok(())
702 },
703 Err(e) => Err(e),
704 }
705 },
706
707 Commands::Federation { command } => match command {
708 FederationCommands::Graph { schema, format } => {
709 match commands::federation::graph::GraphFormat::from_str(&format) {
710 Ok(fmt) => match commands::federation::graph::run(&schema, fmt) {
711 Ok(result) => {
712 println!(
713 "{}",
714 output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
715 );
716 Ok(())
717 },
718 Err(e) => Err(e),
719 },
720 Err(e) => Err(anyhow::anyhow!(e)),
721 }
722 },
723 },
724
725 Commands::GenerateViews {
726 schema,
727 entity,
728 view,
729 refresh_strategy,
730 output,
731 include_composition_views,
732 include_monitoring,
733 validate,
734 gen_verbose,
735 } => match commands::generate_views::RefreshStrategy::parse(&refresh_strategy) {
736 Ok(refresh_strat) => {
737 let config = commands::generate_views::GenerateViewsConfig {
738 schema_path: schema,
739 entity,
740 view,
741 refresh_strategy: refresh_strat,
742 output,
743 include_composition_views,
744 include_monitoring,
745 validate_only: validate,
746 verbose: cli.verbose || gen_verbose,
747 };
748
749 commands::generate_views::run(config)
750 },
751 Err(e) => Err(anyhow::anyhow!(e)),
752 },
753
754 Commands::Validate {
755 command,
756 input,
757 check_cycles,
758 check_unused,
759 strict,
760 types,
761 } => match command {
762 Some(ValidateCommands::Facts { schema, database }) => {
763 commands::validate_facts::run(std::path::Path::new(&schema), &database).await
764 },
765 None => match input {
766 Some(input) => {
767 let opts = commands::validate::ValidateOptions {
768 check_cycles,
769 check_unused,
770 strict,
771 filter_types: types,
772 };
773 match commands::validate::run_with_options(&input, opts) {
774 Ok(result) => {
775 println!(
776 "{}",
777 output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
778 );
779 if result.status == "validation-failed" {
780 Err(anyhow::anyhow!("Validation failed"))
781 } else {
782 Ok(())
783 }
784 },
785 Err(e) => Err(e),
786 }
787 },
788 None => Err(anyhow::anyhow!("INPUT required when no subcommand provided")),
789 },
790 },
791
792 Commands::Introspect { command } => match command {
793 IntrospectCommands::Facts { database, format } => {
794 match commands::introspect_facts::OutputFormat::parse(&format) {
795 Ok(fmt) => commands::introspect_facts::run(&database, fmt).await,
796 Err(e) => Err(anyhow::anyhow!(e)),
797 }
798 },
799 },
800
801 Commands::Generate {
802 input,
803 language,
804 output,
805 } => match commands::init::Language::from_str(&language) {
806 Ok(lang) => commands::generate::run(&input, lang, output.as_deref()),
807 Err(e) => Err(anyhow::anyhow!(e)),
808 },
809
810 Commands::Init {
811 project_name,
812 language,
813 database,
814 size,
815 no_git,
816 } => {
817 match (
818 commands::init::Language::from_str(&language),
819 commands::init::Database::from_str(&database),
820 commands::init::ProjectSize::from_str(&size),
821 ) {
822 (Ok(lang), Ok(db), Ok(sz)) => {
823 let config = commands::init::InitConfig {
824 project_name,
825 language: lang,
826 database: db,
827 size: sz,
828 no_git,
829 };
830 commands::init::run(&config)
831 },
832 (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(anyhow::anyhow!(e)),
833 }
834 },
835
836 Commands::Migrate { command } => match command {
837 MigrateCommands::Up { database, dir } => {
838 let db_url = commands::migrate::resolve_database_url(database.as_deref());
839 match db_url {
840 Ok(url) => {
841 let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
842 let action = commands::migrate::MigrateAction::Up {
843 database_url: url,
844 dir: mig_dir,
845 };
846 commands::migrate::run(&action)
847 },
848 Err(e) => Err(e),
849 }
850 },
851 MigrateCommands::Down {
852 database,
853 dir,
854 steps,
855 } => {
856 let db_url = commands::migrate::resolve_database_url(database.as_deref());
857 match db_url {
858 Ok(url) => {
859 let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
860 let action = commands::migrate::MigrateAction::Down {
861 database_url: url,
862 dir: mig_dir,
863 steps,
864 };
865 commands::migrate::run(&action)
866 },
867 Err(e) => Err(e),
868 }
869 },
870 MigrateCommands::Status { database, dir } => {
871 let db_url = commands::migrate::resolve_database_url(database.as_deref());
872 match db_url {
873 Ok(url) => {
874 let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
875 let action = commands::migrate::MigrateAction::Status {
876 database_url: url,
877 dir: mig_dir,
878 };
879 commands::migrate::run(&action)
880 },
881 Err(e) => Err(e),
882 }
883 },
884 MigrateCommands::Create { name, dir } => {
885 let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
886 let action = commands::migrate::MigrateAction::Create { name, dir: mig_dir };
887 commands::migrate::run(&action)
888 },
889 },
890
891 Commands::Sbom { format, output } => match commands::sbom::SbomFormat::from_str(&format) {
892 Ok(fmt) => commands::sbom::run(fmt, output.as_deref()),
893 Err(e) => Err(anyhow::anyhow!(e)),
894 },
895
896 Commands::Run {
897 input,
898 database,
899 port,
900 bind,
901 watch,
902 introspection,
903 } => commands::run::run(input.as_deref(), database, port, bind, watch, introspection).await,
904
905 Commands::Serve { schema, port } => commands::serve::run(&schema, port).await,
906 };
907
908 if let Err(e) = result {
909 eprintln!("Error: {e}");
910 if cli.debug {
911 eprintln!("\nDebug info:");
912 eprintln!("{e:?}");
913 }
914 process::exit(1);
915 }
916}
917
918fn init_logging(verbose: bool, debug: bool) {
919 let filter = if debug {
920 "fraiseql=debug,fraiseql_core=debug"
921 } else if verbose {
922 "fraiseql=info,fraiseql_core=info"
923 } else {
924 "fraiseql=warn,fraiseql_core=warn"
925 };
926
927 tracing_subscriber::registry()
928 .with(
929 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()),
930 )
931 .with(tracing_subscriber::fmt::layer())
932 .init();
933}
934
935fn handle_introspection_flags() -> Option<i32> {
936 let args: Vec<String> = env::args().collect();
937
938 if args.iter().any(|a| a == "--help-json") {
939 let cmd = Cli::command();
940 let version = env!("CARGO_PKG_VERSION");
941 let help = crate::introspection::extract_cli_help(&cmd, version);
942 let result = crate::output::CommandResult::success(
943 "help",
944 serde_json::to_value(&help).expect("CliHelp is always serializable"),
945 );
946 println!(
947 "{}",
948 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
949 );
950 return Some(0);
951 }
952
953 if args.iter().any(|a| a == "--list-commands") {
954 let cmd = Cli::command();
955 let commands = crate::introspection::list_commands(&cmd);
956 let result = crate::output::CommandResult::success(
957 "list-commands",
958 serde_json::to_value(&commands).expect("command list is always serializable"),
959 );
960 println!(
961 "{}",
962 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
963 );
964 return Some(0);
965 }
966
967 let idx = args.iter().position(|a| a == "--show-output-schema")?;
968 let available = crate::output_schemas::list_schema_commands().join(", ");
969
970 let Some(cmd_name) = args.get(idx + 1) else {
971 let result = crate::output::CommandResult::error(
972 "show-output-schema",
973 &format!("Missing command name. Available: {available}"),
974 "MISSING_ARGUMENT",
975 );
976 println!(
977 "{}",
978 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
979 );
980 return Some(1);
981 };
982
983 if let Some(schema) = crate::output_schemas::get_output_schema(cmd_name) {
984 let result = crate::output::CommandResult::success(
985 "show-output-schema",
986 serde_json::to_value(&schema).expect("output schema is always serializable"),
987 );
988 println!(
989 "{}",
990 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
991 );
992 return Some(0);
993 }
994
995 let result = crate::output::CommandResult::error(
996 "show-output-schema",
997 &format!("Unknown command: {cmd_name}. Available: {available}"),
998 "UNKNOWN_COMMAND",
999 );
1000 println!(
1001 "{}",
1002 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
1003 );
1004 Some(1)
1005}