safrano 0.3.2 → 0.4.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,5 +1,3 @@
1
- #!/usr/bin/env ruby
2
-
3
1
  require 'json'
4
2
  require 'rexml/document'
5
3
  require 'safrano.rb'
@@ -14,6 +12,13 @@ module OData
14
12
  super("class #{name} is not a Sequel Model", name)
15
13
  end
16
14
  end
15
+ # when published class as media does not have the mandatory media fields
16
+ class MediaModelError < NameError
17
+ def initialize(name)
18
+ super("Model #{name} does not have the mandatory media attributes content_type/media_src", name)
19
+ end
20
+ end
21
+
17
22
  # when published association was not defined on Sequel level
18
23
  class ModelAssociationNameError < NameError
19
24
  def initialize(klass, symb)
@@ -24,22 +29,33 @@ module OData
24
29
  end
25
30
  end
26
31
 
27
- # base module for HTTP errors
28
- module Error
32
+ # base module for HTTP errors, when used as a Error Class
33
+ module ErrorClass
29
34
  def odata_get(req)
30
- if req.accept?('application/json')
31
- [const_get(:HTTP_CODE),
32
- { 'Content-Type' => 'application/json;charset=utf-8' },
35
+ if req.accept?(APPJSON)
36
+ [const_get(:HTTP_CODE), CT_JSON,
33
37
  { 'odata.error' => { 'code' => '', 'message' => @msg } }.to_json]
34
38
  else
35
- [const_get(:HTTP_CODE),
36
- { 'Content-Type' => 'text/plain;charset=utf-8' }, @msg]
39
+ [const_get(:HTTP_CODE), CT_TEXT, @msg]
37
40
  end
38
41
  end
39
42
  end
43
+
44
+ # base module for HTTP errors, when used as an Error instance
45
+ module ErrorInstance
46
+ def odata_get(req)
47
+ if req.accept?(APPJSON)
48
+ [self.class.const_get(:HTTP_CODE), CT_JSON,
49
+ { 'odata.error' => { 'code' => '', 'message' => @msg } }.to_json]
50
+ else
51
+ [self.class.const_get(:HTTP_CODE), CT_TEXT, @msg]
52
+ end
53
+ end
54
+ end
55
+
40
56
  # http Bad Req.
41
57
  class BadRequestError
42
- extend Error
58
+ extend ErrorClass
43
59
  HTTP_CODE = 400
44
60
  @msg = 'Bad Request Error'
45
61
  end
@@ -49,11 +65,36 @@ module OData
49
65
  @msg = 'Bad Request: Failed changeset '
50
66
  end
51
67
 
68
+ # $value request for a non-media entity
69
+ class BadRequestNonMediaValue < BadRequestError
70
+ HTTP_CODE = 400
71
+ @msg = 'Bad Request: $value request for a non-media entity'
72
+ end
73
+ class BadRequestSequelAdapterError < BadRequestError
74
+ include ErrorInstance
75
+ def initialize(err)
76
+ @msg = err.inner.message
77
+ end
78
+ end
79
+
52
80
  # for Syntax error in Filtering
53
81
  class BadRequestFilterParseError < BadRequestError
54
82
  HTTP_CODE = 400
55
- @msg = 'Bad Request: Syntax error in Filter'
83
+ @msg = 'Bad Request: Syntax error in $filter'
84
+ end
85
+
86
+ # for Syntax error in $expand param
87
+ class BadRequestExpandParseError < BadRequestError
88
+ HTTP_CODE = 400
89
+ @msg = 'Bad Request: Syntax error in $expand'
90
+ end
91
+
92
+ # for Syntax error in $orderby param
93
+ class BadRequestOrderParseError < BadRequestError
94
+ HTTP_CODE = 400
95
+ @msg = 'Bad Request: Syntax error in $orderby'
56
96
  end
97
+
57
98
  # for $inlinecount error
58
99
  class BadRequestInlineCountParamError < BadRequestError
59
100
  HTTP_CODE = 400
@@ -61,36 +102,36 @@ module OData
61
102
  end
62
103
  # http not found
63
104
  class ErrorNotFound
64
- extend Error
105
+ extend ErrorClass
65
106
  HTTP_CODE = 404
66
107
  @msg = 'The requested ressource was not found'
67
108
  end
68
109
  # Transition error (Safrano specific)
69
110
  class ServerTransitionError
70
- extend Error
111
+ extend ErrorClass
71
112
  HTTP_CODE = 500
72
113
  @msg = 'Server error: Segment could not be parsed'
73
114
  end
74
115
  # generic http 500 server err
75
116
  class ServerError
76
- extend Error
117
+ extend ErrorClass
77
118
  HTTP_CODE = 500
78
119
  @msg = 'Server error'
79
120
  end
80
121
  # not implemented (Safrano specific)
81
122
  class NotImplementedError
82
- extend Error
123
+ extend ErrorClass
83
124
  HTTP_CODE = 501
84
125
  end
85
126
  # batch not implemented (Safrano specific)
86
127
  class BatchNotImplementedError
87
- extend Error
128
+ extend ErrorClass
88
129
  HTTP_CODE = 501
89
130
  @msg = 'Not implemented: OData batch'
90
131
  end
91
132
  # error in filter parsing (Safrano specific)
92
133
  class FilterParseError < BadRequestError
93
- extend Error
134
+ extend ErrorClass
94
135
  HTTP_CODE = 400
95
136
  end
96
137
  end
@@ -0,0 +1,123 @@
1
+ require 'odata/error.rb'
2
+
3
+ # all dataset expanding related classes in our OData module
4
+ # ie do eager loading
5
+ module OData
6
+ # base class for expanding
7
+ class ExpandBase
8
+ EmptyExpand = new # re-useable empty expanding (idempotent)
9
+ EMPTYH = {}.freeze
10
+
11
+ def self.factory(expandstr)
12
+ expandstr.nil? ? EmptyExpand : MultiExpand.new(expandstr)
13
+ end
14
+
15
+ # output template
16
+ attr_reader :template
17
+
18
+ def apply_to_dataset(dtcx)
19
+ dtcx
20
+ end
21
+
22
+ def empty?
23
+ true
24
+ end
25
+
26
+ def parse_error?
27
+ false
28
+ end
29
+
30
+ def template
31
+ EMPTYH
32
+ end
33
+ end
34
+
35
+ # single expand
36
+ class Expand < ExpandBase
37
+ # sequel eager arg.
38
+ attr_reader :arg
39
+ attr_reader :template
40
+
41
+ # used for Sequel eager argument
42
+ # Recursive array to deep hash
43
+ # [1,2,3,4] --> {1=>{2=>{3=>4}}}
44
+ # [1] --> 1
45
+ DEEPH_0 = ->(inp) { inp.size > 1 ? { inp[0] => DEEPH_0.call(inp[1..-1]) } : inp[0] }
46
+
47
+ # used for building output template
48
+ # Recursive array to deep hash
49
+ # [1,2,3,4] --> {1=>{2=>{3=>4}}}
50
+ # [1] --> { 1 => {} }
51
+ DEEPH_1 = ->(inp) { inp.size > 1 ? { inp[0] => DEEPH_1.call(inp[1..-1]) } : { inp[0] => {} } }
52
+
53
+ NODESEP = '/'.freeze
54
+
55
+ def initialize(exstr)
56
+ exstr.strip!
57
+ @expandp = exstr
58
+ @nodes = @expandp.split(NODESEP)
59
+ build_arg
60
+ end
61
+
62
+ def apply_to_dataset(dtcx)
63
+ dtcx
64
+ end
65
+
66
+ def build_arg
67
+ # 'a/b/c/d' ==> {a: {b:{c: :d}}}
68
+ # 'xy' ==> :xy
69
+ @arg = DEEPH_0.call(@nodes.map(&:to_sym))
70
+ @template = DEEPH_1.call(@nodes)
71
+ end
72
+
73
+ def parse_error?
74
+ # todo
75
+ false
76
+ end
77
+
78
+ def empty?
79
+ false
80
+ end
81
+ end
82
+
83
+ # Multi expanding logic
84
+ class MultiExpand < ExpandBase
85
+ COMASPLIT = /\s*,\s*/.freeze
86
+ attr_reader :template
87
+
88
+ def initialize(expandstr)
89
+ expandstr.strip!
90
+ @expandp = expandstr
91
+ @exlist = []
92
+
93
+ @exlist = expandstr.split(COMASPLIT).map { |exstr| Expand.new(exstr) }
94
+ build_template
95
+ end
96
+
97
+ def apply_to_dataset(dtcx)
98
+ # use eager loading for each used association
99
+ @exlist.each { |exp| dtcx = dtcx.eager(exp.arg) }
100
+ dtcx
101
+ end
102
+
103
+ def build_template
104
+ # 'a/b/c/d,xy' ==> [ {'a' =>{ 'b' => {'c' => {'d' => {} } }}},
105
+ # { 'xy' => {} }]
106
+ #
107
+ @template = @exlist.map(&:template)
108
+
109
+ # { 'a' => { 'b' => {'c' => 'd' }},
110
+ # 'xy' => {} }
111
+ @template = @template.inject({}) { |mrg, elmt| mrg.merge elmt }
112
+ end
113
+
114
+ def parse_error?
115
+ # todo
116
+ false
117
+ end
118
+
119
+ def empty?
120
+ false
121
+ end
122
+ end
123
+ end
@@ -1,4 +1,10 @@
1
1
  module OData
2
+ class SequelAdapterError < StandardError
3
+ attr_reader :inner
4
+ def initialize(err)
5
+ @inner = err
6
+ end
7
+ end
2
8
  module Filter
3
9
  class Parser
4
10
  # Parser errors
@@ -68,24 +68,18 @@ module OData
68
68
  each_typed_token(@input) do |tok, typ|
69
69
  case typ
70
70
  when :FuncTree
71
- with_accepted(tok, typ) do
72
- grow_at_cursor(FuncTree.new(tok))
73
- end
71
+ with_accepted(tok, typ) { grow_at_cursor(FuncTree.new(tok)) }
74
72
  when :Delimiter
75
73
  case tok
76
74
  when '('
77
75
  with_accepted(tok, typ) do
78
- unless @cursor.is_a? FuncTree
79
- grow_at_cursor(IdentityFuncTree.new)
80
- end
76
+ grow_at_cursor(IdentityFuncTree.new) unless @cursor.is_a? FuncTree
81
77
  openarg = ArgTree.new('(')
82
78
  @stack << openarg
83
79
  grow_at_cursor(openarg)
84
80
  end
85
81
  when ')'
86
- unless (@cursor = @stack.pop)
87
- break invalid_closing_delimiter_error(tok, typ)
88
- end
82
+ break invalid_closing_delimiter_error(tok, typ) unless (@cursor = @stack.pop)
89
83
 
90
84
  with_accepted(tok, typ) do
91
85
  @cursor.update_state(tok, typ)
@@ -94,9 +88,7 @@ module OData
94
88
  end
95
89
 
96
90
  when :Separator
97
- unless (@cursor = @stack.last)
98
- break invalid_separator_error(tok, typ)
99
- end
91
+ break invalid_separator_error(tok, typ) unless (@cursor = @stack.last)
100
92
 
101
93
  with_accepted(tok, typ) { @cursor.update_state(tok, typ) }
102
94
 
@@ -13,7 +13,7 @@ module OData
13
13
  class RootTree
14
14
  def apply_to_dataset(dtcx, jh)
15
15
  filtexpr = @children.first.leuqes(jh)
16
- dtcx = jh.dataset.where(filtexpr).select_all(jh.start_model.table_name)
16
+ jh.dataset(dtcx).where(filtexpr).select_all(jh.start_model.table_name)
17
17
  end
18
18
 
19
19
  def sequel_expr(jh)
@@ -38,21 +38,21 @@ module OData
38
38
  when :substringof
39
39
 
40
40
  # there are multiple possible argument types (but all should return edm.string)
41
- if (args[0].is_a?(QString))
41
+ if args[0].is_a?(QString)
42
42
  # substringof('Rhum', name) -->
43
43
  # name contains substr 'Rhum'
44
44
  Sequel.like(args[1].leuqes(jh),
45
45
  args[0].leuqes_substringof_sig1(jh))
46
46
  # special non standard (ui5 client) case ?
47
- elsif (args[0].is_a?(Literal) && args[1].is_a?(Literal))
47
+ elsif args[0].is_a?(Literal) && args[1].is_a?(Literal)
48
48
  Sequel.like(args[1].leuqes(jh),
49
49
  args[0].leuqes_substringof_sig1(jh))
50
- elsif (args[1].is_a?(QString))
50
+ elsif args[1].is_a?(QString)
51
51
  # substringof(name, '__Route du Rhum__') -->
52
52
  # '__Route du Rhum__' contains name as a substring
53
53
  # TODO... check if the database supports instr (how?)
54
54
  # othewise use substr(postgresql) or whatevr?
55
- instr_substr_func = if (Sequel::Model.db.adapter_scheme == :postgres)
55
+ instr_substr_func = if Sequel::Model.db.adapter_scheme == :postgres
56
56
  Sequel.function(:strpos, args[1].leuqes(jh), args[0].leuqes(jh))
57
57
  else
58
58
  Sequel.function(:instr, args[1].leuqes(jh), args[0].leuqes(jh))
@@ -167,26 +167,24 @@ module OData
167
167
  end
168
168
 
169
169
  def leuqes_starts_like(_jh)
170
- "#{@value.to_s}%"
170
+ "#{@value}%"
171
171
  end
172
172
 
173
173
  def leuqes_ends_like(_jh)
174
- "%#{@value.to_s}"
174
+ "%#{@value}"
175
175
  end
176
176
 
177
177
  def leuqes_substringof_sig1(_jh)
178
- "%#{@value.to_s}%"
178
+ "%#{@value}%"
179
179
  end
180
180
  end
181
181
 
182
182
  # Literals are unquoted words
183
183
  class Literal
184
184
  def leuqes(jh)
185
- if jh.start_model.db_schema.has_key?(@value.to_sym)
186
- Sequel[jh.start_model.table_name][@value.to_sym]
187
- else
188
- raise OData::Filter::Parser::ErrorWrongColumnName
189
- end
185
+ raise OData::Filter::Parser::ErrorWrongColumnName unless jh.start_model.db_schema.key?(@value.to_sym)
186
+
187
+ Sequel[jh.start_model.table_name][@value.to_sym]
190
188
  end
191
189
 
192
190
  # non stantard extensions to support things like
@@ -238,7 +238,7 @@ module OData
238
238
  end
239
239
  end
240
240
 
241
- # TODO different num types?
241
+ # TODO: different num types?
242
242
  def edm_type
243
243
  :any
244
244
  end
@@ -279,12 +279,10 @@ module OData
279
279
  when :Separator
280
280
  if @value == '(' && tok == ',' && @state == :val
281
281
  true
282
+ elsif @state == :sep
283
+ [false, Parser::ErrorInvalidToken.new(tok, typ, self)]
282
284
  else
283
- if (@state == :sep)
284
- [false, Parser::ErrorInvalidToken.new(tok, typ, self)]
285
- else
286
- true
287
- end
285
+ true
288
286
  end
289
287
  when :Literal, :Qualit, :QString, :FuncTree, :FPNumber
290
288
  if (@state == :open) || (@state == :sep)
@@ -350,22 +348,20 @@ module OData
350
348
  def initialize(val)
351
349
  super(val)
352
350
  # split into path + attrib
353
- if (md = REGEXP.match(val))
354
- @path = md[1].chomp('/')
355
- @attrib = md[2]
356
- else
357
- raise Parser::Error.new(self, Qualit)
358
- end
351
+ raise Parser::Error.new(self, Qualit) unless (md = REGEXP.match(val))
352
+
353
+ @path = md[1].chomp('/')
354
+ @attrib = md[2]
359
355
  end
360
356
  end
361
357
 
362
358
  # Quoted Strings
363
359
  class QString < Leave
364
- DoubleQuote = "''".freeze
365
- SingleQuote = "'".freeze
360
+ DBL_QO = "''".freeze
361
+ SI_QO = "'".freeze
366
362
  def initialize(val)
367
363
  # unescape double quotes
368
- super(val.gsub(DoubleQuote, SingleQuote))
364
+ super(val.gsub(DBL_QO, SI_QO))
369
365
  end
370
366
 
371
367
  def accept?(tok, typ)
@@ -0,0 +1,150 @@
1
+ require 'json'
2
+ require_relative '../safrano/core.rb'
3
+ require_relative './entity.rb'
4
+
5
+ module OData
6
+ # remove the relation between entity and parent by clearing
7
+ # the FK field(s) (if allowed)
8
+ def self.remove_nav_relation(assoc, parent)
9
+ return unless assoc
10
+
11
+ return unless assoc[:type] == :many_to_one
12
+
13
+ # removes/clear the FK values in parent
14
+ # thus deleting the "link" between the entity and the parent
15
+ # Note: This is called if we have to delete the child--> can only be
16
+ # done after removing the FK in parent (if allowed!)
17
+ lks = [assoc[:key]].flatten
18
+ lks.each do |lk|
19
+ parent.set(lk => nil)
20
+ parent.save(transaction: false)
21
+ end
22
+ end
23
+
24
+ # link newly created entities(child) to an existing parent
25
+ # by following the association_reflection rules
26
+ def self.create_nav_relation(child, assoc, parent)
27
+ return unless assoc
28
+
29
+ # Note: this coding shares some bits from our sequel/plugins/join_by_paths,
30
+ # method build_unique_join_segments
31
+ # eventually there is an opportunity to have more reusable code here
32
+ case assoc[:type]
33
+ when :one_to_many, :one_to_one
34
+ # sets the FK values in child to corresponding related parent key-values
35
+ # thus creating the "link" between the new entity and the parent
36
+ # if a FK value is already set (not nil/NULL) then only check the
37
+ # consistency with the corresponding parent key-value
38
+ # If the FK value and the parent key value are different, then it's a
39
+ # a Bad Request error
40
+
41
+ leftm = assoc[:model] # should be same as parent.class
42
+ lks = [leftm.primary_key].flatten
43
+ rks = [assoc[:key]].flatten
44
+ join_cond = rks.zip(lks).to_h
45
+ join_cond.each do |rk, lk|
46
+ if child.values[rk] # FK in new entity from payload not nil, only check consistency
47
+ # with the parent - id(s)
48
+ # if (child.values[rk] != parent.pk_hash[lk]) # error...
49
+ # TODO
50
+ # end
51
+ else # we can set the FK value, thus creating the "link"
52
+ child.set(rk => parent.pk_hash[lk])
53
+ end
54
+ end
55
+ when :many_to_one
56
+ # sets the FK values in parent to corresponding related child key-values
57
+ # thus creating the "link" between the new entity and the parent
58
+ # Per design, this can only be called when the FK value is nil
59
+ # from NilNavigationAttribute.odata_post
60
+ lks = [assoc[:key]].flatten
61
+ rks = [child.class.primary_key].flatten
62
+ join_cond = rks.zip(lks).to_h
63
+ join_cond.each do |rk, lk|
64
+ if parent.values[lk] # FK in parent not nil, only check consistency
65
+ # with the child - id(s)
66
+ # if parent.values[lk] != child.pk_hash[rk] # error...
67
+ # TODO
68
+ # end
69
+ else # we can set the FK value, thus creating the "link"
70
+ parent.set(lk => child.pk_hash[rk])
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ module EntityBase
77
+ module NavigationInfo
78
+ attr_reader :nav_parent
79
+ attr_reader :navattr_reflection
80
+ attr_reader :nav_name
81
+ def set_relation_info(parent, name)
82
+ @nav_parent = parent
83
+ @nav_name = name
84
+ @navattr_reflection = parent.class.association_reflections[name.to_sym]
85
+ @nav_klass = @navattr_reflection[:class_name].constantize
86
+ end
87
+ end
88
+ end
89
+
90
+ # Represents a named but nil-valued navigation-attribute of an Entity
91
+ # (usually resulting from a NULL FK db value)
92
+ class NilNavigationAttribute
93
+ include EntityBase::NavigationInfo
94
+ def odata_get(req)
95
+ if req.walker.media_value
96
+ OData::ErrorNotFound.odata_get
97
+ elsif req.accept?(APPJSON)
98
+ [200, CT_JSON, to_odata_json(service: req.service)]
99
+ else # TODO: other formats
100
+ 415
101
+ end
102
+ end
103
+
104
+ # create the nav. entity
105
+ def odata_post(req)
106
+ # delegate to the class method
107
+ @nav_klass.odata_create_entity_and_relation(req,
108
+ @navattr_reflection,
109
+ @nav_parent)
110
+ end
111
+
112
+ # create the nav. entity
113
+ def odata_put(req)
114
+ # if req.walker.raw_value
115
+ # delegate to the class method
116
+ @nav_klass.odata_create_entity_and_relation(req,
117
+ @navattr_reflection,
118
+ @nav_parent)
119
+ # else
120
+ # end
121
+ end
122
+
123
+ # empty output as OData json (v2)
124
+ def to_odata_json(*)
125
+ { 'd' => EMPTY_HASH }.to_json
126
+ end
127
+
128
+ # for testing purpose (assert_equal ...)
129
+ def ==(other)
130
+ (@nav_parent == other.nav_parent) && (@nav_name == other.nav_name)
131
+ end
132
+
133
+ # methods related to transitions to next state (cf. walker)
134
+ module Transitions
135
+ def transition_end(_match_result)
136
+ [nil, :end]
137
+ end
138
+
139
+ def transition_value(_match_result)
140
+ [self, :end_with_value]
141
+ end
142
+
143
+ def allowed_transitions
144
+ [Safrano::TransitionEnd,
145
+ Safrano::TransitionValue]
146
+ end
147
+ end
148
+ include Transitions
149
+ end
150
+ end