safrano 0.3.4 → 0.4.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/lib/core_ext/Dir/iter.rb +18 -0
  3. data/lib/core_ext/Hash/transform.rb +21 -0
  4. data/lib/core_ext/Integer/edm.rb +13 -0
  5. data/lib/core_ext/REXML/Document/output.rb +16 -0
  6. data/lib/core_ext/String/convert.rb +25 -0
  7. data/lib/core_ext/String/edm.rb +13 -0
  8. data/lib/core_ext/dir.rb +3 -0
  9. data/lib/core_ext/hash.rb +3 -0
  10. data/lib/core_ext/integer.rb +3 -0
  11. data/lib/core_ext/rexml.rb +3 -0
  12. data/lib/core_ext/string.rb +5 -0
  13. data/lib/odata/attribute.rb +15 -10
  14. data/lib/odata/batch.rb +17 -15
  15. data/lib/odata/collection.rb +141 -500
  16. data/lib/odata/collection_filter.rb +44 -37
  17. data/lib/odata/collection_media.rb +193 -43
  18. data/lib/odata/collection_order.rb +50 -37
  19. data/lib/odata/common_logger.rb +39 -12
  20. data/lib/odata/complex_type.rb +152 -0
  21. data/lib/odata/edm/primitive_types.rb +184 -0
  22. data/lib/odata/entity.rb +201 -176
  23. data/lib/odata/error.rb +186 -33
  24. data/lib/odata/expand.rb +126 -0
  25. data/lib/odata/filter/base.rb +69 -0
  26. data/lib/odata/filter/error.rb +55 -6
  27. data/lib/odata/filter/parse.rb +38 -36
  28. data/lib/odata/filter/sequel.rb +121 -67
  29. data/lib/odata/filter/sequel_function_adapter.rb +148 -0
  30. data/lib/odata/filter/token.rb +15 -11
  31. data/lib/odata/filter/tree.rb +110 -60
  32. data/lib/odata/function_import.rb +166 -0
  33. data/lib/odata/model_ext.rb +618 -0
  34. data/lib/odata/navigation_attribute.rb +50 -32
  35. data/lib/odata/relations.rb +7 -7
  36. data/lib/odata/select.rb +54 -0
  37. data/lib/{safrano_core.rb → odata/transition.rb} +14 -60
  38. data/lib/odata/url_parameters.rb +128 -37
  39. data/lib/odata/walker.rb +19 -11
  40. data/lib/safrano.rb +18 -28
  41. data/lib/safrano/contract.rb +143 -0
  42. data/lib/safrano/core.rb +43 -0
  43. data/lib/safrano/core_ext.rb +13 -0
  44. data/lib/safrano/deprecation.rb +73 -0
  45. data/lib/{multipart.rb → safrano/multipart.rb} +37 -41
  46. data/lib/safrano/rack_app.rb +175 -0
  47. data/lib/{odata_rack_builder.rb → safrano/rack_builder.rb} +18 -2
  48. data/lib/{request.rb → safrano/request.rb} +102 -50
  49. data/lib/{response.rb → safrano/response.rb} +5 -4
  50. data/lib/safrano/sequel_join_by_paths.rb +5 -0
  51. data/lib/{service.rb → safrano/service.rb} +257 -188
  52. data/lib/safrano/version.rb +5 -0
  53. data/lib/sequel/plugins/join_by_paths.rb +17 -29
  54. metadata +53 -17
  55. data/lib/rack_app.rb +0 -174
  56. data/lib/sequel_join_by_paths.rb +0 -5
  57. data/lib/version.rb +0 -4
@@ -1,61 +1,68 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'odata/error.rb'
2
4
 
3
5
  require_relative 'filter/parse.rb'
4
6
  require_relative 'filter/sequel.rb'
5
7
 
