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,11 +1,31 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'json'
2
- require_relative '../safrano_core.rb'
3
- require_relative './entity.rb'
4
+ require_relative '../safrano/core'
5
+ require_relative './entity'
6
+
7
+ module Safrano
8
+ # remove the relation between entity and parent by clearing
9
+ # the FK field(s) (if allowed)
10
+ def self.remove_nav_relation(assoc, parent)
11
+ return unless assoc
12
+
13
+ return unless assoc[:type] == :many_to_one
14
+
15
+ # removes/clear the FK values in parent
16
+ # thus deleting the "link" between the entity and the parent
17
+ # Note: This is called if we have to delete the child--> can only be
18
+ # done after removing the FK in parent (if allowed!)
19
+ lks = [assoc[:key]].flatten
20
+ lks.each do |lk|
21
+ parent.set(lk => nil)
22
+ parent.save(transaction: false)
23
+ end
24
+ end
4
25
 
5
- module OData
6
26
  # link newly created entities(child) to an existing parent
7
27
  # by following the association_reflection rules
8
- def OData.create_nav_relation(child, assoc, parent)
28
+ def self.create_nav_relation(child, assoc, parent)
9
29
  return unless assoc
10
30
 
11
31
  # Note: this coding shares some bits from our sequel/plugins/join_by_paths,
@@ -24,16 +44,16 @@ module OData
24
44
  lks = [leftm.primary_key].flatten
25
45
  rks = [assoc[:key]].flatten
26
46
  join_cond = rks.zip(lks).to_h
27
- join_cond.each { |rk, lk|
47
+ join_cond.each do |rk, lk|
28
48
  if child.values[rk] # FK in new entity from payload not nil, only check consistency
29
49
  # with the parent - id(s)
30
- if (child.values[rk] != parent.pk_hash[lk]) # error...
31
- # TODO
32
- end
50
+ # if (child.values[rk] != parent.pk_hash[lk]) # error...
51
+ # TODO
52
+ # end
33
53
  else # we can set the FK value, thus creating the "link"
34
54
  child.set(rk => parent.pk_hash[lk])
35
55
  end
36
- }
56
+ end
37
57
  when :many_to_one
38
58
  # sets the FK values in parent to corresponding related child key-values
39
59
  # thus creating the "link" between the new entity and the parent
@@ -42,36 +62,28 @@ module OData
42
62
  lks = [assoc[:key]].flatten
43
63
  rks = [child.class.primary_key].flatten
44
64
  join_cond = rks.zip(lks).to_h
45
- join_cond.each { |rk, lk|
65
+ join_cond.each do |rk, lk|
46
66
  if parent.values[lk] # FK in parent not nil, only check consistency
47
67
  # with the child - id(s)
48
- if (parent.values[lk] != child.pk_hash[rk]) # error...
49
- # TODO
50
- end
68
+ # if parent.values[lk] != child.pk_hash[rk] # error...
69
+ # TODO
70
+ # end
51
71
  else # we can set the FK value, thus creating the "link"
52
72
  parent.set(lk => child.pk_hash[rk])
53
73
  end
54
- }
74
+ end
55
75
  end
56
76
  end
57
77
 
58
78
  # Represents a named but nil-valued navigation-attribute of an Entity
59
79
  # (usually resulting from a NULL FK db value)
60
80
  class NilNavigationAttribute
61
- attr_reader :name
62
- attr_reader :parent
63
- def initialize(parent, name)
64
- @parent = parent
65
- @name = name
66
- @navattr_reflection = parent.class.association_reflections[name.to_sym]
67
- @klass = @navattr_reflection[:class_name].constantize
68
- end
69
-
81
+ include Safrano::NavigationInfo
70
82
  def odata_get(req)
71
83
  if req.walker.media_value
72
- OData::ErrorNotFound.odata_get
84
+ Safrano::ErrorNotFound.odata_get
73
85
  elsif req.accept?(APPJSON)
74
- [200, CT_JSON, to_odata_json(service: req.service)]
86
+ [200, EMPTY_HASH, to_odata_json(service: req.service)]
75
87
  else # TODO: other formats
76
88
  415
77
89
  end
