Class: GraphWeaver::Codegen

Inherits:
Object
  • Object
show all
Includes:
Emit, Inflect, Selection
Defined in:
lib/graph_weaver/codegen/emit.rb,
lib/graph_weaver/codegen.rb,
lib/graph_weaver/codegen/nodes.rb,
lib/graph_weaver/codegen/enum_type.rb,
lib/graph_weaver/codegen/scalar_type.rb

Overview

typed: true frozen_string_literal: true

Defined Under Namespace

Modules: Emit Classes: EnumNode, EnumType, InputNode, List, MappedEnum, NarrowedNode, Node, NonNull, ObjectNode, Scalar, ScalarType, UnionNode, VarDef

Constant Summary collapse

RUBY_KEYWORDS =

Names that cannot appear bare in generated Ruby: keywords aren't valid identifiers, and the struct's own generated methods would be silently replaced by a same-named prop reader.

%w[
  alias and begin break case class def defined? do else elsif end
  ensure false for if in module next nil not or redo rescue retry
  return self super then true undef unless until when while yield
  BEGIN END __FILE__ __LINE__ __ENCODING__
].to_set.freeze
GENERATED_METHODS =
%w[serialize to_h].to_set.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Inflect

camelize, underscore

Methods included from Selection

#applies?, #each_field, #load_operation, #operation_root_type

Constructor Details

#initialize(schema:, query:, module_name: nil, client: nil, default_module_name: nil, scalars: nil, enums: nil, types: nil, inputs_namespace: nil) ⇒ Codegen

A client is anything responding to execute(query, variables:) whose result to_hs into => ..., "errors" => ... — a GraphWeaver::Client, a transport, a schema class, a fake.

client: (a constant, or its name as a string) becomes the generated module's baked default; when omitted, generated code falls back to the app default (GraphWeaver.client=). module_name: defaults to the operation's name; default_module_name: is parse's container-scoped fallback (file generation stays strict — a checked-in file deserves a deliberate name). scalars:/enums:/types: are client-scoped overlays consulted before the global registries (ScalarType, EnumType, and arrays of mixin modules, each keyed by GraphQL name). inputs_namespace: is the shared-inputs workflow (see GraphWeaver.generate!): variable types live once in that module and the query module aliases what it uses.



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/graph_weaver/codegen.rb', line 52

def initialize(schema:, query:, module_name: nil, client: nil, default_module_name: nil,
  scalars: nil, enums: nil, types: nil, inputs_namespace: nil)
  @schema = schema
  @query = query.strip
  @module_name = module_name
  @default_module_name = default_module_name
  @scalars = scalars || {}
  @enums = enums || {}
  @types = types || {}
  @inputs_namespace = inputs_namespace
  @client_const = self.class.client_const(client)

  if client && @client_const.nil?
    # a live object can't be spelled in generated source — parse can
    # set one via the module's writer, but file generation cannot
    raise ArgumentError, "client: must be a named constant or String (got #{client.inspect}); pass live objects to parse"
  end
end

Instance Attribute Details

#module_nameObject (readonly)

Returns the value of attribute module_name.



35
36
37
# File 'lib/graph_weaver/codegen.rb', line 35

def module_name
  @module_name
end

Class Method Details

.add_type_helpers(entry, graphql_name, mixins, requires, block) ⇒ Object

shared with Client#register_type: build/validate the mixins and append them to a registry entry

Raises:

  • (ArgumentError)


116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/graph_weaver/codegen/enum_type.rb', line 116

def add_type_helpers(entry, graphql_name, mixins, requires, block)
  mixins = mixins.dup
  mixins << helper_module(graphql_name, block) if block

  raise ArgumentError, "pass one or more helper modules, or a block" if mixins.empty?
  mixins.each do |mixin|
    unless mixin.is_a?(Module) && mixin.name
      raise ArgumentError, "type helpers must be named modules, got #{mixin.inspect}"
    end
  end

  entry[:mixins].concat(mixins)
  entry[:requires].concat(Array(requires))
  entry
end

.clear_scalars!Object

