quo 1.0.0.beta2 → 2.0.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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +17 -0
  3. data/.devcontainer/compose.yml +10 -0
  4. data/.devcontainer/devcontainer.json +12 -0
  5. data/Appraisals +4 -12
  6. data/CHANGELOG.md +112 -1
  7. data/CLAUDE.md +19 -0
  8. data/Gemfile +7 -1
  9. data/LICENSE.txt +1 -1
  10. data/README.md +496 -203
  11. data/Rakefile +66 -6
  12. data/UPGRADING.md +216 -0
  13. data/badges/coverage_badge_total.svg +35 -0
  14. data/badges/rubycritic_badge_score.svg +35 -0
  15. data/claude-skill/README.md +100 -0
  16. data/claude-skill/SKILL.md +442 -0
  17. data/claude-skill/references/API_REFERENCE.md +462 -0
  18. data/claude-skill/references/COMPOSITION.md +396 -0
  19. data/claude-skill/references/PAGINATION.md +396 -0
  20. data/claude-skill/references/QUERY_TYPES.md +297 -0
  21. data/claude-skill/references/TRANSFORMERS.md +282 -0
  22. data/context/01-core-architecture.md +247 -0
  23. data/context/02-query-types-implementation.md +355 -0
  24. data/context/03-composition-transformation.md +441 -0
  25. data/context/04-pagination-results.md +485 -0
  26. data/context/05-testing-configuration.md +491 -0
  27. data/context/06-advanced-patterns-examples.md +153 -0
  28. data/gemfiles/rails_8.0.gemfile +10 -5
  29. data/gemfiles/rails_8.1.gemfile +20 -0
  30. data/lib/generators/quo/install/USAGE +21 -0
  31. data/lib/generators/quo/install/install_generator.rb +63 -0
  32. data/lib/quo/collection_backed_query.rb +21 -15
  33. data/lib/quo/collection_results.rb +1 -0
  34. data/lib/quo/composed_collection_backed_query.rb +42 -0
  35. data/lib/quo/composed_instance.rb +144 -0
  36. data/lib/quo/composed_query.rb +43 -178
  37. data/lib/quo/composed_relation_backed_query.rb +42 -0
  38. data/lib/quo/composing/base_strategy.rb +22 -0
  39. data/lib/quo/composing/class_strategy.rb +86 -0
  40. data/lib/quo/composing/class_strategy_registry.rb +31 -0
  41. data/lib/quo/composing/query_classes_strategy.rb +38 -0
  42. data/lib/quo/composing.rb +81 -0
  43. data/lib/quo/engine.rb +1 -0
  44. data/lib/quo/minitest/helpers.rb +14 -24
  45. data/lib/quo/preloadable.rb +1 -0
  46. data/lib/quo/query.rb +22 -5
  47. data/lib/quo/relation_backed_query.rb +24 -18
  48. data/lib/quo/relation_backed_query_specification.rb +44 -25
  49. data/lib/quo/relation_results.rb +1 -0
  50. data/lib/quo/results.rb +31 -2
  51. data/lib/quo/rspec/helpers.rb +15 -26
  52. data/lib/quo/testing/collection_backed_fake.rb +1 -0
  53. data/lib/quo/testing/fake_helpers.rb +30 -0
  54. data/lib/quo/testing/relation_backed_fake.rb +1 -0
  55. data/lib/quo/version.rb +1 -1
  56. data/lib/quo/wrapped_collection_backed_query.rb +21 -0
  57. data/lib/quo/wrapped_relation_backed_query.rb +21 -0
  58. data/lib/quo.rb +8 -0
  59. data/quo.png +0 -0
  60. data/sig/generated/quo/collection_backed_query.rbs +10 -4
  61. data/sig/generated/quo/collection_results.rbs +1 -0
  62. data/sig/generated/quo/composed_collection_backed_query.rbs +25 -0
  63. data/sig/generated/quo/composed_instance.rbs +61 -0
  64. data/sig/generated/quo/composed_query.rbs +23 -56
  65. data/sig/generated/quo/composed_relation_backed_query.rbs +25 -0
  66. data/sig/generated/quo/composing/base_strategy.rbs +16 -0
  67. data/sig/generated/quo/composing/class_strategy.rbs +38 -0
  68. data/sig/generated/quo/composing/class_strategy_registry.rbs +16 -0
  69. data/sig/generated/quo/composing/query_classes_strategy.rbs +24 -0
  70. data/sig/generated/quo/composing.rbs +40 -0
  71. data/sig/generated/quo/engine.rbs +1 -0
  72. data/sig/generated/quo/minitest/helpers.rbs +12 -0
  73. data/sig/generated/quo/preloadable.rbs +1 -0
  74. data/sig/generated/quo/query.rbs +15 -4
  75. data/sig/generated/quo/relation_backed_query.rbs +15 -5
  76. data/sig/generated/quo/relation_backed_query_specification.rbs +47 -39
  77. data/sig/generated/quo/relation_results.rbs +1 -0
  78. data/sig/generated/quo/results.rbs +11 -0
  79. data/sig/generated/quo/rspec/helpers.rbs +12 -0
  80. data/sig/generated/quo/testing/collection_backed_fake.rbs +1 -0
  81. data/sig/generated/quo/testing/fake_helpers.rbs +14 -0
  82. data/sig/generated/quo/testing/relation_backed_fake.rbs +1 -0
  83. data/sig/generated/quo/wrapped_collection_backed_query.rbs +13 -0
  84. data/sig/generated/quo/wrapped_relation_backed_query.rbs +13 -0
  85. data/sig/generated/quo.rbs +1 -0
  86. data/website/.gitignore +6 -0
  87. data/website/.nojekyll +0 -0
  88. data/website/404.html +26 -0
  89. data/website/Gemfile +24 -0
  90. data/website/_config.yml +50 -0
  91. data/website/_data/navigation.yml +8 -0
  92. data/website/_data/sidebar.yml +2 -0
  93. data/website/_data/social_links.yml +3 -0
  94. data/website/_docs/api.md +261 -0
  95. data/website/_docs/get-started.md +289 -0
  96. data/website/assets/quo.png +0 -0
  97. data/website/index.md +141 -0
  98. metadata +70 -13
  99. data/gemfiles/rails_7.0.gemfile +0 -15
  100. data/gemfiles/rails_7.1.gemfile +0 -15
  101. data/gemfiles/rails_7.2.gemfile +0 -15
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require_relative "base_strategy"
6
+
7
+ module Quo
8
+ module Composing
9
+ # Base class for class composition strategies
10
+ class ClassStrategy < BaseStrategy
11
+ # @rbs left_query_class: Class
12
+ # @rbs right_query_class: Class
13
+ # @rbs return: void
14
+ def validate_query_classes(left_query_class, right_query_class)
15
+ unless left_query_class.respond_to?(:<) && right_query_class.respond_to?(:<)
16
+ raise ArgumentError, "Cannot compose #{left_query_class} and #{right_query_class}, are they both classes? If you want to use instances use `.merge_instances`"
17
+ end
18
+ end
19
+
20
+ # Collect properties that need to be (re)defined on the composed class.
21
+ # Properties already declared on `chosen_superclass` are inherited via the
22
+ # normal Ruby/Literal class hierarchy and do not need to be re-registered;
23
+ # skipping them avoids the per-prop Literal::Property allocation, schema
24
+ # dup, and `module_eval` of reader/writer source on every Class.new.
25
+ # @rbs chosen_superclass: Class
26
+ # @rbs left_query_class: Class
27
+ # @rbs right_query_class: Class
28
+ # @rbs return: Hash[Symbol, Literal::Property]
29
+ def collect_properties(chosen_superclass, left_query_class, right_query_class)
30
+ existing = chosen_superclass.literal_properties.properties_index
31
+ props = {}
32
+ if left_query_class < Quo::Query
33
+ left_query_class.literal_properties.properties_index.each do |name, property|
34
+ props[name] = property unless existing.key?(name)
35
+ end
36
+ end
37
+ if right_query_class < Quo::Query
38
+ right_query_class.literal_properties.properties_index.each do |name, property|
39
+ props[name] = property unless existing.key?(name)
40
+ end
41
+ end
42
+ props
43
+ end
44
+
45
+ # @rbs chosen_superclass: Class
46
+ # @rbs props: Hash[Symbol, Literal::Property]
47
+ # @rbs return: Class & Quo::ComposedQuery
48
+ def create_composed_class(chosen_superclass, props)
49
+ Class.new(chosen_superclass) do
50
+ include Quo::ComposedQuery
51
+ props.each do |name, property|
52
+ prop(
53
+ name,
54
+ property.type,
55
+ property.kind,
56
+ reader: property.reader,
57
+ writer: property.writer,
58
+ default: property.default
59
+ )
60
+ end
61
+ end
62
+ end
63
+
64
+ # @rbs klass: Class
65
+ # @rbs left_query_class: Class
66
+ # @rbs right_query_class: Class
67
+ # @rbs joins: Symbol | Hash[Symbol, untyped] | Array[Symbol | Hash[Symbol, untyped]]?
68
+ # @rbs left_spec: Quo::RelationBackedQuerySpecification?
69
+ # @rbs right_spec: Quo::RelationBackedQuerySpecification?
70
+ # @rbs return: void
71
+ def assign_query_metadata(klass, left_query_class, right_query_class, joins, left_spec, right_spec)
72
+ # merge spec and joins
73
+ left_joins = left_spec ? left_spec[:joins] : []
74
+ left_joins = left_joins.is_a?(Array) ? left_joins : [left_joins]
75
+ joins = joins.is_a?(Array) ? joins : [joins] if joins
76
+ merge_left_joins = joins ? joins + left_joins : left_joins
77
+
78
+ klass.instance_variable_set(:@_composing_joins, merge_left_joins)
79
+ klass.instance_variable_set(:@_left_specification, left_spec)
80
+ klass.instance_variable_set(:@_right_specification, right_spec)
81
+ klass.instance_variable_set(:@_left_query, left_query_class)
82
+ klass.instance_variable_set(:@_right_query, right_query_class)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require_relative "query_classes_strategy"
6
+
7
+ module Quo
8
+ module Composing
9
+ # Registry for class composition strategies
10
+ class ClassStrategyRegistry
11
+ # @rbs return: Array[Quo::Composing::BaseStrategy]
12
+ def strategies
13
+ @strategies ||= [
14
+ QueryClassesStrategy.new
15
+ # Add more class strategies as needed
16
+ ]
17
+ end
18
+
19
+ # @rbs left: Class
20
+ # @rbs right: Class
21
+ # @rbs return: Quo::Composing::BaseStrategy
22
+ def find_strategy(left, right)
23
+ strategy = strategies.find { |s| s.applicable?(left, right) }
24
+ unless strategy
25
+ raise ArgumentError, "No class composition strategy found for #{left.class} and #{right.class}"
26
+ end
27
+ strategy
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require_relative "class_strategy"
6
+
7
+ module Quo
8
+ module Composing
9
+ # Strategy for composing two Query classes
10
+ class QueryClassesStrategy < ClassStrategy
11
+ # @rbs override
12
+ # @rbs left: Class
13
+ # @rbs right: Class
14
+ # @rbs return: bool
15
+ def applicable?(left, right)
16
+ left.respond_to?(:<) && right.respond_to?(:<) &&
17
+ (left < Quo::Query || left.is_a?(::ActiveRecord::Relation)) &&
18
+ (right < Quo::Query || right.is_a?(::ActiveRecord::Relation))
19
+ end
20
+
21
+ # @rbs override
22
+ # @rbs chosen_superclass: Class
23
+ # @rbs left_query_class: Class
24
+ # @rbs right_query_class: Class
25
+ # @rbs joins: Symbol | Hash[Symbol, untyped] | Array[Symbol | Hash[Symbol, untyped]]?
26
+ # @rbs left_spec: Quo::RelationBackedQuerySpecification?
27
+ # @rbs right_spec: Quo::RelationBackedQuerySpecification?
28
+ # @rbs return: Class & Quo::ComposedQuery
29
+ def compose(chosen_superclass, left_query_class, right_query_class, joins: nil, left_spec: nil, right_spec: nil)
30
+ validate_query_classes(left_query_class, right_query_class)
31
+ props = collect_properties(chosen_superclass, left_query_class, right_query_class)
32
+ klass = create_composed_class(chosen_superclass, props)
33
+ assign_query_metadata(klass, left_query_class, right_query_class, joins, left_spec, right_spec)
34
+ klass
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ require_relative "composing/class_strategy_registry"
6
+
7
+ module Quo
8
+ module Composing
9
+ CLASS_STRATEGY_REGISTRY = ClassStrategyRegistry.new
10
+
11
+ class << self
12
+ # @rbs chosen_superclass: Class
13
+ # @rbs left_query_class: Class
14
+ # @rbs right_query_class: Class
15
+ # @rbs joins: Symbol | Hash[Symbol, untyped] | Array[Symbol | Hash[Symbol, untyped]]?
16
+ # @rbs left_spec: Quo::RelationBackedQuerySpecification?
17
+ # @rbs right_spec: Quo::RelationBackedQuerySpecification?
18
+ # @rbs return: Class & Quo::ComposedQuery
19
+ def composer(chosen_superclass, left_query_class, right_query_class, joins: nil, left_spec: nil, right_spec: nil)
20
+ strategy = CLASS_STRATEGY_REGISTRY.find_strategy(left_query_class, right_query_class)
21
+ strategy.compose(chosen_superclass, left_query_class, right_query_class, joins: joins, left_spec: left_spec, right_spec: right_spec)
22
+ end
23
+
24
+ # @rbs left_instance: Quo::Query
25
+ # @rbs right_instance: Quo::Query | ActiveRecord::Relation | Object & Enumerable[untyped]
26
+ # @rbs joins: Symbol | Hash[Symbol, untyped] | Array[Symbol | Hash[Symbol, untyped]]?
27
+ # @rbs return: Quo::Query
28
+ def merge_instances(left_instance, right_instance, joins: nil)
29
+ page, page_size = inherited_pagination(left_instance, right_instance)
30
+ transformer = inherited_transformer(left_instance, right_instance)
31
+
32
+ opts = {left: left_instance, right: right_instance, merge_joins: joins}
33
+ opts[:page] = page if page
34
+ opts[:page_size] = page_size if page_size
35
+
36
+ composed = if relation_backed?(left_instance) || relation_backed?(right_instance)
37
+ Quo::ComposedRelationBackedQuery.new(**opts)
38
+ else
39
+ Quo::ComposedCollectionBackedQuery.new(**opts)
40
+ end
41
+
42
+ composed.transform(&transformer) if transformer
43
+ composed
44
+ end
45
+
46
+ private
47
+
48
+ # @rbs operand: untyped
49
+ # @rbs return: bool
50
+ def relation_backed?(operand)
51
+ operand.is_a?(Quo::RelationBackedQuery) || operand.is_a?(::ActiveRecord::Relation)
52
+ end
53
+
54
+ # @rbs left: untyped
55
+ # @rbs right: untyped
56
+ # @rbs return: Array[Integer?]
57
+ def inherited_pagination(left, right)
58
+ operand = pagination_source(right) || pagination_source(left)
59
+ return [nil, nil] unless operand
60
+ [operand.page, operand.page_size]
61
+ end
62
+
63
+ # @rbs operand: untyped
64
+ # @rbs return: untyped
65
+ def pagination_source(operand)
66
+ return nil unless operand.respond_to?(:page) && operand.respond_to?(:page_size)
67
+ operand.page ? operand : nil
68
+ end
69
+
70
+ # @rbs left: untyped
71
+ # @rbs right: untyped
72
+ # @rbs return: Proc?
73
+ def inherited_transformer(left, right)
74
+ right_transformer = left_transformer = nil
75
+ right_transformer = right.send(:transformer) if right.is_a?(Quo::Query) && right.transform?
76
+ left_transformer = left.send(:transformer) if left.is_a?(Quo::Query) && left.transform?
77
+ right_transformer || left_transformer
78
+ end
79
+ end
80
+ end
81
+ end
data/lib/quo/engine.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module Quo
4
+ # Rails engine for integrating Quo with Rails applications
4
5
  class Engine < ::Rails::Engine
