graphql-groups 0.1.1
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 +7 -0
- data/.github/workflows/build.yml +28 -0
- data/.gitignore +166 -0
- data/.rspec +3 -0
- data/.rubocop.yml +21 -0
- data/.travis.yml +7 -0
- data/CHANGELOG.md +0 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +120 -0
- data/LICENSE.txt +21 -0
- data/README.md +182 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/graphql-groups.gemspec +44 -0
- data/lib/graphql/groups.rb +46 -0
- data/lib/graphql/groups/executor.rb +39 -0
- data/lib/graphql/groups/extensions/wrap.rb +11 -0
- data/lib/graphql/groups/has_aggregates.rb +58 -0
- data/lib/graphql/groups/has_groups.rb +69 -0
- data/lib/graphql/groups/lookahead_parser.rb +51 -0
- data/lib/graphql/groups/result_transformer.rb +66 -0
- data/lib/graphql/groups/schema/aggregate_field.rb +21 -0
- data/lib/graphql/groups/schema/aggregate_type.rb +26 -0
- data/lib/graphql/groups/schema/group_field.rb +16 -0
- data/lib/graphql/groups/schema/group_result_type.rb +34 -0
- data/lib/graphql/groups/schema/group_type.rb +21 -0
- data/lib/graphql/groups/version.rb +5 -0
- metadata +235 -0
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2020 Hans-Jörg Schnedlitz
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,182 @@
|
|
1
|
+
# GraphQL Groups
|
2
|
+
|
3
|
+
[](https://github.com/hschne/graphql-groups/workflows/Build/badge.svg)
|
4
|
+
[](https://codeclimate.com/github/hschne/graphql-groups/maintainability)
|
5
|
+
[](https://codeclimate.com/github/hschne/graphql-groups/test_coverage)
|
6
|
+
|
7
|
+
Create flexible and performant aggregation queries with [graphql-ruby](https://github.com/rmosolgo/graphql-ruby)
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
Add this line to your application's Gemfile and execute bundler.
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
gem 'graphql-groups'
|
15
|
+
```
|
16
|
+
```bash
|
17
|
+
$ bundle install
|
18
|
+
```
|
19
|
+
|
20
|
+
## Usage
|
21
|
+
|
22
|
+
Create a new group type to specify which attributes you wish to group by inheriting from `GraphQL::Groups::GroupType`
|
23
|
+
|
24
|
+
```ruby
|
25
|
+
class AuthorGroupType < GraphQL::Groups::GroupType
|
26
|
+
scope { Author.all }
|
27
|
+
|
28
|
+
by :age
|
29
|
+
end
|
30
|
+
```
|
31
|
+
|
32
|
+
Include the new type in your schema using the `group` keyword.
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
class QueryType < BaseType
|
36
|
+
include GraphQL::Groups
|
37
|
+
|
38
|
+
group :author_groups, AuthorGroupType
|
39
|
+
end
|
40
|
+
```
|
41
|
+
|
42
|
+
You can then run an aggregation query for this grouping.
|
43
|
+
|
44
|
+
```graphql
|
45
|
+
query myQuery{
|
46
|
+
authorGroups {
|
47
|
+
age {
|
48
|
+
key
|
49
|
+
count
|
50
|
+
}
|
51
|
+
}
|
52
|
+
}
|
53
|
+
```
|
54
|
+
```json
|
55
|
+
{
|
56
|
+
"authorGroups":{
|
57
|
+
"age":[
|
58
|
+
{
|
59
|
+
"key":"31",
|
60
|
+
"count":1
|
61
|
+
},
|
62
|
+
{
|
63
|
+
"key":"35",
|
64
|
+
"count":3
|
65
|
+
},
|
66
|
+
...
|
67
|
+
]
|
68
|
+
}
|
69
|
+
}
|
70
|
+
|
71
|
+
```
|
72
|
+
|
73
|
+
## Advanced Usage
|
74
|
+
|
75
|
+
#### Custom Grouping Queries
|
76
|
+
|
77
|
+
To customize how items are grouped, you may specify the grouping query by creating a method of the same name in the group type.
|
78
|
+
|
79
|
+
```ruby
|
80
|
+
class AuthorGroupType < GraphQL::Groups::Schema::GroupType
|
81
|
+
scope { Author.all }
|
82
|
+
|
83
|
+
by :age
|
84
|
+
|
85
|
+
def age(scope:)
|
86
|
+
scope.group("(cast(age/10 as int) * 10) || '-' || ((cast(age/10 as int) + 1) * 10)")
|
87
|
+
end
|
88
|
+
end
|
89
|
+
```
|
90
|
+
|
91
|
+
You may also pass arguments to custom grouping queries. In this case, pass any arguments to your group query as keyword arguments.
|
92
|
+
|
93
|
+
```ruby
|
94
|
+
class BookGroupType < GraphQL::Groups::Schema::GroupType
|
95
|
+
scope { Book.all }
|
96
|
+
|
97
|
+
by :published_at do
|
98
|
+
argument :interval, String, required: false
|
99
|
+
end
|
100
|
+
|
101
|
+
def published_at(scope:, interval: nil)
|
102
|
+
case interval
|
103
|
+
when 'month'
|
104
|
+
scope.group("strftime('%Y-%m-01 00:00:00 UTC', published_at)")
|
105
|
+
when 'year'
|
106
|
+
scope.group("strftime('%Y-01-01 00:00:00 UTC', published_at)")
|
107
|
+
else
|
108
|
+
scope.group("strftime('%Y-%m-%d 00:00:00 UTC', published_at)")
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
```
|
113
|
+
|
114
|
+
For more examples see the [feature spec](./spec/graphql/feature_spec.rb) and [test schema](./spec/graphql/support/test_schema)
|
115
|
+
|
116
|
+
### Custom Aggregates
|
117
|
+
|
118
|
+
Per default `graphql-groups` supports aggregating `count` out of the box. If you need to other aggregates, such as sum or average
|
119
|
+
you may add them to your schema by creating a custom `GroupResultType`. Wire this up to your schema by specifying the result type in your
|
120
|
+
group type.
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
class AuthorGroupResultType < GraphQL::Groups::Schema::GroupResultType
|
124
|
+
aggregate :average do
|
125
|
+
attribute :age
|
126
|
+
end
|
127
|
+
end
|
128
|
+
```
|
129
|
+
|
130
|
+
```ruby
|
131
|
+
class AuthorGroupType < GraphQL::Groups::Schema::GroupType
|
132
|
+
scope { Author.all }
|
133
|
+
|
134
|
+
result_type { AuthorGroupResultType }
|
135
|
+
|
136
|
+
by :name
|
137
|
+
end
|
138
|
+
```
|
139
|
+
|
140
|
+
Per default, the aggregate name and attribute will be used to construct the underlying aggregation query. The example above creates
|
141
|
+
```ruby
|
142
|
+
scope.average(:age)
|
143
|
+
```
|
144
|
+
|
145
|
+
If you need more control over how to aggregate you may define a custom query by creating a method matching the aggregate name. The method *must* take the keyword arguments `scope` and `attribute`.
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
class AuthorGroupResultType < GraphQL::Groups::Schema::GroupResultType
|
149
|
+
aggregate :average do
|
150
|
+
attribute :age
|
151
|
+
end
|
152
|
+
|
153
|
+
def average(scope:, attribute:)
|
154
|
+
scope.average(attribute)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
```
|
158
|
+
|
159
|
+
For more examples see the [feature spec](./spec/graphql/feature_spec.rb) and [test schema](./spec/graphql/support/test_schema)
|
160
|
+
|
161
|
+
## Limitations and Known Issues
|
162
|
+
|
163
|
+
*This gem is in early development!*. There are a number of issues that are still being addressed. There is no guarantee
|
164
|
+
that this libraries API will not change fundamentally from one release to the next.
|
165
|
+
|
166
|
+
## Development
|
167
|
+
|
168
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
169
|
+
|
170
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
171
|
+
|
172
|
+
## Contributing
|
173
|
+
|
174
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/graphql-groups. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
175
|
+
|
176
|
+
## License
|
177
|
+
|
178
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
179
|
+
|
180
|
+
## Code of Conduct
|
181
|
+
|
182
|
+
Everyone interacting in the Graphql::Groups project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/graphql-groups/blob/master/CODE_OF_CONDUCT.md).
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "graphql/groups"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
lib = File.expand_path('lib', __dir__)
|
2
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
3
|
+
require 'graphql/groups/version'
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = 'graphql-groups'
|
7
|
+
spec.version = Graphql::Groups::VERSION
|
8
|
+
spec.authors = ['Hans-Jörg Schnedlitz']
|
9
|
+
spec.email = ['hans.schnedlitz@gmail.com']
|
10
|
+
|
11
|
+
spec.summary = 'Create flexible and performant aggregation queries with graphql-ruby'
|
12
|
+
spec.description = <<~HEREDOC
|
13
|
+
GraphQL Groups makes it easy to add aggregation queries to your GraphQL schema. It combines a simple, flexible
|
14
|
+
schema definition with high performance'
|
15
|
+
HEREDOC
|
16
|
+
spec.homepage = 'https://github.com/hschne/graphql-groups'
|
17
|
+
spec.license = 'MIT'
|
18
|
+
|
19
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
20
|
+
spec.metadata['source_code_uri'] = 'https://github.com/hschne/graphql-groups'
|
21
|
+
spec.metadata['changelog_uri'] = 'https://github.com/hschne/graphql-groups/blob/master/CHANGELOG.md'
|
22
|
+
|
23
|
+
# Specify which files should be added to the gem when it is released.
|
24
|
+
# 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
|
+
end
|
28
|
+
spec.bindir = 'exe'
|
29
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
30
|
+
spec.require_paths = ['lib']
|
31
|
+
|
32
|
+
spec.add_development_dependency 'activerecord', '~> 5.0'
|
33
|
+
spec.add_development_dependency 'bundler', '~> 2.0'
|
34
|
+
spec.add_development_dependency 'database_cleaner-active_record'
|
35
|
+
spec.add_development_dependency 'gqli'
|
36
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
37
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
38
|
+
spec.add_development_dependency 'rubocop', '~> 0.88'
|
39
|
+
spec.add_development_dependency 'rubocop-rspec', '~> 1.42'
|
40
|
+
spec.add_development_dependency 'simplecov'
|
41
|
+
spec.add_development_dependency 'sqlite3', '~> 1.4.2'
|
42
|
+
|
43
|
+
spec.add_dependency 'graphql', '~> 1', '> 1.9'
|
44
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'graphql/groups/version'
|
4
|
+
|
5
|
+
require 'graphql'
|
6
|
+
require 'graphql/groups/extensions/wrap'
|
7
|
+
|
8
|
+
require 'graphql/groups/schema/group_field'
|
9
|
+
require 'graphql/groups/schema/aggregate_field'
|
10
|
+
require 'graphql/groups/schema/aggregate_type'
|
11
|
+
|
12
|
+
require 'graphql/groups/has_aggregates'
|
13
|
+
require 'graphql/groups/has_groups'
|
14
|
+
|
15
|
+
require 'graphql/groups/schema/group_result_type'
|
16
|
+
require 'graphql/groups/schema/group_type'
|
17
|
+
|
18
|
+
require 'graphql/groups/lookahead_parser'
|
19
|
+
require 'graphql/groups/result_transformer'
|
20
|
+
require 'graphql/groups/executor'
|
21
|
+
|
22
|
+
|
23
|
+
module GraphQL
|
24
|
+
module Groups
|
25
|
+
def self.included(base)
|
26
|
+
base.extend ClassMethods
|
27
|
+
end
|
28
|
+
|
29
|
+
module ClassMethods
|
30
|
+
def group(name, type, **options)
|
31
|
+
# TODO: Suppress/warn if options are used that cannot be used
|
32
|
+
field name, type, extras: [:lookahead], null: false, **options
|
33
|
+
|
34
|
+
define_method name do |lookahead: nil|
|
35
|
+
execution_plan = GraphQL::Groups::LookaheadParser.parse(lookahead)
|
36
|
+
base_query = nil
|
37
|
+
type.instance_eval do
|
38
|
+
base_query = instance_eval(&@own_scope)
|
39
|
+
end
|
40
|
+
results = Executor.call(base_query, execution_plan)
|
41
|
+
GraphQL::Groups::ResultTransformer.new.run(results)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,39 @@
|
|
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
|
+
return { key => results } unless value[:nested]
|
28
|
+
|
29
|
+
value[:nested].each do |inner_key, inner_value|
|
30
|
+
new_key = (Array.wrap(key) << inner_key)
|
31
|
+
inner_result = execute(group_query, inner_key, inner_value)
|
32
|
+
results[new_key] = inner_result[inner_key]
|
33
|
+
end
|
34
|
+
results
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GraphQL
|
4
|
+
module Groups
|
5
|
+
module HasAggregates
|
6
|
+
def self.included(base)
|
7
|
+
base.extend(ClassMethods)
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
def aggregate(name, *_, **options, &block)
|
12
|
+
aggregate_type = aggregate_type(name)
|
13
|
+
|
14
|
+
# TODO: Handle method name conflicts, or no query method found error
|
15
|
+
resolve_method = "resolve_#{name}".to_sym
|
16
|
+
query_method = options[:query_method] || name
|
17
|
+
field = aggregate_field name, aggregate_type,
|
18
|
+
null: false,
|
19
|
+
query_method: query_method,
|
20
|
+
resolver_method: resolve_method,
|
21
|
+
**options, &block
|
22
|
+
aggregate_type.add_fields(field.own_attributes)
|
23
|
+
|
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)
|
29
|
+
end
|
30
|
+
|
31
|
+
define_method resolve_method do
|
32
|
+
group_result[1][name]
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def aggregate_field(*args, **kwargs, &block)
|
37
|
+
field_defn = Schema::AggregateField.from_options(*args, owner: self, **kwargs, &block)
|
38
|
+
add_field(field_defn)
|
39
|
+
field_defn
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def aggregate_type(name)
|
45
|
+
# TODO: Handle no aggregate type found
|
46
|
+
name = "#{name}AggregateType".upcase_first
|
47
|
+
own_aggregate_types[name] ||= Class.new(Schema::AggregateType) do
|
48
|
+
graphql_name name
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def own_aggregate_types
|
53
|
+
@own_aggregate_types ||= {}
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module GraphQL
|
3
|
+
module Groups
|
4
|
+
module HasGroups
|
5
|
+
def self.included(base)
|
6
|
+
base.extend(ClassMethods)
|
7
|
+
end
|
8
|
+
|
9
|
+
module ClassMethods
|
10
|
+
# TODO: Error if there are no groupings defined
|
11
|
+
def by(name, **options, &block)
|
12
|
+
query_method = options[:query_method] || name
|
13
|
+
resolver_method = "resolve_#{query_method}".to_sym
|
14
|
+
group_field name, [own_result_type],
|
15
|
+
null: false,
|
16
|
+
resolver_method: resolver_method,
|
17
|
+
query_method: query_method,
|
18
|
+
**options, &block
|
19
|
+
|
20
|
+
define_method query_method do |**kwargs|
|
21
|
+
kwargs[:scope].group(name)
|
22
|
+
end
|
23
|
+
|
24
|
+
define_method resolver_method do |**_|
|
25
|
+
group[name]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def group_field(*args, **kwargs, &block)
|
30
|
+
field_defn = Schema::GroupField.from_options(*args, owner: self, **kwargs, &block)
|
31
|
+
add_field(field_defn)
|
32
|
+
field_defn
|
33
|
+
end
|
34
|
+
|
35
|
+
def result_type(&block)
|
36
|
+
@own_result_type = instance_eval(&block)
|
37
|
+
end
|
38
|
+
|
39
|
+
def scope(&block)
|
40
|
+
@own_scope = block
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def own_result_type
|
46
|
+
name = "#{self.name.gsub(/Type$/, '')}ResultType"
|
47
|
+
|
48
|
+
type = name.safe_constantize || GraphQL::Groups::Schema::GroupResultType
|
49
|
+
own_group_type = self
|
50
|
+
|
51
|
+
@classes ||= {}
|
52
|
+
@classes[type] ||= Class.new(type) do
|
53
|
+
graphql_name name
|
54
|
+
|
55
|
+
field :group_by, own_group_type, null: false, camelize: true
|
56
|
+
|
57
|
+
def group_by
|
58
|
+
group_result[1][:nested]
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def own_scope
|
64
|
+
@own_scope ||= nil
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|