From 158fd9c85adfe13f95cb522c8fe490165629383c Mon Sep 17 00:00:00 2001 From: Pierre Merlin Date: Wed, 24 Jun 2026 10:17:25 +0200 Subject: [PATCH 1/5] Parameterize N1QL queries with positional parameters Replace string-interpolated values with positional parameter placeholders ($1, $2, ...) across n1ql, relation, has_many, and query_helper modules. This enables Couchbase prepared-statement caching by making query strings stable regardless of parameter values. Co-Authored-By: Claude Opus 4.6 --- lib/couchbase-orm/n1ql.rb | 22 ++++-- lib/couchbase-orm/relation.rb | 66 +++++++++++------- lib/couchbase-orm/utilities/has_many.rb | 7 +- lib/couchbase-orm/utilities/query_helper.rb | 75 ++++++++++++++------- spec/n1ql_spec.rb | 15 ++++- spec/relation_spec.rb | 50 ++++++++++++++ 6 files changed, 174 insertions(+), 61 deletions(-) diff --git a/lib/couchbase-orm/n1ql.rb b/lib/couchbase-orm/n1ql.rb index 4dd5fe2..2b2e999 100644 --- a/lib/couchbase-orm/n1ql.rb +++ b/lib/couchbase-orm/n1ql.rb @@ -95,12 +95,17 @@ def convert_values(keys, values) end end - def build_where(keys, values) + def build_where(keys, values, params: nil) where = values == NO_VALUE ? '' : keys.zip(Array.wrap(values)) .reject { |key, value| key.nil? && value.nil? } - .map { |key, value| build_match(key, value) } + .map { |key, value| build_match(key, value, params: params) } .join(" AND ") - "type=\"#{design_document}\" #{"AND " + where unless where.blank?}" + if params + type_placeholder = bind(design_document, params) + "type=#{type_placeholder} #{"AND " + where unless where.blank?}" + else + "type=\"#{design_document}\" #{"AND " + where unless where.blank?}" + end end # order-by-clause ::= ORDER BY ordering-term [ ',' ordering-term ]* @@ -119,12 +124,17 @@ def run_query(keys, values, query_fn, custom_order: nil, descending: false, limi N1qlProxy.new(query_fn.call(bucket, values, Couchbase::Options::Query.new(**options))) else bucket_name = bucket.name - where = build_where(keys, values) + params = [] + where = build_where(keys, values, params: params) order = custom_order || build_order(keys, descending) limit = build_limit(limit) n1ql_query = "select raw meta().id from `#{bucket_name}` where #{where} order by #{order} #{limit}" - result = cluster.query(n1ql_query, Couchbase::Options::Query.new(**options)) - CouchbaseOrm.logger.debug "N1QL query: #{n1ql_query} return #{result.rows.to_a.length} rows with scan_consistency : #{options[:scan_consistency]}" + + query_options = options.merge(positional_parameters: params) + result = cluster.query(n1ql_query, Couchbase::Options::Query.new(**query_options)) + CouchbaseOrm.logger.debug { + "N1QL query: #{n1ql_query} params: #{params.inspect} return #{result.rows.to_a.length} rows with scan_consistency: #{options[:scan_consistency]}" + } N1qlProxy.new(result) end end diff --git a/lib/couchbase-orm/relation.rb b/lib/couchbase-orm/relation.rb index fc49511..896cae9 100644 --- a/lib/couchbase-orm/relation.rb +++ b/lib/couchbase-orm/relation.rb @@ -20,32 +20,38 @@ def to_s end def to_n1ql + to_n1ql_with_params.first + end + + def to_n1ql_with_params bucket_name = @model.bucket.name - where = build_where + params = [] + where = build_where_with_params(params) order = build_order limit = build_limit - "select raw meta().id from `#{bucket_name}` where #{where} order by #{order} #{limit}" + ["select raw meta().id from `#{bucket_name}` where #{where} order by #{order} #{limit}", params] end - def execute(n1ql_query) - result = @model.cluster.query(n1ql_query, Couchbase::Options::Query.new(scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency])) - CouchbaseOrm.logger.debug { "Relation query: #{n1ql_query} return #{result.rows.to_a.length} rows with scan_consistency : #{CouchbaseOrm::N1ql.config[:scan_consistency]}" } + def execute(n1ql_query, params = []) + result = @model.cluster.query(n1ql_query, build_query_options(positional_parameters: params)) + CouchbaseOrm.logger.debug { "Relation query: #{n1ql_query} params: #{params.inspect} return #{result.rows.to_a.length} rows" } N1qlProxy.new(result) end def query CouchbaseOrm::logger.debug("Query: #{self}") - n1ql_query = to_n1ql - execute(n1ql_query) + n1ql_query, params = to_n1ql_with_params + execute(n1ql_query, params) end - + def update_all(**cond) bucket_name = @model.bucket.name - where = build_where + params = [] + where = build_where_with_params(params) limit = build_limit - update = build_update(**cond) + update = build_update_with_params(params, **cond) n1ql_query = "update `#{bucket_name}` set #{update} where #{where} #{limit}" - execute(n1ql_query) + execute(n1ql_query, params) end def ids @@ -61,14 +67,16 @@ def strict_loading? end def first - result = @model.cluster.query(self.limit(1).to_n1ql, Couchbase::Options::Query.new(scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency])) + n1ql_query, params = self.limit(1).to_n1ql_with_params + result = @model.cluster.query(n1ql_query, build_query_options(positional_parameters: params)) return unless (first_id = result.rows.to_a.first) @model.find(first_id, with_strict_loading: @strict_loading) end def last - result = @model.cluster.query(to_n1ql, Couchbase::Options::Query.new(scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency])) + n1ql_query, params = to_n1ql_with_params + result = @model.cluster.query(n1ql_query, build_query_options(positional_parameters: params)) last_id = result.rows.to_a.last @model.find(last_id, with_strict_loading: @strict_loading) if last_id end @@ -184,33 +192,33 @@ def build_order order.empty? ? "meta().id" : order end - def build_where - build_conds([[:type, @model.design_document]] + @where) + def build_where_with_params(params) + build_conds_with_params([[:type, @model.design_document]] + @where, params) end - def build_conds(conds) + def build_conds_with_params(conds, params) conds.map do |key, value, opt| if key - opt == :not ? - @model.build_not_match(key, value) : - @model.build_match(key, value) + opt == :not ? + @model.build_not_match(key, value, params: params) : + @model.build_match(key, value, params: params) else value end end.join(" AND ") end - def build_update(**cond) + def build_update_with_params(params, **cond) cond.map do |key, value| - for_clause="" + for_clause = "" if value.is_a?(Hash) && value[:_for] path_clause = value.delete(:_for) var_clause = path_clause.to_s.split(".").last.singularize - + _when = value.delete(:_when) - when_clause = _when ? build_conds(_when.to_a) : "" - - _set = value.delete(:_set) + when_clause = _when ? build_conds_with_params(_when.to_a, params) : "" + + _set = value.delete(:_set) value = _set if _set for_clause = " for #{var_clause} in #{path_clause} when #{when_clause} end" @@ -220,11 +228,17 @@ def build_update(**cond) "#{key}.#{k} = #{v}" end.join(", ") + for_clause else - "#{key} = #{@model.quote(value)}#{for_clause}" + "#{key} = #{@model.bind(value, params)}#{for_clause}" end end.join(", ") end + def build_query_options(positional_parameters: []) + opts = { scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency] } + opts[:positional_parameters] = positional_parameters unless positional_parameters.empty? + Couchbase::Options::Query.new(**opts) + end + def method_missing(method, *args, &block) if @model.respond_to?(method) scoping { diff --git a/lib/couchbase-orm/utilities/has_many.rb b/lib/couchbase-orm/utilities/has_many.rb index 164b75f..965c68e 100644 --- a/lib/couchbase-orm/utilities/has_many.rb +++ b/lib/couchbase-orm/utilities/has_many.rb @@ -96,7 +96,12 @@ def build_index_n1ql(klass, remote_class, remote_method, through_key, foreign_ke klass.class_eval do n1ql remote_method, emit_key: 'id', query_fn: proc { |bucket, values, options| raise ArgumentError, "values[0] must not be blank" if values[0].blank? - cluster.query("SELECT raw #{through_key} FROM `#{bucket.name}` where type = \"#{design_document}\" and #{foreign_key} = #{quote(values[0])}", options) + n1ql_query = "SELECT raw #{through_key} FROM `#{bucket.name}` where type = $1 and #{foreign_key} = $2" + params = [design_document, values[0]] + cluster.query(n1ql_query, Couchbase::Options::Query.new( + positional_parameters: params, + scan_consistency: options.scan_consistency + )) } end else diff --git a/lib/couchbase-orm/utilities/query_helper.rb b/lib/couchbase-orm/utilities/query_helper.rb index 34b64c8..2855409 100644 --- a/lib/couchbase-orm/utilities/query_helper.rb +++ b/lib/couchbase-orm/utilities/query_helper.rb @@ -4,44 +4,67 @@ module QueryHelper module ClassMethods - def build_match(key, value) + def serialize_for_binding(value) + if [DateTime, Time].any? { |clazz| value.is_a?(clazz) } + value.iso8601(@precision || 0) + elsif value.is_a?(Date) + value.to_s + else + value + end + end + + def bind(value, params) + if value.is_a?(Array) + "[#{value.map { |v| bind(v, params) }.join(', ')}]" + elsif value.nil? + nil + else + params << serialize_for_binding(value) + "$#{params.length}" + end + end + + def build_match(key, value, params: nil) use_is_null = self.properties_always_exists_in_document key = "meta().id" if key.to_s == "id" + resolve = ->(v) { params ? bind(v, params) : quote(v) } case when value.nil? && use_is_null "#{key} IS NULL" when value.nil? && !use_is_null "#{key} IS NOT VALUED" when value.is_a?(Hash) && attribute_types[key.to_s].is_a?(CouchbaseOrm::Types::Array) - "any #{key.to_s.singularize} in #{key} satisfies (#{build_match_hash("#{key.to_s.singularize}", value)}) end" + "any #{key.to_s.singularize} in #{key} satisfies (#{build_match_hash("#{key.to_s.singularize}", value, params: params)}) end" when value.is_a?(Hash) && !attribute_types[key.to_s].is_a?(CouchbaseOrm::Types::Array) - build_match_hash(key, value) + build_match_hash(key, value, params: params) when value.is_a?(Array) && value.include?(nil) - "(#{build_match(key, nil)} OR #{build_match(key, value.compact)})" + "(#{build_match(key, nil, params: params)} OR #{build_match(key, value.compact, params: params)})" when value.is_a?(Array) - "#{key} IN #{quote(value)}" + "#{key} IN #{resolve.call(value)}" when value.is_a?(Range) - build_match_range(key, value) + build_match_range(key, value, params: params) else - "#{key} = #{quote(value)}" + "#{key} = #{resolve.call(value)}" end end - def build_match_hash(key, value) + def build_match_hash(key, value, params: nil) matches = [] + resolve = ->(v) { params ? bind(v, params) : quote(v) } value.each do |k, v| case k when :_gt - matches << "#{key} > #{quote(v)}" + matches << "#{key} > #{resolve.call(v)}" when :_gte - matches << "#{key} >= #{quote(v)}" + matches << "#{key} >= #{resolve.call(v)}" when :_lt - matches << "#{key} < #{quote(v)}" + matches << "#{key} < #{resolve.call(v)}" when :_lte - matches << "#{key} <= #{quote(v)}" + matches << "#{key} <= #{resolve.call(v)}" when :_ne - matches << "#{key} != #{quote(v)}" - + matches << "#{key} != #{resolve.call(v)}" + # TODO v2 # when :_in # matches << "#{key} IN #{quote(v)}" @@ -65,7 +88,7 @@ def build_match_hash(key, value) # matches << "#{key} MATCH #{quote(v)}" # when :_nmatch # matches << "#{key} NOT MATCH #{quote(v)}" - + # TODO v3 # when :_any # matches << "#{key} ANY #{quote(v)}" @@ -80,39 +103,41 @@ def build_match_hash(key, value) #when :_nwithin # matches << "#{key} NOT WITHIN #{quote(v)}" else - matches << build_match("#{key}.#{k}", v) + matches << build_match("#{key}.#{k}", v, params: params) end end - + matches.join(" AND ") end - def build_match_range(key, value) + def build_match_range(key, value, params: nil) + resolve = ->(v) { params ? bind(v, params) : quote(v) } matches = [] - matches << "#{key} >= #{quote(value.begin)}" + matches << "#{key} >= #{resolve.call(value.begin)}" if value.exclude_end? - matches << "#{key} < #{quote(value.end)}" + matches << "#{key} < #{resolve.call(value.end)}" else - matches << "#{key} <= #{quote(value.end)}" + matches << "#{key} <= #{resolve.call(value.end)}" end matches.join(" AND ") end - def build_not_match(key, value) + def build_not_match(key, value, params: nil) use_is_null = self.properties_always_exists_in_document key = "meta().id" if key.to_s == "id" + resolve = ->(v) { params ? bind(v, params) : quote(v) } case when value.nil? && use_is_null "#{key} IS NOT NULL" when value.nil? && !use_is_null "#{key} IS VALUED" when value.is_a?(Array) && value.include?(nil) - "(#{build_not_match(key, nil)} AND #{build_not_match(key, value.compact)})" + "(#{build_not_match(key, nil, params: params)} AND #{build_not_match(key, value.compact, params: params)})" when value.is_a?(Array) - "#{key} NOT IN #{quote(value)}" + "#{key} NOT IN #{resolve.call(value)}" else - "#{key} != #{quote(value)}" + "#{key} != #{resolve.call(value)}" end end diff --git a/spec/n1ql_spec.rb b/spec/n1ql_spec.rb index 1a41395..15dacdd 100644 --- a/spec/n1ql_spec.rb +++ b/spec/n1ql_spec.rb @@ -172,7 +172,10 @@ class N1QLTest < CouchbaseOrm::Base it "should log the default scan_consistency when n1ql query is executed" do allow(CouchbaseOrm.logger).to receive(:debug) N1QLTest.by_rating_reverse() - expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once).with("N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=\"n1_ql_test\" order by name DESC return 0 rows with scan_consistency : #{described_class::DEFAULT_SCAN_CONSISTENCY}") + expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once) do |&block| + msg = block ? block.call : nil + msg == "N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=$1 order by name DESC params: [\"n1_ql_test\"] return 0 rows with scan_consistency: #{described_class::DEFAULT_SCAN_CONSISTENCY}" + end end it "should log the set scan_consistency when n1ql query is executed with a specific scan_consistency" do @@ -180,11 +183,17 @@ class N1QLTest < CouchbaseOrm::Base default_n1ql_config = CouchbaseOrm::N1ql.config CouchbaseOrm::N1ql.config({ scan_consistency: :not_bounded }) N1QLTest.by_rating_reverse() - expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once).with("N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=\"n1_ql_test\" order by name DESC return 0 rows with scan_consistency : not_bounded") + expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once) do |&block| + msg = block ? block.call : nil + msg == "N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=$1 order by name DESC params: [\"n1_ql_test\"] return 0 rows with scan_consistency: not_bounded" + end CouchbaseOrm::N1ql.config(default_n1ql_config) N1QLTest.by_rating_reverse() - expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once).with("N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=\"n1_ql_test\" order by name DESC return 0 rows with scan_consistency : #{described_class::DEFAULT_SCAN_CONSISTENCY}") + expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once) do |&block| + msg = block ? block.call : nil + msg == "N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=$1 order by name DESC params: [\"n1_ql_test\"] return 0 rows with scan_consistency: #{described_class::DEFAULT_SCAN_CONSISTENCY}" + end end after(:all) do diff --git a/spec/relation_spec.rb b/spec/relation_spec.rb index f58c18c..0ad9b4a 100644 --- a/spec/relation_spec.rb +++ b/spec/relation_spec.rb @@ -310,6 +310,56 @@ def self.active expect(RelationModel.empty?).to eq(false) end + describe "parameterized queries" do + it "should return parameterized query with to_n1ql_with_params" do + relation = RelationModel.where(active: true, name: "Jane") + n1ql, params = relation.send(:to_n1ql_with_params) + expect(n1ql).to include("type=$1") + expect(n1ql).to include("active = $2") + expect(n1ql).to include("name = $3") + expect(n1ql).not_to include("\"relation_model\"") + expect(n1ql).not_to include("'Jane'") + expect(params).to eq(["relation_model", true, "Jane"]) + end + + it "should parameterize NOT conditions" do + relation = RelationModel.not(active: true) + n1ql, params = relation.send(:to_n1ql_with_params) + expect(n1ql).to include("active != $2") + expect(params).to eq(["relation_model", true]) + end + + it "should parameterize range conditions" do + relation = RelationModel.where(age: 10..30) + n1ql, params = relation.send(:to_n1ql_with_params) + expect(n1ql).to include("age >= $2") + expect(n1ql).to include("age <= $3") + expect(params).to eq(["relation_model", 10, 30]) + end + + it "should parameterize hash operator conditions" do + relation = RelationModel.where(age: { _gte: 18, _lt: 65 }) + n1ql, params = relation.send(:to_n1ql_with_params) + expect(n1ql).to include("age >= $2") + expect(n1ql).to include("age < $3") + expect(params).to eq(["relation_model", 18, 65]) + end + + it "should pass through string conditions without parameterization" do + relation = RelationModel.where("active = true") + n1ql, params = relation.send(:to_n1ql_with_params) + expect(n1ql).to include("(active = true)") + expect(params).to eq(["relation_model"]) + end + + it "should parameterize array IN conditions" do + relation = RelationModel.where(name: ["Alice", "Bob"]) + n1ql, params = relation.send(:to_n1ql_with_params) + expect(n1ql).to include("name IN [$2, $3]") + expect(params).to eq(["relation_model", "Alice", "Bob"]) + end + end + describe "operators" do it "should query by gte and lte" do _m1 = RelationModel.create!(age: 10) From 702c88c859ef8cd728fe0d2242e0d34848233679 Mon Sep 17 00:00:00 2001 From: Pierre Merlin Date: Wed, 24 Jun 2026 10:17:43 +0200 Subject: [PATCH 2/5] Add adhoc option to control prepared-statement caching Default adhoc to true in N1ql.config to preserve current behavior (queries are not cached). When set to false, Couchbase will cache the parameterized query plan, improving performance for repeated queries. The option is forwarded to Couchbase::Options::Query in both n1ql and relation query paths. Co-Authored-By: Claude Opus 4.6 --- lib/couchbase-orm/n1ql.rb | 8 +++++--- lib/couchbase-orm/relation.rb | 2 ++ spec/n1ql_spec.rb | 8 ++++---- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/couchbase-orm/n1ql.rb b/lib/couchbase-orm/n1ql.rb index 2b2e999..9d8c706 100644 --- a/lib/couchbase-orm/n1ql.rb +++ b/lib/couchbase-orm/n1ql.rb @@ -23,7 +23,8 @@ def self.sanitize(value) def self.config(new_config = nil) Thread.current['__couchbaseorm_n1ql_config__'] = new_config if new_config Thread.current['__couchbaseorm_n1ql_config__'] || { - scan_consistency: DEFAULT_SCAN_CONSISTENCY + scan_consistency: DEFAULT_SCAN_CONSISTENCY, + adhoc: true } end @@ -130,10 +131,11 @@ def run_query(keys, values, query_fn, custom_order: nil, descending: false, limi limit = build_limit(limit) n1ql_query = "select raw meta().id from `#{bucket_name}` where #{where} order by #{order} #{limit}" - query_options = options.merge(positional_parameters: params) + adhoc = options.delete(:adhoc) { CouchbaseOrm::N1ql.config[:adhoc] } + query_options = options.merge(positional_parameters: params, adhoc: adhoc) result = cluster.query(n1ql_query, Couchbase::Options::Query.new(**query_options)) CouchbaseOrm.logger.debug { - "N1QL query: #{n1ql_query} params: #{params.inspect} return #{result.rows.to_a.length} rows with scan_consistency: #{options[:scan_consistency]}" + "N1QL query: #{n1ql_query} params: #{params.inspect} return #{result.rows.to_a.length} rows with scan_consistency: #{options[:scan_consistency]} adhoc: #{adhoc}" } N1qlProxy.new(result) end diff --git a/lib/couchbase-orm/relation.rb b/lib/couchbase-orm/relation.rb index 896cae9..297339a 100644 --- a/lib/couchbase-orm/relation.rb +++ b/lib/couchbase-orm/relation.rb @@ -236,6 +236,8 @@ def build_update_with_params(params, **cond) def build_query_options(positional_parameters: []) opts = { scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency] } opts[:positional_parameters] = positional_parameters unless positional_parameters.empty? + adhoc = CouchbaseOrm::N1ql.config[:adhoc] + opts[:adhoc] = adhoc unless adhoc.nil? Couchbase::Options::Query.new(**opts) end diff --git a/spec/n1ql_spec.rb b/spec/n1ql_spec.rb index 15dacdd..45d8f8c 100644 --- a/spec/n1ql_spec.rb +++ b/spec/n1ql_spec.rb @@ -174,25 +174,25 @@ class N1QLTest < CouchbaseOrm::Base N1QLTest.by_rating_reverse() expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once) do |&block| msg = block ? block.call : nil - msg == "N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=$1 order by name DESC params: [\"n1_ql_test\"] return 0 rows with scan_consistency: #{described_class::DEFAULT_SCAN_CONSISTENCY}" + msg == "N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=$1 order by name DESC params: [\"n1_ql_test\"] return 0 rows with scan_consistency: #{described_class::DEFAULT_SCAN_CONSISTENCY} adhoc: true" end end it "should log the set scan_consistency when n1ql query is executed with a specific scan_consistency" do allow(CouchbaseOrm.logger).to receive(:debug) default_n1ql_config = CouchbaseOrm::N1ql.config - CouchbaseOrm::N1ql.config({ scan_consistency: :not_bounded }) + CouchbaseOrm::N1ql.config({ scan_consistency: :not_bounded, adhoc: true }) N1QLTest.by_rating_reverse() expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once) do |&block| msg = block ? block.call : nil - msg == "N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=$1 order by name DESC params: [\"n1_ql_test\"] return 0 rows with scan_consistency: not_bounded" + msg == "N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=$1 order by name DESC params: [\"n1_ql_test\"] return 0 rows with scan_consistency: not_bounded adhoc: true" end CouchbaseOrm::N1ql.config(default_n1ql_config) N1QLTest.by_rating_reverse() expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once) do |&block| msg = block ? block.call : nil - msg == "N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=$1 order by name DESC params: [\"n1_ql_test\"] return 0 rows with scan_consistency: #{described_class::DEFAULT_SCAN_CONSISTENCY}" + msg == "N1QL query: select raw meta().id from `#{CouchbaseOrm::Connection.bucket.name}` where type=$1 order by name DESC params: [\"n1_ql_test\"] return 0 rows with scan_consistency: #{described_class::DEFAULT_SCAN_CONSISTENCY} adhoc: true" end end From c2cffd3f9dc69d0e8b6e19df2e71f823f815655b Mon Sep 17 00:00:00 2001 From: Pierre Merlin Date: Wed, 24 Jun 2026 12:19:33 +0200 Subject: [PATCH 3/5] Fix N1QL injection in nested Hash attribute updates Parameterize Hash values in build_update_with_params using bind() instead of direct string interpolation, which was both a security vulnerability and defeated prepared-statement caching. Co-Authored-By: Claude Opus 4.6 --- lib/couchbase-orm/relation.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/couchbase-orm/relation.rb b/lib/couchbase-orm/relation.rb index 297339a..1264398 100644 --- a/lib/couchbase-orm/relation.rb +++ b/lib/couchbase-orm/relation.rb @@ -225,7 +225,7 @@ def build_update_with_params(params, **cond) end if value.is_a?(Hash) value.map do |k, v| - "#{key}.#{k} = #{v}" + "#{key}.#{k} = #{@model.bind(v, params)}" end.join(", ") + for_clause else "#{key} = #{@model.bind(value, params)}#{for_clause}" From cafe88e6383068337329f05b1ee18a3c3ec757ba Mon Sep 17 00:00:00 2001 From: Pierre Merlin Date: Wed, 24 Jun 2026 12:19:46 +0200 Subject: [PATCH 4/5] Pass arrays as single positional parameter for prepared-statement caching Instead of expanding arrays into individual parameters (e.g. IN [$1, $2]), pass the whole array as one parameter (e.g. IN $1). This keeps the query string stable regardless of array length, enabling Couchbase to cache the prepared statement for IN queries. Co-Authored-By: Claude Opus 4.6 --- lib/couchbase-orm/utilities/query_helper.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/couchbase-orm/utilities/query_helper.rb b/lib/couchbase-orm/utilities/query_helper.rb index 2855409..717bea5 100644 --- a/lib/couchbase-orm/utilities/query_helper.rb +++ b/lib/couchbase-orm/utilities/query_helper.rb @@ -5,7 +5,9 @@ module QueryHelper module ClassMethods def serialize_for_binding(value) - if [DateTime, Time].any? { |clazz| value.is_a?(clazz) } + if value.is_a?(Array) + value.map { |v| serialize_for_binding(v) } + elsif [DateTime, Time].any? { |clazz| value.is_a?(clazz) } value.iso8601(@precision || 0) elsif value.is_a?(Date) value.to_s @@ -15,9 +17,7 @@ def serialize_for_binding(value) end def bind(value, params) - if value.is_a?(Array) - "[#{value.map { |v| bind(v, params) }.join(', ')}]" - elsif value.nil? + if value.nil? nil else params << serialize_for_binding(value) From b3a759e2437998ad92dcd742d35d6e224f25021e Mon Sep 17 00:00:00 2001 From: Pierre Merlin Date: Wed, 24 Jun 2026 12:19:58 +0200 Subject: [PATCH 5/5] Support ActiveSupport::TimeWithZone in serialize_for_binding TimeWithZone does not inherit from Time or DateTime, so it was not being serialized to ISO8601. Use acts_like?(:time) duck-typing to handle all time-like objects from Rails. Co-Authored-By: Claude Opus 4.6 --- lib/couchbase-orm/utilities/query_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/couchbase-orm/utilities/query_helper.rb b/lib/couchbase-orm/utilities/query_helper.rb index 717bea5..0439e44 100644 --- a/lib/couchbase-orm/utilities/query_helper.rb +++ b/lib/couchbase-orm/utilities/query_helper.rb @@ -7,7 +7,7 @@ module ClassMethods def serialize_for_binding(value) if value.is_a?(Array) value.map { |v| serialize_for_binding(v) } - elsif [DateTime, Time].any? { |clazz| value.is_a?(clazz) } + elsif [DateTime, Time].any? { |clazz| value.is_a?(clazz) } || (value.respond_to?(:acts_like?) && value.acts_like?(:time)) value.iso8601(@precision || 0) elsif value.is_a?(Date) value.to_s