From 6b746e96e0fbf6d2a41fd3874e79e2db34d25d24 Mon Sep 17 00:00:00 2001 From: Ivan Dzyzenko Date: Sun, 28 Jun 2026 20:42:12 +0200 Subject: [PATCH 1/5] 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/5] 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 From f94c52f0fb4a0a5926239e3d33ac21a8c60e8f84 Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Wed, 1 Jul 2026 11:39:51 +0200 Subject: [PATCH 3/5] embed_params: Handle hash parameter format and OID properly This adds type casts like "::bytea" to the escaped and embedded values. To make that possible the OID => type name mapping is needed. It can be given as explicit CoderMapsBundle, is retrieved from BasicTypeMap* or retrieved from the database. Also allow `type_map` to be provided like `exec_params` allows. This patch changes `compile` to `embed_params` to be more specific. This patch changes the tests to compare `exec(embed_params())` to `exec_params()`. That ensures the processing by the database server is the same. Since binary format encoders are not usable for embedding into SQL text, this patch changes boolean encoders to text. There's no value in using a binary encoder for query parameters. It's not measurable faster than text for boolean type. --- lib/pg/basic_type_map_based_on_result.rb | 5 ++ lib/pg/basic_type_map_for_queries.rb | 9 ++- lib/pg/basic_type_map_for_results.rb | 5 ++ lib/pg/connection.rb | 56 +++++++++++--- .../pg/basic_type_map_based_on_result_spec.rb | 1 + spec/pg/basic_type_map_for_queries_spec.rb | 3 +- spec/pg/basic_type_map_for_results_spec.rb | 1 + spec/pg/connection_spec.rb | 76 +++++++++---------- 8 files changed, 100 insertions(+), 56 deletions(-) diff --git a/lib/pg/basic_type_map_based_on_result.rb b/lib/pg/basic_type_map_based_on_result.rb index 301bcbf5a..36d8d0dac 100644 --- a/lib/pg/basic_type_map_based_on_result.rb +++ b/lib/pg/basic_type_map_based_on_result.rb @@ -64,4 +64,9 @@ def initialize(connection_or_coder_maps, registry: nil) add_coder(coder) end end + + # Returns the PG::BasicTypeRegistry::CoderMapsBundle used to translate result OIDs to encoders. + def coder_maps_bundle + @coder_maps + end end diff --git a/lib/pg/basic_type_map_for_queries.rb b/lib/pg/basic_type_map_for_queries.rb index 3b0d492d9..926beb2e1 100644 --- a/lib/pg/basic_type_map_for_queries.rb +++ b/lib/pg/basic_type_map_for_queries.rb @@ -57,6 +57,11 @@ def initialize(connection_or_coder_maps, registry: nil, if_undefined: nil) init_encoders end + # Returns the PG::BasicTypeRegistry::CoderMapsBundle used to translate encoders to OIDs. + def coder_maps_bundle + @coder_maps + end + class UndefinedDefault def self.call(oid_name, format) raise UndefinedEncoder, "no encoder defined for type #{oid_name.inspect} format #{format}" @@ -177,8 +182,8 @@ def get_array_type(value) end DEFAULT_TYPE_MAP = PG.make_shareable({ - TrueClass => [1, 'bool', 'bool'], - FalseClass => [1, 'bool', 'bool'], + TrueClass => [0, 'bool', 'bool'], + FalseClass => [0, 'bool', 'bool'], # We use text format and no type OID for numbers, because setting the OID can lead # to unnecessary type conversions on server side. Integer => [0, 'int8'], diff --git a/lib/pg/basic_type_map_for_results.rb b/lib/pg/basic_type_map_for_results.rb index 929320673..015fa8c65 100644 --- a/lib/pg/basic_type_map_for_results.rb +++ b/lib/pg/basic_type_map_for_results.rb @@ -101,4 +101,9 @@ def initialize(connection_or_coder_maps, registry: nil) typenames = @coder_maps.typenames_by_oid self.default_type_map = WarningTypeMap.new(typenames) end + + # Returns the PG::BasicTypeRegistry::CoderMapsBundle used to translate result OIDs to decoders. + def coder_maps_bundle + @coder_maps + end end diff --git a/lib/pg/connection.rb b/lib/pg/connection.rb index 4fa2c2b8b..5983fa497 100644 --- a/lib/pg/connection.rb +++ b/lib/pg/connection.rb @@ -680,32 +680,66 @@ def cancel /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 string. + # + # The resulting SQL string can be used with +conn.exec+ like the prepared SQL statement and parameters with +conn.exec_params+. + # +conn.exec_params+ is usually preferred because it's faster and safer. + # +embed_params+ is intended for debugging messages with positional parameters. + # It avoids manual insertion for later inspection in +psql+ or so. # - # 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]) + # res = conn.embed_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) + def embed_params(sql, params, type_map: type_map_for_queries, coder_maps_bundle: nil) return sql if params.empty? - encoders = type_map_for_queries.query_param_encoders(params) + oid_to_typecast = proc do |oid| + if oid && oid > 0 + by_oid = if coder_maps_bundle + # Try to retrieve types from the method argument + coder_maps_bundle.typenames_by_oid + elsif type_map.respond_to?(:coder_maps_bundle) + # Try to retrieve types from the current type map + type_map.coder_maps_bundle.typenames_by_oid + elsif @typenames_by_oid + # Try to use cached types + @typenames_by_oid + else + # Load and cache types from the database server + @typenames_by_oid = PG::BasicTypeRegistry::CoderMapsBundle.new(self).typenames_by_oid + end + typename = by_oid[oid] || raise(ArgumentError, "cannot determine database type name of OID #{oid}") + "::#{ typename }" + end + end + + encoders = type_map.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) }'" + "'#{ 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) + "'#{escape(enc.encode(value))}'#{oid_to_typecast[enc.oid]}" + elsif Hash === value + next case value[:value] + when NilClass + 'NULL' + else + if value[:format] == 1 + raise ArgumentError, "binary encoded data with OID #{value[:type]} cannot be inserted into SQL text" if value[:type] && value[:type] != 17 + "'#{ PG::TextEncoder::Bytea.new.encode(value[:value].to_s) }'#{oid_to_typecast[value[:type]]}" + else + "'#{escape(value[:value].to_s)}'#{oid_to_typecast[value[:type]]}" + end + end + else + "'#{escape(value.to_s)}'" end - "'#{escape(value.to_s)}'" end end diff --git a/spec/pg/basic_type_map_based_on_result_spec.rb b/spec/pg/basic_type_map_based_on_result_spec.rb index 0d0666f24..a3cfdb289 100644 --- a/spec/pg/basic_type_map_based_on_result_spec.rb +++ b/spec/pg/basic_type_map_based_on_result_spec.rb @@ -13,6 +13,7 @@ maps = PG::BasicTypeRegistry::CoderMapsBundle.new(@conn).freeze tm = PG::BasicTypeMapBasedOnResult.new(maps) expect( tm.rm_coder(0, 16) ).to be_kind_of(PG::TextEncoder::Boolean) + expect( tm.coder_maps_bundle ).to eq(maps) end it "can be initialized with a custom type registry" do diff --git a/spec/pg/basic_type_map_for_queries_spec.rb b/spec/pg/basic_type_map_for_queries_spec.rb index 143afbf47..7f5c0bb8e 100644 --- a/spec/pg/basic_type_map_for_queries_spec.rb +++ b/spec/pg/basic_type_map_for_queries_spec.rb @@ -39,6 +39,7 @@ maps = PG::BasicTypeRegistry::CoderMapsBundle.new(@conn).freeze tm = PG::BasicTypeMapForQueries.new(maps) expect( tm[Integer] ).to be_kind_of(PG::TextEncoder::Integer) + expect( tm.coder_maps_bundle ).to eq(maps) end it "can be initialized with a custom type registry" do @@ -54,7 +55,7 @@ args = [] pr = proc { |*a| args << a } PG::BasicTypeMapForQueries.new(@conn, registry: regi, if_undefined: pr) - expect( args.first ).to eq( ["bool", 1] ) + expect( args.first ).to eq( ["bool", 0] ) end it "raises UndefinedEncoder for undefined types" do diff --git a/spec/pg/basic_type_map_for_results_spec.rb b/spec/pg/basic_type_map_for_results_spec.rb index d3176c43c..cee9b51a1 100644 --- a/spec/pg/basic_type_map_for_results_spec.rb +++ b/spec/pg/basic_type_map_for_results_spec.rb @@ -15,6 +15,7 @@ maps = PG::BasicTypeRegistry::CoderMapsBundle.new(@conn).freeze tm = PG::BasicTypeMapForResults.new(maps) expect( tm.rm_coder(0, 16) ).to be_kind_of(PG::TextDecoder::Boolean) + expect( tm.coder_maps_bundle ).to eq(maps) end it "can be initialized with a custom type registry" do diff --git a/spec/pg/connection_spec.rb b/spec/pg/connection_spec.rb index 2ab565f5f..df1acb7d5 100644 --- a/spec/pg/connection_spec.rb +++ b/spec/pg/connection_spec.rb @@ -3013,10 +3013,20 @@ def wait_check_socket(conn) end end - describe :compile do + describe :embed_params do + + def embed_params_and_check(sql, params, conn: @conn) + compiled = conn.embed_params(sql, params) + + res = conn.exec(compiled) + res2 = conn.exec_params(sql, params) + expect( res.to_a ).to eq( res2.to_a ), compiled + compiled + end + describe "default type map" do it "compiles prepared sql into plain sql" do - compiled = @conn.compile(<<~SQL, [1, "2", true, false, nil]) + compiled = embed_params_and_check(<<~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 @@ -3028,29 +3038,32 @@ def wait_check_socket(conn) 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"]) + embed_params_and_check(<<~SQL, ["', '1"]) select $1 as one SQL + end - expect(@conn.exec(compiled).first).to(eq("one" => "', '1")) + context "with params as Hash" do + it "encodes values properly" do + params = [ + {value: "\0\xff\r\n\t2'".b, format: 1}, + {value: "\0\xff\r\n\t1'".b, format: 1, type: 17}, + {value: "abc"}, + {value: 4}, + {value: 5, type: 23}, + {value: "{ 6, 7}", type: 1007}, + {value: false}, + {value: "\\x000102ff", type: 17}, + {value: nil} + ] + embed_params_and_check <<~SQL, params + select $1::bytea as a, $2 as b, $3 as c, $4 as d, $5 as e, $6 as f, $7 as g, $8 as h, $9 as i + SQL + end end end @@ -3066,7 +3079,7 @@ def wait_check_socket(conn) end it "compiles prepared sql into plain sql" do - compiled = @conn2.compile(<<~SQL, [1, "2", { foo: :bar }, [1], true, false, nil]) + compiled = embed_params_and_check(<<~SQL, [1, "2", { foo: :bar }, [1], true, false, nil], conn: @conn2) -- 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, @@ -3079,41 +3092,20 @@ def wait_check_socket(conn) 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"]) + embed_params_and_check(<<~SQL, ["', '1"], conn: @conn2) select $1 as one SQL - - 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]) + embed_params_and_check(<<~SQL, [binary], conn: @conn2) select $1::bytea as one SQL - - res = @conn2.exec(compiled).first - expect(res["one"]).to(eq(binary)) end end end From 9a055defb7f09648f37fe1d416cd5f70504afb60 Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Wed, 1 Jul 2026 12:22:45 +0200 Subject: [PATCH 4/5] Add some documentation and small improvement to `query_param_encoders` --- ext/pg_type_map.c | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/ext/pg_type_map.c b/ext/pg_type_map.c index 98f40a1fe..d5bb26d12 100644 --- a/ext/pg_type_map.c +++ b/ext/pg_type_map.c @@ -115,7 +115,19 @@ pg_typemap_s_allocate( VALUE klass ) return self; } - +/* + * call-seq: + * res.query_param_encoders(params) + * + * Retrieve the encoders that are used to encode the given values to be submitted to the database server. + * The selection of the encoders is defined in the derived type map class. + * + * +params+ must be an Array of values to be encoded. + * It's like +params+ given to exec_params . + * + * Returns an Array with the same length as +params+. + * + */ static VALUE pg_typemap_query_param_encoders( VALUE self, VALUE params ) { @@ -129,7 +141,7 @@ pg_typemap_query_param_encoders( VALUE self, VALUE params ) this->funcs.fit_to_query( self, params ); nParams = RARRAY_LENINT(params); - res = rb_ary_new(); + res = rb_ary_new2(nParams); for ( i = 0; i < nParams; i++ ) { t_pg_coder *conv; @@ -137,10 +149,7 @@ pg_typemap_query_param_encoders( VALUE self, VALUE params ) /* 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); + rb_ary_push(res, conv ? conv->coder_obj : Qnil); } return res; } From 807c5911608beeaee607e54aaead5251bf2eaa77 Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Wed, 1 Jul 2026 17:02:47 +0200 Subject: [PATCH 5/5] embed_params: Fix escaping bytea on legacy non standard conforming strings This issue was raised by a rails test: test/cases/adapters/postgresql/bytea_test.rb:114 --- lib/pg/connection.rb | 4 +-- spec/pg/connection_spec.rb | 58 +++++++++++++++++++++++++------------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/lib/pg/connection.rb b/lib/pg/connection.rb index 5983fa497..5c4c97327 100644 --- a/lib/pg/connection.rb +++ b/lib/pg/connection.rb @@ -720,7 +720,7 @@ def embed_params(sql, params, type_map: type_map_for_queries, coder_maps_bundle: when NilClass 'NULL' when PG::BasicTypeMapForQueries::BinaryData - "'#{ PG::TextEncoder::Bytea.new.encode(value) }'" + "'#{ escape_bytea(value) }'" else if enc raise ArgumentError, "binary encoded data from #{enc} cannot be inserted into SQL text" if enc.format != 0 @@ -732,7 +732,7 @@ def embed_params(sql, params, type_map: type_map_for_queries, coder_maps_bundle: else if value[:format] == 1 raise ArgumentError, "binary encoded data with OID #{value[:type]} cannot be inserted into SQL text" if value[:type] && value[:type] != 17 - "'#{ PG::TextEncoder::Bytea.new.encode(value[:value].to_s) }'#{oid_to_typecast[value[:type]]}" + "'#{ escape_bytea(value[:value].to_s) }'#{oid_to_typecast[value[:type]]}" else "'#{escape(value[:value].to_s)}'#{oid_to_typecast[value[:type]]}" end diff --git a/spec/pg/connection_spec.rb b/spec/pg/connection_spec.rb index df1acb7d5..e6cfb7512 100644 --- a/spec/pg/connection_spec.rb +++ b/spec/pg/connection_spec.rb @@ -3024,6 +3024,15 @@ def embed_params_and_check(sql, params, conn: @conn) compiled end + def with_std_conf_strings(conn, onoff) + conn.exec("SET standard_conforming_strings = #{onoff}") + conn.exec("SET escape_string_warning = #{onoff}") + yield + ensure + conn.exec("SET standard_conforming_strings = on") + conn.exec("SET escape_string_warning = on") + end + describe "default type map" do it "compiles prepared sql into plain sql" do compiled = embed_params_and_check(<<~SQL, [1, "2", true, false, nil]) @@ -3048,21 +3057,26 @@ def embed_params_and_check(sql, params, conn: @conn) end context "with params as Hash" do - it "encodes values properly" do - params = [ - {value: "\0\xff\r\n\t2'".b, format: 1}, - {value: "\0\xff\r\n\t1'".b, format: 1, type: 17}, - {value: "abc"}, - {value: 4}, - {value: 5, type: 23}, - {value: "{ 6, 7}", type: 1007}, - {value: false}, - {value: "\\x000102ff", type: 17}, - {value: nil} - ] - embed_params_and_check <<~SQL, params - select $1::bytea as a, $2 as b, $3 as c, $4 as d, $5 as e, $6 as f, $7 as g, $8 as h, $9 as i - SQL + + ['on', 'off'].each do |stdconf| + it "encodes values properly with std conforming strings=#{stdconf}" do + with_std_conf_strings(@conn, stdconf) do + params = [ + {value: "'\x1F\\".b, format: 1}, + {value: "'\0\xff\r\n\t1'".b, format: 1, type: 17}, + {value: "abc"}, + {value: 4}, + {value: 5, type: 23}, + {value: "{ 6, 7}", type: 1007}, + {value: false}, + {value: "\\x000102ff", type: 17}, + {value: nil} + ] + embed_params_and_check <<~SQL, params + select $1::bytea as a, $2 as b, $3 as c, $4 as d, $5 as e, $6 as f, $7 as g, $8 as h, $9 as i + SQL + end + end end end end @@ -3101,11 +3115,15 @@ def embed_params_and_check(sql, params, conn: @conn) SQL end - it "encodes binary strings properly" do - binary = PG::BasicTypeMapForQueries::BinaryData.new("\0\xff\r\n\t'".b) - embed_params_and_check(<<~SQL, [binary], conn: @conn2) - select $1::bytea as one - SQL + ['on', 'off'].each do |stdconf| + it "encodes binary strings properly with std conforming strings=#{stdconf}" do + with_std_conf_strings(@conn, stdconf) do + binary = PG::BasicTypeMapForQueries::BinaryData.new("''\0\xff\r\n\t'".b) + embed_params_and_check(<<~SQL, [binary], conn: @conn2) + select $1::bytea as one + SQL + end + end end end end