diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 48b172f..3173c80 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,9 +34,10 @@ jobs: name: ${{ matrix.ruby }} rails-${{ matrix.active-model }} couchbase-${{ matrix.couchbase }} steps: - uses: actions/checkout@v3 - - run: sudo apt-get update && sudo apt-get install libevent-dev libev-dev python3-httplib2 - - run: wget http://security.ubuntu.com/ubuntu/pool/universe/n/ncurses/libtinfo5_6.3-2ubuntu0.1_amd64.deb - - run: sudo apt install ./libtinfo5_6.3-2ubuntu0.1_amd64.deb + - run: | + sudo apt-get update + sudo apt-get install -y libevent-dev libev-dev python3-httplib2 + sudo apt-get install -y libtinfo5 || sudo apt-get install -y libtinfo6 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} diff --git a/docusaurus/docs/tutorial-ruby-couchbase-orm/07-sqlpp-queries.md b/docusaurus/docs/tutorial-ruby-couchbase-orm/07-sqlpp-queries.md index ee12175..9c0cefd 100644 --- a/docusaurus/docs/tutorial-ruby-couchbase-orm/07-sqlpp-queries.md +++ b/docusaurus/docs/tutorial-ruby-couchbase-orm/07-sqlpp-queries.md @@ -147,6 +147,47 @@ docs = N1QLTest.by_custom_rating_values(key: [[1, 2]]).collect { |ob| ob.name } In the above examples, the `collect` method is used to extract the `name` attribute from each document in the result set. +## 7.8 Prepared Statement Plan Caching + +Couchbase Server can cache the query execution plan for a SQL++ query so that subsequent executions skip the planning step. This is controlled by the `adhoc` query option: `adhoc: false` tells the server to prepare and cache the plan on first execution and reuse it on subsequent ones. + +### Default behaviour + +By default CouchbaseOrm runs queries with `adhoc: true` (the Couchbase SDK default), meaning no plan caching. This preserves the existing behaviour — you opt into plan caching explicitly. + +### Enabling caching for a specific call + +Pass `adhoc: false` directly to the query method to prepare and cache the plan (useful for frequently repeated queries): + +```ruby +# Cache the plan for this query +N1QLTest.by_rating(key: 1, adhoc: false) + +# Relation query with plan caching +User.where(country: 'FR').with(adhoc: false).to_a +``` + +### Enabling caching for a specific `n1ql` definition + +Set `adhoc: false` in the macro options to always cache the plan for that particular query: + +```ruby +n1ql :by_stable_filter, emit_key: [:name], adhoc: false +``` + +### Changing the global default + +Override the thread-local config to change the default for all queries in the current thread: + +```ruby +# Enable plan caching for all queries in this thread +CouchbaseOrm::N1ql.config(adhoc: false) +``` + +### Override priority + +From highest to lowest: **per-call kwarg** > **per-`n1ql`-definition option** > **`N1ql.config`** > **default (`true`)**. + ## 7.7 Indexing for SQL++ To optimize the performance of SQL++ queries, it's important to create appropriate indexes on the fields used in the query conditions. Couchbase Server provides a way to create indexes using the Index service. diff --git a/lib/couchbase-orm/n1ql.rb b/lib/couchbase-orm/n1ql.rb index e057bd3..51535cb 100644 --- a/lib/couchbase-orm/n1ql.rb +++ b/lib/couchbase-orm/n1ql.rb @@ -9,6 +9,7 @@ module N1ql extend ActiveSupport::Concern NO_VALUE = :no_value_specified DEFAULT_SCAN_CONSISTENCY = :request_plus + DEFAULT_ADHOC = true # sanitize for injection query def self.sanitize(value) if value.is_a?(String) @@ -22,9 +23,10 @@ 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: DEFAULT_ADHOC + }.merge(Thread.current['__couchbaseorm_n1ql_config__'] || {}) end module ClassMethods @@ -57,7 +59,10 @@ def n1ql(name, query_fn: nil, emit_key: [], custom_order: nil, **options) @indexes[name] = method_opts singleton_class.__send__(:define_method, name) do |key: NO_VALUE, **opts, &result_modifier| - opts = options.merge(opts).reverse_merge(scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency]) + opts = options.merge(opts).reverse_merge( + scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency], + adhoc: CouchbaseOrm::N1ql.config[:adhoc] + ) values = key == NO_VALUE ? NO_VALUE : convert_values(method_opts[:emit_key], key) current_query = run_query(method_opts[:emit_key], values, query_fn, custom_order: custom_order, **opts.except(:include_docs, :key)) if result_modifier diff --git a/lib/couchbase-orm/relation.rb b/lib/couchbase-orm/relation.rb index 984b93a..927efe8 100644 --- a/lib/couchbase-orm/relation.rb +++ b/lib/couchbase-orm/relation.rb @@ -3,7 +3,7 @@ module Relation extend ActiveSupport::Concern class CouchbaseOrm_Relation - def initialize(model:, where: where = nil, order: order = nil, limit: limit = nil, _not: _not = false, strict_loading: strict_loading = false) + def initialize(model:, where: where = nil, order: order = nil, limit: limit = nil, _not: _not = false, strict_loading: strict_loading = false, query_options: query_options = {}) CouchbaseOrm::logger.debug "CouchbaseOrm_Relation init: #{model} where:#{where.inspect} not:#{_not.inspect} order:#{order.inspect} limit: #{limit} strict_loading: #{strict_loading}" @model = model @limit = limit @@ -12,6 +12,7 @@ def initialize(model:, where: where = nil, order: order = nil, limit: limit = ni @order = merge_order(**order) if order @where = merge_where(where, _not) if where @strict_loading = strict_loading + @query_options = query_options || {} CouchbaseOrm::logger.debug "- #{to_s}" end @@ -70,6 +71,10 @@ def strict_loading? !!@strict_loading end + def with(opts = {}) + CouchbaseOrm_Relation.new(**initializer_arguments.merge(query_options: @query_options.merge(opts))) + end + def first n1ql_query, params = self.limit(1).to_n1ql_with_params result = @model.cluster.query(n1ql_query, build_query_options(positional_parameters: params)) @@ -168,7 +173,7 @@ def build_limit end def initializer_arguments - { model: @model, order: @order, where: @where, limit: @limit, strict_loading: @strict_loading } + { model: @model, order: @order, where: @where, limit: @limit, strict_loading: @strict_loading, query_options: @query_options } end def merge_order(*lorder, **horder) @@ -238,7 +243,7 @@ def build_update_with_params(params, **cond) end def build_query_options(positional_parameters: []) - opts = { scan_consistency: CouchbaseOrm::N1ql.config[:scan_consistency] } + opts = CouchbaseOrm::N1ql.config.merge(@query_options) opts[:positional_parameters] = positional_parameters unless positional_parameters.empty? Couchbase::Options::Query.new(**opts) end @@ -261,7 +266,7 @@ def relation delegate :ids, :update_all, :delete_all, :count, :empty?, :filter, :reduce, :find_by, to: :all - delegate :where, :not, :order, :limit, :all, :strict_loading, :strict_loading?, to: :relation + delegate :where, :not, :order, :limit, :all, :strict_loading, :strict_loading?, :with, to: :relation end end end diff --git a/spec/n1ql_spec.rb b/spec/n1ql_spec.rb index b1f7430..ca42599 100644 --- a/spec/n1ql_spec.rb +++ b/spec/n1ql_spec.rb @@ -196,6 +196,25 @@ class N1QLTest < CouchbaseOrm::Base end end + it "should use adhoc: true by default (no prepared statement plan caching)" do + expect(Couchbase::Options::Query).to receive(:new).with(hash_including(adhoc: true)).and_call_original + N1QLTest.by_rating_reverse() + end + + it "should allow overriding adhoc per call to enable plan caching" do + expect(Couchbase::Options::Query).to receive(:new).with(hash_including(adhoc: false)).and_call_original + N1QLTest.by_rating_reverse(adhoc: false) + end + + it "should respect N1ql.config adhoc setting" do + default_config = CouchbaseOrm::N1ql.config + CouchbaseOrm::N1ql.config({ adhoc: false }) + expect(Couchbase::Options::Query).to receive(:new).with(hash_including(adhoc: false)).and_call_original + N1QLTest.by_rating_reverse() + ensure + CouchbaseOrm::N1ql.config(default_config) + end + after(:all) do N1QLTest.delete_all end diff --git a/spec/relation_spec.rb b/spec/relation_spec.rb index f67f1fa..e9b4b84 100644 --- a/spec/relation_spec.rb +++ b/spec/relation_spec.rb @@ -501,5 +501,38 @@ def self.active end end end + + it "should use adhoc: true by default (no prepared statement plan caching)" do + expect(Couchbase::Options::Query).to receive(:new).with(hash_including(adhoc: true)).and_call_original + RelationModel.where(active: true).ids + end + + describe "adhoc option via with" do + it "should return a relation when calling with(adhoc:)" do + expect(RelationModel.all.with(adhoc: false)).to be_a(CouchbaseOrm::Relation::CouchbaseOrm_Relation) + end + + it "should pass adhoc: false to query options when set on the relation" do + expect(Couchbase::Options::Query).to receive(:new).with(hash_including(adhoc: false)).and_call_original + RelationModel.where(active: true).with(adhoc: false).ids + end + + it "should override N1ql.config adhoc when set on the relation" do + default_config = CouchbaseOrm::N1ql.config + CouchbaseOrm::N1ql.config(adhoc: false) + expect(Couchbase::Options::Query).to receive(:new).with(hash_including(adhoc: true)).and_call_original + RelationModel.where(active: true).with(adhoc: true).ids + ensure + CouchbaseOrm::N1ql.config(default_config) + end + + it "should be chainable with other relation methods" do + m1 = RelationModel.create!(active: true, age: 10) + _m2 = RelationModel.create!(active: false, age: 20) + expect(Couchbase::Options::Query).to receive(:new).with(hash_including(adhoc: false)).and_call_original + result = RelationModel.where(active: true).order(:age).with(adhoc: false).to_a + expect(result).to match_array([m1]) + end + end end