Rails Generator for GraphQL queries
Tags: rails, ruby
Published: 2023-03-06 12:02:26

## 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 : 
```ruby
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.
```ruby
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.

```ruby
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: 
```ruby
  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. 

```ruby
  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.

```ruby
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. 

```ruby
  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](https://gist.github.com/Lucas-Barret/cb18f8330eab2d08675c987260854c56).
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 : 
```ruby
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