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.
@@ -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