Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make unboxing args sound with ArgAbi #1731

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
4e3447b
pipe through fcx lifetime
workingjubilee May 24, 2024
e342477
Partial revert
workingjubilee May 24, 2024
5061f60
Primitive borrow-checking internal wrapper
workingjubilee May 25, 2024
de7a546
Bypass pg_guard so lifetimes can typecheck
workingjubilee May 29, 2024
2904955
Remove a space
workingjubilee May 29, 2024
3b45a47
Add a FIXME
workingjubilee May 29, 2024
37c7ad5
sketch out argabi
workingjubilee May 22, 2024
0f0a366
remarks about features
workingjubilee May 22, 2024
df69adf
lifetimes everywhere
workingjubilee May 29, 2024
ee8286e
stamp out some argabi impls
workingjubilee May 29, 2024
d16aef2
Weaken pgrx-pg-sys panic bounds
workingjubilee Jun 5, 2024
7ca24cb
Remodel macro expansion
workingjubilee Jun 5, 2024
f7d00c0
Add safer versions to RetAbi
workingjubilee Jun 5, 2024
caa22f6
Remove fcinfo stub
workingjubilee Jun 5, 2024
6f8a384
Make almost all types compile with ArgAbi unboxing
workingjubilee Jun 6, 2024
9c1737c
impl ArgAbi for HexInt
workingjubilee Jun 6, 2024
e684fee
Revert "Weaken pgrx-pg-sys panic bounds"
workingjubilee Jun 6, 2024
be4c65f
make FcInfo work without weakening pg_extern_c_guard
workingjubilee Jun 6, 2024
a45ccd7
Fuck off rustfmt
workingjubilee Jun 7, 2024
3f13ff1
Impl ArgAbi for pg_sys::FunctionCallInfo
workingjubilee Jun 7, 2024
778f81f
Resolve the last lifetime error by reusing some lifetimes
workingjubilee Jun 7, 2024
2820321
Claim victory over static strings
workingjubilee Jun 7, 2024
1508e00
Claim victory over static composite types in aggregates
workingjubilee Jun 7, 2024
d89b300
Remember to use public reexports
workingjubilee Jun 7, 2024
eed434a
Stub out a few more ArgAbi impls
workingjubilee Jun 7, 2024
89b16d1
Fixup victory lap tests
workingjubilee Jun 7, 2024
6548283
Stubbier ArgAbi impl
workingjubilee Jun 7, 2024
d93ade6
Remove needless borrow
workingjubilee Jun 7, 2024
4fd1799
Reuse reexports
workingjubilee Jun 7, 2024
aeaacd2
Support Vec<u8> as an argument
workingjubilee Jun 7, 2024
b114ce6
Make more things compile even though it doesnt make sense yet
workingjubilee Jun 8, 2024
775ccfd
Make the table iterators die
workingjubilee Jun 8, 2024
8ac225d
More bad aggregates
workingjubilee Jun 8, 2024
9c274b5
Add Arguments iterator
workingjubilee Jun 12, 2024
85f0861
Make Arguments peekable
workingjubilee Jun 18, 2024
9467575
Fixup args
workingjubilee Jun 21, 2024
a856776
Fixup all the arg types
workingjubilee Jun 21, 2024
05448d1
Handle virtual args correctly
workingjubilee Jun 22, 2024
648619a
Pipe through array construction via ArgAbi
workingjubilee Jun 22, 2024
a78786c
impl ArgAbi for FunctionCallInfo again
workingjubilee Jun 24, 2024
0dc7a40
Correctly bound ArgAbi for Vecs
workingjubilee Jun 24, 2024
954dc39
ArgAbi for Vecs
workingjubilee Jun 25, 2024
f206eff
Initial tests passing
workingjubilee Jun 25, 2024
e4f28bd
Dont test a bad function
workingjubilee Jun 25, 2024
8192075
Handle polymorphic arg cases
workingjubilee Jun 25, 2024
0168121
move more bad aggregates
workingjubilee Jun 25, 2024
a247fe9
improve error messages
workingjubilee Jun 25, 2024
1a09c8d
type inference take the wheel
workingjubilee Jun 25, 2024
3721045
Drop support for error messages via the InOutFuncs trait
workingjubilee Jun 25, 2024
a0a6c32
Accept mostly-inconsequential diffs in tests
workingjubilee Jun 25, 2024
6ac3cb7
Remove initial unbox_from_fcinfo_index fn
workingjubilee Jun 25, 2024
a624959
Fix doctest
workingjubilee Jun 25, 2024
db122c3
There is only one sound path forward
workingjubilee Jun 25, 2024
00c5182
Remove unused imports and extra quals
workingjubilee Jun 25, 2024
c8e83e8
cleanup macro expansion so it is easier to read
workingjubilee Jun 26, 2024
a787c71
Use an underscore-free fcinfo ident to silence a clippy warning
workingjubilee Jun 26, 2024
7f14af0
comments
workingjubilee Jun 27, 2024
b68b4fc
Initial safety comment on unbox_argument family
workingjubilee Jun 27, 2024
20974c5
Erase a lifetime transmutation
workingjubilee Jun 28, 2024
3395b1b
Readd Vec example in strings
workingjubilee Jun 28, 2024
1f73271
Support nullable args better
workingjubilee Jun 29, 2024
a4aad19
Revert "Drop support for error messages via the InOutFuncs trait"
workingjubilee Jun 29, 2024
fd587cb
Accept more diffs in UI tests
workingjubilee Jun 29, 2024
9a1766d
Actually support Nullable in arg/ret
workingjubilee Jun 29, 2024
12d3977
argh
workingjubilee Jun 29, 2024
7d4a89d
tests argh
workingjubilee Jun 29, 2024
7803c12
raw_oid perf note
workingjubilee Jun 29, 2024
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
68 changes: 2 additions & 66 deletions pgrx-examples/composite_type/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ fn make_friendship(
}

