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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.ja.md +618 -0
- data/README.md +533 -248
- data/README.zh-CN.md +457 -250
- data/lib/generators/maglev/install/install_generator.rb +20 -0
- data/lib/generators/maglev/upgrade_index_version/upgrade_index_version_generator.rb +27 -0
- data/lib/generators/maglev/upgrade_source_identity/upgrade_source_identity_generator.rb +52 -0
- data/lib/maglev/active_record_extension.rb +141 -24
- data/lib/maglev/adapters/faraday_client.rb +94 -0
- data/lib/maglev/adapters/faraday_embedding.rb +51 -0
- data/lib/maglev/adapters/faraday_generation.rb +49 -0
- data/lib/maglev/adapters/faraday_planner.rb +88 -0
- data/lib/maglev/answerer.rb +30 -12
- data/lib/maglev/chunker.rb +39 -4
- data/lib/maglev/configuration.rb +66 -1
- data/lib/maglev/content_source_graph.rb +17 -11
- data/lib/maglev/dependency_graph.rb +72 -13
- data/lib/maglev/embedding_adapter.rb +10 -0
- data/lib/maglev/hybrid_candidate_set.rb +25 -0
- data/lib/maglev/hybrid_coordinator.rb +112 -0
- data/lib/maglev/hybrid_result.rb +25 -0
- data/lib/maglev/index_diagnostics.rb +83 -0
- data/lib/maglev/index_identity.rb +70 -0
- data/lib/maglev/index_state.rb +9 -0
- data/lib/maglev/indexer.rb +185 -35
- data/lib/maglev/knowledge_config.rb +27 -5
- data/lib/maglev/knowledge_registry.rb +33 -0
- data/lib/maglev/planner.rb +172 -0
- data/lib/maglev/planner_adapter.rb +25 -0
- data/lib/maglev/planner_evaluation.rb +49 -0
- data/lib/maglev/query_compiler.rb +197 -0
- data/lib/maglev/query_ir.rb +143 -0
- data/lib/maglev/query_validator.rb +311 -0
- data/lib/maglev/railtie.rb +9 -0
- data/lib/maglev/registry.rb +72 -0
- data/lib/maglev/reindex_job.rb +34 -2
- data/lib/maglev/relation_order.rb +16 -0
- data/lib/maglev/request.rb +22 -0
- data/lib/maglev/request_executor.rb +101 -0
- data/lib/maglev/resource_config.rb +222 -0
- data/lib/maglev/response.rb +2 -2
- data/lib/maglev/result.rb +30 -0
- data/lib/maglev/retrieval_outcome.rb +52 -0
- data/lib/maglev/retrieval_result.rb +25 -0
- data/lib/maglev/retriever.rb +282 -27
- data/lib/maglev/router.rb +77 -0
- data/lib/maglev/routing_adapter.rb +25 -0
- data/lib/maglev/schema_compiler.rb +17 -4
- data/lib/maglev/schema_snapshot.rb +159 -0
- data/lib/maglev/search_result.rb +7 -3
- data/lib/maglev/snapshot.rb +21 -1
- data/lib/maglev/snapshot_budget.rb +57 -0
- data/lib/maglev/snapshot_builder.rb +89 -11
- data/lib/maglev/source_extractor.rb +43 -0
- data/lib/maglev/source_fragment.rb +9 -0
- data/lib/maglev/structured_answer_composer.rb +67 -0
- data/lib/maglev/structured_evidence_builder.rb +56 -0
- data/lib/maglev/structured_executor.rb +157 -0
- data/lib/maglev/structured_result.rb +97 -0
- data/lib/maglev/trace.rb +56 -0
- data/lib/maglev/vector_stores/base.rb +12 -0
- data/lib/maglev/vector_stores/document.rb +14 -5
- data/lib/maglev/vector_stores/document_id.rb +27 -0
- data/lib/maglev/vector_stores/memory.rb +68 -6
- data/lib/maglev/vector_stores/metadata_filter.rb +55 -0
- data/lib/maglev/vector_stores/pgvector.rb +94 -7
- data/lib/maglev/version.rb +1 -1
- data/lib/maglev-rb.rb +3 -0
- data/lib/maglev.rb +36 -3
- data/lib/tasks/maglev.rake +43 -0
- metadata +71 -11
- data/lib/maglev/adapters/ruby_llm_attachment_extractor.rb +0 -15
- data/lib/maglev/adapters/ruby_llm_embedding.rb +0 -22
- data/lib/maglev/adapters/ruby_llm_generation.rb +0 -22
- 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
|