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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +88 -0
  4. data/Rakefile +8 -0
  5. data/gemfiles/rails_7.2.gemfile +5 -0
  6. data/gemfiles/rails_8.0.gemfile +5 -0
  7. data/gemfiles/rails_8.1.gemfile +5 -0
  8. data/lib/pg_plan_advice/ar/execution.rb +94 -0
  9. data/lib/pg_plan_advice/ar/querying.rb +9 -0
  10. data/lib/pg_plan_advice/ar/relation.rb +39 -0
  11. data/lib/pg_plan_advice/ar/resolver.rb +20 -0
  12. data/lib/pg_plan_advice/ast/advice_set.rb +33 -0
  13. data/lib/pg_plan_advice/ast/bitmap_heap_scan.rb +8 -0
  14. data/lib/pg_plan_advice/ast/do_not_scan.rb +8 -0
  15. data/lib/pg_plan_advice/ast/foreign_join.rb +15 -0
  16. data/lib/pg_plan_advice/ast/gather.rb +8 -0
  17. data/lib/pg_plan_advice/ast/gather_merge.rb +8 -0
  18. data/lib/pg_plan_advice/ast/hash_join.rb +8 -0
  19. data/lib/pg_plan_advice/ast/index_only_scan.rb +8 -0
  20. data/lib/pg_plan_advice/ast/index_scan.rb +8 -0
  21. data/lib/pg_plan_advice/ast/index_target.rb +20 -0
  22. data/lib/pg_plan_advice/ast/join_order.rb +8 -0
  23. data/lib/pg_plan_advice/ast/merge_join_materialize.rb +8 -0
  24. data/lib/pg_plan_advice/ast/merge_join_plain.rb +8 -0
  25. data/lib/pg_plan_advice/ast/nested_loop_materialize.rb +8 -0
  26. data/lib/pg_plan_advice/ast/nested_loop_memoize.rb +8 -0
  27. data/lib/pg_plan_advice/ast/nested_loop_plain.rb +8 -0
  28. data/lib/pg_plan_advice/ast/no_gather.rb +8 -0
  29. data/lib/pg_plan_advice/ast/node.rb +62 -0
  30. data/lib/pg_plan_advice/ast/ordered_group.rb +11 -0
  31. data/lib/pg_plan_advice/ast/partitionwise.rb +8 -0
  32. data/lib/pg_plan_advice/ast/semijoin_non_unique.rb +8 -0
  33. data/lib/pg_plan_advice/ast/semijoin_unique.rb +8 -0
  34. data/lib/pg_plan_advice/ast/seq_scan.rb +8 -0
  35. data/lib/pg_plan_advice/ast/target.rb +39 -0
  36. data/lib/pg_plan_advice/ast/tid_scan.rb +8 -0
  37. data/lib/pg_plan_advice/ast/unordered_group.rb +11 -0
  38. data/lib/pg_plan_advice/ast.rb +32 -0
  39. data/lib/pg_plan_advice/builder.rb +142 -0
  40. data/lib/pg_plan_advice/default_resolver.rb +15 -0
  41. data/lib/pg_plan_advice/version.rb +5 -0
  42. data/lib/pg_plan_advice.rb +37 -0
  43. data/sig/pg_plan_advice.rbs +4 -0
  44. 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: %i[spec]
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ eval_gemfile "../Gemfile"
4
+
5
+ gem "activerecord", "~> 7.2.0"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ eval_gemfile "../Gemfile"
4
+
5
+ gem "activerecord", "~> 8.0.0"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ eval_gemfile "../Gemfile"
4
+
5
+ gem "activerecord", "~> 8.1.0"
@@ -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,9 @@
1
+ module PgPlanAdvice
2
+ module AR
3
+ module Querying
4
+ def plan_advice(&blk)
5
+ all.plan_advice(&blk)
6
+ end
7
+ end
8
+ end
9
+ 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,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class BitmapHeapScan < Node
4
+ accepts Target
5
+ advice_name "BITMAP_HEAP_SCAN"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class DoNotScan < Node
4
+ accepts Target
5
+ advice_name "DO_NOT_SCAN"
6
+ end
7
+ end
8
+ 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,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class Gather < Node
4
+ accepts Target, OrderedGroup
5
+ advice_name "GATHER"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class GatherMerge < Node
4
+ accepts Target, OrderedGroup
5
+ advice_name "GATHER_MERGE"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class HashJoin < Node
4
+ accepts Target, OrderedGroup
5
+ advice_name "HASH_JOIN"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class IndexOnlyScan < Node
4
+ accepts IndexTarget
5
+ advice_name "INDEX_ONLY_SCAN"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class IndexScan < Node
4
+ accepts IndexTarget
5
+ advice_name "INDEX_SCAN"
6
+ end
7
+ end
8
+ 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,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class JoinOrder < Node
4
+ accepts Target, OrderedGroup, UnorderedGroup, allow_nesting: true
5
+ advice_name "JOIN_ORDER"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class MergeJoinMaterialize < Node
4
+ accepts Target, OrderedGroup
5
+ advice_name "MERGE_JOIN_MATERIALIZE"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class MergeJoinPlain < Node
4
+ accepts Target, OrderedGroup
5
+ advice_name "MERGE_JOIN_PLAIN"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class NestedLoopMaterialize < Node
4
+ accepts Target, OrderedGroup
5
+ advice_name "NESTED_LOOP_MATERIALIZE"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class NestedLoopMemoize < Node
4
+ accepts Target, OrderedGroup
5
+ advice_name "NESTED_LOOP_MEMOIZE"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class NestedLoopPlain < Node
4
+ accepts Target, OrderedGroup
5
+ advice_name "NESTED_LOOP_PLAIN"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class NoGather < Node
4
+ accepts Target
5
+ advice_name "NO_GATHER"
6
+ end
7
+ end
8
+ 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,11 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ OrderedGroup = Data.define(:items) do
4
+ def initialize(items:)
5
+ raise InvalidAdviceError, "OrderedGroup must not be empty" if items.empty?
6
+
7
+ super
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class Partitionwise < Node
4
+ accepts Target, OrderedGroup
5
+ advice_name "PARTITIONWISE"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class SemijoinNonUnique < Node
4
+ accepts Target, OrderedGroup
5
+ advice_name "SEMIJOIN_NON_UNIQUE"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class SemijoinUnique < Node
4
+ accepts Target, OrderedGroup
5
+ advice_name "SEMIJOIN_UNIQUE"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class SeqScan < Node
4
+ accepts Target
5
+ advice_name "SEQ_SCAN"
6
+ end
7
+ end
8
+ 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,8 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ class TidScan < Node
4
+ accepts Target
5
+ advice_name "TID_SCAN"
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ module PgPlanAdvice
2
+ module Ast
3
+ UnorderedGroup = Data.define(:items) do
4
+ def initialize(items:)
5
+ raise InvalidAdviceError, "UnorderedGroup must not be empty" if items.empty?
6
+
7
+ super
8
+ end
9
+ end
10
+ end
11
+ 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgPlanAdvice
4
+ VERSION = "0.1.0"
5
+ 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
@@ -0,0 +1,4 @@
1
+ module PgPlanAdvice
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ 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: []