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.
- checksums.yaml +4 -4
- data/.devcontainer/Dockerfile +17 -0
- data/.devcontainer/compose.yml +10 -0
- data/.devcontainer/devcontainer.json +12 -0
- data/Appraisals +4 -12
- data/CHANGELOG.md +112 -1
- data/CLAUDE.md +19 -0
- data/Gemfile +7 -1
- data/LICENSE.txt +1 -1
- data/README.md +496 -203
- data/Rakefile +66 -6
- data/UPGRADING.md +216 -0
- data/badges/coverage_badge_total.svg +35 -0
- data/badges/rubycritic_badge_score.svg +35 -0
- data/claude-skill/README.md +100 -0
- data/claude-skill/SKILL.md +442 -0
- data/claude-skill/references/API_REFERENCE.md +462 -0
- data/claude-skill/references/COMPOSITION.md +396 -0
- data/claude-skill/references/PAGINATION.md +396 -0
- data/claude-skill/references/QUERY_TYPES.md +297 -0
- data/claude-skill/references/TRANSFORMERS.md +282 -0
- data/context/01-core-architecture.md +247 -0
- data/context/02-query-types-implementation.md +355 -0
- data/context/03-composition-transformation.md +441 -0
- data/context/04-pagination-results.md +485 -0
- data/context/05-testing-configuration.md +491 -0
- data/context/06-advanced-patterns-examples.md +153 -0
- data/gemfiles/rails_8.0.gemfile +10 -5
- data/gemfiles/rails_8.1.gemfile +20 -0
- data/lib/generators/quo/install/USAGE +21 -0
- data/lib/generators/quo/install/install_generator.rb +63 -0
- data/lib/quo/collection_backed_query.rb +21 -15
- data/lib/quo/collection_results.rb +1 -0
- data/lib/quo/composed_collection_backed_query.rb +42 -0
- data/lib/quo/composed_instance.rb +144 -0
- data/lib/quo/composed_query.rb +43 -178
- data/lib/quo/composed_relation_backed_query.rb +42 -0
- data/lib/quo/composing/base_strategy.rb +22 -0
- data/lib/quo/composing/class_strategy.rb +86 -0
- data/lib/quo/composing/class_strategy_registry.rb +31 -0
- data/lib/quo/composing/query_classes_strategy.rb +38 -0
- data/lib/quo/composing.rb +81 -0
- data/lib/quo/engine.rb +1 -0
- data/lib/quo/minitest/helpers.rb +14 -24
- data/lib/quo/preloadable.rb +1 -0
- data/lib/quo/query.rb +22 -5
- data/lib/quo/relation_backed_query.rb +24 -18
- data/lib/quo/relation_backed_query_specification.rb +44 -25
- data/lib/quo/relation_results.rb +1 -0
- data/lib/quo/results.rb +31 -2
- data/lib/quo/rspec/helpers.rb +15 -26
- data/lib/quo/testing/collection_backed_fake.rb +1 -0
- data/lib/quo/testing/fake_helpers.rb +30 -0
- data/lib/quo/testing/relation_backed_fake.rb +1 -0
- data/lib/quo/version.rb +1 -1
- data/lib/quo/wrapped_collection_backed_query.rb +21 -0
- data/lib/quo/wrapped_relation_backed_query.rb +21 -0
- data/lib/quo.rb +8 -0
- data/quo.png +0 -0
- data/sig/generated/quo/collection_backed_query.rbs +10 -4
- data/sig/generated/quo/collection_results.rbs +1 -0
- data/sig/generated/quo/composed_collection_backed_query.rbs +25 -0
- data/sig/generated/quo/composed_instance.rbs +61 -0
- data/sig/generated/quo/composed_query.rbs +23 -56
- data/sig/generated/quo/composed_relation_backed_query.rbs +25 -0
- data/sig/generated/quo/composing/base_strategy.rbs +16 -0
- data/sig/generated/quo/composing/class_strategy.rbs +38 -0
- data/sig/generated/quo/composing/class_strategy_registry.rbs +16 -0
- data/sig/generated/quo/composing/query_classes_strategy.rbs +24 -0
- data/sig/generated/quo/composing.rbs +40 -0
- data/sig/generated/quo/engine.rbs +1 -0
- data/sig/generated/quo/minitest/helpers.rbs +12 -0
- data/sig/generated/quo/preloadable.rbs +1 -0
- data/sig/generated/quo/query.rbs +15 -4
- data/sig/generated/quo/relation_backed_query.rbs +15 -5
- data/sig/generated/quo/relation_backed_query_specification.rbs +47 -39
- data/sig/generated/quo/relation_results.rbs +1 -0
- data/sig/generated/quo/results.rbs +11 -0
- data/sig/generated/quo/rspec/helpers.rbs +12 -0
- data/sig/generated/quo/testing/collection_backed_fake.rbs +1 -0
- data/sig/generated/quo/testing/fake_helpers.rbs +14 -0
- data/sig/generated/quo/testing/relation_backed_fake.rbs +1 -0
- data/sig/generated/quo/wrapped_collection_backed_query.rbs +13 -0
- data/sig/generated/quo/wrapped_relation_backed_query.rbs +13 -0
- data/sig/generated/quo.rbs +1 -0
- data/website/.gitignore +6 -0
- data/website/.nojekyll +0 -0
- data/website/404.html +26 -0
- data/website/Gemfile +24 -0
- data/website/_config.yml +50 -0
- data/website/_data/navigation.yml +8 -0
- data/website/_data/sidebar.yml +2 -0
- data/website/_data/social_links.yml +3 -0
- data/website/_docs/api.md +261 -0
- data/website/_docs/get-started.md +289 -0
- data/website/assets/quo.png +0 -0
- data/website/index.md +141 -0
- metadata +70 -13
- data/gemfiles/rails_7.0.gemfile +0 -15
- data/gemfiles/rails_7.1.gemfile +0 -15
- 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
|
-
#
|
|
10
|
-
# @rbs
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
data/lib/quo/composed_query.rb
CHANGED
|
@@ -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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|