safrano 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -47,6 +47,16 @@ module OData
47
47
  raise ArgumentError
48
48
  end
49
49
  end
50
+
51
+ def with_metadata_info(xnamespace)
52
+ bdprops = []
53
+ each_endobj do |eo|
54
+ bdprops << { type: "#{xnamespace}.#{eo}",
55
+ role: eo,
56
+ multiplicity: multiplicity[eo] }
57
+ end
58
+ yield name, bdprops
59
+ end
50
60
  end
51
61
 
52
62
  # some usefull stuff
@@ -74,5 +84,31 @@ module OData
74
84
  @list[rid] = rel
75
85
  end
76
86
  end
87
+
88
+ def get_metadata_xml_attribs(from, to, assoc_type, xnamespace)
89
+ rel = get([from, to])
90
+ # use Sequel reflection to get multiplicity (will be used later
91
+ # in 2. Associations below)
92
+ case assoc_type
93
+ # TODO: use multiplicity 1 when needed instead of '0..1'
94
+ when :one_to_one
95
+ rel.set_multiplicity(from, '0..1')
96
+ rel.set_multiplicity(to, '0..1')
97
+ when :one_to_many
98
+ rel.set_multiplicity(from, '0..1')
99
+ rel.set_multiplicity(to, '*')
100
+ when :many_to_one
101
+ rel.set_multiplicity(from, '*')
102
+ rel.set_multiplicity(to, '0..1')
103
+ when :many_to_many
104
+ rel.set_multiplicity(from, '*')
105
+ rel.set_multiplicity(to, '*')
106
+ end
107
+ # <NavigationProperty Name="Supplier"
108
+ # Relationship="ODataDemo.Product_Supplier_Supplier_Products"
109
+ # FromRole="Product_Supplier" ToRole="Supplier_Products"/>
110
+ { 'Name' => to, 'Relationship' => "#{xnamespace}.#{rel.name}",
111
+ 'FromRole' => from, 'ToRole' => to }
112
+ end
77
113
  end
78
114
  end
@@ -0,0 +1,58 @@
1
+ require 'odata/error.rb'
2
+
3
+ # url parameters processing . Mostly delegates to specialised classes
4
+ # (filter, order...) to convert into Sequel exprs.
5
+ module OData
6
+ class UrlParameters
7
+ attr_reader :filt
8
+ attr_reader :ordby
9
+ def initialize(jh, params)
10
+ @jh = jh
11
+ @params = params
12
+ end
13
+
14
+ def check_filter
15
+ return unless @params['$filter']
16
+
17
+ @filt = FilterByParse.new(@params['$filter'], @jh)
18
+ return BadRequestFilterParseError if @filt.parse_error?
19
+
20
+ # nil is the expected return for no errors
21
+ nil
22
+ end
23
+
24
+ def check_order
25
+ return unless @params['$orderby']
26
+
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
36
+
37
+ @ordby = Order.new_by_parse(@params['$orderby'], @jh)
38
+
39
+ # nil is the expected return for no errors
40
+ nil
41
+ end
42
+
43
+ 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
+ @ordby.apply_to_dataset(dtcx)
49
+ elsif @ordby.nil?
50
+ @filt.apply_to_dataset(dtcx)
51
+ else
52
+ filtexpr = @filt.sequel_expr
53
+ dtcx = @jh.dataset(dtcx).where(filtexpr).select_all(@jh.start_model.table_name)
54
+ @ordby.apply_to_dataset(dtcx)
55
+ end
56
+ end
57
+ end
58
+ end
data/lib/odata/walker.rb CHANGED
@@ -79,57 +79,70 @@ module OData
79
79
  end
80
80
  end
81
81
 
