pg_plan_advice 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 +88 -0
- data/Rakefile +8 -0
- data/gemfiles/rails_7.2.gemfile +5 -0
- data/gemfiles/rails_8.0.gemfile +5 -0
- data/gemfiles/rails_8.1.gemfile +5 -0
- data/lib/pg_plan_advice/ar/execution.rb +94 -0
- data/lib/pg_plan_advice/ar/querying.rb +9 -0
- data/lib/pg_plan_advice/ar/relation.rb +39 -0
- data/lib/pg_plan_advice/ar/resolver.rb +20 -0
- data/lib/pg_plan_advice/ast/advice_set.rb +33 -0
- data/lib/pg_plan_advice/ast/bitmap_heap_scan.rb +8 -0
- data/lib/pg_plan_advice/ast/do_not_scan.rb +8 -0
- data/lib/pg_plan_advice/ast/foreign_join.rb +15 -0
- data/lib/pg_plan_advice/ast/gather.rb +8 -0
- data/lib/pg_plan_advice/ast/gather_merge.rb +8 -0
- data/lib/pg_plan_advice/ast/hash_join.rb +8 -0
- data/lib/pg_plan_advice/ast/index_only_scan.rb +8 -0
- data/lib/pg_plan_advice/ast/index_scan.rb +8 -0
- data/lib/pg_plan_advice/ast/index_target.rb +20 -0
- data/lib/pg_plan_advice/ast/join_order.rb +8 -0
- data/lib/pg_plan_advice/ast/merge_join_materialize.rb +8 -0
- data/lib/pg_plan_advice/ast/merge_join_plain.rb +8 -0
- data/lib/pg_plan_advice/ast/nested_loop_materialize.rb +8 -0
- data/lib/pg_plan_advice/ast/nested_loop_memoize.rb +8 -0
- data/lib/pg_plan_advice/ast/nested_loop_plain.rb +8 -0
- data/lib/pg_plan_advice/ast/no_gather.rb +8 -0
- data/lib/pg_plan_advice/ast/node.rb +62 -0
- data/lib/pg_plan_advice/ast/ordered_group.rb +11 -0
- data/lib/pg_plan_advice/ast/partitionwise.rb +8 -0
- data/lib/pg_plan_advice/ast/semijoin_non_unique.rb +8 -0
- data/lib/pg_plan_advice/ast/semijoin_unique.rb +8 -0
- data/lib/pg_plan_advice/ast/seq_scan.rb +8 -0
- data/lib/pg_plan_advice/ast/target.rb +39 -0
- data/lib/pg_plan_advice/ast/tid_scan.rb +8 -0
- data/lib/pg_plan_advice/ast/unordered_group.rb +11 -0
- data/lib/pg_plan_advice/ast.rb +32 -0
- data/lib/pg_plan_advice/builder.rb +142 -0
- data/lib/pg_plan_advice/default_resolver.rb +15 -0
- data/lib/pg_plan_advice/version.rb +5 -0
- data/lib/pg_plan_advice.rb +37 -0
- data/sig/pg_plan_advice.rbs +4 -0
- metadata +120 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: aff6f2b8f2ed0785002aa93c01c373f2c5ba3c38d1e4c8d8cf946b88f78d8670
|
|
4
|
+
data.tar.gz: 2a42b924f30b8ab9c945bae2860c04834fb2878ee45359d6a1d170a13b424470
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a74380e604022b8a264c8d15fe9a1c05226562bcd1784531190861492c88dbba785dae1576520dd4f644849d91f2ee7d8038377ad44a4864481b8cd37e8251e2
|
|
7
|
+
data.tar.gz: 2f9b62b68344ccbf8ab308068e5c3eee742ef6af98d2066702846eb49149c6c9f4c8c29551164257cb43801af53d1ff284154a2092b1ee8af776673a1f7580f0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 moozzi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# PgPlanAdvice
|
|
2
|
+
|
|
3
|
+
ActiveRecord DSL for PostgreSQL pg_plan_advice.
|
|
4
|
+
|
|
5
|
+
> [!WARNING]
|
|
6
|
+
> This gem is in active development. The API may change without notice and it should not be used in production.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Requirements:
|
|
11
|
+
|
|
12
|
+
- Ruby >= 3.2
|
|
13
|
+
- ActiveRecord 7.2
|
|
14
|
+
- PostgreSQL 19 with the `pg_plan_advice` module loaded (for example via `shared_preload_libraries`), see the [PostgreSQL documentation](https://www.postgresql.org/docs/devel/pgplanadvice.html)
|
|
15
|
+
|
|
16
|
+
Add the gem to your application's Gemfile:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bundle add pg_plan_advice
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
or install it from git:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem "pg_plan_advice"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### ActiveRecord
|
|
31
|
+
|
|
32
|
+
Call `plan_advice` on a model or a relation and describe the advice in a block:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
Post.joins(:comments).plan_advice do
|
|
36
|
+
join_order :posts, :comments
|
|
37
|
+
hash_join :comments
|
|
38
|
+
seq_scan :posts
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The relation above runs with:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
JOIN_ORDER(posts comments) HASH_JOIN(comments) SEQ_SCAN(posts)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Groups
|
|
49
|
+
|
|
50
|
+
Use `ordered` and `unordered` to build target groups:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
Post.plan_advice do
|
|
54
|
+
join_order :posts, ordered(:comments, unordered(:users, :tags))
|
|
55
|
+
nested_loop_plain ordered(:comments, :users)
|
|
56
|
+
foreign_join ordered(:remote_a, :remote_b)
|
|
57
|
+
end
|
|
58
|
+
# JOIN_ORDER(posts (comments {users tags})) NESTED_LOOP_PLAIN((comments users)) FOREIGN_JOIN((remote_a remote_b))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Index advice
|
|
62
|
+
|
|
63
|
+
`index_scan` and `index_only_scan` take a hash of target to index name:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
Post.plan_advice do
|
|
67
|
+
index_scan posts: :index_posts_on_user_id
|
|
68
|
+
end
|
|
69
|
+
# INDEX_SCAN(posts index_posts_on_user_id)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then run the tests:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
bundle exec rspec
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
81
|
+
|
|
82
|
+
## Contributing
|
|
83
|
+
|
|
84
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/moozzi/pg_plan_advice/issues.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module PgPlanAdvice
|
|
2
|
+
module AR
|
|
3
|
+
module Execution
|
|
4
|
+
GUC = "pg_plan_advice.advice"
|
|
5
|
+
MIN_SERVER_VERSION = 190_000
|
|
6
|
+
|
|
7
|
+
SUPPORT = ObjectSpace::WeakMap.new
|
|
8
|
+
|
|
9
|
+
def self.with_advice(connection, advice)
|
|
10
|
+
return yield if advice.nil? || advice.empty?
|
|
11
|
+
return yield unless usable?(connection)
|
|
12
|
+
|
|
13
|
+
if connection.transaction_open?
|
|
14
|
+
previous = connection.select_value("SELECT current_setting('#{GUC}', true)")
|
|
15
|
+
apply(connection, advice)
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
yield
|
|
19
|
+
ensure
|
|
20
|
+
restore(connection, previous)
|
|
21
|
+
end
|
|
22
|
+
else
|
|
23
|
+
connection.transaction do
|
|
24
|
+
apply(connection, advice)
|
|
25
|
+
yield
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.usable?(connection)
|
|
31
|
+
support = support_for(connection)
|
|
32
|
+
return true if support == :supported
|
|
33
|
+
|
|
34
|
+
if PgPlanAdvice.on_unsupported == :raise
|
|
35
|
+
raise UnsupportedDatabaseError, unsupported_message(connection, support)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.support_for(connection)
|
|
42
|
+
return SUPPORT[connection] if SUPPORT.key?(connection)
|
|
43
|
+
|
|
44
|
+
support = detect_support(connection)
|
|
45
|
+
warn_unsupported(connection, support)
|
|
46
|
+
SUPPORT[connection] = support
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.detect_support(connection)
|
|
50
|
+
return :adapter unless postgresql?(connection)
|
|
51
|
+
return :version if connection.database_version < MIN_SERVER_VERSION
|
|
52
|
+
return :module if connection.select_value("SELECT 1 FROM pg_settings WHERE name = '#{GUC}'").nil?
|
|
53
|
+
|
|
54
|
+
:supported
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.postgresql?(connection)
|
|
58
|
+
defined?(::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter) &&
|
|
59
|
+
connection.is_a?(::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.warn_unsupported(connection, support)
|
|
63
|
+
return if support == :supported || PgPlanAdvice.on_unsupported != :warn
|
|
64
|
+
|
|
65
|
+
::ActiveRecord::Base.logger&.warn("#{unsupported_message(connection, support)}, running queries without advice")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.unsupported_message(connection, support)
|
|
69
|
+
case support
|
|
70
|
+
when :adapter
|
|
71
|
+
"pg_plan_advice requires PostgreSQL, got: #{connection.adapter_name}"
|
|
72
|
+
when :version
|
|
73
|
+
"pg_plan_advice requires PostgreSQL 19 or newer, got: #{server_version(connection.database_version)}"
|
|
74
|
+
when :module
|
|
75
|
+
"pg_plan_advice module is not loaded, load it with shared_preload_libraries"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.server_version(version_num)
|
|
80
|
+
"#{version_num / 10_000}.#{version_num % 10_000}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.apply(connection, value)
|
|
84
|
+
connection.select_value("SELECT set_config('#{GUC}', #{connection.quote(value)}, true)")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.restore(connection, value)
|
|
88
|
+
apply(connection, value)
|
|
89
|
+
rescue ::ActiveRecord::StatementInvalid
|
|
90
|
+
# ?
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module PgPlanAdvice
|
|
2
|
+
module AR
|
|
3
|
+
module Relation
|
|
4
|
+
attr_accessor :plan_advice_value
|
|
5
|
+
|
|
6
|
+
def plan_advice(&blk)
|
|
7
|
+
advice = PgPlanAdvice.build(resolver: Resolver.new(klass), &blk)
|
|
8
|
+
spawn.tap do |rel|
|
|
9
|
+
rel.plan_advice_value = plan_advice_value ? plan_advice_value.merge(advice) : advice
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def exec_explain(...) = with_plan_advice { super }
|
|
14
|
+
def pluck(...) = with_plan_advice { super }
|
|
15
|
+
def exists?(...) = with_plan_advice { super }
|
|
16
|
+
def update_all(...) = with_plan_advice { super }
|
|
17
|
+
def delete_all(...) = with_plan_advice { super }
|
|
18
|
+
def calculate(...) = with_plan_advice { super }
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def with_plan_advice(&blk)
|
|
23
|
+
return yield unless plan_advice_value
|
|
24
|
+
|
|
25
|
+
klass.with_connection do |conn|
|
|
26
|
+
Execution.with_advice(conn, plan_advice_value.to_s, &blk)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def exec_main_query(*args, **kwargs, &blk)
|
|
31
|
+
return super unless plan_advice_value
|
|
32
|
+
|
|
33
|
+
klass.with_connection do |conn|
|
|
34
|
+
Execution.with_advice(conn, plan_advice_value.to_s) { super(*args, **kwargs, &blk) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module PgPlanAdvice
|
|
2
|
+
module AR
|
|
3
|
+
class Resolver
|
|
4
|
+
def initialize(model)
|
|
5
|
+
@model = model
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def resolve(item)
|
|
9
|
+
name = case item
|
|
10
|
+
when Class then item.table_name
|
|
11
|
+
when Symbol then @model.reflect_on_association(item)&.klass&.table_name || item.to_s
|
|
12
|
+
when String then item
|
|
13
|
+
else raise InvalidAdviceError, "Invalid advice target: #{item.inspect}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
Ast::Target.new(alias_name: name)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module PgPlanAdvice
|
|
2
|
+
module Ast
|
|
3
|
+
class AdviceSet
|
|
4
|
+
attr_reader :nodes
|
|
5
|
+
|
|
6
|
+
def initialize(nodes)
|
|
7
|
+
@nodes = nodes
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def to_s
|
|
11
|
+
@nodes.map { |node| "#{node.class.tag}(#{serialize_targets(node.targets)})" }.join(" ")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def merge(advice_set)
|
|
15
|
+
self.class.new(nodes + advice_set.nodes)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def serialize_targets(targets)
|
|
21
|
+
targets.map do |target|
|
|
22
|
+
case target
|
|
23
|
+
when Ast::Target then target.to_s
|
|
24
|
+
when Ast::IndexTarget then "#{serialize_targets([target.target])} #{target.index_name}"
|
|
25
|
+
when Ast::OrderedGroup then "(#{serialize_targets(target.items)})"
|
|
26
|
+
when Ast::UnorderedGroup then "{#{serialize_targets(target.items)}}"
|
|
27
|
+
else raise InvalidAdviceError, "unable to serialize: #{target.class}"
|
|
28
|
+
end
|
|
29
|
+
end.join(" ")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module PgPlanAdvice
|
|
2
|
+
module Ast
|
|
3
|
+
class ForeignJoin < Node
|
|
4
|
+
accepts OrderedGroup
|
|
5
|
+
advice_name "FOREIGN_JOIN"
|
|
6
|
+
|
|
7
|
+
def initialize(targets:)
|
|
8
|
+
super
|
|
9
|
+
|
|
10
|
+
single = targets.select { _1.items.one? }
|
|
11
|
+
invalid!("targets must contain more than one relation identifier, got: #{single.inspect}") if single.any?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module PgPlanAdvice
|
|
2
|
+
module Ast
|
|
3
|
+
class IndexTarget < Data.define(:target, :index_name)
|
|
4
|
+
def initialize(target:, index_name:)
|
|
5
|
+
unless target.is_a?(Target)
|
|
6
|
+
raise InvalidAdviceError, "Index advice target must be a single target, got #{target.inspect}"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
index_name = index_name.to_s
|
|
10
|
+
raise InvalidAdviceError, "Index name must not be empty" if index_name.empty?
|
|
11
|
+
|
|
12
|
+
super(target:, index_name: index_name.freeze)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_s
|
|
16
|
+
"#{target} #{index_name}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module PgPlanAdvice
|
|
2
|
+
module Ast
|
|
3
|
+
class Node < Data.define(:targets)
|
|
4
|
+
class << self
|
|
5
|
+
attr_reader :tag
|
|
6
|
+
|
|
7
|
+
def accepts(*matchers, allow_nesting: false)
|
|
8
|
+
@accepted = matchers.freeze
|
|
9
|
+
@allow_nesting = allow_nesting
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def advice_name(tag)
|
|
13
|
+
@tag = tag
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def accepts?(item)
|
|
17
|
+
@accepted.any? { _1 === item }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def nesting_in?(group)
|
|
21
|
+
@allow_nesting && group.is_a?(OrderedGroup)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(targets:)
|
|
26
|
+
invalid!("targets must not be empty") if targets.empty?
|
|
27
|
+
|
|
28
|
+
unexpected = targets.reject { self.class.accepts?(_1) }
|
|
29
|
+
invalid!("accepts only target, got: #{unexpected.inspect}") if unexpected.any?
|
|
30
|
+
|
|
31
|
+
validate_groups!(targets)
|
|
32
|
+
|
|
33
|
+
super(targets: targets.dup.freeze)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def validate_groups!(items)
|
|
39
|
+
items.select { group?(_1) }.each do |group|
|
|
40
|
+
nesting = self.class.nesting_in?(group)
|
|
41
|
+
unexpected = group.items.reject { _1.is_a?(Target) || (nesting && group?(_1)) }
|
|
42
|
+
invalid_group!(group, nesting, unexpected) if unexpected.any?
|
|
43
|
+
|
|
44
|
+
validate_groups!(group.items)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def invalid_group!(group, nesting, unexpected)
|
|
49
|
+
allowed = nesting ? "targets and groups" : "targets"
|
|
50
|
+
invalid!("#{group.class.name.split("::").last} may contain only #{allowed}, got: #{unexpected.inspect}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def group?(item)
|
|
54
|
+
item.is_a?(OrderedGroup) || item.is_a?(UnorderedGroup)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def invalid!(message)
|
|
58
|
+
raise InvalidAdviceError, "#{self.class.tag} #{message}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module PgPlanAdvice
|
|
2
|
+
module Ast
|
|
3
|
+
Target = Data.define(:alias_name, :occurrence_number, :partition_schema, :partition_name, :plan_name) do
|
|
4
|
+
def initialize(alias_name:, occurrence_number: nil, partition_schema: nil, partition_name: nil, plan_name: nil)
|
|
5
|
+
invalid!("alias_name can't be empty") if alias_name.empty?
|
|
6
|
+
|
|
7
|
+
if partition_schema.nil? != partition_name.nil?
|
|
8
|
+
invalid!("partition_schema and partition_name must be given together")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
unless occurrence_number.nil? || positive_integer?(occurrence_number)
|
|
12
|
+
invalid!("occurrence_number must be a positive integer, got: #{occurrence_number.inspect}")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
result = alias_name.to_s
|
|
20
|
+
result += "##{occurrence_number}" if occurrence_number
|
|
21
|
+
result += "/#{partition_schema}" if partition_schema
|
|
22
|
+
result += ".#{partition_name}" if partition_name
|
|
23
|
+
result += "@#{plan_name}" if plan_name
|
|
24
|
+
|
|
25
|
+
result
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def positive_integer?(value)
|
|
31
|
+
value.is_a?(Integer) && value.positive?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def invalid!(message)
|
|
35
|
+
raise InvalidAdviceError, "Target #{message}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require_relative "./ast/node"
|
|
2
|
+
require_relative "./ast/target"
|
|
3
|
+
require_relative "./ast/index_target"
|
|
4
|
+
require_relative "./ast/ordered_group"
|
|
5
|
+
require_relative "./ast/unordered_group"
|
|
6
|
+
|
|
7
|
+
require_relative "./ast/advice_set"
|
|
8
|
+
require_relative "./ast/bitmap_heap_scan"
|
|
9
|
+
require_relative "./ast/do_not_scan"
|
|
10
|
+
require_relative "./ast/foreign_join"
|
|
11
|
+
require_relative "./ast/gather"
|
|
12
|
+
require_relative "./ast/gather_merge"
|
|
13
|
+
require_relative "./ast/hash_join"
|
|
14
|
+
require_relative "./ast/index_only_scan"
|
|
15
|
+
require_relative "./ast/index_scan"
|
|
16
|
+
require_relative "./ast/join_order"
|
|
17
|
+
require_relative "./ast/merge_join_materialize"
|
|
18
|
+
require_relative "./ast/merge_join_plain"
|
|
19
|
+
require_relative "./ast/nested_loop_materialize"
|
|
20
|
+
require_relative "./ast/nested_loop_memoize"
|
|
21
|
+
require_relative "./ast/nested_loop_plain"
|
|
22
|
+
require_relative "./ast/no_gather"
|
|
23
|
+
require_relative "./ast/partitionwise"
|
|
24
|
+
require_relative "./ast/semijoin_non_unique"
|
|
25
|
+
require_relative "./ast/semijoin_unique"
|
|
26
|
+
require_relative "./ast/seq_scan"
|
|
27
|
+
require_relative "./ast/tid_scan"
|
|
28
|
+
|
|
29
|
+
module PgPlanAdvice
|
|
30
|
+
module Ast
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
require_relative "./ast"
|
|
2
|
+
require_relative "./default_resolver"
|
|
3
|
+
|
|
4
|
+
module PgPlanAdvice
|
|
5
|
+
class Builder
|
|
6
|
+
def initialize(resolver: DefaultResolver.new)
|
|
7
|
+
@items = []
|
|
8
|
+
@resolver = resolver
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
#
|
|
12
|
+
# Scan method advice
|
|
13
|
+
#
|
|
14
|
+
# SEQ_SCAN(target [ ... ])
|
|
15
|
+
# TID_SCAN(target [ ... ])
|
|
16
|
+
# INDEX_SCAN(target index_name [ ... ])
|
|
17
|
+
# INDEX_ONLY_SCAN(target index_name [ ... ])
|
|
18
|
+
# FOREIGN_JOIN((target [ ... ]) [ ... ])
|
|
19
|
+
# BITMAP_HEAP_SCAN(target [ ... ])
|
|
20
|
+
# DO_NOT_SCAN(target [ ... ])
|
|
21
|
+
#
|
|
22
|
+
def seq_scan(*targets) = add(Ast::SeqScan, targets)
|
|
23
|
+
def tid_scan(*targets) = add(Ast::TidScan, targets)
|
|
24
|
+
def index_scan(targets) = add_index(Ast::IndexScan, targets)
|
|
25
|
+
def index_only_scan(targets) = add_index(Ast::IndexOnlyScan, targets)
|
|
26
|
+
def foreign_join(*targets) = add(Ast::ForeignJoin, targets)
|
|
27
|
+
def bitmap_heap_scan(*targets) = add(Ast::BitmapHeapScan, targets)
|
|
28
|
+
def do_not_scan(*targets) = add(Ast::DoNotScan, targets)
|
|
29
|
+
|
|
30
|
+
#
|
|
31
|
+
# Join order advice
|
|
32
|
+
#
|
|
33
|
+
# JOIN_ORDER(join_order_item [ ... ])
|
|
34
|
+
#
|
|
35
|
+
# where join_order_item is:
|
|
36
|
+
#
|
|
37
|
+
# advice_target |
|
|
38
|
+
# ( join_order_item [ ... ] ) |
|
|
39
|
+
# { join_order_item [ ... ] }
|
|
40
|
+
#
|
|
41
|
+
def join_order(*targets) = add(Ast::JoinOrder, targets)
|
|
42
|
+
|
|
43
|
+
#
|
|
44
|
+
# Join method advice
|
|
45
|
+
#
|
|
46
|
+
# join_method_name(join_method_item [ ... ])
|
|
47
|
+
#
|
|
48
|
+
# where join_method_name is:
|
|
49
|
+
#
|
|
50
|
+
# { MERGE_JOIN_MATERIALIZE | MERGE_JOIN_PLAIN | NESTED_LOOP_MATERIALIZE | NESTED_LOOP_MEMOIZE | NESTED_LOOP_PLAIN | HASH_JOIN }
|
|
51
|
+
#
|
|
52
|
+
# and join_method_item is:
|
|
53
|
+
#
|
|
54
|
+
# { advice_target |
|
|
55
|
+
# ( advice_target [ ... ] ) }
|
|
56
|
+
#
|
|
57
|
+
def merge_join_materialize(*targets) = add(Ast::MergeJoinMaterialize, targets)
|
|
58
|
+
def merge_join_plain(*targets) = add(Ast::MergeJoinPlain, targets)
|
|
59
|
+
def nested_loop_materialize(*targets) = add(Ast::NestedLoopMaterialize, targets)
|
|
60
|
+
def nested_loop_memoize(*targets) = add(Ast::NestedLoopMemoize, targets)
|
|
61
|
+
def nested_loop_plain(*targets) = add(Ast::NestedLoopPlain, targets)
|
|
62
|
+
def hash_join(*targets) = add(Ast::HashJoin, targets)
|
|
63
|
+
|
|
64
|
+
#
|
|
65
|
+
# Partitionwise advice
|
|
66
|
+
#
|
|
67
|
+
# PARTITIONWISE(partitionwise_item [ ... ])
|
|
68
|
+
#
|
|
69
|
+
# where partitionwise_item is:
|
|
70
|
+
#
|
|
71
|
+
# { advice_target |
|
|
72
|
+
# ( advice_target [ ... ] ) }
|
|
73
|
+
#
|
|
74
|
+
def partitionwise(*targets) = add(Ast::Partitionwise, targets)
|
|
75
|
+
|
|
76
|
+
#
|
|
77
|
+
# Semijoin uniqueness advice
|
|
78
|
+
#
|
|
79
|
+
# SEMIJOIN_UNIQUE(sj_unique_item [ ... ])
|
|
80
|
+
# SEMIJOIN_NON_UNIQUE(sj_unique_item [ ... ])
|
|
81
|
+
#
|
|
82
|
+
# where sj_unique_item is:
|
|
83
|
+
#
|
|
84
|
+
# { advice_target |
|
|
85
|
+
# ( advice_target [ ... ] ) }
|
|
86
|
+
#
|
|
87
|
+
def semijoin_unique(*targets) = add(Ast::SemijoinUnique, targets)
|
|
88
|
+
def semijoin_non_unique(*targets) = add(Ast::SemijoinNonUnique, targets)
|
|
89
|
+
|
|
90
|
+
#
|
|
91
|
+
# Parallel query advice
|
|
92
|
+
#
|
|
93
|
+
# GATHER(gather_item [ ... ])
|
|
94
|
+
# GATHER_MERGE(gather_item [ ... ])
|
|
95
|
+
# NO_GATHER(advice_target [ ... ])
|
|
96
|
+
#
|
|
97
|
+
# where gather_item is:
|
|
98
|
+
#
|
|
99
|
+
# { advice_target |
|
|
100
|
+
# ( advice_target [ ... ] ) }
|
|
101
|
+
#
|
|
102
|
+
def gather(*targets) = add(Ast::Gather, targets)
|
|
103
|
+
def gather_merge(*targets) = add(Ast::GatherMerge, targets)
|
|
104
|
+
def no_gather(*targets) = add(Ast::NoGather, targets)
|
|
105
|
+
|
|
106
|
+
def result = Ast::AdviceSet.new(@items)
|
|
107
|
+
|
|
108
|
+
def ordered(*targets) = Ast::OrderedGroup.new(targets.map { |target| normalize(target) })
|
|
109
|
+
def unordered(*targets) = Ast::UnorderedGroup.new(targets.map { |target| normalize(target) })
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def add(node_class, targets)
|
|
114
|
+
@items << node_class.new(targets.map { |target| normalize(target) })
|
|
115
|
+
self
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def add_index(node_class, targets)
|
|
119
|
+
raise "invalid argument for index_scan/index_only_scan in #{targets}" unless targets.is_a?(Hash)
|
|
120
|
+
|
|
121
|
+
@items << node_class.new(normalize(targets))
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def normalize(item)
|
|
126
|
+
case item
|
|
127
|
+
when Ast::Target, Ast::OrderedGroup, Ast::UnorderedGroup then item
|
|
128
|
+
when Hash
|
|
129
|
+
item.map do |target, index_name|
|
|
130
|
+
Ast::IndexTarget.new(normalize(target), index_name)
|
|
131
|
+
end
|
|
132
|
+
else @resolver.resolve(item)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def self.build(resolver: DefaultResolver.new, &block)
|
|
138
|
+
builder = Builder.new(resolver:)
|
|
139
|
+
builder.instance_exec(&block)
|
|
140
|
+
builder.result
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module PgPlanAdvice
|
|
2
|
+
class DefaultResolver
|
|
3
|
+
def resolve(item)
|
|
4
|
+
case item
|
|
5
|
+
when Ast::Target then item
|
|
6
|
+
when Symbol, String then Ast::Target.new(alias_name: item.to_s)
|
|
7
|
+
when Hash
|
|
8
|
+
item.map do |target, index_name|
|
|
9
|
+
Ast::IndexTarget.new(resolve(target), index_name)
|
|
10
|
+
end
|
|
11
|
+
else raise InvalidAdviceError, "Invalid advice target: #{item.inspect}"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
require_relative "pg_plan_advice/version"
|
|
6
|
+
require_relative "pg_plan_advice/builder"
|
|
7
|
+
require_relative "pg_plan_advice/ar/execution"
|
|
8
|
+
require_relative "pg_plan_advice/ar/querying"
|
|
9
|
+
require_relative "pg_plan_advice/ar/relation"
|
|
10
|
+
require_relative "pg_plan_advice/ar/resolver"
|
|
11
|
+
|
|
12
|
+
module PgPlanAdvice
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
class InvalidAdviceError < Error; end
|
|
15
|
+
class UnsupportedDatabaseError < Error; end
|
|
16
|
+
|
|
17
|
+
ON_UNSUPPORTED = %i[raise warn ignore].freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
attr_reader :on_unsupported
|
|
21
|
+
|
|
22
|
+
def on_unsupported=(value)
|
|
23
|
+
unless ON_UNSUPPORTED.include?(value)
|
|
24
|
+
raise ArgumentError, "on_unsupported must be one of #{ON_UNSUPPORTED.inspect}, got: #{value.inspect}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@on_unsupported = value
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
self.on_unsupported = :raise
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
ActiveSupport.on_load(:active_record) do
|
|
35
|
+
::ActiveRecord::Relation.prepend(PgPlanAdvice::AR::Relation)
|
|
36
|
+
extend PgPlanAdvice::AR::Querying
|
|
37
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pg_plan_advice
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- moozzi
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-11-11 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.2'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '7.2'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: pg
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '1.1'
|
|
39
|
+
type: :runtime
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '1.1'
|
|
46
|
+
description: Apply PostgreSQL 19 pg_plan_advice query plan advice to ActiveRecord
|
|
47
|
+
queries using a simple Ruby DSL.
|
|
48
|
+
email:
|
|
49
|
+
- bieniek.milosz0@gmail.com
|
|
50
|
+
executables: []
|
|
51
|
+
extensions: []
|
|
52
|
+
extra_rdoc_files: []
|
|
53
|
+
files:
|
|
54
|
+
- LICENSE.txt
|
|
55
|
+
- README.md
|
|
56
|
+
- Rakefile
|
|
57
|
+
- gemfiles/rails_7.2.gemfile
|
|
58
|
+
- gemfiles/rails_8.0.gemfile
|
|
59
|
+
- gemfiles/rails_8.1.gemfile
|
|
60
|
+
- lib/pg_plan_advice.rb
|
|
61
|
+
- lib/pg_plan_advice/ar/execution.rb
|
|
62
|
+
- lib/pg_plan_advice/ar/querying.rb
|
|
63
|
+
- lib/pg_plan_advice/ar/relation.rb
|
|
64
|
+
- lib/pg_plan_advice/ar/resolver.rb
|
|
65
|
+
- lib/pg_plan_advice/ast.rb
|
|
66
|
+
- lib/pg_plan_advice/ast/advice_set.rb
|
|
67
|
+
- lib/pg_plan_advice/ast/bitmap_heap_scan.rb
|
|
68
|
+
- lib/pg_plan_advice/ast/do_not_scan.rb
|
|
69
|
+
- lib/pg_plan_advice/ast/foreign_join.rb
|
|
70
|
+
- lib/pg_plan_advice/ast/gather.rb
|
|
71
|
+
- lib/pg_plan_advice/ast/gather_merge.rb
|
|
72
|
+
- lib/pg_plan_advice/ast/hash_join.rb
|
|
73
|
+
- lib/pg_plan_advice/ast/index_only_scan.rb
|
|
74
|
+
- lib/pg_plan_advice/ast/index_scan.rb
|
|
75
|
+
- lib/pg_plan_advice/ast/index_target.rb
|
|
76
|
+
- lib/pg_plan_advice/ast/join_order.rb
|
|
77
|
+
- lib/pg_plan_advice/ast/merge_join_materialize.rb
|
|
78
|
+
- lib/pg_plan_advice/ast/merge_join_plain.rb
|
|
79
|
+
- lib/pg_plan_advice/ast/nested_loop_materialize.rb
|
|
80
|
+
- lib/pg_plan_advice/ast/nested_loop_memoize.rb
|
|
81
|
+
- lib/pg_plan_advice/ast/nested_loop_plain.rb
|
|
82
|
+
- lib/pg_plan_advice/ast/no_gather.rb
|
|
83
|
+
- lib/pg_plan_advice/ast/node.rb
|
|
84
|
+
- lib/pg_plan_advice/ast/ordered_group.rb
|
|
85
|
+
- lib/pg_plan_advice/ast/partitionwise.rb
|
|
86
|
+
- lib/pg_plan_advice/ast/semijoin_non_unique.rb
|
|
87
|
+
- lib/pg_plan_advice/ast/semijoin_unique.rb
|
|
88
|
+
- lib/pg_plan_advice/ast/seq_scan.rb
|
|
89
|
+
- lib/pg_plan_advice/ast/target.rb
|
|
90
|
+
- lib/pg_plan_advice/ast/tid_scan.rb
|
|
91
|
+
- lib/pg_plan_advice/ast/unordered_group.rb
|
|
92
|
+
- lib/pg_plan_advice/builder.rb
|
|
93
|
+
- lib/pg_plan_advice/default_resolver.rb
|
|
94
|
+
- lib/pg_plan_advice/version.rb
|
|
95
|
+
- sig/pg_plan_advice.rbs
|
|
96
|
+
homepage: https://github.com/moozzi/pg_plan_advice
|
|
97
|
+
licenses:
|
|
98
|
+
- MIT
|
|
99
|
+
metadata:
|
|
100
|
+
allowed_push_host: https://rubygems.org
|
|
101
|
+
homepage_uri: https://github.com/moozzi/pg_plan_advice
|
|
102
|
+
source_code_uri: https://github.com/moozzi/pg_plan_advice
|
|
103
|
+
rdoc_options: []
|
|
104
|
+
require_paths:
|
|
105
|
+
- lib
|
|
106
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: 3.2.0
|
|
111
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - ">="
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '0'
|
|
116
|
+
requirements: []
|
|
117
|
+
rubygems_version: 3.6.2
|
|
118
|
+
specification_version: 4
|
|
119
|
+
summary: ActiveRecord DSL for PostgreSQL pg_plan_advice.
|
|
120
|
+
test_files: []
|