6
- # a few helper method
7
- class String
8
- MASK_RGX = /'((?:[^']|(?:\'{2}))*)'/.freeze
9
- UNMASK_RGX = /'(%?)(\$\d+)(%?)'/.freeze
10
- def with_mask_quoted_substrings!
11
- cnt = 0
12
- repl = {}
13
- gsub!(MASK_RGX) do |_m|
14
- cnt += 1
15
- repl["$#{cnt}"] = Regexp.last_match(1)
16
- "'$#{cnt}'"
8
+ # filter base class and subclass in our OData namespace
9
+ module Safrano
10
+ class FilterBase
11
+ # re-useable empty filtering (idempotent)
12
+ EmptyFilter = new.freeze
13
+
14
+ def self.factory(filterstr)
15
+ filterstr.nil? ? EmptyFilter : FilterByParse.new(filterstr)
16
+ end
17
+
18
+ def apply_to_dataset(dtcx)
19
+ Contract.valid(dtcx)
17
20
  end
18
- yield self
19
21
 
20
- gsub!(UNMASK_RGX) do |_m|
21
- k = Regexp.last_match(2).to_s
22
- "'#{Regexp.last_match(1)}#{repl[k]}#{Regexp.last_match(3)}'"
22
+ # finalize
23
+ def finalize(_jh) Contract::OK end
24
+
25
+ def empty?
26
+ true
23
27
  end
24
- end
25
28
 
26
- def with_mask_quoted_substrings
27
- cnt = 0
28
- repl = {}
29
- tmpstr = gsub(MASK_RGX) do |_m|
30
- cnt += 1
31
- repl["$#{cnt}"] = Regexp.last_match(1)
32
- "'$#{cnt}'"
29
+ def parse_error?
30
+ false
33
31
  end
34
- yield tmpstr
35
32
  end
36
- end
37
33
 
38
- # filter base class and subclass in our OData namespace
39
- module OData
40
34
  # should handle everything by parsing
41
- class FilterByParse
42
- def initialize(filterstr, jh)
35
+ class FilterByParse < FilterBase
36
+ attr_reader :filterstr
37
+
38
+ def initialize(filterstr)
43
39
  @filterstr = filterstr.dup
44
- @ast = OData::Filter::Parser.new(@filterstr).build
45
- @jh = jh
40
+ @ast = Safrano::Filter::Parser.new(@filterstr).build
46
41
  end
47
42
 
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)
43
+ # this build's up the Sequel Filter Expression, and as a side effect,
44
+ # it also finalizes the join helper that we need for the start dataset join
45
+ # the join-helper is shared by the order-by object and was potentially already
46
+ # partly built on order-by object creation.
47
+ def finalize(jh)
48
+ @filtexpr = @ast.if_valid do |ast| ast.sequel_expr(jh) end
51
49
  end
52
50
 
53
- def sequel_expr
54
- @ast.sequel_expr(@jh)
51
+ def apply_to_dataset(dtcx)
52
+ # normally finalize is called before, and thus @filtexpr is set
53
+ @filtexpr.map_result! do |f| dtcx.where(f) end
55
54
  end
56
55
 
56
+ # Note: this is really only *parse* error, ie the error encounterd while
57
+ # trying to build the AST
58
+ # Later when evaluating the AST, there can be other errors, they shall
59
+ # be tracked with @error
57
60
  def parse_error?
58
- @ast.kind_of? StandardError
61
+ @ast.error
62
+ end
63
+
64
+ def empty?
65
+ false
59
66
  end
60
67
  end
61
68
  end
@@ -1,7 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rack'
4
+ require 'fileutils'
2
5
  require_relative './navigation_attribute.rb'
3
6
 
4
- module OData
7
+ module Safrano
5
8
  module Media
6
9
  # base class for Media Handler
7
10
  class Handler
@@ -9,61 +12,173 @@ module OData
9
12
 
10
13
  # Simple static File/Directory based media store handler
11
14
  # similar to Rack::Static
15
+ # with a flat directory structure
12
16
  class Static < Handler
13
- def initialize(root: nil)
17
+ def initialize(root: nil, mediaklass:)
14
18
  @root = File.absolute_path(root || Dir.pwd)
15
19
  @file_server = ::Rack::File.new(@root)
20
+ @media_class = mediaklass
21
+ @media_dir_name = mediaklass.to_s
22
+ register
23
+ end
24
+
25
+ def register
26
+ @abs_klass_dir = File.absolute_path(@media_dir_name, @root)
27
+ FileUtils.makedirs @abs_klass_dir unless Dir.exist?(@abs_klass_dir)
16
28
  end
17
29
 
18
30
  # minimal working implementation...
19
31
  # Note: @file_server works relative to @root directory
20
32
  def odata_get(request:, entity:)
21
33
  media_env = request.env.dup
22
- relpath = Dir.chdir(abs_path(entity)) do
34
+ media_env['PATH_INFO'] = filename(entity)
35
+ fsret = @file_server.call(media_env)
36
+ if fsret.first == 200
37
+ # provide own content type as we keep it in the media entity
38
+ fsret[1]['Content-Type'] = entity.content_type
39
+ end
40
+ fsret
41
+ end
42
+
43
+ # this is relative to @root
44
+ # eg. Photo/1
45
+ def media_path(entity)
46
+ File.join(@media_dir_name, media_directory(entity))
47
+ end
48
+
49
+ # relative to @root
50
+ # eg Photo/1/pommes-topaz.jpg
51
+ def filename(entity)
52
+ Dir.chdir(abs_path(entity)) do
23
53
  # simple design: one file per directory, and the directory