82
+ # perform a content-id ($batch changeset ref) transition
83
+ def do_run_with_content_id
84
+ if @content_id_refs.is_a? Hash
85
+ if (@context = @content_id_refs[@context.to_s])
86
+ @status = :run
87
+ else
88
+ @context = nil
89
+ @status = :error
90
+ # TODO: more appropriate error handling
91
+ @error = OData::ErrorNotFound
92
+ end
93
+ else
94
+ @context = nil
95
+ @status = :error
96
+ # TODO: more appropriate error handling
97
+ @error = OData::ErrorNotFound
98
+ end
99
+ end
100
+
101
+ # little hacks... depending on returned state, set some attributes
102
+ def state_mappings
103
+ case @status
104
+ when :end_with_count
105
+ @do_count = true
106
+ @status = :end
107
+ when :end_with_value
108
+ @raw_value = true
109
+ @status = :end
110
+ when :run_with_links
111
+ @do_links = true
112
+ @status = :run
113
+ end
114
+ end
115
+
116
+ def do_next_transition
117
+ @context, @status, @error = @tr_next.do_transition(@context)
118
+ # little hack's
119
+ case @status
120
+ # we dont have the content-id references data on service level
121
+ # but we have it here, so in case of a $content-id transition
122
+ # the returned context is just the id, and we get the final result
123
+ # entity reference here and place it in @context
124
+ when :run_with_content_id
125
+ do_run_with_content_id
126
+ end
127
+
128
+ @contexts << @context
129
+ @path_remain = @tr_next.path_remain
130
+ @path_done << @tr_next.path_done
131
+
132
+ # little hack's
133
+ state_mappings
134
+ end
135
+
82
136
  def eo
83
137
  while @context
84
138
  get_next_transition
85
139
  if @tr_next
86
- @context, @status, @error = @tr_next.do_transition(@context)
87
- # little hack's
88
- case @status
89
- # we dont have the content-id references data on service level
90
- # but we have it here, so in case of a $content-id transition
91
- # the returned context is just the id, and we get the final result
92
- # entity reference here and place it in @context
93
- when :run_with_content_id
94
- if @content_id_refs.is_a? Hash
95
- if (@context = @content_id_refs[@context.to_s])
96
- @status = :run
97
- else
98
- @context = nil
99
- @status = :error
100
- # TODO: more appropriate error handling
101
- @error = OData::ErrorNotFound
102
- end
103
- else
104
- @context = nil
105
- @status = :error
106
- # TODO: more appropriate error handling
107
- @error = OData::ErrorNotFound
108
- end
109
- end
110
-
111
- @contexts << @context
112
- @path_remain = @tr_next.path_remain
113
- @path_done << @tr_next.path_done
114
-
115
- # little hack's
116
- case @status
117
- when :end_with_count
118
- @do_count = true
119
- @status = :end
120
- when :end_with_value
121
- @raw_value = true
122
- @status = :end
123
- when :run_with_links
124
- @do_links = true
125
- @status = :run
126
- end
140
+ do_next_transition
127
141
  else
128
142
  @context = nil
129
143
  @status = :error
130
144
  @error = OData::ErrorNotFound
131
145
  end
132
-
133
146
  end
134
147
  # TODO: shouldnt we raise an error here if @status != :end ?
135
148
  return false unless @status == :end
data/lib/request.rb CHANGED
@@ -8,8 +8,6 @@ module OData
8
8
  # is not passed
9
9
  class Request < Rack::Request
10
10
  HEADER_PARAM = /\s*[\w.]+=(?:[\w.]+|"(?:[^"\\]|\\.)*")?\s*/.freeze
11
- # HEADER_VALUE_WITH_PARAMS = /(?:(?:\w+|\*)\/(?:\w+(?:\.|\-|\+)?|\*)*)
12
- # \s*(?:;#{HEADER_PARAM})*/
13
11
  HEADER_VAL_RAW = '(?:\w+|\*)\/(?:\w+(?:\.|\-|\+)?|\*)*'.freeze
