├── .gitignore ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── graphql-rails-schemaker.gemspec ├── lib ├── .DS_Store ├── graphql │ └── rails │ │ ├── .DS_Store │ │ ├── schemaker.rb │ │ └── schemaker │ │ ├── camel_case_middleware.erb │ │ ├── enum_type.erb │ │ ├── mutation_type.erb │ │ ├── object_type.erb │ │ ├── query_type.erb │ │ ├── railtie.rb │ │ ├── schema.erb │ │ ├── template_renderer.rb │ │ ├── union_type.erb │ │ └── version.rb └── tasks │ └── schemaker.rake └── preview.png /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at turner.cole@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in graphql-rails-schemaker.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Cole Turner 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-rails-schemaker 2 | A rake task to interactively create a GraphQL Schema for Rails Models. 3 | 4 | ![See it in action](https://raw.githubusercontent.com/colepatrickturner/graphql-rails-schemaker/master/preview.png) 5 | 6 | ## Getting Started 7 | - Add `graphql-rails-schemaker` to your Gemfile's `:development` section. 8 | - Run `bundle install` 9 | - Run `rails schemaker:generate` 10 | - Follow the prompts until it is done generating schema. 11 | - Report any bugs or inconsistencies to make this application better. 12 | 13 | ## Features 14 | ### Schema 15 | - Object Types 16 | - Enum Types 17 | - Union Types 18 | - Query Root 19 | - Mutation Root 20 | 21 | ### Smart Generation 22 | - Association detection between models 23 | - Automatic field for one-to-one associations 24 | - Prompts for "many" associations - choice between GraphQL List or Connections 25 | - Detects if naming overlaps with models (prompts for renaming) 26 | - Global Node Support (for Relay v1) 27 | - Camel Case with Middleware (for JavaScript type :camelCase fields) 28 | - Snake Case without middleware 29 | 30 | ### Word of Caution 31 | **This project is meant to generate a basic schema to cover a wide variety of uses. It is not a magical cure-all for your application's needs.** 32 | 33 | This tool is designed to facilitate setup of a GraphQL Schema in Rails 5 Application. It has not been tested in any prior verison of Rails. This task will not run it if detects a previous setup @ `./app/graph/schema.rb` It will overwrite any files in `./app/graph/` if no `schema.rb` exists. 34 | 35 | It will create a "base" schema including object types and sub-type dependencies from all models existing in the Rails application. It has been designed to formulate a generic schema to fit a wide variety of applications with support for associations. 36 | 37 | **Do not run this in production environments.** 38 | 39 | 40 | # Todo 41 | - Add boilerplate input types 42 | - Add boilerplate mutation types 43 | - Integration with [`graphql-rails-resolver`](https://github.com/colepatrickturner/graphql-rails-resolver) (if installed) 44 | 45 | ## Needs Help 46 | The `object_type.rb` template is large and cumbersome. The Todo above is planned for action. If you would like to handle any of the above, please file a pull request and add your name to the credits list. 47 | 48 | 49 | ## Credits 50 | Cole Turner (http://cole.codes/) 51 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | task :default => :spec 3 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "graphql/rails/schemaker" 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 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /graphql-rails-schemaker.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'graphql/rails/schemaker/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "graphql-rails-schemaker" 8 | spec.version = Graphql::Rails::Schemaker::VERSION 9 | spec.date = Date.today.to_s 10 | spec.authors = ["Cole Turner"] 11 | spec.email = ["turner.cole@gmail.com"] 12 | 13 | spec.summary = "" 14 | spec.homepage = "https://github.com/colepatrickturner/graphql-rails-schemaker" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 18 | f.match(%r{^(test|spec|features)/}) 19 | end 20 | spec.bindir = "exe" 21 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 22 | spec.require_paths = ["lib"] 23 | 24 | spec.add_runtime_dependency "graphql", ">= 0.19.0" 25 | spec.add_development_dependency "bundler", "~> 1.13" 26 | spec.add_development_dependency "rake", "~> 10.0" 27 | spec.required_ruby_version = '>= 2.2.2' 28 | end 29 | -------------------------------------------------------------------------------- /lib/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleturner/graphql-rails-schemaker/de7c6398f9b49f86222fd305ff87e962a1698580/lib/.DS_Store -------------------------------------------------------------------------------- /lib/graphql/rails/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleturner/graphql-rails-schemaker/de7c6398f9b49f86222fd305ff87e962a1698580/lib/graphql/rails/.DS_Store -------------------------------------------------------------------------------- /lib/graphql/rails/schemaker.rb: -------------------------------------------------------------------------------- 1 | require "graphql/rails/schemaker/version" 2 | require 'graphql/rails/schemaker/railtie' if defined?(Rails) 3 | 4 | module Graphql 5 | module Rails 6 | module Schemaker 7 | def self.root 8 | File.dirname __dir__ 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/graphql/rails/schemaker/camel_case_middleware.erb: -------------------------------------------------------------------------------- 1 | class CamelCaseMiddleware 2 | def call(parent_type, parent_object, field_definition, field_args, query_context, next_middleware) 3 | next_middleware.call([parent_type, parent_object, field_definition, transform_arguments(field_args), query_context]) 4 | end 5 | 6 | def transform_arguments(field_args) 7 | transformed_args = {} 8 | types = {} 9 | 10 | field_args.each_value do |arg_value| 11 | key = arg_value.key.to_s 12 | unless key == "clientMutationId" 13 | key = key.underscore 14 | end 15 | 16 | transformed_args[key] = transform_value(arg_value.value) 17 | types[key] = arg_value.definition 18 | end 19 | 20 | GraphQL::Query::Arguments.new(transformed_args, argument_definitions: types) 21 | end 22 | 23 | def transform_value(value) 24 | case value 25 | when Array 26 | value.map { |v| transform_value(v) } 27 | when Hash 28 | Hash[value.map { |k, v| [underscore_key(k), convert_hash_keys(v)] }] 29 | when GraphQL::Query::Arguments 30 | transform_arguments(value) 31 | else 32 | value 33 | end 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /lib/graphql/rails/schemaker/enum_type.erb: -------------------------------------------------------------------------------- 1 | <%=name%>Enum = GraphQL::EnumType.define do 2 | name "<%=name%>" 3 | description "Enumerated values for <%=name%>" 4 | 5 | <% 6 | values.each do |key,value| 7 | -%> 8 | value(<%=key.inspect%>, <%="Value of \"#{value}\"".inspect%>, value: <%=value.inspect%>) 9 | <% 10 | end 11 | -%> 12 | end 13 | -------------------------------------------------------------------------------- /lib/graphql/rails/schemaker/mutation_type.erb: -------------------------------------------------------------------------------- 1 | <%=mutation_type_name%> = GraphQL::ObjectType.define do 2 | name "Mutation" 3 | description "The mutation root of this schema" 4 | 5 | #field :sampleMutation, field: SampleMutation.field 6 | end 7 | -------------------------------------------------------------------------------- /lib/graphql/rails/schemaker/object_type.erb: -------------------------------------------------------------------------------- 1 | <%=model.name%>Type = GraphQL::ObjectType.define do 2 | name "<%=model.name%>" 3 | description "Type for <%=model.name%> object" 4 | 5 | interfaces [::GraphQL::Relay::Node.interface] 6 | 7 | <% 8 | attributes.each do |name, attribute| 9 | association = nil 10 | 11 | if attribute.key? :association 12 | association = attribute[:association] 13 | elsif model.respond_to? :reflect_on_all_associations 14 | 15 | end 16 | 17 | if model.respond_to? :primary_key and name.to_s == model.primary_key 18 | -%> 19 | global_id_field :<%=name%> 20 | <% 21 | elsif association.present? 22 | if association.polymorphic? 23 | object_model = association.active_record 24 | else 25 | object_model = association.klass 26 | end 27 | 28 | if association.collection? 29 | list_or_connection = nil 30 | while list_or_connection.nil? 31 | STDOUT.puts "Should many type `\e[32m#{name}\e[0m` on \e[32m#{model.name}\e[0m be a list or connection? (\e[34ml = list \e[39m| \e[32mc = connection \e[39m| \e[93msh = help\e[39m)." 32 | command = STDIN.gets.chomp.downcase 33 | case command 34 | when "c", "g" 35 | list_or_connection = :connection 36 | when "l" 37 | list_or_connection = :list 38 | when "h" 39 | STDOUT.puts "Lists return all objects in the association. Use connections if pagination is necessary." 40 | end 41 | end 42 | 43 | if list_or_connection == :connection 44 | -%> 45 | connection :<%=association.plural_name%>, <%=object_model.is_a?(model) or object_model == model ? "-> { " : ""%><%=object_model%>Type<%=association.polymorphic? ? "Union" : ""%>.connection_type<%=object_model.is_a?(model) or object_model == model ? " }" : ""%>, "Association to many `<%=association.plural_name%>` on <%=model.name%>" 46 | <% 47 | else 48 | -%> 49 | field :<%=association.plural_name%>, <%=object_model.is_a?(model) or object_model == model ? "-> { " : ""%><%=object_model%>Type<%=association.polymorphic? ? "Union" : ""%>.to_list_type<%=object_model.is_a?(model) or object_model == model ? " }" : ""%>, "Association to many `<%=association.plural_name%>` on <%=model.name%>" 50 | <% 51 | end 52 | else 53 | -%> 54 | field :<%=association.name%>, <%=object_model.is_a?(model) or object_model == model ? "-> { " : ""%><%=object_model%>Type<%=association.polymorphic? ? "Union" : ""%><%=object_model.is_a?(model) or object_model == model ? " }" : ""%>, "Association to one `<%=association.name%>` on <%=model.name%>" 55 | <% 56 | end 57 | else 58 | type_str = 59 | if attribute.key? :type and attribute[:type].present? 60 | attribute[:type] 61 | else 62 | "types.String" 63 | end 64 | 65 | -%> 66 | field :<%=name%>, <%=type_str%>, "Property `<%=attribute[:property]%>` for <%=model.name%>"<%=attribute[:property].to_s != name.to_s ? ", property: :#{attribute[:property]}" : ""%> 67 | <% 68 | end 69 | end 70 | %> 71 | end 72 | -------------------------------------------------------------------------------- /lib/graphql/rails/schemaker/query_type.erb: -------------------------------------------------------------------------------- 1 | <%= query_type_name %> = GraphQL::ObjectType.define do 2 | name "Query" 3 | description "The query root of this schema" 4 | 5 | field :<%=root_type_name.underscore%> do 6 | type <%=root_type_name%>Type 7 | resolve -> (obj, args, ctx) { 8 | ctx[:<%=root_type_name.underscore%>] 9 | } 10 | end 11 | 12 | field :node, ::GraphQL::Relay::Node.field 13 | end 14 | -------------------------------------------------------------------------------- /lib/graphql/rails/schemaker/railtie.rb: -------------------------------------------------------------------------------- 1 | module Graphql 2 | module Rails 3 | module Schemaker 4 | class Railtie < ::Rails::Railtie 5 | rake_tasks do 6 | load 'tasks/schemaker.rake' 7 | end 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/graphql/rails/schemaker/schema.erb: -------------------------------------------------------------------------------- 1 | Schema = GraphQL::Schema.define do 2 | query <%= query_type_name %> 3 | mutation <%= mutation_type_name %> 4 | 5 | resolve_type -> (object, ctx) { 6 | Schema.types[object.class.name] 7 | } 8 | 9 | object_from_id -> (id, ctx) { 10 | type_name, object_id = GraphQL::Schema::UniqueWithinType.decode(id) 11 | 12 | unless type_name.safe_constantize.present? 13 | raise ArgumentError, "Type of object (#{type_name}) does not exist." 14 | end 15 | 16 | type_name.constantize.find(object_id) 17 | } 18 | 19 | id_from_object -> (obj, type_defn, ctx) { 20 | GraphQL::Schema::UniqueWithinType.encode(type_defn.name, obj.id) 21 | } 22 | 23 | <%= middleware %> 24 | end 25 | -------------------------------------------------------------------------------- /lib/graphql/rails/schemaker/template_renderer.rb: -------------------------------------------------------------------------------- 1 | require 'erb' 2 | 3 | class TemplateRenderer 4 | def self.empty_binding 5 | binding 6 | end 7 | 8 | def self.render_string(template_content, locals = {}) 9 | b = empty_binding 10 | locals.each { |k, v| b.local_variable_set(k, v) } 11 | 12 | ERB.new(template_content, nil, '-').result(b) 13 | end 14 | 15 | def self.render(file, locals = {}) 16 | path = File.join Graphql::Rails::Schemaker.root, file 17 | render_string(File.read(path), locals) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/graphql/rails/schemaker/union_type.erb: -------------------------------------------------------------------------------- 1 | <%=polymorphic.name.to_s.camelize%>TypeUnion = GraphQL::UnionType.define do 2 | name "<%=polymorphic.name.to_s.camelize%>" 3 | description "Objects for `<%=polymorphic.active_record.name.to_s%>`" 4 | possible_types [<%=associations.map(&:name).map { |n| "#{n.to_s.camelize}Type" }.join(", ")%>] 5 | end 6 | -------------------------------------------------------------------------------- /lib/graphql/rails/schemaker/version.rb: -------------------------------------------------------------------------------- 1 | module Graphql 2 | module Rails 3 | module Schemaker 4 | VERSION = "0.1.0" 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/tasks/schemaker.rake: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | require 'set' 3 | require 'graphql/rails/schemaker/template_renderer' 4 | 5 | namespace :schemaker do 6 | desc "Tasks for operating a GraphQL Schema Server" 7 | 8 | task introspect: :environment do 9 | viewer = SystemViewer.new 10 | 11 | query_string = GraphQL::Introspection::INTROSPECTION_QUERY 12 | result_hash = Schema.execute(query_string, context: {viewer: viewer}) 13 | 14 | File.open("./build/schema.json","w+") do |f| 15 | f.write(result_hash.to_json) 16 | end 17 | end 18 | 19 | task generate: :environment do 20 | if File.exist? "./app/graph/schema.rb" 21 | abort "\e[1mAnother schema already exists @ ./app/graph/schema.rb\e[0m" 22 | end 23 | 24 | # Ensure directories are available 25 | #FileUtils::mkdir_p Rails.root.join("app", "graph", "mutations") 26 | #FileUtils::mkdir_p Rails.root.join("app", "graph", "resolvers") 27 | FileUtils::mkdir_p Rails.root.join("app", "graph", "types") 28 | 29 | # Load our application 30 | Rails.application.eager_load! 31 | models = ActiveRecord::Base.descendants.collect { |type| type } 32 | scalar_types = [:string, :integer, :float, :id, :boolean] 33 | 34 | # Determine whether to do snake case or camel case 35 | STDOUT.puts "\e[1mUse camelCase or snake_case? Enter (c/S).\e[0m" 36 | name_format = 37 | if STDIN.gets.chomp.downcase == 'c' 38 | :camel 39 | else 40 | :snake 41 | end 42 | 43 | # Function to convert object types to string 44 | object_type_to_string = lambda { |model, attributes| 45 | TemplateRenderer.render("rails/schemaker/object_type.erb", { model: model, attributes: attributes, all_models: models }) 46 | } 47 | 48 | # Function to convert enum types to string 49 | enum_type_to_string = lambda { |name, values| 50 | TemplateRenderer.render("rails/schemaker/enum_type.erb", { name: name, values: values }) 51 | } 52 | 53 | # Function to write object types 54 | put_object_type = lambda { |file, model, attributes| 55 | File.open(file, 'w+') { |file| file.write(object_type_to_string.call(model, attributes)) } 56 | } 57 | 58 | # Function to write enum types 59 | put_enum_type = lambda { |file, name, value| 60 | File.open(file, 'w+') { |file| file.write(enum_type_to_string.call(name, value)) } 61 | } 62 | 63 | STDOUT.puts "Using #{name_format == :camel ? 'camel case' : 'snake case'}" 64 | 65 | # Schema vars 66 | query_type_name = "QueryType" 67 | while ActiveRecord::Base.descendants.map(&:name).include? query_type_name 68 | if query_type_name == "QueryType" 69 | query_type_name = "QueryRootType" 70 | elsif query_type_name == "QueryRootType" 71 | query_type_name = "QueryRootObjectType" 72 | elsif query_type_name == "QueryRootObjectType" 73 | STDOUT.puts "Unable to formulate a query root type name - taken: QueryType, QueryRootType, QueryRootObjectType" 74 | abort "Exiting..." 75 | end 76 | end 77 | 78 | mutation_type_name = "MutationType" 79 | while ActiveRecord::Base.descendants.map(&:name).include? mutation_type_name 80 | if query_type_name == "MutationType" 81 | query_type_name = "MutationRootType" 82 | elsif query_type_name == "MutationRootType" 83 | query_type_name = "MutationRootObjectType" 84 | elsif query_type_name == "MutationRootObjectType" 85 | STDOUT.puts "Unable to formulate a mutation root type name - taken: MutationType, MutationRootType, MutationRootObjectType" 86 | abort "Exiting..." 87 | end 88 | end 89 | 90 | # The Main Schema entry point 91 | schema_rb_file = "./app/graph/schema.rb" 92 | unless File.exist? schema_rb_file 93 | STDOUT.puts "\e[1m\e[32mGenerating Schema Root...\e[0m" 94 | 95 | middleware = name_format == :camel ? "middleware AuthorizationMiddleware.new" : "" 96 | 97 | src = TemplateRenderer.render("rails/schemaker/schema.erb", { middleware: middleware, query_type_name: query_type_name, mutation_type_name: mutation_type_name }) 98 | 99 | File.open(schema_rb_file, 'w+') { |file| file.write(src) } 100 | end 101 | 102 | # Camel Case Middleware 103 | if name_format == :camel 104 | FileUtils::mkdir_p Rails.root.join("app", "graph", "middleware") 105 | 106 | middleware_rb_file = "./app/graph/middleware/camel_case_middleware.rb" 107 | unless File.exist? middleware_rb_file 108 | src = TemplateRenderer.render("rails/schemaker/camel_case_middleware.erb") 109 | 110 | File.open(middleware_rb_file, 'w+') { |file| file.write(src) } 111 | end 112 | end 113 | 114 | fake_association = Class.new(Object) { 115 | def initialize(model, plural_name:, macro: :has_many, polymorphic: false) 116 | @model = model 117 | @plural_name = plural_name || model.name.pluralize 118 | @macro = macro 119 | @polymorphic = polymorphic 120 | end 121 | 122 | def klass() @model end 123 | def macro() @macro end 124 | def plural_name() @plural_name end 125 | def polymorphic?() @polymorphic end 126 | def collection?() [:has_many, :has_and_belongs_to_many].include?(@macro) end 127 | } 128 | 129 | scalar_types = { :id => "types.ID", :boolean => "types.Boolean", :integer => "types.Int", :float => "types.Float", :decimal => "types.Float", :string => "types.String"} 130 | 131 | guess_type = lambda { |model, name| 132 | return :enum if model.defined_enums.key?(name) 133 | matches = model.columns.select { |c| c.name == name } 134 | 135 | return matches.first.type if matches.present? 136 | 137 | :string 138 | } 139 | 140 | graphl_field_type = Proc.new { |type, name| 141 | graphql_type = nil 142 | graphql_type = scalar_types[type.to_sym] if scalar_types.key? type.to_sym 143 | 144 | if type == :enum 145 | graphql_type = "#{name.to_s.camelize}Enum" 146 | end 147 | 148 | graphql_type = scalar_types[:string] if type.nil? 149 | 150 | graphql_type 151 | } 152 | 153 | STDOUT.puts "" 154 | STDOUT.puts "\e[1m\e[32mGenerating Object Types...\e[0m" 155 | 156 | active_models = [] 157 | active_enum = {} 158 | active_models_attributes = {} 159 | models.each do |model| 160 | model.connection 161 | skipped = false 162 | STDOUT.puts 163 | STDOUT.puts "----------------------------------" 164 | STDOUT.puts "\e[1mGenerating type for model: \e[32m#{model}\e[0m" 165 | attributes = Set.new 166 | attribute_types = {} 167 | attribute_graphql_types = {} 168 | attribute_properties = {} 169 | attribute_associations = {} 170 | 171 | # Track all the columns 172 | model.columns.each do |column| 173 | name = column.name 174 | 175 | type_sym = 176 | if model.defined_enums.key? name 177 | :enum 178 | else 179 | column.type 180 | end 181 | 182 | graphql_type = graphl_field_type.call(type_sym, name) 183 | 184 | new_name = name_format == :camel ? name.camelize(:lower).to_sym : name.underscore.to_sym 185 | attributes.add(new_name) 186 | attribute_types[new_name] = type_sym 187 | attribute_graphql_types[new_name] = graphql_type 188 | attribute_properties[new_name] = name 189 | end 190 | 191 | # Track all associations 192 | model.reflect_on_all_associations.each do |association| 193 | name = association.collection? ? association.plural_name.to_s : association.name.to_s 194 | type = :object 195 | 196 | new_name = name_format == :camel ? name.camelize(:lower).to_sym : name.underscore.to_sym 197 | attributes.add(new_name) 198 | attribute_types[new_name] = type 199 | attribute_graphql_types[new_name] = graphl_field_type.call(type, name) 200 | attribute_properties[new_name] = name 201 | attribute_associations[new_name] = association 202 | end 203 | 204 | # Process commands for each model 205 | last_input = $_ 206 | is_repeating = false 207 | while last_input != "g" 208 | unless is_repeating 209 | STDOUT.puts 210 | STDOUT.puts "Using attributes: #{attributes.to_a.join(", ")}" 211 | STDOUT.puts "To continue, enter one of the following commands: (\e[34mg = generate \e[39m| \e[31mr = remove attribute \e[39m| \e[32ma = add attribute \e[39m| \e[93ms = skip model\e[39m)" 212 | command = STDIN.gets.chomp.downcase 213 | end 214 | 215 | is_repeating = false 216 | 217 | case command 218 | when "s" 219 | skipped = true 220 | break 221 | when "a" 222 | STDOUT.puts 223 | STDOUT.puts "Enter attribute name and type in following format: \e[32m#{name_format == :snake ? "property_name" : "columName"}\e[39m:(\e[96m#{scalar_types.join("\e[39m|\e[96m")}\e[39m)" 224 | raw_attr = STDIN.gets.chomp.downcase 225 | new_attr = raw_attr 226 | 227 | if name_format == :camel 228 | new_attr = raw_attr.camelize(:lower) 229 | else 230 | new_attr = raw_attr.underscore 231 | end 232 | 233 | unless new_attr === raw_attr 234 | STDOUT.puts "Converting to #{name_format == :camel ? 'camelCase' : 'snake_case'} - \"#{new_attr}\"" 235 | end 236 | 237 | unless new_attr.present? 238 | is_repeating = false 239 | next 240 | end 241 | 242 | unless new_attr.include? ":" 243 | last_input = "a" 244 | is_repeating = true 245 | next 246 | end 247 | 248 | name, type = new_attr.split(":") 249 | property = name 250 | tries = 0; 251 | 252 | if attributes.include? name.to_sym 253 | STDOUT.puts "Attribute #{name} already exists. Retrying..." 254 | is_repeating = false 255 | next 256 | end 257 | 258 | until model.column_names.include? property or model.respond_to? property.to_sym 259 | if tries >= 3 260 | STDOUT.puts "Tried three times. Retrying..." 261 | break 262 | end 263 | 264 | STDOUT.puts "#{model} does not possess property \"#{property}\", what should \"#{name}\" respond with?" 265 | input = STDIN.gets.chomp 266 | property = input if input.present? 267 | 268 | tries += 1 269 | end 270 | 271 | if property.nil? 272 | is_repeating = true 273 | next 274 | end 275 | 276 | unless name == property 277 | attribute_properties[name] = property 278 | end 279 | 280 | attributes.add(name.to_sym) 281 | attribute_types[name.to_sym] = type 282 | attribute_graphql_types[name.to_sym] = graphl_field_type.call(type, name.to_sym) 283 | 284 | when "r" 285 | STDOUT.puts "Enter the name of the attribute to remove:" 286 | raw_attr = STDIN.gets.chomp.downcase 287 | 288 | if raw_attr.include? ":" 289 | raw_attr = raw_attr.split(":").first 290 | end 291 | 292 | remove_attr = raw_attr 293 | 294 | if name_format == :camel 295 | remove_attr = raw_attr.camelize(:lower) 296 | else 297 | remove_attr = raw_attr.underscore 298 | end 299 | 300 | unless remove_attr === raw_attr 301 | STDOUT.puts "Converting to #{name_format == :camel ? 'camelCase' : 'snake_case'} - \"#{remove_attr}\"" 302 | end 303 | 304 | unless attributes.include? remove_attr.to_sym 305 | STDOUT.puts "Attribute \"#{remove_attr}\" does not exist." 306 | is_repeating = true 307 | next 308 | end 309 | 310 | STDOUT.puts "Removing attribute \"#{remove_attr}\"." 311 | attributes.delete(remove_attr.to_sym) 312 | attribute_types.delete remove_attr.to_sym 313 | attribute_graphql_types.delete remove_attr.to_sym 314 | attribute_properties.delete remove_attr.to_sym 315 | 316 | is_repeating = false 317 | when "g" 318 | break 319 | else 320 | STDOUT.puts "Unrecognized command #{command}" 321 | end 322 | 323 | end 324 | 325 | if skipped 326 | STDOUT.puts "\e[93mSkipping #{model.name}...\e[39m" 327 | next 328 | end 329 | 330 | object_type_file = "./app/graph/types/#{model.name.underscore}_type.rb" 331 | attr_composed = {} 332 | 333 | attributes.each do |attribute| 334 | hash = { :type => attribute_types[attribute], :graphql_type => attribute_graphql_types[attribute], :property => attribute_properties[attribute] } 335 | 336 | if attribute_associations.key? attribute 337 | hash[:association] = attribute_associations[attribute] 338 | end 339 | 340 | if model.defined_enums.key? hash[:property] 341 | active_enum[attribute.to_s.camelize] = model.defined_enums[hash[:property]] 342 | end 343 | 344 | attr_composed[attribute] = hash 345 | end 346 | 347 | put_object_type.call(object_type_file, model, attr_composed.sort_by { |k,v| [k == :id ? 0 : 1, k] }) 348 | 349 | # Save this config for later 350 | active_models.push(model) 351 | active_models_attributes[model] = attr_composed 352 | 353 | # Todo 354 | # 2. Generate input types 355 | # 4. Generate generic resolvers (if using graphql-rails-resolver) 356 | 357 | end 358 | 359 | puts "active_enum = #{active_enum}" 360 | if active_enum.present? 361 | STDOUT.puts "" 362 | STDOUT.puts "----------------------------------" 363 | STDOUT.puts "" 364 | STDOUT.puts "\e[1m\e[32mGenerating Enum Types...\e[0m" 365 | 366 | active_enum.each do |name, values| 367 | enum_type_file = "./app/graph/types/#{name.underscore}_enum.rb" 368 | 369 | STDOUT.puts "\e[34m#{name}Enum\e[0m" 370 | put_enum_type.call(enum_type_file, name, values) 371 | end 372 | end 373 | 374 | STDOUT.puts "" 375 | STDOUT.puts "----------------------------------" 376 | STDOUT.puts "" 377 | 378 | # Generate union types from generated models 379 | polymorphics = active_models.map { |m| m.reflect_on_all_associations.select(&:polymorphic?) }.flatten 380 | if polymorphics.present? 381 | STDOUT.puts "\e[1m\e[32mGenerating Union Types...\e[0m" 382 | polymorphics.each do |polymorphic| 383 | 384 | STDOUT.puts "\e[34m#{polymorphic.name.to_s.camelize}Type\e[0m" 385 | 386 | polymorphic_rb_file = "./app/graph/types/#{polymorphic.name}_union.rb" 387 | associations = active_models.select { |m| m.reflect_on_all_associations.select{ |j| j.options[:as] == polymorphic.name }.present? } 388 | 389 | src = TemplateRenderer.render("rails/schemaker/union_type.erb", { polymorphic: polymorphic, associations: associations }) 390 | 391 | File.open(polymorphic_rb_file, 'w+') { |file| file.write(src) } 392 | end 393 | end 394 | 395 | STDOUT.puts "" 396 | STDOUT.puts "----------------------------------" 397 | STDOUT.puts "" 398 | 399 | STDOUT.puts "\e[1m\e[32mGenerating Query Root...\e[0m" 400 | STDOUT.puts "A query root is the entry point to your Schema." 401 | STDOUT.puts "" 402 | STDOUT.puts "If you plan to use Relay v1, your Schema needs a global node to work properly." 403 | STDOUT.puts "See https://github.com/facebook/relay/issues/112 for more info." 404 | 405 | STDOUT.puts "" 406 | STDOUT.puts "----------------------------------" 407 | STDOUT.puts "" 408 | 409 | STDOUT.puts "How would you like to generate your query root?" 410 | STDOUT.puts "1 - Use Global Node (default)" 411 | STDOUT.puts "2 - Expose all fields on query root" 412 | STDOUT.puts "" 413 | 414 | command = STDIN.gets.chomp.downcase 415 | until ["1", "2", "", "g"].include? command 416 | STDOUT.puts "\"#{command}\" not recognized." 417 | command = STDIN.gets.chomp.downcase 418 | end 419 | 420 | if ["", "g"].include? command 421 | command = "1" 422 | end 423 | 424 | # Generate global node for query root 425 | if command == "1" 426 | root_type_name = nil 427 | 428 | # Check if developers already made a global node model 429 | if active_models.map(&:name).include? "Viewer" 430 | STDOUT.puts "A model by the name 'Viewer' already exists. Should this model be the global node? (Y/n)" 431 | 432 | command = STDIN.gets.chomp.downcase 433 | until ["y", "n", "", "g"].include?(command) 434 | STDOUT.puts "\"#{command}\" not recognized." 435 | command = STDIN.gets.chomp.downcase 436 | end 437 | 438 | if ["", "g"].include? command 439 | command = "y" 440 | end 441 | 442 | root_type_name = "Viewer" 443 | 444 | # Reconfigure the file 445 | model = active_models.select { |m| m.name == "Viewer" }.first 446 | attributes = active_models_attributes[model] 447 | 448 | root_type_attr = {} 449 | 450 | active_models.map do |model| 451 | key = model.name.pluralize 452 | 453 | if name_format == :camel 454 | key = key.camelize(:lower) 455 | else 456 | key = key.underscore 457 | end 458 | 459 | while key.nil? or attributes.key? key or attributes.key? key.to_sym 460 | STDOUT.puts "Field `\e[31m#{key}\e[0m` already exists on `\e[32m#{root_type_name}\e[0m`. Enter a new name for the field:" 461 | command = STDIN.gets.chomp 462 | unless command.present? and command[/[a-zA-Z]+/] == command 463 | command = nil 464 | next 465 | end 466 | 467 | key = command 468 | end 469 | 470 | if name_format == :camel 471 | key = key.camelize(:lower) 472 | else 473 | key = key.underscore 474 | end 475 | 476 | association = fake_association.new(model, plural_name: key) 477 | association.polymorphic? 478 | 479 | root_type_attr[key] = { :type => :id, :association => association} 480 | end 481 | 482 | object_type_file = "./app/graph/types/#{model.name.underscore}_type.rb" 483 | sorted_fields = Hash[root_type_attr.sort_by{ |k,v| k }] 484 | put_object_type.call(object_type_file, model, attributes.merge(sorted_fields)) 485 | else 486 | STDOUT.puts "Define the root type to expose your object types: (letters only) (default = \e[32mViewer\e[0m)" 487 | name_command = STDIN.gets.chomp.downcase 488 | while root_type_name.nil? 489 | if name_command.empty? 490 | root_type_name = "Viewer" 491 | else 492 | unless name_command[/[a-zA-Z]+/] == name_command 493 | STDOUT.puts "Root type name must contain only letters. No numbers or special characters." 494 | next 495 | end 496 | 497 | if models.map(&:name).include?(name_command) 498 | STDOUT.puts "A model already exists by that name. Are you sure? (y/N)" 499 | command = STDIN.gets.chomp.downcase 500 | until ["y", "n", ""].include?(command) 501 | STDOUT.puts "\"#{command}\" not recognized." 502 | command = STDIN.gets.chomp.downcase 503 | end 504 | 505 | if command == "" 506 | command = "n" 507 | end 508 | 509 | if command == "n" 510 | next 511 | end 512 | end 513 | 514 | root_type_name = name_command.classify 515 | end 516 | end 517 | 518 | # Generate global node type with generated models 519 | Object.const_set(root_type_name, Class.new { def name() root_type_name end }) 520 | 521 | root_type_attr = {} 522 | active_models.each do |model| 523 | key = model.name.pluralize 524 | 525 | if name_format == :camel 526 | key = key.camelize(:lower) 527 | else 528 | key = key.underscore 529 | end 530 | 531 | association = fake_association.new(model, plural_name: key) 532 | root_type_attr[key] = { :type => :id, :association => association} 533 | end 534 | 535 | root_object_type_file = "./app/graph/types/#{root_type_name.underscore}_type.rb" 536 | put_object_type.call(root_object_type_file, root_type_name.constantize, root_type_attr) 537 | 538 | end 539 | end 540 | 541 | # Generate the Query root type (include) 542 | query_type_rb_file = "./app/graph/types/#{query_type_name.underscore}.rb" 543 | unless File.exist? query_type_rb_file 544 | STDOUT.puts "Generating Query Root Type..." 545 | 546 | src = TemplateRenderer.render("rails/schemaker/query_type.erb", { query_type_name: query_type_name, root_type_name: root_type_name }) 547 | 548 | File.open(query_type_rb_file, 'w+') { |file| file.write(src) } 549 | end 550 | 551 | 552 | # Generate the Mutation root type (include) 553 | # TODO - Add Mutations 554 | mutation_type_rb_file = "./app/graph/types/#{mutation_type_name.underscore}.rb" 555 | unless File.exist? mutation_type_rb_file 556 | STDOUT.puts "Generating Mutation Root Type..." 557 | 558 | src = TemplateRenderer.render("rails/schemaker/mutation_type.erb", { mutation_type_name: mutation_type_name }) 559 | 560 | File.open(mutation_type_rb_file, 'w+') { |file| file.write(src) } 561 | end 562 | 563 | 564 | STDOUT.puts "\e[32mDone...\e[0m" 565 | 566 | 567 | end 568 | 569 | end 570 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/coleturner/graphql-rails-schemaker/de7c6398f9b49f86222fd305ff87e962a1698580/preview.png --------------------------------------------------------------------------------