24
54
  # contains the media entity-id --> implicit link between the media
25
55
  # entity
26
- filename = Dir.glob('*').first
27
-
28
- File.join(path(entity), filename)
56
+ File.join(media_path(entity), Dir.glob('*').sort.first)
29
57
  end
30
- media_env['PATH_INFO'] = relpath
31
- @file_server.call(media_env)
32
58
  end
33
59
 
34
- # TODO perf: this can be precalculated and cached on MediaModelKlass level
35
- # and passed as argument to save_file
36
- def abs_klass_dir(entity)
37
- File.absolute_path(entity.klass_dir, @root)
60
+ # /@root/Photo/1
61
+ def abs_path(entity)
62
+ File.absolute_path(media_path(entity), @root)
38
63
  end
39
64
 
40
- def abs_path(entity)
41
- File.absolute_path(path(entity), @root)
65
+ # this is relative to abs_klass_dir(entity) eg to /@root/Photo
66
+ # simplest implementation is media_directory = entity.media_path_id
67
+ # --> we get a 1 level depth flat directory structure
68
+ def media_directory(entity)
69
+ entity.media_path_id
42
70
  end
43
71
 
44
- # this is relative to @root
45
- def path(entity)
46
- File.join(entity.klass_dir, entity.media_path_id)
72
+ def in_media_directory(entity)
73
+ mpi = media_directory(entity)
74
+ Dir.mkdir mpi unless Dir.exist?(mpi)
75
+ Dir.chdir mpi do
76
+ yield
77
+ end
78
+ end
79
+
80
+ def odata_delete(entity:)
81
+ Dir.chdir(@abs_klass_dir) do
82
+ in_media_directory(entity) do
83
+ Dir.glob('*').each { |oldf| File.delete(oldf) }
84
+ end
85
+ end
47
86
  end
48
87
 
49
88
  # Here as well, MVP implementation
50
89
  def save_file(data:, filename:, entity:)
51
- mpi = entity.media_path_id
52
- Dir.chdir(abs_klass_dir(entity)) do
53
- Dir.mkdir mpi unless Dir.exists?(mpi)
54
- Dir.chdir mpi do
90
+ Dir.chdir(@abs_klass_dir) do
91
+ in_media_directory(entity) do
92
+ filename = '1'
55
93
  File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
56
94
  end
57
95
  end
58
96
  end
59
97
 
98
+ # needed for having a changing media ressource "source" metadata
99
+ # after each upload, so that clients get informed about new versions
100
+ # of the same media ressource
101
+ def ressource_version(entity)
102
+ Dir.chdir(@abs_klass_dir) do
103
+ in_media_directory(entity) do
104
+ Dir.glob('*').sort.last
105
+ end
106
+ end
107
+ end
108
+
60
109
  # Here as well, MVP implementation
61
110
  def replace_file(data:, filename:, entity:)
