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