Introduction
This article is about a real need I had in my work.
Create a graphql queries in rails, Everytime I have to make a do this I have to create a bunch of file, sometimes even directory because they did not exist yet.
I wanted to automate the generation of this file and directory as much as possible. Rails offers a really cool CLI for this, You can create you own generator to generate any file you want this files can follow a template that you will define.
Generator
That said you could wonder, how I write and create my own generator ?
Easy, there is a generator for it 😎.
Let's create our GraphQL Query Generator then :
rails g generator query
This will generate a directory with the following structure in the lib directory of your rails app :
generators/
└── queries
├── USAGE
├── queries_generator.rb
└── templates
3 directories, 2 files
The file which is interesting for us now is : query_generator.rb
. This is the parser for our cli of our generator, we will give it all the arguments, filename and options.
So now we can complete our generator for me it ends up like the following :
require_relative 'templates/queries_template.rb'
class QueriesGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
argument :arguments, type: :array, default: [],
banner: "field:type field:type"
class_option :nested_modules ,type: :string
def create_query_file
path = ["app/graphql/queries","spec/graphql/queries"]
gpath = path.map { |p| generate_dir_and_file(p) }
template = QueriesTemplate.new(class_name, arguments_list,
nested_modules,type)
create_file gpath.first, template.render
end
private
def arguments_list
arguments
.map { |arg| arg.split(':') }
.reject { |arg| arg.first == 'type' }
end
def nested_modules
return [] unless options[:nested_modules].present?
options[:nested_modules].split(':')
end
def type
arguments.find { |arg| arg.include?('type') }
end
def generate_dir_and_file(path)
p = "#{path}/#{nested_modules.join('/')}"
FileUtils.mkdir_p(p) unless File.exist?(p)
File.join(p, "#{file_name}.rb")
end
end
First our generators is a class, it inherits from Rails::Generators::Namedbase. I will not dig too deeply into that.
class QueriesGenerator < Rails::Generators::NamedBase
So it inherits from Rails::Generators::Namedbase.
A generators have all his public methods invoked one after other. So be really careful when you define method to make them private if you don't want them to be executed by the Rails generators CLI.
After that we have the following line which defines an optional arguments for our cli.
class_option :nested_modules, type: :string
Thanks to that you will be able to write things like that which really common in cli :
rails g query coffee --nested-module module1
This argument will use for the nesting of our module in our file and the nesting of our directory.
Then we have the create_query_file
method let's break it up too:
def create_query_file
path = ["app/graphql/queries","spec/graphql/queries"]
gpath = path.map { |p| generate_dir_and_file(p) }
template = QueryTemplate.new(class_name, arguments,
nested_modules,type)
create_file gpath[0], template.render
end
This method will be executed by the rails generator cli. Basically it instantiate the QueryTemplate class that we will talk later. Finally create the good path and the files where it belongs to.
To do that, writing your file in the correct directory, which match the nesting of the module you wrote in your optional argument.
I have defined a bunch of private function, remember if you do not define it as private it will be executed.
The most important here is the one that will create the path of the directories, then generate them.
Finally it will create the path for the file with the correct name.
private
def generate_dir_and_file(path)
p = path + "/" + @nested_modules.join("/")
FileUtils.mkdir_p(p) unless File.exist?(p)
File.join(p, "#{file_name}.rb")
end
I used FileUtils.mkdir_p
method, indeed from my understanding the classic File.mkdir
will not generate the nested directory.
Template
For the template after a lot iterations, I have made a really and modular one. To do that I have created a Ruby Class.
class QueriesTemplate
attr_accessor :class_name, :arguments, :nested_modules,
:type_array, :type
def initialize(
class_name,
arguments,
nested_modules,
type_array)
@class_name = class_name
@arguments = arguments
@nested_modules = nested_modules
@type_array = type_array
end
def render
<<-EOS
module Queries
#{render_modules}
#{render_class}
#{render_arguments}
#{render_type}
#{render_resolve}
#{render_end}
end
EOS
end
private
def type
type_array.second if type_array.present?
end
def type_nullable
type_array.last == 'null' if type_array.present?
end
def render_class
"#{correct_indent(level + 1)}\
class #{class_name.camelize} < Queries::BaseQuery"
end
def render_modules
nested_modules
.each_with_index
.map do |mod, i|
"#{correct_indent(i + 1)}module #{mod.camelize}\n"
end
.join
end
def render_end
(0..nested_modules.size)
.reverse_each
.map do |i|
"#{correct_indent(i + 1)}end\n"
end
.join
end
def render_arguments
return '' if arguments.blank?
arguments
.map do |arg|
"#{correct_indent(level + 2)}argument :#{arg.first},\
#{arg.second.delete("!")}#{render_argument_required(arg)}"
end
.join("\n")
end
def render_argument_required(argument)
', required: true' if argument.last.include?('!')
end
def type_modules(type)
type_nested_modules = ['Types']
.concat(nested_modules)
.push(type.camelize)
type_nested_modules
.each_with_index
.map { |mod| mod.camelize }
.join('::')
end
def render_type
if type.present?
"#{correct_indent(level + 2)}type \
#{type_modules(type)}Type#{render_nullable_type}"
else
"#{correct_indent(level + 2)}type \
#{type_modules(class_name)}Type"
end
end
def render_nullable_type
', null: true' if type_nullable
end
def render_resolve
"#{correct_indent(level + 2)}def resolve\n\
#{correct_indent(level + 2)}end\n"
end
def level
nested_modules.size
end
def correct_indent(indent)
"\t" * indent
end
end
So each methods of this class is a method that render a part of our template.
It is really cool because it enables us to do a really modular template, that is built dynamically from our cli option and arguments.
I have defined several helpers methods. The most important one is correct_indent
which enables to have the correct indentation of all the lines in our template.
The render_end method is kind of interesting it will indent the end keyword in the reverse order.
def render_end
(0..nested_modules.size)
.reverse_each
.map do |i|
"#{correct_indent(i + 1)}end\n"
end
.join
end
Difficulties
First I have created an awful erb file. It worked though but it was not really modular, I did not like. You can find it in this gist.
Then thanks to people on the Discord Ruby I came to solution that you saw in this article which is much cleaner.
At first I had really weird things, it used the class as a template, and it was not the way I intended to.
To render a file with a template you have at least 2 functions in generators :
-
template
function -
create_file
function
I had some issues with the template
one but maybe I used it wrong. So I used the create_file one and then it worked well.
Results
If you want to create a query for you Coffee model, with a name which is a String and is required and a localisation not required. In a module named module1 you can write this :
rails g query Coffee name:String!
localisation:String --nested-modules module1
It will generate this file :
module Queries
module Module1
class Coffee < Queries::BaseQuery
argument :name, String!, required: true
argument :localisation, String
type Types::Module1::CoffeeType
def resolve
end
end
end
end
And that's it ! This is the final result of many iterations and I've a learnt a lot through this project.
Conclusion
I am sure there is still room for improvements. By using introspection on the model for example. But at the end of the day I'm really happy with it now, it does what I want.
Features will come in the future with needs, or bugs discovered by using this generator.
To be honest I did not forecast the amount of work it has been. I thought that generating nested modules would have been easier. Maybe there is a more elegant to do it.
Thanks for reading me !
Keep in Touch
On Twitter : @yet_anotherdev
Top comments (0)