Skip to content

Add parameterized execute for postgres#122

Merged
liuzicheng1987 merged 1 commit intogetml:mainfrom
mcraveiro:f/add_bound_parameters
Feb 8, 2026
Merged

Add parameterized execute for postgres#122
liuzicheng1987 merged 1 commit intogetml:mainfrom
mcraveiro:f/add_bound_parameters

Conversation

@mcraveiro
Copy link
Contributor

Summary

Add support for parameterized queries using PostgreSQL's $1, $2, ... placeholder syntax. This enables safe parameter binding for:

  • Calling PostgreSQL functions without defining custom types
  • Executing dynamic queries with SQL injection protection
  • Passing NULL values via std::optional or std::nullopt

API

// Variadic execute with automatic type conversion
conn->execute("SELECT my_func($1, $2)", tenant_id, user_email);

// Supported types: string, numeric, bool, optional, nullptr
conn->execute("INSERT INTO t (a, b, c) VALUES ($1, $2, $3)",
              std::string("text"), 42, std::nullopt);

Implementation

  • Add PostgresV2Result::make() overload using PQexecParams
  • Add variadic execute() template to Connection with to_param() helper
  • Internal execute_params() method handles the actual libpq call

Test plan

  • execute_with_string_params - string and int parameters
  • execute_with_null_param - NULL via std::optional
  • execute_with_numeric_params - int, double, bool
  • execute_call_function - calling PL/pgSQL function

🤖 Generated with Claude Code

@liuzicheng1987
Copy link
Collaborator

@mcraveiro , what exactly is the use case for this? I think for something like SELECT and INSERT we already use stored procedures under-the-hood. In my view, executing raw strings should really be the exception, not the norm. So I would be interested what specific problem you are trying to solve here to figure out whether we could find a solution that does not rely on raw strings. If we were to find out that the problem cannot be solved without raw strings, I would be willing to merge this.

@mcraveiro
Copy link
Contributor Author

mcraveiro commented Feb 3, 2026

Hi, thanks for the review. So, we have a large number of utility stored procs we run on our database. These will increase in the future but for now we do things such as provisioning a new tenant, de-provisioning an existing tenant, de-provision all test tenents and so on. These are "true" stored procedures, in the sense that we are not really trying to return a result set. In addition, sometimes we need to supply arguments (e.g. tenant id). Sometimes it's just a function with no arguments. Basically all of our non-CRUD infrastructure relies on these.

How I bumped into this approach: I was using session to execute SQL. Claude started to use libpq to address Gemini code review comments about SQL injection as we were using raw strings concatenated to call the procs. Then we did some investigation to figure out the idiomatic way to do this in sqlgen. We explored adding types, but it does not make a lot of sense to create types for these procs because we'll end up with a myriad of tiny types just to call the proc and retrieve the result. So I thought this change could handle both issues at the same time - I don't want to use raw libpq if I can avoid it.

But yes, this is useful only to call "real" stored procedures.

@mcraveiro
Copy link
Contributor Author

hm, actually the more we discuss this the more I am thinking maybe the correct solution is to add types for each stored proc (_arguments and _results?). The types could be named after the stored proc, have the stored proc name (much like we can do for schema and table). Does sqlgen support selecting from a proc at present? Let's take this proc as an example:

create or replace function ores_iam_purge_tenant_fn(
    p_tenant_id uuid
) returns void as $$
...

I could create a ores_iam_purge_tenant_fn_arguments type, and then use sqlgen to generate the select for it which would map to:

select ores_iam_purge_tenant_fn("SOME_TENANT");

Am I going in the right direction?

@mcraveiro
Copy link
Contributor Author

In-line SQL and Setting up RLS

Another example of why we use inline SQL is RLS [1]:

  conn.execute("SELECT set_config('app.current_tenant_id', '" + tenant_id + "', false)");  

Which raises another point. When using RLS we need to setup variables for policies on each connection. This is a bit of a problem when using a pool because every time we get a connection off of the pool, we need to remember to run this little snippet. For now I have created my own connection pool which ensures it's setup correctly:

template <class Connection>
class tenant_aware_pool {
public:
    using init_callback = std::function<void(Connection&)>;
    tenant_aware_pool(sqlgen::ConnectionPool<Connection> pool,
                                       init_callback on_acquire)
    : pool_(std::move(pool)), on_acquire_(std::move(on_acquire)) {}

    sqlgen::Result<sqlgen::Ref<sqlgen::Session<Connection>>> acquire() noexcept {
        auto session_result = pool_.acquire();
        if (session_result && on_acquire_) {
            // Run init SQL on the connection
            auto& session = *session_result;
            session->execute(/* init SQL */);  // or call the callback
        }
        return session_result;
    }

    size_t available() const { return pool_.available(); }
    size_t size() const { return pool_.size(); }
private:
    sqlgen::ConnectionPool<Connection> pool_;
    init_callback on_acquire_;
};

// Custom session() overload for our wrapper
template <class Connection>
sqlgen::Result<sqlgen::Ref<sqlgen::Session<Connection>>> session(
    tenant_aware_pool<Connection>& pool) noexcept {
        return pool.acquire();
}

[1] An introduction to Postgres Row Level Security (RLS)

Support variadic execute with $1, $2, ... placeholders for safe parameter
binding. This allows calling PostgreSQL functions without defining custom
types.

Example:
  conn->execute("SELECT my_func($1, $2)", arg1, arg2);

Supported types: string, numeric, bool, optional, nullptr/nullopt for NULL.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@mcraveiro mcraveiro force-pushed the f/add_bound_parameters branch from a848c21 to 47e5711 Compare February 7, 2026 10:26
@mcraveiro
Copy link
Contributor Author

Reflection and Final Thoughts

OK so I had a think about all of this, and tried to summarise my thinking.

  • it seems there will always be cases where you want to run a query at the session level without having to bind it to a type. For example, running a stored procedure that does not return results.
  • since you already provide a way to run arbitrary SQL via the session, it seems logical to allow for constructing that SQL in a type-safe manner, else users of sqlgen will have to use libpq for this use case.
  • however, one should really discourage going via this API for the common case.

In light of this, I have:

  • removed the common case from the documentation, only put the "call a stored proc that returns no results".
  • will have a think at our use cases to unpick between those where we should have created a type (however "temporary"/"anonymous") and those where we should really invoke the proc directly and not worry about creating the type.

Hopefully this answers your concerns.

Cheers

@liuzicheng1987
Copy link
Collaborator

@mcraveiro , it does. Thank you for your contribution!

@liuzicheng1987 liuzicheng1987 merged commit 5768fb9 into getml:main Feb 8, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants