Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions cargo-pgrx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -886,10 +886,17 @@ If you just want to look at the full extension schema that pgrx will generate, u
$ cargo pgrx schema --help
Generate extension schema files

Usage: cargo pgrx schema [OPTIONS] [PG_VERSION]
Usage: cargo pgrx schema [OPTIONS] [ARGS]...

Arguments:
[PG_VERSION] Do you want to run against pg13, pg14, pg15, pg16, pg17, or pg18?
[ARGS]... First arg may be a PostgreSQL version label (`pg13`..`pg18`).
Remaining args are SQL item names to emit (functions, types,
enums, operators, aggregates, triggers, schemas, extension_sql
blocks). When item names are given, only those items and their
transitive dependencies are emitted, in install order, and
'MODULE_PATHNAME' is substituted with '$libdir/<lib_name>' so
the output can be replayed directly. Names containing `::` are
matched as Rust paths to disambiguate.

Options:
-p, --package <PACKAGE> Package to build (see `cargo help pkgid`)
Expand All @@ -905,10 +912,66 @@ Options:
-o, --out <OUT> A path to output a produced SQL file (default is `stdout`)
-d, --dot <DOT> A path to output a produced GraphViz DOT file
--skip-build Skip building a fresh extension shared object
--no-alter-extension Don't emit `ALTER EXTENSION ... ADD ...` statements when
extracting specific items (see "Attaching Slices" below)
-h, --help Print help
-V, --version Print version
```

### Emitting a Slice of the Schema

Any positional arguments after an optional `pgXX` version label are treated as SQL item
names. The output is restricted to those items plus every dependency they need, in
install order. Names match against each entity's SQL-visible identifier: `name` for
functions, types, enums, aggregates, and schemas; `opname` for operators (for example
`===`); `function_name` for triggers; and `name` for `extension_sql!` blocks. A name
containing `::` is treated as a Rust path and matched against `full_path`, which is the
way to disambiguate collisions (for example two functions named `dup_fn` in different
modules).

When item names are supplied, every occurrence of `'MODULE_PATHNAME'` in the generated
SQL is substituted with `'$libdir/<lib_name>'`, so the output can be replayed directly
into a database without relying on the extension's control file to resolve
`MODULE_PATHNAME`.

```shell
# Emit one function and its dependencies, with MODULE_PATHNAME substituted
cargo pgrx schema my_function

# Multiple items at once
cargo pgrx schema my_function MyType ===

# Specify a Postgres version first, then the items
cargo pgrx schema pg18 my_function MyType

# Disambiguate with a Rust path when the bare name matches multiple items
cargo pgrx schema my_crate::submodule::dup_fn