5
6
  isolate_namespace Quo
6
7
 
@@ -1,40 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  require "minitest/mock"
4
6
 
5
7
  require_relative "../testing/collection_backed_fake"
6
8
  require_relative "../testing/relation_backed_fake"
9
+ require_relative "../testing/fake_helpers"
7
10
 
8
11
  module Quo
9
12
  module Minitest
13
+ # Test helpers for stubbing query objects in Minitest
10
14
  module Helpers
11
- def fake_query(query_class, results: [], total_count: nil, page_count: nil, &block)
12
- # make it so that results of instances of this class return a fake Result object
13
- # of the right type which returns the results passed in
14
- if query_class < Quo::CollectionBackedQuery
15
- klass = Class.new(Quo::Testing::CollectionBackedFake) do
16
- if query_class < Quo::Preloadable
17
- include Quo::Preloadable
15
+ include Quo::Testing::FakeHelpers
18
16
 
19
- def query
20
- collection
21
- end
22
- end
23
- end
24
- query_class.stub(:new, ->(**kwargs) {
25
- klass.new(results: results, total_count: total_count, page_count: page_count)
26
- }) do
27
- yield
28
- end
29
- elsif query_class < Quo::RelationBackedQuery
30
- query_class.stub(:new, ->(**kwargs) {
31
- Quo::Testing::RelationBackedFake.new(results: results, total_count: total_count, page_count: page_count)
32
- }) do
33
- yield
34
- end
35
- else
17
+ def fake_query(query_class, results: [], total_count: nil, page_count: nil, &block)
18
+ unless query_class < Quo::Query
36
19
  raise ArgumentError, "Not a Query class: #{query_class}"