/*
FIXME: make this example no longer DOA
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the plan for this? Aggregate support is a pretty important feature

Copy link
Member Author

@workingjubilee workingjubilee Jun 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem isn't with Aggregate, per se, it's with certain types.

These are aggregates that are supposed to manage composite types. But we know composite types are allocated in Postgres, thus are effectively lifetime-bound. But #[pg_aggregate] and Aggregate don't know how to convey the lifetimes through. Thus even annotating it with something like this does not actually fix the problem:

#[pg_aggregate]
impl<'a> Aggregate for SumScritches<'a> {
    type State = i32;
    const INITIAL_CONDITION: Option<&'static str> = Some("0");
    type Args = pgrx::name!(value, pgrx::composite_type!('a, "Dog"));

    fn state(
        current: Self::State,
        arg: Self::Args,
        _fcinfo: pg_sys::FunctionCallInfo,
    ) -> Self::State {
        todo!()
    }
}

Thus in order to make aggregates work for complex cases we must break the world for them as well, making all their functions use the correct, lifetime-bound types. Hopefully this will let us simplify the code expansion for them as well.

Create sum the scritches received by dogs, roughly the equivalent of:

```sql
Expand All @@ -161,64 +162,7 @@ CREATE AGGREGATE sum_scritches ("value" Dog) (
)
```
*/
struct SumScritches;

