1use std::io::{Read, Write};
2
3use crate::limits::{MAX_PARAMS, MAX_SQL_BYTES};
4use crate::secret_config::{SecretConfigRef, resolve_config_secret};
5use crate::types::{ContainerConfig, Permission, QueryOptions, SessionConfig, SshConfig};
6use agent_first_data::{LogFilters, OutputFormat, cli_parse_log_filters, cli_parse_output};
7use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
8use serde_json::{Value, json};
9use std::collections::{BTreeMap, btree_map::Entry};
10
11const STARTUP_ENV_KEYS: &[&str] = &[
12 "AFPSQL_DSN_SECRET",
13 "AFPSQL_CONNINFO_SECRET",
14 "AFPSQL_HOST",
15 "AFPSQL_PORT",
16 "AFPSQL_USER",
17 "AFPSQL_DBNAME",
18 "AFPSQL_PASSWORD_SECRET",
19 "AFPSQL_SSH",
20 "AFPSQL_SSH_LOCAL_HOST",
21 "AFPSQL_SSH_LOCAL_PORT",
22 "AFPSQL_SSH_REMOTE_SOCKET",
23 "AFPSQL_SSH_SUDO_USER",
24 "AFPSQL_CONTAINER",
25 "AFPSQL_CONTAINER_DRIVER",
26 "AFPSQL_CONTAINER_RUNTIME",
27 "AFPSQL_CONTAINER_USER",
28 "AFPSQL_CONTAINER_NAMESPACE",
29 "AFPSQL_CONTAINER_CONTEXT",
30 "AFPSQL_CONTAINER_COMPOSE_FILE",
31 "AFPSQL_CONTAINER_COMPOSE_PROJECT",
32 "AFPSQL_CONTAINER_POD_CONTAINER",
33 "PGHOST",
34 "PGPORT",
35 "PGUSER",
36 "PGDATABASE",
37 "PGPASSWORD",
38 "PGSSLMODE",
39];
40
41pub enum Mode {
42 Cli(CliRequest),
43 Pipe(PipeInit),
44 PsqlAdmin(PsqlAdminRequest),
45 SkillAdmin(SkillAdminRequest),
46 PsqlUnsupported(PsqlUnsupportedRequest),
47}
48
49pub struct PipeInit {
50 pub output: OutputFormat,
51 pub session: SessionConfig,
52 pub log: LogFilters,
53 pub startup_args: Value,
54 pub startup_env: Value,
55 pub startup_requested: bool,
56}
57
58#[derive(Debug, Clone)]
59pub struct PsqlAdminRequest {
60 pub action: PsqlAdminAction,
61 pub output: OutputFormat,
62}
63
64#[derive(Debug, Clone)]
65pub enum PsqlAdminAction {
66 Status { bin_dir: Option<String> },
67 Install { bin_dir: Option<String> },
68 Uninstall { bin_dir: Option<String> },
69}
70
71#[derive(Debug, Clone)]
72pub struct SkillAdminRequest {
73 pub action: SkillAdminAction,
74 pub output: OutputFormat,
75}
76
77#[derive(Debug, Clone)]
78pub enum SkillAdminAction {
79 Status(SkillAdminOptions),
80 Install(SkillAdminOptions),
81 Uninstall(SkillAdminOptions),
82}
83
84#[derive(Debug, Clone)]
85pub struct SkillAdminOptions {
86 pub agent: SkillAgentSelection,
87 pub scope: SkillScope,
88 pub skills_dir: Option<String>,
89 pub force: bool,
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
93pub enum SkillAgentSelection {
94 All,
96 Codex,
98 #[value(name = "claude-code", alias = "claude")]
100 ClaudeCode,
101 Opencode,
103 Hermes,
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
108pub enum SkillScope {
109 Personal,
111 Workspace,
113}
114
115pub struct CliRequest {
116 pub sql: String,
117 pub params: Vec<Value>,
118 pub options: QueryOptions,
119 pub session: SessionConfig,
120 pub output: OutputFormat,
121 pub log: LogFilters,
122 pub startup_args: Value,
123 pub startup_env: Value,
124 pub startup_requested: bool,
125 pub dry_run: bool,
126 pub psql_mode: bool,
127}
128
129pub struct PsqlUnsupportedRequest {
130 pub reason: String,
131}
132
133#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
134enum RuntimeMode {
135 Cli,
136 Pipe,
137 #[value(name = "psql")]
138 Psql,
139}
140
141#[derive(Subcommand)]
142enum AfdCommand {
143 Psql(PsqlCommand),
145 Skill(SkillCommand),
147 Inspect(InspectCommand),
149}
150
151#[derive(Args)]
152struct InspectCommand {
153 #[command(subcommand)]
154 action: InspectAction,
155}
156
157#[derive(Subcommand)]
158enum InspectAction {
159 Databases(InspectDatabasesArgs),
161 Database,
163 Schemas,
165 Schema(InspectSchemaArgs),
167 Snapshot(InspectSchemaArgs),
169 Tables(InspectTablesArgs),
171 Views(InspectViewsArgs),
173 Indexes(InspectIndexesArgs),
175 Table(InspectTableArgs),
177}
178
179#[derive(Args)]
180struct InspectDatabasesArgs {
181 #[arg(long = "all")]
183 all: bool,
184}
185
186#[derive(Args)]
187struct InspectTablesArgs {
188 #[arg(long = "schema", default_value = "public")]
190 schema: String,
191 #[arg(long = "like")]
193 like: Option<String>,
194}
195
196#[derive(Args)]
197struct InspectSchemaArgs {
198 #[arg(long = "schema", default_value = "public")]
200 schema: String,
201 #[arg(long = "like")]
203 like: Option<String>,
204}
205
206#[derive(Args)]
207struct InspectViewsArgs {
208 #[arg(long = "schema", default_value = "public")]
210 schema: String,
211 #[arg(long = "like")]
213 like: Option<String>,
214}
215
216#[derive(Args)]
217struct InspectIndexesArgs {
218 #[arg(long = "schema", default_value = "public")]
220 schema: String,
221 #[arg(long = "table")]
223 table: Option<String>,
224 #[arg(long = "stats")]
226 stats: bool,
227}
228
229#[derive(Args)]
230struct InspectTableArgs {
231 name: String,
233 #[arg(long = "full")]
235 full: bool,
236}
237
238#[derive(Args)]
239struct PsqlCommand {
240 #[command(subcommand)]
241 action: PsqlCliAction,
242}
243
244#[derive(Subcommand)]
245enum PsqlCliAction {
246 Status(PsqlPathArgs),
248 Install(PsqlPathArgs),
250 Uninstall(PsqlPathArgs),
252}
253
254#[derive(Args)]
255struct PsqlPathArgs {
256 #[arg(long = "bin-dir")]
258 bin_dir: Option<String>,
259}
260
261#[derive(Args)]
262struct SkillCommand {
263 #[command(subcommand)]
264 action: SkillCliAction,
265}
266
267#[derive(Subcommand)]
268enum SkillCliAction {
269 Status(SkillTargetArgs),
271 Install(SkillWriteArgs),
273 Uninstall(SkillWriteArgs),
275}
276
277#[derive(Args)]
278struct SkillTargetArgs {
279 #[arg(long = "agent", value_enum, default_value_t = SkillAgentSelection::All)]
281 agent: SkillAgentSelection,
282 #[arg(long = "scope", value_enum, default_value_t = SkillScope::Personal)]
284 scope: SkillScope,
285 #[arg(long = "skills-dir")]
287 skills_dir: Option<String>,
288}
289
290#[derive(Args)]
291struct SkillWriteArgs {
292 #[command(flatten)]
293 target: SkillTargetArgs,
294 #[arg(long)]
296 force: bool,
297}
298
299#[doc = r#"`afpsql` gives agents a reliable PostgreSQL contract: structured stdout
300events, first-class SSH/container transports, explicit write permissions,
301stable pipe sessions, and machine-readable failures.
302
303### Interface Policy
304
305- default mode is canonical agent-first CLI
306- `--mode psql` is argument translation only; runtime output stays JSONL
307- stdout carries protocol events; stderr is not a protocol channel
308- native CLI and pipe mode default to read-only transactions; writes require permission
309- SSH/container transports keep afpsql local instead of running human `psql` across boundaries
310
311### Modes
312
313- default (native CLI): one SQL action per process — a single agent step
314- `--mode pipe`: a long-lived JSONL session with `id` correlation and named sessions for multi-step work
315- `--mode psql`: run existing `psql` scripts unchanged — flags are translated, runtime output stays JSONL
316
317### Query Sources and Parameters
318
319- use `--sql` for inline SQL or `--sql-file` for a file
320- use repeatable `--param N=value` for positional binds
321- placeholder count is validated from prepared-statement metadata, not by SQL text scanning
322
323### Connection Sources
324
325- `--dsn-secret` for a PostgreSQL URI
326- `--conninfo-secret` for libpq-style conninfo
327- or discrete `--host`, `--port`, `--user`, `--dbname`, `--password-secret`
328- every `*-secret` flag has a `*-secret-env` partner that reads the value from a named environment variable
329- every secret slot also has a `*-secret-config FILE DOT_PATH` source for JSON, TOML, YAML, or dotenv
330- add `--ssh user@server` when PostgreSQL is reachable only from the server boundary
331- add `--container TARGET` when PostgreSQL is reachable only from inside a container boundary
332- use named container scope flags instead of raw driver option passthrough
333- use `--container-driver docker|podman|nerdctl|compose|kubectl` for the exec syntax
334- combine `--ssh user@server --container TARGET` for containers on an SSH host
335- agent-first environment fallbacks: `AFPSQL_*`
336- PostgreSQL environment fallbacks: `PGHOST`, `PGPORT`, `PGUSER`, `PGDATABASE`, `PGPASSWORD`, `PGSSLMODE`
337
338### Result Shaping
339
340- default mode buffers a bounded inline result
341- use `--stream-rows` for large result sets, with `--batch-rows` and `--batch-bytes` to tune chunk size
342- `--output json|yaml|plain` changes rendering only, not the runtime schema
343
344### Examples
345
346```text
347afpsql --sql "select now() as now_rfc3339"
348afpsql --sql-file ./query.sql
349afpsql --sql 'select * from users where id = $1' --param 1=123
350afpsql --dsn-secret-env DATABASE_URL --sql "select 1"
351afpsql --dsn-secret-config config.yaml database.url --sql "select 1"
352afpsql --ssh user@server --host 127.0.0.1 --port 5432 --user app --dbname appdb --sql "select 1"
353afpsql --container pg-container --dsn-secret-env DATABASE_URL --sql "select 1"
354afpsql --ssh root@server --container app --host host.container.internal --port 5432 --user app --dbname appdb --sql "select 1"
355afpsql --mode psql -h 127.0.0.1 -p 5432 -U app -d appdb -c "select 1"
356afpsql --sql "select * from big_table" --stream-rows --batch-rows 1000
357afpsql --mode pipe
358afpsql psql status
359afpsql psql install
360afpsql skill status
361afpsql skill install
362```
363
364### Exit Codes
365
366- `0`: query completed successfully
367- `1`: SQL error or runtime error
368- `2`: invalid CLI arguments
369"#]
370#[derive(Parser)]
371#[command(
372 name = env!("DISPLAY_NAME"),
373 bin_name = "afpsql",
374 version,
375 verbatim_doc_comment,
376 about = env!("CARGO_PKG_DESCRIPTION"),
377)]
378pub struct AfdCli {
379 #[arg(long, allow_hyphen_values = true, help_heading = "Query")]
381 sql: Option<String>,
382 #[arg(long = "sql-file", allow_hyphen_values = true, help_heading = "Query")]
384 sql_file: Option<String>,
385 #[arg(long = "param", help_heading = "Query")]
387 param: Vec<String>,
388 #[arg(long = "stream-rows", help_heading = "Query")]
390 stream_rows: bool,
391 #[arg(long = "batch-rows", help_heading = "Query")]
393 batch_rows: Option<usize>,
394 #[arg(long = "batch-bytes", help_heading = "Query")]
396 batch_bytes: Option<usize>,
397 #[arg(long = "statement-timeout-ms", help_heading = "Query")]
399 statement_timeout_ms: Option<u64>,
400 #[arg(long = "lock-timeout-ms", help_heading = "Query")]
402 lock_timeout_ms: Option<u64>,
403 #[arg(long = "inline-max-rows", help_heading = "Query")]
405 inline_max_rows: Option<usize>,
406 #[arg(long = "inline-max-bytes", help_heading = "Query")]
408 inline_max_bytes: Option<usize>,
409 #[arg(long = "permission", value_enum, help_heading = "Query")]
412 permission: Option<Permission>,
413 #[arg(long, help_heading = "Query")]
415 dry_run: bool,
416 #[arg(
419 long = "explain",
420 help_heading = "Query",
421 conflicts_with = "explain_analyze"
422 )]
423 explain: bool,
424 #[arg(long = "explain-analyze", help_heading = "Query")]
427 explain_analyze: bool,
428
429 #[arg(
431 long = "dsn-secret",
432 global = true,
433 help_heading = "Connection",
434 conflicts_with_all = ["dsn_secret_env", "dsn_secret_config"]
435 )]
436 dsn_secret: Option<String>,
437 #[arg(
439 long = "dsn-secret-env",
440 global = true,
441 help_heading = "Connection",
442 conflicts_with = "dsn_secret_config"
443 )]
444 dsn_secret_env: Option<String>,
445 #[arg(
447 long = "dsn-secret-config",
448 global = true,
449 help_heading = "Connection",
450 value_names = ["FILE", "DOT_PATH"],
451 num_args = 2
452 )]
453 dsn_secret_config: Option<Vec<String>>,
454 #[arg(
456 long = "conninfo-secret",
457 global = true,
458 help_heading = "Connection",
459 conflicts_with_all = ["conninfo_secret_env", "conninfo_secret_config"]
460 )]
461 conninfo_secret: Option<String>,
462 #[arg(
464 long = "conninfo-secret-env",
465 global = true,
466 help_heading = "Connection",
467 conflicts_with = "conninfo_secret_config"
468 )]
469 conninfo_secret_env: Option<String>,
470 #[arg(
472 long = "conninfo-secret-config",
473 global = true,
474 help_heading = "Connection",
475 value_names = ["FILE", "DOT_PATH"],
476 num_args = 2
477 )]
478 conninfo_secret_config: Option<Vec<String>>,
479 #[arg(long, global = true, help_heading = "Connection")]
481 host: Option<String>,
482 #[arg(long, global = true, help_heading = "Connection")]
484 port: Option<u16>,
485 #[arg(long, global = true, help_heading = "Connection")]
487 user: Option<String>,
488 #[arg(long, global = true, help_heading = "Connection")]
490 dbname: Option<String>,
491 #[arg(
493 long = "password-secret",
494 global = true,
495 help_heading = "Connection",
496 conflicts_with_all = ["password_secret_env", "password_secret_config"]
497 )]
498 password_secret: Option<String>,
499 #[arg(
501 long = "password-secret-env",
502 global = true,
503 help_heading = "Connection",
504 conflicts_with = "password_secret_config"
505 )]
506 password_secret_env: Option<String>,
507 #[arg(
509 long = "password-secret-config",
510 global = true,
511 help_heading = "Connection",
512 value_names = ["FILE", "DOT_PATH"],
513 num_args = 2
514 )]
515 password_secret_config: Option<Vec<String>>,
516 #[arg(long = "ssh", global = true, help_heading = "SSH Transport")]
518 ssh: Option<String>,
519 #[arg(long = "ssh-via", global = true, help_heading = "SSH Transport")]
521 ssh_via: Vec<String>,
522 #[arg(long = "ssh-option", global = true, help_heading = "SSH Transport")]
524 ssh_options: Vec<String>,
525 #[arg(long = "ssh-local-host", global = true, help_heading = "SSH Transport")]
527 ssh_local_host: Option<String>,
528 #[arg(long = "ssh-local-port", global = true, help_heading = "SSH Transport")]
530 ssh_local_port: Option<u16>,
531 #[arg(
533 long = "ssh-remote-socket",
534 global = true,
535 help_heading = "SSH Transport"
536 )]
537 ssh_remote_socket: Option<String>,
538 #[arg(long = "ssh-sudo-user", global = true, help_heading = "SSH Transport")]
540 ssh_sudo_user: Option<String>,
541
542 #[arg(
544 long = "container",
545 global = true,
546 help_heading = "Container Transport"
547 )]
548 container: Option<String>,
549 #[arg(
551 long = "container-driver",
552 global = true,
553 help_heading = "Container Transport"
554 )]
555 container_driver: Option<String>,
556 #[arg(
558 long = "container-runtime",
559 global = true,
560 help_heading = "Container Transport"
561 )]
562 container_runtime: Option<String>,
563 #[arg(
565 long = "container-user",
566 global = true,
567 help_heading = "Container Transport"
568 )]
569 container_user: Option<String>,
570 #[arg(
572 long = "container-namespace",
573 global = true,
574 help_heading = "Container Transport"
575 )]
576 container_namespace: Option<String>,
577 #[arg(
579 long = "container-context",
580 global = true,
581 help_heading = "Container Transport"
582 )]
583 container_context: Option<String>,
584 #[arg(
586 long = "container-compose-file",
587 global = true,
588 help_heading = "Container Transport"
589 )]
590 container_compose_files: Vec<String>,
591 #[arg(
593 long = "container-compose-project",
594 global = true,
595 help_heading = "Container Transport"
596 )]
597 container_compose_project: Option<String>,
598 #[arg(
600 long = "container-pod-container",
601 global = true,
602 help_heading = "Container Transport"
603 )]
604 container_pod_container: Option<String>,
605
606 #[arg(
608 long,
609 short = 'o',
610 default_value = "json",
611 global = true,
612 help_heading = "Runtime"
613 )]
614 output: String,
615 #[arg(
617 long = "stdout-file",
618 value_name = "PATH",
619 global = true,
620 help_heading = "Runtime"
621 )]
622 stdout_file: Option<String>,
623 #[arg(
625 long = "stderr-file",
626 value_name = "PATH",
627 global = true,
628 help_heading = "Runtime"
629 )]
630 stderr_file: Option<String>,
631 #[arg(
635 long = "log",
636 value_delimiter = ',',
637 global = true,
638 help_heading = "Runtime"
639 )]
640 log: Vec<String>,
641 #[arg(long, value_enum, default_value_t = RuntimeMode::Cli, help_heading = "Runtime")]
643 mode: RuntimeMode,
644
645 #[command(subcommand)]
646 command: Option<AfdCommand>,
647}
648
649pub fn parse_args(bin_name: &str) -> Result<Mode, String> {
650 let raw: Vec<String> = std::env::args().collect();
651 if is_psql_mode_requested(&raw) {
652 return parse_psql_mode(&raw);
653 }
654 let startup_requested = startup_requested_from_raw(&raw);
655
656 let build = match env!("GIT_SHA") {
657 "unknown" => None,
658 sha => Some(sha),
659 };
660 match agent_first_data::cli_handle_version_or_continue(
661 &raw,
662 &command_for_bin(bin_name),
663 bin_name,
664 Some(env!("DISPLAY_NAME")),
665 env!("CARGO_PKG_VERSION"),
666 build,
667 ) {
668 Ok(Some(version)) => {
669 let _ = write!(std::io::stdout(), "{version}");
670 std::process::exit(0);
671 }
672 Ok(None) => {}
673 Err(err) => {
674 let stdout = std::io::stdout();
675 let mut emitter = agent_first_data::CliEmitter::new(stdout.lock(), OutputFormat::Json);
676 let _ = emitter.emit_error("cli_error", &err.to_string());
677 std::process::exit(2);
678 }
679 }
680
681 match agent_first_data::cli_handle_help_or_continue(
682 &raw,
683 &command_for_bin(bin_name),
684 &agent_first_data::HelpConfig::human_cli_default(),
685 ) {
686 Ok(Some(help)) => {
687 let _ = write!(std::io::stdout(), "{help}");
688 std::process::exit(0);
689 }
690 Ok(None) => {}
691 Err(err) => {
692 let stdout = std::io::stdout();
693 let mut emitter = agent_first_data::CliEmitter::new(stdout.lock(), OutputFormat::Json);
694 let _ = emitter.emit_error("cli_error", &err.to_string());
695 std::process::exit(2);
696 }
697 }
698
699 let cli = match command_for_bin(bin_name)
700 .try_get_matches_from(&raw)
701 .and_then(|matches| AfdCli::from_arg_matches(&matches))
702 {
703 Ok(c) => c,
704 Err(e) => {
705 use clap::error::ErrorKind;
706 if matches!(e.kind(), ErrorKind::DisplayVersion | ErrorKind::DisplayHelp) {
707 let _ = writeln!(std::io::stdout(), "{e}");
708 std::process::exit(0);
709 }
710 return Err(e.to_string());
711 }
712 };
713 let _stream_redirect_args = (&cli.stdout_file, &cli.stderr_file);
714 let output = parse_output(&cli.output)?;
715 let log = parse_log_categories(&cli.log);
716 let dsn_config = SecretConfigRef::from_values("--dsn-secret-config", cli.dsn_secret_config)?;
717 let conninfo_config =
718 SecretConfigRef::from_values("--conninfo-secret-config", cli.conninfo_secret_config)?;
719 let password_config =
720 SecretConfigRef::from_values("--password-secret-config", cli.password_secret_config)?;
721 let connection_sources = connection_source_metadata([
722 (
723 "dsn",
724 cli.dsn_secret.is_some(),
725 cli.dsn_secret_env.as_deref(),
726 dsn_config.as_ref(),
727 ),
728 (
729 "conninfo",
730 cli.conninfo_secret.is_some(),
731 cli.conninfo_secret_env.as_deref(),
732 conninfo_config.as_ref(),
733 ),
734 (
735 "password",
736 cli.password_secret.is_some(),
737 cli.password_secret_env.as_deref(),
738 password_config.as_ref(),
739 ),
740 ]);
741 let dsn_secret = resolve_secret_value(
742 "--dsn-secret",
743 cli.dsn_secret,
744 cli.dsn_secret_env.as_deref(),
745 dsn_config.as_ref(),
746 )?;
747 let password_secret = resolve_secret_value(
748 "--password-secret",
749 cli.password_secret,
750 cli.password_secret_env.as_deref(),
751 password_config.as_ref(),
752 )?;
753 let conninfo_secret = resolve_secret_value(
754 "--conninfo-secret",
755 cli.conninfo_secret,
756 cli.conninfo_secret_env.as_deref(),
757 conninfo_config.as_ref(),
758 )?;
759 let session = SessionConfig {
760 dsn_secret,
761 conninfo_secret,
762 host: cli.host,
763 port: cli.port,
764 user: cli.user,
765 dbname: cli.dbname,
766 password_secret,
767 ssh: SshConfig {
768 destination: cli.ssh.or_else(|| std::env::var("AFPSQL_SSH").ok()),
769 via: if cli.ssh_via.is_empty() {
770 parse_csv_env("AFPSQL_SSH_VIA")
771 } else {
772 cli.ssh_via
773 },
774 options: cli.ssh_options,
775 local_host: cli
776 .ssh_local_host
777 .or_else(|| std::env::var("AFPSQL_SSH_LOCAL_HOST").ok()),
778 local_port: cli.ssh_local_port.or_else(|| {
779 std::env::var("AFPSQL_SSH_LOCAL_PORT")
780 .ok()
781 .and_then(|v| v.parse().ok())
782 }),
783 remote_socket: cli
784 .ssh_remote_socket
785 .or_else(|| std::env::var("AFPSQL_SSH_REMOTE_SOCKET").ok()),
786 sudo_user: cli
787 .ssh_sudo_user
788 .or_else(|| std::env::var("AFPSQL_SSH_SUDO_USER").ok()),
789 },
790 container: ContainerConfig {
791 target: cli
792 .container
793 .or_else(|| std::env::var("AFPSQL_CONTAINER").ok()),
794 driver: cli
795 .container_driver
796 .or_else(|| std::env::var("AFPSQL_CONTAINER_DRIVER").ok()),
797 runtime: cli
798 .container_runtime
799 .or_else(|| std::env::var("AFPSQL_CONTAINER_RUNTIME").ok()),
800 user: cli
801 .container_user
802 .or_else(|| std::env::var("AFPSQL_CONTAINER_USER").ok()),
803 namespace: cli
804 .container_namespace
805 .or_else(|| std::env::var("AFPSQL_CONTAINER_NAMESPACE").ok()),
806 context: cli
807 .container_context
808 .or_else(|| std::env::var("AFPSQL_CONTAINER_CONTEXT").ok()),
809 compose_files: resolve_container_compose_files(cli.container_compose_files),
810 compose_project: cli
811 .container_compose_project
812 .or_else(|| std::env::var("AFPSQL_CONTAINER_COMPOSE_PROJECT").ok()),
813 pod_container: cli
814 .container_pod_container
815 .or_else(|| std::env::var("AFPSQL_CONTAINER_POD_CONTAINER").ok()),
816 },
817 };
818 let mode_name = match cli.mode {
819 RuntimeMode::Cli => "cli",
820 RuntimeMode::Pipe => "pipe",
821 RuntimeMode::Psql => "psql",
822 };
823 let startup_env = startup_env_snapshot();
824
825 if let Some(command) = cli.command {
826 return match command {
827 AfdCommand::Psql(psql) => Ok(Mode::PsqlAdmin(PsqlAdminRequest {
828 action: psql_admin_action(psql.action),
829 output,
830 })),
831 AfdCommand::Skill(skill) => Ok(Mode::SkillAdmin(SkillAdminRequest {
832 action: skill_admin_action(skill.action),
833 output,
834 })),
835 AfdCommand::Inspect(inspect) => {
836 let (sql, params) = build_inspect_sql(inspect.action);
837 let startup_args = with_connection_sources(
838 startup_args(mode_name, Some(&sql), None, params.len()),
839 &connection_sources,
840 );
841 Ok(Mode::Cli(CliRequest {
842 sql,
843 params,
844 options: QueryOptions::default(),
845 session,
846 output,
847 log,
848 startup_args,
849 startup_env,
850 startup_requested,
851 dry_run: false,
852 psql_mode: false,
853 }))
854 }
855 };
856 }
857
858 match cli.mode {
859 RuntimeMode::Pipe => {
860 return Ok(Mode::Pipe(PipeInit {
861 output,
862 session,
863 log: log.clone(),
864 startup_args: with_connection_sources(
865 startup_args(mode_name, None, None, 0),
866 &connection_sources,
867 ),
868 startup_env,
869 startup_requested,
870 }));
871 }
872 RuntimeMode::Cli | RuntimeMode::Psql => {}
873 }
874
875 let startup_sql_file = cli.sql_file.clone();
876 let user_sql = load_sql(cli.sql, cli.sql_file)?;
877 let params = parse_params(&cli.param)?;
878 let sql = if cli.explain {
879 wrap_explain_sql(&user_sql, false)
880 } else if cli.explain_analyze {
881 wrap_explain_sql(&user_sql, true)
882 } else {
883 user_sql
884 };
885 let startup_args = with_connection_sources(
886 startup_args(
887 mode_name,
888 Some(&sql),
889 startup_sql_file.as_deref(),
890 params.len(),
891 ),
892 &connection_sources,
893 );
894
895 let options = QueryOptions {
896 stream_rows: cli.stream_rows,
897 batch_rows: cli.batch_rows,
898 batch_bytes: cli.batch_bytes,
899 statement_timeout_ms: cli.statement_timeout_ms,
900 lock_timeout_ms: cli.lock_timeout_ms,
901 permission: cli.permission,
902 inline_max_rows: cli.inline_max_rows,
903 inline_max_bytes: cli.inline_max_bytes,
904 };
905
906 Ok(Mode::Cli(CliRequest {
907 sql,
908 params,
909 options,
910 session,
911 output,
912 log,
913 startup_args,
914 startup_env,
915 startup_requested,
916 dry_run: cli.dry_run,
917 psql_mode: false,
918 }))
919}
920
921fn command_for_bin(bin_name: &str) -> clap::Command {
922 match bin_name {
923 "afpsql-readonly" => AfdCli::command()
924 .name("afpsql-readonly")
925 .bin_name("afpsql-readonly"),
926 _ => AfdCli::command().name("afpsql").bin_name("afpsql"),
927 }
928}
929
930fn parse_psql_mode(raw: &[String]) -> Result<Mode, String> {
931 let startup_requested = startup_requested_from_raw(raw);
932 let mut state = PsqlModeState::default();
933
934 let mut i = 1usize;
935 while i < raw.len() {
936 let arg = raw[i].as_str();
937 if arg == "--" {
938 i += 1;
939 while i < raw.len() {
940 state.positionals.push(raw[i].clone());
941 i += 1;
942 }
943 break;
944 }
945 if arg.starts_with("--") {
946 parse_psql_long_arg(raw, &mut i, &mut state)?;
947 continue;
948 }
949 if arg.starts_with('-') && arg.len() > 1 {
950 parse_psql_short_arg(raw, &mut i, &mut state)?;
951 continue;
952 }
953 state.positionals.push(raw[i].clone());
954 i += 1;
955 }
956
957 if let Some(reason) = state.interactive_reason {
958 return Ok(Mode::PsqlUnsupported(PsqlUnsupportedRequest { reason }));
959 }
960
961 apply_psql_positionals(&mut state)?;
962 if state.list_databases {
963 state.sql = Some(psql_list_databases_sql());
964 state.sql_file = None;
965 }
966 if state.sql.is_none() && state.sql_file.is_none() {
967 return Ok(Mode::PsqlUnsupported(PsqlUnsupportedRequest {
968 reason: "no -c/--command, -f/--file, or -l/--list was provided".to_string(),
969 }));
970 }
971
972 let connection_sources = connection_source_metadata([
973 (
974 "dsn",
975 state.dsn_secret.is_some(),
976 state.dsn_secret_env.as_deref(),
977 state.dsn_secret_config.as_ref(),
978 ),
979 (
980 "conninfo",
981 state.conninfo_secret.is_some(),
982 state.conninfo_secret_env.as_deref(),
983 state.conninfo_secret_config.as_ref(),
984 ),
985 (
986 "password",
987 state.password_secret.is_some(),
988 state.password_secret_env.as_deref(),
989 state.password_secret_config.as_ref(),
990 ),
991 ]);
992 let dsn_secret = resolve_secret_value(
993 "--dsn-secret",
994 state.dsn_secret,
995 state.dsn_secret_env.as_deref(),
996 state.dsn_secret_config.as_ref(),
997 )?;
998 let password_secret = resolve_secret_value(
999 "--password-secret",
1000 state.password_secret,
1001 state.password_secret_env.as_deref(),
1002 state.password_secret_config.as_ref(),
1003 )?;
1004 let conninfo_secret = resolve_secret_value(
1005 "--conninfo-secret",
1006 state.conninfo_secret,
1007 state.conninfo_secret_env.as_deref(),
1008 state.conninfo_secret_config.as_ref(),
1009 )?;
1010 let session = SessionConfig {
1011 dsn_secret,
1012 conninfo_secret,
1013 host: state.host,
1014 port: state.port,
1015 user: state.user,
1016 dbname: state.dbname,
1017 password_secret,
1018 ssh: SshConfig::default(),
1019 container: ContainerConfig {
1020 target: state
1021 .container
1022 .or_else(|| std::env::var("AFPSQL_CONTAINER").ok()),
1023 driver: state
1024 .container_driver
1025 .or_else(|| std::env::var("AFPSQL_CONTAINER_DRIVER").ok()),
1026 runtime: state
1027 .container_runtime
1028 .or_else(|| std::env::var("AFPSQL_CONTAINER_RUNTIME").ok()),
1029 user: state
1030 .container_user
1031 .or_else(|| std::env::var("AFPSQL_CONTAINER_USER").ok()),
1032 namespace: state
1033 .container_namespace
1034 .or_else(|| std::env::var("AFPSQL_CONTAINER_NAMESPACE").ok()),
1035 context: state
1036 .container_context
1037 .or_else(|| std::env::var("AFPSQL_CONTAINER_CONTEXT").ok()),
1038 compose_files: resolve_container_compose_files(state.container_compose_files),
1039 compose_project: state
1040 .container_compose_project
1041 .or_else(|| std::env::var("AFPSQL_CONTAINER_COMPOSE_PROJECT").ok()),
1042 pod_container: state
1043 .container_pod_container
1044 .or_else(|| std::env::var("AFPSQL_CONTAINER_POD_CONTAINER").ok()),
1045 },
1046 };
1047
1048 let startup_sql_file = state.sql_file.clone();
1049 let sql = load_sql(state.sql, state.sql_file)?;
1050 let params = parse_params(&state.params_kv)?;
1051 let startup_args = with_connection_sources(
1052 psql_startup_args(PsqlStartupArgs {
1053 mode: "psql",
1054 sql: Some(&sql),
1055 sql_file: startup_sql_file,
1056 param_count: params.len(),
1057 }),
1058 &connection_sources,
1059 );
1060 Ok(Mode::Cli(CliRequest {
1061 sql,
1062 params,
1063 options: QueryOptions {
1064 permission: Some(if session.uses_container_transport() {
1065 Permission::ContainerWrite
1066 } else {
1067 Permission::Write
1068 }),
1069 ..Default::default()
1070 },
1071 session,
1072 output: state.output,
1073 log: parse_log_categories(&state.log_entries),
1074 startup_args,
1075 startup_env: startup_env_snapshot(),
1076 startup_requested,
1077 dry_run: false,
1078 psql_mode: true,
1079 }))
1080}
1081
1082struct PsqlModeState {
1083 sql: Option<String>,
1084 sql_file: Option<String>,
1085 host: Option<String>,
1086 port: Option<u16>,
1087 user: Option<String>,
1088 dbname: Option<String>,
1089 dsn_secret: Option<String>,
1090 dsn_secret_env: Option<String>,
1091 dsn_secret_config: Option<SecretConfigRef>,
1092 conninfo_secret: Option<String>,
1093 conninfo_secret_env: Option<String>,
1094 conninfo_secret_config: Option<SecretConfigRef>,
1095 password_secret: Option<String>,
1096 password_secret_env: Option<String>,
1097 password_secret_config: Option<SecretConfigRef>,
1098 container: Option<String>,
1099 container_driver: Option<String>,
1100 container_runtime: Option<String>,
1101 container_user: Option<String>,
1102 container_namespace: Option<String>,
1103 container_context: Option<String>,
1104 container_compose_files: Vec<String>,
1105 container_compose_project: Option<String>,
1106 container_pod_container: Option<String>,
1107 params_kv: Vec<String>,
1108 output: OutputFormat,
1109 log_entries: Vec<String>,
1110 list_databases: bool,
1111 positionals: Vec<String>,
1112 interactive_reason: Option<String>,
1113}
1114
1115impl Default for PsqlModeState {
1116 fn default() -> Self {
1117 Self {
1118 sql: None,
1119 sql_file: None,
1120 host: None,
1121 port: None,
1122 user: None,
1123 dbname: None,
1124 dsn_secret: None,
1125 dsn_secret_env: None,
1126 dsn_secret_config: None,
1127 conninfo_secret: None,
1128 conninfo_secret_env: None,
1129 conninfo_secret_config: None,
1130 password_secret: None,
1131 password_secret_env: None,
1132 password_secret_config: None,
1133 container: None,
1134 container_driver: None,
1135 container_runtime: None,
1136 container_user: None,
1137 container_namespace: None,
1138 container_context: None,
1139 container_compose_files: vec![],
1140 container_compose_project: None,
1141 container_pod_container: None,
1142 params_kv: vec![],
1143 output: OutputFormat::Json,
1144 log_entries: vec![],
1145 list_databases: false,
1146 positionals: vec![],
1147 interactive_reason: None,
1148 }
1149 }
1150}
1151
1152impl PsqlModeState {
1153 fn set_sql(&mut self, sql: String, flag: &str) -> Result<(), String> {
1154 if self.sql.is_some() || self.sql_file.is_some() {
1155 return Err(format!(
1156 "psql mode currently supports only one -c/--command or -f/--file source; repeated source at {flag}"
1157 ));
1158 }
1159 self.sql = Some(sql);
1160 Ok(())
1161 }
1162
1163 fn set_sql_file(&mut self, path: String, flag: &str) -> Result<(), String> {
1164 if self.sql.is_some() || self.sql_file.is_some() {
1165 return Err(format!(
1166 "psql mode currently supports only one -c/--command or -f/--file source; repeated source at {flag}"
1167 ));
1168 }
1169 self.sql_file = Some(path);
1170 Ok(())
1171 }
1172}
1173
1174fn parse_psql_long_arg(
1175 raw: &[String],
1176 i: &mut usize,
1177 state: &mut PsqlModeState,
1178) -> Result<(), String> {
1179 let arg = raw[*i].as_str();
1180 if arg == "--mode" {
1181 let value = take_arg_value(raw, i, "--mode")?;
1182 if value != "psql" {
1183 return Err(format!(
1184 "unsupported psql-mode argument: --mode {value}; only --mode psql is allowed with psql translation"
1185 ));
1186 }
1187 return Ok(());
1188 }
1189 if let Some(value) = arg.strip_prefix("--mode=") {
1190 if value != "psql" {
1191 return Err(format!(
1192 "unsupported psql-mode argument: {arg}; only --mode=psql is allowed with psql translation"
1193 ));
1194 }
1195 *i += 1;
1196 return Ok(());
1197 }
1198
1199 if arg == "--help" || arg.starts_with("--help=") {
1200 emit_psql_mode_help();
1201 std::process::exit(0);
1202 }
1203 if arg == "--version" {
1204 emit_psql_mode_version();
1205 std::process::exit(0);
1206 }
1207
1208 match long_name(arg) {
1209 "--command" => {
1210 let value = take_long_arg_value(raw, i, "--command")?;
1211 state.set_sql(value, "--command")
1212 }
1213 "--file" => {
1214 let value = take_long_arg_value(raw, i, "--file")?;
1215 state.set_sql_file(value, "--file")
1216 }
1217 "--host" => {
1218 state.host = Some(take_long_arg_value(raw, i, "--host")?);
1219 Ok(())
1220 }
1221 "--port" => {
1222 state.port = Some(parse_port(
1223 &take_long_arg_value(raw, i, "--port")?,
1224 "--port",
1225 )?);
1226 Ok(())
1227 }
1228 "--username" | "--user" => {
1229 state.user = Some(take_long_arg_value(raw, i, long_name(arg))?);
1230 Ok(())
1231 }
1232 "--dbname" => {
1233 apply_dbname_value(state, take_long_arg_value(raw, i, "--dbname")?);
1234 Ok(())
1235 }
1236 "--set" | "--variable" => {
1237 let value = take_long_arg_value(raw, i, long_name(arg))?;
1238 add_psql_variable(state, value)
1239 }
1240 "--list" => {
1241 state.list_databases = true;
1242 *i += 1;
1243 Ok(())
1244 }
1245 "--no-password"
1246 | "--no-psqlrc"
1247 | "--no-readline"
1248 | "--quiet"
1249 | "--echo-all"
1250 | "--echo-errors"
1251 | "--echo-queries"
1252 | "--echo-hidden"
1253 | "--no-align"
1254 | "--csv"
1255 | "--html"
1256 | "--tuples-only"
1257 | "--expanded"
1258 | "--field-separator-zero"
1259 | "--record-separator-zero"
1260 | "--single-transaction" => {
1261 *i += 1;
1262 Ok(())
1263 }
1264 "--field-separator" | "--record-separator" | "--pset" | "--table-attr" => {
1265 let _ = take_long_arg_value(raw, i, long_name(arg))?;
1266 Ok(())
1267 }
1268 "--password" => {
1269 state.interactive_reason =
1270 Some("--password/-W requests an interactive password prompt".to_string());
1271 *i += 1;
1272 Ok(())
1273 }
1274 "--single-step" => {
1275 state.interactive_reason =
1276 Some("--single-step/-s requires interactive command confirmation".to_string());
1277 *i += 1;
1278 Ok(())
1279 }
1280 "--single-line" => {
1281 state.interactive_reason =
1282 Some("--single-line/-S is a human-interactive input mode".to_string());
1283 *i += 1;
1284 Ok(())
1285 }
1286 "--dsn-secret" => {
1287 state.dsn_secret = Some(take_long_arg_value(raw, i, "--dsn-secret")?);
1288 Ok(())
1289 }
1290 "--dsn-secret-env" => {
1291 state.dsn_secret_env = Some(take_long_arg_value(raw, i, "--dsn-secret-env")?);
1292 Ok(())
1293 }
1294 "--dsn-secret-config" => {
1295 state.dsn_secret_config = Some(take_secret_config_ref(raw, i, "--dsn-secret-config")?);
1296 Ok(())
1297 }
1298 "--conninfo-secret" => {
1299 state.conninfo_secret = Some(take_long_arg_value(raw, i, "--conninfo-secret")?);
1300 Ok(())
1301 }
1302 "--conninfo-secret-env" => {
1303 state.conninfo_secret_env = Some(take_long_arg_value(raw, i, "--conninfo-secret-env")?);
1304 Ok(())
1305 }
1306 "--conninfo-secret-config" => {
1307 state.conninfo_secret_config =
1308 Some(take_secret_config_ref(raw, i, "--conninfo-secret-config")?);
1309 Ok(())
1310 }
1311 "--password-secret" => {
1312 state.password_secret = Some(take_long_arg_value(raw, i, "--password-secret")?);
1313 Ok(())
1314 }
1315 "--password-secret-env" => {
1316 state.password_secret_env = Some(take_long_arg_value(raw, i, "--password-secret-env")?);
1317 Ok(())
1318 }
1319 "--password-secret-config" => {
1320 state.password_secret_config =
1321 Some(take_secret_config_ref(raw, i, "--password-secret-config")?);
1322 Ok(())
1323 }
1324 "--container" => {
1325 state.container = Some(take_long_arg_value(raw, i, "--container")?);
1326 Ok(())
1327 }
1328 "--container-driver" => {
1329 state.container_driver = Some(take_long_arg_value(raw, i, "--container-driver")?);
1330 Ok(())
1331 }
1332 "--container-runtime" => {
1333 state.container_runtime = Some(take_long_arg_value(raw, i, "--container-runtime")?);
1334 Ok(())
1335 }
1336 "--container-user" => {
1337 state.container_user = Some(take_long_arg_value(raw, i, "--container-user")?);
1338 Ok(())
1339 }
1340 "--container-namespace" => {
1341 state.container_namespace = Some(take_long_arg_value(raw, i, "--container-namespace")?);
1342 Ok(())
1343 }
1344 "--container-context" => {
1345 state.container_context = Some(take_long_arg_value(raw, i, "--container-context")?);
1346 Ok(())
1347 }
1348 "--container-compose-file" => {
1349 state.container_compose_files.push(take_long_arg_value(
1350 raw,
1351 i,
1352 "--container-compose-file",
1353 )?);
1354 Ok(())
1355 }
1356 "--container-compose-project" => {
1357 state.container_compose_project =
1358 Some(take_long_arg_value(raw, i, "--container-compose-project")?);
1359 Ok(())
1360 }
1361 "--container-pod-container" => {
1362 state.container_pod_container =
1363 Some(take_long_arg_value(raw, i, "--container-pod-container")?);
1364 Ok(())
1365 }
1366 "--output" => {
1367 let value = take_long_arg_value(raw, i, "--output")?;
1368 state.output = parse_output(&value)?;
1369 Ok(())
1370 }
1371 "--stdout-file" | "--stderr-file" => {
1372 let _ = take_long_arg_value(raw, i, long_name(arg))?;
1373 Ok(())
1374 }
1375 "--log" => {
1376 let values = take_long_arg_value(raw, i, "--log")?;
1377 add_log_entries(state, &values);
1378 Ok(())
1379 }
1380 _ => Err(format!("unsupported psql-mode argument: {arg}")),
1381 }
1382}
1383
1384fn parse_psql_short_arg(
1385 raw: &[String],
1386 i: &mut usize,
1387 state: &mut PsqlModeState,
1388) -> Result<(), String> {
1389 let arg = raw[*i].as_str();
1390 let mut offset = 1usize;
1391 while offset < arg.len() {
1392 let flag = arg.as_bytes()[offset] as char;
1393 offset += 1;
1394 match flag {
1395 '?' => {
1396 emit_psql_mode_help();
1397 std::process::exit(0);
1398 }
1399 'V' => {
1400 emit_psql_mode_version();
1401 std::process::exit(0);
1402 }
1403 'c' => {
1404 let value = take_short_arg_value(raw, i, arg, offset, "-c")?;
1405 return state.set_sql(value, "-c");
1406 }
1407 'f' => {
1408 let value = take_short_arg_value(raw, i, arg, offset, "-f")?;
1409 return state.set_sql_file(value, "-f");
1410 }
1411 'h' => {
1412 state.host = Some(take_short_arg_value(raw, i, arg, offset, "-h")?);
1413 return Ok(());
1414 }
1415 'p' => {
1416 let value = take_short_arg_value(raw, i, arg, offset, "-p")?;
1417 state.port = Some(parse_port(&value, "-p")?);
1418 return Ok(());
1419 }
1420 'U' => {
1421 state.user = Some(take_short_arg_value(raw, i, arg, offset, "-U")?);
1422 return Ok(());
1423 }
1424 'd' => {
1425 apply_dbname_value(state, take_short_arg_value(raw, i, arg, offset, "-d")?);
1426 return Ok(());
1427 }
1428 'v' => {
1429 let value = take_short_arg_value(raw, i, arg, offset, "-v")?;
1430 return add_psql_variable(state, value);
1431 }
1432 'F' | 'P' | 'R' | 'T' => {
1433 let _ = take_short_arg_value(raw, i, arg, offset, &format!("-{flag}"))?;
1434 return Ok(());
1435 }
1436 'o' => {
1437 let value = take_short_arg_value(raw, i, arg, offset, "-o")?;
1438 state.output = parse_output(&value)?;
1439 return Ok(());
1440 }
1441 'l' => state.list_databases = true,
1442 'W' => {
1443 state.interactive_reason =
1444 Some("--password/-W requests an interactive password prompt".to_string());
1445 }
1446 's' => {
1447 state.interactive_reason =
1448 Some("--single-step/-s requires interactive command confirmation".to_string());
1449 }
1450 'S' => {
1451 state.interactive_reason =
1452 Some("--single-line/-S is a human-interactive input mode".to_string());
1453 }
1454 'a' | 'A' | 'b' | 'e' | 'E' | 'H' | 'n' | 'q' | 't' | 'w' | 'x' | 'X' | 'z' | '0'
1455 | '1' => {}
1456 _ => return Err(format!("unsupported psql-mode argument: -{flag}")),
1457 }
1458 }
1459 *i += 1;
1460 Ok(())
1461}
1462
1463fn long_name(arg: &str) -> &str {
1464 arg.split_once('=').map(|(name, _)| name).unwrap_or(arg)
1465}
1466
1467fn take_arg_value(raw: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
1468 *i += 1;
1469 let value = raw
1470 .get(*i)
1471 .ok_or_else(|| format!("{flag} requires value"))?
1472 .clone();
1473 *i += 1;
1474 Ok(value)
1475}
1476
1477fn take_long_arg_value(raw: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
1478 let arg = raw[*i].as_str();
1479 if let Some((_, value)) = arg.split_once('=') {
1480 *i += 1;
1481 return Ok(value.to_string());
1482 }
1483 take_arg_value(raw, i, flag)
1484}
1485
1486fn take_secret_config_ref(
1487 raw: &[String],
1488 i: &mut usize,
1489 flag: &str,
1490) -> Result<SecretConfigRef, String> {
1491 if raw[*i].contains('=') {
1492 return Err(format!(
1493 "{flag} requires space-separated values: {flag} <FILE> <DOT_PATH>"
1494 ));
1495 }
1496 let file = take_long_arg_value(raw, i, flag)?;
1497 let path = raw
1498 .get(*i)
1499 .filter(|value| !value.starts_with('-'))
1500 .ok_or_else(|| format!("{flag} requires exactly two values: <FILE> <DOT_PATH>"))?
1501 .clone();
1502 *i += 1;
1503 if raw.get(*i).is_some_and(|value| !value.starts_with('-')) {
1504 return Err(format!(
1505 "{flag} accepts exactly two values: <FILE> <DOT_PATH>"
1506 ));
1507 }
1508 if file.is_empty() || path.is_empty() {
1509 return Err(format!(
1510 "{flag} requires exactly two non-empty values: <FILE> <DOT_PATH>"
1511 ));
1512 }
1513 Ok(SecretConfigRef {
1514 file: file.into(),
1515 path,
1516 })
1517}
1518
1519fn take_short_arg_value(
1520 raw: &[String],
1521 i: &mut usize,
1522 arg: &str,
1523 offset: usize,
1524 flag: &str,
1525) -> Result<String, String> {
1526 if offset < arg.len() {
1527 let value = arg[offset..].to_string();
1528 *i += 1;
1529 return Ok(value);
1530 }
1531 take_arg_value(raw, i, flag)
1532}
1533
1534fn parse_port(value: &str, flag: &str) -> Result<u16, String> {
1535 value.parse().map_err(|_| format!("invalid {flag} port"))
1536}
1537
1538fn add_log_entries(state: &mut PsqlModeState, values: &str) {
1539 for part in values.split(',') {
1540 let trimmed = part.trim();
1541 if !trimmed.is_empty() {
1542 state.log_entries.push(trimmed.to_string());
1543 }
1544 }
1545}
1546
1547fn add_psql_variable(state: &mut PsqlModeState, value: String) -> Result<(), String> {
1548 let name = value
1549 .split_once('=')
1550 .map(|(name, _)| name)
1551 .unwrap_or(value.as_str());
1552 if name.parse::<usize>().is_ok() {
1553 if value.contains('=') {
1554 state.params_kv.push(value);
1555 return Ok(());
1556 }
1557 return Err(format!("invalid param '{value}', expected N=value"));
1558 }
1559 if is_psql_behavior_variable(name) {
1560 return Ok(());
1561 }
1562 Err(format!(
1563 "invalid or unsupported psql variable '{name}'; afpsql supports numeric -v N=value bind parameters, not client-side :name interpolation"
1564 ))
1565}
1566
1567fn is_psql_behavior_variable(name: &str) -> bool {
1568 matches!(
1569 name.to_ascii_uppercase().as_str(),
1570 "ON_ERROR_STOP"
1571 | "ON_ERROR_ROLLBACK"
1572 | "QUIET"
1573 | "ECHO"
1574 | "ECHO_HIDDEN"
1575 | "FETCH_COUNT"
1576 | "VERBOSITY"
1577 | "SHOW_CONTEXT"
1578 | "HISTCONTROL"
1579 | "HISTFILE"
1580 | "HISTSIZE"
1581 | "IGNOREEOF"
1582 | "PAGER"
1583 | "COLUMNS"
1584 )
1585}
1586
1587fn apply_psql_positionals(state: &mut PsqlModeState) -> Result<(), String> {
1588 let positionals = std::mem::take(&mut state.positionals);
1589 for value in positionals {
1590 if is_postgres_uri(&value) {
1591 state.dsn_secret = Some(value);
1592 continue;
1593 }
1594 if looks_like_conninfo(&value) {
1595 state.conninfo_secret = Some(value);
1596 continue;
1597 }
1598 if state.dbname.is_none() {
1599 state.dbname = Some(value);
1600 continue;
1601 }
1602 if state.user.is_none() {
1603 state.user = Some(value);
1604 continue;
1605 }
1606 return Err(format!("too many positional psql arguments: {value}"));
1607 }
1608 Ok(())
1609}
1610
1611fn apply_dbname_value(state: &mut PsqlModeState, value: String) {
1612 if is_postgres_uri(&value) {
1613 state.dsn_secret = Some(value);
1614 } else if looks_like_conninfo(&value) {
1615 state.conninfo_secret = Some(value);
1616 } else {
1617 state.dbname = Some(value);
1618 }
1619}
1620
1621fn is_postgres_uri(value: &str) -> bool {
1622 value.starts_with("postgresql://") || value.starts_with("postgres://")
1623}
1624
1625fn looks_like_conninfo(value: &str) -> bool {
1626 value.contains('=')
1627}
1628
1629fn psql_list_databases_sql() -> String {
1630 "select datname as name from pg_catalog.pg_database where datallowconn order by datname"
1631 .to_string()
1632}
1633
1634fn emit_psql_mode_version() {
1635 let _ = writeln!(
1636 std::io::stdout(),
1637 "psql (afpsql wrapper) {}",
1638 env!("CARGO_PKG_VERSION")
1639 );
1640}
1641
1642fn emit_psql_mode_help() {
1643 let _ = writeln!(
1644 std::io::stdout(),
1645 "psql (afpsql wrapper) {}\n\
1646Usage:\n psql [OPTION]... [DBNAME [USERNAME]]\n\n\
1647Supported non-interactive forms:\n -c, --command=SQL\n -f, --file=FILE\n -l, --list\n -h/-p/-U/-d and --host/--port/--username/--dbname\n -v N=value, --set N=value for positional bind parameters\n\n\
1648Output:\n -o, --output=json|yaml|plain changes afpsql rendering\n --stdout-file=FILE redirects stdout bytes to FILE\n --stderr-file=FILE redirects stderr bytes to FILE\n\n\
1649Human-interactive psql modes and psql meta-commands are not supported by this wrapper.",
1650 env!("CARGO_PKG_VERSION")
1651 );
1652}
1653
1654fn psql_admin_action(action: PsqlCliAction) -> PsqlAdminAction {
1655 match action {
1656 PsqlCliAction::Status(args) => PsqlAdminAction::Status {
1657 bin_dir: args.bin_dir,
1658 },
1659 PsqlCliAction::Install(args) => PsqlAdminAction::Install {
1660 bin_dir: args.bin_dir,
1661 },
1662 PsqlCliAction::Uninstall(args) => PsqlAdminAction::Uninstall {
1663 bin_dir: args.bin_dir,
1664 },
1665 }
1666}
1667
1668fn skill_admin_action(action: SkillCliAction) -> SkillAdminAction {
1669 match action {
1670 SkillCliAction::Status(args) => SkillAdminAction::Status(skill_options(args, false)),
1671 SkillCliAction::Install(args) => {
1672 SkillAdminAction::Install(skill_options(args.target, args.force))
1673 }
1674 SkillCliAction::Uninstall(args) => {
1675 SkillAdminAction::Uninstall(skill_options(args.target, args.force))
1676 }
1677 }
1678}
1679
1680fn skill_options(args: SkillTargetArgs, force: bool) -> SkillAdminOptions {
1681 SkillAdminOptions {
1682 agent: args.agent,
1683 scope: args.scope,
1684 skills_dir: args.skills_dir,
1685 force,
1686 }
1687}
1688
1689fn is_psql_mode_requested(raw: &[String]) -> bool {
1690 let mut i = 1usize;
1691 while i < raw.len() {
1692 let arg = raw[i].as_str();
1693 if arg == "--" {
1694 break;
1695 }
1696 if arg == "--mode" {
1697 if let Some(v) = raw.get(i + 1) {
1698 return v == "psql";
1699 }
1700 return false;
1701 }
1702 if arg == "--mode=psql" {
1703 return true;
1704 }
1705 if top_level_arg_consumes_two_values(arg) {
1706 i += if arg.contains('=') { 2 } else { 3 };
1707 continue;
1708 }
1709 if top_level_arg_consumes_value(arg) {
1710 i += if arg.contains('=') { 1 } else { 2 };
1711 continue;
1712 }
1713 if arg.starts_with('-') {
1714 i += 1;
1715 continue;
1716 }
1717 break;
1718 }
1719 false
1720}
1721
1722fn top_level_arg_consumes_two_values(arg: &str) -> bool {
1723 let name = arg.split_once('=').map(|(name, _)| name).unwrap_or(arg);
1724 matches!(
1725 name,
1726 "--dsn-secret-config" | "--conninfo-secret-config" | "--password-secret-config"
1727 )
1728}
1729
1730fn top_level_arg_consumes_value(arg: &str) -> bool {
1731 let name = arg.split_once('=').map(|(name, _)| name).unwrap_or(arg);
1732 matches!(
1733 name,
1734 "--sql"
1735 | "--sql-file"
1736 | "--param"
1737 | "--batch-rows"
1738 | "--batch-bytes"
1739 | "--statement-timeout-ms"
1740 | "--lock-timeout-ms"
1741 | "--inline-max-rows"
1742 | "--inline-max-bytes"
1743 | "--permission"
1744 | "--dsn-secret"
1745 | "--dsn-secret-env"
1746 | "--conninfo-secret"
1747 | "--conninfo-secret-env"
1748 | "--host"
1749 | "--port"
1750 | "--user"
1751 | "--dbname"
1752 | "--password-secret"
1753 | "--password-secret-env"
1754 | "--ssh"
1755 | "--ssh-via"
1756 | "--ssh-option"
1757 | "--ssh-local-host"
1758 | "--ssh-local-port"
1759 | "--ssh-remote-socket"
1760 | "--ssh-sudo-user"
1761 | "--container"
1762 | "--container-driver"
1763 | "--container-runtime"
1764 | "--container-user"
1765 | "--container-namespace"
1766 | "--container-context"
1767 | "--container-compose-file"
1768 | "--container-compose-project"
1769 | "--container-pod-container"
1770 | "--output"
1771 | "--stdout-file"
1772 | "--stderr-file"
1773 | "--log"
1774 )
1775}
1776
1777fn resolve_container_compose_files(cli_files: Vec<String>) -> Vec<String> {
1778 if !cli_files.is_empty() {
1779 return cli_files;
1780 }
1781 std::env::var("AFPSQL_CONTAINER_COMPOSE_FILE")
1782 .ok()
1783 .map(|value| {
1784 value
1785 .split(':')
1786 .filter(|part| !part.is_empty())
1787 .map(std::string::ToString::to_string)
1788 .collect()
1789 })
1790 .unwrap_or_default()
1791}
1792
1793fn load_sql(sql: Option<String>, sql_file: Option<String>) -> Result<String, String> {
1794 match (sql, sql_file) {
1795 (Some(s), None) => validate_sql_size(s),
1796 (None, Some(path)) if path == "-" => {
1797 let stdin = std::io::stdin();
1798 read_limited_sql(stdin.lock(), "read --sql-file -")
1799 }
1800 (None, Some(path)) => {
1801 let metadata =
1802 std::fs::metadata(&path).map_err(|e| format!("read --sql-file failed: {e}"))?;
1803 if metadata.is_file() && metadata.len() > MAX_SQL_BYTES as u64 {
1804 return Err(sql_size_error());
1805 }
1806 let file =
1807 std::fs::File::open(&path).map_err(|e| format!("read --sql-file failed: {e}"))?;
1808 read_limited_sql(file, "read --sql-file")
1809 }
1810 (Some(_), Some(_)) => Err("--sql and --sql-file are mutually exclusive".to_string()),
1811 (None, None) => Err("one of --sql or --sql-file is required".to_string()),
1812 }
1813}
1814
1815fn read_limited_sql<R: Read>(reader: R, context: &str) -> Result<String, String> {
1816 let mut buf = Vec::new();
1817 let mut limited = reader.take(MAX_SQL_BYTES as u64 + 1);
1818 limited
1819 .read_to_end(&mut buf)
1820 .map_err(|e| format!("{context} failed: {e}"))?;
1821 if buf.len() > MAX_SQL_BYTES {
1822 return Err(sql_size_error());
1823 }
1824 String::from_utf8(buf).map_err(|e| format!("{context} failed: {e}"))
1825}
1826
1827fn validate_sql_size(sql: String) -> Result<String, String> {
1828 if sql.len() > MAX_SQL_BYTES {
1829 return Err(sql_size_error());
1830 }
1831 Ok(sql)
1832}
1833
1834fn sql_size_error() -> String {
1835 format!("sql exceeds maximum size; maximum SQL size is {MAX_SQL_BYTES} bytes")
1836}
1837
1838fn parse_output(v: &str) -> Result<OutputFormat, String> {
1839 cli_parse_output(v)
1840}
1841
1842fn parse_log_categories(entries: &[String]) -> LogFilters {
1843 cli_parse_log_filters(entries)
1844}
1845
1846fn parse_csv_env(name: &str) -> Vec<String> {
1847 std::env::var(name)
1848 .ok()
1849 .into_iter()
1850 .flat_map(|value| {
1851 value
1852 .split(',')
1853 .map(str::trim)
1854 .filter(|part| !part.is_empty())
1855 .map(std::string::ToString::to_string)
1856 .collect::<Vec<_>>()
1857 })
1858 .collect()
1859}
1860
1861fn startup_requested_from_raw(raw: &[String]) -> bool {
1862 let mut i = 1usize;
1863 while i < raw.len() {
1864 if raw[i] == "--log" {
1865 if let Some(values) = raw.get(i + 1) {
1866 for part in values.split(',') {
1867 let v = part.trim().to_ascii_lowercase();
1868 if matches!(v.as_str(), "startup" | "all" | "*") {
1869 return true;
1870 }
1871 }
1872 }
1873 i += 2;
1874 continue;
1875 }
1876 if let Some(values) = raw[i].strip_prefix("--log=") {
1877 for part in values.split(',') {
1878 let v = part.trim().to_ascii_lowercase();
1879 if matches!(v.as_str(), "startup" | "all" | "*") {
1880 return true;
1881 }
1882 }
1883 }
1884 i += 1;
1885 }
1886 false
1887}
1888
1889fn startup_env_snapshot() -> Value {
1890 Value::Array(
1891 STARTUP_ENV_KEYS
1892 .iter()
1893 .map(|key| {
1894 json!({
1895 "key": key,
1896 "present": std::env::var_os(key).is_some(),
1897 })
1898 })
1899 .collect(),
1900 )
1901}
1902
1903fn startup_args(
1904 mode: &str,
1905 sql: Option<&str>,
1906 sql_file: Option<&str>,
1907 param_count: usize,
1908) -> Value {
1909 json!({
1910 "mode": mode,
1911 "sql": startup_sql_summary(sql, sql_file),
1912 "param_count": param_count,
1913 })
1914}
1915
1916fn with_connection_sources(mut args: Value, sources: &Value) -> Value {
1917 if let (Some(args), Some(sources)) = (args.as_object_mut(), sources.as_object())
1918 && !sources.is_empty()
1919 {
1920 args.insert(
1921 "connection_sources".to_string(),
1922 Value::Object(sources.clone()),
1923 );
1924 }
1925 args
1926}
1927
1928fn connection_source_metadata<const N: usize>(
1929 sources: [(&str, bool, Option<&str>, Option<&SecretConfigRef>); N],
1930) -> Value {
1931 let mut metadata = serde_json::Map::new();
1932 for (slot, direct, env_name, config) in sources {
1933 let value = if let Some(reference) = config {
1934 Some(reference.safe_metadata())
1935 } else if let Some(env_name) = env_name {
1936 Some(json!({"kind": "env", "name": env_name}))
1937 } else if direct {
1938 Some(json!({"kind": "direct"}))
1939 } else {
1940 None
1941 };
1942 if let Some(value) = value {
1943 metadata.insert(slot.to_string(), value);
1944 }
1945 }
1946 Value::Object(metadata)
1947}
1948
1949fn startup_sql_summary(sql: Option<&str>, sql_file: Option<&str>) -> Value {
1950 let Some(sql) = sql else {
1951 return json!({
1952 "present": false,
1953 "source": "none",
1954 "bytes": 0,
1955 "chars": 0,
1956 "operation": null,
1957 });
1958 };
1959 json!({
1960 "present": true,
1961 "source": if sql_file.is_some() { "file" } else { "inline" },
1962 "bytes": sql.len(),
1963 "chars": sql.chars().count(),
1964 "operation": sql_operation(sql),
1965 })
1966}
1967
1968fn sql_operation(sql: &str) -> Option<String> {
1969 let sql = trim_leading_sql_comments(sql);
1970 let token: String = sql
1971 .chars()
1972 .skip_while(|c| c.is_whitespace())
1973 .take_while(|c| c.is_ascii_alphabetic() || *c == '_')
1974 .collect();
1975 if token.is_empty() {
1976 None
1977 } else {
1978 Some(token.to_ascii_lowercase())
1979 }
1980}
1981
1982fn trim_leading_sql_comments(mut sql: &str) -> &str {
1983 loop {
1984 sql = sql.trim_start();
1985 if let Some(rest) = sql.strip_prefix("--") {
1986 sql = rest.split_once('\n').map(|(_, rest)| rest).unwrap_or("");
1987 continue;
1988 }
1989 if let Some(rest) = sql.strip_prefix("/*") {
1990 let Some((_, after)) = rest.split_once("*/") else {
1991 return "";
1992 };
1993 sql = after;
1994 continue;
1995 }
1996 return sql;
1997 }
1998}
1999
2000struct PsqlStartupArgs<'a> {
2001 mode: &'a str,
2002 sql: Option<&'a str>,
2003 sql_file: Option<String>,
2004 param_count: usize,
2005}
2006
2007fn psql_startup_args(args: PsqlStartupArgs<'_>) -> Value {
2008 startup_args(
2009 args.mode,
2010 args.sql,
2011 args.sql_file.as_deref(),
2012 args.param_count,
2013 )
2014}
2015
2016fn resolve_secret_value(
2017 flag_name: &str,
2018 direct: Option<String>,
2019 env_name: Option<&str>,
2020 config: Option<&SecretConfigRef>,
2021) -> Result<Option<String>, String> {
2022 let source_count = usize::from(direct.is_some())
2023 + usize::from(env_name.is_some())
2024 + usize::from(config.is_some());
2025 if source_count > 1 {
2026 return Err(format!(
2027 "{flag_name}, {flag_name}-env, and {flag_name}-config are mutually exclusive"
2028 ));
2029 }
2030 match (direct, env_name, config) {
2031 (Some(value), None, None) => Ok(Some(value)),
2032 (None, Some(name), None) => {
2033 if name.is_empty() {
2034 return Err(format!(
2035 "{flag_name}-env requires a non-empty variable name"
2036 ));
2037 }
2038 std::env::var(name).map(Some).map_err(|_| {
2039 format!("{flag_name}-env references unset environment variable: {name}")
2040 })
2041 }
2042 (None, None, Some(reference)) => {
2043 resolve_config_secret(&format!("{flag_name}-config"), reference).map(Some)
2044 }
2045 (None, None, None) => Ok(None),
2046 _ => Err(format!(
2047 "{flag_name}, {flag_name}-env, and {flag_name}-config are mutually exclusive"
2048 )),
2049 }
2050}
2051
2052pub fn parse_params(entries: &[String]) -> Result<Vec<Value>, String> {
2053 if entries.len() > MAX_PARAMS {
2054 return Err(format!("too many params; maximum params is {MAX_PARAMS}"));
2055 }
2056
2057 let mut by_index: BTreeMap<usize, Value> = BTreeMap::new();
2058 for entry in entries {
2059 let (idx, raw) = split_index_value(entry)?;
2060 if idx == 0 {
2061 return Err("param index must start at 1".to_string());
2062 }
2063 if idx > MAX_PARAMS {
2064 return Err(format!(
2065 "parameter index {idx} exceeds maximum params {MAX_PARAMS}"
2066 ));
2067 }
2068 match by_index.entry(idx) {
2069 Entry::Vacant(slot) => {
2070 slot.insert(parse_param_value(raw));
2071 }
2072 Entry::Occupied(_) => return Err(format!("duplicate parameter index {idx}")),
2073 }
2074 }
2075 if by_index.is_empty() {
2076 return Ok(vec![]);
2077 }
2078 let max = by_index.keys().max().copied().unwrap_or(0);
2079 for i in 1..=max {
2080 if !by_index.contains_key(&i) {
2081 return Err(format!("missing parameter index {i}"));
2082 }
2083 }
2084 Ok(by_index.into_values().collect())
2085}
2086
2087fn split_index_value(entry: &str) -> Result<(usize, &str), String> {
2088 let mut parts = entry.splitn(2, '=');
2089 let left = parts.next().unwrap_or_default();
2090 let right = parts
2091 .next()
2092 .ok_or_else(|| format!("invalid param '{entry}', expected N=value"))?;
2093 let idx = left
2094 .parse::<usize>()
2095 .map_err(|_| format!("invalid param index in '{entry}'"))?;
2096 Ok((idx, right))
2097}
2098
2099fn parse_param_value(v: &str) -> Value {
2100 if v == "null" {
2101 return Value::Null;
2102 }
2103 if v == "true" {
2104 return Value::Bool(true);
2105 }
2106 if v == "false" {
2107 return Value::Bool(false);
2108 }
2109 Value::String(v.to_string())
2113}
2114
2115fn wrap_explain_sql(user_sql: &str, analyze: bool) -> String {
2116 let body = user_sql.trim_end_matches([';', ' ', '\n', '\t', '\r']);
2117 if analyze {
2118 format!("explain (analyze true, format json, buffers true) {body}")
2119 } else {
2120 format!("explain (format json) {body}")
2121 }
2122}
2123
2124fn optional_string_value(value: Option<String>) -> Value {
2125 value.map(Value::String).unwrap_or(Value::Null)
2126}
2127
2128fn split_table_name(default_schema: String, name: String) -> (String, String) {
2129 match name.split_once('.') {
2130 Some((schema, table)) => (schema.to_string(), table.to_string()),
2131 None => (default_schema, name),
2132 }
2133}
2134
2135fn split_optional_table(default_schema: String, table: Option<String>) -> (String, Option<String>) {
2136 match table {
2137 Some(name) => {
2138 let (schema, table_name) = split_table_name(default_schema, name);
2139 (schema, Some(table_name))
2140 }
2141 None => (default_schema, None),
2142 }
2143}
2144
2145fn full_schema_snapshot_sql(relation_filter: &str, schema_only_filter: &str) -> String {
2146 format!(
2147 "with relation_filter as ( \
2148 select c.oid, c.relname, c.relkind, c.relpersistence, c.reltuples, c.relowner, \
2149 n.nspname, pg_catalog.obj_description(c.oid, 'pg_class') as comment \
2150 from pg_catalog.pg_class c \
2151 join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2152 where n.nspname = $1 \
2153 and c.relkind in ('r', 'p', 'f', 'v', 'm', 'S') \
2154 and ({relation_filter}) \
2155 ), snapshot as ( \
2156 select 'extension'::text as kind, \
2157 n.nspname::text as schema, \
2158 null::text as relation, \
2159 e.extname::text as name, \
2160 'extension'::text as object_type, \
2161 null::integer as position, \
2162 null::text as definition, \
2163 null::bigint as size_bytes, \
2164 null::text as size, \
2165 null::bigint as estimated_rows, \
2166 pg_catalog.jsonb_build_object('version', e.extversion) as payload \
2167 from pg_catalog.pg_extension e \
2168 join pg_catalog.pg_namespace n on n.oid = e.extnamespace \
2169 where n.nspname = $1 and ({schema_only_filter}) \
2170 union all \
2171 select 'relation'::text as kind, \
2172 rf.nspname::text as schema, \
2173 rf.relname::text as relation, \
2174 rf.relname::text as name, \
2175 case rf.relkind \
2176 when 'r' then 'table' \
2177 when 'p' then 'partitioned table' \
2178 when 'f' then 'foreign table' \
2179 when 'v' then 'view' \
2180 when 'm' then 'materialized view' \
2181 else rf.relkind::text \
2182 end as object_type, \
2183 null::integer as position, \
2184 case when rf.relkind in ('v', 'm') \
2185 then pg_catalog.pg_get_viewdef(rf.oid, true) end as definition, \
2186 case when rf.relkind in ('r', 'p', 'm') \
2187 then pg_catalog.pg_total_relation_size(rf.oid) end as size_bytes, \
2188 case when rf.relkind in ('r', 'p', 'm') \
2189 then pg_catalog.pg_size_pretty(pg_catalog.pg_total_relation_size(rf.oid)) end as size, \
2190 rf.reltuples::bigint as estimated_rows, \
2191 pg_catalog.jsonb_build_object( \
2192 'owner', pg_catalog.pg_get_userbyid(rf.relowner), \
2193 'persistence', rf.relpersistence, \
2194 'comment', rf.comment \
2195 ) as payload \
2196 from relation_filter rf \
2197 where rf.relkind in ('r', 'p', 'f', 'v', 'm') \
2198 union all \
2199 select 'sequence'::text as kind, \
2200 rf.nspname::text as schema, \
2201 rf.relname::text as relation, \
2202 rf.relname::text as name, \
2203 'sequence'::text as object_type, \
2204 null::integer as position, \
2205 null::text as definition, \
2206 pg_catalog.pg_relation_size(rf.oid) as size_bytes, \
2207 pg_catalog.pg_size_pretty(pg_catalog.pg_relation_size(rf.oid)) as size, \
2208 null::bigint as estimated_rows, \
2209 pg_catalog.jsonb_build_object( \
2210 'owner', pg_catalog.pg_get_userbyid(rf.relowner), \
2211 'comment', rf.comment \
2212 ) as payload \
2213 from relation_filter rf \
2214 where rf.relkind = 'S' \
2215 union all \
2216 select 'column'::text as kind, \
2217 rf.nspname::text as schema, \
2218 rf.relname::text as relation, \
2219 a.attname::text as name, \
2220 pg_catalog.format_type(a.atttypid, a.atttypmod)::text as object_type, \
2221 a.attnum::integer as position, \
2222 pg_catalog.pg_get_expr(ad.adbin, ad.adrelid)::text as definition, \
2223 null::bigint as size_bytes, \
2224 null::text as size, \
2225 null::bigint as estimated_rows, \
2226 pg_catalog.jsonb_build_object( \
2227 'nullable', not a.attnotnull, \
2228 'primary_key', coalesce(pk.is_primary, false), \
2229 'identity', a.attidentity::text, \
2230 'generated', a.attgenerated::text, \
2231 'serial_sequence', pg_catalog.pg_get_serial_sequence( \
2232 pg_catalog.format('%I.%I', rf.nspname, rf.relname), a.attname), \
2233 'comment', pg_catalog.col_description(rf.oid, a.attnum) \
2234 ) as payload \
2235 from pg_catalog.pg_attribute a \
2236 join relation_filter rf on rf.oid = a.attrelid \
2237 left join pg_catalog.pg_attrdef ad on ad.adrelid = a.attrelid and ad.adnum = a.attnum \
2238 left join lateral ( \
2239 select true as is_primary \
2240 from pg_catalog.pg_index i \
2241 where i.indrelid = a.attrelid and i.indisprimary \
2242 and a.attnum = any(i.indkey) \
2243 ) pk on true \
2244 where rf.relkind in ('r', 'p', 'f', 'v', 'm') \
2245 and a.attnum > 0 and not a.attisdropped \
2246 union all \
2247 select 'constraint'::text as kind, \
2248 rf.nspname::text as schema, \
2249 rf.relname::text as relation, \
2250 con.conname::text as name, \
2251 case con.contype \
2252 when 'p' then 'primary key' \
2253 when 'u' then 'unique' \
2254 when 'f' then 'foreign key' \
2255 when 'c' then 'check' \
2256 when 'x' then 'exclusion' \
2257 else con.contype::text \
2258 end as object_type, \
2259 null::integer as position, \
2260 pg_catalog.pg_get_constraintdef(con.oid, true)::text as definition, \
2261 null::bigint as size_bytes, \
2262 null::text as size, \
2263 null::bigint as estimated_rows, \
2264 pg_catalog.jsonb_build_object( \
2265 'type', con.contype::text, \
2266 'deferrable', con.condeferrable, \
2267 'deferred_by_default', con.condeferred, \
2268 'validated', con.convalidated \
2269 ) as payload \
2270 from pg_catalog.pg_constraint con \
2271 join relation_filter rf on rf.oid = con.conrelid \
2272 union all \
2273 select 'index'::text as kind, \
2274 rf.nspname::text as schema, \
2275 rf.relname::text as relation, \
2276 ic.relname::text as name, \
2277 am.amname::text as object_type, \
2278 null::integer as position, \
2279 pg_catalog.pg_get_indexdef(i.indexrelid)::text as definition, \
2280 pg_catalog.pg_relation_size(i.indexrelid) as size_bytes, \
2281 pg_catalog.pg_size_pretty(pg_catalog.pg_relation_size(i.indexrelid)) as size, \
2282 null::bigint as estimated_rows, \
2283 pg_catalog.jsonb_build_object( \
2284 'unique', i.indisunique, \
2285 'primary', i.indisprimary, \
2286 'valid', i.indisvalid, \
2287 'ready', i.indisready \
2288 ) as payload \
2289 from pg_catalog.pg_index i \
2290 join pg_catalog.pg_class ic on ic.oid = i.indexrelid \
2291 join relation_filter rf on rf.oid = i.indrelid \
2292 join pg_catalog.pg_am am on am.oid = ic.relam \
2293 union all \
2294 select 'trigger'::text as kind, \
2295 rf.nspname::text as schema, \
2296 rf.relname::text as relation, \
2297 tg.tgname::text as name, \
2298 'trigger'::text as object_type, \
2299 null::integer as position, \
2300 pg_catalog.pg_get_triggerdef(tg.oid, true)::text as definition, \
2301 null::bigint as size_bytes, \
2302 null::text as size, \
2303 null::bigint as estimated_rows, \
2304 pg_catalog.jsonb_build_object( \
2305 'enabled', tg.tgenabled::text, \
2306 'function_schema', fn_ns.nspname, \
2307 'function_name', fn.proname \
2308 ) as payload \
2309 from pg_catalog.pg_trigger tg \
2310 join relation_filter rf on rf.oid = tg.tgrelid \
2311 join pg_catalog.pg_proc fn on fn.oid = tg.tgfoid \
2312 join pg_catalog.pg_namespace fn_ns on fn_ns.oid = fn.pronamespace \
2313 where not tg.tgisinternal \
2314 union all \
2315 select 'function'::text as kind, \
2316 n.nspname::text as schema, \
2317 null::text as relation, \
2318 (p.proname || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')')::text as name, \
2319 'function'::text as object_type, \
2320 null::integer as position, \
2321 pg_catalog.pg_get_functiondef(p.oid)::text as definition, \
2322 null::bigint as size_bytes, \
2323 null::text as size, \
2324 null::bigint as estimated_rows, \
2325 pg_catalog.jsonb_build_object( \
2326 'language', l.lanname, \
2327 'result', pg_catalog.pg_get_function_result(p.oid), \
2328 'identity_args', pg_catalog.pg_get_function_identity_arguments(p.oid) \
2329 ) as payload \
2330 from pg_catalog.pg_proc p \
2331 join pg_catalog.pg_namespace n on n.oid = p.pronamespace \
2332 join pg_catalog.pg_language l on l.oid = p.prolang \
2333 where n.nspname = $1 \
2334 and p.prokind = 'f' \
2335 and ({schema_only_filter}) \
2336 and not exists ( \
2337 select 1 \
2338 from pg_catalog.pg_depend d \
2339 where d.classid = 'pg_catalog.pg_proc'::regclass \
2340 and d.objid = p.oid \
2341 and d.deptype = 'e' \
2342 ) \
2343 ) \
2344 select * from snapshot \
2345 order by case kind \
2346 when 'extension' then 0 \
2347 when 'relation' then 1 \
2348 when 'sequence' then 2 \
2349 when 'column' then 3 \
2350 when 'constraint' then 4 \
2351 when 'index' then 5 \
2352 when 'trigger' then 6 \
2353 when 'function' then 7 \
2354 else 99 end, \
2355 schema, relation nulls first, position nulls last, name"
2356 )
2357}
2358
2359fn build_schema_snapshot_sql(args: InspectSchemaArgs) -> (String, Vec<Value>) {
2360 (
2361 full_schema_snapshot_sql("$2::text is null or c.relname like $2", "$2::text is null"),
2362 vec![Value::String(args.schema), optional_string_value(args.like)],
2363 )
2364}
2365
2366fn build_table_full_sql(schema: String, name: String) -> (String, Vec<Value>) {
2367 (
2368 full_schema_snapshot_sql("c.relname = $2", "false"),
2369 vec![Value::String(schema), Value::String(name)],
2370 )
2371}
2372
2373fn build_inspect_indexes_sql(args: InspectIndexesArgs) -> (String, Vec<Value>) {
2374 let (schema, table) = split_optional_table(args.schema, args.table);
2375 let mut sql = String::from(
2376 "select n.nspname as schema, \
2377 tc.relname as table, \
2378 ic.relname as name, \
2379 am.amname as method, \
2380 i.indisunique as unique, \
2381 i.indisprimary as primary, \
2382 i.indisvalid as valid, \
2383 i.indisready as ready, \
2384 pg_catalog.pg_get_indexdef(i.indexrelid) as definition, \
2385 pg_catalog.pg_relation_size(i.indexrelid) as size_bytes, \
2386 pg_catalog.pg_size_pretty(pg_catalog.pg_relation_size(i.indexrelid)) as size",
2387 );
2388 if args.stats {
2389 sql.push_str(
2390 ", s.idx_scan as index_scan_count, \
2391 s.idx_tup_read as index_tuple_read_count, \
2392 s.idx_tup_fetch as index_tuple_fetch_count",
2393 );
2394 }
2395 sql.push_str(
2396 " from pg_catalog.pg_index i \
2397 join pg_catalog.pg_class ic on ic.oid = i.indexrelid \
2398 join pg_catalog.pg_class tc on tc.oid = i.indrelid \
2399 join pg_catalog.pg_namespace n on n.oid = tc.relnamespace \
2400 join pg_catalog.pg_am am on am.oid = ic.relam",
2401 );
2402 if args.stats {
2403 sql.push_str(" left join pg_catalog.pg_stat_user_indexes s on s.indexrelid = i.indexrelid");
2404 }
2405 sql.push_str(" where n.nspname = $1");
2406
2407 let mut params = vec![Value::String(schema)];
2408 if let Some(table_name) = table {
2409 sql.push_str(" and tc.relname = $2");
2410 params.push(Value::String(table_name));
2411 }
2412 sql.push_str(" order by tc.relname, ic.relname");
2413 (sql, params)
2414}
2415
2416fn build_inspect_sql(action: InspectAction) -> (String, Vec<Value>) {
2417 match action {
2418 InspectAction::Databases(args) => {
2419 let mut sql = String::from(
2420 "select d.datname as database, \
2421 pg_catalog.pg_get_userbyid(d.datdba) as owner, \
2422 pg_catalog.pg_encoding_to_char(d.encoding) as encoding, \
2423 d.datcollate as collate, \
2424 d.datctype as ctype, \
2425 d.datistemplate as is_template, \
2426 d.datallowconn as allow_connections, \
2427 d.datconnlimit as connection_limit, \
2428 case when has_database_privilege(d.datname, 'CONNECT') \
2429 then pg_catalog.pg_database_size(d.oid) end as size_bytes, \
2430 case when has_database_privilege(d.datname, 'CONNECT') \
2431 then pg_catalog.pg_size_pretty(pg_catalog.pg_database_size(d.oid)) end as size, \
2432 s.numbackends as active_connections \
2433 from pg_catalog.pg_database d \
2434 left join pg_catalog.pg_stat_database s on s.datid = d.oid",
2435 );
2436 if !args.all {
2437 sql.push_str(" where not d.datistemplate");
2438 }
2439 sql.push_str(" order by d.datname");
2440 (sql, vec![])
2441 }
2442 InspectAction::Database => (
2443 "with rels as ( \
2444 select c.relkind \
2445 from pg_catalog.pg_class c \
2446 join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2447 where n.nspname not in ('pg_catalog', 'information_schema') \
2448 and n.nspname not like 'pg_toast%' \
2449 and n.nspname not like 'pg_temp_%' \
2450 ) \
2451 select current_database() as database, \
2452 ( select count(*) from pg_catalog.pg_namespace n \
2453 where n.nspname not in ('pg_catalog', 'information_schema') \
2454 and n.nspname not like 'pg_toast%' \
2455 and n.nspname not like 'pg_temp_%' ) as schemas, \
2456 count(*) filter (where relkind in ('r', 'p')) as tables, \
2457 count(*) filter (where relkind = 'v') as views, \
2458 count(*) filter (where relkind = 'm') as materialized_views, \
2459 count(*) filter (where relkind = 'S') as sequences, \
2460 pg_catalog.pg_database_size(current_database()) as size_bytes, \
2461 pg_catalog.pg_size_pretty(pg_catalog.pg_database_size(current_database())) as size \
2462 from rels"
2463 .to_string(),
2464 vec![],
2465 ),
2466 InspectAction::Schemas => (
2467 "select n.nspname as schema, \
2468 pg_catalog.pg_get_userbyid(n.nspowner) as owner, \
2469 count(*) filter (where c.relkind in ('r', 'p')) as tables, \
2470 count(*) filter (where c.relkind = 'v') as views, \
2471 count(*) filter (where c.relkind = 'm') as materialized_views, \
2472 count(*) filter (where c.relkind = 'S') as sequences, \
2473 pg_catalog.pg_size_pretty(coalesce( \
2474 sum(pg_catalog.pg_total_relation_size(c.oid)) \
2475 filter (where c.relkind in ('r', 'p', 'm')), 0)) as size \
2476 from pg_catalog.pg_namespace n \
2477 left join pg_catalog.pg_class c on c.relnamespace = n.oid \
2478 where n.nspname not in ('pg_catalog', 'information_schema') \
2479 and n.nspname not like 'pg_toast%' \
2480 and n.nspname not like 'pg_temp_%' \
2481 group by n.nspname, n.nspowner \
2482 order by n.nspname"
2483 .to_string(),
2484 vec![],
2485 ),
2486 InspectAction::Schema(args) | InspectAction::Snapshot(args) => build_schema_snapshot_sql(args),
2487 InspectAction::Tables(args) => {
2488 let mut sql = String::from(
2489 "select n.nspname as schema, \
2490 c.relname as name, \
2491 case c.relkind when 'r' then 'table' \
2492 when 'p' then 'partitioned table' \
2493 when 'f' then 'foreign table' end as kind, \
2494 pg_catalog.pg_get_userbyid(c.relowner) as owner, \
2495 c.reltuples::bigint as estimated_rows, \
2496 pg_catalog.pg_size_pretty(pg_catalog.pg_total_relation_size(c.oid)) as size, \
2497 pg_catalog.pg_total_relation_size(c.oid) as size_bytes \
2498 from pg_catalog.pg_class c \
2499 join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2500 where n.nspname = $1 and c.relkind in ('r', 'p', 'f')",
2501 );
2502 let mut params = vec![Value::String(args.schema)];
2503 if let Some(pattern) = args.like {
2504 sql.push_str(" and c.relname like $2");
2505 params.push(Value::String(pattern));
2506 }
2507 sql.push_str(" order by c.relname");
2508 (sql, params)
2509 }
2510 InspectAction::Views(args) => {
2511 let mut sql = String::from(
2512 "select n.nspname as schema, \
2513 c.relname as name, \
2514 case c.relkind when 'm' then true else false end as materialized, \
2515 pg_catalog.pg_get_userbyid(c.relowner) as owner \
2516 from pg_catalog.pg_class c \
2517 join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2518 where n.nspname = $1 and c.relkind in ('v', 'm')",
2519 );
2520 let mut params = vec![Value::String(args.schema)];
2521 if let Some(pattern) = args.like {
2522 sql.push_str(" and c.relname like $2");
2523 params.push(Value::String(pattern));
2524 }
2525 sql.push_str(" order by c.relname");
2526 (sql, params)
2527 }
2528 InspectAction::Indexes(args) => build_inspect_indexes_sql(args),
2529 InspectAction::Table(args) => {
2530 let (schema, name) = split_table_name("public".to_string(), args.name);
2531 if args.full {
2532 return build_table_full_sql(schema, name);
2533 }
2534 (
2535 "select a.attname as name, \
2536 pg_catalog.format_type(a.atttypid, a.atttypmod) as type, \
2537 not a.attnotnull as nullable, \
2538 pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) as default, \
2539 a.attnum as position, \
2540 coalesce(pk.is_primary, false) as primary_key, \
2541 pg_catalog.col_description(c.oid, a.attnum) as comment \
2542 from pg_catalog.pg_attribute a \
2543 join pg_catalog.pg_class c on c.oid = a.attrelid \
2544 join pg_catalog.pg_namespace n on n.oid = c.relnamespace \
2545 left join pg_catalog.pg_attrdef ad \
2546 on ad.adrelid = a.attrelid and ad.adnum = a.attnum \
2547 left join lateral ( \
2548 select true as is_primary \
2549 from pg_catalog.pg_index i \
2550 where i.indrelid = a.attrelid and i.indisprimary \
2551 and a.attnum = any(i.indkey) \
2552 ) pk on true \
2553 where n.nspname = $1 and c.relname = $2 \
2554 and a.attnum > 0 and not a.attisdropped \
2555 order by a.attnum"
2556 .to_string(),
2557 vec![Value::String(schema), Value::String(name)],
2558 )
2559 }
2560 }
2561}
2562
2563#[cfg(test)]
2564#[path = "../tests/support/unit_cli.rs"]
2565mod tests;