safrano 0.3.2 → 0.4.2

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.
@@ -37,25 +37,56 @@ end
37
37
 
38
38
  # filter base class and subclass in our OData namespace
39
39
  module OData
40
+ class FilterBase
41
+ # re-useable empty filtering (idempotent)
42
+ EmptyFilter = new
43
+
44
+ def self.factory(filterstr)
45
+ filterstr.nil? ? EmptyFilter : FilterByParse.new(filterstr)
46
+ end
47
+
48
+ def apply_to_dataset(dtcx)
49
+ dtcx
50
+ end
51
+
52
+ # finalize
53
+ def finalize(jh) end
54
+
55
+ def empty?
56
+ true
57
+ end
58
+
59
+ def parse_error?
60
+ false
61
+ end
62
+ end
40
63
  # should handle everything by parsing
41
- class FilterByParse
42
- def initialize(filterstr, jh)
64
+ class FilterByParse < FilterBase
65
+ attr_reader :filterstr
66
+ def initialize(filterstr)
43
67
  @filterstr = filterstr.dup
44
68
  @ast = OData::Filter::Parser.new(@filterstr).build
45
- @jh = jh
46
69
  end
47
70
 
48
- def apply_to_dataset(dtcx)
49
- filtexpr = @ast.sequel_expr(@jh)
50
- dtcx = @jh.dataset(dtcx).where(filtexpr).select_all(@jh.start_model.table_name)
71
+ # this build's up the Sequel Filter Expression, and as a side effect,
72
+ # it also finalizes the join helper that we need for the start dataset join
73
+ # the join-helper is shared by the order-by object and was potentially already
74
+ # partly built on order-by object creation.
75
+ def finalize(jh)
76
+ @filtexpr = @ast.sequel_expr(jh)
51
77
  end
52
78
 
53
- def sequel_expr
54
- @ast.sequel_expr(@jh)
79
+ def apply_to_dataset(dtcx)
80
+ # normally finalize is called before, and thus @filtexpr is set
81
+ dtcx.where(@filtexpr)
55
82
  end
56
83
 
57
84
  def parse_error?
58
- @ast.kind_of? StandardError
85
+ @ast.is_a? StandardError
86
+ end
87
+
88
+ def empty?
89
+ false
59
90
  end
60
91
  end
61
92
  end
