DEV Community

Lucas Barret
Lucas Barret

Posted on • Edited on

Rails Generator for GraphQL queries

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Thanks to that you will be able to write things like that which really common in cli :

rails g query coffee --nested-module module1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)