@@ -80,38 +92,44 @@ module OData
80
92
  # create the nav. entity
81
93
  def odata_post(req)
82
94
  # delegate to the class method
83
- @klass.odata_create_entity_and_relation(req, @navattr_reflection, @parent)
95
+ @nav_klass.odata_create_entity_and_relation(req,
96
+ @navattr_reflection,
97
+ @nav_parent)
84
98
  end
85
99
 
86
100
  # create the nav. entity
87
101
  def odata_put(req)
88
102
  # delegate to the class method
89
- @klass.odata_create_entity_and_relation(req, @navattr_reflection, @parent)
103
+ @nav_klass.odata_create_entity_and_relation(req,
104
+ @navattr_reflection,
105
+ @nav_parent)
90
106
  end
91
107
 
92
108
  # empty output as OData json (v2)
93
109
  def to_odata_json(*)
94
- { 'd' => {} }.to_json
110
+ { 'd' => EMPTY_HASH }.to_json
95
111
  end
96
112
 
97
113
  # for testing purpose (assert_equal ...)
98
114
  def ==(other)
99
- (@parent == other.parent) && (@name == other.name)
115
+ (@nav_parent == other.nav_parent) && (@nav_name == other.nav_name)
100
116
  end
101
117
 
102
118
  # methods related to transitions to next state (cf. walker)
103
119
  module Transitions
104
120
  def transition_end(_match_result)
105
- [nil, :end]
121
+ Safrano::Transition::RESULT_END
106
122
  end
107
123
 
108
124
  def transition_value(_match_result)
109
125
  [self, :end_with_value]
110
126
  end
111
127
 
128
+ ALLOWED_TRANSITIONS = [Safrano::TransitionEnd,
129
+ Safrano::TransitionValue].freeze
130
+
112
131
  def allowed_transitions
113
- [Safrano::TransitionEnd,
114
- Safrano::TransitionValue]
132
+ ALLOWED_TRANSITIONS
115
133
  end
116
134
  end
117
135
  include Transitions
@@ -1,9 +1,9 @@
1
- #!/usr/bin/env ruby
1
+ # frozen_string_literal: true
2
2
 
3
3
  require 'set'
4
4
 
5
5
  # OData relation related classes/module
6
- module OData
6
+ module Safrano
7
7
  # we represent a relation as a Set (unordered) of two end elements
8
8
  class Relation < Set
9
9
  # attr_reader :rid
@@ -20,7 +20,7 @@ module OData
20
20
 
21
21
  # we need a from/to order independant ID
22
22
  def rid
23
- OData::RelationManager.build_id(self)
23
+ Safrano::RelationManager.build_id(self)
24
24
  end
25
25
 
26
26
  # we need a from/to order independant OData like name
@@ -76,16 +76,16 @@ module OData
76
76
  end
77
77
 
78
78
  def get(arg)
79
- rid = OData::RelationManager.build_id(arg)
79
+ rid = Safrano::RelationManager.build_id(arg)
80
80
  if @list.key?(rid)
81
81
  @list[rid]
82
82
  else
83
- rel = OData::Relation.new(arg)
83
+ rel = Safrano::Relation.new(arg)
84
84
  @list[rid] = rel
85
85
  end
86
86
  end
87
87
 
88
- def get_metadata_xml_attribs(from, to, assoc_type, xnamespace)
88
+ def get_metadata_xml_attribs(from, to, assoc_type, xnamespace, attrname)
89
89
  rel = get([from, to])
90
90
  # use Sequel reflection to get multiplicity (will be used later
91
91
  # in 2. Associations below)
@@ -107,7 +107,7 @@ module OData
107
107
  # <NavigationProperty Name="Supplier"
108
108
  # Relationship="ODataDemo.Product_Supplier_Supplier_Products"
109
109
  # FromRole="Product_Supplier" ToRole="Supplier_Products"/>
