Skip to content

Commit 158fd9c

Browse files
pimpinclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 01f8280 commit 158fd9c

6 files changed

Lines changed: 174 additions & 61 deletions

File tree

lib/couchbase-orm/n1ql.rb

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,17 @@ def convert_values(keys, values)
9595
end
9696
end
9797

98-
def build_where(keys, values)
98+
def build_where(keys, values, params: nil)
9999
where = values == NO_VALUE ? '' : keys.zip(Array.wrap(values))
100100
.reject { |key, value| key.nil? && value.nil? }
101-
.map { |key, value| build_match(key, value) }
101+
.map { |key, value| build_match(key, value, params: params) }
102102
.join(" AND ")
103-
"type=\"#{design_document}\" #{"AND " + where unless where.blank?}"
103+
if params
104+
type_placeholder = bind(design_document, params)
105+
"type=#{type_placeholder} #{"AND " + where unless where.blank?}"
106+
else
107+
"type=\"#{design_document}\" #{"AND " + where unless where.blank?}"
108+
end
104109
end
105110

106111
# 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
119124
N1qlProxy.new(query_fn.call(bucket, values, Couchbase::Options::Query.new(**options)))
120125
else
121126
bucket_name = bucket.name
122-
where = build_where(keys, values)
127+
params = []
128+
where = build_where(keys, values, params: params)
123129
order = custom_order || build_order(keys, descending)
124130
limit = build_limit(limit)
125131
n1ql_query = "select raw meta().id from `#{bucket_name}` where #{where} order by #{order} #{limit}"
126-
result = cluster.query(n1ql_query, Couchbase::Options::Query.new(**options))
127-
CouchbaseOrm.logger.debug "N1QL query: #{n1ql_query} return #{result.rows.to_a.length} rows with scan_consistency : #{options[:scan_consistency]}"
132+
133+
query_options = options.merge(positional_parameters: params)
134+
result = cluster.query(n1ql_query, Couchbase::Options::Query.new(**query_options))
135+
CouchbaseOrm.logger.debug {
136+
"N1QL query: #{n1ql_query} params: #{params.inspect} return #{result.rows.to_a.length} rows with scan_consistency: #{options[:scan_consistency]}"
137+
}
128138
N1qlProxy.new(result)
129139
end
130140
end

lib/couchbase-orm/relation.rb

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,32 +20,38 @@ def to_s
2020
end
2121

2222
def to_n1ql
23+
to_n1ql_with_params.first
24+
end
25+
26+
def to_n1ql_with_params
2327
bucket_name = @model.bucket.name
24-
where = build_where
28+
params = []
29+
where = build_where_with_params(params)
2530
order = build_order
2631
limit = build_limit
27-
"select raw meta().id from `#{bucket_name}` where #{where} order by #{order} #{limit}"
32+
["select raw meta().id from `#{bucket_name}` where #{where} order by #{order} #{limit}", params]
2833
end
2934

30-
def execute(n1ql_query)
31-
result = @model.cluster.query(n1ql_query, Couchbase::Options::Query.new(scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency]))
32-
CouchbaseOrm.logger.debug { "Relation query: #{n1ql_query} return #{result.rows.to_a.length} rows with scan_consistency : #{CouchbaseOrm::N1ql.config[:scan_consistency]}" }
35+
def execute(n1ql_query, params = [])
36+
result = @model.cluster.query(n1ql_query, build_query_options(positional_parameters: params))
37+
CouchbaseOrm.logger.debug { "Relation query: #{n1ql_query} params: #{params.inspect} return #{result.rows.to_a.length} rows" }
3338
N1qlProxy.new(result)
3439
end
3540

3641
def query
3742
CouchbaseOrm::logger.debug("Query: #{self}")
38-
n1ql_query = to_n1ql
39-
execute(n1ql_query)
43+
n1ql_query, params = to_n1ql_with_params
44+
execute(n1ql_query, params)
4045
end
41-
46+
4247
def update_all(**cond)
4348
bucket_name = @model.bucket.name
44-
where = build_where
49+
params = []
50+
where = build_where_with_params(params)
4551
limit = build_limit
46-
update = build_update(**cond)
52+
update = build_update_with_params(params, **cond)
4753
n1ql_query = "update `#{bucket_name}` set #{update} where #{where} #{limit}"
48-
execute(n1ql_query)
54+
execute(n1ql_query, params)
4955
end
5056