14
12
  HEADER_VAL_WITH_PAR = /(?:#{HEADER_VAL_RAW})\s*(?:;#{HEADER_PARAM})*/.freeze
15
13
 
@@ -135,7 +133,7 @@ module OData
135
133
  end
136
134
 
137
135
  def with_parsed_data(on_error: nil)
138
- if content_type == 'application/json'
136
+ if content_type == APPJSON
139
137
  # Parse json payload
140
138
  begin
141
139
  data = JSON.parse(body.read)
data/lib/safrano.rb CHANGED
@@ -10,5 +10,22 @@ require 'odata/collection.rb'
10
10
  require 'service.rb'
11
11
  require 'odata/walker.rb'
12
12
  require 'sequel'
13
+ require_relative './sequel_join_by_paths.rb'
13
14
  require 'rack_app'
14
15
  require 'odata_rack_builder'
16
+
17
+ # picked from activsupport; needed for ruby < 2.5
18
+ # Destructively converts all keys using the +block+ operations.
19
+ # Same as +transform_keys+ but modifies +self+.
20
+ class Hash
21
+ def transform_keys!
22
+ keys.each do |key|
23
+ self[yield(key)] = delete(key)
24
+ end
25
+ self
26
+ end unless method_defined? :transform_keys!
27
+
28
+ def symbolize_keys!
29
+ transform_keys! { |key| key.to_sym rescue key }
30
+ end
31
+ end
data/lib/safrano_core.rb CHANGED
@@ -1,7 +1,24 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- # Type mapping DB --> Edm
3
+ # our main namespace
4
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
+ TEXTPLAIN_UTF8 = 'text/plain;charset=utf-8'.freeze
9
+ APPJSON = 'application/json'.freeze
10
+ APPXML = 'application/xml'.freeze
11
+
12
+ APPXML_UTF8 = 'application/xml;charset=utf-8'.freeze
13
+ APPATOMXML_UTF8 = 'application/atomsvc+xml;charset=utf-8'.freeze
14
+ APPJSON_UTF8 = 'application/json;charset=utf-8'.freeze
15
+
16
+ CT_JSON = { CONTENT_TYPE => APPJSON_UTF8 }.freeze
17
+ CT_TEXT = { CONTENT_TYPE => TEXTPLAIN_UTF8 }.freeze
18
+ CT_ATOMXML = { CONTENT_TYPE => APPATOMXML_UTF8 }.freeze
19
+ CT_APPXML = { CONTENT_TYPE => APPXML_UTF8 }.freeze
20
+
21
+ # Type mapping DB --> Edm
5
22
  # TypeMap = {"INTEGER" => "Edm.Int32" , "TEXT" => "Edm.String",
6
23
  # "STRING" => "Edm.String"}
7
24
  # Todo: complete mapping... this is just for the most common ones
@@ -12,6 +29,8 @@ module OData
12
29
  # Time File TrueClass FalseClass'.freeze
13
30
  # Classes specifying generic types that Sequel will convert to
14
31
  # database-specific types.
32
+ DB_TYPE_STRING_RGX = /\ACHAR\s*\(\d+\)\z/.freeze
33
+
15
34
  def self.get_edm_type(db_type:)
16
35
  case db_type
17
36
  when 'INTEGER'
@@ -19,7 +38,7 @@ module OData
19
38
  when 'TEXT', 'STRING'
20
39
  'Edm.String'
21
40
  else
22
- 'Edm.String' if /\ACHAR\s*\(\d+\)\z/ =~ db_type
41
+ 'Edm.String' if DB_TYPE_STRING_RGX =~ db_type
23
42
  end
24
43
  end
25
44
  end
@@ -45,22 +64,25 @@ module Safrano
45
64
  attr_accessor :trans
46
65
  attr_accessor :match_result
47
66
  attr_accessor :rgx
48
- def initialize(arg, trans: nil)
67
+ attr_reader :remain_idx
68
+ def initialize(arg, trans: nil, remain_idx: 2)
49
69
  @rgx = if arg.respond_to? :each_char
50
70
  Regexp.new(arg)
51
71
  else
52
72
  arg
53
73
  end
54
74
  @trans = trans
75
+ @remain_idx = remain_idx
55
76
  end
56
77
 
57
78
  def do_match(str)
58
79
  @match_result = @rgx.match(str)
59
80
  end
60
81
 
61
- # this assumes some implicit rules in the way the regexps are built
82
+ # remain_idx is the index of the last match-data. ususally its 2
83
+ # but can be overidden
62
84
  def path_remain
63
- @match_result[2] if @match_result && @match_result[2]
85
+ @match_result[@remain_idx] if @match_result && @match_result[@remain_idx]
64
86
  end
65
87
 
66
88
  def path_done
@@ -81,8 +103,9 @@ module Safrano
81
103
  trans: 'transition_metadata')
