maglev-rb 0.1.1 → 0.2.1

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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.ja.md +618 -0
  4. data/README.md +533 -248
  5. data/README.zh-CN.md +457 -250
  6. data/lib/generators/maglev/install/install_generator.rb +20 -0
  7. data/lib/generators/maglev/upgrade_index_version/upgrade_index_version_generator.rb +27 -0
  8. data/lib/generators/maglev/upgrade_source_identity/upgrade_source_identity_generator.rb +52 -0
  9. data/lib/maglev/active_record_extension.rb +141 -24
  10. data/lib/maglev/adapters/faraday_client.rb +94 -0
  11. data/lib/maglev/adapters/faraday_embedding.rb +51 -0
  12. data/lib/maglev/adapters/faraday_generation.rb +49 -0
  13. data/lib/maglev/adapters/faraday_planner.rb +88 -0
  14. data/lib/maglev/answerer.rb +30 -12
  15. data/lib/maglev/chunker.rb +39 -4
  16. data/lib/maglev/configuration.rb +66 -1
  17. data/lib/maglev/content_source_graph.rb +17 -11
  18. data/lib/maglev/dependency_graph.rb +72 -13
  19. data/lib/maglev/embedding_adapter.rb +10 -0
  20. data/lib/maglev/hybrid_candidate_set.rb +25 -0
  21. data/lib/maglev/hybrid_coordinator.rb +112 -0
  22. data/lib/maglev/hybrid_result.rb +25 -0
  23. data/lib/maglev/index_diagnostics.rb +83 -0
  24. data/lib/maglev/index_identity.rb +70 -0
  25. data/lib/maglev/index_state.rb +9 -0
  26. data/lib/maglev/indexer.rb +185 -35
  27. data/lib/maglev/knowledge_config.rb +27 -5
  28. data/lib/maglev/knowledge_registry.rb +33 -0
  29. data/lib/maglev/planner.rb +172 -0
  30. data/lib/maglev/planner_adapter.rb +25 -0
  31. data/lib/maglev/planner_evaluation.rb +49 -0
  32. data/lib/maglev/query_compiler.rb +197 -0
  33. data/lib/maglev/query_ir.rb +143 -0
  34. data/lib/maglev/query_validator.rb +311 -0
  35. data/lib/maglev/railtie.rb +9 -0
  36. data/lib/maglev/registry.rb +72 -0
  37. data/lib/maglev/reindex_job.rb +34 -2
  38. data/lib/maglev/relation_order.rb +16 -0
  39. data/lib/maglev/request.rb +22 -0
  40. data/lib/maglev/request_executor.rb +101 -0
  41. data/lib/maglev/resource_config.rb +222 -0
  42. data/lib/maglev/response.rb +2 -2
  43. data/lib/maglev/result.rb +30 -0
  44. data/lib/maglev/retrieval_outcome.rb +52 -0
  45. data/lib/maglev/retrieval_result.rb +25 -0
  46. data/lib/maglev/retriever.rb +282 -27
  47. data/lib/maglev/router.rb +77 -0
  48. data/lib/maglev/routing_adapter.rb +25 -0
  49. data/lib/maglev/schema_compiler.rb +17 -4
  50. data/lib/maglev/schema_snapshot.rb +159 -0
  51. data/lib/maglev/search_result.rb +7 -3
  52. data/lib/maglev/snapshot.rb +21 -1
  53. data/lib/maglev/snapshot_budget.rb +57 -0
  54. data/lib/maglev/snapshot_builder.rb +89 -11
  55. data/lib/maglev/source_extractor.rb +43 -0
  56. data/lib/maglev/source_fragment.rb +9 -0
  57. data/lib/maglev/structured_answer_composer.rb +67 -0
  58. data/lib/maglev/structured_evidence_builder.rb +56 -0
  59. data/lib/maglev/structured_executor.rb +157 -0
  60. data/lib/maglev/structured_result.rb +97 -0
  61. data/lib/maglev/trace.rb +56 -0
  62. data/lib/maglev/vector_stores/base.rb +12 -0
  63. data/lib/maglev/vector_stores/document.rb +14 -5
  64. data/lib/maglev/vector_stores/document_id.rb +27 -0
  65. data/lib/maglev/vector_stores/memory.rb +68 -6
  66. data/lib/maglev/vector_stores/metadata_filter.rb +55 -0
  67. data/lib/maglev/vector_stores/pgvector.rb +94 -7
  68. data/lib/maglev/version.rb +1 -1
  69. data/lib/maglev-rb.rb +3 -0
  70. data/lib/maglev.rb +36 -3
  71. data/lib/tasks/maglev.rake +43 -0
  72. metadata +71 -11
  73. data/lib/maglev/adapters/ruby_llm_attachment_extractor.rb +0 -15
  74. data/lib/maglev/adapters/ruby_llm_embedding.rb +0 -22
  75. data/lib/maglev/adapters/ruby_llm_generation.rb +0 -22
  76. data/lib/maglev/adapters/ruby_llm_provider.rb +0 -64
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "planner_adapter"
6
+ require_relative "query_validator"
7
+ require_relative "trace"
8
+
9
+ module Maglev
10
+ class Planner
11
+ QUERY_IR_SCHEMA = {
12
+ type: "object", additionalProperties: false,
13
+ required: %w[version root operation scopes filters joins sort distinct limit],
14
+ properties: {
15
+ version: {const: 1}, root: {type: "string"}, operation: {enum: %w[records aggregate]},
16
+ scopes: {type: "array", items: {type: "object", additionalProperties: false,
17
+ required: %w[name parameters], properties: {name: {type: "string"}, parameters: {type: "object"}}}},
18
+ filters: {type: "array", items: {type: "object", additionalProperties: false,
19
+ required: %w[field operator value], properties: {field: {type: "string"}, operator: {type: "string"}, value: {}}}},
20
+ joins: {type: "array", items: {type: "string"}},
21
+ sort: {type: "array", items: {type: "object", additionalProperties: false,
22
+ required: %w[field direction], properties: {field: {type: "string"}, direction: {enum: %w[asc desc]}}}},
23
+ distinct: {type: "boolean"}, limit: {type: "integer", minimum: 1},
24
+ aggregate: {type: ["object", "null"]}
25
+ }
26
+ }.freeze
27
+ OUTCOMES = %w[ready clarification_required unsupported].freeze
28
+
29
+ Plan = Struct.new(:status, :route, :ir, :resource, :explanation, :warnings, :errors,
30
+ :clarification, :constraints, :trace_id, :validation, :base_relation,
31
+ :evidence_requirements, :policy_limits) do
32
+ def initialize(**attributes)
33
+ attributes[:status] = attributes.fetch(:status).to_sym
34
+ attributes[:route] = (attributes[:route] || :structured).to_sym
35
+ attributes[:warnings] = Array(attributes[:warnings]).freeze
36
+ attributes[:errors] = Array(attributes[:errors]).freeze
37
+ attributes[:constraints] = (attributes[:constraints] || {}).freeze
38
+ attributes[:evidence_requirements] = (attributes[:evidence_requirements] || {}).freeze
39
+ attributes[:policy_limits] = (attributes[:policy_limits] || {}).freeze
40
+ attributes[:clarification] = attributes[:clarification]&.freeze
41
+ super
42
+ freeze
43
+ end
44
+
45
+ def ready? = status == :ready
46
+ end
47
+
48
+ def initialize(adapter:)
49
+ unless adapter.respond_to?(:plan)
50
+ raise ConfigurationError, "planner adapter must implement #plan"
51
+ end
52
+
53
+ @adapter = adapter
54
+ end
55
+
56
+ def plan(question:, snapshot:, resource:, constraints: {}, base_relation: nil)
57
+ root = resource.to_s
58
+ trace_id = SecureRandom.uuid
59
+ request_constraints = normalize_constraints(constraints)
60
+ output = Trace.instrument(:planning, trace_id: trace_id, resource: root) do |payload|
61
+ provider_plan(question, snapshot, request_constraints, nil).tap do |result|
62
+ payload[:status] = result.is_a?(Hash) ? result["status"]&.to_sym || :invalid : :invalid
63
+ end
64
+ end
65
+ return outcome_plan(output, root, request_constraints, trace_id) unless output.is_a?(Hash) && output["status"] == "ready"
66
+
67
+ validation = Trace.instrument(:validation, trace_id: trace_id, resource: root,
68
+ operation: output.dig("ir", "operation")) do |payload|
69
+ validate(output["ir"], snapshot, root, request_constraints).tap do |result|
70
+ payload[:status] = result.valid? ? :ready : :invalid
71
+ payload[:error_codes] = result.errors.map(&:code).uniq.sort if result.errors.any?
72
+ end
73
+ end
74
+ unless validation.valid?
75
+ repair = {errors: safe_errors(validation.errors)}.freeze
76
+ output = Trace.instrument(:planning, trace_id: trace_id, resource: root) do |payload|
77
+ provider_plan(question, snapshot, request_constraints, repair).tap do |result|
78
+ payload[:status] = result.is_a?(Hash) ? result["status"]&.to_sym || :invalid : :invalid
79
+ end
80
+ end
81
+ return outcome_plan(output, root, request_constraints, trace_id) unless output.is_a?(Hash) && output["status"] == "ready"
82
+ validation = Trace.instrument(:validation, trace_id: trace_id, resource: root,
83
+ operation: output.dig("ir", "operation")) do |payload|
84
+ validate(output["ir"], snapshot, root, request_constraints).tap do |result|
85
+ payload[:status] = result.valid? ? :ready : :invalid
86
+ payload[:error_codes] = result.errors.map(&:code).uniq.sort if result.errors.any?
87
+ end
88
+ end
89
+ end
90
+
91
+ return invalid_plan(root, request_constraints, validation.errors, trace_id) unless validation.valid?
92
+
93
+ evidence_requirements = {kind: validation.ir.operation, max_rows: validation.ir.limit}.freeze
94
+ Plan.new(status: :ready, ir: validation.ir, resource: root, explanation: validation.explanation,
95
+ validation: validation, constraints: request_constraints, trace_id: trace_id,
96
+ base_relation: base_relation, evidence_requirements: evidence_requirements,
97
+ policy_limits: validator_limits(snapshot, root, request_constraints))
98
+ end
99
+
100
+ private
101
+
102
+ def provider_plan(question, snapshot, constraints, repair)
103
+ @adapter.plan(question: question.to_s, schema_snapshot: snapshot, constraints: constraints,
104
+ query_ir_schema: QUERY_IR_SCHEMA, repair: repair)
105
+ end
106
+
107
+ def validate(input, snapshot, root, constraints)
108
+ QueryValidator.new(snapshot: snapshot, root: root, limits: constraints).call(input)
109
+ end
110
+
111
+ def validator_limits(snapshot, root, constraints)
112
+ QueryValidator.new(snapshot: snapshot, root: root, limits: constraints).policy_limits
113
+ end
114
+
115
+ def normalize_constraints(constraints)
116
+ values = constraints.transform_keys(&:to_sym)
117
+ allowed = QueryValidator::DEFAULT_LIMITS.keys
118
+ unless (values.keys - allowed).empty? && values.values.all? { |value| value.is_a?(Integer) && value.positive? }
119
+ raise ArgumentError, "invalid planner constraints"
120
+ end
121
+ values.freeze
122
+ end
123
+
124
+ def safe_errors(errors)
125
+ errors.map { |error| {code: error.code, message: error.message, path: error.path}.freeze }.freeze
126
+ end
127
+
128
+ def outcome_plan(output, root, constraints, trace_id)
129
+ return invalid_plan(root, constraints, [], trace_id) unless output.is_a?(Hash) && OUTCOMES.include?(output["status"])
130
+
131
+ status = output["status"].to_sym
132
+ case status
133
+ when :clarification_required
134
+ message = output["message"]
135
+ choices = output["choices"]
136
+ return invalid_plan(root, constraints, [], trace_id) unless message.is_a?(String) && choices.is_a?(Array) &&
137
+ choices.length.between?(1, 10) && choices.all? { |choice| choice.is_a?(String) && choice.bytesize <= 200 }
138
+ Plan.new(status: status, resource: root, clarification: {message: message, choices: choices.freeze},
139
+ constraints: constraints, trace_id: trace_id)
140
+ when :unsupported
141
+ return invalid_plan(root, constraints, [], trace_id) unless output["message"].is_a?(String)
142
+ Plan.new(status: status, resource: root, warnings: [output["message"]], constraints: constraints,
143
+ trace_id: trace_id)
144
+ else
145
+ invalid_plan(root, constraints, [], trace_id)
146
+ end
147
+ end
148
+
149
+ def invalid_plan(root, constraints, errors, trace_id)
150
+ Plan.new(status: :invalid, resource: root, errors: errors, constraints: constraints,
151
+ trace_id: trace_id)
152
+ end
153
+ end
154
+
155
+ def self.plan(question, resource:, base_relation:, resources: nil, constraints: {}, user: nil,
156
+ authorizer: nil, adapter: Maglev.configuration.planner_adapter)
157
+ raise ConfigurationError, "planner adapter is not configured" unless adapter
158
+ unless defined?(ActiveRecord::Relation) && base_relation.is_a?(ActiveRecord::Relation)
159
+ raise ConfigurationError, "an ActiveRecord base relation is required for planning"
160
+ end
161
+
162
+ identifiers = [resource, *Array(resources)].compact.map(&:to_s).uniq
163
+ snapshot = Registry.snapshot(resources: identifiers, user: user, authorizer: authorizer)
164
+ root_model = snapshot.model_class_for(resource)
165
+ unless root_model && base_relation.klass == root_model
166
+ raise ConfigurationError, "base relation does not match the authorized resource"
167
+ end
168
+
169
+ Planner.new(adapter: adapter).plan(question: question, snapshot: snapshot,
170
+ resource: resource, constraints: constraints, base_relation: base_relation)
171
+ end
172
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maglev
4
+ class PlannerAdapter
5
+ def plan(question:, schema_snapshot:, constraints:, query_ir_schema:, repair: nil)
6
+ raise NotImplementedError, "#{self.class.name} must implement #plan"
7
+ end
8
+ end
9
+
10
+ class FakePlannerAdapter < PlannerAdapter
11
+ attr_reader :requests
12
+
13
+ def initialize(outputs)
14
+ @outputs = Array(outputs).dup
15
+ @requests = []
16
+ end
17
+
18
+ def plan(**request)
19
+ @requests << request.freeze
20
+ raise PermanentProviderError, "Fake planner has no remaining output" if @outputs.empty?
21
+
22
+ @outputs.shift
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Maglev
6
+ module PlannerEvaluation
7
+ module_function
8
+
9
+ def score(cases)
10
+ results = cases.map { |test_case| score_case(test_case) }.freeze
11
+ passed = results.count { |result| result[:passed] }
12
+ total = results.length
13
+ {total: total, passed: passed, failed: total - passed,
14
+ score: total.zero? ? 0.0 : passed.fdiv(total), cases: results}.freeze
15
+ end
16
+
17
+ def load(path)
18
+ corpus = JSON.parse(File.read(path))
19
+ raise ArgumentError, "unsupported planner evaluation corpus" unless corpus["version"] == 1 && corpus["cases"].is_a?(Array)
20
+
21
+ corpus
22
+ end
23
+
24
+ def score_case(test_case)
25
+ expected = test_case.fetch("expected")
26
+ actual = test_case.fetch("actual")
27
+ failure_class = if expected["status"] != actual["status"]
28
+ :status_mismatch
29
+ elsif expected["status"] == "ready" && canonical(expected["ir"]) != canonical(actual["ir"])
30
+ :ir_mismatch
31
+ end
32
+ {id: test_case.fetch("id"), passed: failure_class.nil?, failure_class: failure_class}.freeze
33
+ end
34
+ private_class_method :score_case
35
+
36
+ def canonical(value, parent = nil)
37
+ case value
38
+ when Hash
39
+ value.keys.sort.to_h { |key| [key, canonical(value[key], key)] }
40
+ when Array
41
+ items = value.map { |item| canonical(item) }
42
+ %w[filters joins].include?(parent) ? items.sort_by { |item| JSON.generate(item) } : items
43
+ else
44
+ value
45
+ end
46
+ end
47
+ private_class_method :canonical
48
+ end
49
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "query_validator"
5
+
6
+ module Maglev
7
+ class QueryCompilationError < Error
8
+ end
9
+
10
+ class StructuredPlan
11
+ attr_reader :relation, :operations, :explanation, :warnings, :aggregate, :aggregate_column
12
+
13
+ def initialize(relation:, operations:, explanation:, aggregate: nil, aggregate_column: nil, warnings: [])
14
+ @relation = relation
15
+ @operations = operations.freeze
16
+ @explanation = explanation.to_s.freeze
17
+ @warnings = warnings.freeze
18
+ @aggregate = aggregate
19
+ @aggregate_column = aggregate_column
20
+ freeze
21
+ end
22
+
23
+ def records? = aggregate.nil?
24
+ def aggregate? = !records?
25
+ def executed? = false
26
+ def to_sql = relation.to_sql
27
+ end
28
+
29
+ module ReadOnlyRelation
30
+ MUTATION_METHODS = %i[
31
+ build create create! create_or_find_by create_or_find_by! create_with create_with!
32
+ delete delete_all delete_by destroy destroy_all destroy_by find_or_create_by
33
+ find_or_create_by! first_or_create first_or_create! insert insert! insert_all
34
+ insert_all! new touch_all update update! update_all update_counters upsert upsert_all
35
+ ].freeze
36
+
37
+ MUTATION_METHODS.each do |method_name|
38
+ define_method(method_name) do |*|
39
+ raise QueryCompilationError, "Structured query relations are read-only"
40
+ end
41
+ end
42
+ end
43
+
44
+ class QueryCompiler
45
+ SAFE_SCOPE_KEYS = %i[where order limit distinct].freeze
46
+
47
+ def initialize(snapshot:)
48
+ @snapshot = snapshot
49
+ @resources = snapshot.resources.to_h { |resource| [resource.identifier, resource] }
50
+ end
51
+
52
+ def compile(validation:, base_relation:)
53
+ unless validation.is_a?(QueryValidator::Result) && validation.valid? && validation.ir && validation.snapshot.equal?(@snapshot)
54
+ raise QueryCompilationError, "A valid query validation result is required"
55
+ end
56
+ unless defined?(ActiveRecord::Relation) && base_relation.is_a?(ActiveRecord::Relation)
57
+ raise QueryCompilationError, "An ActiveRecord base relation is required"
58
+ end
59
+
60
+ ir = validation.ir
61
+ resource = @resources[ir.root]
62
+ root_model = @snapshot.model_class_for(ir.root)
63
+ unless resource && root_model && base_relation.klass == root_model
64
+ raise QueryCompilationError, "The base relation does not match the validated root resource"
65
+ end
66
+
67
+ relation = base_relation
68
+ ir.scopes.each { |scope| relation = apply_scope(relation, resource, scope) }
69
+ ir.joins.each { |path| relation = relation.joins(join_argument(path.segments)) }
70
+ ir.filters.each { |predicate| relation = apply_predicate(relation, root_model, predicate) }
71
+ ir.sort.each { |sort| relation = apply_sort(relation, root_model, sort) }
72
+ relation = relation.distinct if ir.distinct
73
+ relation = relation.limit([relation.limit_value, ir.limit].compact.min)
74
+ relation = relation.readonly.extending(ReadOnlyRelation)
75
+
76
+ StructuredPlan.new(
77
+ relation: relation,
78
+ aggregate: ir.aggregate,
79
+ aggregate_column: aggregate_column(root_model, ir.aggregate),
80
+ operations: operation_descriptions(ir),
81
+ explanation: validation.explanation
82
+ )
83
+ rescue QueryCompilationError
84
+ raise
85
+ rescue NoMethodError, ArgumentError, ActiveRecord::ActiveRecordError => error
86
+ raise QueryCompilationError, "Validated query could not be compiled: #{error.class.name}"
87
+ end
88
+
89
+ private
90
+
91
+ def apply_scope(relation, resource, scope)
92
+ declaration = resource.scopes.find { |candidate| candidate.fetch(:name) == scope.name }
93
+ raise QueryCompilationError, "The validated scope is unavailable" unless declaration
94
+
95
+ values = declaration.fetch(:parameters).keys.map { |name| literal_value(scope.parameters.fetch(name)) }
96
+ scoped = ActiveRecord::Base.while_preventing_writes do
97
+ relation.public_send(scope.name, *values)
98
+ end
99
+ if scoped.respond_to?(:unscope_values) && scoped.unscope_values.any?
100
+ raise QueryCompilationError, "Registered scope cannot remove relation constraints"
101
+ end
102
+ unless scoped.is_a?(ActiveRecord::Relation) && scoped.klass == relation.klass
103
+ raise QueryCompilationError, "Registered scope returned an incompatible relation"
104
+ end
105
+ raise QueryCompilationError, "Registered scope cannot widen the base relation" unless preserves_relation?(relation, scoped)
106
+
107
+ scoped
108
+ rescue ActiveRecord::ReadOnlyError
109
+ raise QueryCompilationError, "Registered scope cannot widen the base relation or introduce unsafe clauses"
110
+ end
111
+
112
+ def apply_predicate(relation, root_model, predicate)
113
+ model = model_for_path(root_model, predicate.field.segments)
114
+ column = model.arel_table[predicate.field.segments.last]
115
+ value = literal_value(predicate.value)
116
+ node = case predicate.operator
117
+ when :eq then column.eq(value)
118
+ when :not_eq then column.not_eq(value)
119
+ when :gt then column.gt(value)
120
+ when :gte then column.gteq(value)
121
+ when :lt then column.lt(value)
122
+ when :lte then column.lteq(value)
123
+ when :in then column.in(value)
124
+ when :not_in then column.not_in(value)
125
+ when :is_null then column.eq(nil)
126
+ when :is_not_null then column.not_eq(nil)
127
+ when :between then column.between(value.first..value.last)
128
+ else raise QueryCompilationError, "The validated predicate is unavailable"
129
+ end
130
+ relation.where(node)
131
+ end
132
+
133
+ def apply_sort(relation, root_model, sort)
134
+ model = model_for_path(root_model, sort.field.segments)
135
+ column = model.arel_table[sort.field.segments.last]
136
+ relation.order((sort.direction == :desc) ? column.desc : column.asc)
137
+ end
138
+
139
+ def model_for_path(root_model, segments)
140
+ segments[0...-1].reduce(root_model) { |model, name| model.reflect_on_association(name).klass }
141
+ end
142
+
143
+ def aggregate_column(root_model, aggregate)
144
+ return unless aggregate&.field
145
+
146
+ model_for_path(root_model, aggregate.field.segments).arel_table[aggregate.field.segments.last]
147
+ end
148
+
149
+ def preserves_relation?(base, scoped)
150
+ base_values = base.values
151
+ scoped_values = scoped.values
152
+ return false unless (base_values.keys - scoped_values.keys).empty?
153
+ return false unless (scoped_values.keys - base_values.keys - SAFE_SCOPE_KEYS).empty?
154
+ return false unless base.where_clause.send(:predicates).all? { |predicate| scoped.where_clause.send(:predicates).include?(predicate) }
155
+ return false unless Array(base.joins_values).all? { |join| scoped.joins_values.include?(join) }
156
+ return false unless Array(base.left_outer_joins_values).all? { |join| scoped.left_outer_joins_values.include?(join) }
157
+ return false unless Array(base.order_values).all? { |order| scoped.order_values.include?(order) }
158
+ return false if base.limit_value && (!scoped.limit_value || scoped.limit_value > base.limit_value)
159
+ return false if base.distinct_value && !scoped.distinct_value
160
+
161
+ protected_keys = base_values.keys - SAFE_SCOPE_KEYS
162
+ protected_keys.all? { |key| base_values[key] == scoped_values[key] }
163
+ end
164
+
165
+ def join_argument(segments)
166
+ return segments.first.to_sym if segments.one?
167
+
168
+ segments.reverse.reduce { |nested, segment| {segment.to_sym => nested} }
169
+ end
170
+
171
+ def literal_value(value)
172
+ case value
173
+ when QueryIR::Literal
174
+ value.value
175
+ when QueryIR::RelativeTime
176
+ amount = value.amount.public_send(value.unit)
177
+ (value.direction == "ago") ? Time.current - amount : Time.current + amount
178
+ when Array
179
+ value.map { |item| literal_value(item) }
180
+ else
181
+ value
182
+ end
183
+ end
184
+
185
+ def operation_descriptions(ir)
186
+ [
187
+ *ir.scopes.map { |scope| "scope #{scope.name}" },
188
+ *ir.joins.map { |join| "join #{join}" },
189
+ *ir.filters.map { |filter| "filter #{filter.field} #{filter.operator}" },
190
+ *ir.sort.map { |sort| "sort #{sort.field} #{sort.direction}" },
191
+ ("distinct" if ir.distinct),
192
+ "limit #{ir.limit}",
193
+ ("aggregate #{ir.aggregate.function}" if ir.aggregate)
194
+ ].compact.freeze
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Maglev
6
+ module QueryIR
7
+ VERSION = 1
8
+
9
+ module Value
10
+ def to_json(*arguments)
11
+ JSON.generate(to_h, *arguments)
12
+ end
13
+ end
14
+
15
+ class Path
16
+ include Value
17
+
18
+ attr_reader :segments
19
+
20
+ def initialize(value)
21
+ @segments = value.to_s.split(".").map { |segment| segment.dup.freeze }.freeze
22
+ freeze
23
+ end
24
+
25
+ def to_s = segments.join(".")
26
+ def to_h = to_s
27
+ end
28
+
29
+ class Literal
30
+ include Value
31
+
32
+ attr_reader :value, :type
33
+
34
+ def initialize(value:, type:)
35
+ @value = value.freeze
36
+ @type = type.to_sym
37
+ freeze
38
+ end
39
+
40
+ def to_h = value
41
+ end
42
+
43
+ class RelativeTime
44
+ include Value
45
+
46
+ UNITS = %w[seconds minutes hours days weeks months years].freeze
47
+ DIRECTIONS = %w[ago from_now].freeze
48
+ attr_reader :amount, :unit, :direction
49
+
50
+ def initialize(amount:, unit:, direction:)
51
+ @amount = amount
52
+ @unit = unit.to_s.freeze
53
+ @direction = direction.to_s.freeze
54
+ freeze
55
+ end
56
+
57
+ def to_h = {"relative" => {"amount" => amount, "unit" => unit, "direction" => direction}.freeze}.freeze
58
+ end
59
+
60
+ Predicate = Struct.new(:field, :operator, :value) do
61
+ include Value
62
+
63
+ def initialize(**attributes) = super.tap { freeze }
64
+
65
+ def to_h
66
+ result = {"field" => field.to_s, "operator" => operator.to_s}
67
+ unless value.nil?
68
+ result["value"] = if value.is_a?(Array)
69
+ value.map { |item| item.respond_to?(:to_h) ? item.to_h : item }
70
+ elsif value.respond_to?(:to_h)
71
+ value.to_h
72
+ else
73
+ value
74
+ end
75
+ end
76
+ result.freeze
77
+ end
78
+ end
79
+ Scope = Struct.new(:name, :parameters) do
80
+ include Value
81
+
82
+ def initialize(**attributes)
83
+ attributes[:parameters] = attributes.fetch(:parameters).freeze
84
+ super
85
+ freeze
86
+ end
87
+
88
+ def to_h = {"name" => name.to_s, "parameters" => parameters.transform_values { |value| value.respond_to?(:to_h) ? value.to_h : value }.freeze}.freeze
89
+ end
90
+ Sort = Struct.new(:field, :direction) do
91
+ include Value
92
+
93
+ def initialize(**attributes) = super.tap { freeze }
94
+ def to_h = {"field" => field.to_s, "direction" => direction.to_s}.freeze
95
+ end
96
+ Aggregate = Struct.new(:function, :field) do
97
+ include Value
98
+
99
+ def initialize(**attributes) = super.tap { freeze }
100
+ def to_h = {"function" => function.to_s}.tap { |hash| hash["field"] = field.to_s if field }.freeze
101
+ end
102
+
103
+ class Query
104
+ include Value
105
+
106
+ attr_reader :version, :root, :operation, :scopes, :filters, :joins, :sort, :distinct, :limit, :aggregate
107
+
108
+ def initialize(version:, root:, operation:, scopes:, filters:, joins:, sort:, distinct:, limit:, aggregate: nil)
109
+ @version = version
110
+ @root = root.to_s.freeze
111
+ @operation = operation.to_sym
112
+ @scopes = scopes.freeze
113
+ @filters = filters.freeze
114
+ @joins = joins.freeze
115
+ @sort = sort.freeze
116
+ @distinct = distinct
117
+ @limit = limit
118
+ @aggregate = aggregate
119
+ freeze
120
+ end
121
+
122
+ def to_h
123
+ hash = {"version" => version, "root" => root, "operation" => operation.to_s,
124
+ "scopes" => scopes.map(&:to_h), "filters" => filters.map(&:to_h),
125
+ "joins" => joins.map(&:to_s), "sort" => sort.map(&:to_h),
126
+ "distinct" => distinct, "limit" => limit}
127
+ hash["aggregate"] = aggregate.to_h if aggregate
128
+ hash.freeze
129
+ end
130
+ end
131
+
132
+ def self.from_json(json)
133
+ data = JSON.parse(json)
134
+ Query.new(version: data.fetch("version"), root: data.fetch("root"), operation: data.fetch("operation"),
135
+ scopes: data.fetch("scopes").map { |scope| Scope.new(name: scope.fetch("name"), parameters: scope.fetch("parameters")) },
136
+ filters: data.fetch("filters").map { |filter| Predicate.new(field: Path.new(filter.fetch("field")), operator: filter.fetch("operator").to_sym, value: filter["value"]) },
137
+ joins: data.fetch("joins").map { |path| Path.new(path) },
138
+ sort: data.fetch("sort").map { |sort| Sort.new(field: Path.new(sort.fetch("field")), direction: sort.fetch("direction").to_sym) },
139
+ distinct: data.fetch("distinct"), limit: data.fetch("limit"),
140
+ aggregate: data["aggregate"] && Aggregate.new(function: data.dig("aggregate", "function").to_sym, field: data.dig("aggregate", "field") && Path.new(data.dig("aggregate", "field"))))
141
+ end
142
+ end
143
+ end