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.
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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
1
+ module Hquery
2
+ VERSION = "1.0.0"
3
+ end