#[pg_aggregate]
impl Aggregate for SumScritches {
type State = i32;
const INITIAL_CONDITION: Option<&'static str> = Some("0");
type Args = pgrx::name!(value, pgrx::composite_type!('static, "Dog"));

fn state(
current: Self::State,
arg: Self::Args,
_fcinfo: pg_sys::FunctionCallInfo,
) -> Self::State {
let arg_scritches: i32 = arg
.get_by_name("scritches")
.unwrap() // Unwrap the result of the conversion
.unwrap_or_default(); // The number of scritches, or 0 if there was none set
current + arg_scritches
}
}

/*
Create sum the scritches received by dogs, roughly the equivalent of:

```sql
CREATE FUNCTION scritch_collector_state(state Dog, new integer)
RETURNS Dog
LANGUAGE SQL
STRICT
RETURN ROW(state.name, state.scritches + new)::Dog;

CREATE AGGREGATE scritch_collector ("value" integer) (
SFUNC = "sum_scritches_state",
STYPE = Dog,
)
```
*/
struct ScritchCollector;

#[pg_aggregate]
impl Aggregate for ScritchCollector {
type State = Option<pgrx::composite_type!('static, "Dog")>;
type Args = i32;

fn state(
current: Self::State,
arg: Self::Args,
_fcinfo: pg_sys::FunctionCallInfo,
) -> Self::State {
let mut current = match current {
Some(v) => v,
None => PgHeapTuple::new_composite_type(DOG_COMPOSITE_TYPE).unwrap(),
};
let current_scritches: i32 = current.get_by_name("scritches").unwrap().unwrap_or_default();
current.set_by_name("scritches", current_scritches + arg).unwrap();
Some(current)
}
}
// struct SumScritches;

/*
Create an operator allowing dogs to accept scritches directly.
Expand Down Expand Up @@ -298,14 +242,6 @@ mod tests {
Ok(())
}

#[pg_test]
fn test_scritch_collector() {
let retval = Spi::get_one::<i32>(
"SELECT (scritchcollector(value)).scritches FROM UNNEST(ARRAY [1,2,3]) as value;",
);
assert_eq!(retval, Ok(Some(6)));
}

#[pg_test]
fn test_dog_add_operator() {
let retval = Spi::get_one::<i32>("SELECT (ROW('Nami', 0)::Dog + 1).scritches;");
Expand Down
11 changes: 10 additions & 1 deletion pgrx-examples/custom_types/src/hexint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//LICENSE All rights reserved.
//LICENSE
//LICENSE Use of this source code is governed by the MIT license that can be found in the LICENSE file.
use pgrx::callconv::BoxRet;
use pgrx::callconv::{ArgAbi, BoxRet};
use pgrx::pg_sys::{Datum, Oid};
use pgrx::pgrx_sql_entity_graph::metadata::{
ArgumentError, Returns, ReturnsError, SqlMapping, SqlTranslatable,
Expand Down Expand Up @@ -94,6 +94,15 @@ impl IntoDatum for HexInt {
}
}

unsafe impl<'fcx> ArgAbi<'fcx> for HexInt
where
Self: 'fcx,
{
unsafe fn unbox_arg_unchecked(arg: ::pgrx::callconv::Arg<'_, 'fcx>) -> Self {
unsafe { arg.unbox_arg_using_from_datum().unwrap() }
}
}

unsafe impl BoxRet for HexInt {
unsafe fn box_in_fcinfo(self, _fcinfo: pg_sys::FunctionCallInfo) -> pg_sys::Datum {
Datum::from(self.value)
Expand Down
2 changes: 1 addition & 1 deletion pgrx-examples/strings/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ fn append(mut input: String, extra: &str) -> String {
}

#[pg_extern]
fn split(input: &'static str, pattern: &str) -> Vec<&'static str> {
fn split<'a>(input: &'a str, pattern: &str) -> Vec<&'a str> {
input.split_terminator(pattern).collect()
}

Expand Down
56 changes: 54 additions & 2 deletions pgrx-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,7 @@ pub fn postgres_enum(input: TokenStream) -> TokenStream {
fn impl_postgres_enum(ast: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let mut stream = proc_macro2::TokenStream::new();
let sql_graph_entity_ast = ast.clone();
let generics = &ast.generics;
let generics = &ast.generics.clone();
let enum_ident = &ast.ident;
let enum_name = enum_ident.to_string();

Expand All @@ -666,6 +666,24 @@ fn impl_postgres_enum(ast: DeriveInput) -> syn::Result<proc_macro2::TokenStream>
into_datum.extend(quote! { #enum_ident::#label_ident => Some(::pgrx::enum_helper::lookup_enum_by_label(#enum_name, #label_string)), });
}

// We need another variant of the params for the ArgAbi impl
let fcx_lt = syn::Lifetime::new("'fcx", proc_macro2::Span::mixed_site());
let mut generics_with_fcx = generics.clone();
// so that we can bound on Self: 'fcx
generics_with_fcx.make_where_clause().predicates.push(syn::WherePredicate::Type(
syn::PredicateType {
lifetimes: None,
bounded_ty: syn::parse_quote! { Self },
colon_token: syn::Token![:](proc_macro2::Span::mixed_site()),
bounds: syn::parse_quote! { #fcx_lt },
},
));
let (impl_gens, ty_gens, where_clause) = generics_with_fcx.split_for_impl();
let mut impl_gens: syn::Generics = syn::parse_quote! { #impl_gens };
impl_gens
.params
.insert(0, syn::GenericParam::Lifetime(syn::LifetimeParam::new(fcx_lt.clone())));

stream.extend(quote! {
impl ::pgrx::datum::FromDatum for #enum_ident {
#[inline]
Expand All @@ -683,6 +701,14 @@ fn impl_postgres_enum(ast: DeriveInput) -> syn::Result<proc_macro2::TokenStream>
}
}

unsafe impl #impl_gens ::pgrx::callconv::ArgAbi<#fcx_lt> for #enum_ident #ty_gens #where_clause {
unsafe fn unbox_arg_unchecked(arg: ::pgrx::callconv::Arg<'_, #fcx_lt>) -> Self {
let index = arg.index();
unsafe { arg.unbox_arg_using_from_datum().unwrap_or_else(|| panic!("argument {index} must not be null")) }
}

}

unsafe impl #generics ::pgrx::datum::UnboxDatum for #enum_ident #generics {
type As<'dat> = #enum_ident #generics where Self: 'dat;
#[inline]
Expand Down Expand Up @@ -761,7 +787,7 @@ pub fn postgres_type(input: TokenStream) -> TokenStream {

fn impl_postgres_type(ast: DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
let name = &ast.ident;
let generics = &ast.generics;
let generics = &ast.generics.clone();
let has_lifetimes = generics.lifetimes().next();
let funcname_in = Ident::new(&format!("{name}_in").to_lowercase(), name.span());
let funcname_out = Ident::new(&format!("{name}_out").to_lowercase(), name.span());
Expand Down Expand Up @@ -794,6 +820,24 @@ fn impl_postgres_type(ast: DeriveInput) -> syn::Result<proc_macro2::TokenStream>
None => quote! {'_},
};

// We need another variant of the params for the ArgAbi impl
let fcx_lt = syn::Lifetime::new("'fcx", proc_macro2::Span::mixed_site());
let mut generics_with_fcx = generics.clone();
// so that we can bound on Self: 'fcx
generics_with_fcx.make_where_clause().predicates.push(syn::WherePredicate::Type(
syn::PredicateType {
lifetimes: None,
bounded_ty: syn::parse_quote! { Self },
colon_token: syn::Token![:](proc_macro2::Span::mixed_site()),
bounds: syn::parse_quote! { #fcx_lt },
},
));
let (impl_gens, ty_gens, where_clause) = generics_with_fcx.split_for_impl();
let mut impl_gens: syn::Generics = syn::parse_quote! { #impl_gens };
impl_gens
.params
.insert(0, syn::GenericParam::Lifetime(syn::LifetimeParam::new(fcx_lt.clone())));

// all #[derive(PostgresType)] need to implement that trait
// and also the FromDatum and IntoDatum
stream.extend(quote! {
Expand Down Expand Up @@ -861,6 +905,14 @@ fn impl_postgres_type(ast: DeriveInput) -> syn::Result<proc_macro2::TokenStream>
<Self as ::pgrx::datum::FromDatum>::from_datum(::core::mem::transmute(datum), false).unwrap()
}
}

unsafe impl #impl_gens ::pgrx::callconv::ArgAbi<#fcx_lt> for #name #ty_gens #where_clause
{
unsafe fn unbox_arg_unchecked(arg: ::pgrx::callconv::Arg<'_, #fcx_lt>) -> Self {
let index = arg.index();
unsafe { arg.unbox_arg_using_from_datum().unwrap_or_else(|| panic!("argument {index} must not be null")) }
}
}
}
)
}
Expand Down
5 changes: 5 additions & 0 deletions pgrx-pg-sys/src/submodules/datum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ impl Datum {
sptr::Strict::addr(self.0)
}

#[inline]
pub const fn null() -> Datum {
Datum(core::ptr::null_mut())
}

/// True if the datum is equal to the null pointer.
#[inline]
pub fn is_null(self) -> bool {
Expand Down
103 changes: 53 additions & 50 deletions pgrx-sql-entity-graph/src/pg_extern/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ use quote::{format_ident, quote, quote_spanned};
use syn::parse::{Parse, ParseStream, Parser};
use syn::punctuated::Punctuated;
use syn::spanned::Spanned;
use syn::{Meta, Token, Type};
use syn::{Meta, Token};

/// A parsed `#[pg_extern]` item.
///
Expand Down Expand Up @@ -374,72 +374,75 @@ impl PgExtern {
}

pub fn wrapper_func(&self) -> Result<syn::ItemFn, syn::Error> {
let func_name = &self.func.sig.ident;
let is_raw = self.extern_attrs().contains(&Attribute::Raw);
// We use a `_` prefix to make functions with no args more satisfied during linting.
let fcinfo_ident = syn::Ident::new("_fcinfo", self.func.sig.ident.span());
let lifetimes =
self.func.sig.generics.lifetimes().collect::<syn::punctuated::Punctuated<_, Comma>>();
let fc_lt = syn::Lifetime::new("'fcx", Span::mixed_site());
let signature = &self.func.sig;
let func_name = &signature.ident;
// we do this odd dance so we can pass the same ident to macros that don't know each other
let fcinfo_ident = syn::Ident::new("fcinfo", signature.ident.span());
let mut lifetimes = signature
.generics
.lifetimes()
.cloned()
.collect::<syn::punctuated::Punctuated<_, Comma>>();
// we pick an arbitrary lifetime from the provided signature of the fn, if available,
// so lifetime-bound fn are easier to write with pgrx
let fc_lt = lifetimes
.first()
.map(|lt_p| lt_p.lifetime.clone())
.filter(|lt| lt.ident != "static")
.unwrap_or(syn::Lifetime::new("'fcx", Span::mixed_site()));
// we need the bare lifetime later, but we also jam it into the bounds if it is new
let fc_ltparam = syn::LifetimeParam::new(fc_lt.clone());
if lifetimes.first() != Some(&fc_ltparam) {
lifetimes.insert(0, fc_ltparam)
}

let args = &self.inputs;
// for unclear reasons the linker vomits if we don't do this
let arg_pats = args.iter().map(|v| format_ident!("{}_", &v.pat)).collect::<Vec<_>>();
let arg_fetches = args.iter().enumerate().map(|(idx, arg)| {
let pat = &arg_pats[idx];
let resolved_ty = &arg.used_ty.resolved_ty;
match resolved_ty {
// There's no danger of misinterpreting the type, as pointer coercions must typecheck!
Type::Path(ty_path) if ty_path.last_ident_is("FunctionCallInfo") => quote_spanned! { pat.span() =>
let #pat = #fcinfo_ident;
},
Type::Tuple(tup) if tup.elems.is_empty() => quote_spanned! { pat.span() =>
debug_assert!(unsafe { ::pgrx::fcinfo::pg_getarg::<()>(#fcinfo_ident, #idx).is_none() }, "A `()` argument should always receive `NULL`");
let #pat = ();
},
_ => match (is_raw, &arg.used_ty.optional) {
(true, None) | (true, Some(_)) => quote_spanned! { pat.span() =>
let #pat = unsafe { ::pgrx::fcinfo::pg_getarg_datum_raw(#fcinfo_ident, #idx) as #resolved_ty };
},
(false, None) => quote_spanned! { pat.span() =>
let #pat = unsafe { ::pgrx::fcinfo::pg_getarg::<#resolved_ty>(#fcinfo_ident, #idx).unwrap_or_else(|| panic!("{} is null", stringify!{#pat})) };
},
(false, Some(inner)) => quote_spanned! { pat.span() =>
let #pat = unsafe { ::pgrx::fcinfo::pg_getarg::<#inner>(#fcinfo_ident, #idx) };
},
let args_ident = proc_macro2::Ident::new("_args", Span::call_site());
let arg_fetches = arg_pats.iter().map(|pat| {
quote_spanned!{ pat.span() =>
let #pat = #args_ident.next_arg_unchecked().unwrap_or_else(|| panic!("unboxing {} argument failed", stringify!(#pat)));
}
}
});
);

match &self.returns {
Returning::None
| Returning::Type(_)
| Returning::SetOf { .. }
| Returning::Iterated { .. } => {
let ret_ty = match &self.func.sig.output {
let ret_ty = match &signature.output {
syn::ReturnType::Default => syn::parse_quote! { () },
syn::ReturnType::Type(_, ret_ty) => ret_ty.clone(),
};
let wrapper_code = quote_spanned! { self.func.block.span() =>
fn _internal_wrapper<#fc_ltparam, #lifetimes>(fcinfo: ::pgrx::callconv::Fcinfo<#fc_lt>) -> ::pgrx::datum::Datum<#fc_lt> {
#[allow(unused_unsafe)]
unsafe {
let #fcinfo_ident = fcinfo.0;
let result = match <#ret_ty as ::pgrx::callconv::RetAbi>::check_fcinfo_and_prepare(#fcinfo_ident) {
::pgrx::callconv::CallCx::WrappedFn(mcx) => {
let mut mcx = ::pgrx::PgMemoryContexts::For(mcx);
::pgrx::callconv::RetAbi::to_ret(mcx.switch_to(|_| {
#(#arg_fetches)*
#func_name( #(#arg_pats),* )
}))
}
::pgrx::callconv::CallCx::RestoreCx => <#ret_ty as ::pgrx::callconv::RetAbi>::ret_from_fcinfo_fcx(#fcinfo_ident),
};
::core::mem::transmute(unsafe { <#ret_ty as ::pgrx::callconv::RetAbi>::box_ret_in_fcinfo(#fcinfo_ident, result) })
}}
let fcinfo = ::pgrx::callconv::Fcinfo(#fcinfo_ident, ::core::marker::PhantomData);
fn _internal_wrapper<#lifetimes>(fcinfo: &mut ::pgrx::callconv::FcInfo<#fc_lt>) -> ::pgrx::datum::Datum<#fc_lt> {
#[allow(unused_unsafe)]
unsafe {
let call_flow = <#ret_ty as ::pgrx::callconv::RetAbi>::check_and_prepare(fcinfo);
let result = match call_flow {
::pgrx::callconv::CallCx::WrappedFn(mcx) => {
let mut mcx = ::pgrx::PgMemoryContexts::For(mcx);
let #args_ident = &mut fcinfo.args();
let call_result = mcx.switch_to(|_| {
#(#arg_fetches)*
#func_name( #(#arg_pats),* )
});
::pgrx::callconv::RetAbi::to_ret(call_result)
}
::pgrx::callconv::CallCx::RestoreCx => <#ret_ty as ::pgrx::callconv::RetAbi>::ret_from_fcx(fcinfo),
};
unsafe { <#ret_ty as ::pgrx::callconv::RetAbi>::box_ret_in(fcinfo, result) }
}
}
// We preserve the invariants
let datum = unsafe { ::pgrx::pg_sys::submodules::panic::pgrx_extern_c_guard(move || _internal_wrapper(fcinfo)) };
let datum = unsafe {
::pgrx::pg_sys::submodules::panic::pgrx_extern_c_guard(|| {
let mut fcinfo = ::pgrx::callconv::FcInfo::from_ptr(#fcinfo_ident);
_internal_wrapper(&mut fcinfo)
})
};
datum.sans_lifetime()
};
finfo_v1_extern_c(&self.func, fcinfo_ident, wrapper_code)
Expand Down
Loading
Loading