82
104
  TransitionBatch = Transition.new('\A(\/\$batch)(.*)',
83
105
  trans: 'transition_batch')
84
- TransitionContentId = Transition.new('\A(\/\$\d+)(.*)',
85
- trans: 'transition_content_id')
106
+ TransitionContentId = Transition.new('\A(\/\$(\d+))(.*)',
107
+ trans: 'transition_content_id',
108
+ remain_idx: 3)
86
109
  TransitionCount = Transition.new('(\A\/\$count)(.*)\z',
87
110
  trans: 'transition_count')
88
111
  TransitionValue = Transition.new('(\A\/\$value)(.*)\z',
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'set'
4
+ require 'sequel'
5
+
6
+ # some helpers
7
+ class String
8
+ # thanks https://stackoverflow.com/questions/1448670/ruby-stringto-class
9
+ def constantize
10
+ names = split('::')
11
+ names.shift if names.empty? || names.first.empty?
12
+
13
+ const = Object
14
+ names.each do |name|
15
+ const = if const.const_defined?(name)
16
+ const.const_get(name)
17
+ else
18
+ const.const_missing(name)
19
+ end
20
+ end
21
+ const
22
+ end
23
+ end
24
+
25
+ class PathNode < String
26
+ attr_reader :table_name
27
+ attr_accessor :model_class
28
+ attr_accessor :path_str
29
+ def initialize(str, start_model)
30
+ super(str)
31
+ @start_model = start_model
32
+ end
33
+
34
+ def table_name
35
+ @model_class.table_name
36
+ end
37
+
38
+ def set_model_class_by_parent_model(pmclass)
39
+ @model_class = pmclass.association_reflection(to_sym)[:class_name].constantize
40
+ end
41
+
42
+ def alias_sym
43
+ @start_model.get_alias_sym(path_str)
44
+ end
45
+ end
46
+
47
+ class QPath
48
+ attr_reader :segments
49
+ attr_reader :start_model
50
+ attr_reader :qpath
51
+
52
+ def initialize(smodel, path_str)
53
+ @qpath = path_str
54
+ @start_model = smodel
55
+ segment
56
+ end
57
+
58
+ def size
59
+ @segments.size
60
+ end
61
+
62
+ # source: ..https://ruby-doc.org/core-2.6.5/Hash.html
63
+ def ==(other)
64
+ (self.class === other) &&
65
+ (@start_model == other.start_model) &&
66
+ (@qpath == other.qpath)
67
+ end
68
+
69
+ alias eql? ==
70
+
71
+ def hash
72
+ @start_model.hash ^ @qpath.hash # XOR
73
+ end
74
+
75
+ def segment
76
+ @segments = []
77
+ start_node = @start_model.create_path_node('')
78
+ start_node.model_class = @start_model
79
+ path_str = ''
80
+ path_nodes = []
81
+ start_node.path_str = path_str
82
+ nodes = [start_node]
83
+ nodes.concat @qpath.split('/').map { |nstr| @start_model.create_path_node(nstr) }
84
+ if (ns = nodes.size) > 1
85
+ nodes[0...-1].each_with_index do |node, i|
86
+ nodes[i + 1].set_model_class_by_parent_model(node.model_class)
87
+ path_nodes << nodes[i + 1]
88
+ nodes[i + 1].path_str = path_nodes.join('/')
89
+ @segments << [node, nodes[i + 1]]
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ class JoinByPathsHelper < Set
96
+ attr_reader :result
97
+ attr_reader :start_model
98
+
99
+ def initialize(smodel)
100
+ super()
101
+ @start_model = smodel
102
+ end
103
+
104
+ def build_unique_join_segments
105
+ return (@result = []) if empty?
106
+
107
+ maxlen = map(&:size).max
108
+ iset = nil
109
+ @result = []
110
+
111
+ (0...maxlen).each do |i|
112
+ iset = Set.new
113
+ each { |qp| (iset << qp.segments[i]) if qp.segments[i] }
114
+ @result << iset
115
+ end
116
+
117
+ @result.map! do |jseg|
118
+ jseg.map do |seg|
119
+ leftm = seg.first.model_class
120
+ assoc = leftm.association_reflection(seg.last.to_sym)
121
+ rightm = seg.last.model_class
122
+ # cf. documentation in sequel/model/associations.rb
123
+ case assoc[:type]
124
+ # :many_to_one :: Foreign key in current model's table points to
125
+ # associated model's primary key.
126
+ when :many_to_one
127
+ lks = [assoc[:key]].flatten
128
+ rks = [rightm.primary_key].flatten
129
+
130
+ when :one_to_many, :one_to_one
131
+ # :one_to_many :: Foreign key in associated model's table points to this
132
+ # model's primary key.
133
+ # :one_to_one :: Similar to one_to_many in terms of foreign keys, but
134
+ # only one object is associated to the current object through the
135
+ # association.
136
+ lks = [leftm.primary_key].flatten
137
+ rks = [assoc[:key]].flatten
138
+
139
+ # TODO
140
+ # when # :many_to_many :: A join table is used that has a foreign key that points
141
+ # to this model's primary key and a foreign key that points to the
142
+ # associated model's primary key. Each current model object can be
143
+ # associated with many associated model objects, and each associated
144
+ # model object can be associated with many current model objects.
145
+ # when # :one_through_one :: Similar to many_to_many in terms of foreign keys, but only one object
146
+ # is associated to the current object through the association.
147
+ # Provides only getter methods, no setter or modification methods.
148
+
149
+ end
150
+
151
+ lks.map! { |k| Sequel[seg.first.alias_sym][k] } unless seg.first.empty?
152
+
153
+ rks.map! { |k| Sequel[seg.last.alias_sym][k] }
154
+
155
+ { type: assoc[:type],
156
+ segment: seg,
157
+ left: leftm.table_name,
158
+ right: rightm.table_name,
159
+ alias: seg.last.alias_sym,
160
+ left_keys: lks,
161
+ right_keys: rks,
162
+ cond: rks.zip(lks).to_h }
163
+ end
164
+ end
165
+ end
166
+
167
+ def add(path_str)
168
+ super(QPath.new(@start_model, path_str))
169
+ end
170
+
171
+ def dataset(start_dtset = nil)
172
+ start_dataset = (start_dtset || @start_model.dataset)
173
+ return start_dataset if empty?
174
+
175
+ build_unique_join_segments
176
+ @result.flatten.inject(start_dataset) do |dt, jo|
177
+ dt.left_join(Sequel[jo[:right]].as(jo[:alias]),
178
+ jo[:cond],
179
+ implicit_qualifier: jo[:left])
180
+ end
181
+ end
182
+
183
+ def join_by_paths_helper(*pathlist)
184
+ pathlist.each { |path_str| add path_str }
185
+ self
186
+ end
187
+ end
188
+
189
+ module Sequel
190
+ module Plugins
191
+ module JoinByPaths
192
+ def self.apply(model)
193
+ model.instance_exec do
194
+ @aliases_sym = {}
195
+ @alias_cnt = 0
196
+ end
197
+ end
198
+
199
+ def self.configure(model)
200
+ model.instance_eval do
201
+ @aliases_sym = {}
202
+ @alias_cnt = 0
203
+ end
204
+ end
205
+ module ClassMethods
206
+ attr_reader :aliases_sym
207
+ attr_reader :alias_cnt
208
+ Plugins.inherited_instance_variables(self,
209
+ :@aliases_sym => :dup,
210
+ :@alias_cnt => :dup)
211
+
212
+ def get_alias_sym(pstr)
213
+ if @aliases_sym.key?(pstr)
214
+ @aliases_sym[pstr]
215
+ else
216
+ @alias_cnt += 1
217
+ @aliases_sym[pstr] = "a#{@alias_cnt}".to_sym
218
+ end
219
+ end
220
+
221
+ def create_path_node(pstr)
222
+ PathNode.new(pstr, self)
223
+ end
224
+
225
+ def join_by_paths_helper(*pathlist)
226
+ jh = JoinByPathsHelper.new(self)
227
+ pathlist.each { |path_str| jh.add path_str }
228
+ jh
229
+ end
230
+ end
231
+ module DatasetMethods
232
+ attr_reader :join_helper
233
+ def join_by_paths(*pathlist)
234
+ model.join_by_paths_helper(*pathlist).dataset
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end