graphql-groups 0.1.2 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46969188f84ff7921fee98bfad6f6264125bcce523dfcb7c93550eb1755a3951
4
- data.tar.gz: f6a925a6c8037b42a134fca19d5d0d631656bdd3ec3a8a963fcfe95dbfbf16e9
3
+ metadata.gz: 402086494f2bba5acc764ad745f9083b13b1f9986f43c9717fb511647c51aa11
4
+ data.tar.gz: 043ea5a04163d20f31b7ea6becac5da0dfda5b3d9721b97798acabb2bd8a9974
5
5
  SHA512:
6
- metadata.gz: 450065ae2c4f015adc736241d1a917cdc65ceaa122305db706f6b138263cbbdf32c1636b7ad2017d058ff4c2276c970a4896e471015badbabbc5ee72aca3be01
7
- data.tar.gz: 81da40cf05c3b6b284858037baee1b9402ef38eaeb4559315784e4fbfd2f6d8a5f7897dabbf981f4a5fd220d5ee66fb06dc89fd8e785202951ca0edb22d69f3b
6
+ metadata.gz: 90e97ff34a842f148d49fcaa6c39bd6605c32bbe9f19e7f7b1d9d2b6ac3562da4fdfb1deff38fe110c83b980229225c45ceff75b3f97d14e0f71791eba610862
7
+ data.tar.gz: 5b561e1e307a1a9618316f4bf695bc96273dd67e156f629ddf3a4cc5a9fdf3d60ada09fce0857b3fdf9575ce1b7425c08864163561d4c18f0912eb2a22a6ec56
data/Gemfile.lock CHANGED
@@ -1,28 +1,28 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- graphql-groups (0.1.1)
4
+ graphql-groups (0.2.0)
5
5
  graphql (~> 1, > 1.9)
6
6
 
7
7
  GEM
8
8
  remote: https://rubygems.org/
9
9
  specs:
10
- activemodel (6.0.3.2)
11
- activesupport (= 6.0.3.2)
12
- activerecord (6.0.3.2)
13
- activemodel (= 6.0.3.2)
14
- activesupport (= 6.0.3.2)
15
- activesupport (6.0.3.2)
10
+ activemodel (6.1.3)
11
+ activesupport (= 6.1.3)
12
+ activerecord (6.1.3)
13
+ activemodel (= 6.1.3)
14
+ activesupport (= 6.1.3)
15
+ activesupport (6.1.3)
16
16
  concurrent-ruby (~> 1.0, >= 1.0.2)
17
- i18n (>= 0.7, < 2)
18
- minitest (~> 5.1)
19
- tzinfo (~> 1.1)
20
- zeitwerk (~> 2.2, >= 2.2.2)
21
- addressable (2.7.0)
17
+ i18n (>= 1.6, < 2)
18
+ minitest (>= 5.1)
19
+ tzinfo (~> 2.0)
20
+ zeitwerk (~> 2.3)
21
+ addressable (2.8.0)
22
22
  public_suffix (>= 2.0.2, < 5.0)
23
23
  ast (2.4.1)
24
24
  benchmark-ips (2.8.2)
25
- concurrent-ruby (1.1.6)
25
+ concurrent-ruby (1.1.8)
26
26
  database_cleaner (1.8.5)
27
27
  database_cleaner-active_record (1.8.0)
28
28
  activerecord
@@ -36,6 +36,8 @@ GEM
36
36
  http (> 0.8, < 3.0)
37
37
  multi_json (~> 1)
38
38
  graphql (1.11.1)
39
+ groupdate (5.2.1)
40
+ activesupport (>= 5)
39
41
  gruff (0.10.0)
40
42
  histogram
41
43
  rmagick
@@ -50,18 +52,18 @@ GEM
50
52
  domain_name (~> 0.5)
51
53
  http-form_data (1.0.3)
52
54
  http_parser.rb (0.6.0)
53
- i18n (1.8.3)
55
+ i18n (1.8.9)
54
56
  concurrent-ruby (~> 1.0)
55
- minitest (5.14.1)
57
+ minitest (5.14.4)
56
58
  multi_json (1.15.0)
57
59
  parallel (1.19.2)
58
60
  parser (2.7.1.4)
59
61
  ast (~> 2.4.1)
60
- public_suffix (4.0.5)
62
+ public_suffix (4.0.6)
61
63
  rainbow (3.0.0)
62
64
  rake (13.0.1)
63
65
  regexp_parser (1.7.1)
64
- rexml (3.2.4)
66
+ rexml (3.2.5)
65
67
  rmagick (4.1.2)
66
68
  rspec (3.9.0)
67
69
  rspec-core (~> 3.9.0)
@@ -95,14 +97,13 @@ GEM
95
97
  simplecov-html (~> 0.11)
96
98
  simplecov-html (0.12.2)
97
99
  sqlite3 (1.4.2)
98
- thread_safe (0.3.6)
99
- tzinfo (1.2.7)
100
- thread_safe (~> 0.1)
100
+ tzinfo (2.0.4)
101
+ concurrent-ruby (~> 1.0)
101
102
  unf (0.1.4)
102
103
  unf_ext
103
104
  unf_ext (0.0.7.7)
104
105
  unicode-display_width (1.7.0)
105
- zeitwerk (2.4.0)
106
+ zeitwerk (2.4.2)
106
107
 
107
108
  PLATFORMS
108
109
  ruby
@@ -114,6 +115,7 @@ DEPENDENCIES
114
115
  database_cleaner-active_record (~> 1.8)
115
116
  gqli (~> 1.0)
116
117
  graphql-groups!
118
+ groupdate (~> 5.2.1)
117
119
  gruff (~> 0.10)
118
120
  rake (~> 13.0)
