simply_couch 0.1.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 +182 -0
- data/LICENSE.txt +15 -0
- data/README.md +294 -0
- data/lib/core_ext/date.rb +15 -0
- data/lib/core_ext/time.rb +23 -0
- data/lib/simply_couch/class_methods_base.rb +72 -0
- data/lib/simply_couch/has_attachment.rb +225 -0
- data/lib/simply_couch/include_relation.rb +160 -0
- data/lib/simply_couch/instance_methods.rb +356 -0
- data/lib/simply_couch/locale/en.yml +5 -0
- data/lib/simply_couch/model/ancestry.rb +307 -0
- data/lib/simply_couch/model/association_property.rb +26 -0
- data/lib/simply_couch/model/attachments.rb +90 -0
- data/lib/simply_couch/model/belongs_to.rb +140 -0
- data/lib/simply_couch/model/database.rb +209 -0
- data/lib/simply_couch/model/embedded_in.rb +196 -0
- data/lib/simply_couch/model/find_by.rb +202 -0
- data/lib/simply_couch/model/finders.rb +77 -0
- data/lib/simply_couch/model/has_and_belongs_to_many.rb +223 -0
- data/lib/simply_couch/model/has_many.rb +177 -0
- data/lib/simply_couch/model/has_many_embedded.rb +187 -0
- data/lib/simply_couch/model/has_one.rb +75 -0
- data/lib/simply_couch/model/pagination.rb +25 -0
- data/lib/simply_couch/model/pagination_options.rb +55 -0
- data/lib/simply_couch/model/persistence.rb +411 -0
- data/lib/simply_couch/model/properties.rb +11 -0
- data/lib/simply_couch/model/validations.rb +28 -0
- data/lib/simply_couch/model/view/base_view_spec.rb +115 -0
- data/lib/simply_couch/model/view/custom_view_spec.rb +49 -0
- data/lib/simply_couch/model/view/custom_views.rb +50 -0
- data/lib/simply_couch/model/view/lists.rb +25 -0
- data/lib/simply_couch/model/view/model_view_spec.rb +106 -0
- data/lib/simply_couch/model/view/properties_view_spec.rb +53 -0
- data/lib/simply_couch/model/view/raw_view_spec.rb +30 -0
- data/lib/simply_couch/model/view/view_query.rb +98 -0
- data/lib/simply_couch/model/view.rb +8 -0
- data/lib/simply_couch/model/views/array_property_view_spec.rb +26 -0
- data/lib/simply_couch/model/views/deleted_model_view_spec.rb +43 -0
- data/lib/simply_couch/model/views.rb +2 -0
- data/lib/simply_couch/model.rb +195 -0
- data/lib/simply_couch/rake.rb +23 -0
- data/lib/simply_couch/storage.rb +147 -0
- data/lib/simply_couch.rb +26 -0
- metadata +144 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SimplyCouch
|
|
4
|
+
# Drop-in replacement for Paperclip in SimplyCouch/CouchDB models.
|
|
5
|
+
#
|
|
6
|
+
# Usage (replaces `include Paperclip::Glue` + `has_attached_file`):
|
|
7
|
+
#
|
|
8
|
+
# class Image
|
|
9
|
+
# include SimplyCouch::Model
|
|
10
|
+
# include SimplyCouch::HasAttachment
|
|
11
|
+
#
|
|
12
|
+
# has_attachment :file, styles: {
|
|
13
|
+
# medium: "354x1000>",
|
|
14
|
+
# thumb: "160x1250>"
|
|
15
|
+
# }
|
|
16
|
+
#
|
|
17
|
+
# # Validations (standard Rails, replaces validates_attachment)
|
|
18
|
+
# validate :file_content_type_must_be_image
|
|
19
|
+
#
|
|
20
|
+
# private
|
|
21
|
+
#
|
|
22
|
+
# def file_content_type_must_be_image
|
|
23
|
+
# return if file.blank?
|
|
24
|
+
# unless %w[image/jpeg image/gif image/png].include?(file_content_type)
|
|
25
|
+
# errors.add(:file, :invalid_content_type)
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# The module auto-declares CouchDB properties for:
|
|
31
|
+
# <name>_file_name, <name>_content_type, <name>_file_size, <name>_updated_at
|
|
32
|
+
#
|
|
33
|
+
# Files are stored on disk at:
|
|
34
|
+
# public/system/<attachment>/<id>/original.<ext>
|
|
35
|
+
# public/system/<attachment>/<id>/<style>.<ext>
|
|
36
|
+
#
|
|
37
|
+
# Uses MiniMagick (ImageMagick) for thumbnail generation.
|
|
38
|
+
# Same geometry syntax as Paperclip: "300x300>", "160x1250>", etc.
|
|
39
|
+
module HasAttachment
|
|
40
|
+
def self.included(base)
|
|
41
|
+
base.extend(ClassMethods)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Proxy object returned by attachment getters (e.g. `image.file`).
|
|
45
|
+
# Mimics the Paperclip::Attachment API that views expect.
|
|
46
|
+
class Proxy
|
|
47
|
+
def initialize(record, name)
|
|
48
|
+
@record = record
|
|
49
|
+
@name = name
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def present?
|
|
53
|
+
@record.send(:"#{@name}_file_name").present?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def blank?
|
|
57
|
+
!present?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def url(style = nil)
|
|
61
|
+
@record.send(:"#{@name}_url", style)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def original_filename
|
|
65
|
+
@record.send(:"#{@name}_file_name")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def content_type
|
|
69
|
+
@record.send(:"#{@name}_content_type")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def size
|
|
73
|
+
@record.send(:"#{@name}_file_size")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Delegate anything else to the record
|
|
77
|
+
def method_missing(method, *args, &block)
|
|
78
|
+
if @record.respond_to?(method, true)
|
|
79
|
+
@record.send(method, *args, &block)
|
|
80
|
+
else
|
|
81
|
+
super
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def respond_to_missing?(method, include_private = false)
|
|
86
|
+
@record.respond_to?(method, include_private) || super
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
module ClassMethods
|
|
91
|
+
def has_attachment(name, styles: {}, default_url: nil, default_style: :original)
|
|
92
|
+
# Auto-declare CouchDB properties for attachment metadata
|
|
93
|
+
property :"#{name}_file_name"
|
|
94
|
+
property :"#{name}_content_type"
|
|
95
|
+
property :"#{name}_file_size", type: Integer
|
|
96
|
+
property :"#{name}_updated_at", type: Time
|
|
97
|
+
|
|
98
|
+
# Register configuration
|
|
99
|
+
attachment_registry[name] = {
|
|
100
|
+
styles: styles,
|
|
101
|
+
default_url: default_url,
|
|
102
|
+
default_style: default_style,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# ---- Setter: model.file = uploaded_file ----
|
|
106
|
+
define_method(:"#{name}=") do |uploaded|
|
|
107
|
+
# Handle clearing: nil, empty string
|
|
108
|
+
if uploaded.nil? || (uploaded.respond_to?(:empty?) && uploaded.empty?)
|
|
109
|
+
send(:"#{name}_file_name=", nil)
|
|
110
|
+
send(:"#{name}_content_type=", nil)
|
|
111
|
+
send(:"#{name}_file_size=", nil)
|
|
112
|
+
send(:"#{name}_updated_at=", nil)
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
config = self.class.attachment_registry[name]
|
|
117
|
+
|
|
118
|
+
# Extract file info
|
|
119
|
+
original_filename = if uploaded.respond_to?(:original_filename)
|
|
120
|
+
uploaded.original_filename
|
|
121
|
+
elsif uploaded.respond_to?(:path)
|
|
122
|
+
File.basename(uploaded.path)
|
|
123
|
+
else
|
|
124
|
+
"file"
|
|
125
|
+
end
|
|
126
|
+
ext = File.extname(original_filename)
|
|
127
|
+
ext = ".bin" if ext.blank?
|
|
128
|
+
|
|
129
|
+
content_type = uploaded.respond_to?(:content_type) ? uploaded.content_type : nil
|
|
130
|
+
file_size = uploaded.respond_to?(:size) ? uploaded.size : nil
|
|
131
|
+
|
|
132
|
+
# Read content (handle multiple upload types)
|
|
133
|
+
content = if uploaded.respond_to?(:read)
|
|
134
|
+
data = uploaded.read
|
|
135
|
+
uploaded.rewind if uploaded.respond_to?(:rewind)
|
|
136
|
+
data
|
|
137
|
+
elsif uploaded.respond_to?(:path) && File.exist?(uploaded.path)
|
|
138
|
+
File.binread(uploaded.path)
|
|
139
|
+
elsif uploaded.respond_to?(:tempfile)
|
|
140
|
+
uploaded.tempfile.read
|
|
141
|
+
else
|
|
142
|
+
uploaded.to_s
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Build storage path
|
|
146
|
+
record_id = respond_to?(:id) && id.present? ? id.to_s : "tmp"
|
|
147
|
+
base_dir = Rails.root.join("public", "system", name.to_s, record_id)
|
|
148
|
+
FileUtils.mkdir_p(base_dir)
|
|
149
|
+
|
|
150
|
+
begin
|
|
151
|
+
# Write original file
|
|
152
|
+
original_path = base_dir.join("original#{ext}")
|
|
153
|
+
File.binwrite(original_path, content)
|
|
154
|
+
|
|
155
|
+
# Generate thumbnail styles
|
|
156
|
+
config[:styles].each do |style_name, geometry|
|
|
157
|
+
style_path = base_dir.join("#{style_name}#{ext}")
|
|
158
|
+
begin
|
|
159
|
+
image = MiniMagick::Image.open(original_path.to_s)
|
|
160
|
+
image.resize(geometry)
|
|
161
|
+
image.write(style_path.to_s)
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
# If ImageMagick fails, copy original as fallback
|
|
164
|
+
Rails.logger.warn(
|
|
165
|
+
"[HasAttachment] Could not generate #{style_name} for #{name}: #{e.message}"
|
|
166
|
+
)
|
|
167
|
+
FileUtils.cp(original_path, style_path)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Store metadata as CouchDB properties
|
|
172
|
+
send(:"#{name}_file_name=", original_filename)
|
|
173
|
+
send(:"#{name}_content_type=", content_type)
|
|
174
|
+
send(:"#{name}_file_size=", file_size || content.bytesize)
|
|
175
|
+
send(:"#{name}_updated_at=", Time.current)
|
|
176
|
+
rescue StandardError => e
|
|
177
|
+
errors.add(name, "could not be processed: #{e.message}")
|
|
178
|
+
Rails.logger.error("[HasAttachment] Error processing #{name}: #{e.message}")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# ---- Getter: model.file → Proxy ----
|
|
183
|
+
define_method(name) do
|
|
184
|
+
Proxy.new(self, name)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# ---- URL helper: model.file_url(:thumb) ----
|
|
188
|
+
define_method(:"#{name}_url") do |style_name = nil|
|
|
189
|
+
config = self.class.attachment_registry[name]
|
|
190
|
+
style_name ||= config[:default_style]
|
|
191
|
+
|
|
192
|
+
fname = send(:"#{name}_file_name")
|
|
193
|
+
if fname.blank?
|
|
194
|
+
return config[:default_url] if config[:default_url]
|
|
195
|
+
return nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
ext = File.extname(fname)
|
|
199
|
+
record_id = respond_to?(:id) && id.present? ? id.to_s : "tmp"
|
|
200
|
+
|
|
201
|
+
"/system/#{name}/#{record_id}/#{style_name}#{ext}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# ---- Backward compat: *_path for Paperclip migrations ----
|
|
205
|
+
define_method(:"#{name}_path") do |style_name = nil|
|
|
206
|
+
config = self.class.attachment_registry[name]
|
|
207
|
+
style_name ||= config[:default_style]
|
|
208
|
+
|
|
209
|
+
fname = send(:"#{name}_file_name")
|
|
210
|
+
return nil if fname.blank?
|
|
211
|
+
|
|
212
|
+
ext = File.extname(fname)
|
|
213
|
+
record_id = respond_to?(:id) && id.present? ? id.to_s : "tmp"
|
|
214
|
+
|
|
215
|
+
Rails.root.join("public", "system", name.to_s, record_id, "#{style_name}#{ext}").to_s
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Registry of all attachments defined on this class
|
|
220
|
+
def attachment_registry
|
|
221
|
+
@_has_attachment_registry ||= {}
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# This file is a tool extension to simply stored. Its intention is to not
|
|
2
|
+
# change anything of an existing implementation, but to hugely speedup existing
|
|
3
|
+
# implementations. I will illustrate this given the example relation types:
|
|
4
|
+
# Person has_many posts and belongs to a group, Post has many comments, Comment belongs to Writer
|
|
5
|
+
# If for a reason you have one page where you want to display all of these objects
|
|
6
|
+
# (Person, Post, Comment) you can ofcourse create a view returning all these
|
|
7
|
+
# objects with a smart key for some handy selection. This will probably end up
|
|
8
|
+
# in a controller implementation:
|
|
9
|
+
# @persons = view_result.select{|r| r.is_a?(Person)}
|
|
10
|
+
# @posts = view_result.select{|r| r.is_a?(Post)}
|
|
11
|
+
# @comments = view_result.select{|r| r.is_a?(Comment)}
|
|
12
|
+
# @writers = view_result.select{|r| r.is_a?(Writer)}
|
|
13
|
+
# This probably is the recommended way of solving problems in most cases, but sometimes
|
|
14
|
+
# because you are lazy or some other obscure reason, you want to use the standard
|
|
15
|
+
# SimplyCouch behaviour, but not wait too long. For example, if I have a list of 40 persons,
|
|
16
|
+
# all having 5 posts that all have 10 comments belonging to a writer, getting all these
|
|
17
|
+
# through their standard relations:
|
|
18
|
+
# @persons.each{ |person| person.posts.each{ |post| post.comments.each{ |comment| puts person.group.name + comment.writer.name } } }
|
|
19
|
+
# This will result in 40 * 5 * 10 + 40 = 2040 queries to the database. Doing exactly the same thing using this script
|
|
20
|
+
# will look like:
|
|
21
|
+
# @persons = Person.all.include_relation( :group, posts: { conmments: :writer } )
|
|
22
|
+
# The useless script above will not take 2040 queries but:
|
|
23
|
+
# 1 (persons) + 1 (group) + 1 (posts) + 1 (comments) + 1 (write) = 5 queries
|
|
24
|
+
# This makes a difference.
|
|
25
|
+
# Issues:
|
|
26
|
+
# * Supported relation types:
|
|
27
|
+
# * has_many
|
|
28
|
+
# * belongs_to
|
|
29
|
+
# * belongs_to relations, that have no value (nil) will be queried again.
|
|
30
|
+
# That would make the calculation above: 5 + number of persons without a group + number of comments without a writer
|
|
31
|
+
# * Little test coverage
|
|
32
|
+
class Array
|
|
33
|
+
def include_relation(*relations_arg)
|
|
34
|
+
return self if empty?
|
|
35
|
+
relations = {}
|
|
36
|
+
database = nil
|
|
37
|
+
database = relations_arg.last.delete(:database) if relations_arg.last.is_a?(Hash) and relations_arg.last.has_key?(:database)
|
|
38
|
+
database ||= self.first&.class&.database
|
|
39
|
+
raise ArgumentError, "Cannot include relations without a database — pass :database option or ensure models respond to .database" unless database
|
|
40
|
+
|
|
41
|
+
# Make sure relations is a Hash, process up to two levels for recursion
|
|
42
|
+
# keys with value nil will not have a followup
|
|
43
|
+
relations_arg.each do |arg|
|
|
44
|
+
if arg.is_a?(Symbol)
|
|
45
|
+
relations[arg] = nil
|
|
46
|
+
elsif arg.is_a?(Hash)
|
|
47
|
+
arg.each{|k, v| relations[k] = v}
|
|
48
|
+
elsif arg.is_a?(Array)
|
|
49
|
+
arg.each do |v|
|
|
50
|
+
if arg.is_a?(Symbol)
|
|
51
|
+
relations[v] = nil
|
|
52
|
+
elsif arg.is_a?(Hash)
|
|
53
|
+
arg.each{|k, v| relations[k] = v}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# For now, assume an array of only one datatype
|
|
60
|
+
klass = first.class
|
|
61
|
+
|
|
62
|
+
relations.each do |relation, followup|
|
|
63
|
+
property = klass.properties.find{|p| p.name == relation}
|
|
64
|
+
unless property
|
|
65
|
+
warn "Attempt to include_relations #{relation} on #{klass.name} but does not have supporting relation", uplevel: 1
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
case property
|
|
69
|
+
when SimplyCouch::Model::HasMany::Property then
|
|
70
|
+
other_class = property.options[:class_name].constantize
|
|
71
|
+
other_property = other_class.properties.find{|p| p.is_a?(SimplyCouch::Model::BelongsTo::Property) && p.options[:class_name] == klass.name}
|
|
72
|
+
#TODO riase when soft_delete is enabled
|
|
73
|
+
view_name = "by_#{other_property.name}_id"
|
|
74
|
+
raise "Cannot include has_many relation #{other_class.name.underscore.pluralize} on #{klass.name} when view :#{view_name}, key: :#{other_property.name}_id is not defined on #{other_class.name}" unless other_class.views[view_name].present?
|
|
75
|
+
relation_objects = other_class.database.view(other_class.send(view_name, keys: collect(&:id))) #not working yet
|
|
76
|
+
if followup # deeper nested including
|
|
77
|
+
case followup
|
|
78
|
+
when Hash
|
|
79
|
+
then relation_objects.include_relation(followup.merge(database: database))
|
|
80
|
+
else
|
|
81
|
+
relation_objects.include_relation(*(Array.wrap(followup) + [{database: database}]))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
for obj in self
|
|
86
|
+
found_relation_objects = relation_objects.select{|r| r.send("#{other_property.name}_id") == obj.id}
|
|
87
|
+
|
|
88
|
+
# Make sure every object has a cached value, no more loading is done
|
|
89
|
+
obj.instance_variable_set("@#{relation}", {all: []}) unless obj.instance_variable_get("@#{relation}").try('[]', :all)
|
|
90
|
+
if found_relation_objects.any?
|
|
91
|
+
obj.instance_variable_get("@#{relation}")[:all] |= found_relation_objects
|
|
92
|
+
if reverse_property_name = other_class.properties.find{|p| p.is_a?(SimplyCouch::Model::BelongsTo::Property) && p.options[:class_name] == klass.name }.try(:name)
|
|
93
|
+
found_relation_objects.each{|relation_object| relation_object.instance_variable_set("@#{reverse_property_name}", obj)}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
when SimplyCouch::Model::BelongsTo::Property then
|
|
98
|
+
key = "#{relation}_id"
|
|
99
|
+
# Collect keys for all objects
|
|
100
|
+
keys = []
|
|
101
|
+
each do |obj|
|
|
102
|
+
next unless obj.is_a?(SimplyCouch::Model) && obj.respond_to?(key)
|
|
103
|
+
keys << obj.send(key)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Get from the database
|
|
107
|
+
relation_objects = database.couchrest_database.bulk_load(keys.compact.uniq)
|
|
108
|
+
relation_objects = Array.wrap(relation_objects['rows']).map{|r| r['doc']}.compact if relation_objects.is_a?(Hash)
|
|
109
|
+
relation_objects ||= [] # Ensure array datatype
|
|
110
|
+
if followup # deeper nested including
|
|
111
|
+
case followup
|
|
112
|
+
when Hash
|
|
113
|
+
then relation_objects.include_relation(followup.merge(database: database))
|
|
114
|
+
else
|
|
115
|
+
relation_objects.include_relation(*(Array.wrap(followup) + [{database: database}]))
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Set to attributes
|
|
120
|
+
each do |obj|
|
|
121
|
+
obj.instance_variable_set("@#{relation}", relation_objects.find{|o| o.id == obj.send(key)})
|
|
122
|
+
end
|
|
123
|
+
when SimplyCouch::Model::HasAndBelongsToMany::Property
|
|
124
|
+
if property.options[:storing_keys]
|
|
125
|
+
key = "#{relation.to_s.singularize}_ids"
|
|
126
|
+
# Collect relation ids for all objects
|
|
127
|
+
relation_ids = []
|
|
128
|
+
each do |obj|
|
|
129
|
+
next unless obj.is_a?(SimplyCouch::Model) && obj.respond_to?(key) && obj.send(key).present?
|
|
130
|
+
relation_ids += obj.send(key)
|
|
131
|
+
end
|
|
132
|
+
# Create unique list of ids, this will optimize stuff and synchronize the object ids
|
|
133
|
+
relation_ids = relation_ids.flatten.compact.uniq
|
|
134
|
+
|
|
135
|
+
# Get from the database
|
|
136
|
+
relation_objects = database.couchrest_database.bulk_load(relation_ids)
|
|
137
|
+
relation_objects = Array.wrap(relation_objects['rows']).map{|r| r['doc']}.compact if relation_objects.is_a?(Hash)
|
|
138
|
+
relation_objects ||= [] # Ensure array datatype
|
|
139
|
+
each do |obj|
|
|
140
|
+
obj.instance_variable_set("@#{relation}", {all: relation_objects.select{|o| Array.wrap(obj.send(key)).include?(o.id)}})
|
|
141
|
+
end
|
|
142
|
+
if followup # deeper nested including
|
|
143
|
+
case followup
|
|
144
|
+
when Hash
|
|
145
|
+
then relation_objects.include_relation(followup.merge(database: database))
|
|
146
|
+
else
|
|
147
|
+
relation_objects.include_relation(*(Array.wrap(followup) + [{database: database}]))
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
self
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Alias method as plural form
|
|
157
|
+
def include_relations(*args)
|
|
158
|
+
include_relation(*args)
|
|
159
|
+
end
|
|
160
|
+
end
|