desiru 0.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.
- checksums.yaml +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +55 -0
- data/CLAUDE.md +22 -0
- data/Gemfile +36 -0
- data/Gemfile.lock +255 -0
- data/LICENSE +21 -0
- data/README.md +343 -0
- data/Rakefile +18 -0
- data/desiru.gemspec +44 -0
- data/examples/README.md +55 -0
- data/examples/async_processing.rb +135 -0
- data/examples/few_shot_learning.rb +66 -0
- data/examples/graphql_api.rb +190 -0
- data/examples/graphql_integration.rb +114 -0
- data/examples/rag_retrieval.rb +80 -0
- data/examples/simple_qa.rb +31 -0
- data/examples/typed_signatures.rb +45 -0
- data/lib/desiru/async_capable.rb +170 -0
- data/lib/desiru/cache.rb +116 -0
- data/lib/desiru/configuration.rb +40 -0
- data/lib/desiru/field.rb +171 -0
- data/lib/desiru/graphql/data_loader.rb +210 -0
- data/lib/desiru/graphql/executor.rb +115 -0
- data/lib/desiru/graphql/schema_generator.rb +301 -0
- data/lib/desiru/jobs/async_predict.rb +52 -0
- data/lib/desiru/jobs/base.rb +53 -0
- data/lib/desiru/jobs/batch_processor.rb +71 -0
- data/lib/desiru/jobs/optimizer_job.rb +45 -0
- data/lib/desiru/models/base.rb +112 -0
- data/lib/desiru/models/raix_adapter.rb +210 -0
- data/lib/desiru/module.rb +204 -0
- data/lib/desiru/modules/chain_of_thought.rb +106 -0
- data/lib/desiru/modules/predict.rb +142 -0
- data/lib/desiru/modules/retrieve.rb +199 -0
- data/lib/desiru/optimizers/base.rb +130 -0
- data/lib/desiru/optimizers/bootstrap_few_shot.rb +212 -0
- data/lib/desiru/program.rb +106 -0
- data/lib/desiru/registry.rb +74 -0
- data/lib/desiru/signature.rb +322 -0
- data/lib/desiru/version.rb +5 -0
- data/lib/desiru.rb +67 -0
- metadata +184 -0
data/lib/desiru/cache.rb
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Desiru
|
4
|
+
# Thread-safe in-memory cache with TTL support and LRU eviction
|
5
|
+
class Cache
|
6
|
+
Entry = Struct.new(:value, :expires_at, :accessed_at, keyword_init: true)
|
7
|
+
|
8
|
+
def initialize(max_size: 1000, cleanup_interval: 300)
|
9
|
+
@store = {}
|
10
|
+
@mutex = Mutex.new
|
11
|
+
@max_size = max_size
|
12
|
+
@cleanup_interval = cleanup_interval
|
13
|
+
@last_cleanup = Time.now
|
14
|
+
end
|
15
|
+
|
16
|
+
# Get a value from cache or set it using the provided block
|
17
|
+
def get_or_set(key, ttl: 3600)
|
18
|
+
@mutex.synchronize do
|
19
|
+
cleanup_if_needed
|
20
|
+
|
21
|
+
if (entry = @store[key])
|
22
|
+
if entry.expires_at > Time.now
|
23
|
+
entry.accessed_at = Time.now
|
24
|
+
return entry.value
|
25
|
+
else
|
26
|
+
@store.delete(key)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Evict LRU if at capacity
|
31
|
+
evict_lru if @store.size >= @max_size
|
32
|
+
|
33
|
+
value = yield
|
34
|
+
@store[key] = Entry.new(
|
35
|
+
value: value,
|
36
|
+
expires_at: Time.now + ttl,
|
37
|
+
accessed_at: Time.now
|
38
|
+
)
|
39
|
+
value
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Get a value from cache without setting
|
44
|
+
def get(key)
|
45
|
+
@mutex.synchronize do
|
46
|
+
if (entry = @store[key])
|
47
|
+
if entry.expires_at > Time.now
|
48
|
+
entry.accessed_at = Time.now
|
49
|
+
entry.value
|
50
|
+
else
|
51
|
+
@store.delete(key)
|
52
|
+
nil
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Set a value in cache
|
59
|
+
def set(key, value, ttl: 3600)
|
60
|
+
@mutex.synchronize do
|
61
|
+
cleanup_if_needed
|
62
|
+
evict_lru if @store.size >= @max_size && !@store.key?(key)
|
63
|
+
|
64
|
+
@store[key] = Entry.new(
|
65
|
+
value: value,
|
66
|
+
expires_at: Time.now + ttl,
|
67
|
+
accessed_at: Time.now
|
68
|
+
)
|
69
|
+
value
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# Delete a key from cache
|
74
|
+
def delete(key)
|
75
|
+
@mutex.synchronize do
|
76
|
+
@store.delete(key)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Clear all entries
|
81
|
+
def clear
|
82
|
+
@mutex.synchronize do
|
83
|
+
@store.clear
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Get the current size
|
88
|
+
def size
|
89
|
+
@mutex.synchronize do
|
90
|
+
@store.size
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# Manually trigger cleanup of expired entries
|
95
|
+
def cleanup_expired
|
96
|
+
@mutex.synchronize do
|
97
|
+
@store.delete_if { |_, entry| entry.expires_at <= Time.now }
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
def cleanup_if_needed
|
104
|
+
return unless Time.now - @last_cleanup > @cleanup_interval
|
105
|
+
|
106
|
+
@store.delete_if { |_, entry| entry.expires_at <= Time.now }
|
107
|
+
@last_cleanup = Time.now
|
108
|
+
end
|
109
|
+
|
110
|
+
def evict_lru
|
111
|
+
# Find least recently used entry
|
112
|
+
lru_key = @store.min_by { |_, entry| entry.accessed_at }&.first
|
113
|
+
@store.delete(lru_key) if lru_key
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Desiru
|
4
|
+
# Global configuration for Desiru
|
5
|
+
# Follows singleton pattern for service-oriented design
|
6
|
+
class Configuration
|
7
|
+
attr_accessor :default_model, :cache_enabled, :cache_ttl, :max_retries,
|
8
|
+
:retry_delay, :logger, :module_registry, :model_timeout,
|
9
|
+
:redis_url
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@default_model = nil
|
13
|
+
@cache_enabled = true
|
14
|
+
@cache_ttl = 3600 # 1 hour
|
15
|
+
@max_retries = 3
|
16
|
+
@retry_delay = 1
|
17
|
+
@logger = default_logger
|
18
|
+
@module_registry = Desiru::Registry.instance
|
19
|
+
@model_timeout = 30
|
20
|
+
@redis_url = nil # Defaults to REDIS_URL env var if not set
|
21
|
+
end
|
22
|
+
|
23
|
+
def validate!
|
24
|
+
raise ConfigurationError, 'default_model must be set' unless default_model
|
25
|
+
raise ConfigurationError, 'default_model must respond to :complete' unless default_model.respond_to?(:complete)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def default_logger
|
31
|
+
require 'logger'
|
32
|
+
Logger.new($stdout).tap do |logger|
|
33
|
+
logger.level = Logger::INFO
|
34
|
+
logger.formatter = proc do |severity, datetime, _progname, msg|
|
35
|
+
"[Desiru] #{datetime}: #{severity} -- #{msg}\n"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/desiru/field.rb
ADDED
@@ -0,0 +1,171 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Desiru
|
4
|
+
# Represents a field in a signature with type information and metadata
|
5
|
+
class Field
|
6
|
+
attr_reader :name, :type, :description, :optional, :default, :validator, :literal_values, :element_type
|
7
|
+
|
8
|
+
alias optional? optional
|
9
|
+
|
10
|
+
def initialize(name, type = :string, description: nil, optional: false, default: nil, validator: nil,
|
11
|
+
literal_values: nil, element_type: nil)
|
12
|
+
@name = name.to_sym
|
13
|
+
@type = normalize_type(type)
|
14
|
+
@description = description
|
15
|
+
@optional = optional
|
16
|
+
@default = default
|
17
|
+
@literal_values = literal_values&.map(&:freeze)&.freeze if literal_values
|
18
|
+
@element_type = element_type
|
19
|
+
@validator = validator || default_validator
|
20
|
+
end
|
21
|
+
|
22
|
+
def validate(value)
|
23
|
+
return true if optional && value.nil?
|
24
|
+
return true if value.nil? && !default.nil?
|
25
|
+
|
26
|
+
raise ValidationError, validation_error_message(value) unless validator.call(value)
|
27
|
+
|
28
|
+
true
|
29
|
+
end
|
30
|
+
|
31
|
+
def coerce(value)
|
32
|
+
return default if value.nil? && !default.nil?
|
33
|
+
return value if value.nil? && optional
|
34
|
+
|
35
|
+
case type
|
36
|
+
when :string
|
37
|
+
value.to_s
|
38
|
+
when :int, :integer
|
39
|
+
value.to_i
|
40
|
+
when :float
|
41
|
+
value.to_f
|
42
|
+
when :bool, :boolean
|
43
|
+
case value.to_s.downcase
|
44
|
+
when 'true', 'yes', '1', 't'
|
45
|
+
true
|
46
|
+
when 'false', 'no', '0', 'f'
|
47
|
+
false
|
48
|
+
else
|
49
|
+
!!value
|
50
|
+
end
|
51
|
+
when :literal
|
52
|
+
# For literal types, ensure the value is a string and matches one of the allowed values
|
53
|
+
coerced = value.to_s
|
54
|
+
unless literal_values.include?(coerced)
|
55
|
+
raise ValidationError, "Value '#{coerced}' is not one of allowed values: #{literal_values.join(', ')}"
|
56
|
+
end
|
57
|
+
|
58
|
+
coerced
|
59
|
+
when :list, :array
|
60
|
+
array_value = Array(value)
|
61
|
+
# If we have an element type, coerce each element
|
62
|
+
if element_type && element_type[:type] == :literal
|
63
|
+
array_value.map do |elem|
|
64
|
+
coerced_elem = elem.to_s
|
65
|
+
unless element_type[:literal_values].include?(coerced_elem)
|
66
|
+
raise ValidationError,
|
67
|
+
"Array element '#{coerced_elem}' is not one of allowed values: #{element_type[:literal_values].join(', ')}"
|
68
|
+
end
|
69
|
+
|
70
|
+
coerced_elem
|
71
|
+
end
|
72
|
+
else
|
73
|
+
array_value
|
74
|
+
end
|
75
|
+
when :hash, :dict
|
76
|
+
value.is_a?(Hash) ? value : {}
|
77
|
+
else
|
78
|
+
value
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def to_h
|
83
|
+
result = {
|
84
|
+
name: name,
|
85
|
+
type: type,
|
86
|
+
description: description,
|
87
|
+
optional: optional,
|
88
|
+
default: default
|
89
|
+
}
|
90
|
+
result[:literal_values] = literal_values if literal_values
|
91
|
+
result[:element_type] = element_type if element_type
|
92
|
+
result.compact
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
def validation_error_message(value)
|
98
|
+
case type
|
99
|
+
when :string
|
100
|
+
"#{name} must be a string, got #{value.class}"
|
101
|
+
when :int, :integer
|
102
|
+
"#{name} must be an integer, got #{value.class}"
|
103
|
+
when :float
|
104
|
+
"#{name} must be a float, got #{value.class}"
|
105
|
+
when :bool, :boolean
|
106
|
+
"#{name} must be a boolean (true/false), got #{value.class}"
|
107
|
+
when :list, :array
|
108
|
+
if element_type && element_type[:type] == :literal
|
109
|
+
"#{name} must be an array of literal values: #{element_type[:literal_values].join(', ')}"
|
110
|
+
else
|
111
|
+
"#{name} must be a list, got #{value.class}"
|
112
|
+
end
|
113
|
+
when :literal
|
114
|
+
"#{name} must be one of: #{literal_values.join(', ')}"
|
115
|
+
when :hash, :dict
|
116
|
+
"#{name} must be a hash, got #{value.class}"
|
117
|
+
else
|
118
|
+
"#{name} validation failed for value: #{value}"
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def normalize_type(type)
|
123
|
+
# If type is already a symbol, return it
|
124
|
+
return type if type.is_a?(Symbol)
|
125
|
+
|
126
|
+
case type.to_s.downcase
|
127
|
+
when 'str', 'string'
|
128
|
+
:string
|
129
|
+
when 'int', 'integer'
|
130
|
+
:int
|
131
|
+
when 'float', 'number', 'double'
|
132
|
+
:float
|
133
|
+
when 'bool', 'boolean'
|
134
|
+
:bool
|
135
|
+
when 'list', 'array'
|
136
|
+
:list
|
137
|
+
when 'hash', 'dict', 'dictionary'
|
138
|
+
:hash
|
139
|
+
when 'literal'
|
140
|
+
:literal
|
141
|
+
else
|
142
|
+
type.to_sym
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
def default_validator
|
147
|
+
case type
|
148
|
+
when :string
|
149
|
+
->(v) { v.is_a?(String) }
|
150
|
+
when :int
|
151
|
+
->(v) { v.is_a?(Integer) }
|
152
|
+
when :float
|
153
|
+
->(v) { v.is_a?(Float) || v.is_a?(Integer) }
|
154
|
+
when :bool
|
155
|
+
->(v) { v.is_a?(TrueClass) || v.is_a?(FalseClass) }
|
156
|
+
when :literal
|
157
|
+
->(v) { v.is_a?(String) && literal_values.include?(v) }
|
158
|
+
when :list
|
159
|
+
if element_type && element_type[:type] == :literal
|
160
|
+
->(v) { v.is_a?(Array) && v.all? { |elem| element_type[:literal_values].include?(elem.to_s) } }
|
161
|
+
else
|
162
|
+
->(v) { v.is_a?(Array) }
|
163
|
+
end
|
164
|
+
when :hash
|
165
|
+
->(v) { v.is_a?(Hash) }
|
166
|
+
else
|
167
|
+
->(_v) { true }
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
@@ -0,0 +1,210 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Desiru
|
4
|
+
module GraphQL
|
5
|
+
# DataLoader pattern implementation for batching Desiru module calls
|
6
|
+
# Prevents N+1 query problems when multiple GraphQL fields request similar data
|
7
|
+
class DataLoader
|
8
|
+
def initialize
|
9
|
+
@loaders = {}
|
10
|
+
@results_cache = {}
|
11
|
+
@pending_loads = Hash.new { |h, k| h[k] = [] }
|
12
|
+
end
|
13
|
+
|
14
|
+
# Get or create a loader for a specific module
|
15
|
+
def for(module_class, **options)
|
16
|
+
key = loader_key(module_class, options)
|
17
|
+
@loaders[key] ||= BatchLoader.new(module_class, **options)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Execute all pending loads in batch
|
21
|
+
def perform_loads
|
22
|
+
@pending_loads.each do |loader_key, batch|
|
23
|
+
next if batch.empty?
|
24
|
+
|
25
|
+
loader = @loaders[loader_key]
|
26
|
+
results = loader.load_batch(batch.map(&:first))
|
27
|
+
|
28
|
+
batch.each_with_index do |(_inputs, promise), idx|
|
29
|
+
promise.fulfill(results[idx])
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
@pending_loads.clear
|
34
|
+
end
|
35
|
+
|
36
|
+
# Clear all caches
|
37
|
+
def clear!
|
38
|
+
@results_cache.clear
|
39
|
+
@pending_loads.clear
|
40
|
+
@loaders.values.each(&:clear_cache!)
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def loader_key(module_class, options)
|
46
|
+
"#{module_class.name}:#{options.hash}"
|
47
|
+
end
|
48
|
+
|
49
|
+
# Individual batch loader for a specific module
|
50
|
+
class BatchLoader
|
51
|
+
attr_reader :module_class, :batch_size, :cache
|
52
|
+
|
53
|
+
def initialize(module_class, batch_size: 100, cache: true)
|
54
|
+
@module_class = module_class
|
55
|
+
@batch_size = batch_size
|
56
|
+
@cache = cache
|
57
|
+
@cache_store = {} if cache
|
58
|
+
end
|
59
|
+
|
60
|
+
# Load a batch of inputs
|
61
|
+
def load_batch(inputs_array)
|
62
|
+
return load_from_cache(inputs_array) if cache && all_cached?(inputs_array)
|
63
|
+
|
64
|
+
# Group inputs by signature to optimize processing
|
65
|
+
grouped = group_by_signature(inputs_array)
|
66
|
+
results = []
|
67
|
+
|
68
|
+
grouped.each do |_signature_key, inputs_group|
|
69
|
+
module_instance = create_module_instance(inputs_group.first)
|
70
|
+
|
71
|
+
# Process in chunks to respect batch_size
|
72
|
+
inputs_group.each_slice(batch_size) do |chunk|
|
73
|
+
chunk_results = process_chunk(module_instance, chunk)
|
74
|
+
results.concat(chunk_results)
|
75
|
+
|
76
|
+
# Cache results if enabled
|
77
|
+
cache_results(chunk, chunk_results) if cache
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
results
|
82
|
+
end
|
83
|
+
|
84
|
+
# Load a single input (returns a promise for lazy evaluation)
|
85
|
+
def load(inputs)
|
86
|
+
Promise.new do |promise|
|
87
|
+
if cache && @cache_store.key?(cache_key(inputs))
|
88
|
+
promise.fulfill(@cache_store[cache_key(inputs)])
|
89
|
+
else
|
90
|
+
# Queue for batch loading
|
91
|
+
queue_for_loading(inputs, promise)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def clear_cache!
|
97
|
+
@cache_store.clear if cache
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def all_cached?(inputs_array)
|
103
|
+
inputs_array.all? { |inputs| @cache_store.key?(cache_key(inputs)) }
|
104
|
+
end
|
105
|
+
|
106
|
+
def load_from_cache(inputs_array)
|
107
|
+
inputs_array.map { |inputs| @cache_store[cache_key(inputs)] }
|
108
|
+
end
|
109
|
+
|
110
|
+
def cache_results(inputs_array, results)
|
111
|
+
inputs_array.each_with_index do |inputs, idx|
|
112
|
+
@cache_store[cache_key(inputs)] = results[idx]
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def cache_key(inputs)
|
117
|
+
inputs.sort.to_h.hash
|
118
|
+
end
|
119
|
+
|
120
|
+
def group_by_signature(inputs_array)
|
121
|
+
inputs_array.group_by do |inputs|
|
122
|
+
# Group by input keys to process similar queries together
|
123
|
+
inputs.keys.sort.join(':')
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def create_module_instance(sample_inputs)
|
128
|
+
# Infer signature from inputs
|
129
|
+
signature = infer_signature(sample_inputs)
|
130
|
+
module_class.new(signature)
|
131
|
+
end
|
132
|
+
|
133
|
+
def infer_signature(inputs)
|
134
|
+
# Create a signature based on input structure
|
135
|
+
input_fields = inputs.map { |k, v| "#{k}: #{type_for_value(v)}" }.join(', ')
|
136
|
+
output_fields = "result: hash" # Default output, can be customized
|
137
|
+
"#{input_fields} -> #{output_fields}"
|
138
|
+
end
|
139
|
+
|
140
|
+
def type_for_value(value)
|
141
|
+
case value
|
142
|
+
when String then 'string'
|
143
|
+
when Integer then 'int'
|
144
|
+
when Float then 'float'
|
145
|
+
when TrueClass, FalseClass then 'bool'
|
146
|
+
when Array then 'list'
|
147
|
+
when Hash then 'hash'
|
148
|
+
else 'string'
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def process_chunk(module_instance, chunk)
|
153
|
+
if module_instance.respond_to?(:batch_forward)
|
154
|
+
# If module supports batch processing
|
155
|
+
module_instance.batch_forward(chunk)
|
156
|
+
else
|
157
|
+
# Fall back to individual processing
|
158
|
+
chunk.map { |inputs| module_instance.call(inputs) }
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def queue_for_loading(inputs, promise)
|
163
|
+
# This would integrate with the parent DataLoader's pending loads
|
164
|
+
# For now, process immediately
|
165
|
+
result = module_class.new(infer_signature(inputs)).call(inputs)
|
166
|
+
promise.fulfill(result)
|
167
|
+
@cache_store[cache_key(inputs)] = result if cache
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# Promise implementation for lazy loading
|
172
|
+
class Promise
|
173
|
+
def initialize(&block)
|
174
|
+
@fulfilled = false
|
175
|
+
@value = nil
|
176
|
+
@callbacks = []
|
177
|
+
block.call(self) if block
|
178
|
+
end
|
179
|
+
|
180
|
+
def fulfill(value)
|
181
|
+
return if @fulfilled
|
182
|
+
|
183
|
+
@value = value
|
184
|
+
@fulfilled = true
|
185
|
+
@callbacks.each { |cb| cb.call(value) }
|
186
|
+
@callbacks.clear
|
187
|
+
end
|
188
|
+
|
189
|
+
def then(&block)
|
190
|
+
if @fulfilled
|
191
|
+
block.call(@value)
|
192
|
+
else
|
193
|
+
@callbacks << block
|
194
|
+
end
|
195
|
+
self
|
196
|
+
end
|
197
|
+
|
198
|
+
def value
|
199
|
+
raise "Promise not yet fulfilled" unless @fulfilled
|
200
|
+
|
201
|
+
@value
|
202
|
+
end
|
203
|
+
|
204
|
+
def fulfilled?
|
205
|
+
@fulfilled
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'graphql'
|
4
|
+
|
5
|
+
module Desiru
|
6
|
+
module GraphQL
|
7
|
+
# Custom GraphQL executor with batch loading support
|
8
|
+
class Executor
|
9
|
+
attr_reader :schema, :data_loader
|
10
|
+
|
11
|
+
def initialize(schema, data_loader: nil)
|
12
|
+
@schema = schema
|
13
|
+
@data_loader = data_loader || DataLoader.new
|
14
|
+
end
|
15
|
+
|
16
|
+
# Execute a GraphQL query with batch loading
|
17
|
+
def execute(query_string, variables: {}, context: {}, operation_name: nil)
|
18
|
+
# Add data loader to context
|
19
|
+
context[:data_loader] = @data_loader
|
20
|
+
|
21
|
+
# Wrap execution with batch loading
|
22
|
+
result = nil
|
23
|
+
batch_execute do
|
24
|
+
result = @schema.execute(
|
25
|
+
query_string,
|
26
|
+
variables: variables,
|
27
|
+
context: context,
|
28
|
+
operation_name: operation_name
|
29
|
+
)
|
30
|
+
end
|
31
|
+
|
32
|
+
result
|
33
|
+
end
|
34
|
+
|
35
|
+
# Execute multiple queries in a single batch
|
36
|
+
def execute_batch(queries)
|
37
|
+
results = []
|
38
|
+
|
39
|
+
batch_execute do
|
40
|
+
queries.each do |query_params|
|
41
|
+
query_params[:context] ||= {}
|
42
|
+
query_params[:context][:data_loader] = @data_loader
|
43
|
+
|
44
|
+
results << @schema.execute(
|
45
|
+
query_params[:query],
|
46
|
+
variables: query_params[:variables] || {},
|
47
|
+
context: query_params[:context],
|
48
|
+
operation_name: query_params[:operation_name]
|
49
|
+
)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
results
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def batch_execute
|
59
|
+
# Start batch loading context
|
60
|
+
@data_loader.clear! if @data_loader.respond_to?(:clear!)
|
61
|
+
|
62
|
+
# Execute the GraphQL queries
|
63
|
+
result = yield
|
64
|
+
|
65
|
+
# Perform all pending batch loads
|
66
|
+
@data_loader.perform_loads if @data_loader.respond_to?(:perform_loads)
|
67
|
+
|
68
|
+
result
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# GraphQL field extension for lazy loading
|
73
|
+
class LazyFieldExtension < ::GraphQL::Schema::FieldExtension
|
74
|
+
def resolve(object:, arguments:, context:)
|
75
|
+
result = yield(object, arguments)
|
76
|
+
|
77
|
+
# If result is a promise, handle it appropriately
|
78
|
+
if result.respond_to?(:then) && result.respond_to?(:fulfilled?)
|
79
|
+
if result.fulfilled?
|
80
|
+
result.value
|
81
|
+
else
|
82
|
+
# Create a lazy resolver
|
83
|
+
::GraphQL::Execution::Lazy.new do
|
84
|
+
result.value
|
85
|
+
end
|
86
|
+
end
|
87
|
+
else
|
88
|
+
result
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Middleware for automatic batch loading
|
94
|
+
class BatchLoaderMiddleware
|
95
|
+
def initialize(app)
|
96
|
+
@app = app
|
97
|
+
end
|
98
|
+
|
99
|
+
def call(env)
|
100
|
+
# Extract GraphQL context
|
101
|
+
context = env['graphql.context'] || {}
|
102
|
+
|
103
|
+
# Ensure data loader is available
|
104
|
+
context[:data_loader] ||= DataLoader.new
|
105
|
+
env['graphql.context'] = context
|
106
|
+
|
107
|
+
# Execute with batch loading
|
108
|
+
@app.call(env)
|
109
|
+
ensure
|
110
|
+
# Clean up after request
|
111
|
+
context[:data_loader]&.clear!
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|