@@ -0,0 +1,279 @@
1
+ require 'rack'
2
+ require 'fileutils'
3
+ require_relative './navigation_attribute.rb'
4
+
5
+ module OData
6
+ module Media
7
+ # base class for Media Handler
8
+ class Handler
9
+ end
10
+
11
+ # Simple static File/Directory based media store handler
12
+ # similar to Rack::Static
13
+ # with a flat directory structure
14
+ class Static < Handler
15
+ def initialize(root: nil)
16
+ @root = File.absolute_path(root || Dir.pwd)
17
+ @file_server = ::Rack::File.new(@root)
18
+ end
19
+
20
+ # TODO: testcase and better abs_klass_dir design
21
+ def register(klass)
22
+ abs_klass_dir = File.absolute_path(klass.type_name, @root)
23
+ FileUtils.makedirs abs_klass_dir unless Dir.exist?(abs_klass_dir)
24
+ end
25
+
26
+ # minimal working implementation...
27
+ # Note: @file_server works relative to @root directory
28
+ def odata_get(request:, entity:)
29
+ media_env = request.env.dup
30
+ media_env['PATH_INFO'] = filename(entity)
31
+ fsret = @file_server.call(media_env)
32
+ if fsret.first == 200
33
+ # provide own content type as we keep it in the media entity
34
+ fsret[1]['Content-Type'] = entity.content_type
35
+ end
36
+ fsret
37
+ end
38
+
39
+ # TODO: [perf] this can be precalculated and cached on MediaModelKlass level
40
+ # and passed as argument to save_file
41
+ # eg. /@root/Photo
42
+ def abs_klass_dir(entity)
43
+ File.absolute_path(entity.klass_dir, @root)
44
+ end
45
+
46
+ # this is relative to @root
47
+ # eg. Photo/1
48
+ def media_path(entity)
49
+ File.join(entity.klass_dir, media_directory(entity))
50
+ end
51
+
52
+ # relative to @root
53
+ # eg Photo/1/pommes-topaz.jpg
54
+ def filename(entity)
55
+ Dir.chdir(abs_path(entity)) do
56
+ # simple design: one file per directory, and the directory
57
+ # contains the media entity-id --> implicit link between the media
58
+ # entity
59
+ File.join(media_path(entity), Dir.glob('*').first)
60
+ end
61
+ end
62
+
63
+ # /@root/Photo/1
64
+ def abs_path(entity)
65
+ File.absolute_path(media_path(entity), @root)
66
+ end
67
+
68
+ # this is relative to abs_klass_dir(entity) eg to /@root/Photo
69
+ # simplest implementation is media_directory = entity.media_path_id
70
+ # --> we get a 1 level depth flat directory structure
71
+ def media_directory(entity)
72
+ entity.media_path_id
73
+ end
74
+
75
+ def in_media_directory(entity)
76
+ mpi = media_directory(entity)
77
+ Dir.mkdir mpi unless Dir.exist?(mpi)
78
+ Dir.chdir mpi do
79
+ yield
80
+ end
81
+ end
82
+
83
+ def odata_delete(entity:)
84
+ Dir.chdir(abs_klass_dir(entity)) do
85
+ in_media_directory(entity) do
86
+ Dir.glob('*').each { |oldf| File.delete(oldf) }
87
+ end
88
+ end
89
+ end
90
+
91
+ # Here as well, MVP implementation
92
+ def save_file(data:, filename:, entity:)
93
+ Dir.chdir(abs_klass_dir(entity)) do
94
+ in_media_directory(entity) do
95
+ filename = '1'
96
+ File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
97
+ end
98
+ end
99
+ end
100
+
101
+ # needed for having a changing media ressource "source" metadata
102
+ # after each upload, so that clients get informed about new versions
103
+ # of the same media ressource
104
+ def ressource_version(entity)
105
+ Dir.chdir(abs_klass_dir(entity)) do
106
+ in_media_directory(entity) do
107
+ Dir.glob('*').last
108
+ end
109
+ end
110
+ end
111
+
112
+ # Here as well, MVP implementation
113
+ def replace_file(data:, filename:, entity:)
114
+ Dir.chdir(abs_klass_dir(entity)) do
115
+ in_media_directory(entity) do
116
+ version = nil
117
+ Dir.glob('*').each do |oldf|
118
+ version = oldf
119
+ File.delete(oldf)
120
+ end
121
+ filename = (version.to_i + 1).to_s
122
+ File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
123
+ end
124
+ end
125
+ end
126
+ end
127
+ # Simple static File/Directory based media store handler
128
+ # similar to Rack::Static
129
+ # with directory Tree structure
130
+
131
+ class StaticTree < Static
132
+ SEP = '/00/'.freeze
133
+ VERS = '/v'.freeze
134
+
135
+ def self.path_builder(ids)
136
+ ids.map { |id| id.to_s.chars.join('/') }.join(SEP) << VERS
137
+ end
138
+
139
+ # this is relative to abs_klass_dir(entity) eg to /@root/Photo
140
+ # tree-structure
141
+ # media_path_ids = 1 --> 1
142
+ # media_path_ids = 15 --> 1/5
143
+ # media_path_ids = 555 --> 5/5/5
144
+ # media_path_ids = 5,5,5 --> 5/00/5/00/5
145
+ # media_path_ids = 5,00,5 --> 5/00/0/0/00/5
146
+ # media_path_ids = 5,xyz,5 --> 5/00/x/y/z/00/5
147
+ def media_directory(entity)
148
+ StaticTree.path_builder(entity.media_path_ids)
149
+ # entity.media_path_ids.map{|id| id.to_s.chars.join('/')}.join(@sep)
150
+ end
151
+
152
+ def in_media_directory(entity)
153
+ mpi = media_directory(entity)
154
+ FileUtils.makedirs mpi unless Dir.exist?(mpi)
155
+ Dir.chdir(mpi) { yield }
156
+ end
157
+
158
+ def odata_delete(entity:)
159
+ Dir.chdir(abs_klass_dir(entity)) do
160
+ in_media_directory(entity) do
161
+ Dir.glob('*').each { |oldf| File.delete(oldf) if File.file?(oldf) }
162
+ end
163
+ end
164
+ end
165
+
166
+ # Here as well, MVP implementation
167
+ # def replace_file(data:, filename:, entity:)
168
+ # Dir.chdir(abs_klass_dir(entity)) do
169
+ # in_media_directory(entity) do
170
+ # Dir.glob('*').each { |oldf| File.delete(oldf) if File.file?(oldf) }
171
+ # File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
172
+ # end
173
+ # end
174
+ # end
175
+ # Here as well, MVP implementation
176
+ def replace_file(data:, filename:, entity:)
177
+ Dir.chdir(abs_klass_dir(entity)) do
178
+ in_media_directory(entity) do
179
+ version = nil
180
+ Dir.glob('*').each do |oldf|
181
+ version = oldf
182
+ File.delete(oldf)
183
+ end
184
+ filename = (version.to_i + 1).to_s
185
+ File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
191
+
192
+ # special handling for media entity
193
+ module EntityClassMedia
194
+ attr_reader :media_handler
195
+ attr_reader :slug_field
196
+
197
+ # API method for defining the media handler
198
+ # eg.
199
+ # publish_media_model photos do
200
+ # use OData::Media::Static, :root => '/media_root'
201
+ # end
202
+
203
+ def set_default_media_handler
204
+ @media_handler = OData::Media::Static.new
205
+ end
206
+
207
+ def use(klass, args)
208
+ @media_handler = klass.new(**args)
209
+ end
210
+
211
+ # API method for setting the model field mapped to SLUG on upload
212
+ def slug(inp)
213
+ @slug_field = inp
214
+ end
215
+
216
+ def api_check_media_fields
217
+ raise(OData::API::MediaModelError, self) unless db_schema.key?(:content_type)
218
+
219
+ # unless self.db_schema.has_key?(:media_src)
220
+ # raise OData::API::MediaModelError, self
221
+ # end
222
+ end
223
+
224
+ # END API methods
225
+
226
+ def new_media_entity(mimetype:)
227
+ nh = { 'content_type' => mimetype }
228
+ new_from_hson_h(nh)
229
+ end
230
+
231
+ # POST for media entity collection --> Create media-entity linked to
232
+ # uploaded file from payload
233
+ # 1. create new media entity
234
+ # 2. get the pk/id from the new media entity
235
+ # 3. Upload the file and use the pk/id to get an unique Directory/filename
236
+ # assignment to the media entity record
237
+ # NOTE: we will implement this first in a MVP way. There will be plenty of
238
+ # potential future enhancements (performance, scallability, flexibility... with added complexity)
239
+ # 4. create relation to parent if needed
240
+ def odata_create_entity_and_relation(req, assoc, parent)
241
+ req.with_media_data do |data, mimetype, filename|
242
+ ## future enhancement: validate allowed mimetypes ?
243
+ # if (invalid = invalid_media_mimetype(mimetype))
244
+ # ::OData::Request::ON_CGST_ERROR.call(req)
245
+ # return [422, {}, ['Invalid mime type: ', invalid.to_s]]
246
+ # end
247
+
248
+ if req.accept?(APPJSON)
249
+
250
+ new_entity = new_media_entity(mimetype: mimetype)
251
+
252
+ if slug_field
253
+
254
+ new_entity.set_fields({ slug_field => filename },
255
+ data_fields,
256
+ missing: :skip)
257
+ end
258
+
259
+ # to_one rels are create with FK data set on the parent entity
260
+ if parent
261
+ odata_create_save_entity_and_rel(req, new_entity, assoc, parent)
262
+ else
263
+ # in-changeset requests get their own transaction
264
+ new_entity.save(transaction: !req.in_changeset)
265
+ end
266
+
267
+ req.register_content_id_ref(new_entity)
268
+ new_entity.copy_request_infos(req)
269
+
270
+ media_handler.save_file(data: data, entity: new_entity, filename: filename)
271
+
272
+ [201, CT_JSON, new_entity.to_odata_post_json(service: req.service)]
273
+ else # TODO: other formats
274
+ 415
275
+ end
276
+ end
277
+ end
278
+ end
279
+ end
@@ -1,18 +1,32 @@
1
1
  require 'odata/error.rb'
