query_stash 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/cache"
5
+ require "active_support/core_ext/numeric/bytes"
6
+ require "active_support/isolated_execution_state"
7
+ require "active_support/notifications"
8
+
9
+ # The railtie is the only part of QueryStash that expects ActiveSupport: it hands the rest what it
10
+ # needs from it, as options of `config.query_stash`.
11
+ module QueryStash
12
+ class Railtie < Rails::Railtie
13
+ # The options an adapter can set for itself, in `config.query_stash.active_record` and
14
+ # `config.query_stash.mongo`. What it does not set, it takes from `config.query_stash`.
15
+ ADAPTER_OPTIONS = %i[enabled isolated_state instrumenter error_reporter cache_store].freeze
16
+
17
+ # The adapters' sections of `config.query_stash`, which are not options of their own.
18
+ ADAPTER_SECTIONS = %i[active_record mongo sequel].freeze
19
+
20
+ class << self
21
+ # The `adapter` (QueryStash::ActiveRecord or QueryStash::Mongo) for the app's `config`, with
22
+ # its own options over the ones of `config.query_stash`, and the cache store they name.
23
+ #: (singleton(Base) adapter, untyped config) -> Base
24
+ def build(adapter, config)
25
+ options = options_for(adapter, config)
26
+
27
+ # `cache_store` takes the same values as Rails' `config.cache_store`.
28
+ adapter.new(
29
+ **options.slice(:enabled, :isolated_state, :instrumenter, :error_reporter, :raise_on_cache_error),
30
+ cache_store: ActiveSupport::Cache.lookup_store(*Array(options.cache_store))
31
+ )
32
+ end
33
+
34
+ # `config.query_stash`, with what the adapter's own section sets on top of it. A section only
35
+ # holds what the user set, so an option it has, even a `false` or a `nil`, is the user's choice.
36
+ #: (singleton(Base) adapter, untyped config) -> untyped
37
+ def options_for(adapter, config)
38
+ root = config.query_stash
39
+ own = root[adapter.name.to_s.demodulize.underscore.to_sym] || {}
40
+
41
+ ActiveSupport::OrderedOptions.new.update(root.except(*ADAPTER_SECTIONS)).update(own.slice(*ADAPTER_OPTIONS))
42
+ end
43
+ end
44
+
45
+ # What follows is Rails' own DSL: it runs blocks with another `self` and reaches methods that are
46
+ # added at runtime, so there is nothing for Steep to check it against.
47
+ # steep:ignore:start
48
+ config.before_configuration do
49
+ config.query_stash = ActiveSupport::OrderedOptions.new.update(
50
+ enabled: true,
51
+ isolated_state: ActiveSupport::IsolatedExecutionState,
52
+ instrumenter: ActiveSupport::Notifications,
53
+ error_reporter: ActiveSupport.error_reporter,
54
+ cache_store: [:memory_store, { size: 128.megabytes, compress: true }],
55
+ raise_on_cache_error: Rails.env.development? || Rails.env.test?
56
+ )
57
+
58
+ # Only for the adapters that are loaded.
59
+ config.query_stash.active_record = ActiveSupport::OrderedOptions.new if defined?(::ActiveRecord::Railtie)
60
+ config.query_stash.mongo = ActiveSupport::OrderedOptions.new if defined?(::Rails::Mongoid::Railtie)
61
+ config.query_stash.sequel = ActiveSupport::OrderedOptions.new if defined?(::SequelRails::Railtie)
62
+ end
63
+
64
+ # Builds each adapter's instance from the app's config, which the environment files have already
65
+ # set by now, and before config/initializers run, so those can configure its rules.
66
+ #
67
+ # The middleware stack is built before ActiveRecord::Base and Mongoid::Document load, so the
68
+ # middlewares can't be added from their on_load hooks: they go in whenever the framework is present.
69
+ initializer "query_stash", before: :load_config_initializers do |app|
70
+ if defined?(::ActiveRecord::Railtie)
71
+ require_relative "active_record"
72
+
73
+ QueryStash::ActiveRecord.set(app, QueryStash::Railtie.build(QueryStash::ActiveRecord, app.config))
74
+ app.config.middleware.use QueryStash::ActiveRecord::Middleware, app
75
+
76
+ # What the stash does for the Rails in use needs ActiveRecord.
77
+ ActiveSupport.on_load(:active_record) do
78
+ QueryStash::ActiveRecord[app].extend(QueryStash::ActiveRecord.implementation::Stash)
79
+ end
80
+ end
81
+
82
+ if defined?(::Rails::Mongoid::Railtie)
83
+ require_relative "mongo"
84
+
85
+ QueryStash::Mongo.set(app, QueryStash::Railtie.build(QueryStash::Mongo, app.config))
86
+ app.config.middleware.use QueryStash::Mongo::Middleware, app
87
+ end
88
+
89
+ if defined?(::SequelRails::Railtie)
90
+ require_relative "sequel"
91
+
92
+ QueryStash::Sequel.set(app, QueryStash::Railtie.build(QueryStash::Sequel, app.config))
93
+ app.config.middleware.use QueryStash::Sequel::Middleware, app
94
+
95
+ # Every database that loads the extension is stashed by the app's stash.
96
+ QueryStash::Sequel.stash_builder = ->(_db) { QueryStash::Sequel[app] }
97
+ end
98
+ end
99
+
100
+ # The database the app uses is the one sequel-rails connected, which is `Sequel::Model.db`: by now it has
101
+ # connected, unless it was told not to. The other databases of the app are up to it (see the README).
102
+ config.after_initialize do
103
+ ::Sequel::Model.db.extension(:query_stash) if defined?(::SequelRails::Railtie) && ::Sequel::DATABASES.any?
104
+ end
105
+
106
+ initializer "query_stash.active_job" do |app|
107
+ ActiveSupport.on_load(:active_job) do
108
+ if defined?(::ActiveRecord::Railtie)
109
+ around_perform { |_job, block| QueryStash::ActiveRecord[app].enable!(&block) }
110
+ end
111
+
112
+ around_perform { |_job, block| QueryStash::Mongo[app].enable!(&block) } if defined?(::Rails::Mongoid::Railtie)
113
+
114
+ around_perform { |_job, block| QueryStash::Sequel[app].enable!(&block) } if defined?(::SequelRails::Railtie)
115
+ end
116
+ end
117
+
118
+ config.to_prepare do |_app|
119
+ ActiveSupport.on_load(:active_record) do
120
+ require_relative "active_record"
121
+
122
+ implementation = QueryStash::ActiveRecord.implementation
123
+ if implementation.const_defined?(:IntentPatch)
124
+ ::ActiveRecord::ConnectionAdapters::QueryIntent.prepend(implementation::IntentPatch)
125
+ end
126
+
127
+ %i[
128
+ active_record_sqlite3adapter
129
+ active_record_postgresqladapter
130
+ active_record_mysql2adapter
131
+ active_record_trilogyadapter
132
+ ].each do |hook|
133
+ ActiveSupport.on_load(hook) do
134
+ patch = implementation::AdapterPatch
135
+ prepend patch
136
+ end
137
+ end
138
+ end
139
+
140
+ ActiveSupport.on_load(:mongoid) do
141
+ require_relative "mongo"
142
+
143
+ ::Mongo::Collection::View::Iterable.prepend(
144
+ QueryStash::Mongo::AdapterPatch
145
+ )
146
+ end
147
+ end
148
+ # steep:ignore:end
149
+ end
150
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../registry"
4
+
5
+ module QueryStash
6
+ class Registry
7
+ # The rules of the adapters that stash SQL: a rule's selector is a regexp, matched against the query.
8
+ class Sql < Registry
9
+ # @rbs override
10
+ def add_rule(regex, ...)
11
+ raise Error, "Regex must be a Regexp, got #{regex.class}" if regex.class != Regexp
12
+
13
+ super
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryStash
4
+ class Registry
5
+ # @rbs!
6
+ # # How long a result is kept: a number of seconds, or something that works it out from the result.
7
+ # type ttl = _ToI | ^(untyped result) -> _ToI
8
+
9
+ Rule = Data.define(
10
+ :selector, #: untyped
11
+ :name, #: String
12
+ :ttl_seconds, #: ttl
13
+ :ttl_jitter_seconds, #: ttl
14
+ :enabled, #: (^() -> boolish)?
15
+ :store #: _CacheStore?
16
+ )
17
+
18
+ class Rule
19
+ # The seconds a result is kept for, for `result`.
20
+ #: (untyped result) -> Integer
21
+ def ttl_seconds_for(result)
22
+ seconds = ttl_seconds #: untyped
23
+ seconds.respond_to?(:call) ? seconds.call(result).to_i : seconds.to_i
24
+ end
25
+
26
+ # The jitter added to that, for `result`.
27
+ #: (untyped result) -> Integer
28
+ def ttl_jitter_seconds_for(result)
29
+ seconds = ttl_jitter_seconds #: untyped
30
+ seconds.respond_to?(:call) ? seconds.call(result).to_i : seconds.to_i
31
+ end
32
+ end
33
+
34
+ # What `set_rule_defaults` can set: the options of `add_rule` other than the selector and the name.
35
+ RULE_DEFAULTS = %i[ttl_seconds ttl_jitter_seconds enabled store].freeze #: Array[Symbol]
36
+
37
+ # What an option of `add_rule` is when it is not given (`nil` is a value too: `enabled: nil`).
38
+ UNSET = Object.new.freeze #: Object
39
+
40
+ # @rbs @rules: Array[Rule]
41
+ # @rbs @rule_defaults: Hash[Symbol, untyped]
42
+
43
+ attr_reader :rules #: Array[Rule]
44
+
45
+ #: () -> void
46
+ def initialize
47
+ @rules = []
48
+ @rule_defaults = {}
49
+ end
50
+
51
+ # Sets what the rules added from now on take for the options they don't give, so that a block of rules
52
+ # needs to say only what is different about each one:
53
+ #
54
+ # registry.set_rule_defaults(ttl_seconds: 60, ttl_jitter_seconds: 10)
55
+ # registry.add_rule(/SELECT .*users/, name: "users") # 60 seconds, 10 of jitter
56
+ # registry.add_rule(/SELECT .*orders/, name: "orders", ttl_seconds: 5) # 5 seconds, 10 of jitter
57
+ #
58
+ # The rules that exist are left as they are. A later call changes only the options it names, and `nil`
59
+ # takes a default away.
60
+ #
61
+ # @rbs **defaults: untyped
62
+ # @rbs return: Hash[Symbol, untyped]
63
+ def set_rule_defaults(**defaults)
64
+ unknown = defaults.keys - RULE_DEFAULTS
65
+ unless unknown.empty?
66
+ raise Error, "Unknown rule defaults: #{unknown.join(", ")} (use #{RULE_DEFAULTS.join(", ")})"
67
+ end
68
+
69
+ @rule_defaults = @rule_defaults.merge(defaults).compact
70
+ end
71
+
72
+ # `ttl_seconds` and `ttl_jitter_seconds` are required, unless `set_rule_defaults` gave them.
73
+ #
74
+ # @rbs selector: untyped
75
+ # @rbs name: String
76
+ # @rbs ttl_seconds: ttl | Object
77
+ # @rbs ttl_jitter_seconds: ttl | Object
78
+ # @rbs enabled: (^() -> boolish)? | Object
79
+ # @rbs store: _CacheStore? | Object
80
+ # @rbs return: Array[Rule]
81
+ def add_rule(
82
+ selector, # required
83
+ name:,
84
+ ttl_seconds: UNSET,
85
+ ttl_jitter_seconds: UNSET,
86
+ enabled: UNSET,
87
+ store: UNSET
88
+ )
89
+ raise Error, "Rule name is required" if name.nil? || name.empty?
90
+
91
+ raise Error, "Rule name #{name.inspect} is already taken" if @rules.any? { |r| r.name == name }
92
+
93
+ given = { ttl_seconds: ttl_seconds, ttl_jitter_seconds: ttl_jitter_seconds, enabled: enabled, store: store }
94
+ given = given.reject { |_, value| UNSET.equal?(value) }
95
+ options = { enabled: nil, store: nil }.merge(@rule_defaults, given) #: Hash[Symbol, untyped]
96
+ %i[ttl_seconds ttl_jitter_seconds].each do |required|
97
+ raise Error, "Rule #{name.inspect} needs #{required}, or a default for it" if options[required].nil?
98
+ end
99
+
100
+ @rules << Rule.new(
101
+ selector: selector,
102
+ name: name,
103
+ ttl_seconds: options[:ttl_seconds],
104
+ ttl_jitter_seconds: options[:ttl_jitter_seconds],
105
+ enabled: options[:enabled],
106
+ store: options[:store]
107
+ )
108
+ end
109
+
110
+ #: (String name) -> Array[Rule]
111
+ def delete_rule(name)
112
+ @rules.delete_if { |r| r.name == name }
113
+ end
114
+
115
+ # Selector / options / collection-based lookup
116
+ #: (untyped selector) -> Rule?
117
+ def match_rule(selector)
118
+ @rules.detect do |rule|
119
+ value_matches_pattern?(selector, rule.selector)
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ # Pattern matching for hashes.
126
+ #
127
+ # `pattern` is a subset / pattern-form of `full`:
128
+ # - wildcards and special values are interpreted via value_matches_pattern?
129
+ #
130
+ #: (Hash[untyped, untyped] full, Hash[untyped, untyped] pattern) -> bool
131
+ def hash_matches_pattern?(full, pattern)
132
+ pattern.all? do |k, expected|
133
+ full.key?(k) && value_matches_pattern?(full[k], expected)
134
+ end
135
+ end
136
+
137
+ # Pattern semantics:
138
+ #
139
+ # - Proc:
140
+ # pattern.call(actual) must be truthy
141
+ # - Regexp:
142
+ # actual.to_s must match pattern
143
+ # - Hash:
144
+ # pattern is a nested hash pattern, recurse
145
+ # - Array:
146
+ # pattern is a "prefix" array pattern:
147
+ # - pattern length <= actual length
148
+ # - each element is matched via value_matches_pattern?
149
+ # - Fallback:
150
+ # actual == pattern
151
+ #
152
+ #: (untyped actual, untyped pattern) -> bool
153
+ def value_matches_pattern?(actual, pattern)
154
+ # Proc – fully custom predicate (Note: ANY is not a Proc)
155
+ return !!pattern.call(actual) if pattern.respond_to?(:call)
156
+
157
+ # Regexp – string-like match
158
+ return pattern.match?(actual.to_s) if pattern.is_a?(Regexp)
159
+
160
+ # Hash – recursive hash pattern
161
+ return hash_matches_pattern?(actual, pattern) if pattern.is_a?(Hash) && actual.is_a?(Hash)
162
+
163
+ # Array – element-wise prefix pattern
164
+ if pattern.is_a?(Array) && actual.is_a?(Array)
165
+ return false if pattern.length > actual.length
166
+
167
+ return pattern.each_with_index.all? do |pat_elem, idx|
168
+ value_matches_pattern?(actual[idx], pat_elem)
169
+ end
170
+ end
171
+
172
+ # Fallback: strict equality
173
+ actual == pattern
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "sequel/core"
5
+ require_relative "base"
6
+ require_relative "registry/sql"
7
+
8
+ module QueryStash
9
+ # Stashes the `SELECT`s of Sequel datasets, matched by regexp like the ActiveRecord ones.
10
+ #
11
+ # A database stashes once it loads the extension, which gives it a stash of its own, from the options in its
12
+ # `:query_stash` option:
13
+ #
14
+ # DB = Sequel.connect(url, query_stash: { cache_store: store, enabled: true })
15
+ # DB.extension(:query_stash)
16
+ # QueryStash::Sequel[DB].configure { |rules| ... }
17
+ #
18
+ # (`cache_store:` and the options of `QueryStash::Base#initialize`.) With sequel-rails, the railtie makes it
19
+ # the app's stash instead: `Sequel::Model.db.extension(:query_stash)` is all it takes for another database.
20
+ #
21
+ # (Inside this class `Sequel` is this class: the library is `::Sequel`.)
22
+ class Sequel < Base
23
+ # What the patch asks of Sequel's own objects.
24
+ #
25
+ # @rbs!
26
+ # # A dataset, as far as the patch and the stash look at it.
27
+ # interface _Dataset
28
+ # def fetch_rows: (String sql) { (Hash[Symbol, untyped] row) -> untyped } -> untyped
29
+ #
30
+ # def db: () -> ::Sequel::Database
31
+ #
32
+ # def opts: () -> Hash[Symbol, untyped]
33
+ #
34
+ # def is_a?: (Module mod) -> bool
35
+ # end
36
+
37
+ CACHEABLE_SQL_REGEX = /\ASELECT/i #: Regexp
38
+ private_constant :CACHEABLE_SQL_REGEX
39
+
40
+ # Enables the stash for the duration of each request. `application` is the Rails application the
41
+ # middleware stack belongs to; `app` is the next middleware.
42
+ class Middleware
43
+ # @rbs @app: _RackApp
44
+ # @rbs @application: untyped
45
+
46
+ #: (_RackApp app, untyped application) -> void
47
+ def initialize(app, application)
48
+ @app = app
49
+ @application = application
50
+ end
51
+
52
+ #: (Hash[String, untyped] env) -> untyped
53
+ def call(env)
54
+ QueryStash::Sequel[@application].enable! { @app.call(env) }
55
+ end
56
+ end
57
+
58
+ # Included in the dataset class of each database that loads the extension. Every query a dataset runs
59
+ # goes through `fetch_rows`, which each adapter defines for its own dataset class, so this has to sit
60
+ # in front of those rather than in `Sequel::Dataset`.
61
+ #
62
+ # A database's stash is the one set for the database itself, which the extension does (see
63
+ # `QueryStash::Sequel.stash_builder`).
64
+ # @rbs module-self _Dataset
65
+ module DatasetPatch
66
+ #: (String sql) { (Hash[Symbol, untyped] row) -> untyped } -> untyped
67
+ def fetch_rows(sql, &consumer)
68
+ QueryStash::Sequel[db].wrap(self, sql, consumer) { |&rows| super(sql, &rows) }
69
+ end
70
+ end
71
+
72
+ # What builds the stash of a database that loads the extension without one: from its `:query_stash`
73
+ # option. (The railtie replaces it, to hand out the app's stash.)
74
+ DEFAULT_STASH_BUILDER = lambda do |db|
75
+ options = db.opts[:query_stash]
76
+ cache_store = options.is_a?(Hash) && options[:cache_store]
77
+ raise Error, "Give the database a :query_stash option with a :cache_store, to stash it" unless cache_store
78
+
79
+ QueryStash::Sequel.new(cache_store: cache_store, **options.except(:cache_store))
80
+ end #: ^(::Sequel::Database) -> QueryStash::Sequel
81
+
82
+ class << self
83
+ # @rbs self.@stash_builder: ^(::Sequel::Database) -> QueryStash::Sequel
84
+
85
+ # Builds the stash of a database that loads the extension without having one.
86
+ #: () -> (^(::Sequel::Database) -> QueryStash::Sequel)
87
+ def stash_builder
88
+ @stash_builder ||= DEFAULT_STASH_BUILDER
89
+ end
90
+
91
+ #: (^(::Sequel::Database) -> QueryStash::Sequel builder) -> void
92
+ attr_writer :stash_builder
93
+ end
94
+
95
+ ::Sequel::Database.register_extension(:query_stash) do |db|
96
+ QueryStash::Sequel.set(db, QueryStash::Sequel.stash_builder.call(db)) unless QueryStash::Sequel.key?(db)
97
+ db.extend_datasets(DatasetPatch)
98
+ end
99
+
100
+ # Runs the dataset's query through the stash: `cursor` fetches its rows into the block it is given,
101
+ # and `consumer` is the block `fetch_rows` was called with. (RBS can't say that a block takes a block,
102
+ # so the parameters of `cursor` are not typed.)
103
+ #: (_Dataset dataset, String sql, (^(Hash[Symbol, untyped]) -> untyped)? consumer) { (?) -> untyped } -> untyped
104
+ def wrap(dataset, sql, consumer, &cursor)
105
+ rule = rule_for(dataset, sql)
106
+ # Avoid eager loading every row unless this query actually matches a rule.
107
+ return cursor.call(&consumer) unless rule
108
+
109
+ rows = read(rule, dataset, sql) || write(rule, load_rows(&cursor), dataset, sql)
110
+ rows.each(&consumer)
111
+ end
112
+
113
+ # The rule the query is stashed under, or nil when it isn't to be stashed: the stash is off, it has
114
+ # binds or runs through a cursor (not supported), it is not a SELECT, or no rule matches it.
115
+ #: (_Dataset dataset, String sql) -> Registry::Rule?
116
+ def rule_for(dataset, sql)
117
+ return unless stashing?
118
+ return if bound?(dataset) || dataset.opts[:cursor]
119
+ return unless CACHEABLE_SQL_REGEX.match?(sql)
120
+
121
+ rule = registry.match_rule(sql)
122
+ rule if rule && (rule.enabled.nil? || rule.enabled.call)
123
+ end
124
+
125
+ # The same SQL is not the same query on another database, or on another server of the same one.
126
+ #: (_Dataset dataset, String sql) -> String
127
+ def cache_key_for(dataset, sql)
128
+ db = dataset.db
129
+ database = [db.database_type, db.opts[:host], db.opts[:database]].join("/")
130
+
131
+ "v1:#{dataset.opts[:server] || :default}:#{database}:#{Digest::SHA256.hexdigest(sql)}"
132
+ end
133
+
134
+ private
135
+
136
+ #: () -> Registry::Sql
137
+ def build_registry
138
+ Registry::Sql.new
139
+ end
140
+
141
+ # Whether the dataset is a prepared statement, or has bind variables.
142
+ #: (_Dataset dataset) -> bool
143
+ def bound?(dataset)
144
+ options = dataset.opts
145
+ !!(options[:bind_vars] || options[:bind_arguments] || dataset.is_a?(::Sequel::Dataset::PreparedStatementMethods))
146
+ end
147
+
148
+ #: () { (?) -> untyped } -> Array[Hash[Symbol, untyped]]
149
+ def load_rows(&cursor)
150
+ rows = [] #: Array[Hash[Symbol, untyped]]
151
+ cursor.call { |row| rows << row }
152
+ rows
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QueryStash
4
+ # Merging a change to this file into main releases the gem (.github/workflows/release.yml).
5
+ VERSION = "0.1.0" #: String
6
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "query_stash/version"
4
+ require_relative "query_stash/railtie" if defined?(Rails::Railtie)
5
+
6
+ module QueryStash
7
+ # What a stash needs from the objects it is given. None of them has to be an ActiveSupport one: the
8
+ # railtie hands over the ActiveSupport ones, and anything that responds the same way works.
9
+ #
10
+ # @rbs!
11
+ # # Where `enable!` and `disable!` keep what they set, like `ActiveSupport::IsolatedExecutionState`.
12
+ # interface _IsolatedState
13
+ # def []: (untyped key) -> untyped
14
+ #
15
+ # def []=: (untyped key, untyped value) -> untyped
16
+ #
17
+ # def key?: (untyped key) -> bool
18
+ #
19
+ # def delete: (untyped key) -> untyped
20
+ # end
21
+ #
22
+ # # Where results are kept, like an `ActiveSupport::Cache::Store`. `write` is given `expires_in:`.
23
+ # interface _CacheStore
24
+ # def read: (String key) -> untyped
25
+ #
26
+ # def write: (String key, untyped value, ?Hash[Symbol, untyped] options) -> untyped
27
+ #
28
+ # def clear: () -> untyped
29
+ # end
30
+ #
31
+ # # Told about what the stash does, like `ActiveSupport::Notifications`.
32
+ # interface _Instrumenter
33
+ # def instrument: (String name, ?Hash[Symbol, untyped] payload) ?{ (Hash[Symbol, untyped]) -> untyped } -> untyped
34
+ # end
35
+ #
36
+ # # Told about the errors the stash handles, like `ActiveSupport.error_reporter`.
37
+ # interface _ErrorReporter
38
+ # def report: (Exception error, ?handled: bool) -> untyped
39
+ # end
40
+ #
41
+ # # The next middleware.
42
+ # interface _RackApp
43
+ # def call: (Hash[String, untyped] env) -> untyped
44
+ # end
45
+
46
+ class Error < StandardError; end
47
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `Sequel::Database.extension(:query_stash)` (or `DB.extension(:query_stash)`) loads this, which registers
4
+ # the extension.
5
+ require "query_stash/sequel"
@@ -0,0 +1,118 @@
1
+ # Generated from lib/query_stash/active_record.rb with RBS::Inline
2
+
3
+ module QueryStash
4
+ class ActiveRecord < Base
5
+ # An ActiveRecord adapter, up to Rails 8.1.
6
+ interface _InternalExecQueryAdapter
7
+ def internal_exec_query: (String sql, ?String name, ?Array[untyped] binds, **untyped) -> ::ActiveRecord::Result
8
+ end
9
+
10
+ # An ActiveRecord adapter, from Rails 8.2.
11
+ interface _IntentAdapter
12
+ def execute_intent: (_QueryIntent intent) -> untyped
13
+
14
+ def cast_result: (untyped raw_result) -> ::ActiveRecord::Result
15
+ end
16
+
17
+ # A query, from Rails 8.2.
18
+ interface _QueryIntent
19
+ def raw_sql: () -> String
20
+
21
+ def has_binds?: () -> bool
22
+
23
+ def deliver_result: (::ActiveRecord::Result result) -> untyped
24
+
25
+ def cast_result: () -> ::ActiveRecord::Result
26
+ end
27
+
28
+ CACHEABLE_SQL_REGEX: Regexp
29
+
30
+ # Enables the stash for the duration of each request.
31
+ class Middleware
32
+ @app: _RackApp
33
+
34
+ @application: untyped
35
+
36
+ # : (_RackApp app, untyped application) -> void
37
+ def initialize: (_RackApp app, untyped application) -> void
38
+
39
+ # : (Hash[String, untyped] env) -> untyped
40
+ def call: (Hash[String, untyped] env) -> untyped
41
+ end
42
+
43
+ # Patch wraps queries run through `internal_exec_query` (rails 7.2 to 8.1).
44
+ # See `QueryStash::ActiveRecord.implementation`.
45
+ module InternalExecQuery
46
+ # @rbs module-self _InternalExecQueryAdapter
47
+ module AdapterPatch : _InternalExecQueryAdapter
48
+ # @rbs override
49
+ def internal_exec_query: ...
50
+ end
51
+
52
+ # @rbs module-self QueryStash::ActiveRecord
53
+ module Stash : QueryStash::ActiveRecord
54
+ # Runs the query through the stash, or just yields when the stash is off or the query isn't
55
+ # stashable.
56
+ # : (String sql, Array[untyped]? binds) { () -> ::ActiveRecord::Result } -> ::ActiveRecord::Result
57
+ def wrap: (String sql, Array[untyped]? binds) { () -> ::ActiveRecord::Result } -> ::ActiveRecord::Result
58
+ end
59
+ end
60
+
61
+ # Rails 8.2 runs queries as intents: the adapter's `execute_intent` runs one and hands its raw result
62
+ # back to the intent, which turns it into an ActiveRecord::Result in `QueryIntent#cast_result`.
63
+ module ExecuteIntent
64
+ # @rbs module-self _IntentAdapter
65
+ module AdapterPatch : _IntentAdapter
66
+ # : (_QueryIntent intent) -> untyped
67
+ def execute_intent: (_QueryIntent intent) -> untyped
68
+
69
+ private
70
+
71
+ # A stash hit is delivered as the ActiveRecord::Result it already is.
72
+ # : (untyped raw_result) -> ::ActiveRecord::Result
73
+ def cast_result: (untyped raw_result) -> ::ActiveRecord::Result
74
+ end
75
+
76
+ # @rbs module-self _QueryIntent
77
+ module IntentPatch : _QueryIntent
78
+ # : () -> ::ActiveRecord::Result
79
+ def cast_result: () -> ::ActiveRecord::Result
80
+ end
81
+
82
+ # @rbs module-self QueryStash::ActiveRecord
83
+ module Stash : QueryStash::ActiveRecord
84
+ @intent_rules: ObjectSpace::WeakMap[_QueryIntent, Registry::Rule]
85
+
86
+ # The rule each intent that missed the stash is to be stored under, until its result is cast.
87
+ # : (QueryStash::ActiveRecord stash) -> void
88
+ def self.extended: (QueryStash::ActiveRecord stash) -> void
89
+
90
+ # The stashed result of an intent (see AdapterPatch), or nil: when there is none, the intent is
91
+ # remembered for `write_intent` to store its result.
92
+ # : (_QueryIntent intent) -> ::ActiveRecord::Result?
93
+ def read_intent: (_QueryIntent intent) -> ::ActiveRecord::Result?
94
+
95
+ # Stores the result of an intent `read_intent` found nothing for.
96
+ # : (_QueryIntent intent, ::ActiveRecord::Result result) -> untyped
97
+ def write_intent: (_QueryIntent intent, ::ActiveRecord::Result result) -> untyped
98
+ end
99
+ end
100
+
101
+ # The implementation for the Rails in use: only to be asked once ActiveRecord is loaded.
102
+ # : () -> (singleton(InternalExecQuery) | singleton(ExecuteIntent))
103
+ def self.implementation: () -> (singleton(InternalExecQuery) | singleton(ExecuteIntent))
104
+
105
+ # The rule the query is stashed under, or nil when it isn't to be stashed: the stash is off, it has
106
+ # binds (not supported), it is not a SELECT, or no rule matches it.
107
+ # : (String sql, ?has_binds: bool?) -> Registry::Rule?
108
+ def rule_for: (String sql, ?has_binds: bool?) -> Registry::Rule?
109
+
110
+ # : (String sql) -> String
111
+ def cache_key_for: (String sql) -> String
112
+
113
+ private
114
+
115
+ # : () -> Registry::Sql
116
+ def build_registry: () -> Registry::Sql
117
+ end
118
+ end