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,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Quo
6
+ module Generators
7
+ # Installs the bundled Claude Code skill into the host application's
8
+ # .claude/skills/quo/ directory. Optionally appends a short "Quo" section
9
+ # to the project's top-level CLAUDE.md.
10
+ class InstallGenerator < ::Rails::Generators::Base
11
+ # Source the skill content directly from claude-skill/ at the gem root.
12
+ # The generator file lives at lib/generators/quo/install/, so we go up
13
+ # four levels to reach the gem root.
14
+ source_root File.expand_path("../../../../claude-skill", __dir__)
15
+
16
+ desc "Install the Quo Claude Code skill into .claude/skills/quo/"
17
+
18
+ class_option :with_claude_md, type: :boolean, default: false,
19
+ desc: "Append a 'Quo' section to CLAUDE.md pointing at the skill"
20
+
21
+ CLAUDE_MD_MARKER = "## Quo"
22
+
23
+ CLAUDE_MD_FRAGMENT = <<~MD
24
+ #{CLAUDE_MD_MARKER}
25
+
26
+ This project uses the [Quo gem](https://github.com/stevegeek/quo) for
27
+ query objects. See `.claude/skills/quo/SKILL.md` for usage guidance,
28
+ including the class-vs-instance composition rules.
29
+ MD
30
+
31
+ def copy_skill
32
+ directory ".", ".claude/skills/quo"
33
+ end
34
+
35
+ def maybe_append_claude_md
36
+ return unless options[:with_claude_md]
37
+
38
+ path = "CLAUDE.md"
39
+ full_path = File.join(destination_root, path)
40
+ if File.exist?(full_path)
41
+ if File.read(full_path).include?(CLAUDE_MD_MARKER)
42
+ say_status :skip, "#{path} already contains '#{CLAUDE_MD_MARKER}' section", :yellow
43
+ else
44
+ append_to_file path, "\n#{CLAUDE_MD_FRAGMENT}"
45
+ end
46
+ else
47
+ create_file path, CLAUDE_MD_FRAGMENT
48
+ end
49
+ end
50
+
51
+ def show_post_install_message
52
+ say ""
53
+ say "Quo skill installed at .claude/skills/quo/", :green
54
+ say "Claude Code will pick it up automatically on the next session.", :green
55
+ if options[:with_claude_md]
56
+ say "CLAUDE.md updated with a pointer to the skill.", :green
57
+ end
58
+ say ""
59
+ say "Run with --force after upgrading Quo to refresh the skill content."
60
+ end
61
+ end
62
+ end
63
+ end
@@ -3,33 +3,39 @@
3
3
  # rbs_inline: enabled
4
4
 
5
5
  module Quo
6
+ # Query object backed by in-memory collections
6
7
  class CollectionBackedQuery < Query
7
8
  prop :total_count, _Nilable(Integer), reader: false
8
9
 
9
- # Wrap an enumerable collection or a block that returns an enumerable collection
10
- # @rbs data: untyped, props: Symbol => untyped, block: () -> untyped
10
+ # @rbs data: Enumerable[untyped] | Quo::CollectionBackedQuery
11
+ # @rbs props: Hash[Symbol, untyped]
12
+ # @rbs &block: ? () -> Enumerable[untyped]
11
13
  # @rbs return: Quo::CollectionBackedQuery
12
14
  def self.wrap(data = nil, props: {}, &block)
13
- klass = Class.new(self) do
14
- props.each do |name, property|
15
- if property.is_a?(Literal::Property)
16
- prop name, property.type, property.kind, reader: property.reader, writer: property.writer, default: property.default
17
- else
18
- prop name, property
19
- end
20
- end
15
+ raise ArgumentError, "either a query or a block must be provided" unless data || block
16
+
17
+ if data && !(data.is_a?(::Enumerable) || data.is_a?(Quo::CollectionBackedQuery))
18
+ raise ArgumentError,
19
+ "Quo::CollectionBackedQuery.wrap requires an Enumerable or a Quo::CollectionBackedQuery instance; got #{data.class}. " \
20
+ "Use Quo::RelationBackedQuery.wrap or Quo::RelationBackedQuery.from for ActiveRecord relations."
21
21
  end
22
+
23
+ klass = Class.new(self)
24
+ define_props_on_class(klass, props)
22
25
  if block
23
26
  klass.define_method(:collection, &block)
24
- elsif data
25
- klass.define_method(:collection) { data }
26
27
  else
27
- raise ArgumentError, "either a query or a block must be provided"
28
+ klass.define_method(:collection) { data }
28
29
  end
29
- # klass.set_temporary_name = "quo::Wrapper" # Ruby 3.3+
30
30
  klass
31
31
  end
32
32
 
33
+ # @rbs enumerable: Enumerable[untyped]
34
+ # @rbs return: Quo::WrappedCollectionBackedQuery
35
+ def self.from(enumerable)
36
+ Quo::WrappedCollectionBackedQuery.new(wrapped: enumerable)
37
+ end
38
+
33
39
  # @rbs return: Object & Enumerable[untyped]
34
40
  def collection
35
41
  raise NotImplementedError, "Collection backed query objects must define a 'collection' method"
@@ -42,7 +48,7 @@ module Quo
42
48
  collection
43
49
  end
44
50
 
45
- def results
51
+ def results #: Quo::Results
46
52
  Quo::CollectionResults.new(self, transformer: transformer, total_count: @total_count)
47
53
  end
48
54
 
@@ -3,6 +3,7 @@
3
3
  # rbs_inline: enabled
4
4
 
5
5
  module Quo
6
+ # Results wrapper for collection-backed queries providing pagination and counting
6
7
  class CollectionResults < Results
7
8
  # @rbs override
8
9
  def initialize(query, transformer: nil, total_count: nil)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module Quo
6
+ class ComposedCollectionBackedQuery < Quo.collection_backed_query_base_class
7
+ include ComposedInstance
8
+
9
+ # @rbs!
10
+ # @left: Quo::Query | Enumerable[untyped]
11
+ # @right: Quo::Query | Enumerable[untyped]
12
+ # @merge_joins: Symbol | Hash[untyped, untyped] | Array[untyped] | nil
13
+ prop :left, _Union(Quo::Query, Enumerable), writer: false
14
+ prop :right, _Union(Quo::Query, Enumerable), writer: false
15
+ prop :merge_joins, _Nilable(_Union(Symbol, Hash, Array)), default: -> {}, writer: false
16
+
17
+ # @rbs override
18
+ def collection
19
+ merge_left_and_right
20
+ end
21
+
22
+ # @rbs override
23
+ def inspect
24
+ "#{self.class.name}[#{operand_desc(left)}, #{operand_desc(right)}]"
25
+ end
26
+
27
+ private
28
+
29
+ # @rbs operand: untyped
30
+ # @rbs return: String
31
+ def operand_desc(operand)
32
+ case operand
33
+ when Quo::Query
34
+ operand.class.name || "(anonymous Quo::Query)"
35
+ when ::ActiveRecord::Relation
36
+ operand.klass.name
37
+ else
38
+ operand.class.name || "(anonymous)"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module Quo
6
+ module ComposedInstance
7
+ # @rbs **overrides: untyped
8
+ # @rbs return: Quo::Query
9
+ def copy(**overrides)
10
+ return super if overrides.empty?
11
+
12
+ own_keys = own_property_names
13
+ own_overrides = overrides.slice(*own_keys)
14
+ fan_overrides = overrides.except(*own_keys)
15
+
16
+ result = own_overrides.empty? ? self : super(**own_overrides)
17
+ fan_overrides.each do |key, value|
18
+ result = result.send(:fan_override, key, value)
19
+ end
20
+ result
21
+ end
22
+
23
+ private
24
+
25
+ # @rbs prop_name: Symbol
26
+ # @rbs value: untyped
27
+ # @rbs return: Quo::Query
28
+ def fan_override(prop_name, value)
29
+ right_match = operand_accepts?(right, prop_name)
30
+ left_match = operand_accepts?(left, prop_name)
31
+ unless right_match || left_match
32
+ raise ArgumentError, "unknown property #{prop_name.inspect} on #{self.class}"
33
+ end
34
+
35
+ result = self
36
+ if right_match
37
+ result = result.copy(right: apply_to_operand(right, prop_name, value))
38
+ end
39
+ if left_match
40
+ result = result.copy(left: apply_to_operand(left, prop_name, value))
41
+ end
42
+ result
43
+ end
44
+
45
+ # @rbs return: Array[Symbol]
46
+ def own_property_names
47
+ self.class.literal_properties.properties_index.keys
48
+ end
49
+
50
+ # @rbs operand: untyped
51
+ # @rbs prop_name: Symbol
52
+ # @rbs return: bool
53
+ def operand_accepts?(operand, prop_name)
54
+ case operand
55
+ when Quo::ComposedInstance
56
+ operand.send(:operand_accepts?, operand.right, prop_name) ||
57
+ operand.send(:operand_accepts?, operand.left, prop_name)
58
+ when Quo::Query
59
+ operand.class.literal_properties.properties_index.key?(prop_name)
60
+ else
61
+ false
62
+ end
63
+ end
64
+
65
+ # @rbs operand: untyped
66
+ # @rbs prop_name: Symbol
67
+ # @rbs value: untyped
68
+ # @rbs return: untyped
69
+ def apply_to_operand(operand, prop_name, value)
70
+ case operand
71
+ when Quo::ComposedInstance
72
+ operand.send(:fan_override, prop_name, value)
73
+ when Quo::Query
74
+ operand.copy(prop_name => value)
75
+ end
76
+ end
77
+
78
+ # @rbs return: ActiveRecord::Relation | Enumerable[untyped]
79
+ def merge_left_and_right
80
+ left_rel = unwrap_operand(left)
81
+ right_rel = unwrap_operand(right)
82
+
83
+ if both_relations?(left_rel, right_rel)
84
+ merge_active_record_relations(left_rel, right_rel)
85
+ elsif left_relation_right_enumerable?(left_rel, right_rel)
86
+ left_rel.to_a + right_rel.to_a
87
+ elsif left_enumerable_right_relation?(left_rel, right_rel) && left_rel.respond_to?(:+)
88
+ left_rel.to_a + right_rel.to_a
89
+ elsif left_rel.respond_to?(:+)
90
+ left_rel + right_rel
91
+ else
92
+ raise ArgumentError, "Cannot merge #{left.class} with #{right.class}"
93
+ end
94
+ end
95
+
96
+ # @rbs operand: untyped
97
+ # @rbs return: ActiveRecord::Relation | Enumerable[untyped]
98
+ def unwrap_operand(operand)
99
+ case operand
100
+ when Quo::Query
101
+ operand.unwrap_unpaginated
102
+ when ::ActiveRecord::Relation
103
+ operand
104
+ else
105
+ operand
106
+ end
107
+ end
108
+
109
+ # @rbs left_rel: ActiveRecord::Relation
110
+ # @rbs right_rel: ActiveRecord::Relation
111
+ # @rbs return: ActiveRecord::Relation
112
+ def merge_active_record_relations(left_rel, right_rel)
113
+ left_rel = left_rel.joins(merge_joins) if merge_joins
114
+ left_rel.merge(right_rel)
115
+ end
116
+
117
+ # @rbs rel: untyped
118
+ # @rbs return: bool
119
+ def is_relation?(rel)
120
+ rel.is_a?(::ActiveRecord::Relation)
121
+ end
122
+
123
+ # @rbs lr: untyped
124
+ # @rbs rr: untyped
125
+ # @rbs return: bool
126
+ def both_relations?(lr, rr)
127
+ is_relation?(lr) && is_relation?(rr)
128
+ end
129
+
130
+ # @rbs lr: untyped
131
+ # @rbs rr: untyped
132
+ # @rbs return: bool
133
+ def left_relation_right_enumerable?(lr, rr)
134
+ is_relation?(lr) && !is_relation?(rr)
135
+ end
136
+
137
+ # @rbs lr: untyped
138
+ # @rbs rr: untyped
139
+ # @rbs return: bool
140
+ def left_enumerable_right_relation?(lr, rr)
141
+ !is_relation?(lr) && is_relation?(rr)
142
+ end
143
+ end
144
+ end
@@ -2,48 +2,45 @@
2
2
 
3
3
  # rbs_inline: enabled
4
4
 
5
+ require_relative "composing"
6
+
5
7
  module Quo
8
+ # Mixin for queries composed of two child queries
6
9
  module ComposedQuery
7
- # Combine two Query classes into a new composed query class
8
- # Combine two query-like or composeable entities:
9
- # These can be Quo::Query, Quo::ComposedQuery, Quo::CollectionBackedQuery and ActiveRecord::Relations.
10
- # See the `README.md` docs for more details.
11
- # @rbs chosen_superclass: singleton(Quo::RelationBackedQuery | Quo::CollectionBackedQuery)
12
- # @rbs left_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
13
- # @rbs right_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
14
- # @rbs joins: untyped
15
- # @rbs return: singleton(Quo::ComposedQuery)
16
- def composer(chosen_superclass, left_query_class, right_query_class, joins: nil)
17
- validate_query_classes(left_query_class, right_query_class)
18
-
19
- props = collect_properties(left_query_class, right_query_class)
20
- klass = create_composed_class(chosen_superclass, props)
21
-
22
- assign_query_metadata(klass, left_query_class, right_query_class, joins)
23
- klass
24
- end
25
- module_function :composer
26
-
27
- # We can also merge instance of prepared queries
28
- # @rbs left_instance: Quo::Query | ::ActiveRecord::Relation
29
- # @rbs right_instance: Quo::Query | ::ActiveRecord::Relation
30
- # @rbs joins: untyped
31
- # @rbs return: Quo::ComposedQuery
32
- def merge_instances(left_instance, right_instance, joins: nil)
33
- validate_instances(left_instance, right_instance)
10
+ # Class-level methods shared by every composed query class. Defined once
11
+ # here and extended onto each anon class via the `included` hook below
12
+ # rather than being re-defined on every Class.new in the composer.
13
+ module ClassMethods
14
+ attr_reader :_composing_joins, :_left_specification, :_right_specification, :_left_query, :_right_query
15
+
16
+ # @rbs return: String
17
+ def inspect
18
+ left_desc = quo_operand_desc(_left_query)
19
+ right_desc = quo_operand_desc(_right_query)
20
+ klass_name = if self < Quo::RelationBackedQuery
21
+ Quo.relation_backed_query_base_class.name
22
+ else
23
+ Quo.collection_backed_query_base_class.name
24
+ end
25
+ "#{klass_name}<Quo::ComposedQuery>[#{left_desc}, #{right_desc}]"
26
+ end
34
27
 
35
- if left_instance.is_a?(Quo::Query) && right_instance.is_a?(::ActiveRecord::Relation)
36
- return merge_query_and_relation(left_instance, right_instance, joins)
37
- elsif right_instance.is_a?(Quo::Query) && left_instance.is_a?(::ActiveRecord::Relation)
38
- return merge_relation_and_query(left_instance, right_instance, joins)
39
- elsif left_instance.is_a?(Quo::Query) && right_instance.is_a?(Quo::Query)
40
- return merge_query_instances(left_instance, right_instance, joins)
28
+ # @rbs operand: Class
29
+ # @rbs return: String
30
+ def quo_operand_desc(operand)
31
+ if operand < Quo::ComposedQuery
32
+ operand.inspect
33
+ else
34
+ operand.name || operand.superclass&.name || "(anonymous)"
35
+ end
41
36
  end
37
+ end
42
38
 
43
- # Both are AR relations
44
- composer(Quo.relation_backed_query_base_class, left_instance, right_instance, joins: joins).new
39
+ # @rbs base: Class
40
+ # @rbs return: void
41
+ def self.included(base)
42
+ base.extend(ClassMethods)
45
43
  end
46
- module_function :merge_instances
47
44
 
48
45
  # @rbs override
49
46
  def query
@@ -56,139 +53,6 @@ module Quo
56
53
  "#{klass_name}<Quo::ComposedQuery>[#{self.class.quo_operand_desc(left.class)}, #{self.class.quo_operand_desc(right.class)}](#{super})"
57
54
  end
58
55
 
59
- class << self
60
- private
61
-
62
- # @rbs left_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
63
- # @rbs right_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
64
- def validate_query_classes(left_query_class, right_query_class)
65
- unless left_query_class.respond_to?(:<) && right_query_class.respond_to?(:<)
66
- raise ArgumentError, "Cannot compose #{left_query_class} and #{right_query_class}, are they both classes? If you want to use instances use `.merge_instances`"
67
- end
68
- end
69
-
70
- # @rbs left_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
71
- # @rbs right_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
72
- def collect_properties(left_query_class, right_query_class)
73
- props = {}
74
- props.merge!(left_query_class.literal_properties.properties_index) if left_query_class < Quo::Query
75
- props.merge!(right_query_class.literal_properties.properties_index) if right_query_class < Quo::Query
76
- props
77
- end
78
-
79
- def create_composed_class(chosen_superclass, props)
80
- Class.new(chosen_superclass) do
81
- include Quo::ComposedQuery
82
-
83
- class << self
84
- attr_reader :_composing_joins, :_left_query, :_right_query
85
-
86
- def inspect
87
- left_desc = quo_operand_desc(_left_query)
88
- right_desc = quo_operand_desc(_right_query)
89
- klass_name = determine_class_name
90
- "#{klass_name}<Quo::ComposedQuery>[#{left_desc}, #{right_desc}]"
91
- end
92
-
93
- # @rbs operand: Quo::ComposedQuery | Quo::Query | ::ActiveRecord::Relation
94
- # @rbs return: String
95
- def quo_operand_desc(operand)
96
- if operand < Quo::ComposedQuery
97
- operand.inspect
98
- else
99
- operand.name || operand.superclass&.name || "(anonymous)"
100
- end
101
- end
102
-
103
- private
104
-
105
- # @rbs return: String
106
- def determine_class_name
107
- if self < Quo::RelationBackedQuery
108
- Quo.relation_backed_query_base_class.name
109
- else
110
- Quo.collection_backed_query_base_class.name
111
- end
112
- end
113
- end
114
-
115
- props.each do |name, property|
116
- prop(
117
- name,
118
- property.type,
119
- property.kind,
120
- reader: property.reader,
121
- writer: property.writer,
122
- default: property.default
123
- )
124
- end
125
- end
126
- end
127
-
128
- # @rbs klass: Class
129
- # @rbs left_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
130
- # @rbs right_query_class: singleton(Quo::Query | ::ActiveRecord::Relation)
131
- # @rbs joins: untyped
132
- def assign_query_metadata(klass, left_query_class, right_query_class, joins)
133
- klass.instance_variable_set(:@_composing_joins, joins)
134
- klass.instance_variable_set(:@_left_query, left_query_class)
135
- klass.instance_variable_set(:@_right_query, right_query_class)
136
- end
137
-
138
- # @rbs left_instance: Quo::Query | ::ActiveRecord::Relation
139
- # @rbs right_instance: Quo::Query | ::ActiveRecord::Relation
140
- def validate_instances(left_instance, right_instance)
141
- unless left_instance.is_a?(Quo::Query) || left_instance.is_a?(::ActiveRecord::Relation)
142
- raise ArgumentError, "Cannot merge, left has incompatible type #{left_instance.class}"
143
- end
144
-
145
- unless right_instance.is_a?(Quo::Query) || right_instance.is_a?(::ActiveRecord::Relation)
146
- raise ArgumentError, "Cannot merge, right has incompatible type #{right_instance.class}"
147
- end
148
- end
149
-
150
- # @rbs relation: ::ActiveRecord::Relation
151
- # @rbs query: Quo::Query
152
- # @rbs joins: untyped
153
- def merge_query_and_relation(query, relation, joins)
154
- base_class = query.is_a?(Quo::RelationBackedQuery) ?
155
- Quo.relation_backed_query_base_class :
156
- Quo.collection_backed_query_base_class
157
-
158
- composer(base_class, query.class, relation, joins: joins).new(**query.to_h)
159
- end
160
-
161
- # @rbs relation: ::ActiveRecord::Relation
162
- # @rbs query: Quo::Query
163
- # @rbs joins: untyped
164
- def merge_relation_and_query(relation, query, joins)
165
- base_class = query.is_a?(Quo::RelationBackedQuery) ?
166
- Quo.relation_backed_query_base_class :
167
- Quo.collection_backed_query_base_class
168
-
169
- composer(base_class, relation, query.class, joins: joins).new(**query.to_h)
170
- end
171
-
172
- # @rbs left_query: Quo::Query | ::ActiveRecord::Relation
173
- # @rbs right_query: Quo::Query | ::ActiveRecord::Relation
174
- def merge_query_instances(left_query, right_query, joins)
175
- props = left_query.to_h.merge(right_query.to_h.compact)
176
-
177
- base_class = determine_base_class_for_queries(left_query, right_query)
178
- composer(base_class, left_query.class, right_query.class, joins: joins).new(**props)
179
- end
180
-
181
- # @rbs left_query: Quo::Query | ::ActiveRecord::Relation
182
- # @rbs right_query: Quo::Query | ::ActiveRecord::Relation
183
- def determine_base_class_for_queries(left_query, right_query)
184
- both_relation_backed = left_query.is_a?(Quo::RelationBackedQuery) &&
185
- right_query.is_a?(Quo::RelationBackedQuery)
186
-
187
- both_relation_backed ? Quo.relation_backed_query_base_class :
188
- Quo.collection_backed_query_base_class
189
- end
190
- end
191
-
192
56
  private
193
57
 
194
58
  # @rbs return: Hash[Symbol, untyped]
@@ -206,14 +70,20 @@ module Quo
206
70
  def left
207
71
  lq = self.class._left_query
208
72
  return lq if is_relation?(lq)
209
- lq.new(**child_options(lq))
73
+ options = child_options(lq)
74
+ spec = self.class._left_specification
75
+ options[:_specification] = spec if spec && lq < Quo::RelationBackedQuery
76
+ lq.new(**options)
210
77
  end
211
78
 
212
79
  # @rbs return: Quo::Query | ::ActiveRecord::Relation
213
80
  def right
214
81
  rq = self.class._right_query
215
82
  return rq if is_relation?(rq)
216
- rq.new(**child_options(rq))
83
+ options = child_options(rq)
84
+ spec = self.class._right_specification
85
+ options[:_specification] = spec if spec && rq < Quo::RelationBackedQuery
86
+ rq.new(**options)
217
87
  end
218
88
 
219
89
  # @rbs return: ActiveRecord::Relation | CollectionBackedQuery
@@ -238,14 +108,9 @@ module Quo
238
108
  # @rbs right_rel: ActiveRecord::Relation
239
109
  # @rbs return: ActiveRecord::Relation
240
110
  def merge_active_record_relations(left_rel, right_rel)
241
- apply_joins(left_rel).merge(right_rel)
242
- end
243
-
244
- # @rbs left_rel: ActiveRecord::Relation
245
- # @rbs return: ActiveRecord::Relation
246
- def apply_joins(left_rel)
247
111
  joins = self.class._composing_joins
248
- joins.present? ? left_rel.joins(joins) : left_rel
112
+ left_rel = left_rel.joins(joins) if joins
113
+ left_rel.merge(right_rel)
249
114
  end
250
115
 
251
116
  # @rbs rel: untyped
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module Quo
6
+ class ComposedRelationBackedQuery < Quo.relation_backed_query_base_class
7
+ include ComposedInstance
8
+
9
+ # @rbs!
10
+ # @left: Quo::Query | ActiveRecord::Relation | Enumerable[untyped]
11
+ # @right: Quo::Query | ActiveRecord::Relation | Enumerable[untyped]
12
+ # @merge_joins: Symbol | Hash[untyped, untyped] | Array[untyped] | nil
13
+ prop :left, _Union(Quo::Query, Enumerable), writer: false
14
+ prop :right, _Union(Quo::Query, Enumerable), writer: false
15
+ prop :merge_joins, _Nilable(_Union(Symbol, Hash, Array)), default: -> {}, writer: false
16
+
17
+ # @rbs override
18
+ def query
19
+ merge_left_and_right
20
+ end
21
+
22
+ # @rbs override
23
+ def inspect
24
+ "#{self.class.name}[#{operand_desc(left)}, #{operand_desc(right)}]"
25
+ end
26
+
27
+ private
28
+
29
+ # @rbs operand: untyped
30
+ # @rbs return: String
31
+ def operand_desc(operand)
32
+ case operand
33
+ when Quo::Query
34
+ operand.class.name || "(anonymous Quo::Query)"
35
+ when ::ActiveRecord::Relation
36
+ operand.klass.name
37
+ else
38
+ operand.class.name || "(anonymous)"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module Quo
6
+ module Composing
7
+ # Base class for all composition strategies
8
+ class BaseStrategy
9
+ # @rbs left: untyped
10
+ # @rbs right: untyped
11
+ # @rbs return: bool
12
+ def applicable?(left, right)
13
+ raise NoMethodError, "Subclasses must implement #applicable?"
14
+ end
15
+
16
+ # @rbs return: untyped
17
+ def compose(...)
18
+ raise NoMethodError, "Subclasses must implement #compose"
19
+ end
20
+ end
21
+ end
22
+ end