From 6b746e96e0fbf6d2a41fd3874e79e2db34d25d24 Mon Sep 17 00:00:00 2001 From: Ivan Dzyzenko Date: Sun, 28 Jun 2026 20:42:12 +0200 Subject: [PATCH 1/2] Implement PG::Connection#complile --- lib/pg/connection.rb | 62 +++++++++++++++++++++++++ spec/pg/connection_spec.rb | 95 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/lib/pg/connection.rb b/lib/pg/connection.rb index 09e23b50c..d843262f7 100644 --- a/lib/pg/connection.rb +++ b/lib/pg/connection.rb @@ -669,6 +669,68 @@ def cancel end alias async_cancel cancel + PLACEHOLDER_RE = / + '(?:''|[^'])*' | # string literal + "(?:""|[^"])*" | # quoted identifier + --[^\n]* | # line comment + \/\*.*?\*\/ | # block comment + \$\$.*?\$\$ | # dollar-quoted string. E.g. $$ $1 $$ + \$(?<__dq_tag>[A-Za-z_][A-Za-z_0-9]*)\$.*?\$\k<__dq_tag>\$ | # named dollar-quoted string. E.g. $foo$ $1 $foo$ + (?\$(?:[1-9]\d*)) # placeholder we are interested in + /mx + private_constant :PLACEHOLDER_RE + + # call-seq: + # conn.compile( sql, params ) -> String + # + # Compiles your prepared sql statement and the given positional arguments into plain sql. + # Example: + # res = conn.exec_params('SELECT $1 AS a, $2 AS b, $3 AS c', [1, 2, nil]) + # # => "SELECT '1' AS a, '2' AS b, NULL AS c" + def compile(sql, params) + return sql if params.empty? + + sql.gsub(PLACEHOLDER_RE).each do |matched| + placeholder = Regexp.last_match[:placeholder] + # Do not replace non-positional args string and pass it as is + next matched unless placeholder + + value = params[placeholder[1..].to_i - 1] + value = encode_value(value) + normalize_value(value) + end + end + + private def encode_value(value) + return value if type_map_for_queries.is_a?(PG::TypeMapAllStrings) + unless type_map_for_queries.is_a?(PG::TypeMapByClass) + raise <<~TEXT.strip + Unsupported type map. Please use the one which is inherited from PG::TypeMapByClass, for example \ + PG::BasicTypeMapForQueries: + conn = PG::Connection.new + conn.type_map_for_queries = PG::BasicTypeMapForQueries.new(conn) + TEXT + end + + encoder = type_map_for_queries[value.class] + return type_map_for_queries.send(encoder, value).encode(value) if encoder.is_a?(Symbol) + # format == 1 stands for binary format + return value if encoder.nil? || encoder.format == 1 + + encoder.encode(value) + end + + private def normalize_value(value) + case value + when TrueClass, FalseClass + "#{value}" + when NilClass + 'NULL' + else + "'#{self.class.escape(value.to_s)}'" + end + end + module Pollable # Track the progress of the connection, waiting for the socket to become readable/writable before polling it. # diff --git a/spec/pg/connection_spec.rb b/spec/pg/connection_spec.rb index 1546344f7..4f9c0a61f 100644 --- a/spec/pg/connection_spec.rb +++ b/spec/pg/connection_spec.rb @@ -3013,6 +3013,101 @@ def wait_check_socket(conn) end end + describe :compile do + describe "default type map" do + it "compiles prepared sql into plain sql" do + compiled = @conn.compile(<<~SQL, [1, "2", true, false, nil]) + -- this is one: $1 + /* this is another one: $1 */ + select $1::int as a, $2 as b, $3 as c, $4 as d, $5 as e, '$5' as f, $$ $6 $$ as g, -- this is two: $2 + $body$ $1 $body$ as h, t."$1", t."$2" + from (select 10 as "$1", 20 as "$2") as t + SQL + + aggregate_failures do + expect(compiled).to include("-- this is one: $1") + expect(compiled).to include("/* this is another one: $1 */") + expect(compiled).to include("-- this is two: $2") + expect(@conn.exec(compiled).first).to( + eq( + "a" => "1", + "b" => "2", + "c" => "t", + "d" => "f", + "e" => nil, + "f" => "$5", + "g" => " $6 ", + "h" => " $1 ", + "$1" => "10", + "$2" => "20", + ), compiled + ) + end + end + + it "escapes strings properly" do + compiled = @conn.compile(<<~SQL, ["', '1"]) + select $1 as one + SQL + + expect(@conn.exec(compiled).first).to(eq("one" => "', '1")) + end + end + + describe "PG::TypeMapByClass type map" do + before do + @conn2 = PG.connect(@conninfo) + @conn2.type_map_for_queries = PG::BasicTypeMapForQueries.new(@conn2) + @conn2.type_map_for_results = PG::BasicTypeMapForResults.new(@conn2) + end + + after do + @conn2.close + end + + it "compiles prepared sql into plain sql" do + compiled = @conn2.compile(<<~SQL, [1, "2", { foo: :bar }, [1], true, false, nil]) + -- this is one: $1 + /* this is another one: $1 */ + select $1::int as a, $2 as b, $3::json as c, $4::int[] as d, '$5' as e, $$ $6 $$ as f, + $body$ $1 $body$ as g, -- this is two: $2 + t."$1", t."$2", $5 as h, $6 as i, $7 j + from (select 10 as "$1", 20 as "$2") as t + SQL + + aggregate_failures do + expect(compiled).to include("-- this is one: $1") + expect(compiled).to include("/* this is another one: $1 */") + expect(compiled).to include("-- this is two: $2") + expect(@conn2.exec(compiled).first).to( + eq( + "a" => 1, + "b" => "2", + "c" => { "foo" => "bar" }, + "d" => [1], + "e" => "$5", + "f" => " $6 ", + "g" => " $1 ", + "$1" => 10, + "$2" => 20, + "h" => true, + "i" => false, + "j" => nil + ) + ) + end + end + + it "escapes strings properly" do + compiled = @conn2.compile(<<~SQL, ["', '1"]) + select $1 as one + SQL + + expect(@conn2.exec(compiled).first).to(eq("one" => "', '1")) + end + end + end + describe "deprecated forms of methods" do if PG::VERSION < "2" it "should forward exec to exec_params" do From f5893b6b9eed77de7f94280622c805cec9cd2b05 Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Tue, 30 Jun 2026 14:59:37 +0200 Subject: [PATCH 2/2] Add a generic method to PG::TypeMap to retrieve encoders .. to replace code specific to the classes derived from PG::TypeMap. Also: - Deny binary format in SQL text - Make BinaryString working --- ext/pg_type_map.c | 31 ++++++++++++++++++++++ lib/pg/connection.rb | 53 ++++++++++++++------------------------ spec/pg/connection_spec.rb | 10 +++++++ 3 files changed, 61 insertions(+), 33 deletions(-) diff --git a/ext/pg_type_map.c b/ext/pg_type_map.c index 8f06cb12f..98f40a1fe 100644 --- a/ext/pg_type_map.c +++ b/ext/pg_type_map.c @@ -115,6 +115,36 @@ pg_typemap_s_allocate( VALUE klass ) return self; } + +static VALUE +pg_typemap_query_param_encoders( VALUE self, VALUE params ) +{ + t_typemap *this = RTYPEDDATA_DATA( self ); + int nParams; + int i=0; + VALUE res; + + Check_Type(params, T_ARRAY); + + this->funcs.fit_to_query( self, params ); + + nParams = RARRAY_LENINT(params); + res = rb_ary_new(); + + for ( i = 0; i < nParams; i++ ) { + t_pg_coder *conv; + VALUE param_value = rb_ary_entry(params, i); + + /* Let the given typemap select a coder for this param */ + conv = this->funcs.typecast_query_param(this, param_value, i); + if(conv) + rb_ary_push(res, conv->coder_obj); + else + rb_ary_push(res, Qnil); + } + return res; +} + /* * call-seq: * res.default_type_map = typemap @@ -194,6 +224,7 @@ init_pg_type_map(void) */ rb_cTypeMap = rb_define_class_under( rb_mPG, "TypeMap", rb_cObject ); rb_define_alloc_func( rb_cTypeMap, pg_typemap_s_allocate ); + rb_define_method( rb_cTypeMap, "query_param_encoders", pg_typemap_query_param_encoders, 1 ); rb_mDefaultTypeMappable = rb_define_module_under( rb_cTypeMap, "DefaultTypeMappable"); rb_define_method( rb_mDefaultTypeMappable, "default_type_map=", pg_typemap_default_type_map_set, 1 ); diff --git a/lib/pg/connection.rb b/lib/pg/connection.rb index d843262f7..4fa2c2b8b 100644 --- a/lib/pg/connection.rb +++ b/lib/pg/connection.rb @@ -690,44 +690,31 @@ def cancel def compile(sql, params) return sql if params.empty? + encoders = type_map_for_queries.query_param_encoders(params) + params = encoders.map.with_index do |enc, i| + value = params[i] + case value + when TrueClass, FalseClass + "#{value}" + when NilClass + 'NULL' + when PG::BasicTypeMapForQueries::BinaryData + value = "'#{ PG::TextEncoder::Bytea.new.encode(value) }'" + else + if enc + raise ArgumentError, "binary encoded data from #{enc} cannot be inserted into SQL text" if enc.format != 0 + value = enc.encode(value) + end + "'#{escape(value.to_s)}'" + end + end + sql.gsub(PLACEHOLDER_RE).each do |matched| placeholder = Regexp.last_match[:placeholder] # Do not replace non-positional args string and pass it as is next matched unless placeholder - value = params[placeholder[1..].to_i - 1] - value = encode_value(value) - normalize_value(value) - end - end - - private def encode_value(value) - return value if type_map_for_queries.is_a?(PG::TypeMapAllStrings) - unless type_map_for_queries.is_a?(PG::TypeMapByClass) - raise <<~TEXT.strip - Unsupported type map. Please use the one which is inherited from PG::TypeMapByClass, for example \ - PG::BasicTypeMapForQueries: - conn = PG::Connection.new - conn.type_map_for_queries = PG::BasicTypeMapForQueries.new(conn) - TEXT - end - - encoder = type_map_for_queries[value.class] - return type_map_for_queries.send(encoder, value).encode(value) if encoder.is_a?(Symbol) - # format == 1 stands for binary format - return value if encoder.nil? || encoder.format == 1 - - encoder.encode(value) - end - - private def normalize_value(value) - case value - when TrueClass, FalseClass - "#{value}" - when NilClass - 'NULL' - else - "'#{self.class.escape(value.to_s)}'" + params[placeholder[1..].to_i - 1] end end diff --git a/spec/pg/connection_spec.rb b/spec/pg/connection_spec.rb index 4f9c0a61f..2ab565f5f 100644 --- a/spec/pg/connection_spec.rb +++ b/spec/pg/connection_spec.rb @@ -3105,6 +3105,16 @@ def wait_check_socket(conn) expect(@conn2.exec(compiled).first).to(eq("one" => "', '1")) end + + it "encodes binary strings properly" do + binary = PG::BasicTypeMapForQueries::BinaryData.new("\0\xff\r\n\t'".b) + compiled = @conn2.compile(<<~SQL, [binary]) + select $1::bytea as one + SQL + + res = @conn2.exec(compiled).first + expect(res["one"]).to(eq(binary)) + end end end