Empty the registry entirely, built-ins included. Mostly useful for tests; see reset_scalars! to restore the built-in defaults.



221
222
223
224
# File 'lib/graph_weaver/codegen/scalar_type.rb', line 221

def clear_scalars!
  scalar_registry.clear
  self
end

.client_const(client) ⇒ Object

The constant name a client can be referenced by in generated source — nil when it can't be (live objects, anonymous modules).



73
74
75
76
77
78
# File 'lib/graph_weaver/codegen.rb', line 73

def self.client_const(client)
  case client
  when String then client
  when Module then client.name
  end
end

.enum_registryObject



86
87
88
# File 'lib/graph_weaver/codegen/enum_type.rb', line 86

def enum_registry
  @enum_registry ||= {}
end

.generate(schema:, query:, module_name: nil, client: nil, scalars: nil, enums: nil, types: nil) ⇒ Object

one-step shorthand



81
82
83
# File 'lib/graph_weaver/codegen.rb', line 81

def self.generate(schema:, query:, module_name: nil, client: nil, scalars: nil, enums: nil, types: nil)
  new(schema:, query:, module_name:, client:, scalars:, enums:, types:).generate
end

.generate_inputs(schema:, module_name:, input_types: [], enum_types: [], scalars: nil, enums: nil, types: nil) ⇒ Object

The shared inputs artifact: the named input/enum types — plus everything they transitively reference — emitted once per schema as a manifest (inputs.rb) plus one file per type under inputs/, so a schema migration diffs only the types it touched. Returns { relative_filename => source }.



119
120
121
122
123
# File 'lib/graph_weaver/codegen.rb', line 119

def self.generate_inputs(schema:, module_name:, input_types: [], enum_types: [],
  scalars: nil, enums: nil, types: nil)
  codegen = new(schema:, query: "", module_name:, scalars:, enums:, types:)
  codegen.generate_inputs(input_types, enum_types)
end

.helper_module(graphql_name, block) ⇒ Object

a block-built mixin needs a name generated files can reference: GraphWeaver::TypeHelpers::Pet (suffixed on re-registration)



134
135
136
137
138
139
140
# File 'lib/graph_weaver/codegen/enum_type.rb', line 134

def helper_module(graphql_name, block)
  base = GraphWeaver::Inflect.camelize(graphql_name.to_s)
  name = base
  count = 1
  name = "#{base}V#{count += 1}" while GraphWeaver::TypeHelpers.const_defined?(name, false)
  GraphWeaver::TypeHelpers.const_set(name, Module.new(&block))
end

.parse(schema:, query:, module_name: nil, client: nil, scalars: nil, enums: nil, types: nil) ⇒ Object

Development convenience: generate + eval in one step, no build artifact or checked-in file. Same runtime semantics as the generated file, but invisible to srb tc — use the build step for static typing. Evaluates into an anonymous container, so no global constants leak; client: additionally accepts a live object (set via .client=).



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/graph_weaver/codegen.rb', line 90

def self.parse(schema:, query:, module_name: nil, client: nil, scalars: nil, enums: nil, types: nil)
  client_const = client_const(client)

  codegen = new(schema:, query:, module_name:, client: client_const, default_module_name: "Query",
    scalars:, enums:, types:)
  source = codegen.generate

  container = Module.new
  container.module_eval(source, "(graph_weaver)", 1)
  mod = container.const_get(codegen.module_name)
  GraphWeaver.log(:debug) { "parsed #{codegen.module_name} (dynamic module, #{source.bytesize} bytes)" }
  # live objects (or anonymous modules) can't be referenced from
  # generated source — set them via the module's writer instead
  mod.client = client if client && client_const.nil?
  mod
end

.register_builtin_scalars!Object

