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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +182 -0
  3. data/LICENSE.txt +15 -0
  4. data/README.md +294 -0
  5. data/lib/core_ext/date.rb +15 -0
  6. data/lib/core_ext/time.rb +23 -0
  7. data/lib/simply_couch/class_methods_base.rb +72 -0
  8. data/lib/simply_couch/has_attachment.rb +225 -0
  9. data/lib/simply_couch/include_relation.rb +160 -0
  10. data/lib/simply_couch/instance_methods.rb +356 -0
  11. data/lib/simply_couch/locale/en.yml +5 -0
  12. data/lib/simply_couch/model/ancestry.rb +307 -0
  13. data/lib/simply_couch/model/association_property.rb +26 -0
  14. data/lib/simply_couch/model/attachments.rb +90 -0
  15. data/lib/simply_couch/model/belongs_to.rb +140 -0
  16. data/lib/simply_couch/model/database.rb +209 -0
  17. data/lib/simply_couch/model/embedded_in.rb +196 -0
  18. data/lib/simply_couch/model/find_by.rb +202 -0
  19. data/lib/simply_couch/model/finders.rb +77 -0
  20. data/lib/simply_couch/model/has_and_belongs_to_many.rb +223 -0
  21. data/lib/simply_couch/model/has_many.rb +177 -0
  22. data/lib/simply_couch/model/has_many_embedded.rb +187 -0
  23. data/lib/simply_couch/model/has_one.rb +75 -0
  24. data/lib/simply_couch/model/pagination.rb +25 -0
  25. data/lib/simply_couch/model/pagination_options.rb +55 -0
  26. data/lib/simply_couch/model/persistence.rb +411 -0
  27. data/lib/simply_couch/model/properties.rb +11 -0
  28. data/lib/simply_couch/model/validations.rb +28 -0
  29. data/lib/simply_couch/model/view/base_view_spec.rb +115 -0
  30. data/lib/simply_couch/model/view/custom_view_spec.rb +49 -0
  31. data/lib/simply_couch/model/view/custom_views.rb +50 -0
  32. data/lib/simply_couch/model/view/lists.rb +25 -0
  33. data/lib/simply_couch/model/view/model_view_spec.rb +106 -0
  34. data/lib/simply_couch/model/view/properties_view_spec.rb +53 -0
  35. data/lib/simply_couch/model/view/raw_view_spec.rb +30 -0
  36. data/lib/simply_couch/model/view/view_query.rb +98 -0
  37. data/lib/simply_couch/model/view.rb +8 -0
  38. data/lib/simply_couch/model/views/array_property_view_spec.rb +26 -0
  39. data/lib/simply_couch/model/views/deleted_model_view_spec.rb +43 -0
  40. data/lib/simply_couch/model/views.rb +2 -0
  41. data/lib/simply_couch/model.rb +195 -0
  42. data/lib/simply_couch/rake.rb +23 -0
  43. data/lib/simply_couch/storage.rb +147 -0
  44. data/lib/simply_couch.rb +26 -0
  45. 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