hquery 1.0.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/CHANGELOG.md +133 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +21 -0
- data/README.md +700 -0
- data/Rakefile +8 -0
- data/lib/hq/context.rb +65 -0
- data/lib/hq/instance.rb +87 -0
- data/lib/hq/page.rb +33 -0
- data/lib/hq/query.rb +306 -0
- data/lib/hq/relations.rb +134 -0
- data/lib/hq/version.rb +3 -0
- data/lib/hq.rb +91 -0
- data/test.md +1466 -0
- metadata +57 -0
data/lib/hq/context.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# lib/hq/context.rb
|
|
2
|
+
require_relative 'relations'
|
|
3
|
+
|
|
4
|
+
module Hq
|
|
5
|
+
class DefinitionContext
|
|
6
|
+
include Hq::Relations::DSL
|
|
7
|
+
|
|
8
|
+
attr_reader :loaded_data, :scopes
|
|
9
|
+
|
|
10
|
+
def initialize(model_name, data_path)
|
|
11
|
+
@model_name = model_name
|
|
12
|
+
@data_path = data_path
|
|
13
|
+
@loaded_data = []
|
|
14
|
+
@scopes = {}
|
|
15
|
+
@relations = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def load(pattern)
|
|
19
|
+
file_pattern = File.join(@data_path, pattern)
|
|
20
|
+
files = Dir[file_pattern].sort
|
|
21
|
+
|
|
22
|
+
raise DataFileError, "No files found matching pattern: #{file_pattern}" if files.empty?
|
|
23
|
+
|
|
24
|
+
@loaded_data = files.map do |file|
|
|
25
|
+
begin
|
|
26
|
+
hash_data = eval(File.read(file), TOPLEVEL_BINDING, file)
|
|
27
|
+
|
|
28
|
+
unless hash_data.is_a?(::Hash)
|
|
29
|
+
raise DataFileError, "#{file} should return a Hash, but returned #{hash_data.class}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
hash_data.freeze
|
|
33
|
+
rescue DataFileError => e
|
|
34
|
+
raise e
|
|
35
|
+
rescue SyntaxError => e
|
|
36
|
+
raise DataFileError, "#{file} has a syntax error:\n#{e.message}"
|
|
37
|
+
rescue => e
|
|
38
|
+
raise DataFileError, "#{file} couldn't be loaded:\n#{e.message}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def from_array(array)
|
|
44
|
+
unless array.is_a?(Array)
|
|
45
|
+
raise DataFileError, "from_array() expects an Array of hashes, but got #{array.class}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@loaded_data = array.map.with_index do |item, index|
|
|
49
|
+
unless item.is_a?(::Hash)
|
|
50
|
+
raise DataFileError, "from_array() array item #{index + 1} should be a Hash, but got #{item.class}"
|
|
51
|
+
end
|
|
52
|
+
item.freeze
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def scope(methods_hash)
|
|
57
|
+
methods_hash.each do |name, lambda_proc|
|
|
58
|
+
unless lambda_proc.respond_to?(:call)
|
|
59
|
+
raise ScopeError, "Scope #{name} must be callable (lambda or proc)"
|
|
60
|
+
end
|
|
61
|
+
@scopes[name.to_sym] = lambda_proc
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/hq/instance.rb
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require_relative 'relations'
|
|
2
|
+
|
|
3
|
+
module Hq
|
|
4
|
+
class InstanceWrapper
|
|
5
|
+
include Hq::Relations::Fetcher
|
|
6
|
+
RESERVED_METHODS = [
|
|
7
|
+
:class, :object_id, :hash, :to_s, :inspect, :nil?, :frozen?,
|
|
8
|
+
:clone, :dup, :taint, :untaint, :freeze, :equal?, :==, :===,
|
|
9
|
+
:send, :public_send, :respond_to?, :method, :methods,
|
|
10
|
+
:instance_variables, :instance_variable_get, :instance_variable_set,
|
|
11
|
+
:instance_of?, :kind_of?, :is_a?, :to_h, :to_hash
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(hash, model_name)
|
|
15
|
+
@_hash = hash
|
|
16
|
+
@_memoized = {}
|
|
17
|
+
@_model_name = model_name
|
|
18
|
+
@_relations = Hq.relations_for(@_model_name)
|
|
19
|
+
@_presenter_module = Hq.presenter_for(@_model_name)
|
|
20
|
+
|
|
21
|
+
@_hash.each_key do |key|
|
|
22
|
+
method_name = key.to_sym
|
|
23
|
+
next if RESERVED_METHODS.include?(method_name)
|
|
24
|
+
|
|
25
|
+
define_singleton_method(method_name) do
|
|
26
|
+
@_hash[key]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@_relations.each_key do |relation_name|
|
|
31
|
+
define_singleton_method(relation_name) do
|
|
32
|
+
fetch_relation(relation_name)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if @_presenter_module
|
|
37
|
+
@_presenter_module.instance_methods(false).each do |method_name|
|
|
38
|
+
original_method = @_presenter_module.instance_method(method_name)
|
|
39
|
+
|
|
40
|
+
define_singleton_method(method_name) do |*args, &block|
|
|
41
|
+
cache_key = [method_name, args]
|
|
42
|
+
|
|
43
|
+
unless @_memoized.key?(cache_key)
|
|
44
|
+
begin
|
|
45
|
+
@_memoized[cache_key] = original_method.bind(self).call(*args, &block)
|
|
46
|
+
rescue => e
|
|
47
|
+
raise Hq::ItemMethodError,
|
|
48
|
+
"Error executing presenter method :#{method_name} on #{@_model_name} instance #{@_hash.inspect}: #{e.message}\n #{e.backtrace&.first}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@_memoized[cache_key]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def [](key)
|
|
59
|
+
@_hash&.[](key.to_sym) || @_hash&.[](key)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def []=(key, value)
|
|
63
|
+
raise "Hq::InstanceWrapper collections are immutable. Cannot assign #{key} = #{value}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def respond_to?(method_name, include_private = false)
|
|
67
|
+
symbol_name = method_name.to_sym
|
|
68
|
+
return true if singleton_class.method_defined?(symbol_name)
|
|
69
|
+
return true if @_presenter_module&.method_defined?(symbol_name)
|
|
70
|
+
return true if @_hash&.key?(symbol_name) && !RESERVED_METHODS.include?(symbol_name)
|
|
71
|
+
return true if @_relations&.key?(symbol_name)
|
|
72
|
+
super
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def inspect
|
|
76
|
+
"#<Hq::Instance #{@_hash.inspect}>"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_h
|
|
80
|
+
@_hash
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def to_hash
|
|
84
|
+
@_hash
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/hq/page.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Hq
|
|
2
|
+
class Page
|
|
3
|
+
attr_reader :items, :current_page, :total_pages, :total_items,
|
|
4
|
+
:prev_page, :next_page, :is_first_page, :is_last_page
|
|
5
|
+
|
|
6
|
+
def initialize(items, current_page, total_pages, total_items)
|
|
7
|
+
@items = items
|
|
8
|
+
@current_page = current_page
|
|
9
|
+
@total_pages = total_pages
|
|
10
|
+
@total_items = total_items
|
|
11
|
+
@prev_page = current_page > 1 ? current_page - 1 : nil
|
|
12
|
+
@next_page = current_page < total_pages ? current_page + 1 : nil
|
|
13
|
+
@is_first_page = current_page == 1
|
|
14
|
+
@is_last_page = current_page == total_pages
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Aliases for consistency with your SSG (e.g., 'posts' instead of 'items')
|
|
18
|
+
alias_method :posts, :items
|
|
19
|
+
|
|
20
|
+
def to_h
|
|
21
|
+
{
|
|
22
|
+
items: @items,
|
|
23
|
+
current_page: @current_page,
|
|
24
|
+
total_pages: @total_pages,
|
|
25
|
+
total_items: @total_items,
|
|
26
|
+
prev_page: @prev_page,
|
|
27
|
+
next_page: @next_page,
|
|
28
|
+
is_first_page: @is_first_page,
|
|
29
|
+
is_last_page: @is_last_page
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/hq/query.rb
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
require 'forwardable'
|
|
2
|
+
|
|
3
|
+
module Hq
|
|
4
|
+
class Query
|
|
5
|
+
include Enumerable
|
|
6
|
+
extend Forwardable
|
|
7
|
+
|
|
8
|
+
def_delegators :results, :each, :map, :select, :reject, :[], :size, :length,
|
|
9
|
+
:empty?, :any?, :all?, :to_a, :inspect
|
|
10
|
+
|
|
11
|
+
def initialize(model_name, data, scopes, relations, conditions: [], order_field: nil, order_desc: false, limit_count: nil, offset_count: nil)
|
|
12
|
+
@model_name = model_name
|
|
13
|
+
@data = data
|
|
14
|
+
@scopes = scopes
|
|
15
|
+
@relations = relations
|
|
16
|
+
@conditions = conditions
|
|
17
|
+
@order_field = order_field
|
|
18
|
+
@order_desc = order_desc
|
|
19
|
+
@limit_count = limit_count
|
|
20
|
+
@offset_count = offset_count
|
|
21
|
+
@_results = nil
|
|
22
|
+
|
|
23
|
+
@scopes.each do |scope_name, scope_lambda|
|
|
24
|
+
define_singleton_method(scope_name) do |*args|
|
|
25
|
+
begin
|
|
26
|
+
instance_exec(*args, &scope_lambda)
|
|
27
|
+
rescue => e
|
|
28
|
+
raise Hq::ScopeError, "Error in scope :#{scope_name} for model :#{@model_name}: #{e.message}\n #{e.backtrace&.first}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def where(condition_hash = nil, &block)
|
|
35
|
+
if block
|
|
36
|
+
sub_query = self.class.new(@model_name, @data, @scopes, @relations)
|
|
37
|
+
result_query = block.call(sub_query)
|
|
38
|
+
sub_conditions = result_query.instance_variable_get(:@conditions)
|
|
39
|
+
|
|
40
|
+
Query.new(@model_name, @data, @scopes, @relations,
|
|
41
|
+
conditions: @conditions + [[:and_block, sub_conditions]],
|
|
42
|
+
order_field: @order_field,
|
|
43
|
+
order_desc: @order_desc,
|
|
44
|
+
limit_count: @limit_count,
|
|
45
|
+
offset_count: @offset_count)
|
|
46
|
+
elsif condition_hash
|
|
47
|
+
Query.new(@model_name, @data, @scopes, @relations,
|
|
48
|
+
conditions: @conditions + [condition_hash],
|
|
49
|
+
order_field: @order_field,
|
|
50
|
+
order_desc: @order_desc,
|
|
51
|
+
limit_count: @limit_count,
|
|
52
|
+
offset_count: @offset_count)
|
|
53
|
+
else
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def or_where(condition_hash = nil, &block)
|
|
59
|
+
if block
|
|
60
|
+
sub_query = self.class.new(@model_name, @data, @scopes, @relations)
|
|
61
|
+
result_query = block.call(sub_query)
|
|
62
|
+
sub_conditions = result_query.instance_variable_get(:@conditions)
|
|
63
|
+
|
|
64
|
+
Query.new(@model_name, @data, @scopes, @relations,
|
|
65
|
+
conditions: @conditions + [[:or_block, sub_conditions]],
|
|
66
|
+
order_field: @order_field,
|
|
67
|
+
order_desc: @order_desc,
|
|
68
|
+
limit_count: @limit_count,
|
|
69
|
+
offset_count: @offset_count)
|
|
70
|
+
elsif condition_hash
|
|
71
|
+
Query.new(@model_name, @data, @scopes, @relations,
|
|
72
|
+
conditions: @conditions + [[:or, condition_hash]],
|
|
73
|
+
order_field: @order_field,
|
|
74
|
+
order_desc: @order_desc,
|
|
75
|
+
limit_count: @limit_count,
|
|
76
|
+
offset_count: @offset_count)
|
|
77
|
+
else
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def order(field, desc: false)
|
|
83
|
+
Query.new(@model_name, @data, @scopes, @relations,
|
|
84
|
+
conditions: @conditions,
|
|
85
|
+
order_field: field,
|
|
86
|
+
order_desc: desc,
|
|
87
|
+
limit_count: @limit_count,
|
|
88
|
+
offset_count: @offset_count)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def limit(count)
|
|
92
|
+
Query.new(@model_name, @data, @scopes, @relations,
|
|
93
|
+
conditions: @conditions,
|
|
94
|
+
order_field: @order_field,
|
|
95
|
+
order_desc: @order_desc,
|
|
96
|
+
limit_count: count,
|
|
97
|
+
offset_count: @offset_count)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def offset(count)
|
|
101
|
+
Query.new(@model_name, @data, @scopes, @relations,
|
|
102
|
+
conditions: @conditions,
|
|
103
|
+
order_field: @order_field,
|
|
104
|
+
order_desc: @order_desc,
|
|
105
|
+
limit_count: @limit_count,
|
|
106
|
+
offset_count: count)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def all
|
|
110
|
+
results
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def first
|
|
114
|
+
raw_first = apply_filters.first
|
|
115
|
+
raw_first ? wrap_instance(raw_first) : nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def last
|
|
119
|
+
raw_last = apply_filters.last
|
|
120
|
+
raw_last ? wrap_instance(raw_last) : nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def find_by(conditions)
|
|
124
|
+
raw_found = where(conditions).apply_filters.first
|
|
125
|
+
raw_found ? wrap_instance(raw_found) : nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def count
|
|
129
|
+
results.length
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def each(&block)
|
|
133
|
+
all.each(&block)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def paginate(per_page:)
|
|
137
|
+
raise ArgumentError, "per_page must be positive" if per_page <= 0
|
|
138
|
+
|
|
139
|
+
total_items = raw_count
|
|
140
|
+
return [Page.new([], 1, 1, total_items)] if total_items == 0
|
|
141
|
+
|
|
142
|
+
total_pages = (total_items.to_f / per_page).ceil
|
|
143
|
+
(1..total_pages).map do |page_num|
|
|
144
|
+
offset_val = (page_num - 1) * per_page
|
|
145
|
+
items_per_page = if @limit_count && (@limit_count < per_page)
|
|
146
|
+
remaining_items = [@limit_count - offset_val, 0].max
|
|
147
|
+
[remaining_items, per_page].min
|
|
148
|
+
else
|
|
149
|
+
per_page
|
|
150
|
+
end
|
|
151
|
+
items = self.offset(offset_val).limit(items_per_page).to_a
|
|
152
|
+
Page.new(items, page_num, total_pages, total_items)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def raw_count
|
|
157
|
+
apply_filters.length
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def results
|
|
161
|
+
@_results ||= apply_filters.map { |hash| wrap_instance(hash) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def apply_filters
|
|
165
|
+
result = @data.dup
|
|
166
|
+
|
|
167
|
+
# Handle no conditions
|
|
168
|
+
return apply_ordering_and_limits(result) if @conditions.empty?
|
|
169
|
+
|
|
170
|
+
# Process conditions with proper AND/OR logic
|
|
171
|
+
and_result = result.dup
|
|
172
|
+
or_results = []
|
|
173
|
+
|
|
174
|
+
@conditions.each do |condition|
|
|
175
|
+
if condition.is_a?(Array)
|
|
176
|
+
operator = condition[0]
|
|
177
|
+
|
|
178
|
+
case operator
|
|
179
|
+
when :or
|
|
180
|
+
# OR with hash condition - evaluate against original dataset
|
|
181
|
+
condition_hash = condition[1]
|
|
182
|
+
matching = @data.select do |item|
|
|
183
|
+
condition_hash.all? { |field, value| evaluate_condition(item, field.to_sym, value) }
|
|
184
|
+
end
|
|
185
|
+
or_results.concat(matching)
|
|
186
|
+
|
|
187
|
+
when :or_block
|
|
188
|
+
# OR with block condition - evaluate against original dataset
|
|
189
|
+
sub_conditions = condition[1]
|
|
190
|
+
temp_query = self.class.new(@model_name, @data, {}, @relations, conditions: sub_conditions)
|
|
191
|
+
matching = temp_query.apply_filters
|
|
192
|
+
or_results.concat(matching)
|
|
193
|
+
|
|
194
|
+
when :and_block
|
|
195
|
+
# AND with block condition - apply to current AND result
|
|
196
|
+
sub_conditions = condition[1]
|
|
197
|
+
temp_query = self.class.new(@model_name, and_result, {}, @relations, conditions: sub_conditions)
|
|
198
|
+
and_result = temp_query.apply_filters
|
|
199
|
+
end
|
|
200
|
+
else
|
|
201
|
+
# Regular AND condition
|
|
202
|
+
and_result = and_result.select do |item|
|
|
203
|
+
condition.all? { |field, value| evaluate_condition(item, field.to_sym, value) }
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Combine results: AND results + OR results (remove duplicates)
|
|
209
|
+
final_result = and_result
|
|
210
|
+
unless or_results.empty?
|
|
211
|
+
final_result = (final_result + or_results).uniq
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
apply_ordering_and_limits(final_result)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
private
|
|
218
|
+
|
|
219
|
+
def apply_ordering_and_limits(data)
|
|
220
|
+
result = data
|
|
221
|
+
|
|
222
|
+
if @order_field
|
|
223
|
+
# Separate nil values to always sort them to the end
|
|
224
|
+
nil_items = result.select { |item| item[@order_field.to_sym].nil? }
|
|
225
|
+
non_nil_items = result.reject { |item| item[@order_field.to_sym].nil? }
|
|
226
|
+
|
|
227
|
+
# Sort non-nil items with proper type handling
|
|
228
|
+
sorted_non_nil = non_nil_items.sort_by do |item|
|
|
229
|
+
value = item[@order_field.to_sym]
|
|
230
|
+
case value
|
|
231
|
+
when Numeric then [0, value]
|
|
232
|
+
when String then [1, value]
|
|
233
|
+
when Date, Time then [2, value]
|
|
234
|
+
else [3, value.to_s]
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
result = @order_desc ? sorted_non_nil.reverse + nil_items : sorted_non_nil + nil_items
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
result = result[@offset_count..-1] || [] if @offset_count && @offset_count > 0
|
|
242
|
+
result = result[0, @limit_count] || [] if @limit_count
|
|
243
|
+
|
|
244
|
+
result
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
public :apply_filters
|
|
248
|
+
|
|
249
|
+
private
|
|
250
|
+
|
|
251
|
+
def evaluate_condition(item, field, value)
|
|
252
|
+
field_value = item[field]
|
|
253
|
+
|
|
254
|
+
case value
|
|
255
|
+
when ::Hash
|
|
256
|
+
if value.key?(:contains)
|
|
257
|
+
field_value.to_s.include?(value[:contains].to_s)
|
|
258
|
+
elsif value.key?(:starts_with)
|
|
259
|
+
field_value.to_s.start_with?(value[:starts_with].to_s)
|
|
260
|
+
elsif value.key?(:ends_with)
|
|
261
|
+
field_value.to_s.end_with?(value[:ends_with].to_s)
|
|
262
|
+
elsif value.key?(:greater_than)
|
|
263
|
+
field_value && field_value > value[:greater_than]
|
|
264
|
+
elsif value.key?(:less_than)
|
|
265
|
+
field_value && field_value < value[:less_than]
|
|
266
|
+
elsif value.key?(:greater_than_or_equal)
|
|
267
|
+
field_value && field_value >= value[:greater_than_or_equal]
|
|
268
|
+
elsif value.key?(:less_than_or_equal)
|
|
269
|
+
field_value && field_value <= value[:less_than_or_equal]
|
|
270
|
+
elsif value.key?(:from) && value.key?(:to)
|
|
271
|
+
field_value && field_value >= value[:from] && field_value <= value[:to]
|
|
272
|
+
elsif value.key?(:from)
|
|
273
|
+
field_value && field_value >= value[:from]
|
|
274
|
+
elsif value.key?(:to)
|
|
275
|
+
field_value && field_value <= value[:to]
|
|
276
|
+
elsif value.key?(:in)
|
|
277
|
+
Array(value[:in]).include?(field_value)
|
|
278
|
+
elsif value.key?(:not_in)
|
|
279
|
+
!Array(value[:not_in]).include?(field_value)
|
|
280
|
+
elsif value.key?(:not)
|
|
281
|
+
field_value != value[:not]
|
|
282
|
+
elsif value.key?(:exists)
|
|
283
|
+
value[:exists] ? !field_value.nil? : field_value.nil?
|
|
284
|
+
elsif value.key?(:empty)
|
|
285
|
+
if value[:empty]
|
|
286
|
+
field_value.nil? || field_value == '' || (field_value.respond_to?(:empty?) && field_value.empty?)
|
|
287
|
+
else
|
|
288
|
+
!field_value.nil? && field_value != '' && !(field_value.respond_to?(:empty?) && field_value.empty?)
|
|
289
|
+
end
|
|
290
|
+
else
|
|
291
|
+
field_value == value
|
|
292
|
+
end
|
|
293
|
+
when Array
|
|
294
|
+
field_value.is_a?(Array) ? field_value == value : value.include?(field_value)
|
|
295
|
+
when Range
|
|
296
|
+
value.include?(field_value)
|
|
297
|
+
else
|
|
298
|
+
field_value == value
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def wrap_instance(hash)
|
|
303
|
+
InstanceWrapper.new(hash, @model_name)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
data/lib/hq/relations.rb
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# lib/hq/relations.rb
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Hq
|
|
5
|
+
module Inflector
|
|
6
|
+
def self.pluralize(word)
|
|
7
|
+
str = word.to_s
|
|
8
|
+
str.end_with?('s') ? str : "#{str}s"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.singularize(word)
|
|
12
|
+
str = word.to_s
|
|
13
|
+
str.end_with?('s') ? str.chomp('s') : str
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module Relations
|
|
18
|
+
module DSL
|
|
19
|
+
def relations
|
|
20
|
+
@relations ||= {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def belongs_to(name, class_name: nil, foreign_key: nil, primary_key: :id)
|
|
24
|
+
relations[name.to_sym] = {
|
|
25
|
+
type: :belongs_to,
|
|
26
|
+
class_name: class_name || Hq::Inflector.pluralize(name).to_sym,
|
|
27
|
+
foreign_key: foreign_key || "#{name}_id".to_sym,
|
|
28
|
+
primary_key: primary_key
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def has_many(name, class_name: nil, foreign_key: nil, through: nil, source: nil, owner_key: :id)
|
|
33
|
+
options = { type: :has_many, owner_key: owner_key }
|
|
34
|
+
if through
|
|
35
|
+
options[:through] = through.to_sym
|
|
36
|
+
options[:source] = source || Hq::Inflector.singularize(name).to_sym
|
|
37
|
+
else
|
|
38
|
+
options[:class_name] = class_name || name.to_sym
|
|
39
|
+
options[:foreign_key] = foreign_key || "#{Hq::Inflector.singularize(@model_name)}_id".to_sym
|
|
40
|
+
end
|
|
41
|
+
relations[name.to_sym] = options
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def has_one(name, class_name: nil, foreign_key: nil, owner_key: :id)
|
|
45
|
+
relations[name.to_sym] = {
|
|
46
|
+
type: :has_one,
|
|
47
|
+
class_name: class_name || Hq::Inflector.pluralize(name).to_sym,
|
|
48
|
+
foreign_key: foreign_key || "#{Hq::Inflector.singularize(@model_name)}_id".to_sym,
|
|
49
|
+
owner_key: owner_key
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end # End DSL
|
|
53
|
+
|
|
54
|
+
module Fetcher
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def fetch_relation(name)
|
|
58
|
+
@_memoized[name] ||= begin
|
|
59
|
+
relation = @_relations[name]
|
|
60
|
+
raise "Undefined relation '#{name}' called on model '#{@_model_name}'" unless relation
|
|
61
|
+
|
|
62
|
+
case relation[:type]
|
|
63
|
+
when :belongs_to
|
|
64
|
+
fetch_belongs_to(relation)
|
|
65
|
+
when :has_one
|
|
66
|
+
fetch_has_one(relation)
|
|
67
|
+
when :has_many
|
|
68
|
+
relation[:through] ? fetch_has_many_through(relation) : fetch_has_many_direct(relation)
|
|
69
|
+
else
|
|
70
|
+
raise "Unknown relation type: #{relation[:type]}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fetch_belongs_to(relation)
|
|
76
|
+
foreign_key_value = @_hash[relation[:foreign_key]]
|
|
77
|
+
primary_key = relation[:primary_key]
|
|
78
|
+
foreign_key_value ? Hq.query(relation[:class_name]).find_by(primary_key => foreign_key_value) : nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def fetch_has_one(relation)
|
|
82
|
+
owner_key_value = @_hash[relation[:owner_key]]
|
|
83
|
+
Hq.query(relation[:class_name]).find_by(relation[:foreign_key] => owner_key_value)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def fetch_has_many_direct(relation)
|
|
87
|
+
owner_key_value = @_hash[relation[:owner_key]]
|
|
88
|
+
Hq.query(relation[:class_name]).where(relation[:foreign_key] => owner_key_value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def fetch_has_many_through(relation)
|
|
92
|
+
through_association_name = relation[:through]
|
|
93
|
+
source_association_name = relation[:source]
|
|
94
|
+
|
|
95
|
+
through_relation = @_relations[through_association_name]
|
|
96
|
+
unless through_relation && through_relation[:type] == :has_many && !through_relation[:through]
|
|
97
|
+
raise "Invalid 'through' association: #{through_association_name} on model '#{@_model_name}'. Must be a direct has_many."
|
|
98
|
+
end
|
|
99
|
+
intermediate_model_name = through_relation[:class_name]
|
|
100
|
+
intermediate_foreign_key = through_relation[:foreign_key]
|
|
101
|
+
intermediate_owner_key = through_relation[:owner_key]
|
|
102
|
+
|
|
103
|
+
intermediate_relations = Hq.relations_for(intermediate_model_name)
|
|
104
|
+
target_relation_on_intermediate = intermediate_relations[source_association_name]
|
|
105
|
+
|
|
106
|
+
unless target_relation_on_intermediate && target_relation_on_intermediate[:type] == :belongs_to
|
|
107
|
+
inferred_belongs_to = intermediate_relations.values.find do |rel|
|
|
108
|
+
rel[:type] == :belongs_to && Hq::Inflector.pluralize(rel[:class_name]).to_sym == source_association_name
|
|
109
|
+
end
|
|
110
|
+
target_relation_on_intermediate = inferred_belongs_to
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
unless target_relation_on_intermediate && target_relation_on_intermediate[:type] == :belongs_to
|
|
114
|
+
raise("Cannot find target 'belongs_to' association " \
|
|
115
|
+
"(expected name like ':#{source_association_name}' or similar, found: #{intermediate_relations.keys.inspect}) " \
|
|
116
|
+
"on intermediate model '#{intermediate_model_name}' for through relation " \
|
|
117
|
+
"'#{through_association_name}' on model '#{@_model_name}'. Did you define the belongs_to on #{intermediate_model_name}?")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
final_model_name = target_relation_on_intermediate[:class_name]
|
|
121
|
+
final_foreign_key_on_intermediate = target_relation_on_intermediate[:foreign_key]
|
|
122
|
+
final_primary_key = target_relation_on_intermediate[:primary_key]
|
|
123
|
+
|
|
124
|
+
intermediate_records = Hq.query(intermediate_model_name)
|
|
125
|
+
.where(intermediate_foreign_key => @_hash[intermediate_owner_key])
|
|
126
|
+
.to_a
|
|
127
|
+
|
|
128
|
+
target_ids = intermediate_records.map { |record| record[final_foreign_key_on_intermediate] }.compact.uniq
|
|
129
|
+
|
|
130
|
+
target_ids.empty? ? Hq.query(final_model_name).where(id: -1) : Hq.query(final_model_name).where(final_primary_key => { in: target_ids })
|
|
131
|
+
end
|
|
132
|
+
end # End Fetcher
|
|
133
|
+
end # End Relations
|
|
134
|
+
end # End Hq
|
data/lib/hq/version.rb
ADDED