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