# Write the slice to a file instead of stdout (combines with item selection)
cargo pgrx schema --out /tmp/extracted_schema_objects.sql my_function MyType
```

#### Attaching Slices to an Already-Installed Extension

When item names are supplied, the emitted slice is wrapped in `BEGIN;`/`COMMIT;`
and every created object is followed by an `ALTER EXTENSION "<ext>" ADD ...`
statement. Piping the output into a database where the extension is already
installed makes the new objects members of the extension, verifiable via
`pg_depend`.

```shell
# Add a new function to an already-installed extension
cargo pgrx schema my_new_fn | cargo pgrx connect
```

Pass `--no-alter-extension` to opt out of this (for example, to generate SQL
for hand-editing or to match the pre-feature output).

`extension_sql!()` blocks that don't declare `creates = [...]` cannot be
attached automatically; the emitter prints a warning to stderr naming the
block's `file:line` so the user knows which objects to attach by hand.


## Extension Version Upgrade Scripts

When creating a pgrx extension using `cargo pgrx new foo`, the new extension template directory tree includes a
Expand Down
4 changes: 4 additions & 0 deletions cargo-pgrx/src/command/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,10 @@ fn copy_sql_files(
None,
None,
skip_build,
None,
// install scripts run inside CREATE EXTENSION and auto-attach;
// explicit ALTER EXTENSION would be redundant.
false,
output_tracking,
)?;
}
Expand Down
132 changes: 125 additions & 7 deletions cargo-pgrx/src/command/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use cargo_toml::Manifest;
use eyre::WrapErr;
use owo_colors::OwoColorize;
use pgrx_pg_config::cargo::PgrxManifestExt;
use pgrx_pg_config::{Pgrx, get_target_dir};
use pgrx_pg_config::{Pgrx, get_target_dir, is_supported_major_version};
use pgrx_sql_entity_graph::section::decode_entities;
use pgrx_sql_entity_graph::{ControlFile, PgrxSql, SqlGraphEntity};
use std::path::{Path, PathBuf};
Expand All @@ -34,8 +34,16 @@ pub(crate) struct Schema {
/// Build in test mode (for `cargo pgrx test`)
#[clap(long)]
test: bool,
/// Do you want to run against pg13, pg14, pg15, pg16, pg17, or pg18?
pg_version: Option<String>,
/// Positional arguments.
///
/// The first may be a PostgreSQL version label (`pg13`..`pg18`); every
/// remaining value is an SQL item name to emit (functions, types,
/// enums, operators, aggregates, triggers, schemas, extension_sql
/// blocks). Only those items and their transitive dependencies are
/// emitted, in install order, and `'MODULE_PATHNAME'` is substituted
/// with `'$libdir/<lib_name>'` so the output can be replayed directly.
/// Names containing `::` are matched as Rust paths to disambiguate.
args: Vec<String>,
/// Compile for release mode (default is debug)
#[clap(long, short)]
release: bool,
Expand All @@ -60,6 +68,12 @@ pub(crate) struct Schema {
/// Skip building a fresh extension shared object.
#[clap(long)]
skip_build: bool,
/// Don't emit `ALTER EXTENSION ... ADD ...` statements when extracting
/// specific items. By default, item mode emits ALTER EXTENSION so the
/// output can be piped into a running database and attached to the
/// already-installed extension.
#[clap(long)]
no_alter_extension: bool,
}

impl CommandExecute for Schema {
Expand All @@ -76,6 +90,8 @@ impl CommandExecute for Schema {
}
};

let (pg_version, items) = split_positional_args(&self.args);

let pgrx = Pgrx::from_config()?;
let (package_manifest, package_manifest_path) = get_package_manifest(
&self.features,
Expand All @@ -86,7 +102,7 @@ impl CommandExecute for Schema {
let (_pg_config, _pg_version) = pg_config_and_version(
&pgrx,
&package_manifest,
self.pg_version.clone(),
pg_version,
Some(&mut self.features),
true,
)?;
Expand All @@ -96,6 +112,7 @@ impl CommandExecute for Schema {
if self.release { CargoProfile::Release } else { CargoProfile::Dev },
)?;

let attach = !self.no_alter_extension;
generate_schema(
self.manifest_path.as_deref(),
self.package.as_deref(),
Expand All @@ -108,11 +125,33 @@ impl CommandExecute for Schema {
self.dot.as_deref(),
log_level,
self.skip_build,
items,
attach,
&mut vec![],
)
}
}

/// Split the schema command's positional arguments into an optional
/// `pgXX` version label and an optional list of SQL item names.
///
/// If the first argument parses as a supported PostgreSQL major version it
/// is consumed as `pg_version`; everything after it (or everything, if
/// there is no version) flows through as item names. `None` items means
/// the caller supplied no names at all — as distinct from an empty slice.
fn split_positional_args(args: &[String]) -> (Option<String>, Option<&[String]>) {
let (pg_version, rest) = if let Some((first, rest)) = args.split_first()
&& let Some(major) = first.strip_prefix("pg")
&& let Ok(major) = major.parse::<u16>()
&& is_supported_major_version(major)
{
(Some(first.clone()), rest)
} else {
(None, args)
};
(pg_version, (!rest.is_empty()).then_some(rest))
}

#[tracing::instrument(level = "error", skip_all, fields(
profile = ?profile,
test = is_test,
Expand All @@ -132,6 +171,8 @@ pub(crate) fn generate_schema_for_cli(
dot: Option<&Path>,
log_level: Option<String>,
skip_build: bool,
items: Option<&[String]>,
attach: bool,
output_tracking: &mut Vec<PathBuf>,
) -> eyre::Result<()> {
let manifest = Manifest::from_path(package_manifest_path)?;
Expand Down Expand Up @@ -160,6 +201,8 @@ pub(crate) fn generate_schema_for_cli(
target,
path,
dot,
items,
attach,
output_tracking,
manifest,
)
Expand All @@ -172,10 +215,12 @@ pub(crate) fn generate_schema_implicit(
target: Option<&str>,
path: Option<&Path>,
dot: Option<&Path>,
items: Option<&[String]>,
attach: bool,
output_tracking: &mut Vec<PathBuf>,
manifest: cargo_toml::Manifest,
) -> eyre::Result<()> {
let (control_file_path, _extname) = find_control_file(package_manifest_path)?;
let (control_file_path, extname) = find_control_file(package_manifest_path)?;
let lib_name = manifest.lib_name()?;
let lib_filename = manifest.lib_filename()?;
let versioned_so = get_property(package_manifest_path, "module_pathname")?.is_none();
Expand Down Expand Up @@ -206,7 +251,36 @@ pub(crate) fn generate_schema_implicit(
let pgrx_sql = PgrxSql::build(entities.into_iter(), lib_name.to_string(), versioned_so)
.wrap_err("SQL generation error")?;

if let Some(path) = path {
if let Some(items) = items {
let extension_name = attach.then_some(extname.as_str());
let sliced = pgrx_sql
.to_sql_for_items(items, &lib_name, extension_name)
.wrap_err("Could not generate SQL for requested items")?;
if let Some(path) = path {
eprintln!(
"{} SQL for {} item(s) to {}",
" Writing".bold().green(),
items.len(),
path.display()
);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, sliced)
.wrap_err_with(|| format!("Could not write SQL to {}", path.display()))?;
} else {
eprintln!(
"{} SQL for {} item(s) to {}",
" Writing".bold().green(),
items.len(),
"/dev/stdout"
);
use std::io::Write as _;
std::io::stdout()
.write_all(sliced.as_bytes())
.wrap_err("Could not write SQL to stdout")?;
}
} else if let Some(path) = path {
eprintln!("{} SQL entities to {}", " Writing".bold().green(), path.display());
pgrx_sql
.to_file(path)
Expand Down Expand Up @@ -335,7 +409,11 @@ fn first_build(

#[cfg(test)]
mod tests {
use super::decode_section_entities;
use super::{decode_section_entities, split_positional_args};

fn strs(args: &[&str]) -> Vec<String> {
args.iter().map(|s| (*s).to_owned()).collect()
}

#[test]
fn test_missing_schema_section_errors() {
Expand All @@ -346,4 +424,44 @@ mod tests {
let error = decode_section_entities(&bin).expect_err("missing section");
assert!(error.to_string().contains("no embedded pgrx schema section found"));
}

#[test]
fn empty_args_yield_no_version_and_no_items() {
let args = strs(&[]);
let (pg, items) = split_positional_args(&args);
assert!(pg.is_none());
assert!(items.is_none());
}

#[test]
fn version_alone_is_captured() {
let args = strs(&["pg18"]);
let (pg, items) = split_positional_args(&args);
assert_eq!(pg.as_deref(), Some("pg18"));
assert!(items.is_none());
}

#[test]
fn version_followed_by_items() {
let args = strs(&["pg18", "sum_vec", "MyType", "==="]);
let (pg, items) = split_positional_args(&args);
assert_eq!(pg.as_deref(), Some("pg18"));
assert_eq!(items, Some(&["sum_vec".to_owned(), "MyType".to_owned(), "===".to_owned()][..]));
}

#[test]
fn items_only_without_version() {
let args = strs(&["sum_vec", "MyType", "==="]);
let (pg, items) = split_positional_args(&args);
assert!(pg.is_none());
assert_eq!(items, Some(&["sum_vec".to_owned(), "MyType".to_owned(), "===".to_owned()][..]));
}

#[test]
fn first_arg_that_looks_like_version_but_isnt_is_an_item() {
let args = strs(&["pgfoo", "sum_vec"]);
let (pg, items) = split_positional_args(&args);
assert!(pg.is_none());
assert_eq!(items, Some(&["pgfoo".to_owned(), "sum_vec".to_owned()][..]));
}
}
34 changes: 34 additions & 0 deletions pgrx-sql-entity-graph/src/aggregate/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use crate::to_sql::ToSql;
use crate::to_sql::entity::ToSqlConfigEntity;
use crate::{SqlGraphEntity, SqlGraphIdentifier, UsedTypeEntity};
use eyre::{WrapErr, eyre};
use petgraph::graph::NodeIndex;

#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct AggregateTypeEntity<'a> {
Expand Down Expand Up @@ -184,6 +185,39 @@ fn aggregate_sql_type(mapping: &SqlMapping, composite_type: Option<&str>) -> eyr
}
}

/// Render the positional argument-type signature for an aggregate as it
/// would appear inside `ALTER EXTENSION … ADD AGGREGATE name(…)`. For
/// ordered-set aggregates the rendering is `(direct ORDER BY args)`;
/// otherwise it is `(args)`. Matches the shape produced by
/// `PgAggregateEntity::to_sql`.
pub(crate) fn render_aggregate_argtypes(
context: &PgrxSql,
owner: NodeIndex,
a: &PgAggregateEntity,
) -> eyre::Result<String> {
let render_slot = |arg: &AggregateTypeEntity| -> eyre::Result<String> {
let slot = arg.name.unwrap_or("aggregate argument");
let prefix = context.schema_prefix_for_used_type(&owner, slot, &arg.used_ty)?;
let sql = match arg.used_ty.metadata.argument_sql {
Ok(ref mapping) => aggregate_sql_type(mapping, arg.used_ty.composite_type)?,
Err(err) => return Err(err.into()),
};
let variadic = if arg.used_ty.variadic { "VARIADIC " } else { "" };
Ok(format!("{variadic}{prefix}{sql}"))
};

let args = a.args.iter().map(render_slot).collect::<eyre::Result<Vec<_>>>()?.join(", ");
let direct = a.direct_args.as_deref().unwrap_or(&[]);

if a.ordered_set {
let direct_rendered =
direct.iter().map(render_slot).collect::<eyre::Result<Vec<_>>>()?.join(", ");
Ok(format!("({direct_rendered} ORDER BY {args})"))
} else {
Ok(format!("({args})"))
}
}

impl ToSql for PgAggregateEntity<'_> {
fn to_sql(&self, context: &PgrxSql) -> eyre::Result<String> {
let self_index = context.aggregates[self];
Expand Down
Loading
Loading