37
20
  end
21
+
22
+ klass = build_fake_class(query_class)
23
+ query_class.stub(:new, ->(**kwargs) {
24
+ klass.new(results: results, total_count: total_count, page_count: page_count)
25
+ }) do
26
+ yield
27
+ end
38
28
  end
39
29
  end
40
30
  end
@@ -3,6 +3,7 @@
3
3
  # rbs_inline: enabled
4
4
 
5
5
  module Quo
6
+ # Mixin for adding preload/includes support to collection-backed queries
6
7
  module Preloadable
7
8
  def self.included(base)
8
9
  base.prop :_rel_preload, base._Nilable(base._Any), reader: false, writer: false
data/lib/quo/query.rb CHANGED
@@ -2,24 +2,27 @@
2
2
 
3
3
  # rbs_inline: enabled
4
4
 
5
- require "literal"
6
-
7
5
  module Quo
6
+ # Base class for query objects with pagination and composition support
8
7
  class Query < Literal::Struct
9
8
  include Literal::Types
10
9
 
10
+ # @rbs override
11
11
  def self.inspect
12
12
  "#{name || "(anonymous)"}<#{superclass}>"
13
13
  end
14
14
 
15
+ # @rbs override
15
16
  def self.to_s
16
17
  inspect
17
18
  end