62
- mpi = entity.media_path_id
63
- Dir.chdir(abs_klass_dir(entity)) do
64
- Dir.mkdir mpi unless Dir.exists?(mpi)
65
- Dir.chdir mpi do
66
- Dir.glob('*').each { |oldf| File.delete(oldf) }
111
+ Dir.chdir(@abs_klass_dir) do
112
+ in_media_directory(entity) do
113
+ version = nil
114
+ Dir.glob('*').sort.each do |oldf|
115
+ version = oldf
116
+ File.delete(oldf)
117
+ end
118
+ filename = (version.to_i + 1).to_s
119
+ File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
120
+ end
121
+ end
122
+ end
123
+ end
124
+ # Simple static File/Directory based media store handler
125
+ # similar to Rack::Static
126
+ # with directory Tree structure
127
+
128
+ class StaticTree < Static
129
+ SEP = '/00/'.freeze
130
+ VERS = '/v'.freeze
131
+
132
+ def self.path_builder(ids)
133
+ ids.map { |id| id.to_s.chars.join('/') }.join(SEP) << VERS
134
+ end
135
+
136
+ # this is relative to abs_klass_dir(entity) eg to /@root/Photo
137
+ # tree-structure
138
+ # media_path_ids = 1 --> 1
139
+ # media_path_ids = 15 --> 1/5
140
+ # media_path_ids = 555 --> 5/5/5
141
+ # media_path_ids = 5,5,5 --> 5/00/5/00/5
142
+ # media_path_ids = 5,00,5 --> 5/00/0/0/00/5
143
+ # media_path_ids = 5,xyz,5 --> 5/00/x/y/z/00/5
144
+ def media_directory(entity)
145
+ StaticTree.path_builder(entity.media_path_ids)
146
+ # entity.media_path_ids.map{|id| id.to_s.chars.join('/')}.join(@sep)
147
+ end
148
+
149
+ def in_media_directory(entity)
150
+ mpi = media_directory(entity)
151
+ FileUtils.makedirs mpi unless Dir.exist?(mpi)
152
+ Dir.chdir(mpi) { yield }
153
+ end
154
+
155
+ def odata_delete(entity:)
156
+ Dir.chdir(@abs_klass_dir) do
157
+ in_media_directory(entity) do
158
+ Dir.glob('*').sort.each { |oldf| File.delete(oldf) if File.file?(oldf) }
159
+ end
160
+ end
161
+ end
162
+
163
+ # Here as well, MVP implementation
164
+ # def replace_file(data:, filename:, entity:)
165
+ # Dir.chdir(abs_klass_dir(entity)) do
166
+ # in_media_directory(entity) do
167
+ # Dir.glob('*').each { |oldf| File.delete(oldf) if File.file?(oldf) }
168
+ # File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
169
+ # end
170
+ # end
171
+ # end
172
+ # Here as well, MVP implementation
173
+ def replace_file(data:, filename:, entity:)
174
+ Dir.chdir(@abs_klass_dir) do
175
+ in_media_directory(entity) do
176
+ version = nil
177
+ Dir.glob('*').sort.each do |oldf|
178
+ version = oldf
179
+ File.delete(oldf)
180
+ end
181
+ filename = (version.to_i + 1).to_s
67
182
  File.open(filename, 'wb') { |f| IO.copy_stream(data, f) }
68
183
  end
69
184
  end
@@ -74,33 +189,36 @@ module OData
74
189
  # special handling for media entity
75
190
  module EntityClassMedia
76
191
  attr_reader :media_handler
192
+ attr_reader :slug_field
77
193
 
78
194
  # API method for defining the media handler
79
195
  # eg.
80
196
  # publish_media_model photos do
81
- # use OData::Media::Static, :root => '/media_root'
197
+ # use Safrano::Media::Static, :root => '/media_root'
82
198
  # end
83
199
 
84
200
  def set_default_media_handler
85
- @media_handler = OData::Media::Static.new
201
+ @media_handler = Safrano::Media::Static.new(mediaklass: self)
202
+ end
203
+
204
+ def use(klass, args)
205
+ args[:mediaklass] = self
206
+ @media_handler = klass.new(**args)
86
207
  end
87
208
 
88
- def use(klass, *args)
89
- @media_handler = klass.new(*args)
209
+ # API method for setting the model field mapped to SLUG on upload
210
+ def slug(inp)
211
+ @slug_field = inp
90
212
  end
91
213
 
92
214
  def api_check_media_fields
93
- unless self.db_schema.has_key?(:content_type)
94
- raise OData::API::MediaModelError, self
95
- end
96
- # unless self.db_schema.has_key?(:media_src)
97
- # raise OData::API::MediaModelError, self
98
- # end
215
+ raise(Safrano::API::MediaModelError, self) unless db_schema.key?(:content_type)
99
216
  end
100
217
 
218
+ # END API methods
219
+
101
220
  def new_media_entity(mimetype:)
102
- nh = {}
103
- nh['content_type'] = mimetype
221
+ nh = { 'content_type' => mimetype }
104
222
  new_from_hson_h(nh)
105
223
  end
106
224
 
@@ -113,11 +231,11 @@ module OData
113
231
  # NOTE: we will implement this first in a MVP way. There will be plenty of
114
232
  # potential future enhancements (performance, scallability, flexibility... with added complexity)
115
233
  # 4. create relation to parent if needed
116
- def odata_create_entity_and_relation(req, assoc, parent)
234
+ def odata_create_entity_and_relation(req, assoc = nil, parent = nil)
117
235
  req.with_media_data do |data, mimetype, filename|
118
236
  ## future enhancement: validate allowed mimetypes ?
119
237
  # if (invalid = invalid_media_mimetype(mimetype))
120
- # ::OData::Request::ON_CGST_ERROR.call(req)
238
+ # ::Safrano::Request::ON_CGST_ERROR.call(req)
121
239
  # return [422, {}, ['Invalid mime type: ', invalid.to_s]]
122
240
  # end
123
241
 
@@ -125,6 +243,13 @@ module OData
125
243
 
126
244
  new_entity = new_media_entity(mimetype: mimetype)
127
245
 