Built-in scalars — pre-registered entries in the one registry. The standard scalars stay pass-through: their Ruby classes (String, Integer, Float) define neither .parse nor .load, so codec inference matches nothing and leaves them identity — which is exactly why we can name them with the real class constants. Date deserializes via ISO-8601 (it does define .parse, but we want iso8601 specifically, so it's explicit). Input coercion is a generation-time concern: GraphWeaver.auto_coerce gives the convertible built-ins their conversion (see ScalarType::AUTO_CONVERSIONS).



245
246
247
248
249
250
251
252
# File 'lib/graph_weaver/codegen/scalar_type.rb', line 245

def register_builtin_scalars!
  register_scalar "ID", String
  register_scalar "String", String
  register_scalar "Int", Integer
  register_scalar "Float", Float
  register_scalar "Boolean", "T::Boolean"
  register_scalar "Date", Date, cast: :iso8601, serialize: :iso8601, requires: "date"
end

.register_enum(graphql_name, type, map: nil, fallback: nil, requires: nil) ⇒ Object

Map a GraphQL enum onto an app-owned T::Enum (see EnumType); the global default — client.register_enum scopes to one client.



77
78
79
# File 'lib/graph_weaver/codegen/enum_type.rb', line 77

def register_enum(graphql_name, type, map: nil, fallback: nil, requires: nil)
  enum_registry[graphql_name.to_s] = EnumType.new(graphql_name, type, map:, fallback:, requires:)
end

.register_enums(mappings) ⇒ Object

Bulk, inference-only form: register_enums("Species" => PetKind, ...)



82
83
84
# File 'lib/graph_weaver/codegen/enum_type.rb', line 82

def register_enums(mappings)
  mappings.each { |graphql_name, type| register_enum(graphql_name, type) }
end

.register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil) ⇒ Object

Register (or override) how a GraphQL custom scalar deserializes into a Ruby object and serializes back onto the wire. See ScalarType for the accepted cast:/serialize:/requires: forms. Later registrations win, so an app can override a built-in (e.g. map Date onto its own type).



201
202
203
204
# File 'lib/graph_weaver/codegen/scalar_type.rb', line 201

def register_scalar(graphql_name, type, cast: nil, serialize: nil, requires: nil, coerce: nil)
  scalar_registry[graphql_name.to_s] =
    ScalarType.new(graphql_name, type, cast:, serialize:, requires:, coerce:)
end

.register_type(graphql_name, *mixins, requires: nil, &block) ⇒ Object

Attach app-owned helper modules to every struct generated from a GraphQL type — the logic stays in your code, generation wires it in:

 GraphWeaver.register_type("Pet", PetHelpers)

Or build the mixin inline — the block is module_eval'd into a fresh module auto-named GraphWeaver::TypeHelpers::. Handy for quick decoration; srb tc can't see into block-defined methods, so prefer a named module where static checking matters:

 GraphWeaver.register_type("Pet") do
   def display_name = "#{name} the pet"
 end

Additive: repeated registrations (and client-scoped ones) stack.



105
106
107
108
# File 'lib/graph_weaver/codegen/enum_type.rb', line 105

def register_type(graphql_name, *mixins, requires: nil, &block)
  entry = type_registry[graphql_name.to_s] ||= { mixins: [], requires: [] }
  add_type_helpers(entry, graphql_name, mixins, requires, block)
end

.reset_scalars!Object