18
19
 
20
+ # @rbs override
19
21
  def inspect
20
22
  "#{self.class.name || "(anonymous)"}<#{self.class.superclass} #{paged? ? "" : "not "}paginated>#{super}"
21
23
  end
22
24
 
25
+ # @rbs override
23
26
  def to_s
24
27
  inspect
25
28
  end
@@ -35,12 +38,26 @@ module Quo
35
38
  else
36
39
  Quo.relation_backed_query_base_class
37
40
  end
38
- ComposedQuery.composer(super_class, self, right, joins: joins)
41
+ Composing.composer(super_class, self, right, joins: joins)
39
42
  end
40
43
  singleton_class.alias_method :+, :compose
41
44
 
45
+ # Helper method to define properties on a dynamically created class
46
+ # @rbs klass: Class
47
+ # @rbs props: Hash[Symbol, untyped]
48
+ # @rbs return: void
49
+ def self.define_props_on_class(klass, props)
50
+ props.each do |name, property|
51
+ if property.is_a?(Literal::Property)
52
+ klass.prop name, property.type, property.kind, reader: property.reader, writer: property.writer, default: property.default
53
+ else
54
+ klass.prop name, property
55
+ end
56
+ end
57
+ end
58
+
42
59
  COERCE_TO_INT = ->(value) do #: (untyped value) -> Integer?
