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 = "\
463EXAMPLES:
464 fraiseql run
465 fraiseql run fraiseql.toml --database postgres://localhost/mydb
466 fraiseql run --port 3000 --watch
467 fraiseql run schema.json --introspection
468
469TOML CONFIG:
470 [server]
471 host = \"127.0.0.1\"
472 port = 9000
473
474 [server.cors]
475 origins = [\"https://app.example.com\"]
476
477 [database]
478 url = \"${DATABASE_URL}\"
479 pool_min = 2
480 pool_max = 20")]
481 Run {
482 #[arg(value_name = "INPUT")]
484 input: Option<String>,
485
486 #[arg(short, long, value_name = "DATABASE_URL")]
488 database: Option<String>,
489
490 #[arg(short, long, value_name = "PORT")]
492 port: Option<u16>,
493
494 #[arg(long, value_name = "HOST")]
496 bind: Option<String>,
497
498 #[arg(short, long)]
500 watch: bool,
501
502 #[arg(long)]
504 introspection: bool,
505 },
506
507 #[command(hide = true)] Serve {
510 #[arg(value_name = "SCHEMA")]
512 schema: String,
513
514 #[arg(short, long, default_value = "8080")]
516 port: u16,
517 },
518}
519
520#[derive(Subcommand)]
521enum ValidateCommands {
522 Facts {
524 #[arg(short, long, value_name = "SCHEMA")]
526 schema: String,
527
528 #[arg(short, long, value_name = "DATABASE_URL")]
530 database: String,
531 },
532}
533
534#[derive(Subcommand)]
535enum FederationCommands {
536 Graph {
538 #[arg(value_name = "SCHEMA")]
540 schema: String,
541
542 #[arg(short, long, value_name = "FORMAT", default_value = "json")]
544 format: String,
545 },
546}
547
548#[derive(Subcommand)]
549enum IntrospectCommands {
550 Facts {
552 #[arg(short, long, value_name = "DATABASE_URL")]
554 database: String,
555
556 #[arg(short, long, value_name = "FORMAT", default_value = "python")]
558 format: String,
559 },
560}
561
562#[derive(Subcommand)]
563enum MigrateCommands {
564 Up {
566 #[arg(short, long, value_name = "DATABASE_URL")]
568 database: Option<String>,
569
570 #[arg(long, value_name = "DIR")]
572 dir: Option<String>,
573 },
574
575 Down {
577 #[arg(short, long, value_name = "DATABASE_URL")]
579 database: Option<String>,
580
581 #[arg(long, value_name = "DIR")]
583 dir: Option<String>,
584
585 #[arg(long, default_value = "1")]
587 steps: u32,
588 },
589
590 Status {
592 #[arg(short, long, value_name = "DATABASE_URL")]
594 database: Option<String>,
595
596 #[arg(long, value_name = "DIR")]
598 dir: Option<String>,
599 },
600
601 Create {
603 #[arg(value_name = "NAME")]
605 name: String,
606
607 #[arg(long, value_name = "DIR")]
609 dir: Option<String>,
610 },
611}
612
613pub async fn run() {
615 use crate::{commands, output};
616
617 if let Some(code) = handle_introspection_flags() {
618 process::exit(code);
619 }
620
621 let cli = Cli::parse();
622
623 init_logging(cli.verbose, cli.debug);
624
625 let result = match cli.command {
626 Commands::Compile {
627 input,
628 types,
629 schema_dir,
630 type_files,
631 query_files,
632 mutation_files,
633 output,
634 check,
635 database,
636 } => {
637 commands::compile::run(
638 &input,
639 types.as_deref(),
640 schema_dir.as_deref(),
641 type_files,
642 query_files,
643 mutation_files,
644 &output,
645 check,
646 database.as_deref(),
647 )
648 .await
649 },
650
651 Commands::Extract {
652 input,
653 language,
654 recursive,
655 output,
656 } => commands::extract::run(&input, language.as_deref(), recursive, &output),
657
658 Commands::Explain { query } => match commands::explain::run(&query) {
659 Ok(result) => {
660 println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
661 Ok(())
662 },
663 Err(e) => Err(e),
664 },
665
666 Commands::Cost { query } => match commands::cost::run(&query) {
667 Ok(result) => {
668 println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
669 Ok(())
670 },
671 Err(e) => Err(e),
672 },
673
674 Commands::Analyze { schema } => match commands::analyze::run(&schema) {
675 Ok(result) => {
676 println!("{}", output::OutputFormatter::new(cli.json, cli.quiet).format(&result));
677 Ok(())
678 },
679 Err(e) => Err(e),
680 },
681
682 Commands::DependencyGraph { schema, format } => {
683 match commands::dependency_graph::GraphFormat::from_str(&format) {
684 Ok(fmt) => match commands::dependency_graph::run(&schema, fmt) {
685 Ok(result) => {
686 println!(
687 "{}",
688 output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
689 );
690 Ok(())
691 },
692 Err(e) => Err(e),
693 },
694 Err(e) => Err(anyhow::anyhow!(e)),
695 }
696 },
697
698 Commands::Lint {
699 schema,
700 federation: _,
701 cost: _,
702 cache: _,
703 auth: _,
704 compilation: _,
705 fail_on_critical,
706 fail_on_warning,
707 verbose: _,
708 } => {
709 let opts = commands::lint::LintOptions {
710 fail_on_critical,
711 fail_on_warning,
712 };
713 match commands::lint::run(&schema, opts) {
714 Ok(result) => {
715 println!(
716 "{}",
717 output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
718 );
719 Ok(())
720 },
721 Err(e) => Err(e),
722 }
723 },
724
725 Commands::Federation { command } => match command {
726 FederationCommands::Graph { schema, format } => {
727 match commands::federation::graph::GraphFormat::from_str(&format) {
728 Ok(fmt) => match commands::federation::graph::run(&schema, fmt) {
729 Ok(result) => {
730 println!(
731 "{}",
732 output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
733 );
734 Ok(())
735 },
736 Err(e) => Err(e),
737 },
738 Err(e) => Err(anyhow::anyhow!(e)),
739 }
740 },
741 },
742
743 Commands::GenerateViews {
744 schema,
745 entity,
746 view,
747 refresh_strategy,
748 output,
749 include_composition_views,
750 include_monitoring,
751 validate,
752 gen_verbose,
753 } => match commands::generate_views::RefreshStrategy::parse(&refresh_strategy) {
754 Ok(refresh_strat) => {
755 let config = commands::generate_views::GenerateViewsConfig {
756 schema_path: schema,
757 entity,
758 view,
759 refresh_strategy: refresh_strat,
760 output,
761 include_composition_views,
762 include_monitoring,
763 validate_only: validate,
764 verbose: cli.verbose || gen_verbose,
765 };
766
767 commands::generate_views::run(config)
768 },
769 Err(e) => Err(anyhow::anyhow!(e)),
770 },
771
772 Commands::Validate {
773 command,
774 input,
775 check_cycles,
776 check_unused,
777 strict,
778 types,
779 } => match command {
780 Some(ValidateCommands::Facts { schema, database }) => {
781 commands::validate_facts::run(std::path::Path::new(&schema), &database).await
782 },
783 None => match input {
784 Some(input) => {
785 let opts = commands::validate::ValidateOptions {
786 check_cycles,
787 check_unused,
788 strict,
789 filter_types: types,
790 };
791 match commands::validate::run_with_options(&input, opts) {
792 Ok(result) => {
793 println!(
794 "{}",
795 output::OutputFormatter::new(cli.json, cli.quiet).format(&result)
796 );
797 if result.status == "validation-failed" {
798 Err(anyhow::anyhow!("Validation failed"))
799 } else {
800 Ok(())
801 }
802 },
803 Err(e) => Err(e),
804 }
805 },
806 None => Err(anyhow::anyhow!("INPUT required when no subcommand provided")),
807 },
808 },
809
810 Commands::Introspect { command } => match command {
811 IntrospectCommands::Facts { database, format } => {
812 match commands::introspect_facts::OutputFormat::parse(&format) {
813 Ok(fmt) => commands::introspect_facts::run(&database, fmt).await,
814 Err(e) => Err(anyhow::anyhow!(e)),
815 }
816 },
817 },
818
819 Commands::Generate {
820 input,
821 language,
822 output,
823 } => match commands::init::Language::from_str(&language) {
824 Ok(lang) => commands::generate::run(&input, lang, output.as_deref()),
825 Err(e) => Err(anyhow::anyhow!(e)),
826 },
827
828 Commands::Init {
829 project_name,
830 language,
831 database,
832 size,
833 no_git,
834 } => {
835 match (
836 commands::init::Language::from_str(&language),
837 commands::init::Database::from_str(&database),
838 commands::init::ProjectSize::from_str(&size),
839 ) {
840 (Ok(lang), Ok(db), Ok(sz)) => {
841 let config = commands::init::InitConfig {
842 project_name,
843 language: lang,
844 database: db,
845 size: sz,
846 no_git,
847 };
848 commands::init::run(&config)
849 },
850 (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => Err(anyhow::anyhow!(e)),
851 }
852 },
853
854 Commands::Migrate { command } => match command {
855 MigrateCommands::Up { database, dir } => {
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::Up {
861 database_url: url,
862 dir: mig_dir,
863 };
864 commands::migrate::run(&action)
865 },
866 Err(e) => Err(e),
867 }
868 },
869 MigrateCommands::Down {
870 database,
871 dir,
872 steps,
873 } => {
874 let db_url = commands::migrate::resolve_database_url(database.as_deref());
875 match db_url {
876 Ok(url) => {
877 let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
878 let action = commands::migrate::MigrateAction::Down {
879 database_url: url,
880 dir: mig_dir,
881 steps,
882 };
883 commands::migrate::run(&action)
884 },
885 Err(e) => Err(e),
886 }
887 },
888 MigrateCommands::Status { database, dir } => {
889 let db_url = commands::migrate::resolve_database_url(database.as_deref());
890 match db_url {
891 Ok(url) => {
892 let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
893 let action = commands::migrate::MigrateAction::Status {
894 database_url: url,
895 dir: mig_dir,
896 };
897 commands::migrate::run(&action)
898 },
899 Err(e) => Err(e),
900 }
901 },
902 MigrateCommands::Create { name, dir } => {
903 let mig_dir = commands::migrate::resolve_migration_dir(dir.as_deref());
904 let action = commands::migrate::MigrateAction::Create { name, dir: mig_dir };
905 commands::migrate::run(&action)
906 },
907 },
908
909 Commands::Sbom { format, output } => match commands::sbom::SbomFormat::from_str(&format) {
910 Ok(fmt) => commands::sbom::run(fmt, output.as_deref()),
911 Err(e) => Err(anyhow::anyhow!(e)),
912 },
913
914 Commands::Run {
915 input,
916 database,
917 port,
918 bind,
919 watch,
920 introspection,
921 } => {
922 commands::run::run(input.as_deref(), database, port, bind, watch, introspection).await
923 },
924
925 Commands::Serve { schema, port } => commands::serve::run(&schema, port).await,
926 };
927
928 if let Err(e) = result {
929 eprintln!("Error: {e}");
930 if cli.debug {
931 eprintln!("\nDebug info:");
932 eprintln!("{e:?}");
933 }
934 process::exit(1);
935 }
936}
937
938fn init_logging(verbose: bool, debug: bool) {
939 let filter = if debug {
940 "fraiseql=debug,fraiseql_core=debug"
941 } else if verbose {
942 "fraiseql=info,fraiseql_core=info"
943 } else {
944 "fraiseql=warn,fraiseql_core=warn"
945 };
946
947 tracing_subscriber::registry()
948 .with(
949 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| filter.into()),
950 )
951 .with(tracing_subscriber::fmt::layer())
952 .init();
953}
954
955fn handle_introspection_flags() -> Option<i32> {
956 let args: Vec<String> = env::args().collect();
957
958 if args.iter().any(|a| a == "--help-json") {
959 let cmd = Cli::command();
960 let version = env!("CARGO_PKG_VERSION");
961 let help = crate::introspection::extract_cli_help(&cmd, version);
962 let result = crate::output::CommandResult::success(
963 "help",
964 serde_json::to_value(&help).expect("CliHelp is always serializable"),
965 );
966 println!(
967 "{}",
968 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
969 );
970 return Some(0);
971 }
972
973 if args.iter().any(|a| a == "--list-commands") {
974 let cmd = Cli::command();
975 let commands = crate::introspection::list_commands(&cmd);
976 let result = crate::output::CommandResult::success(
977 "list-commands",
978 serde_json::to_value(&commands).expect("command list is always serializable"),
979 );
980 println!(
981 "{}",
982 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
983 );
984 return Some(0);
985 }
986
987 let idx = args.iter().position(|a| a == "--show-output-schema")?;
988 let available = crate::output_schemas::list_schema_commands().join(", ");
989
990 let Some(cmd_name) = args.get(idx + 1) else {
991 let result = crate::output::CommandResult::error(
992 "show-output-schema",
993 &format!("Missing command name. Available: {available}"),
994 "MISSING_ARGUMENT",
995 );
996 println!(
997 "{}",
998 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
999 );
1000 return Some(1);
1001 };
1002
1003 if let Some(schema) = crate::output_schemas::get_output_schema(cmd_name) {
1004 let result = crate::output::CommandResult::success(
1005 "show-output-schema",
1006 serde_json::to_value(&schema).expect("output schema is always serializable"),
1007 );
1008 println!(
1009 "{}",
1010 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
1011 );
1012 return Some(0);
1013 }
1014
1015 let result = crate::output::CommandResult::error(
1016 "show-output-schema",
1017 &format!("Unknown command: {cmd_name}. Available: {available}"),
1018 "UNKNOWN_COMMAND",
1019 );
1020 println!(
1021 "{}",
1022 serde_json::to_string_pretty(&result).expect("CommandResult is always serializable")
1023 );
1024 Some(1)
1025}