safrano 0.3.4 → 0.4.4

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 (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