fragment-dev 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ return unless defined?(Tapioca::Dsl::Compilers)
5
+
6
+ require 'fragment_client'
7
+ require 'tapioca/dsl/helpers/graphql_sorbet_types'
8
+
9
+ module Tapioca
10
+ module Dsl
11
+ module Compilers
12
+ # Declares the response objects the query methods return, so that
13
+ # `client.get_ledger(...).data.ledger.name` typechecks.
14
+ #
15
+ # graphql-client builds those objects at runtime from anonymous classes whose
16
+ # fields are served by `method_missing`, so there is nothing to reflect on.
17
+ # The shape is instead derived statically from each operation's selection set
18
+ # and the pinned Schema -- the same two inputs graphql-client itself uses,
19
+ # which is what makes the declared types agree with the runtime.
20
+ #
21
+ # In particular graphql-client refuses at runtime to read a field the
22
+ # operation did not select (`ImplicitlyFetchedFieldError`), so a type derived
23
+ # from the selection set cannot promise more than the runtime allows.
24
+ #
25
+ # Emitted under `FragmentClient::Responses::<Operation>`, one nested class per
26
+ # selection level.
27
+ class FragmentResponseTypes < Compiler
28
+ extend T::Sig
29
+
30
+ ConstantType = type_member { { fixed: T.class_of(FragmentClient) } }
31
+
32
+ # Scalar names this Schema uses, mapped for `GraphqlSorbetTypes`.
33
+ SCALARS = T.let(
34
+ {
35
+ 'String' => '::String', 'ID' => '::String', 'SafeString' => '::String',
36
+ 'ParameterizedString' => '::String', 'Date' => '::String',
37
+ 'DateTime' => '::String', 'FirstMoment' => '::String',
38
+ 'LastMoment' => '::String', 'Period' => '::String',
39
+ 'PeriodFilter' => '::String', 'UTCOffset' => '::String',
40
+ 'Int96' => '::String', 'Int' => '::Integer', 'Float' => '::Float',
41
+ 'Boolean' => 'T::Boolean', 'JSON' => 'T.untyped', 'Parameters' => 'T.untyped'
42
+ }.freeze,
43
+ T::Hash[String, String]
44
+ )
45
+
46
+ RESPONSES_NAMESPACE = 'FragmentClient::Responses'
47
+
48
+ class << self
49
+ extend T::Sig
50
+
51
+ sig { override.returns(T::Enumerable[Module]) }
52
+ def gather_constants
53
+ [FragmentClient]
54
+ end
55
+ end
56
+
57
+ sig { override.void }
58
+ def decorate
59
+ operations = FragmentGraphQl.operations
60
+ return if operations.empty?
61
+
62
+ root.create_module(RESPONSES_NAMESPACE) do |namespace|
63
+ operations.keys.sort.each do |name|
64
+ declare_operation(namespace, name, T.must(operations[name]))
65
+ end
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ sig do
72
+ params(namespace: RBI::Scope, name: String,
73
+ operation: GraphQL::Language::Nodes::OperationDefinition).void
74
+ end
75
+ def declare_operation(namespace, name, operation)
76
+ root_type = operation.operation_type == 'mutation' ? schema.mutation : schema.query
77
+ return if root_type.nil?
78
+
79
+ namespace.create_class(name) do |response|
80
+ response.create_method('data', return_type: "T.nilable(#{RESPONSES_NAMESPACE}::#{name}::Data)")
81
+ response.create_method('errors', return_type: 'T.untyped')
82
+ response.create_method('original_hash', return_type: 'T::Hash[String, T.untyped]')
83
+ declare_object(response, 'Data', operation.selections, root_type)
84
+ end
85
+ end
86
+
87
+ # One nested class per selection level. `path` is its fully qualified name,
88
+ # which children extend so nested references resolve.
89
+ sig do
90
+ params(parent: RBI::Scope, class_name: String, selections: T::Array[T.untyped],
91
+ graphql_type: T.untyped).void
92
+ end
93
+ def declare_object(parent, class_name, selections, graphql_type)
94
+ fields = collect_fields(selections, graphql_type)
95
+
96
+ parent.create_class(class_name) do |scope|
97
+ fields.each do |field|
98
+ scope.create_method(field.fetch(:method), return_type: field.fetch(:type),
99
+ comments: field.fetch(:comments))
100
+ nested = field[:nested]
101
+ next unless nested
102
+
103
+ declare_object(scope, nested.fetch(:class_name), nested.fetch(:selections),
104
+ nested.fetch(:type))
105
+ end
106
+ end
107
+ end
108
+
109
+ # Flatten a selection set into field descriptors, following inline fragments.
110
+ #
111
+ # A union or interface selection contributes every branch's fields, each
112
+ # made nilable: graphql-client returns one object whatever the `__typename`,
113
+ # and only the branch that matched carries values.
114
+ sig do
115
+ params(selections: T::Array[T.untyped], graphql_type: T.untyped, force_nilable: T::Boolean,
116
+ taken: T::Set[String])
117
+ .returns(T::Array[T::Hash[Symbol, T.untyped]])
118
+ end
119
+ def collect_fields(selections, graphql_type, force_nilable: false, taken: Set.new)
120
+ selections.flat_map do |selection|
121
+ case selection
122
+ when GraphQL::Language::Nodes::Field
123
+ field = describe_field(selection, graphql_type, force_nilable, taken)
124
+ field ? [field] : []
125
+ when GraphQL::Language::Nodes::InlineFragment
126
+ branch = selection.type ? lookup_type(selection.type.name) : graphql_type
127
+ next [] if branch.nil?
128
+
129
+ collect_fields(selection.selections, branch, force_nilable: true, taken: taken)
130
+ else
131
+ []
132
+ end
133
+ end
134
+ end
135
+
136
+ sig do
137
+ params(selection: GraphQL::Language::Nodes::Field, graphql_type: T.untyped,
138
+ force_nilable: T::Boolean, taken: T::Set[String])
139
+ .returns(T.nilable(T::Hash[Symbol, T.untyped]))
140
+ end
141
+ def describe_field(selection, graphql_type, force_nilable, taken)
142
+ name = selection.alias || selection.name
143
+ method = ActiveSupport::Inflector.underscore(name)
144
+ return nil if taken.include?(method)
145
+
146
+ taken << method
147
+ return { method: method, type: '::String', comments: [], nested: nil } if name == '__typename'
148
+
149
+ field = graphql_type.respond_to?(:fields) ? graphql_type.fields[selection.name] : nil
150
+ return { method: method, type: 'T.untyped', comments: [], nested: nil } if field.nil?
151
+
152
+ build_field(selection, field, method, name, force_nilable, taken)
153
+ end
154
+
155
+ sig do
156
+ params(selection: GraphQL::Language::Nodes::Field, field: T.untyped, method: String,
157
+ name: String, force_nilable: T::Boolean, taken: T::Set[String])
158
+ .returns(T::Hash[Symbol, T.untyped])
159
+ end
160
+ def build_field(selection, field, method, name, force_nilable, taken)
161
+ unwrapped = field.type.unwrap
162
+ nilable = force_nilable || !field.type.non_null?
163
+
164
+ if selection.selections.empty?
165
+ return { method: method, type: wrap(scalar_type(unwrapped), field.type, nilable),
166
+ comments: [comment("`#{name}`: #{field.type.to_type_signature}")], nested: nil }
167
+ end
168
+
169
+ class_name = ActiveSupport::Inflector.camelize(method)
170
+ class_name = "#{class_name}Object" if taken.include?("class:#{class_name}")
171
+ taken << "class:#{class_name}"
172
+
173
+ { method: method, type: wrap(class_name, field.type, nilable),
174
+ comments: [comment("`#{name}`: #{field.type.to_type_signature}")],
175
+ nested: { class_name: class_name, selections: selection.selections,
176
+ type: unwrapped } }
177
+ end
178
+
179
+ # Apply the GraphQL type's list and nullability wrappers to `inner`.
180
+ #
181
+ # `[Ledger!]!` becomes `T::Array[X]`, `[Ledger]` becomes
182
+ # `T::Array[T.nilable(X)]`. `T.untyped` already includes nil, so it is never
183
+ # wrapped again.
184
+ sig { params(inner: String, graphql_type: T.untyped, nilable: T::Boolean).returns(String) }
185
+ def wrap(inner, graphql_type, nilable)
186
+ bare = graphql_type.non_null? ? graphql_type.of_type : graphql_type
187
+ type = if bare.list?
188
+ "T::Array[#{nilable_unless(inner, bare.of_type.non_null?)}]"
189
+ else
190
+ inner
191
+ end
192
+ nilable_unless(type, !nilable)
193
+ end
194
+
195
+ sig { params(type: String, non_null: T::Boolean).returns(String) }
196
+ def nilable_unless(type, non_null)
197
+ non_null || type == 'T.untyped' ? type : "T.nilable(#{type})"
198
+ end
199
+
200
+ sig { params(unwrapped: T.untyped).returns(String) }
201
+ def scalar_type(unwrapped)
202
+ Helpers::GraphqlSorbetTypes.translate(unwrapped.graphql_name, scalars: SCALARS)
203
+ end
204
+
205
+ sig { params(name: String).returns(T.untyped) }
206
+ def lookup_type(name)
207
+ schema.types[name]
208
+ end
209
+
210
+ sig { returns(T.untyped) }
211
+ def schema
212
+ FragmentGraphQl::FragmentSchema
213
+ end
214
+
215
+ sig { params(text: String).returns(RBI::Comment) }
216
+ def comment(text)
217
+ RBI::Comment.new(text)
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,169 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ return unless defined?(Tapioca::Dsl::Compilers)
5
+
6
+ require 'fragment_client'
7
+ require 'tapioca/dsl/helpers/graphql_sorbet_types'
8
+
9
+ module Tapioca
10
+ module Dsl
11
+ module Compilers
12
+ # Declares the typed payload classes {FragmentClient::TypedEntries.load}
13
+ # builds at load time, giving each a real `initialize` signature and typed
14
+ # readers.
15
+ #
16
+ # `bundle exec tapioca dsl` writes them to
17
+ # `sorbet/rbi/dsl/fragment_client/entries/`. The classes are only visible if
18
+ # whatever calls `TypedEntries.load` runs when Tapioca loads the application;
19
+ # it needs no credentials, so an initializer works.
20
+ class FragmentTypedEntries < Compiler
21
+ extend T::Sig
22
+
23
+ ConstantType = type_member { { fixed: T.class_of(FragmentClient::TypedLedgerEntry) } }
24
+
25
+ # Sorbet types for the scalars a Fragment Schema binds parameters to.
26
+ # Anything absent -- enums, input objects, newer scalars -- falls through to
27
+ # `T.untyped` rather than a guess that could reject a valid call.
28
+ GRAPHQL_SCALARS = T.let(
29
+ {
30
+ 'String' => '::String',
31
+ 'ID' => '::String',
32
+ 'SafeString' => '::String',
33
+ 'ParameterizedString' => '::String',
34
+ # ISO 8601 strings, not Ruby date objects.
35
+ 'Date' => '::String',
36
+ 'DateTime' => '::String',
37
+ 'FirstMoment' => '::String',
38
+ 'LastMoment' => '::String',
39
+ 'Period' => '::String',
40
+ 'PeriodFilter' => '::String',
41
+ 'UTCOffset' => '::String',
42
+ # Fragment's big integers are strings on the wire.
43
+ 'Int96' => '::String',
44
+ 'Int' => '::Integer',
45
+ 'Float' => '::Float',
46
+ 'Boolean' => 'T::Boolean',
47
+ 'JSON' => 'T.untyped',
48
+ 'Parameters' => 'T.untyped'
49
+ }.freeze,
50
+ T::Hash[String, String]
51
+ )
52
+
53
+ # The optional common fields, in the order the base class declares them.
54
+ COMMON_OPTIONAL_FIELDS = T.let(
55
+ {
56
+ posted: '::String',
57
+ description: '::String',
58
+ tags: 'T::Array[T.untyped]',
59
+ groups: 'T::Array[T.untyped]',
60
+ conditions: 'T::Array[T.untyped]'
61
+ }.freeze,
62
+ T::Hash[Symbol, String]
63
+ )
64
+
65
+ class << self
66
+ extend T::Sig
67
+
68
+ sig { override.returns(T::Enumerable[Module]) }
69
+ def gather_constants
70
+ # `name_of` is nil for a payload in an anonymous namespace, whose name
71
+ # (`#<Module:0x...>::AuthCaptureV1`) Sorbet cannot resolve anyway.
72
+ descendants_of(FragmentClient::TypedLedgerEntry).select { |klass| name_of(klass) }
73
+ end
74
+ end
75
+
76
+ sig { override.void }
77
+ def decorate
78
+ spec = constant.spec
79
+
80
+ # `create_path` would omit the superclass, leaving Sorbet unaware that a
81
+ # payload inherits `to_entry_input`, `set?` and the common-field readers.
82
+ root.create_class(constant.to_s,
83
+ superclass_name: '::FragmentClient::TypedLedgerEntry') do |payload|
84
+ payload.create_method('initialize', parameters: initialize_parameters(spec),
85
+ return_type: 'void')
86
+
87
+ spec.parameters.each do |parameter|
88
+ payload.create_method(parameter.name.to_s, return_type: reader_type(parameter),
89
+ comments: parameter_comments(parameter))
90
+ end
91
+
92
+ payload.create_method('entry_type', return_type: '::String', class_method: true,
93
+ comments: [comment(spec.entry_type.inspect)])
94
+ payload.create_method('type_version', return_type: '::Integer', class_method: true,
95
+ comments: [comment(spec.type_version.to_s)])
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ sig do
102
+ params(spec: FragmentClient::TypedEntries::EntrySpec)
103
+ .returns(T::Array[RBI::TypedParam])
104
+ end
105
+ def initialize_parameters(spec)
106
+ required, optional = spec.parameters.partition(&:required)
107
+
108
+ # Required before optional: Sorbet rejects a `sig` that interleaves them,
109
+ # though Ruby permits it for keyword arguments. Source order survives
110
+ # within each group and on the wire, which is where spec 2.4 applies.
111
+ parameters = [
112
+ create_kw_param('ik', type: '::String'),
113
+ create_kw_param('ledger_ik', type: '::String')
114
+ ]
115
+ required.each do |parameter|
116
+ parameters << create_kw_param(parameter.name.to_s, type: sorbet_type(parameter))
117
+ end
118
+ optional.each do |parameter|
119
+ parameters << create_kw_opt_param(parameter.name.to_s, type: optional_type(parameter),
120
+ default: 'T.unsafe(nil)')
121
+ end
122
+ COMMON_OPTIONAL_FIELDS.each do |name, type|
123
+ parameters << create_kw_opt_param(name.to_s, type: nilable(type),
124
+ default: 'T.unsafe(nil)')
125
+ end
126
+
127
+ parameters
128
+ end
129
+
130
+ sig { params(parameter: FragmentClient::TypedEntries::Parameter).returns(String) }
131
+ def reader_type(parameter)
132
+ parameter.required ? sorbet_type(parameter) : optional_type(parameter)
133
+ end
134
+
135
+ sig { params(parameter: FragmentClient::TypedEntries::Parameter).returns(String) }
136
+ def optional_type(parameter)
137
+ nilable(sorbet_type(parameter))
138
+ end
139
+
140
+ # Optional fields are plainly nilable: the unset sentinel is private to the
141
+ # constructor and never reaches a caller.
142
+ sig { params(type: String).returns(String) }
143
+ def nilable(type)
144
+ type == 'T.untyped' ? 'T.untyped' : "T.nilable(#{type})"
145
+ end
146
+
147
+ sig { params(parameter: FragmentClient::TypedEntries::Parameter).returns(String) }
148
+ def sorbet_type(parameter)
149
+ Helpers::GraphqlSorbetTypes.translate(parameter.graphql_type, scalars: GRAPHQL_SCALARS)
150
+ end
151
+
152
+ sig { params(parameter: FragmentClient::TypedEntries::Parameter).returns(T::Array[RBI::Comment]) }
153
+ def parameter_comments(parameter)
154
+ text = "Schema parameter `#{parameter.wire_name}` (`#{parameter.graphql_type}`)."
155
+ if parameter.escaped?
156
+ text += " Exposed as `#{parameter.name}` because `#{parameter.wire_name}` is " \
157
+ 'already taken; the wire name is unchanged.'
158
+ end
159
+ [comment(text)]
160
+ end
161
+
162
+ sig { params(text: String).returns(RBI::Comment) }
163
+ def comment(text)
164
+ RBI::Comment.new(text)
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,53 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Tapioca
5
+ module Dsl
6
+ module Helpers
7
+ # Translates a GraphQL type expression, as written in a document, into a
8
+ # Sorbet type string.
9
+ #
10
+ # Nothing here knows about Fragment: the scalar table is supplied by the
11
+ # caller, and anything absent from it becomes `T.untyped`. Kept separate so
12
+ # it can move to a gem of its own if a second consumer appears -- no gem
13
+ # does this today, and Tapioca ships no GraphQL compiler.
14
+ #
15
+ # translate('[SafeString!]!', scalars: { 'SafeString' => '::String' })
16
+ # #=> "T::Array[::String]"
17
+ # translate('[String]', scalars: { 'String' => '::String' })
18
+ # #=> "T::Array[T.nilable(::String)]"
19
+ #
20
+ # Top-level nullability is the caller's to apply, since only the caller knows
21
+ # whether an optional field is nilable, defaulted, or something else.
22
+ # Nullability inside a list is applied here, having nowhere else to go.
23
+ module GraphqlSorbetTypes
24
+ extend T::Sig
25
+
26
+ UNTYPED = 'T.untyped'
27
+
28
+ class << self
29
+ extend T::Sig
30
+
31
+ sig { params(graphql_type: String, scalars: T::Hash[String, String]).returns(String) }
32
+ def translate(graphql_type, scalars:)
33
+ type = graphql_type.delete_suffix('!')
34
+ return scalars.fetch(type, UNTYPED) unless type.start_with?('[')
35
+
36
+ element = type.delete_prefix('[').delete_suffix(']')
37
+ "T::Array[#{list_element(element, scalars: scalars)}]"
38
+ end
39
+
40
+ private
41
+
42
+ sig { params(element: String, scalars: T::Hash[String, String]).returns(String) }
43
+ def list_element(element, scalars:)
44
+ type = translate(element, scalars: scalars)
45
+ return type if element.end_with?('!') || type == UNTYPED
46
+
47
+ "T.nilable(#{type})"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fragment-dev
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - fragment
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-08 00:00:00.000000000 Z
11
+ date: 2026-08-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: graphql
@@ -67,8 +67,15 @@ files:
67
67
  - lib/fragment.schema.json
68
68
  - lib/fragment_client.rb
69
69
  - lib/fragment_client.rbi
70
+ - lib/fragment_client/graphql_ast.rb
71
+ - lib/fragment_client/typed_entries.rb
72
+ - lib/fragment_client/typed_ledger_entry.rb
70
73
  - lib/fragment_client/version.rb
71
74
  - lib/queries.graphql
75
+ - lib/tapioca/dsl/compilers/fragment_query_methods.rb
76
+ - lib/tapioca/dsl/compilers/fragment_response_types.rb
77
+ - lib/tapioca/dsl/compilers/fragment_typed_entries.rb
78
+ - lib/tapioca/dsl/helpers/graphql_sorbet_types.rb
72
79
  homepage: https://fragment.dev
73
80
  licenses:
74
81
  - Apache-2.0