119
121
  rspec (~> 3.0)
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Maintainability](https://api.codeclimate.com/v1/badges/692d4125ac8548fb145e/maintainability)](https://codeclimate.com/github/hschne/graphql-groups/maintainability)
6
6
  [![Test Coverage](https://api.codeclimate.com/v1/badges/692d4125ac8548fb145e/test_coverage)](https://codeclimate.com/github/hschne/graphql-groups/test_coverage)
7
7
 
8
- Statistics and aggregates built on top of [graphql-ruby](https://github.com/rmosolgo/graphql-ruby).
8
+ Run group- and aggregation queries with [graphql-ruby](https://github.com/rmosolgo/graphql-ruby).
9
9
 
10
10
  ## Installation
11
11
 
@@ -20,13 +20,12 @@ $ bundle install
20
20
 
21
21
  ## Usage
22
22
 
23
- Create a new group type to specify which attributes you wish to group by inheriting from `GraphQL::Groups::GroupType`:
23
+ Suppose you want to get the number of authors, grouped by their age. Create a new group type by inheriting from `GraphQL::Groups::GroupType`:
24
24
 
25
25
  ```ruby
26
- class AuthorGroupType < GraphQL::Groups::GroupType
26
+ class AuthorGroupType < GraphQL::Groups::Schema::GroupType
27
27
  scope { Author.all }
28
28
 
29
- by :name
30
29
  by :age
31
30
  end
32
31
  ```
@@ -37,15 +36,14 @@ Include the new type in your schema using the `group` keyword, and you are done.
37
36
  class QueryType < GraphQL::Schema::Object
38
37
  include GraphQL::Groups
39
38
 
40
- group :author_groups, AuthorGroupType
39
+ group :author_group_by, AuthorGroupType
41
40
  end
42
41
  ```
43
42
 
44
- You can then run a query to retrieve statistical information about your data, for example the number of authors per age.
45
-
43
+ You can then run the following query to retrieve the number of authors per age.
46
44
  ```graphql
47
45
  query myQuery{
48
- authorGroups {
46
+ authorGroupBy {
49
47
  age {
50
48
  key
51
49
  count
@@ -55,7 +53,7 @@ query myQuery{
55
53
  ```
56
54
  ```json
57
55
  {
58
- "authorGroups":{
56
+ "authorGroupBy":{
59
57
  "age":[
60
58
  {
61
59
  "key":"31",
@@ -71,18 +69,21 @@ query myQuery{
71
69
  }
72
70
  ```
73
71
 
72
+
74
73
  ## Why?
75
74
 
76
- `graphql-ruby` lacks a built in way to query statistical data of collections. It is possible to add this functionality by
77
- using `group_by` (see for example [here](https://dev.to/gopeter/how-to-add-a-groupby-field-to-your-graphql-api-1f2j)),
78
- but this performs poorly for large amounts of data.
75
+ `graphql-ruby` lacks a built in way to retrieve statistical data, such as counts or averages. It is possible to implement custom queries that provide this functionality by using `group_by` (see for example [here](https://dev.to/gopeter/how-to-add-a-groupby-field-to-your-graphql-api-1f2j)), but this performs poorly for large amounts of data.
76
+
77
+ `graphql-groups` allows you to write flexible, readable queries while leveraging your database to aggreate data. It does so by performing an AST analysis on your request and executing exactly the database queries needed to fulfill it. This performs much better than grouping and aggregating in memory. See [performance](#Performance) for a benchmark.
79
78
 
80
- `graphql-groups` allows you to write flexible, readable queries while leveraging your database to group and
81
- aggregate data. See [performance](#Performance) for a benchmark.
82
79
 
83
80
  ## Advanced Usage
84
81
 
85
- #### Grouping by Multiple Attributes
82
+ For a showcase of what you can do with `graphql-groups` check out [graphql-groups-demo](https://github.com/hschne/graphql-groups-demo)
83
+
84
+ Find a hosted version of the demo app [on Heroku](https://graphql-groups-demo.herokuapp.com/).
85
+
86
+ ### Grouping by Multiple Attributes
86
87
 
87
88
  This library really shines when you want to group by multiple attributes, or otherwise retrieve complex statistical information
88
89
  within a single GraphQL query.
@@ -134,7 +135,7 @@ query myQuery{
134
135
 
135
136
  `graphql-groups` will automatically execute the required queries and return the results in a easily parsable response.
136
137
 
137
- #### Custom Grouping Queries
138
+ ### Custom Grouping Queries
138
139
 
139
140
  To customize which queries are executed to group items, you may specify the grouping query by creating a method of the same name in the group type.
140
141
 
@@ -150,7 +151,7 @@ class AuthorGroupType < GraphQL::Groups::Schema::GroupType
150
151
  end
151
152
  ```
152
153
 
153
- You may also pass arguments to custom grouping queries. In this case, pass any arguments to your group query as keyword arguments.
154
+ You may also pass arguments to custom grouping queries. In this case, pass any arguments to your group query as keyword arguments.
154
155
 
155
156
  ```ruby
156
157
  class BookGroupType < GraphQL::Groups::Schema::GroupType
@@ -173,6 +174,23 @@ class BookGroupType < GraphQL::Groups::Schema::GroupType
173
174
  end
174
175
  ```
175
176
 
177
+ You may access the query `context` in custom queries. As opposed to resolver methods accessing `object` is not possible and will raise an error.
178
+
179
+ ```ruby
180
+ class BookGroupType < GraphQL::Groups::Schema::GroupType
181
+ scope { Book.all }
182
+
183
+ by :list_price
184
+
185
+ def list_price(scope:)
186
+ currency = context[:currency] || ' $'
187
+ scope.group("list_price || ' #{currency}'")
188
+ end
189
+ end
190
+ ```
191
+
192
+ ### Custom Scopes
193
+
176
194
  When defining a group type's scope you may access the parents `object` and `context`.
177
195
 
178
196
  ```ruby
@@ -198,8 +216,6 @@ class BookGroupType < GraphQL::Groups::Schema::GroupType
198
216
  end
199
217
  ```
200
218
 
201
- For more examples see the [feature spec](./spec/graphql/feature_spec.rb) and [test schema](./spec/graphql/support/test_schema.rb)
202
-
203
219
  ### Custom Aggregates
204
220
 
205
221
  Per default `graphql-groups` supports aggregating `count` out of the box. If you need to other aggregates, such as sum or average
@@ -253,18 +269,17 @@ While it is possible to add grouping to your GraphQL schema by using `group_by`
253
269
 
254
270
  The benchmark queries the author count grouped by name, using an increasing number of authors. While the in-memory approach of grouping works well for a small number of records, it is outperformed quickly as that number increases.
255
271
 
256
- Benchmarks are generated using [benchmark-ips](https://github.com/evanphx/benchmark-ips). The benchmark script used to generate the report be found [here](./benchmark/benchmark.rb)
272
+ Benchmarks can be generated by running `rake benchmark`. The benchmark script used to generate the report be found [here](./benchmark/benchmark.rb)
257
273
 
258
274
  ## Limitations and Known Issues
259
275
 
260
- *This gem is in early development!* There are a number of issues that are still being addressed. There is no guarantee
261
- that this libraries API will not change fundamentally from one release to the next. Please refer to the [issue tracker](https://github.com/hschne/graphql-groups/issues) for a list of known issues.
276
+ Please refer to the [issue tracker](https://github.com/hschne/graphql-groups/issues) for a list of known issues.
262
277
 
263
278
  ## Credits
264
279
 
265
- ![Meister](meister.png)
280
+ <a href="https://www.meisterlabs.com"><img src="Meister.png" width="50%"></a>
266
281
 
267
- graphql-groups is supported by and battle-tested at [Meister](https://www.meisterlabs.com/)
282
+ [graphql-groups](https://github.com/hschne/graphql-groups) was created at [meister](https://www.meisterlabs.com/)
268
283
 
269
284
  ## Development
270
285
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  lib = File.expand_path('lib', __dir__)
2
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
5
  require 'graphql/groups/version'
@@ -8,10 +10,10 @@ Gem::Specification.new do |spec|
8
10
  spec.authors = ['Hans-Jörg Schnedlitz']
9
11
  spec.email = ['hans.schnedlitz@gmail.com']
10
12
 
11
- spec.summary = 'Create flexible and performant aggregation queries with graphql-ruby'
13
+ spec.summary = 'Create flexible and fast aggregation queries with graphql-ruby'
12
14
  spec.description = <<~HEREDOC
13
15
  GraphQL Groups makes it easy to add aggregation queries to your GraphQL schema. It combines a simple, flexible
14
- schema definition with high performance'
16
+ schema definition with high performance
15
17
  HEREDOC
16
18
  spec.homepage = 'https://github.com/hschne/graphql-groups'
17
19
  spec.license = 'MIT'
@@ -22,18 +24,21 @@ Gem::Specification.new do |spec|
22
24
 
23
25
  # Specify which files should be added to the gem when it is released.
24
26
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25
- spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
26
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
27
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
28
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|benchmark|.github)/}) }
27
29
  end
28
30
  spec.bindir = 'exe'
29
31
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
30
32
  spec.require_paths = ['lib']
31
33
 
34
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.6.0')
35
+
32
36
  spec.add_development_dependency 'activerecord', '~> 6.0'
33
37
  spec.add_development_dependency 'benchmark-ips', '~> 2.8'
34
38
  spec.add_development_dependency 'bundler', '~> 2.0'
35
39
  spec.add_development_dependency 'database_cleaner-active_record', '~> 1.8'
36
40
  spec.add_development_dependency 'gqli', '~> 1.0'
41
+ spec.add_development_dependency 'groupdate', '~> 5.2.1'
37
42
  spec.add_development_dependency 'gruff', '~> 0.10'
38
43
  spec.add_development_dependency 'rake', '~> 13.0'
39
44
  spec.add_development_dependency 'rspec', '~> 3.0'
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'singleton'
2
4
 
3
5
  module GraphQL
@@ -11,6 +13,10 @@ module GraphQL
11
13
  @types = {}
12
14
  end
13
15
 
16
+ def clear
17
+ @types = {}
18
+ end
19
+
14
20
  def register(type, derived)
15
21
  types[type] = derived
16
22
  end
@@ -11,7 +11,6 @@ module GraphQL
11
11
  def aggregate(name, *_, **options, &block)
12
12
  aggregate_type = aggregate_type(name)
13
13
 
14
- # TODO: Handle method name conflicts, or no query method found error
15
14
  resolve_method = "resolve_#{name}".to_sym
16
15
  query_method = options[:query_method] || name
17
16
  field = aggregate_field name, aggregate_type,
@@ -21,11 +20,8 @@ module GraphQL
21
20
  **options, &block
22
21
  aggregate_type.add_fields(field.own_attributes)
23
22
 
24
- # TODO: Avoid overwriting existing method
25
- define_method query_method do |**kwargs|
26
- scope = kwargs[:scope]
27
- attribute = kwargs[:attribute]
28
- scope.public_send(name, attribute)
23
+ define_method query_method do |scope:, **kwargs|
24
+ scope.public_send(name, **kwargs)
29
25
  end
30
26
 
31
27
  define_method resolve_method do
@@ -42,7 +38,6 @@ module GraphQL
42
38
  private
43
39
 
44
40
  def aggregate_type(name)
45
- # TODO: Handle no aggregate type found
46
41
  name = "#{name}AggregateType".upcase_first
47
42
  own_aggregate_types[name] ||= Class.new(Schema::AggregateType) do
48
43
  graphql_name name
@@ -17,28 +17,27 @@ module GraphQL
17
17
  module ClassMethods
18
18
  attr_reader :class_scope
19
19
 
20
- # TODO: Error if there are no groupings defined
21
20
  def by(name, **options, &block)
22
21
  query_method = options[:query_method] || name
23
22
  resolver_method = "resolve_#{query_method}".to_sym
24
23
  group_field name, [own_result_type],
25
- null: false,
26
- resolver_method: resolver_method,
27
24
  query_method: query_method,
25
+ null: true,
26
+ resolver_method: resolver_method,
27
+
28
28
  **options, &block
29
29
 
30
30
  define_method query_method do |**kwargs|
31
31
  kwargs[:scope].group(name)
32
32
  end
33
33
 
34
- # TODO: Warn / Disallow overwriting these resolver methods
35
34
  define_method resolver_method do |**_|
36
35
  group[name]
37
36
  end
38
37
  end
39
38
 
40
39
  def group_field(*args, **kwargs, &block)
41
- field_defn = Schema::GroupField.from_options(*args, owner: self, **kwargs, &block)
40
+ field_defn = field_class.from_options(*args, owner: self, **kwargs, &block)
42
41
  add_field(field_defn)
43
42
  field_defn
44
43
  end
@@ -65,7 +64,21 @@ module GraphQL
65
64
  field :group_by, own_group_type, null: false, camelize: true
66
65
 
67
66
  def group_by
68
- group_result[1][:nested]
67
+ group_result[1][:group_by]
68
+ end
69
+ end)
70
+ end
71
+
72
+ def own_field_type
73
+ type = "#{name}Field"
74
+ base_field_type = field_class
75
+ registry = GraphQL::Groups::GroupTypeRegistry.instance
76
+ registry.get(type) || registry.register(type, Class.new(base_field_type) do
77
+ attr_reader :query_method
78
+
79
+ def initialize(query_method:, **options, &definition_block)
80
+ @query_method = query_method
81
+ super(**options.except(:query_method), &definition_block)
69
82
  end
70
83
  end)
71
84
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Groups
5
+ class PendingQuery
6
+ attr_reader :key
7
+ attr_reader :aggregate
8
+ attr_reader :query
9
+
10
+ def initialize(key, aggregate, proc)
11
+ @key = Utils.wrap(key)
12
+ @aggregate = Utils.wrap(aggregate)
13
+ @query = proc
14
+ end
15
+
16
+ def execute
17
+ result = @query.call
18
+ QueryResult.new(@key, @aggregate, result)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Groups
5
+ class QueryBuilder
6
+ def self.parse(lookahead, object, context)
7
+ QueryBuilder.new(lookahead, object, context).group_selections
8
+ end
9
+
10
+ def initialize(lookahead, object, context)
11
+ @lookahead = lookahead
12
+ @context = context
13
+ type = @lookahead.field.type.of_type
14
+ @base_query = proc { type.authorized_new(object, context).scope }
15
+ super()
16
+ end
17
+
18
+ def group_selections(lookahead = @lookahead, current_context = QueryBuilderContext.new([], @base_query))
19
+ selections = lookahead.selections
20
+ group_field_type = lookahead.field.type.of_type.field_class
21
+ group_selections = selections.select { |selection| selection.field.is_a?(group_field_type) }
22
+ queries = group_selections.each_with_object([]) do |selection, object|
23
+ field_proc = proc_from_selection(selection.field, selection.arguments)
24
+ context = current_context.update(selection.name, field_proc)
25
+ object << create_pending_queries(selection, context)
26
+ end
27
+ nested_queries = group_selections
28
+ .filter { |selection| selection.selects?(:group_by) }
29
+ .each_with_object([]) do |selection, object|
30
+ field_proc = proc_from_selection(selection.field, selection.arguments)
31
+ context = current_context.update(selection.name, field_proc)
32
+ object << group_selections(selection.selection(:group_by), context)
33
+ end
34
+ (queries + nested_queries).flatten
35
+ end
36
+
37
+ private
38
+
39
+ def create_pending_queries(current_selection, context)
40
+ aggregate_selections = current_selection
41
+ .selections
42
+ .select { |selection| selection.field.is_a?(GraphQL::Groups::Schema::AggregateField) }
43
+ count_queries = count_queries(aggregate_selections, context)
44
+ aggregate_queries = aggregate_queries(aggregate_selections, context)
45
+ (count_queries + aggregate_queries)
46
+ end
47
+
48
+ def count_queries(aggregate_selections, context)
49
+ # TODO: When getting multiple aggregates for the same base data we could do just a single query instead of many
50
+ aggregate_selections
51
+ .select { |selection| selection.name == :count }
52
+ .map do |selection|
53
+ field = selection.field
54
+ count_proc = proc { |scope| field.owner.send(:new, {}, nil).public_send(field.query_method, scope: scope) }
55
+ combined = combine_procs(context.current_proc, count_proc)
56
+ PendingQuery.new(context.grouping, selection.name, combined)
57
+ end
58
+ end
59
+
60
+ def aggregate_queries(aggregate_selections, context)
61
+ aggregate_selections
62
+ .select { |selection| selection.field.own_attributes.present? }
63
+ .map { |selection| attribute_queries(context, selection) }
64
+ .flatten
65
+ end
66
+
67
+ def attribute_queries(context, selection)
68
+ selection.field
69
+ .own_attributes
70
+ .select { |attribute| selection.selections.map(&:name).include?(attribute) }
71
+ .map do |attribute|
72
+ aggregate_proc = proc_from_attribute(selection.field, attribute, selection.arguments)
73
+ combined = combine_procs(context.current_proc, aggregate_proc)
74
+ PendingQuery.new(context.grouping, [selection.name, attribute], combined)
75
+ end
76
+ end
77
+
78
+ def combine_procs(base_proc, new_proc)
79
+ proc { new_proc.call(base_proc.call) }
80
+ end
81
+
82
+ def proc_from_selection(field, arguments)
83
+ proc { |scope| field.owner.authorized_new(nil, @context).public_send(field.query_method, scope: scope, **arguments) }
84
+ end
85
+
86
+ def proc_from_attribute(field, attribute, arguments)
87
+ proc do |scope|
88
+ field.owner.authorized_new(nil, @context)
89
+ .public_send(field.query_method,
90
+ scope: scope,
91
+ attribute: attribute, **arguments)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class QueryBuilderContext
4
+ attr_reader :grouping
5
+ attr_reader :current_proc
6
+
7
+ def initialize(groupings = [], current_proc = nil)
8
+ @grouping = groupings
9
+ @current_proc = current_proc
10
+ end
11
+
12
+ def update(grouping, new_proc)
13
+ new_grouping = @grouping + [grouping]
14
+ combined_proc = combine_procs(@current_proc, new_proc)
15
+ QueryBuilderContext.new(new_grouping, combined_proc)
16
+ end
17
+
18
+ def combine_procs(base_proc, new_proc)
19
+ proc { new_proc.call(base_proc.call) }
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Groups
5
+ class QueryResult
6
+ attr_reader :key
7
+ attr_reader :aggregate
8
+ attr_reader :result_hash
9
+
10
+ def initialize(key, aggregate, result)
11
+ @key = Utils.wrap(key)
12
+ @aggregate = Utils.wrap(aggregate)
13
+ @result_hash = result
14
+ end
15
+ end
16
+ end
17
+ end
@@ -3,77 +3,47 @@
3
3
  module GraphQL
4
4
  module Groups
5
5
  class ResultTransformer
6
- def run(results)
7
- transform_results(results)
6
+ def run(query_results)
7
+ # Sort by key length so that deeper nested queries come later
8
+ query_results
9
+ .sort_by { |query_result| query_result.key.length }
10
+ .each_with_object({}) do |query_result, object|
11
+ transform_result(query_result, object)
12
+ end
8
13
  end
9
14
 
10
15
  private
11
16
 
12
- def transform_results(results)
13
- # Because group query returns its results in a way that is not usable by GraphQL we need to transform these results
14
- # and merge them into a single dataset.
15
- #
16
- # The result of a group query usually is a hash where they keys are the values of the columns that were grouped
17
- # and the values are the aggregates. What we want is a deep hash where each level contains the statistics for that
18
- # level in regards to the parent level.
19
- #
20
- # It all makes a lot more sense if you look at the GraphQL interface for statistics :)
21
- #
22
- # We accomplish this by transforming each result set to a hash and then merging them into a single one.
23
- results.each_with_object({}) { |(key, value), object| object.deep_merge!(transform_result(key, value)) }
24
- end
25
-
26
- def transform_result(key, result)
27
- transformed = result.each_with_object({}) do |(aggregate_key, aggregate_value), object|
28
- if aggregate_value.values.any? { |x| x.is_a?(Hash) }
29
- aggregate_value.each do |attribute, value|
30
- object.deep_merge!(transform_attribute(key, aggregate_key, attribute, value))
31
- end
17
+ def transform_result(query_result, object)
18
+ keys = query_result.key
19
+ return object[keys[0]] = [] if query_result.result_hash.empty?
20
+
21
+ query_result.result_hash.each do |grouping_result|
22
+ group_result_keys = Utils.wrap(grouping_result[0])
23
+ group_result_value = grouping_result[1]
24
+ Utils.duplicate(keys, group_result_keys)
25
+ inner_hash = create_nested_result(keys, group_result_keys, object)
26
+ if query_result.aggregate.length == 1
27
+ inner_hash[query_result.aggregate[0]] = group_result_value
32
28
  else
33
- object.deep_merge!(transform_aggregate(key, aggregate_key, aggregate_value))
29
+ aggregate_type = query_result.aggregate[0]
30
+ aggregate_attribute = query_result.aggregate[1]
31
+ inner_hash[aggregate_type] ||= {}
32
+ inner_hash[aggregate_type][aggregate_attribute] ||= group_result_value
34
33
  end
35
34
  end
36
-
37
- transformed.presence || { key => [] }
38
35
  end
39
36
 
40
- # TODO: Merge transform aggregate and transform attribute
41
- def transform_aggregate(key, aggregate, result)
42
- result.each_with_object({}) do |(keys, value), object|
43
- with_zipped = build_keys(key, keys)
44
- with_zipped.append(aggregate)
45
- hash = with_zipped.reverse.inject(value) { |a, n| { n => a } }
46
- object.deep_merge!(hash)
47
- end
48
- end
37
+ def create_nested_result(keys, group_result_keys, object)
38
+ head_key, *rest_keys = keys
39
+ head_group_key, *rest_group_keys = group_result_keys
40
+ object[head_key] ||= {}
41
+ object[head_key][head_group_key] ||= {}
42
+ inner_hash = object[head_key][head_group_key]
43
+ return inner_hash if rest_keys.empty?
49
44
 
50
- def transform_attribute(key, aggregate, attribute, result)
51
- result.each_with_object({}) do |(keys, value), object|
52
- with_zipped = build_keys(key, keys)
53
- with_zipped.append(aggregate)
54
- with_zipped.append(attribute)
55
- hash = with_zipped.reverse.inject(value) { |a, n| { n => a } }
56
- object.deep_merge!(hash)
57
- end
58
- end
59
-
60
- def build_keys(key, keys)
61
- key = wrap(key)
62
- keys = keys ? wrap(keys) : [nil]
63
- nested = [:nested] * (key.length - 1)
64
-
65
- with_zipped = key.zip(keys).zip(nested).flatten!
66
- with_zipped.first(with_zipped.size - 1)
67
- end
68
-
69
- def wrap(object)
70
- if object.nil?
71
- []
72
- elsif object.respond_to?(:to_ary)
73
- object.to_ary || [object]
74
- else
75
- [object]
76
- end
45
+ inner_hash[:group_by] ||= {}
46
+ create_nested_result(rest_keys, rest_group_keys, inner_hash[:group_by])
77
47
  end
78
48
  end
79
49
  end
@@ -7,7 +7,6 @@ module GraphQL
7
7
  attr_reader :query_method
8
8
 
9
9
  def initialize(query_method:, **options, &definition_block)
10
- # TODO: Make sure that users can access the context in custom query methods
11
10
  @query_method = query_method
12
11
  super(**options, &definition_block)
13
12
  end
@@ -13,15 +13,13 @@ module GraphQL
13
13
 
14
14
  field :key, String, null: true
15
15
 
16
- field :count, Integer, null: false
17
-
18
16
  aggregate_field :count, Integer, null: false, query_method: :count, resolver_method: :resolve_count
19
17
 
20
18
  def key
21
19
  group_result[0]
22
20
  end
23
21
 
24
- def count(scope:, **_)
22
+ def count(scope:, **)
25
23
  scope.size
26
24
  end
27
25
 
@@ -10,8 +10,7 @@ module GraphQL
10
10
 
11
11
  alias group object
12
12
 
13
- # TODO: Make group field inherit from default field, so that users default args/fields are respected
14
- field_class(GroupField)
13
+ field_class(own_field_type)
15
14
  end
16
15
  end
17
16
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Groups
5
+ module Utils
6
+ class << self
7
+ def wrap(object)
8
+ if object.nil?
9
+ []
10
+ elsif object.respond_to?(:to_ary)
11
+ object.to_ary || [object]
12
+ else
13
+ [object]
14
+ end
15
+ end
16
+
17
+ # This is used by the resul transformer when the user executed a query where some groupings are repeated, so depth
18
+ # of the query doesn't match the length of the query result keys. We need to modify the result keys so everything
19
+ # matches again.
20
+ def duplicate(keys, values)
21
+ return if keys.length == values.length
22
+
23
+ duplicates = duplicates(keys)
24
+ return if duplicates.empty?
25
+
26
+ duplicates.each do |_, indices|
27
+ first_occurrence, *rest = indices
28
+ value_to_duplicate = values[first_occurrence]
29
+ rest.each { |index| values.insert(index, value_to_duplicate) }
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def duplicates(array)
36
+ map = {}
37
+ duplicates = {}
38
+ array.each_with_index do |v, i|
39
+ map[v] = (map[v] || 0) + 1
40
+ duplicates[v] ||= []
41
+ duplicates[v] << i
42
+ end
43
+ duplicates.select { |_, v| v.length > 1 }
44
+ end
45
+
46
+ end
47
+ end
48
+ end
49
+ end
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Graphql
2
4
  module Groups
3
- VERSION = '0.1.2'.freeze
5
+ VERSION = '0.2.1'
4
6
  end
5
7
  end
@@ -4,6 +4,7 @@ require 'graphql/groups/version'
4
4
 
5
5
  require 'graphql'
6
6
 
7
+ require 'graphql/groups/utils'
7
8
  require 'graphql/groups/group_type_registry'
8
9
  require 'graphql/groups/schema/group_field'
9
10
  require 'graphql/groups/schema/aggregate_field'
@@ -15,9 +16,11 @@ require 'graphql/groups/has_groups'
15
16
  require 'graphql/groups/schema/group_result_type'
16
17
  require 'graphql/groups/schema/group_type'
17
18
 
18
- require 'graphql/groups/lookahead_parser'
19
+ require 'graphql/groups/query_result'
20
+ require 'graphql/groups/pending_query'
21
+ require 'graphql/groups/query_builder_context'
22
+ require 'graphql/groups/query_builder'
19
23
  require 'graphql/groups/result_transformer'
20
- require 'graphql/groups/executor'
21
24
 
22
25
 
23
26
  module GraphQL
@@ -31,10 +34,9 @@ module GraphQL
31
34
  field name, type, extras: [:lookahead], null: false, **options
32
35
 
33
36
  define_method name do |lookahead: nil|
34
- execution_plan = GraphQL::Groups::LookaheadParser.parse(lookahead)
35
- base_query = type.authorized_new(object, context).scope
36
- results = Executor.call(base_query, execution_plan)
37
- GraphQL::Groups::ResultTransformer.new.run(results)
37
+ pending_queries = QueryBuilder.parse(lookahead, object, context)
38
+ query_results = pending_queries.map(&:execute)
39
+ GraphQL::Groups::ResultTransformer.new.run(query_results)
38
40
  end
39
41
  end
40
42
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphql-groups
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hans-Jörg Schnedlitz
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-07-30 00:00:00.000000000 Z
11
+ date: 2021-11-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '1.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: groupdate
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: 5.2.1
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: 5.2.1
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: gruff
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -199,14 +213,13 @@ dependencies:
199
213
  - !ruby/object:Gem::Version
200
214
  version: '1.9'
201
215
  description: "GraphQL Groups makes it easy to add aggregation queries to your GraphQL
202
- schema. It combines a simple, flexible \nschema definition with high performance'\n"
216
+ schema. It combines a simple, flexible \nschema definition with high performance\n"
203
217
  email:
204
218
  - hans.schnedlitz@gmail.com
205
219
  executables: []
206
220
  extensions: []
207
221
  extra_rdoc_files: []
208
222
  files:
209
- - ".github/workflows/build.yml"
210
223
  - ".gitignore"
211
224
  - ".rspec"
212
225
  - ".rubocop.yml"
@@ -219,24 +232,24 @@ files:
219
232
  - Meister.png
220
233
  - README.md
221
234
  - Rakefile
222
- - benchmark/benchmark.jpg
223
- - benchmark/benchmark.rb
224
- - benchmark/benchmark_schema.rb
225
235
  - bin/console
226
236
  - bin/setup
227
237
  - graphql-groups.gemspec
228
238
  - lib/graphql/groups.rb
229
- - lib/graphql/groups/executor.rb
230
239
  - lib/graphql/groups/group_type_registry.rb
231
240
  - lib/graphql/groups/has_aggregates.rb
232
241
  - lib/graphql/groups/has_groups.rb
233
- - lib/graphql/groups/lookahead_parser.rb
242
+ - lib/graphql/groups/pending_query.rb
243
+ - lib/graphql/groups/query_builder.rb
244
+ - lib/graphql/groups/query_builder_context.rb
245
+ - lib/graphql/groups/query_result.rb
234
246
  - lib/graphql/groups/result_transformer.rb
235
247
  - lib/graphql/groups/schema/aggregate_field.rb
236
248
  - lib/graphql/groups/schema/aggregate_type.rb
237
249
  - lib/graphql/groups/schema/group_field.rb
238
250
  - lib/graphql/groups/schema/group_result_type.rb
239
251
  - lib/graphql/groups/schema/group_type.rb
252
+ - lib/graphql/groups/utils.rb
240
253
  - lib/graphql/groups/version.rb
241
254
  homepage: https://github.com/hschne/graphql-groups
242
255
  licenses:
@@ -253,15 +266,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
253
266
  requirements:
254
267
  - - ">="
255
268
  - !ruby/object:Gem::Version
256
- version: '0'
269
+ version: 2.6.0
257
270
  required_rubygems_version: !ruby/object:Gem::Requirement
258
271
  requirements:
259
272
  - - ">="
260
273
  - !ruby/object:Gem::Version
261
274
  version: '0'
262
275
  requirements: []
263
- rubygems_version: 3.0.3
276
+ rubygems_version: 3.0.3.1
264
277
  signing_key:
265
278
  specification_version: 4
266
- summary: Create flexible and performant aggregation queries with graphql-ruby
279
+ summary: Create flexible and fast aggregation queries with graphql-ruby
267
280
  test_files: []
@@ -1,28 +0,0 @@
1
- name: Build
2
-
3
- on:
4
- push:
5
- branches: [ master ]
6
- pull_request:
7
- branches: [ master ]
8
-
9
- jobs:
10
- test:
11
- runs-on: ubuntu-latest
12
- steps:
13
- - uses: actions/checkout@v2
14
- - name: Set up Ruby
15
- uses: ruby/setup-ruby@v1
16
- with:
17
- ruby-version: 2.6.5
18
- - name: Install dependencies
19
- run: bundle install
20
- - name: Run tests
21
- run: bundle exec rspec
22
- - name: Report coverage
23
- uses: paambaati/codeclimate-action@v2.6.0
24
- env:
25
- CC_TEST_REPORTER_ID: ${{secrets.CC_TEST_REPORTER_ID}}
26
- with:
27
- # Coverage is already generated in test step, need to run anything again
28
- coverageCommand: echo '' > /dev/null
Binary file
@@ -1,101 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'graphql/groups'
4
-
5
- require 'database_cleaner/active_record'
6
- require 'gqli/dsl'
7
- require 'benchmark/ips'
8
- require 'gruff'
9
-
10
- require_relative '../spec/graphql/support/test_schema/db'
11
- require_relative '../spec/graphql/support/test_schema/models'
12
- require_relative 'benchmark_schema'
13
-
14
- class Compare
15
- def run
16
- puts 'Generating reports...'
17
- datasets = perform_runs
18
- labels = { 0 => '10', 1 => '100', 2 => '1000', 3 => '10000' }
19
- puts 'Generating graph...'
20
- graph = graph(labels, datasets)
21
- graph.write('benchmark/benchmark.jpg')
22
- puts 'Done!'
23
- end
24
-
25
- private
26
-
27
- def perform_runs
28
- runs = [10, 100, 1000, 10_000]
29
- runs.map { |count| single_run(count) }
30
- .map(&:data)
31
- .map { |data| { groups: data.first[:ips], group_by: data.second[:ips] } }
32
- .each_with_object({ groups: [], group_by: [] }) do |item, object|
33
- object[:groups].push(item[:groups])
34
- object[:group_by].push(item[:group_by])
35
- end
36
- end
37
-
38
- def graph(labels, datasets)
39
- g = Gruff::Bar.new(800)
40
- g.title = 'graphql-groups vs group_by'
41
- g.theme = Gruff::Themes::THIRTYSEVEN_SIGNALS
42
- g.labels = labels
43
- g.x_axis_label = 'records'
44
- g.y_axis_label = 'iterations/second'
45
- datasets.each do |data|
46
- g.data(data[0], data[1])
47
- end
48
- g
49
- end
50
-
51
- def single_run(count)
52
- DatabaseCleaner.strategy = :transaction
53
- DatabaseCleaner.start
54
- seed(count)
55
- report = run_benchmark
56
- DatabaseCleaner.clean
57
- report
58
- end
59
-
60
- def seed(count)
61
- names = %w[Ada Alice Bob Bruce]
62
- count.times { Author.create(name: names.sample) }
63
- end
64
-
65
- def groups_query
66
- GQLi::DSL.query {
67
- fastGroups {
68
- name {
69
- key
70
- count
71
- }
72
- }
73
- }.to_gql
74
- end
75
-
76
- def naive_query
77
- GQLi::DSL.query {
78
- slowGroups {
79
- name {
80
- key
81
- count
82
- }
83
- }
84
- }.to_gql
85
- end
86
-
87
- def run_benchmark
88
- Benchmark.ips(quiet: true) do |x|
89
- # Configure the number of seconds used during
90
- # the warmup phase (default 2) and calculation phase (default 5)
91
- x.config(time: 2, warmup: 1)
92
-
93
- x.report('groups') { PerformanceSchema.execute(groups_query) }
94
-
95
- x.report('group-by') { PerformanceSchema.execute(naive_query) }
96
-
97
- # Compare the iterations per second of the various reports!
98
- x.compare!
99
- end
100
- end
101
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'graphql'
4
- require 'graphql/groups'
5
-
6
- class BaseType < GraphQL::Schema::Object; end
7
-
8
- class AuthorGroupType < GraphQL::Groups::Schema::GroupType
9
- scope { Author.all }
10
-
11
- by :name
12
- end
13
-
14
- class SlowAuthorGroupResultType < GraphQL::Schema::Object
15
- field :key, String, null: false
16
- field :count, Integer, null: false
17
-
18
- def key
19
- object[0]
20
- end
21
-
22
- def count
23
- object[1].size
24
- end
25
- end
26
-
27
- class SlowAuthorGroupType < GraphQL::Schema::Object
28
- field :name, [SlowAuthorGroupResultType], null: false
29
-
30
- def name
31
- object.group_by(&:name)
32
- end
33
- end
34
-
35
- class QueryType < BaseType
36
- include GraphQL::Groups
37
-
38
- group :fast_groups, AuthorGroupType, camelize: true
39
-
40
- field :slow_groups, SlowAuthorGroupType, null: false, camelize: true
41
-
42
- def slow_groups
43
- Author.all
44
- end
45
- end
46
-
47
- class PerformanceSchema < GraphQL::Schema
48
- query QueryType
49
-
50
- def self.resolve_type(_type, obj, _ctx)
51
- "#{obj.class.name}Type"
52
- end
53
- end
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GraphQL
4
- module Groups
5
- class Executor
6
- class << self
7
- def call(base_query, execution_plan)
8
- execution_plan.each_with_object({}) do |(key, value), object|
9
- object.merge!(execute(base_query, key, value))
10
- end
11
- end
12
-
13
- def execute(scope, key, value)
14
- group_query = value[:proc].call(scope: scope)
15
- results = value[:aggregates].each_with_object({}) do |(aggregate_key, aggregate), object|
16
- if aggregate_key == :count
17
- object[:count] = aggregate[:proc].call(scope: group_query)
18
- else
19
- object[aggregate_key] ||= {}
20
- aggregate[:attributes].each do |attribute|
21
- result = aggregate[:proc].call(scope: group_query, attribute: attribute)
22
- object[aggregate_key][attribute] = result
23
- end
24
- end
25
- end
26
-
27
- results = { key => results }
28
- return results unless value[:nested]
29
-
30
- value[:nested].each do |inner_key, inner_value|
31
- new_key = (Array.wrap(key) << inner_key)
32
- inner_result = execute(group_query, inner_key, inner_value)
33
- results[new_key] = inner_result[inner_key]
34
- end
35
- results
36
- end
37
- end
38
- end
39
- end
40
- end
@@ -1,51 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module GraphQL
4
- module Groups
5
- class LookaheadParser
6
- def self.parse(base_selection)
7
- LookaheadParser.new.group_selections(base_selection, {})
8
- end
9
-
10
- def group_selections(root, hash)
11
- selections = root.selections
12
- group_selections = selections.select { |selection| selection.field.is_a?(GraphQL::Groups::Schema::GroupField) }
13
- group_selections.each do |selection|
14
- own_query = get_field_proc(selection.field, selection.arguments)
15
- hash[selection.name] ||= { proc: own_query }
16
- hash[selection.name][:aggregates] = aggregates(selection)
17
- end
18
- group_selections
19
- .filter { |selection| selection.selects?(:group_by) }
20
- .each { |selection| hash[selection.name][:nested] = group_selections(selection.selection(:group_by), {}) }
21
- hash
22
- end
23
-
24
- def get_field_proc(field, arguments)
25
- # TODO: Use authorized instead of using send to circument protection
26
- proc { |**kwargs| field.owner.send(:new, {}, nil).public_send(field.query_method, **arguments, **kwargs) }
27
- end
28
-
29
- def aggregates(group_selection)
30
- aggregate_selections = group_selection.selections.select do |selection|
31
- selection.field.is_a?(GraphQL::Groups::Schema::AggregateField)
32
- end
33
- aggregate_selections.each_with_object({}) do |selection, object|
34
- name = selection.name
35
- field = selection.field
36
- if name == :count
37
- proc = proc { |**kwargs| field.owner.send(:new, {}, nil).public_send(field.query_method, **kwargs) }
38
- object[name] = { proc: proc }
39
- elsif selection.field.own_attributes.present?
40
- object[name] = { proc: get_aggregate_proc(field, selection.arguments), attributes: field.own_attributes }
41
- end
42
- end
43
- end
44
-
45
- def get_aggregate_proc(field, arguments)
46
- # TODO: Use authorized instead of using send to circument protection
47
- proc { |**kwargs| field.owner.send(:new, {}, nil).send(field.query_method, **kwargs, **arguments) }
48
- end
49
- end
50
- end
51
- end