43
- return if value == Literal::Null
60
+ return if value.equal?(Literal::Undefined)
44
61
  value&.to_i
45
62
  end
46
63
 
@@ -87,7 +104,7 @@ module Quo
87
104
  # @rbs joins: untyped
88
105
  # @rbs return: Quo::ComposedQuery
89
106
  def merge(right, joins: nil)
90
- ComposedQuery.merge_instances(self, right, joins: joins)
107
+ Composing.merge_instances(self, right, joins: joins)
91
108
  end
92
109
  alias_method :+, :merge
93
110
 
@@ -2,26 +2,24 @@
2
2
 
3
3
  # rbs_inline: enabled
4
4
 
5
- require "literal"
6
-
7
5
  module Quo
6
+ # Query object backed by ActiveRecord relations
8
7
  class RelationBackedQuery < Query
9
- # @rbs query: ActiveRecord::Relation | Quo::Query
8
+ # @rbs query: ActiveRecord::Relation | Quo::RelationBackedQuery
10
9
  # @rbs props: Hash[Symbol, untyped]
11
- # @rbs &block: () -> ActiveRecord::Relation | Quo::Query | Object & Enumerable[untyped]
10
+ # @rbs &block: ? () -> (ActiveRecord::Relation | Quo::Query)
12
11
  # @rbs return: Quo::RelationBackedQuery