246
+ if slug_field
247
+
248
+ new_entity.set_fields({ slug_field => filename },
249
+ data_fields,
250
+ missing: :skip)
251
+ end
252
+
128
253
  # to_one rels are create with FK data set on the parent entity
129
254
  if parent
130
255
  odata_create_save_entity_and_rel(req, new_entity, assoc, parent)
@@ -136,9 +261,11 @@ module OData
136
261
  req.register_content_id_ref(new_entity)
137
262
  new_entity.copy_request_infos(req)
138
263
 
139
- media_handler.save_file(data: data, entity: new_entity, filename: filename)
140
-
141
- [201, CT_JSON, new_entity.to_odata_post_json(service: req.service)]
264
+ media_handler.save_file(data: data,
265
+ entity: new_entity,
266
+ filename: filename)
267
+ # json is default content type so we dont need to specify it here again
268
+ [201, EMPTY_HASH, new_entity.to_odata_post_json(service: req.service)]
142
269
  else # TODO: other formats
143
270
  415
144
271
  end
@@ -146,3 +273,26 @@ module OData
146
273
  end
147
274
  end
148
275
  end
276
+
277
+ # deprecated
278
+ # REMOVE 0.6
279
+ module OData
280
+ module Media
281
+ class Static < ::Safrano::Media::Static
282
+ def initialize(root: nil, mediaklass:)
283
+ ::Safrano::Deprecation.deprecate('OData::Media::Static',
284
+ 'Use Safrano::Media::Static instead')
285
+
286
+ super
287
+ end
288
+ end
289
+ class StaticTree < ::Safrano::Media::StaticTree
290
+ def initialize(root: nil, mediaklass:)
291
+ ::Safrano::Deprecation.deprecate('OData::Media::StaticTree',
292
+ 'Use Safrano::Media::StaticTree instead')
293
+
294
+ super
295
+ end
296
+ end
297
+ end
298
+ end
@@ -1,19 +1,36 @@
1
- require 'odata/error.rb'
1
+ # frozen_string_literal: true
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
3
+ require 'odata/error.rb'
11
4
 
12
5
  # all ordering related classes in our OData module
13
- module OData
6
+ module Safrano
14
7
  # base class for ordering
15
- class Order
8
+ class OrderBase
9
+ # re-useable empty ordering (idempotent)
10
+ EmptyOrder = new.freeze
11
+
12
+ # input : the OData order string
13
+ # returns a Order object that should have a apply_to(cx) method
14
+ def self.factory(orderstr, jh)
15
+ orderstr.nil? ? EmptyOrder : MultiOrder.new(orderstr, jh)
16
+ end
17
+
18
+ def empty?
19
+ true
20
+ end
21
+
22
+ def parse_error?
23
+ false
24
+ end
25
+
26
+ def apply_to_dataset(dtcx)
27
+ dtcx
28
+ end
29
+ end
30
+
31
+ class Order < OrderBase
16
32
  attr_reader :oarg
33
+
17
34
  def initialize(ostr, jh)
18
35
  ostr.strip!
19
36
  @orderp = ostr
@@ -21,23 +38,14 @@ module OData
21
38
  build_oarg if @orderp
22
39
  end
23
40
 
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)
41
+ def empty?
42
+ false
37
43
  end
38
44
 
39
45
  def apply_to_dataset(dtcx)
40
- dtcx
46
+ # Warning, we need order_append, simply order(oarg) overwrites
47
+ # previous one !
48
+ dtcx.order_append(@oarg)
41
49
  end
42
50
 
43
51
  def build_oarg
@@ -60,26 +68,31 @@ module OData
60
68
  end
61
69
 
62
70
  # complex ordering logic
63
- class ComplexOrder < Order
71
+ class MultiOrder < Order
64
72
  def initialize(orderstr, jh)
65
73
  super
66
74
  @olist = []
67
75
  @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
76
+ @orderstr = orderstr.dup
77
+ @olist = orderstr.split(',').map { |ostr| Order.new(ostr, @jh) }
74
78
  end
75
79
 
76
80
  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
- }
81
+ @olist.each { |osingl| dtcx = osingl.apply_to_dataset(dtcx) }
82
82
  dtcx
83
83
  end
84
+
85
+ def parse_error?
86
+ @orderstr.split(',').each do |pord|
87
+ pord.strip!
88
+ qualfn, dir = pord.split(/\s/)
89
+ qualfn.strip!
90
+ dir.strip! if dir
91
+ return true unless @jh.start_model.attrib_path_valid? qualfn
92
+ return true unless [nil, 'asc', 'desc'].include? dir
93
+ end
94
+
95
+ false
96
+ end
84
97
  end
85
98
  end