2
2
 
3
- # Ordering with ruby expression
4
- module OrderWithRuby
5
- # this module requires the @fn attribute to exist where it is used
6
- def fn=(fnam)
7
- @fn = fnam
8
- @fn_tab = fnam.split('/').map(&:to_sym)
9
- end
10
- end
11
-
12
3
  # all ordering related classes in our OData module
13
4
  module OData
14
5
  # base class for ordering
15
- class Order
6
+ class OrderBase
7
+ # re-useable empty ordering (idempotent)
8
+ EmptyOrder = new
9
+
10
+ # input : the OData order string
11
+ # returns a Order object that should have a apply_to(cx) method
12
+ def self.factory(orderstr, jh)
13
+ orderstr.nil? ? EmptyOrder : MultiOrder.new(orderstr, jh)
14
+ end
15
+
16
+ def empty?
17
+ true
18
+ end
19
+
20
+ def parse_error?
21
+ false
22
+ end
23
+
24
+ def apply_to_dataset(dtcx)
25
+ dtcx
26
+ end
27
+ end
28
+
29
+ class Order < OrderBase
16
30
  attr_reader :oarg
17
31
  def initialize(ostr, jh)
18
32
  ostr.strip!
@@ -21,23 +35,14 @@ module OData
21
35
  build_oarg if @orderp
