tina4 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/tina4/graphql.rb +837 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72ab361453b9a6f60b1d62f9450dbab31a94d4ef69af9fb51c3e3047035d8ce9
|
|
4
|
+
data.tar.gz: 7c8d983c70fb05f14a4da70d9b743c61b652efd825b5152c9d85a4ea859e2826
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dbe3231942c281359623df045a34ca3e0687e9383c2bc678914f30dd5075190b7b7e1531752531b04b72878e625665add006e55e0beb5a2c4d153582a8f4f2d7
|
|
7
|
+
data.tar.gz: 91e996e88bdbda88b4d6b7791f4fdea9af0e633f941b4f231474df1400d5882c10c3039fa9b62d18d31654d7b1ee4fae9f1fea14bc12433fb31c37854abd26a3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-03-14
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Zero-dependency GraphQL implementation (matching tina4php-graphql)
|
|
7
|
+
- Recursive descent GraphQL parser (queries, mutations, fragments, variables, aliases)
|
|
8
|
+
- Depth-first AST executor with resolver pattern
|
|
9
|
+
- GraphQL schema with programmatic type registration
|
|
10
|
+
- ORM auto-schema generation (`schema.from_orm(User)`) — auto-creates CRUD queries/mutations
|
|
11
|
+
- GraphiQL UI served at GET /graphql
|
|
12
|
+
- Route integration via `gql.register_route("/graphql")`
|
|
13
|
+
- Full GraphQL type system (scalars, objects, lists, non-null, input objects)
|
|
14
|
+
|
|
3
15
|
## [0.2.0] - 2026-03-14
|
|
4
16
|
|
|
5
17
|
### Added
|
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
# frozen_string_literal: false
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
# Lightweight, zero-dependency GraphQL implementation for Tina4 Ruby.
|
|
7
|
+
# Mirrors the tina4php-graphql approach: custom recursive-descent parser,
|
|
8
|
+
# depth-first executor, programmatic schema, and ORM auto-schema generation.
|
|
9
|
+
|
|
10
|
+
# ─── Type System ──────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
class GraphQLType
|
|
13
|
+
SCALARS = %w[String Int Float Boolean ID].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :name, :kind, :fields, :description
|
|
16
|
+
|
|
17
|
+
# kind: :scalar, :object, :list, :non_null, :input_object, :enum
|
|
18
|
+
def initialize(name, kind = :object, fields: {}, of_type: nil, description: nil)
|
|
19
|
+
@name = name
|
|
20
|
+
@kind = kind.to_sym
|
|
21
|
+
@fields = fields # { field_name => { type:, args:, resolve:, description: } }
|
|
22
|
+
@of_type = of_type # wrapped type for list / non_null
|
|
23
|
+
@description = description
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def scalar?
|
|
27
|
+
@kind == :scalar || SCALARS.include?(@name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def list?
|
|
31
|
+
@kind == :list
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def non_null?
|
|
35
|
+
@kind == :non_null
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def of_type
|
|
39
|
+
@of_type
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Parse a type string like "String", "String!", "[String]", "[Int!]!"
|
|
43
|
+
def self.parse(type_str)
|
|
44
|
+
type_str = type_str.to_s.strip
|
|
45
|
+
if type_str.end_with?("!")
|
|
46
|
+
inner = parse(type_str[0..-2])
|
|
47
|
+
new(type_str, :non_null, of_type: inner)
|
|
48
|
+
elsif type_str.start_with?("[") && type_str.end_with?("]")
|
|
49
|
+
inner = parse(type_str[1..-2])
|
|
50
|
+
new(type_str, :list, of_type: inner)
|
|
51
|
+
elsif SCALARS.include?(type_str)
|
|
52
|
+
new(type_str, :scalar)
|
|
53
|
+
else
|
|
54
|
+
new(type_str, :object)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ─── Schema ───────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
class GraphQLSchema
|
|
62
|
+
attr_reader :types, :queries, :mutations
|
|
63
|
+
|
|
64
|
+
def initialize
|
|
65
|
+
@types = {}
|
|
66
|
+
@queries = {} # name => { type:, args:, resolve:, description: }
|
|
67
|
+
@mutations = {}
|
|
68
|
+
register_scalars
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def add_type(type)
|
|
72
|
+
@types[type.name] = type
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def get_type(name)
|
|
76
|
+
@types[name]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Register a query field
|
|
80
|
+
# schema.add_query("user", type: "User", args: { id: { type: "ID!" } }) { |root, args, ctx| ... }
|
|
81
|
+
def add_query(name, type:, args: {}, description: nil, &resolve)
|
|
82
|
+
@queries[name] = { type: type, args: args, resolve: resolve, description: description }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Register a mutation field
|
|
86
|
+
def add_mutation(name, type:, args: {}, description: nil, &resolve)
|
|
87
|
+
@mutations[name] = { type: type, args: args, resolve: resolve, description: description }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# ── ORM Auto-Schema ──────────────────────────────────────────────────
|
|
91
|
+
# Generates GraphQL types + CRUD queries/mutations from a Tina4::ORM subclass.
|
|
92
|
+
#
|
|
93
|
+
# schema.from_orm(User)
|
|
94
|
+
#
|
|
95
|
+
# Creates:
|
|
96
|
+
# Query: user(id), users(limit, offset)
|
|
97
|
+
# Mutation: createUser(input), updateUser(id, input), deleteUser(id)
|
|
98
|
+
def from_orm(klass)
|
|
99
|
+
model_name = klass.name.split("::").last
|
|
100
|
+
type_name = model_name
|
|
101
|
+
table_lower = model_name.gsub(/([A-Z])/, '_\1').sub(/\A_/, "").downcase
|
|
102
|
+
plural = "#{table_lower}s"
|
|
103
|
+
|
|
104
|
+
# Build GraphQL object type from ORM field definitions
|
|
105
|
+
gql_fields = {}
|
|
106
|
+
pk_field = nil
|
|
107
|
+
|
|
108
|
+
if klass.respond_to?(:field_definitions)
|
|
109
|
+
klass.field_definitions.each do |fname, fdef|
|
|
110
|
+
gql_type = ruby_field_to_gql(fdef[:type] || :string)
|
|
111
|
+
gql_fields[fname.to_s] = { type: gql_type }
|
|
112
|
+
pk_field = fname.to_s if fdef[:primary_key]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
pk_field ||= "id"
|
|
117
|
+
gql_fields[pk_field] ||= { type: "ID" }
|
|
118
|
+
|
|
119
|
+
obj_type = GraphQLType.new(type_name, :object, fields: gql_fields)
|
|
120
|
+
add_type(obj_type)
|
|
121
|
+
|
|
122
|
+
# Input type for create/update
|
|
123
|
+
input_fields = gql_fields.reject { |k, _| k == pk_field }
|
|
124
|
+
input_type = GraphQLType.new("#{type_name}Input", :input_object, fields: input_fields)
|
|
125
|
+
add_type(input_type)
|
|
126
|
+
|
|
127
|
+
# ── Queries ──
|
|
128
|
+
|
|
129
|
+
# Single record: user(id: ID!): User
|
|
130
|
+
add_query(table_lower, type: type_name,
|
|
131
|
+
args: { pk_field => { type: "ID!" } },
|
|
132
|
+
description: "Fetch a single #{model_name} by #{pk_field}") do |_root, args, _ctx|
|
|
133
|
+
record = klass.find(args[pk_field])
|
|
134
|
+
record&.to_hash
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# List: users(limit: Int, offset: Int): [User]
|
|
138
|
+
add_query(plural, type: "[#{type_name}]",
|
|
139
|
+
args: { "limit" => { type: "Int" }, "offset" => { type: "Int" } },
|
|
140
|
+
description: "Fetch a list of #{model_name} records") do |_root, args, _ctx|
|
|
141
|
+
limit = args["limit"] || 100
|
|
142
|
+
offset = args["offset"] || 0
|
|
143
|
+
result = klass.all(limit: limit, offset: offset)
|
|
144
|
+
result.respond_to?(:to_array) ? result.to_array : Array(result).map { |r| r.respond_to?(:to_hash) ? r.to_hash : r }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# ── Mutations ──
|
|
148
|
+
|
|
149
|
+
# Create
|
|
150
|
+
add_mutation("create#{model_name}", type: type_name,
|
|
151
|
+
args: { "input" => { type: "#{type_name}Input!" } },
|
|
152
|
+
description: "Create a new #{model_name}") do |_root, args, _ctx|
|
|
153
|
+
record = klass.create(args["input"] || {})
|
|
154
|
+
record.respond_to?(:to_hash) ? record.to_hash : record
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Update
|
|
158
|
+
add_mutation("update#{model_name}", type: type_name,
|
|
159
|
+
args: { pk_field => { type: "ID!" }, "input" => { type: "#{type_name}Input!" } },
|
|
160
|
+
description: "Update an existing #{model_name}") do |_root, args, _ctx|
|
|
161
|
+
record = klass.find(args[pk_field])
|
|
162
|
+
return nil unless record
|
|
163
|
+
(args["input"] || {}).each { |k, v| record.send(:"#{k}=", v) if record.respond_to?(:"#{k}=") }
|
|
164
|
+
record.save
|
|
165
|
+
record.to_hash
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Delete
|
|
169
|
+
add_mutation("delete#{model_name}", type: "Boolean",
|
|
170
|
+
args: { pk_field => { type: "ID!" } },
|
|
171
|
+
description: "Delete a #{model_name} by #{pk_field}") do |_root, args, _ctx|
|
|
172
|
+
record = klass.find(args[pk_field])
|
|
173
|
+
return false unless record
|
|
174
|
+
record.delete
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def register_scalars
|
|
182
|
+
GraphQLType::SCALARS.each do |s|
|
|
183
|
+
@types[s] = GraphQLType.new(s, :scalar)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def ruby_field_to_gql(field_type)
|
|
188
|
+
case field_type.to_s.downcase
|
|
189
|
+
when "integer", "int" then "Int"
|
|
190
|
+
when "float", "double", "decimal", "numeric" then "Float"
|
|
191
|
+
when "boolean", "bool" then "Boolean"
|
|
192
|
+
when "string", "text", "varchar" then "String"
|
|
193
|
+
when "datetime", "date", "timestamp" then "String"
|
|
194
|
+
when "blob", "binary" then "String"
|
|
195
|
+
when "json", "jsonb" then "String"
|
|
196
|
+
else "String"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# ─── Parser (recursive descent) ──────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
class GraphQLParser
|
|
204
|
+
Token = Struct.new(:type, :value, :pos)
|
|
205
|
+
|
|
206
|
+
KEYWORDS = %w[query mutation fragment on true false null].freeze
|
|
207
|
+
|
|
208
|
+
def initialize(source)
|
|
209
|
+
@source = source
|
|
210
|
+
@tokens = tokenize(source)
|
|
211
|
+
@pos = 0
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def parse
|
|
215
|
+
document = { kind: :document, definitions: [] }
|
|
216
|
+
while current
|
|
217
|
+
skip(:comma)
|
|
218
|
+
break unless current
|
|
219
|
+
document[:definitions] << parse_definition
|
|
220
|
+
end
|
|
221
|
+
document
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
# ── Tokenizer ──
|
|
227
|
+
|
|
228
|
+
def tokenize(src)
|
|
229
|
+
tokens = []
|
|
230
|
+
i = 0
|
|
231
|
+
while i < src.length
|
|
232
|
+
ch = src[i]
|
|
233
|
+
|
|
234
|
+
# Skip whitespace
|
|
235
|
+
if ch =~ /\s/
|
|
236
|
+
i += 1
|
|
237
|
+
next
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Skip comments
|
|
241
|
+
if ch == "#"
|
|
242
|
+
i += 1 while i < src.length && src[i] != "\n"
|
|
243
|
+
next
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Punctuation
|
|
247
|
+
if "{}()[]!:=@$,".include?(ch)
|
|
248
|
+
tokens << Token.new(:punct, ch, i)
|
|
249
|
+
i += 1
|
|
250
|
+
next
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Spread operator
|
|
254
|
+
if ch == "." && src[i + 1] == "." && src[i + 2] == "."
|
|
255
|
+
tokens << Token.new(:spread, "...", i)
|
|
256
|
+
i += 3
|
|
257
|
+
next
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# String
|
|
261
|
+
if ch == '"'
|
|
262
|
+
str, i = read_string(src, i)
|
|
263
|
+
tokens << Token.new(:string, str, i)
|
|
264
|
+
next
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Number
|
|
268
|
+
if ch =~ /[\d\-]/
|
|
269
|
+
num, i = read_number(src, i)
|
|
270
|
+
tokens << Token.new(:number, num, i)
|
|
271
|
+
next
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Name / keyword
|
|
275
|
+
if ch =~ /[a-zA-Z_]/
|
|
276
|
+
name = ""
|
|
277
|
+
while i < src.length && src[i] =~ /[a-zA-Z0-9_]/
|
|
278
|
+
name << src[i]
|
|
279
|
+
i += 1
|
|
280
|
+
end
|
|
281
|
+
type = KEYWORDS.include?(name) ? :keyword : :name
|
|
282
|
+
tokens << Token.new(type, name, i - name.length)
|
|
283
|
+
next
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
i += 1 # skip unknown
|
|
287
|
+
end
|
|
288
|
+
tokens
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def read_string(src, i)
|
|
292
|
+
i += 1 # skip opening quote
|
|
293
|
+
str = ""
|
|
294
|
+
while i < src.length && src[i] != '"'
|
|
295
|
+
if src[i] == "\\"
|
|
296
|
+
i += 1
|
|
297
|
+
case src[i]
|
|
298
|
+
when "n" then str << "\n"
|
|
299
|
+
when "t" then str << "\t"
|
|
300
|
+
when '"' then str << '"'
|
|
301
|
+
when "\\" then str << "\\"
|
|
302
|
+
else str << src[i].to_s
|
|
303
|
+
end
|
|
304
|
+
else
|
|
305
|
+
str << src[i]
|
|
306
|
+
end
|
|
307
|
+
i += 1
|
|
308
|
+
end
|
|
309
|
+
i += 1 # skip closing quote
|
|
310
|
+
[str, i]
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def read_number(src, i)
|
|
314
|
+
start = i
|
|
315
|
+
i += 1 if src[i] == "-"
|
|
316
|
+
i += 1 while i < src.length && src[i] =~ /[\d.eE+\-]/
|
|
317
|
+
[src[start...i], i]
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# ── Token helpers ──
|
|
321
|
+
|
|
322
|
+
def current
|
|
323
|
+
@tokens[@pos]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def peek(offset = 0)
|
|
327
|
+
@tokens[@pos + offset]
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def advance
|
|
331
|
+
tok = @tokens[@pos]
|
|
332
|
+
@pos += 1
|
|
333
|
+
tok
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def expect(type, value = nil)
|
|
337
|
+
tok = current
|
|
338
|
+
if tok.nil?
|
|
339
|
+
raise GraphQLError, "Unexpected end of query, expected #{type} #{value}"
|
|
340
|
+
end
|
|
341
|
+
if tok.type != type || (value && tok.value != value)
|
|
342
|
+
raise GraphQLError, "Expected #{type} '#{value}' at position #{tok.pos}, got #{tok.type} '#{tok.value}'"
|
|
343
|
+
end
|
|
344
|
+
advance
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def match(type, value = nil)
|
|
348
|
+
tok = current
|
|
349
|
+
return nil unless tok
|
|
350
|
+
return nil unless tok.type == type
|
|
351
|
+
return nil if value && tok.value != value
|
|
352
|
+
advance
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def skip(type, value = nil)
|
|
356
|
+
match(type, value) while current && current.type == type && (value.nil? || current.value == value)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# ── Parse rules ──
|
|
360
|
+
|
|
361
|
+
def parse_definition
|
|
362
|
+
tok = current
|
|
363
|
+
if tok.nil?
|
|
364
|
+
raise GraphQLError, "Unexpected end of input"
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
if tok.type == :keyword && tok.value == "fragment"
|
|
368
|
+
return parse_fragment
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
if tok.type == :keyword && (tok.value == "query" || tok.value == "mutation")
|
|
372
|
+
return parse_operation
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Shorthand query (just a selection set)
|
|
376
|
+
if tok.type == :punct && tok.value == "{"
|
|
377
|
+
return { kind: :operation, operation: :query, name: nil, variables: [], selection_set: parse_selection_set }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
raise GraphQLError, "Unexpected token '#{tok.value}' at position #{tok.pos}"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def parse_operation
|
|
384
|
+
op = advance.value.to_sym # :query or :mutation
|
|
385
|
+
name = match(:name)&.value
|
|
386
|
+
|
|
387
|
+
variables = []
|
|
388
|
+
if current&.value == "("
|
|
389
|
+
variables = parse_variable_definitions
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
selection_set = parse_selection_set
|
|
393
|
+
|
|
394
|
+
{ kind: :operation, operation: op, name: name, variables: variables, selection_set: selection_set }
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def parse_variable_definitions
|
|
398
|
+
expect(:punct, "(")
|
|
399
|
+
vars = []
|
|
400
|
+
until current&.value == ")"
|
|
401
|
+
skip(:comma)
|
|
402
|
+
break if current&.value == ")"
|
|
403
|
+
expect(:punct, "$")
|
|
404
|
+
vname = expect(:name).value
|
|
405
|
+
expect(:punct, ":")
|
|
406
|
+
vtype = parse_type_ref
|
|
407
|
+
default = nil
|
|
408
|
+
if match(:punct, "=")
|
|
409
|
+
default = parse_value
|
|
410
|
+
end
|
|
411
|
+
vars << { name: vname, type: vtype, default: default }
|
|
412
|
+
end
|
|
413
|
+
expect(:punct, ")")
|
|
414
|
+
vars
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def parse_type_ref
|
|
418
|
+
if match(:punct, "[")
|
|
419
|
+
inner = parse_type_ref
|
|
420
|
+
expect(:punct, "]")
|
|
421
|
+
type_str = "[#{inner}]"
|
|
422
|
+
else
|
|
423
|
+
type_str = expect(:name).value
|
|
424
|
+
end
|
|
425
|
+
type_str += "!" if match(:punct, "!")
|
|
426
|
+
type_str
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def parse_selection_set
|
|
430
|
+
expect(:punct, "{")
|
|
431
|
+
selections = []
|
|
432
|
+
until current&.value == "}"
|
|
433
|
+
skip(:comma)
|
|
434
|
+
break if current&.value == "}"
|
|
435
|
+
|
|
436
|
+
if current&.type == :spread
|
|
437
|
+
selections << parse_fragment_spread
|
|
438
|
+
else
|
|
439
|
+
selections << parse_field
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
expect(:punct, "}")
|
|
443
|
+
selections
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def parse_field
|
|
447
|
+
name_tok = expect(:name)
|
|
448
|
+
field_name = name_tok.value
|
|
449
|
+
alias_name = nil
|
|
450
|
+
|
|
451
|
+
# Check for alias: alias: fieldName
|
|
452
|
+
if current&.value == ":"
|
|
453
|
+
advance
|
|
454
|
+
alias_name = field_name
|
|
455
|
+
field_name = expect(:name).value
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
arguments = {}
|
|
459
|
+
if current&.value == "("
|
|
460
|
+
arguments = parse_arguments
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
selection_set = nil
|
|
464
|
+
if current&.value == "{"
|
|
465
|
+
selection_set = parse_selection_set
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
{ kind: :field, name: field_name, alias: alias_name, arguments: arguments, selection_set: selection_set }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def parse_arguments
|
|
472
|
+
expect(:punct, "(")
|
|
473
|
+
args = {}
|
|
474
|
+
until current&.value == ")"
|
|
475
|
+
skip(:comma)
|
|
476
|
+
break if current&.value == ")"
|
|
477
|
+
arg_name = expect(:name).value
|
|
478
|
+
expect(:punct, ":")
|
|
479
|
+
args[arg_name] = parse_value
|
|
480
|
+
end
|
|
481
|
+
expect(:punct, ")")
|
|
482
|
+
args
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def parse_value
|
|
486
|
+
tok = current
|
|
487
|
+
case tok.type
|
|
488
|
+
when :string
|
|
489
|
+
advance
|
|
490
|
+
tok.value
|
|
491
|
+
when :number
|
|
492
|
+
advance
|
|
493
|
+
tok.value.include?(".") ? tok.value.to_f : tok.value.to_i
|
|
494
|
+
when :keyword
|
|
495
|
+
advance
|
|
496
|
+
case tok.value
|
|
497
|
+
when "true" then true
|
|
498
|
+
when "false" then false
|
|
499
|
+
when "null" then nil
|
|
500
|
+
else tok.value
|
|
501
|
+
end
|
|
502
|
+
when :name
|
|
503
|
+
# Enum value
|
|
504
|
+
advance
|
|
505
|
+
tok.value
|
|
506
|
+
when :punct
|
|
507
|
+
if tok.value == "["
|
|
508
|
+
parse_list_value
|
|
509
|
+
elsif tok.value == "{"
|
|
510
|
+
parse_object_value
|
|
511
|
+
elsif tok.value == "$"
|
|
512
|
+
advance
|
|
513
|
+
{ kind: :variable, name: expect(:name).value }
|
|
514
|
+
else
|
|
515
|
+
raise GraphQLError, "Unexpected '#{tok.value}' in value at position #{tok.pos}"
|
|
516
|
+
end
|
|
517
|
+
else
|
|
518
|
+
raise GraphQLError, "Unexpected token type #{tok.type} at position #{tok.pos}"
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def parse_list_value
|
|
523
|
+
expect(:punct, "[")
|
|
524
|
+
items = []
|
|
525
|
+
until current&.value == "]"
|
|
526
|
+
skip(:comma)
|
|
527
|
+
break if current&.value == "]"
|
|
528
|
+
items << parse_value
|
|
529
|
+
end
|
|
530
|
+
expect(:punct, "]")
|
|
531
|
+
items
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def parse_object_value
|
|
535
|
+
expect(:punct, "{")
|
|
536
|
+
obj = {}
|
|
537
|
+
until current&.value == "}"
|
|
538
|
+
skip(:comma)
|
|
539
|
+
break if current&.value == "}"
|
|
540
|
+
key = expect(:name).value
|
|
541
|
+
expect(:punct, ":")
|
|
542
|
+
obj[key] = parse_value
|
|
543
|
+
end
|
|
544
|
+
expect(:punct, "}")
|
|
545
|
+
obj
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def parse_fragment_spread
|
|
549
|
+
expect(:spread)
|
|
550
|
+
if current&.type == :keyword && current&.value == "on"
|
|
551
|
+
# Inline fragment
|
|
552
|
+
advance
|
|
553
|
+
type_name = expect(:name).value
|
|
554
|
+
selection_set = parse_selection_set
|
|
555
|
+
{ kind: :inline_fragment, on: type_name, selection_set: selection_set }
|
|
556
|
+
else
|
|
557
|
+
name = expect(:name).value
|
|
558
|
+
{ kind: :fragment_spread, name: name }
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def parse_fragment
|
|
563
|
+
expect(:keyword, "fragment")
|
|
564
|
+
name = expect(:name).value
|
|
565
|
+
expect(:keyword, "on")
|
|
566
|
+
type_name = expect(:name).value
|
|
567
|
+
selection_set = parse_selection_set
|
|
568
|
+
{ kind: :fragment, name: name, on: type_name, selection_set: selection_set }
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# ─── Executor ─────────────────────────────────────────────────────────
|
|
573
|
+
|
|
574
|
+
class GraphQLExecutor
|
|
575
|
+
def initialize(schema)
|
|
576
|
+
@schema = schema
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def execute(document, variables: {}, context: {}, operation_name: nil)
|
|
580
|
+
# Collect fragments
|
|
581
|
+
fragments = {}
|
|
582
|
+
operations = []
|
|
583
|
+
|
|
584
|
+
document[:definitions].each do |defn|
|
|
585
|
+
case defn[:kind]
|
|
586
|
+
when :fragment
|
|
587
|
+
fragments[defn[:name]] = defn
|
|
588
|
+
when :operation
|
|
589
|
+
operations << defn
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
# Pick the operation
|
|
594
|
+
operation = if operation_name
|
|
595
|
+
operations.find { |op| op[:name] == operation_name }
|
|
596
|
+
elsif operations.length == 1
|
|
597
|
+
operations.first
|
|
598
|
+
else
|
|
599
|
+
raise GraphQLError, "Must provide operation name when multiple operations exist"
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
raise GraphQLError, "Unknown operation: #{operation_name}" unless operation
|
|
603
|
+
|
|
604
|
+
# Resolve variables
|
|
605
|
+
resolved_vars = resolve_variables(operation[:variables], variables)
|
|
606
|
+
|
|
607
|
+
# Choose root fields
|
|
608
|
+
root_fields = case operation[:operation]
|
|
609
|
+
when :query then @schema.queries
|
|
610
|
+
when :mutation then @schema.mutations
|
|
611
|
+
else raise GraphQLError, "Unsupported operation: #{operation[:operation]}"
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Execute selection set
|
|
615
|
+
data = {}
|
|
616
|
+
errors = []
|
|
617
|
+
|
|
618
|
+
operation[:selection_set].each do |selection|
|
|
619
|
+
resolve_selection(selection, root_fields, nil, resolved_vars, context, fragments, data, errors)
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
result = { "data" => data }
|
|
623
|
+
result["errors"] = errors unless errors.empty?
|
|
624
|
+
result
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
private
|
|
628
|
+
|
|
629
|
+
def resolve_selection(selection, fields, parent, variables, context, fragments, data, errors)
|
|
630
|
+
case selection[:kind]
|
|
631
|
+
when :field
|
|
632
|
+
resolve_field(selection, fields, parent, variables, context, fragments, data, errors)
|
|
633
|
+
when :fragment_spread
|
|
634
|
+
frag = fragments[selection[:name]]
|
|
635
|
+
if frag
|
|
636
|
+
frag[:selection_set].each do |sel|
|
|
637
|
+
resolve_selection(sel, fields, parent, variables, context, fragments, data, errors)
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
when :inline_fragment
|
|
641
|
+
selection[:selection_set].each do |sel|
|
|
642
|
+
resolve_selection(sel, fields, parent, variables, context, fragments, data, errors)
|
|
643
|
+
end
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def resolve_field(selection, fields, parent, variables, context, fragments, data, errors)
|
|
648
|
+
field_name = selection[:name]
|
|
649
|
+
output_name = selection[:alias] || field_name
|
|
650
|
+
|
|
651
|
+
# Resolve arguments (substitute variables)
|
|
652
|
+
args = resolve_args(selection[:arguments], variables)
|
|
653
|
+
|
|
654
|
+
field_def = fields[field_name]
|
|
655
|
+
|
|
656
|
+
begin
|
|
657
|
+
if field_def && field_def[:resolve]
|
|
658
|
+
value = field_def[:resolve].call(parent, args, context)
|
|
659
|
+
elsif parent.is_a?(Hash)
|
|
660
|
+
value = parent[field_name] || parent[field_name.to_sym]
|
|
661
|
+
else
|
|
662
|
+
value = nil
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# Recurse into nested selections
|
|
666
|
+
if selection[:selection_set] && value
|
|
667
|
+
if value.is_a?(Array)
|
|
668
|
+
data[output_name] = value.map do |item|
|
|
669
|
+
nested = {}
|
|
670
|
+
sub_fields = item.is_a?(Hash) ? item_fields(item) : {}
|
|
671
|
+
selection[:selection_set].each do |sel|
|
|
672
|
+
resolve_selection(sel, sub_fields, item, variables, context, fragments, nested, errors)
|
|
673
|
+
end
|
|
674
|
+
nested
|
|
675
|
+
end
|
|
676
|
+
elsif value.is_a?(Hash)
|
|
677
|
+
nested = {}
|
|
678
|
+
sub_fields = item_fields(value)
|
|
679
|
+
selection[:selection_set].each do |sel|
|
|
680
|
+
resolve_selection(sel, sub_fields, value, variables, context, fragments, nested, errors)
|
|
681
|
+
end
|
|
682
|
+
data[output_name] = nested
|
|
683
|
+
else
|
|
684
|
+
data[output_name] = value
|
|
685
|
+
end
|
|
686
|
+
else
|
|
687
|
+
data[output_name] = coerce_value(value)
|
|
688
|
+
end
|
|
689
|
+
rescue => e
|
|
690
|
+
errors << { "message" => e.message, "path" => [output_name] }
|
|
691
|
+
data[output_name] = nil
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# Build field resolvers from a hash (for nested object access)
|
|
696
|
+
def item_fields(hash)
|
|
697
|
+
result = {}
|
|
698
|
+
hash.each do |k, _v|
|
|
699
|
+
ks = k.to_s
|
|
700
|
+
result[ks] = { resolve: ->(_p, _a, _c) { hash[k] || hash[k.to_s] || hash[k.to_sym] } }
|
|
701
|
+
end
|
|
702
|
+
result
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def resolve_args(args, variables)
|
|
706
|
+
return {} unless args
|
|
707
|
+
resolved = {}
|
|
708
|
+
args.each do |key, val|
|
|
709
|
+
resolved[key] = resolve_value(val, variables)
|
|
710
|
+
end
|
|
711
|
+
resolved
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def resolve_value(val, variables)
|
|
715
|
+
if val.is_a?(Hash) && val[:kind] == :variable
|
|
716
|
+
variables[val[:name]]
|
|
717
|
+
elsif val.is_a?(Hash)
|
|
718
|
+
val.transform_values { |v| resolve_value(v, variables) }
|
|
719
|
+
elsif val.is_a?(Array)
|
|
720
|
+
val.map { |v| resolve_value(v, variables) }
|
|
721
|
+
else
|
|
722
|
+
val
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def resolve_variables(var_defs, provided)
|
|
727
|
+
result = {}
|
|
728
|
+
(var_defs || []).each do |vd|
|
|
729
|
+
name = vd[:name]
|
|
730
|
+
result[name] = provided.key?(name) ? provided[name] : vd[:default]
|
|
731
|
+
end
|
|
732
|
+
# Also include any extra provided variables
|
|
733
|
+
provided.each { |k, v| result[k.to_s] = v unless result.key?(k.to_s) }
|
|
734
|
+
result
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def coerce_value(val)
|
|
738
|
+
case val
|
|
739
|
+
when Time, Date then val.iso8601
|
|
740
|
+
when Symbol then val.to_s
|
|
741
|
+
else val
|
|
742
|
+
end
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
# ─── Error class ──────────────────────────────────────────────────────
|
|
747
|
+
|
|
748
|
+
class GraphQLError < StandardError; end
|
|
749
|
+
|
|
750
|
+
# ─── Main GraphQL class ──────────────────────────────────────────────
|
|
751
|
+
|
|
752
|
+
class GraphQL
|
|
753
|
+
attr_reader :schema
|
|
754
|
+
|
|
755
|
+
def initialize(schema = nil)
|
|
756
|
+
@schema = schema || GraphQLSchema.new
|
|
757
|
+
@executor = GraphQLExecutor.new(@schema)
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
# Execute a query string directly
|
|
761
|
+
def execute(query, variables: {}, context: {}, operation_name: nil)
|
|
762
|
+
parser = GraphQLParser.new(query)
|
|
763
|
+
document = parser.parse
|
|
764
|
+
@executor.execute(document, variables: variables, context: context, operation_name: operation_name)
|
|
765
|
+
rescue GraphQLError => e
|
|
766
|
+
{ "data" => nil, "errors" => [{ "message" => e.message }] }
|
|
767
|
+
rescue => e
|
|
768
|
+
{ "data" => nil, "errors" => [{ "message" => "Internal error: #{e.message}" }] }
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# Handle an HTTP request body (JSON string)
|
|
772
|
+
def handle_request(body, context: {})
|
|
773
|
+
payload = JSON.parse(body)
|
|
774
|
+
query = payload["query"] || ""
|
|
775
|
+
variables = payload["variables"] || {}
|
|
776
|
+
op_name = payload["operationName"]
|
|
777
|
+
|
|
778
|
+
execute(query, variables: variables, context: context, operation_name: op_name)
|
|
779
|
+
rescue JSON::ParserError
|
|
780
|
+
{ "data" => nil, "errors" => [{ "message" => "Invalid JSON in request body" }] }
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
# ── Route Registration ─────────────────────────────────────────────
|
|
784
|
+
# Register a POST /graphql route in the Tina4 router.
|
|
785
|
+
#
|
|
786
|
+
# gql = Tina4::GraphQL.new(schema)
|
|
787
|
+
# gql.register_route # POST /graphql
|
|
788
|
+
# gql.register_route("/api/graphql") # custom path
|
|
789
|
+
#
|
|
790
|
+
def register_route(path = "/graphql")
|
|
791
|
+
graphql = self
|
|
792
|
+
Tina4.post path, auth: false do |request, response|
|
|
793
|
+
body = request.body
|
|
794
|
+
result = graphql.handle_request(body, context: { request: request })
|
|
795
|
+
response.json(result)
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
# Optional: GET for GraphiQL/introspection
|
|
799
|
+
Tina4.get path, auth: false do |request, response|
|
|
800
|
+
query = request.params["query"]
|
|
801
|
+
if query
|
|
802
|
+
variables = request.params["variables"]
|
|
803
|
+
variables = JSON.parse(variables) if variables.is_a?(String) && !variables.empty?
|
|
804
|
+
result = graphql.execute(query, variables: variables || {}, context: { request: request })
|
|
805
|
+
response.json(result)
|
|
806
|
+
else
|
|
807
|
+
response.html(graphiql_html(path))
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
private
|
|
813
|
+
|
|
814
|
+
def graphiql_html(endpoint)
|
|
815
|
+
<<~HTML
|
|
816
|
+
<!DOCTYPE html>
|
|
817
|
+
<html>
|
|
818
|
+
<head>
|
|
819
|
+
<title>GraphiQL — Tina4 Ruby</title>
|
|
820
|
+
<link rel="stylesheet" href="https://unpkg.com/graphiql@3/graphiql.min.css" />
|
|
821
|
+
</head>
|
|
822
|
+
<body style="margin:0;height:100vh;">
|
|
823
|
+
<div id="graphiql" style="height:100vh;"></div>
|
|
824
|
+
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
825
|
+
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
826
|
+
<script crossorigin src="https://unpkg.com/graphiql@3/graphiql.min.js"></script>
|
|
827
|
+
<script>
|
|
828
|
+
const fetcher = GraphiQL.createFetcher({ url: '#{endpoint}' });
|
|
829
|
+
ReactDOM.createRoot(document.getElementById('graphiql'))
|
|
830
|
+
.render(React.createElement(GraphiQL, { fetcher }));
|
|
831
|
+
</script>
|
|
832
|
+
</body>
|
|
833
|
+
</html>
|
|
834
|
+
HTML
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
end
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
@@ -248,6 +248,7 @@ files:
|
|
|
248
248
|
- lib/tina4/drivers/sqlite_driver.rb
|
|
249
249
|
- lib/tina4/env.rb
|
|
250
250
|
- lib/tina4/field_types.rb
|
|
251
|
+
- lib/tina4/graphql.rb
|
|
251
252
|
- lib/tina4/localization.rb
|
|
252
253
|
- lib/tina4/middleware.rb
|
|
253
254
|
- lib/tina4/migration.rb
|