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