22
36
  end
23
37
 
24
- class << self
25
- attr_reader :regexp
26
- end
27
-
28
- # input : the filter string
29
- # returns a filter object that should have a apply_to(cx) method
30
- def self.new_by_parse(orderstr, jh)
31
- Order.new_full_match_complexpr(orderstr, jh)
32
- end
33
-
34
- # handle with Sequel
35
- def self.new_full_match_complexpr(orderstr, jh)
36
- ComplexOrder.new(orderstr, jh)
38
+ def empty?
39
+ false
37
40
  end
38
41
 
39
42
  def apply_to_dataset(dtcx)
40
- dtcx
43
+ # Warning, we need order_append, simply order(oarg) overwrites
44
+ # previous one !
45
+ dtcx.order_append(@oarg)
41
46
  end
42
47
 
43
48
  def build_oarg
@@ -60,26 +65,31 @@ module OData
60
65
  end
61
66
 
62
67
  # complex ordering logic
63
- class ComplexOrder < Order
68
+ class MultiOrder < Order
64
69
  def initialize(orderstr, jh)
65
70
  super
66
71
  @olist = []
67
72
  @jh = jh
68
- return unless orderstr
69
-
70
- @olist = orderstr.split(',').map do |ostr|
71
- oo = Order.new(ostr, @jh)
72
- oo.oarg
73
- end
73
+ @orderstr = orderstr.dup
74
+ @olist = orderstr.split(',').map { |ostr| Order.new(ostr, @jh) }
74
75
  end
75
76
 
76
77
  def apply_to_dataset(dtcx)
77
- @olist.each { |oarg|
78
- # Warning, we need order_append, simply order(oarg) overwrites
79
- # previous one !
80
- dtcx = dtcx.order_append(oarg)
81
- }
78
+ @olist.each { |osingl| dtcx = osingl.apply_to_dataset(dtcx) }
82
79
  dtcx
83
80
  end
81
+
82
+ def parse_error?
83
+ @orderstr.split(',').each do |pord|
84
+ pord.strip!
85
+ qualfn, dir = pord.split(/\s/)
86
+ qualfn.strip!
87
+ dir.strip! if dir
88
+ return true unless @jh.start_model.attrib_path_valid? qualfn
89
+ return true unless [nil, 'asc', 'desc'].include? dir
90
+ end
91
+
92
+ false
93
+ end
84
94
  end
85
95
  end