5157
def ids
@@ -61,14 +67,16 @@ def strict_loading?
6167
end
6268

6369
def first
64-
result = @model.cluster.query(self.limit(1).to_n1ql, Couchbase::Options::Query.new(scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency]))
70+
n1ql_query, params = self.limit(1).to_n1ql_with_params
71+
result = @model.cluster.query(n1ql_query, build_query_options(positional_parameters: params))
6572
return unless (first_id = result.rows.to_a.first)
6673

6774
@model.find(first_id, with_strict_loading: @strict_loading)
6875
end
6976

7077
def last
71-
result = @model.cluster.query(to_n1ql, Couchbase::Options::Query.new(scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency]))
78+
n1ql_query, params = to_n1ql_with_params
79+
result = @model.cluster.query(n1ql_query, build_query_options(positional_parameters: params))
7280
last_id = result.rows.to_a.last
7381
@model.find(last_id, with_strict_loading: @strict_loading) if last_id
7482
end
@@ -184,33 +192,33 @@ def build_order
184192
order.empty? ? "meta().id" : order
185193
end
186194

187-
def build_where
188-
build_conds([[:type, @model.design_document]] + @where)
195+
def build_where_with_params(params)
196+
build_conds_with_params([[:type, @model.design_document]] + @where, params)
189197
end
190198

191-
def build_conds(conds)
199+
def build_conds_with_params(conds, params)
192200
conds.map do |key, value, opt|
193201
if key
194-
opt == :not ?
195-
@model.build_not_match(key, value) :
196-
@model.build_match(key, value)
202+
opt == :not ?
203+
@model.build_not_match(key, value, params: params) :
204+
@model.build_match(key, value, params: params)
197205
else
198206
value
199207
end
200208
end.join(" AND ")
201209
end
202210

203-
def build_update(**cond)
211+
def build_update_with_params(params, **cond)
204212
cond.map do |key, value|
205-
for_clause=""
213+
for_clause = ""
206214
if value.is_a?(Hash) && value[:_for]
207215
path_clause = value.delete(:_for)
208216
var_clause = path_clause.to_s.split(".").last.singularize
209-
217+
210218
_when = value.delete(:_when)
211-
when_clause = _when ? build_conds(_when.to_a) : ""
212-
213-
_set = value.delete(:_set)
219+
when_clause = _when ? build_conds_with_params(_when.to_a, params) : ""
220+
221+
_set = value.delete(:_set)
214222
value = _set if _set
215223

216224
for_clause = " for #{var_clause} in #{path_clause} when #{when_clause} end"
@@ -220,11 +228,17 @@ def build_update(**cond)
220228
"#{key}.#{k} = #{v}"
221229
end.join(", ") + for_clause
222230
else
223-
"#{key} = #{@model.quote(value)}#{for_clause}"
231+
"#{key} = #{@model.bind(value, params)}#{for_clause}"
224232
end
225233
end.join(", ")
226234
end
227235

