Parameterize N1QL queries with positional parameters#34
Conversation
Add serialize_for_binding(value) to convert Ruby types for N1QL binding (Time/DateTime → ISO8601, Date → string, others pass through). Add bind(value, params) to push values into a positional parameter array and return the $N placeholder. Add params: keyword argument to build_match, build_match_hash, build_match_range, and build_not_match. When params: is provided, values are bound via bind(); otherwise falls back to quote() for backward compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
build_where now accepts a params: keyword and binds the type filter and emit_key values as positional parameters ($1, $2, ...) instead of interpolating them into the query string. run_query creates a params array, passes it through build_where, and forwards positional_parameters to Couchbase::Options::Query. The debug log switches to a block form and includes the params array. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace string-interpolated values with positional parameters in all Relation query paths: - Add to_n1ql_with_params returning [query, params] - Add build_where_with_params, build_conds_with_params using bind() - Add build_update_with_params using bind() for both scalar and Hash values (fixes the injection vulnerability from build_update) - Add build_query_options helper for Couchbase::Options::Query - Update execute, query, first, last, update_all to use params Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace string interpolation in build_index_n1ql with positional parameters ($1 for type, $2 for foreign key value), enabling prepared-statement caching for has_many :through queries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…hing 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. serialize_for_binding now handles arrays recursively. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces parameterized queries to the Couchbase ORM to replace raw string interpolation, enhancing security against N1QL injection and improving query performance. It updates query-building methods across relations, N1QL execution, and helpers to bind values into positional parameters. The review feedback highlights two important issues: first, a mismatch in spec/relation_spec.rb where the test assertions for array parameterization do not align with the actual implementation (which binds the entire array as a single parameter); second, a bug in QueryHelper#bind where returning nil for nil values leads to invalid N1QL syntax during update_all operations instead of binding nil as a positional parameter.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| def bind(value, params) | ||
| if value.nil? | ||
| nil | ||
| else | ||
| params << serialize_for_binding(value) | ||
| "$#{params.length}" | ||
| end | ||
| end |
There was a problem hiding this comment.
Currently, bind returns nil when value is nil. In build_update_with_params, this causes update_all(field: nil) to generate invalid N1QL syntax like field = (with an empty value after the equals sign). Since build_match and build_not_match already handle nil values explicitly before calling bind, we can safely remove the nil check in bind so that nil is correctly bound as a positional parameter (e.g., field = $N with nil in params).
def bind(value, params)
params << serialize_for_binding(value)
"$#{params.length}"
endResolved conflict in lib/couchbase-orm/relation.rb: kept parameterized bind(v, params) for nested Hash attribute updates (from this branch) over the quote(v) inline approach added in PR #33 — bind is both injection-safe and enables prepared-statement caching. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…y_fn Couchbase::Options::Query exposes only a writer (scan_consistency=), not a reader. Reading options.scan_consistency raises NoMethodError at runtime. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… clause bind(nil) returns Ruby nil which interpolates to "" in a string, producing invalid N1QL like "child.name = ". Restore the old `|| 'NULL'` guard by checking nil before calling bind. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The code generates "type = $1" with spaces around =, consistent with all other conditions. The test incorrectly expected "type=$1". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
bind() passes the whole array as one positional parameter ($2), not one param per element. This is correct: "name IN $2" with $2 = ["Alice","Bob"] is valid N1QL and produces a single prepared-statement shape regardless of array length, which is the caching goal of this PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… positional parameter type/design_document is a class-level constant (equivalent to a table name) and should not consume a positional parameter slot. Interpolating it gives each model class its own query fingerprint for prepared-statement caching, consistent with how bucket.name is treated. Applies to all three callsites: n1ql.rb, relation.rb, and has_many.rb. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pimpin
left a comment
There was a problem hiding this comment.
Some points to check before merging this.
to_n1ql delegated to to_n1ql_with_params.first, which returns a query with positional placeholders ($1, $2) instead of literal values. This broke the method's main external use case: producing a query you can copy-paste into the database. Build the query with interpolated values (params: nil) so to_n1ql keeps its previous, human-readable output while to_n1ql_with_params remains the parameterized path used internally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lambda ->(v) { params ? bind(v, params) : quote(v) } was redefined
in build_match, build_match_hash, build_match_range and build_not_match.
Replace the four copies with a single resolve_value(value, params) helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The query_fn rebuilt a fresh Couchbase::Options::Query copying only scan_consistency, silently dropping every other option the caller set (timeout, adhoc, client_context_id, profile, etc.). Mutate the existing options object via #positional_parameters and pass it through, so all caller-supplied options are retained. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Replace string-interpolated values with positional parameter placeholders ($1, $2, ...) across all N1QL query paths. This makes query strings stable regardless of parameter values, enabling Couchbase prepared-statement caching.
Changes (6 commits)
serialize_for_binding/bindhelpers and parameterize query_helper — Building blocks: type conversion for binding and$Nplaceholder generation. Allbuild_match*methods accept optionalparams:keyword.build_whereandrun_queryuse positional parameters; debug log switches to block form with params.to_n1ql_with_params,build_where_with_params,build_update_with_params,build_query_options. All query paths (execute,query,first,last,update_all) use params.build_index_n1qluses$1/$2for type and foreign key.IN $1instead ofIN [$1, $2, ...]for stable query strings regardless of array length.ActiveSupport::TimeWithZone—acts_like?(:time)duck-typing inserialize_for_binding.Releasability
Queries are parameterized but
adhocremains at SDK default (true= no prepared-statement cache), so there is no behavioral change. The adhoc option will be added in a follow-up PR.Observability side effect
With parameterized queries, parameter values are no longer embedded in the
statementfield ofsystem:completed_requests(andsystem:active_requests). The statement now contains$1,$2, … placeholders regardless of the actual values.DBAs or monitoring tools that inspect
statementto retrieve query parameters must now read the separatepositionalArgsfield instead:positionalArgsis always populated when querying via N1QL (the keyspace callsFormatwithprofiling=true— seesystem_keyspace_request_log.go). It is absent only when hitting the REST endpoint withGET /admin/completed_requests.This separation is a net positive for performance analysis: multiple executions with different values share the same canonical
statement, making it straightforward to aggregate metrics per query pattern without manual string normalization.Test plan
/test --fail-fast=false🤖 Generated with Claude Code