110
- { 'Name' => to, 'Relationship' => "#{xnamespace}.#{rel.name}",
110
+ { 'Name' => attrname, 'Relationship' => "#{xnamespace}.#{rel.name}",
111
111
  'FromRole' => from, 'ToRole' => to }
112
112
  end
113
113
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'odata/error'
4
+
5
+ # all dataset selecting related classes in our OData module
6
+ # ie do eager loading
7
+ module Safrano
8
+ # base class for selecting. We have to distinguish between
9
+ # fields of the current entity, and the navigation properties
10
+ # we can have one special case
11
+ # empty, ie no $select specified --> return all fields and all nav props
12
+ # ==> SelectAll
13
+
14
+ class SelectBase
15
+ ALL = new # re-useable selecting-all handler
16
+
17
+ def self.factory(selectstr, model)
18
+ case selectstr&.strip
19
+ when nil, '', '*'
20
+ ALL
21
+ else
22
+ Select.new(selectstr, model)
23
+ end
24
+ end
25
+
26
+ def all_props?
27
+ false
28
+ end
29
+
30
+ def ALL.all_props?
31
+ true
32
+ end
33
+
34
+ def parse_error?
35
+ Contract::OK
36
+ end
37
+ end
38
+
39
+ # single select
40
+ class Select < SelectBase
41
+ COMASPLIT = /\s*,\s*/.freeze
42
+ attr_reader :props
43
+
44
+ def initialize(selstr, model)
45
+ @model = model
46
+ @selectp = selstr.strip
47
+ @props = @selectp.split(COMASPLIT)
48
+ end
49
+
50
+ def parse_error?
51
+ (invalids = @model.find_invalid_props(props.to_set)) ? BadRequestSelectInvalidProps.new(@model, invalids) : Contract::OK
52
+ end
53
+ end
54
+ end
@@ -1,63 +1,8 @@
1
- #!/usr/bin/env ruby
1
+ # frozen_string_literal: true
2
2
 
3
- # our main namespace
4
- module OData
5
- # some prominent constants... probably already defined elsewhere eg in Rack
6
- # but lets KISS
7
- CONTENT_TYPE = 'Content-Type'.freeze
8
- CTT_TYPE_LC = 'content-type'.freeze
9
- TEXTPLAIN_UTF8 = 'text/plain;charset=utf-8'.freeze
10
- APPJSON = 'application/json'.freeze
11
- APPXML = 'application/xml'.freeze
12
- MP_MIXED = 'multipart/mixed'.freeze
13
- APPXML_UTF8 = 'application/xml;charset=utf-8'.freeze
14
- APPATOMXML_UTF8 = 'application/atomsvc+xml;charset=utf-8'.freeze
15
- APPJSON_UTF8 = 'application/json;charset=utf-8'.freeze
16
-
17
- CT_JSON = { CONTENT_TYPE => APPJSON_UTF8 }.freeze
18
- CT_TEXT = { CONTENT_TYPE => TEXTPLAIN_UTF8 }.freeze
19
- CT_ATOMXML = { CONTENT_TYPE => APPATOMXML_UTF8 }.freeze
20
- CT_APPXML = { CONTENT_TYPE => APPXML_UTF8 }.freeze
21
-
22
- # Type mapping DB --> Edm
23
- # TypeMap = {"INTEGER" => "Edm.Int32" , "TEXT" => "Edm.String",
24
- # "STRING" => "Edm.String"}
25
- # Todo: complete mapping... this is just for the most common ones
26
-
27
- # TODO: use Sequel GENERIC_TYPES: -->
28
- # Constants
29
- # GENERIC_TYPES = %w'String Integer Float Numeric BigDecimal Date DateTime
30
- # Time File TrueClass FalseClass'.freeze
31
- # Classes specifying generic types that Sequel will convert to
32
- # database-specific types.
33
- DB_TYPE_STRING_RGX = /\ACHAR\s*\(\d+\)\z/.freeze
34
-
35
- def self.get_edm_type(db_type:)
36
- case db_type
37
- when 'INTEGER'
38
- 'Edm.Int32'
39
- when 'TEXT', 'STRING'
40
- 'Edm.String'
41
- else
42
- 'Edm.String' if DB_TYPE_STRING_RGX =~ db_type
43
- end
44
- end
45
- end
3
+ require_relative 'error'
46
4
 
47
- module REXML
48
- # some small extensions
49
- class Document
50
- def to_pretty_xml
51
- formatter = REXML::Formatters::Pretty.new(2)
52
- formatter.compact = true
53
- strio = ''
54
- formatter.write(root, strio)
55
- strio
56
- end
57
- end
58
- end
59
-
60
- # Core
5
+ # our main namespace
61
6
  module Safrano
62
7
  # represents a state transition when navigating/parsing the url path
63
8
  # from left to right
@@ -66,6 +11,15 @@ module Safrano
66
11
  attr_accessor :match_result
67
12
  attr_accessor :rgx
68
13
  attr_reader :remain_idx
14
+
15
+ EMPTYSTR = ''.freeze
16
+ SLASH = '/'.freeze
17
+
18
+ RESULT_BAD_REQ_ERR = [nil, :error, ::Safrano::BadRequestError].freeze
19
+ RESULT_NOT_FOUND_ERR = [nil, :error, ::Safrano::ErrorNotFound].freeze
20
+ RESULT_SERVER_TR_ERR = [nil, :error, ServerTransitionError].freeze
21
+ RESULT_END = [nil, :end].freeze
22
+
69
23
  def initialize(arg, trans: nil, remain_idx: 2)
70
24
  @rgx = if arg.respond_to? :each_char
71
25
  Regexp.new(arg)
@@ -88,9 +42,9 @@ module Safrano
88
42
 
89
43
  def path_done
90
44
  if @match_result
91
- @match_result[1] || ''
45
+ @match_result[1] || EMPTYSTR
92
46
  else
93
- ''
47
+ EMPTYSTR
94
48
  end
95
49
  end
96
50
 
@@ -1,58 +1,149 @@
1
- require 'odata/error.rb'
1
+ # frozen_string_literal: true
2
+
3
+ require 'odata/error'
2
4
 
3
5
  # url parameters processing . Mostly delegates to specialised classes
4
6
  # (filter, order...) to convert into Sequel exprs.
5
- module OData
6
- class UrlParameters
7
+ module Safrano
8
+ class UrlParametersBase
9
+ attr_reader :expand
10
+ attr_reader :select
11
+
12
+ # url params validation methods.
13
+ # nil is the expected return for no errors
14
+ # an error class is returned in case of errors
15
+ # this way we can combine multiple validation calls with logical ||
16
+ def check_expand
17
+ @expand.parse_error?
18
+ end
19
+
20
+ def check_select
21
+ @select.parse_error?
22
+ end
23
+
24
+ def initialize(dataset, params = {})
25
+ @model = if dataset.respond_to? :model
26
+ dataset.model
27
+ else
28
+ dataset
29
+ end
30
+ @params = params
31
+ end
32
+ end
33
+
34
+ # url parameters for a single entity expand/select
35
+ class UrlParameters4Single < UrlParametersBase
36
+ def initialize(dataset, params)
37
+ super
38
+ @expand = ExpandBase.factory(@params['$expand'], @model)
39
+ @select = SelectBase.factory(@params['$select'], @model)
40
+ end
41
+
42
+ def check_all
43
+ return Contract::OK unless @params
44
+
45
+ check_expand.if_valid do
46
+ check_select
47
+ end
48
+ end
49
+ end
50
+
51
+ # url parameters for a collection expand/select + filter/order
52
+ class UrlParameters4Coll < UrlParametersBase
7
53
  attr_reader :filt
8
54
  attr_reader :ordby
9
- def initialize(jh, params)
10
- @jh = jh
55
+
56
+ def initialize(dataset, params = {})
57
+ super
58
+ # join helper is only needed for odering or filtering
59
+ @jh = @model.join_by_paths_helper if params['$orderby'] || params['$filter']
11
60
  @params = params
61
+ @ordby = OrderBase.factory(@params['$orderby'], @jh)
62
+ @filt = FilterBase.factory(@params['$filter'])
63
+ @expand = ExpandBase.factory(@params['$expand'], @model)
64
+ @select = SelectBase.factory(@params['$select'], @model)
12
65
  end
13
66
 
14
- def check_filter
15
- return unless @params['$filter']
67
+ def check_top
68
+ return Contract::OK unless @params['$top']
16
69
 
17
- @filt = FilterByParse.new(@params['$filter'], @jh)
18
- return BadRequestFilterParseError if @filt.parse_error?
70
+ itop = number_or_nil(@params['$top'])
71
+ (itop.nil? || itop.zero?) ? BadRequestError : Contract::OK
72
+ end
73
+
74
+ def check_skip
75
+ return Contract::OK unless @params['$skip']
19
76
 
20
- # nil is the expected return for no errors
21
- nil
77
+ iskip = number_or_nil(@params['$skip'])
78
+ (iskip.nil? || iskip.negative?) ? BadRequestError : Contract::OK
22
79
  end
23
80
 
24
- def check_order
25
- return unless @params['$orderby']
81
+ def check_inlinecount
82
+ return Contract::OK unless (icp = @params['$inlinecount'])
26
83
 
27
- pordlist = @params['$orderby'].dup
28
- pordlist.split(',').each do |pord|
29
- pord.strip!
30
- qualfn, dir = pord.split(/\s/)
31
- qualfn.strip!
32
- dir.strip! if dir
33
- return BadRequestError unless @jh.start_model.attrib_path_valid? qualfn
34
- return BadRequestError unless [nil, 'asc', 'desc'].include? dir
35
- end
84
+ ((icp == 'allpages') || (icp == 'none')) ? Contract::OK : BadRequestInlineCountParamError
85
+ end
86
+
87
+ def check_filter
88
+ (err = @filt.parse_error?) ? err : Contract::OK
89
+ end
36
90
 
37
- @ordby = Order.new_by_parse(@params['$orderby'], @jh)
91
+ def check_orderby
92
+ return Contract::OK if @ordby.empty?
93
+ return BadRequestOrderParseError if @ordby.parse_error?
38
94
 
39
- # nil is the expected return for no errors
40
- nil
95
+ Contract::OK
41
96
  end
42
97
 
43
98
  def apply_to_dataset(dtcx)
44
- return dtcx if (@filt.nil? && @ordby.nil?)
45
-
46
- if @filt.nil?
47
- dtcx = @jh.dataset.select_all(@jh.start_model.table_name)
48
-
49
- @ordby.apply_to_dataset(dtcx)
50
- elsif @ordby.nil?
51
- @filt.apply_to_dataset(dtcx)
52
- else
53
- filtexpr = @filt.sequel_expr
54
- dtcx = @jh.dataset(dtcx).where(filtexpr).select_all(@jh.start_model.table_name)
55
- @ordby.apply_to_dataset(dtcx)
99
+ apply_expand_to_dataset(dtcx).if_valid do |dataset|
100
+ apply_filter_order_to_dataset(dataset)
101
+ end
102
+ end
103
+
104
+ def apply_expand_to_dataset(dtcx)
105
+ return Contract.valid(dtcx) if @expand.empty?
106
+
107
+ @expand.apply_to_dataset(dtcx)
108
+ end
109
+
110
+ # Warning, the @ordby and @filt objects are coupled by way of the join helper
111
+ def apply_filter_order_to_dataset(dtcx)
112
+ return Contract.valid(dtcx) if @filt.empty? && @ordby.empty?
113
+
114
+ # filter object and join-helper need to be finalized after filter
115
+ # has been parsed and checked
116
+ @filt.finalize(@jh).if_valid do
117
+ # start with the join
118
+ dtcx = @jh.dataset(dtcx)
119
+
120
+ @filt.apply_to_dataset(dtcx).map_result! do |dataset|
121
+ dtcx = dataset
122
+ dtcx = @ordby.apply_to_dataset(dtcx)
123
+ dtcx.select_all(@jh.start_model.table_name)
124
+ end
125
+ end
126
+ end
127
+
128
+ ###########################################################
129
+ def check_all
130
+ return Contract::OK unless @params
131
+
132
+ # lazy nested proc evaluation.
133
+ # if one check fails, it will be passed up the chain and the ones
134
+ # below will not be evaluated
135
+ check_top.if_valid do
136
+ check_skip.if_valid do
137
+ check_orderby.if_valid do
138
+ check_filter.if_valid do
139
+ check_expand.if_valid do
140
+ check_select.if_valid do
141
+ check_inlinecount
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
56
147
  end
57
148
  end
58
149
  end