236+
def build_query_options(positional_parameters: [])
237+
opts = { scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency] }
238+
opts[:positional_parameters] = positional_parameters unless positional_parameters.empty?
239+
Couchbase::Options::Query.new(**opts)
240+
end
241+
228242
def method_missing(method, *args, &block)
229243
if @model.respond_to?(method)
230244
scoping {

lib/couchbase-orm/utilities/has_many.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,12 @@ def build_index_n1ql(klass, remote_class, remote_method, through_key, foreign_ke
9696
klass.class_eval do
9797
n1ql remote_method, emit_key: 'id', query_fn: proc { |bucket, values, options|
9898
raise ArgumentError, "values[0] must not be blank" if values[0].blank?
99-
cluster.query("SELECT raw #{through_key} FROM `#{bucket.name}` where type = \"#{design_document}\" and #{foreign_key} = #{quote(values[0])}", options)
99+
n1ql_query = "SELECT raw #{through_key} FROM `#{bucket.name}` where type = $1 and #{foreign_key} = $2"
100+
params = [design_document, values[0]]
101+
cluster.query(n1ql_query, Couchbase::Options::Query.new(
102+
positional_parameters: params,
103+
scan_consistency: options.scan_consistency
104+
))
100105
}
101106
end
102107
else

lib/couchbase-orm/utilities/query_helper.rb

Lines changed: 50 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,44 +4,67 @@ module QueryHelper
44

55
module ClassMethods
66

7-
def build_match(key, value)
7+
def serialize_for_binding(value)
8+
if [DateTime, Time].any? { |clazz| value.is_a?(clazz) }
9+
value.iso8601(@precision || 0)
10+
elsif value.is_a?(Date)
11+
value.to_s
12+
else
13+
value
14+
end
15+
end
16+
17+
def bind(value, params)
18+
if value.is_a?(Array)
19+
"[#{value.map { |v| bind(v, params) }.join(', ')}]"
20+
elsif value.nil?
21+
nil
22+
else
23+
params << serialize_for_binding(value)
24+
"$#{params.length}"
25+
end
26+
end
27+
28+
def build_match(key, value, params: nil)
829
use_is_null = self.properties_always_exists_in_document
930
key = "meta().id" if key.to_s == "id"
31+
resolve = ->(v) { params ? bind(v, params) : quote(v) }
1032
case
1133
when value.nil? && use_is_null
1234
"#{key} IS NULL"
1335
when value.nil? && !use_is_null
1436
"#{key} IS NOT VALUED"
1537
when value.is_a?(Hash) && attribute_types[key.to_s].is_a?(CouchbaseOrm::Types::Array)
16-
"any #{key.to_s.singularize} in #{key} satisfies (#{build_match_hash("#{key.to_s.singularize}", value)}) end"
38+
"any #{key.to_s.singularize} in #{key} satisfies (#{build_match_hash("#{key.to_s.singularize}", value, params: params)}) end"
1739
when value.is_a?(Hash) && !attribute_types[key.to_s].is_a?(CouchbaseOrm::Types::Array)
18-
build_match_hash(key, value)
40+
build_match_hash(key, value, params: params)
1941
when value.is_a?(Array) && value.include?(nil)
20-
"(#{build_match(key, nil)} OR #{build_match(key, value.compact)})"
42+
"(#{build_match(key, nil, params: params)} OR #{build_match(key, value.compact, params: params)})"
2143
when value.is_a?(Array)
22-
"#{key} IN #{quote(value)}"
44+
"#{key} IN #{resolve.call(value)}"
2345
when value.is_a?(Range)
24-
build_match_range(key, value)
46+
build_match_range(key, value, params: params)
2547
else
26-
"#{key} = #{quote(value)}"
48+
"#{key} = #{resolve.call(value)}"
2749
end
2850
end
2951

30-
def build_match_hash(key, value)
52+
def build_match_hash(key, value, params: nil)
3153
matches = []
54+
resolve = ->(v) { params ? bind(v, params) : quote(v) }
3255
value.each do |k, v|
3356
case k
3457
when :_gt
35-
matches << "#{key} > #{quote(v)}"
58+
matches << "#{key} > #{resolve.call(v)}"
3659
when :_gte
37-
matches << "#{key} >= #{quote(v)}"
60+
matches << "#{key} >= #{resolve.call(v)}"
3861
when :_lt
39-
matches << "#{key} < #{quote(v)}"
62+
matches << "#{key} < #{resolve.call(v)}"
4063
when :_lte
41-
matches << "#{key} <= #{quote(v)}"
64+
matches << "#{key} <= #{resolve.call(v)}"
4265
when :_ne
43-
matches << "#{key} != #{quote(v)}"
44-
66+
matches << "#{key} != #{resolve.call(v)}"
67+
4568
# TODO v2
4669
# when :_in
4770
# matches << "#{key} IN #{quote(v)}"
@@ -65,7 +88,7 @@ def build_match_hash(key, value)
6588
# matches << "#{key} MATCH #{quote(v)}"
6689
# when :_nmatch
6790
# matches << "#{key} NOT MATCH #{quote(v)}"
68-
91+
6992
# TODO v3
7093
# when :_any
7194
# matches << "#{key} ANY #{quote(v)}"
@@ -80,39 +103,41 @@ def build_match_hash(key, value)
80103
#when :_nwithin
81104
# matches << "#{key} NOT WITHIN #{quote(v)}"
82105
else
83-
matches << build_match("#{key}.#{k}", v)
106+
matches << build_match("#{key}.#{k}", v, params: params)
84107
end
85108
end
86-
109+
87110
matches.join(" AND ")
88111
end
89112

90-
def build_match_range(key, value)
113+
def build_match_range(key, value, params: nil)
114+
resolve = ->(v) { params ? bind(v, params) : quote(v) }
91115
matches = []
92-
matches << "#{key} >= #{quote(value.begin)}"
116+
matches << "#{key} >= #{resolve.call(value.begin)}"
93117
if value.exclude_end?
94-
matches << "#{key} < #{quote(value.end)}"
118+
matches << "#{key} < #{resolve.call(value.end)}"
95119
else
96-
matches << "#{key} <= #{quote(value.end)}"
120+
matches << "#{key} <= #{resolve.call(value.end)}"
97121
end
98122
matches.join(" AND ")
99123
end
100124

101125

102-
def build_not_match(key, value)
126+
def build_not_match(key, value, params: nil)
103127
use_is_null = self.properties_always_exists_in_document
104128
key = "meta().id" if key.to_s == "id"
129+
resolve = ->(v) { params ? bind(v, params) : quote(v) }
105130
case
106131
when value.nil? && use_is_null
107132
"#{key} IS NOT NULL"
108133
when value.nil? && !use_is_null
109134
"#{key} IS VALUED"
110135
when value.is_a?(Array) && value.include?(nil)
111-
"(#{build_not_match(key, nil)} AND #{build_not_match(key, value.compact)})"
136+
"(#{build_not_match(key, nil, params: params)} AND #{build_not_match(key, value.compact, params: params)})"
112137
when value.is_a?(Array)
113-
"#{key} NOT IN #{quote(value)}"
138+
"#{key} NOT IN #{resolve.call(value)}"
114139
else
115-
"#{key} != #{quote(value)}"
140+
"#{key} != #{resolve.call(value)}"
116141
end
117142
end
118143

spec/n1ql_spec.rb

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,19 +172,28 @@ class N1QLTest < CouchbaseOrm::Base
172172
it "should log the default scan_consistency when n1ql query is executed" do
173173
allow(CouchbaseOrm.logger).to receive(:debug)
174174
N1QLTest.by_rating_reverse()
175-
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}")
175+
expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once) do |&block|
176+
msg = block ? block.call : nil
177+
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}"
178+
end
176179
end
177180

178181
it "should log the set scan_consistency when n1ql query is executed with a specific scan_consistency" do
179182
allow(CouchbaseOrm.logger).to receive(:debug)
180183
default_n1ql_config = CouchbaseOrm::N1ql.config
181184
CouchbaseOrm::N1ql.config({ scan_consistency: :not_bounded })
182185
N1QLTest.by_rating_reverse()
183-
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")
186+
expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once) do |&block|
187+
msg = block ? block.call : nil
188+
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"
189+
end
184190

185191
CouchbaseOrm::N1ql.config(default_n1ql_config)
186192
N1QLTest.by_rating_reverse()
187-
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}")
193+
expect(CouchbaseOrm.logger).to have_received(:debug).at_least(:once) do |&block|
194+
msg = block ? block.call : nil
195+
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}"
196+
end
188197
end
189198

190199
after(:all) do

0 commit comments

Comments
 (0)