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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +477 -0
- data/lib/query_stash/active_record.rb +188 -0
- data/lib/query_stash/base.rb +275 -0
- data/lib/query_stash/mongo.rb +152 -0
- data/lib/query_stash/railtie.rb +150 -0
- data/lib/query_stash/registry/sql.rb +17 -0
- data/lib/query_stash/registry.rb +176 -0
- data/lib/query_stash/sequel.rb +155 -0
- data/lib/query_stash/version.rb +6 -0
- data/lib/query_stash.rb +47 -0
- data/lib/sequel/extensions/query_stash.rb +5 -0
- data/sig/query_stash/active_record.rbs +118 -0
- data/sig/query_stash/base.rbs +151 -0
- data/sig/query_stash/mongo.rbs +90 -0
- data/sig/query_stash/railtie.rbs +24 -0
- data/sig/query_stash/registry/sql.rbs +11 -0
- data/sig/query_stash/registry.rbs +114 -0
- data/sig/query_stash/sequel.rbs +97 -0
- data/sig/query_stash/version.rbs +6 -0
- data/sig/query_stash.rbs +41 -0
- data/sig/sequel/extensions/query_stash.rbs +2 -0
- metadata +82 -0
|
@@ -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
|
data/lib/query_stash.rb
ADDED
|
@@ -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,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
|