13
12
  def self.wrap(query = nil, props: {}, &block)
14
13
  raise ArgumentError, "either a query or a block must be provided" unless query || block
15
14
 
16
- klass = Class.new(self) do
17
- props.each do |name, property|
18
- if property.is_a?(Literal::Property)
19
- prop name, property.type, property.kind, reader: property.reader, writer: property.writer, default: property.default
20
- else
21
- prop name, property
22
- end
23
- end
15
+ if query && !(query.is_a?(::ActiveRecord::Relation) || query.is_a?(Quo::RelationBackedQuery))
16
+ raise ArgumentError,
17
+ "Quo::RelationBackedQuery.wrap requires an ActiveRecord::Relation or a Quo::RelationBackedQuery instance; got #{query.class}. " \
18
+ "Use Quo::CollectionBackedQuery.wrap or Quo::CollectionBackedQuery.from for in-memory collections."
24
19
  end
20
+
21
+ klass = Class.new(self)
22
+ define_props_on_class(klass, props)
25
23
  if block
26
24
  klass.define_method(:query, &block)
27
25
  else
@@ -30,6 +28,12 @@ module Quo
30
28
  klass
31
29
  end
32
30
 
31
+ # @rbs relation: ActiveRecord::Relation
32
+ # @rbs return: Quo::WrappedRelationBackedQuery
33
+ def self.from(relation)
34
+ Quo::WrappedRelationBackedQuery.new(wrapped: relation)
35
+ end
36
+
33
37
  # @rbs conditions: untyped?
34
38
  # @rbs return: String
35
39
  def self.sanitize_sql_for_conditions(conditions)
@@ -53,7 +57,6 @@ module Quo
53
57
  # @_specification: Quo::RelationBackedQuerySpecification?
54
58
  prop :_specification, _Nilable(Quo::RelationBackedQuerySpecification),
55
59
  default: -> { RelationBackedQuerySpecification.blank },
56
- reader: false,
57
60
  writer: false
58
61
 
59
62
  # Apply a query specification to this query
@@ -72,10 +75,12 @@ module Quo
72
75
  end
73
76
 
74
77
  # Delegate methods that let us get the model class (available on AR relations)
75
- # @rbs def model: () -> (untyped | nil)
76
- # @rbs def klass: () -> (untyped | nil)
78
+ # @rbs!
79
+ # def model: () -> (untyped | nil)
80
+ # def klass: () -> (untyped | nil)
77
81
  delegate :model, :klass, to: :underlying_query
78
82
 
83
+ # @rbs total_count: Integer?
79
84
  # @rbs return: Quo::CollectionBackedQuery
80
85
  def to_collection(total_count: nil)
81
86
  Quo.collection_backed_query_base_class.wrap(results.to_a).new(total_count:)
@@ -86,7 +91,7 @@ module Quo
86
91
  end
