safrano 0.2.0 → 0.3.0

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