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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43ddb7daa0a71fd29538497f46e1387cb8acaed0c6609db13b1a2462f6198ade
4
- data.tar.gz: 761ceeff217c275682aa53f1d5a7f2e30a199327e936687eef69413d762e0316
3
+ metadata.gz: 72ab361453b9a6f60b1d62f9450dbab31a94d4ef69af9fb51c3e3047035d8ce9
4
+ data.tar.gz: 7c8d983c70fb05f14a4da70d9b743c61b652efd825b5152c9d85a4ea859e2826
5
5
  SHA512:
6
- metadata.gz: d636d691b826f1234b87336c94be4418bfb113d055486d25b41e41426e3c0708b333665cb8c4ca14afe9b64a29b208d239742f895ccefb11e0f8022f52ca51d8
7
- data.tar.gz: fc03efefcd6eb2647a3790e01e31bbc0944217f411f2f39945c8f96eab1f33136cf6b03ca0aa1a453f3f8a4ce06ffa3ff9530903c48df88967fc1d4fe8e395de
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -37,6 +37,7 @@ require_relative "tina4/wsdl"
37
37
  require_relative "tina4/scss_compiler"
38
38
  require_relative "tina4/dev_reload"
39
39
  require_relative "tina4/localization"
40
+ require_relative "tina4/graphql"
40
41
  require_relative "tina4/testing"
41
42
 
42
43
  module Tina4
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.2.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