87
92
 
88
93
  # Return the SQL string for this query if its a relation type query object
89
- def to_sql #: String
94
+ def to_sql #: String?
90
95
  configured_query.to_sql if relation?
91
96
  end
92
97
 
@@ -115,8 +120,9 @@ module Quo
115
120
  # @rbs include_private: bool
116
121
  # @rbs return: bool
117
122
  def respond_to_missing?(method_name, include_private = false)
118
- spec_instance = RelationBackedQuerySpecification.new
119
- spec_instance.respond_to?(method_name, include_private) || super
123
+ # Reuse the memoized blank specification rather than allocating a fresh
124
+ # instance per probe — ActiveRecord's delegation hits respond_to? heavily.
125
+ RelationBackedQuerySpecification.blank.respond_to?(method_name, include_private) || super
120
126
  end
121
127
 
122
128
  private
@@ -17,9 +17,10 @@ module Quo
17
17
  end
18
18
 
19
19
  # Creates a new specification with merged options
20
- # @rbs new_options: Hash[Symbol, untyped]
21
- # @rbs return: Quo::QuerySpecification
20
+ # @rbs new_options: Hash[Symbol, untyped] | RelationBackedQuerySpecification
21
+ # @rbs return: Quo::RelationBackedQuerySpecification
22
22
  def merge(new_options)
23
+ new_options = new_options.options if new_options.is_a?(self.class)
23
24
  self.class.new(options.merge(new_options))
24
25
  end
25
26
 
@@ -34,8 +35,12 @@ module Quo
34
35
  rel = rel.group(*options[:group]) if options[:group]
35
36
  rel = rel.limit(options[:limit]) if options[:limit]
36
37
  rel = rel.offset(options[:offset]) if options[:offset]
37
- rel = rel.joins(options[:joins]) if options[:joins]
38
- rel = rel.left_outer_joins(options[:left_outer_joins]) if options[:left_outer_joins]
38
+ if (joins = options[:joins])
39
+ rel = joins.is_a?(Array) ? rel.joins(*joins) : rel.joins(joins)
40
+ end
41
+ if (left_outer_joins = options[:left_outer_joins])
42
+ rel = left_outer_joins.is_a?(Array) ? rel.left_outer_joins(*left_outer_joins) : rel.left_outer_joins(left_outer_joins)
43
+ end
39
44
  rel = rel.includes(*options[:includes]) if options[:includes]
40
45
  rel = rel.preload(*options[:preload]) if options[:preload]
41
46
  rel = rel.eager_load(*options[:eager_load]) if options[:eager_load]
@@ -46,107 +51,121 @@ module Quo
46
51
  rel
47
52
  end
48
53
 
54
+ # Introspection
55
+
56
+ # @rbs key: Symbol
57
+ # @rbs return: bool
58
+ def has?(key)
59
+ options.key?(key)
60
+ end
61
+
62
+ # @rbs key: Symbol
63
+ # @rbs return: untyped
64
+ def [](key)
65
+ options[key]
66
+ end
67
+
49
68
  # Create helpers for each query option
50
69
 
51
70
  # @rbs *fields: untyped
52
- # @rbs return: Quo::QuerySpecification
71
+ # @rbs return: Quo::RelationBackedQuerySpecification
53
72
  def select(*fields)
54
73
  merge(select: fields)
55
74
  end
56
75
 
57
76
  # @rbs conditions: untyped
58
- # @rbs return: Quo::QuerySpecification
77
+ # @rbs return: Quo::RelationBackedQuerySpecification
59
78
  def where(conditions)
60
79
  merge(where: conditions)
61
80
  end
62
81
 
63
82
  # @rbs order_clause: untyped
64
- # @rbs return: Quo::QuerySpecification
83
+ # @rbs return: Quo::RelationBackedQuerySpecification
65
84
  def order(order_clause)
66
85
  merge(order: order_clause)
67
86
  end
68
87
 
69
88
  # @rbs *columns: untyped
70
- # @rbs return: Quo::QuerySpecification
89
+ # @rbs return: Quo::RelationBackedQuerySpecification
71
90
  def group(*columns)
72
91
  merge(group: columns)
73
92
  end
74
93
 