Drop every custom registration and restore the built-in scalars — the clean slate to reach for between tests, or to undo overrides. (Want the built-ins to coerce loose input? That's GraphWeaver.auto_coerce, resolved at generation time — no re-registering.)



230
231
232
233
234
# File 'lib/graph_weaver/codegen/scalar_type.rb', line 230

def reset_scalars!
  clear_scalars!
  register_builtin_scalars!
  self
end

.scalar(graphql_name) ⇒ Object

The ScalarType for a scalar name; unknown scalars fall back to an untyped pass-through (T.untyped, no cast) — the prior behavior for scalars outside the table.



209
210
211
212
213
# File 'lib/graph_weaver/codegen/scalar_type.rb', line 209

def scalar(graphql_name)
  scalar_registry.fetch(graphql_name.to_s) do
    ScalarType.new(graphql_name, "T.untyped")
  end
end

.scalar_registryObject



215
216
217
# File 'lib/graph_weaver/codegen/scalar_type.rb', line 215

def scalar_registry
  @scalar_registry ||= {}
end

.type_registryObject



110
111
112
# File 'lib/graph_weaver/codegen/enum_type.rb', line 110

def type_registry
  @type_registry ||= {}
end

.validate_registration!(schema, kind, name) ⇒ Object

A client-scoped registration names a type in a specific schema — a typo'd name would otherwise be a silent no-op, the most confusing failure mode available. Called eagerly by Client#register_* when the schema is already loaded, and again at generation (covers clients whose schema introspects lazily). Global registrations skip this: they may target a different client's server.

Raises:



216
217
218
219
220
221
222
223
# File 'lib/graph_weaver/codegen.rb', line 216

def self.validate_registration!(schema, kind, name)
  return if schema.get_type(name)

  suggestion = defined?(DidYouMean::SpellChecker) &&
    DidYouMean::SpellChecker.new(dictionary: schema.types.keys).correct(name).first
  hint = suggestion ? " — did you mean '#{suggestion}'?" : ""
  raise GraphWeaver::Error, "register_#{kind}(#{name.inspect}) matches no type in this schema#{hint}"
end

Instance Method Details

#generateObject



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'lib/graph_weaver/codegen.rb', line 154

def generate
  begin
    errors = @schema.validate(@query)
  rescue GraphQL::ParseError => e
    # unparseable queries wrap like invalid ones — everything raised
    # here descends from GraphWeaver::Error
    raise GraphWeaver::ValidationError.new([{ message: e.message, line: nil, column: nil }])
  end
  if errors.any?
    raise GraphWeaver::ValidationError.new(errors.map { |e| validation_detail(e) })
  end

  validate_registrations!

  @variable_enums = {}
  @variable_inputs = {}
  @mapped_enums = {}
  # requires the generated file needs (custom scalars, enum mappings,
  # type helpers all contribute)
  @requires = []

  operation = load_operation(@query)
  root_type = operation_root_type(operation)

  @module_name ||= operation.name || @default_module_name
  unless @module_name
    raise ArgumentError, "module_name: required for anonymous operations"
  end

  # generated source is eval'd by parse — never let a name inject code
  unless @module_name.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
    raise ArgumentError, "module_name: must be a constant name, got #{@module_name.inspect}"
  end

  variables = operation.variables.map do |var|
    node = ast_type_ref(var.type)
    # a variable is optional when nullable or defaulted; optional kwargs
    # default to nil and are omitted from the wire
    required = node.non_null? && var.default_value.nil?
    kwarg = underscore(var.name)
    # kwargs are declared and forwarded bare in generated source
    if RUBY_KEYWORDS.include?(kwarg)
      raise GraphWeaver::Error,
        "variable $#{var.name} would become the kwarg '#{kwarg}:', which generated code can't declare " \
        "(a Ruby keyword) — rename the variable"
    end
    VarDef.new(kwarg, var.name, node, required)
  end

  root = object_node(root_type, operation.selections, "Result")

  emit_module(root, variables)
end

#generate_inputs(input_types, enum_types) ⇒ Object



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/graph_weaver/codegen.rb', line 125

def generate_inputs(input_types, enum_types)
  unless @module_name&.match?(/\A[A-Z]\w*(::[A-Z]\w*)*\z/)
    raise ArgumentError, "inputs module name must be a constant name, got #{@module_name.inspect}"
  end

  @variable_enums = {}
  @variable_inputs = {}
  @mapped_enums = {}
  @requires = []

  enum_types.sort.each { |name| variable_core(@schema.get_type(name)) }
  input_types.sort.each { |name| input_node(@schema.get_type(name)) }

  emit_inputs_files
end

#variable_type_namesObject

The schema-level variable types this query touched, by GraphQL name — the generate! workflow unions these across queries to decide what the shared inputs module must contain.



110
111
112
# File 'lib/graph_weaver/codegen.rb', line 110

def variable_type_names
  { inputs: @variable_inputs.keys, enums: @variable_enums.keys, mapped: @mapped_enums.keys }
end