75
94
  # @rbs value: Integer
76
- # @rbs return: Quo::QuerySpecification
95
+ # @rbs return: Quo::RelationBackedQuerySpecification
77
96
  def limit(value)
78
97
  merge(limit: value)
79
98
  end
80
99
 
81
100
  # @rbs value: Integer
82
- # @rbs return: Quo::QuerySpecification
101
+ # @rbs return: Quo::RelationBackedQuerySpecification
83
102
  def offset(value)
84
103
  merge(offset: value)
85
104
  end
86
105
 
87
- # @rbs tables: untyped
88
- # @rbs return: Quo::QuerySpecification
89
- def joins(tables)
106
+ # @rbs *tables: untyped
107
+ # @rbs return: Quo::RelationBackedQuerySpecification
108
+ def joins(*tables)
90
109
  merge(joins: tables)
91
110
  end
92
111
 
93
- # @rbs tables: untyped
94
- # @rbs return: Quo::QuerySpecification
95
- def left_outer_joins(tables)
112
+ # @rbs *tables: untyped
113
+ # @rbs return: Quo::RelationBackedQuerySpecification
114
+ def left_outer_joins(*tables)
96
115
  merge(left_outer_joins: tables)
97
116
  end
98
117
 
99
118
  # @rbs *associations: untyped
100
- # @rbs return: Quo::QuerySpecification
119
+ # @rbs return: Quo::RelationBackedQuerySpecification
101
120
  def includes(*associations)
102
121
  merge(includes: associations)
103
122
  end
104
123
 
105
124
  # @rbs *associations: untyped
106
- # @rbs return: Quo::QuerySpecification
125
+ # @rbs return: Quo::RelationBackedQuerySpecification
107
126
  def preload(*associations)
108
127
  merge(preload: associations)
109
128
  end
110
129
 
111
130
  # @rbs *associations: untyped
112
- # @rbs return: Quo::QuerySpecification
131
+ # @rbs return: Quo::RelationBackedQuerySpecification
113
132
  def eager_load(*associations)
114
133
  merge(eager_load: associations)
115
134
  end
116
135
 
117
136
  # @rbs enabled: bool
118
- # @rbs return: Quo::QuerySpecification
137
+ # @rbs return: Quo::RelationBackedQuerySpecification
119
138
  def distinct(enabled = true)
120
139
  merge(distinct: enabled)
121
140
  end
122
141
 
123
142
  # @rbs order_clause: untyped
124
- # @rbs return: Quo::QuerySpecification
143
+ # @rbs return: Quo::RelationBackedQuerySpecification
125
144
  def reorder(order_clause)
126
145
  merge(reorder: order_clause)
127
146
  end
128
147
 
129
148
  # @rbs *modules: untyped
130
- # @rbs return: Quo::QuerySpecification
149
+ # @rbs return: Quo::RelationBackedQuerySpecification
131
150
  def extending(*modules)
132
151
  merge(extending: modules)
133
152
  end
134
153
 
135
154
  # @rbs *args: untyped
136
- # @rbs return: Quo::QuerySpecification
155
+ # @rbs return: Quo::RelationBackedQuerySpecification
137
156
  def unscope(*args)
138
157
  merge(unscope: args)
139
158
  end
140
159
 
141
160
  # Builds a new specification from a hash of options
142
161
  # @rbs options: Hash[Symbol, untyped]
143
- # @rbs return: Quo::QuerySpecification
162
+ # @rbs return: Quo::RelationBackedQuerySpecification
144
163
  def self.build(options = {})
145
164
  new(options)
146
165
  end
147
166
 
148
167
  # Returns a blank specification
149
- # @rbs return: Quo::QuerySpecification
168
+ # @rbs return: Quo::RelationBackedQuerySpecification
150
169
  def self.blank
151
170
  @blank ||= new
152
171
  end
@@ -3,6 +3,7 @@
3
3
  # rbs_inline: enabled
4
4
 
5
5
  module Quo
6
+ # Results wrapper for relation-backed queries providing pagination and counting
6
7
  class RelationResults < Results
7
8
  # @rbs query: Quo::Query
8
9
  # @rbs transformer: (^(untyped, ?Integer) -> untyped)?