safrano 0.4.2 → 0.5.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.
- checksums.yaml +4 -4
- data/lib/core_ext/Dir/iter.rb +18 -0
- data/lib/core_ext/Hash/transform.rb +21 -0
- data/lib/core_ext/Integer/edm.rb +13 -0
- data/lib/core_ext/REXML/Document/output.rb +16 -0
- data/lib/core_ext/String/convert.rb +25 -0
- data/lib/core_ext/String/edm.rb +13 -0
- data/lib/core_ext/dir.rb +3 -0
- data/lib/core_ext/hash.rb +3 -0
- data/lib/core_ext/integer.rb +3 -0
- data/lib/core_ext/rexml.rb +3 -0
- data/lib/core_ext/string.rb +5 -0
- data/lib/odata/attribute.rb +15 -10
- data/lib/odata/batch.rb +9 -7
- data/lib/odata/collection.rb +140 -591
- data/lib/odata/collection_filter.rb +18 -42
- data/lib/odata/collection_media.rb +111 -54
- data/lib/odata/collection_order.rb +5 -2
- data/lib/odata/common_logger.rb +2 -0
- data/lib/odata/complex_type.rb +152 -0
- data/lib/odata/edm/primitive_types.rb +184 -0
- data/lib/odata/entity.rb +123 -172
- data/lib/odata/error.rb +183 -32
- data/lib/odata/expand.rb +20 -17
- data/lib/odata/filter/base.rb +74 -0
- data/lib/odata/filter/error.rb +49 -6
- data/lib/odata/filter/parse.rb +41 -25
- data/lib/odata/filter/sequel.rb +133 -62
- data/lib/odata/filter/sequel_function_adapter.rb +148 -0
- data/lib/odata/filter/token.rb +26 -19
- data/lib/odata/filter/tree.rb +106 -52
- data/lib/odata/function_import.rb +168 -0
- data/lib/odata/model_ext.rb +639 -0
- data/lib/odata/navigation_attribute.rb +13 -26
- data/lib/odata/relations.rb +5 -5
- data/lib/odata/select.rb +17 -5
- data/lib/odata/transition.rb +71 -0
- data/lib/odata/url_parameters.rb +100 -24
- data/lib/odata/walker.rb +20 -10
- data/lib/safrano.rb +18 -38
- data/lib/safrano/contract.rb +143 -0
- data/lib/safrano/core.rb +23 -107
- data/lib/safrano/core_ext.rb +13 -0
- data/lib/safrano/deprecation.rb +73 -0
- data/lib/safrano/multipart.rb +29 -33
- data/lib/safrano/rack_app.rb +66 -65
- data/lib/safrano/{odata_rack_builder.rb → rack_builder.rb} +18 -2
- data/lib/safrano/request.rb +96 -45
- data/lib/safrano/response.rb +4 -2
- data/lib/safrano/sequel_join_by_paths.rb +2 -2
- data/lib/safrano/service.rb +240 -130
- data/lib/safrano/version.rb +3 -1
- data/lib/sequel/plugins/join_by_paths.rb +6 -19
- metadata +32 -11
@@ -0,0 +1,168 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'complex_type'
|
4
|
+
require_relative 'edm/primitive_types'
|
5
|
+
require_relative 'transition'
|
6
|
+
|
7
|
+
module Safrano
|
8
|
+
def self.FunctionImport(name)
|
9
|
+
FunctionImport::Function.new(name)
|
10
|
+
end
|
11
|
+
|
12
|
+
module FunctionImport
|
13
|
+
class Function
|
14
|
+
@allowed_transitions = [Safrano::TransitionEnd]
|
15
|
+
attr_reader :name
|
16
|
+
attr_reader :proc
|
17
|
+
|
18
|
+
def initialize(name)
|
19
|
+
@name = name
|
20
|
+
@http_method = 'GET'
|
21
|
+
end
|
22
|
+
|
23
|
+
def allowed_transitions
|
24
|
+
[Safrano::TransitionEnd]
|
25
|
+
end
|
26
|
+
|
27
|
+
def input(**parmtypes)
|
28
|
+
@input = {}
|
29
|
+
parmtypes.each do |k, t|
|
30
|
+
@input[k] = case t.name
|
31
|
+
when 'Integer'
|
32
|
+
Safrano::Edm::Edm::Int32
|
33
|
+
when 'String'
|
34
|
+
Safrano::Edm::Edm::String
|
35
|
+
when 'Float'
|
36
|
+
Safrano::Edm::Edm::Double
|
37
|
+
when 'DateTime'
|
38
|
+
Safrano::Edm::Edm::DateTime
|
39
|
+
else
|
40
|
+
t
|
41
|
+
end
|
42
|
+
end
|
43
|
+
self
|
44
|
+
end
|
45
|
+
|
46
|
+
def return(klassmod, &proc)
|
47
|
+
raise('Please provide a code block') unless block_given?
|
48
|
+
|
49
|
+
@returning = if klassmod.respond_to? :return_as_instance_descriptor
|
50
|
+
klassmod.return_as_instance_descriptor
|
51
|
+
else
|
52
|
+
# if it's neither a ComplexType nor a Model-Entity
|
53
|
+
# --> assume it is a Primitive
|
54
|
+
ResultAsPrimitiveType.new(klassmod)
|
55
|
+
end
|
56
|
+
@proc = proc
|
57
|
+
self
|
58
|
+
end
|
59
|
+
|
60
|
+
def return_collection(klassmod, &proc)
|
61
|
+
raise('Please provide a code block') unless block_given?
|
62
|
+
|
63
|
+
@returning = if klassmod.respond_to? :return_as_collection_descriptor
|
64
|
+
klassmod.return_as_collection_descriptor
|
65
|
+
else
|
66
|
+
# if it's neither a ComplexType nor a Modle-Entity
|
67
|
+
# --> assume it is a Primitive
|
68
|
+
ResultAsPrimitiveTypeColl.new(klassmod)
|
69
|
+
end
|
70
|
+
@proc = proc
|
71
|
+
self
|
72
|
+
end
|
73
|
+
# def initialize_params
|
74
|
+
# @uparms = UrlParameters4Func.new(@model, @params)
|
75
|
+
# end
|
76
|
+
|
77
|
+
def check_missing_params
|
78
|
+
# do we have all parameters provided ? use Set difference to check
|
79
|
+
pkeys = @params.keys.map(&:to_sym).to_set
|
80
|
+
unless (idiff = @input.keys.to_set - pkeys).empty?
|
81
|
+
|
82
|
+
Safrano::ServiceOperationParameterMissing.new(
|
83
|
+
missing: idiff.to_a,
|
84
|
+
sopname: @name
|
85
|
+
)
|
86
|
+
else
|
87
|
+
Contract::OK
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def check_url_func_params
|
92
|
+
@funcparams = {}
|
93
|
+
return nil unless @input # anything to check ?
|
94
|
+
|
95
|
+
# do we have all parameters provided ?
|
96
|
+
check_missing_params.tap_error { |error| return error }
|
97
|
+
# ==> all params were provided
|
98
|
+
|
99
|
+
# now we shall check the content and type of the parameters
|
100
|
+
@input.each do |ksym, typ|
|
101
|
+
typ.convert_from_urlparam(v = @params[ksym.to_s])
|
102
|
+
.tap_valid do |retval|
|
103
|
+
@funcparams[ksym] = retval
|
104
|
+
end
|
105
|
+
.tap_error do
|
106
|
+
# return is really needed here, or we end up returning nil below
|
107
|
+
return parameter_convertion_error(ksym, typ, v)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
nil
|
111
|
+
end
|
112
|
+
|
113
|
+
def parameter_convertion_error(param, type, val)
|
114
|
+
Safrano::ServiceOperationParameterError.new(type: type,
|
115
|
+
value: val,
|
116
|
+
param: param,
|
117
|
+
sopname: @name)
|
118
|
+
end
|
119
|
+
|
120
|
+
def add_metadata_rexml(ec)
|
121
|
+
## https://services.odata.org/V2/OData/Safrano.svc/$metadata
|
122
|
+
# <FunctionImport Name="GetProductsByRating" EntitySet="Products" ReturnType="Collection(ODataDemo.Product)" m:HttpMethod="GET">
|
123
|
+
# <Parameter Name="rating" Type="Edm.Int32" Mode="In"/>
|
124
|
+
# </FunctionImport>
|
125
|
+
funky = ec.add_element('FunctionImport',
|
126
|
+
'Name' => @name.to_s,
|
127
|
+
# EntitySet= @entity_set ,
|
128
|
+
'ReturnType' => @returning.type_metadata,
|
129
|
+
'm:HttpMethod' => @http_method)
|
130
|
+
@input.each do |iname, type|
|
131
|
+
funky.add_element('Parameter',
|
132
|
+
'Name' => iname.to_s,
|
133
|
+
'Type' => type.type_name,
|
134
|
+
'Mode' => 'In')
|
135
|
+
end if @input
|
136
|
+
funky
|
137
|
+
end
|
138
|
+
|
139
|
+
def with_validated_get(req)
|
140
|
+
# initialize_params
|
141
|
+
return yield unless (@error = check_url_func_params)
|
142
|
+
|
143
|
+
@error.odata_get(req) if @error
|
144
|
+
end
|
145
|
+
|
146
|
+
def to_odata_json(req)
|
147
|
+
result = @proc.call(**@funcparams)
|
148
|
+
@returning.to_odata_json(result, req)
|
149
|
+
end
|
150
|
+
|
151
|
+
def odata_get_output(req)
|
152
|
+
[200, EMPTY_HASH, [to_odata_json(req)]]
|
153
|
+
end
|
154
|
+
|
155
|
+
def odata_get(req)
|
156
|
+
@params = req.params
|
157
|
+
|
158
|
+
with_validated_get(req) do
|
159
|
+
odata_get_output(req)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def transition_end(_match_result)
|
164
|
+
Transition::RESULT_END
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
@@ -0,0 +1,639 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Design: Collections are nothing more as Sequel based model classes that have
|
4
|
+
# somehow the character of an array (Enumerable)
|
5
|
+
# Thus Below we have called that "EntityClass". It's meant as "Collection"
|
6
|
+
|
7
|
+
require 'json'
|
8
|
+
require 'rexml/document'
|
9
|
+
require_relative '../safrano/core'
|
10
|
+
require_relative 'error'
|
11
|
+
require_relative 'collection_filter'
|
12
|
+
require_relative 'collection_order'
|
13
|
+
require_relative 'expand'
|
14
|
+
require_relative 'select'
|
15
|
+
require_relative 'url_parameters'
|
16
|
+
require_relative 'collection_media'
|
17
|
+
require_relative 'function_import'
|
18
|
+
|
19
|
+
module Safrano
|
20
|
+
# class methods. They Make heavy use of Sequel::Model functionality
|
21
|
+
# we will add this to our Model classes with "extend" --> self is the Class
|
22
|
+
module EntityClassBase
|
23
|
+
ONLY_INTEGER_RGX = /\A[+-]?\d+\z/.freeze
|
24
|
+
|
25
|
+
attr_reader :nav_collection_url_regexp
|
26
|
+
attr_reader :nav_entity_url_regexp
|
27
|
+
attr_reader :entity_id_url_regexp
|
28
|
+
attr_reader :nav_collection_attribs
|
29
|
+
attr_reader :nav_entity_attribs
|
30
|
+
attr_reader :data_fields
|
31
|
+
attr_reader :default_template
|
32
|
+
attr_reader :uri
|
33
|
+
attr_reader :odata_upk_parts
|
34
|
+
attr_reader :time_cols
|
35
|
+
attr_reader :namespace
|
36
|
+
|
37
|
+
# initialising block of code to be executed at end of
|
38
|
+
# ServerApp.publish_service after all model classes have been registered
|
39
|
+
# (without the associations/relationships)
|
40
|
+
# typically the block should contain the publication of the associations
|
41
|
+
attr_accessor :deferred_iblock
|
42
|
+
|
43
|
+
# convention: entityType is the namepsaced Ruby Model class --> name is just to_s
|
44
|
+
# Warning: for handling Navigation relations, we use anonymous collection classes
|
45
|
+
# dynamically subtyped from a Model class, and in such an anonymous class
|
46
|
+
# the class-name is not the OData Type. In these subclass we redefine "type_name"
|
47
|
+
# thus when we need the Odata type name, we shall use this method instead
|
48
|
+
# of just the collection class name
|
49
|
+
def type_name
|
50
|
+
@type_name
|
51
|
+
end
|
52
|
+
|
53
|
+
def default_entity_set_name
|
54
|
+
@default_entity_set_name
|
55
|
+
end
|
56
|
+
|
57
|
+
def build_type_name
|
58
|
+
@type_name = @namespace.to_s.empty? ? to_s : "#{@namespace}.#{self}"
|
59
|
+
@default_entity_set_name = to_s
|
60
|
+
end
|
61
|
+
|
62
|
+
# default for entity_set_name is @default_entity_set_name
|
63
|
+
def entity_set_name
|
64
|
+
@entity_set_name = (@entity_set_name || @default_entity_set_name)
|
65
|
+
end
|
66
|
+
|
67
|
+
def reset
|
68
|
+
# TODO: automatically reset all attributes?
|
69
|
+
@deferred_iblock = nil
|
70
|
+
@entity_set_name = nil
|
71
|
+
@uri = nil
|
72
|
+
@odata_upk_parts = nil
|
73
|
+
@uparms = nil
|
74
|
+
@params = nil
|
75
|
+
@cx = nil
|
76
|
+
@@time_cols = nil
|
77
|
+
end
|
78
|
+
|
79
|
+
def build_uri(uribase)
|
80
|
+
@uri = "#{uribase}/#{entity_set_name}"
|
81
|
+
end
|
82
|
+
|
83
|
+
def return_as_collection_descriptor
|
84
|
+
Safrano::FunctionImport::ResultAsEntityColl.new(self)
|
85
|
+
end
|
86
|
+
|
87
|
+
def return_as_instance_descriptor
|
88
|
+
Safrano::FunctionImport::ResultAsEntity.new(self)
|
89
|
+
end
|
90
|
+
|
91
|
+
def execute_deferred_iblock
|
92
|
+
instance_eval { @deferred_iblock.call } if @deferred_iblock
|
93
|
+
end
|
94
|
+
|
95
|
+
# Factory json-> Model Object instance
|
96
|
+
def new_from_hson_h(hash)
|
97
|
+
enty = new
|
98
|
+
enty.set_fields(hash, data_fields, missing: :skip)
|
99
|
+
enty
|
100
|
+
end
|
101
|
+
|
102
|
+
def attrib_path_valid?(path)
|
103
|
+
@attribute_path_list.include? path
|
104
|
+
end
|
105
|
+
|
106
|
+
def expand_path_valid?(path)
|
107
|
+
@expand_path_list.include? path
|
108
|
+
end
|
109
|
+
|
110
|
+
def find_invalid_props(propsset)
|
111
|
+
(propsset - @all_props) unless propsset.subset?(@all_props)
|
112
|
+
end
|
113
|
+
|
114
|
+
def build_expand_path_list
|
115
|
+
@expand_path_list = expand_path_list
|
116
|
+
end
|
117
|
+
|
118
|
+
# list of table columns + all nav attribs --> all props
|
119
|
+
def build_all_props_list
|
120
|
+
@all_props = @columns_str.dup
|
121
|
+
(@all_props += @nav_entity_attribs_keys.map(&:to_s)) if @nav_entity_attribs
|
122
|
+
(@all_props += @nav_collection_attribs_keys.map(&:to_s)) if @nav_collection_attribs
|
123
|
+
@all_props = @all_props.to_set
|
124
|
+
end
|
125
|
+
|
126
|
+
def build_attribute_path_list
|
127
|
+
@attribute_path_list = attribute_path_list
|
128
|
+
end
|
129
|
+
|
130
|
+
MAX_DEPTH = 6
|
131
|
+
def attribute_path_list(depth = 0)
|
132
|
+
ret = @columns_str.dup
|
133
|
+
# break circles
|
134
|
+
return ret if depth > MAX_DEPTH
|
135
|
+
|
136
|
+
depth += 1
|
137
|
+
|
138
|
+
@nav_entity_attribs&.each do |a, k|
|
139
|
+
ret.concat(k.attribute_path_list(depth).map { |kc| "#{a}/#{kc}" })
|
140
|
+
end
|
141
|
+
|
142
|
+
@nav_collection_attribs&.each do |a, k|
|
143
|
+
ret.concat(k.attribute_path_list(depth).map { |kc| "#{a}/#{kc}" })
|
144
|
+
end
|
145
|
+
ret
|
146
|
+
end
|
147
|
+
|
148
|
+
def expand_path_list(depth = 0)
|
149
|
+
ret = []
|
150
|
+
ret.concat(@nav_entity_attribs_keys) if @nav_entity_attribs
|
151
|
+
ret.concat(@nav_collection_attribs_keys) if @nav_collection_attribs
|
152
|
+
|
153
|
+
# break circles
|
154
|
+
return ret if depth > MAX_DEPTH
|
155
|
+
|
156
|
+
depth += 1
|
157
|
+
|
158
|
+
@nav_entity_attribs&.each do |a, k|
|
159
|
+
ret.concat(k.expand_path_list(depth).map { |kc| "#{a}/#{kc}" })
|
160
|
+
end
|
161
|
+
|
162
|
+
@nav_collection_attribs&.each do |a, k|
|
163
|
+
ret.concat(k.expand_path_list(depth).map { |kc| "#{a}/#{kc}" })
|
164
|
+
end
|
165
|
+
ret
|
166
|
+
end
|
167
|
+
|
168
|
+
# add metadata xml to the passed REXML schema object
|
169
|
+
def add_metadata_rexml(schema)
|
170
|
+
enty = if @media_handler
|
171
|
+
schema.add_element('EntityType', 'Name' => to_s, 'HasStream' => 'true')
|
172
|
+
else
|
173
|
+
schema.add_element('EntityType', 'Name' => to_s)
|
174
|
+
end
|
175
|
+
# with their properties
|
176
|
+
db_schema.each do |pnam, prop|
|
177
|
+
if prop[:primary_key] == true
|
178
|
+
enty.add_element('Key').add_element('PropertyRef',
|
179
|
+
'Name' => pnam.to_s)
|
180
|
+
end
|
181
|
+
attrs = { 'Name' => pnam.to_s,
|
182
|
+
# 'Type' => Safrano.get_edm_type(db_type: prop[:db_type]) }
|
183
|
+
'Type' => prop[:odata_edm_type] }
|
184
|
+
attrs['Nullable'] = 'false' if prop[:allow_null] == false
|
185
|
+
enty.add_element('Property', attrs)
|
186
|
+
end
|
187
|
+
enty
|
188
|
+
end
|
189
|
+
|
190
|
+
# metadata REXML data for a single Nav attribute
|
191
|
+
def metadata_nav_rexml_attribs(assoc, to_klass, relman)
|
192
|
+
from = to_s
|
193
|
+
to = to_klass.to_s
|
194
|
+
relman.get_metadata_xml_attribs(from,
|
195
|
+
to,
|
196
|
+
association_reflection(assoc.to_sym)[:type],
|
197
|
+
@namespace,
|
198
|
+
assoc)
|
199
|
+
end
|
200
|
+
|
201
|
+
# and their Nav attributes == Sequel Model association
|
202
|
+
def add_metadata_navs_rexml(schema_enty, relman)
|
203
|
+
@nav_entity_attribs&.each do |ne, klass|
|
204
|
+
nattr = metadata_nav_rexml_attribs(ne,
|
205
|
+
klass,
|
206
|
+
relman)
|
207
|
+
schema_enty.add_element('NavigationProperty', nattr)
|
208
|
+
end
|
209
|
+
|
210
|
+
@nav_collection_attribs&.each do |nc, klass|
|
211
|
+
nattr = metadata_nav_rexml_attribs(nc,
|
212
|
+
klass,
|
213
|
+
relman)
|
214
|
+
schema_enty.add_element('NavigationProperty', nattr)
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
# Recursive
|
219
|
+
# this method is performance critical. Called at least once for every request
|
220
|
+
def output_template(expand_list:,
|
221
|
+
select: Safrano::SelectBase::ALL)
|
222
|
+
|
223
|
+
return @default_template if expand_list.empty? && select.all_props?
|
224
|
+
|
225
|
+
template = {}
|
226
|
+
expand_e = {}
|
227
|
+
expand_c = {}
|
228
|
+
deferr = []
|
229
|
+
|
230
|
+
# 1. handle non-navigation properties, only consider $select
|
231
|
+
# 2. handle navigations properties, need to check $select and $expand
|
232
|
+
if select.all_props?
|
233
|
+
|
234
|
+
template[:all_values] = EMPTYH
|
235
|
+
|
236
|
+
# include all nav attributes -->
|
237
|
+
@nav_entity_attribs&.each do |attr, klass|
|
238
|
+
if expand_list.key?(attr)
|
239
|
+
expand_e[attr] = klass.output_template(expand_list: expand_list[attr])
|
240
|
+
else
|
241
|
+
deferr << attr
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
@nav_collection_attribs&.each do |attr, klass|
|
246
|
+
if expand_list.key?(attr)
|
247
|
+
expand_c[attr] = klass.output_template(expand_list: expand_list[attr])
|
248
|
+
else
|
249
|
+
deferr << attr
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
else
|
254
|
+
template[:selected_vals] = @columns_str & select.props
|
255
|
+
|
256
|
+
# include only selected nav attribs-->need additional intersection step
|
257
|
+
if @nav_entity_attribs
|
258
|
+
selected_nav_e = @nav_entity_attribs_keys & select.props
|
259
|
+
|
260
|
+
selected_nav_e&.each do |attr|
|
261
|
+
if expand_list.key?(attr)
|
262
|
+
klass = @nav_entity_attribs[attr]
|
263
|
+
expand_e[attr] = klass.output_template(expand_list: expand_list[attr])
|
264
|
+
else
|
265
|
+
deferr << attr
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
if @nav_collection_attribs
|
270
|
+
selected_nav_c = @nav_collection_attribs_keys & select.props
|
271
|
+
selected_nav_c&.each do |attr|
|
272
|
+
if expand_list.key?(attr)
|
273
|
+
klass = @nav_collection_attribs[attr]
|
274
|
+
expand_c[attr] = klass.output_template(expand_list: expand_list[attr])
|
275
|
+
else
|
276
|
+
deferr << attr
|
277
|
+
end
|
278
|
+
end
|
279
|
+
end
|
280
|
+
end
|
281
|
+
template[:expand_e] = expand_e
|
282
|
+
template[:expand_c] = expand_c
|
283
|
+
template[:deferr] = deferr
|
284
|
+
template
|
285
|
+
end
|
286
|
+
|
287
|
+
# this functionally similar to the Sequel Rels (many_to_one etc)
|
288
|
+
# We need to base this on the Sequel rels, or extend them
|
289
|
+
def add_nav_prop_collection(assoc_symb, attr_name_str = nil)
|
290
|
+
@nav_collection_attribs = (@nav_collection_attribs || {})
|
291
|
+
@nav_collection_attribs_keys = (@nav_collection_attribs_keys || [])
|
292
|
+
# DONE: Error handling. This requires that associations
|
293
|
+
# have been properly defined with Sequel before
|
294
|
+
assoc = all_association_reflections.find do |a|
|
295
|
+
a[:name] == assoc_symb && a[:model] == self
|
296
|
+
end
|
297
|
+
|
298
|
+
raise Safrano::API::ModelAssociationNameError.new(self, assoc_symb) unless assoc
|
299
|
+
|
300
|
+
attr_class = assoc[:class_name].constantize
|
301
|
+
lattr_name_str = (attr_name_str || assoc_symb.to_s)
|
302
|
+
|
303
|
+
# check duplicate attributes names
|
304
|
+
raise Safrano::API::ModelDuplicateAttributeError.new(self, lattr_name_str) if @columns.include? lattr_name_str.to_sym
|
305
|
+
|
306
|
+
if @nav_entity_attribs_keys
|
307
|
+
raise Safrano::API::ModelDuplicateAttributeError.new(self, lattr_name_str) if @nav_entity_attribs_keys.include? lattr_name_str
|
308
|
+
end
|
309
|
+
|
310
|
+
@nav_collection_attribs[lattr_name_str] = attr_class
|
311
|
+
@nav_collection_attribs_keys << lattr_name_str
|
312
|
+
@nav_collection_url_regexp = @nav_collection_attribs_keys.join('|')
|
313
|
+
end
|
314
|
+
|
315
|
+
def add_nav_prop_single(assoc_symb, attr_name_str = nil)
|
316
|
+
@nav_entity_attribs = (@nav_entity_attribs || {})
|
317
|
+
@nav_entity_attribs_keys = (@nav_entity_attribs_keys || [])
|
318
|
+
# DONE: Error handling. This requires that associations
|
319
|
+
# have been properly defined with Sequel before
|
320
|
+
assoc = all_association_reflections.find do |a|
|
321
|
+
a[:name] == assoc_symb && a[:model] == self
|
322
|
+
end
|
323
|
+
|
324
|
+
raise Safrano::API::ModelAssociationNameError.new(self, assoc_symb) unless assoc
|
325
|
+
|
326
|
+
attr_class = assoc[:class_name].constantize
|
327
|
+
lattr_name_str = (attr_name_str || assoc_symb.to_s)
|
328
|
+
|
329
|
+
# check duplicate attributes names
|
330
|
+
raise Safrano::API::ModelDuplicateAttributeError.new(self, lattr_name_str) if @columns.include? lattr_name_str.to_sym
|
331
|
+
|
332
|
+
if @nav_collection_attribs_keys
|
333
|
+
raise Safrano::API::ModelDuplicateAttributeError.new(self, lattr_name_str) if @nav_collection_attribs_keys.include? lattr_name_str
|
334
|
+
end
|
335
|
+
|
336
|
+
@nav_entity_attribs[lattr_name_str] = attr_class
|
337
|
+
@nav_entity_attribs_keys << lattr_name_str
|
338
|
+
@nav_entity_url_regexp = @nav_entity_attribs_keys.join('|')
|
339
|
+
end
|
340
|
+
|
341
|
+
EMPTYH = {}.freeze
|
342
|
+
|
343
|
+
def build_default_template
|
344
|
+
@default_template = { all_values: EMPTYH }
|
345
|
+
if @nav_entity_attribs || @nav_collection_attribs
|
346
|
+
@default_template[:deferr] = (@nav_entity_attribs&.keys || []) + (@nav_collection_attribs&.keys || EMPTY_ARRAY)
|
347
|
+
end
|
348
|
+
end
|
349
|
+
|
350
|
+
def finalize_publishing
|
351
|
+
build_type_name
|
352
|
+
|
353
|
+
# build default output template structure
|
354
|
+
build_default_template
|
355
|
+
|
356
|
+
# Time columns
|
357
|
+
@time_cols = db_schema.select { |_c, v| v[:type] == :datetime }.map { |c, _v| c }
|
358
|
+
|
359
|
+
# add edm_types into schema
|
360
|
+
db_schema.each do |_col, props|
|
361
|
+
props[:odata_edm_type] = Safrano.default_edm_type(ruby_type: props[:type])
|
362
|
+
end
|
363
|
+
|
364
|
+
# and finally build the path lists and allowed tr's
|
365
|
+
build_attribute_path_list
|
366
|
+
build_expand_path_list
|
367
|
+
build_all_props_list
|
368
|
+
|
369
|
+
build_allowed_transitions
|
370
|
+
build_entity_allowed_transitions
|
371
|
+
|
372
|
+
# for media
|
373
|
+
finalize_media if self.respond_to? :finalize_media
|
374
|
+
end
|
375
|
+
|
376
|
+
KEYPRED_URL_REGEXP = /\A\(\s*'?([\w=,'\s]+)'?\s*\)(.*)/.freeze
|
377
|
+
def prepare_pk
|
378
|
+
if primary_key.is_a? Array
|
379
|
+
@pk_names = []
|
380
|
+
@pk_cast_from_string = {}
|
381
|
+
odata_upk_build = []
|
382
|
+
primary_key.each { |pk|
|
383
|
+
@pk_names << pk.to_s
|
384
|
+
kvpredicate = case db_schema[pk][:type]
|
385
|
+
when :integer
|
386
|
+
@pk_cast_from_string[pk] = ->(str) { Integer(str) }
|
387
|
+
'?'
|
388
|
+
else
|
389
|
+
"'?'"
|
390
|
+
end
|
391
|
+
odata_upk_build << "#{pk}=#{kvpredicate}"
|
392
|
+
}
|
393
|
+
@odata_upk_parts = odata_upk_build.join(',').split('?')
|
394
|
+
|
395
|
+
# regex parts for unordered matching
|
396
|
+
@iuk_rgx_parts = primary_key.map { |pk|
|
397
|
+
kvpredicate = case db_schema[pk][:type]
|
398
|
+
when :integer
|
399
|
+
"(\\d+)"
|
400
|
+
else
|
401
|
+
"'(\\w+)'"
|
402
|
+
end
|
403
|
+
[pk, "#{pk}=#{kvpredicate}"]
|
404
|
+
}.to_h
|
405
|
+
|
406
|
+
# single regex assuming the key fields are ordered !
|
407
|
+
@iuk_rgx = /\A#{@iuk_rgx_parts.values.join(',\s*')}\z/
|
408
|
+
|
409
|
+
@iuk_rgx_parts.transform_values! { |v| /\A#{v}\z/ }
|
410
|
+
|
411
|
+
@entity_id_url_regexp = KEYPRED_URL_REGEXP
|
412
|
+
else
|
413
|
+
@pk_names = [primary_key.to_s]
|
414
|
+
@pk_cast_from_string = nil
|
415
|
+
kvpredicate = case db_schema[primary_key][:type]
|
416
|
+
when :integer
|
417
|
+
@pk_cast_from_string = ->(str) { Integer(str) }
|
418
|
+
"(\\d+)"
|
419
|
+
else
|
420
|
+
"'(\\w+)'"
|
421
|
+
end
|
422
|
+
@iuk_rgx = /\A\s*#{kvpredicate}\s*\z/
|
423
|
+
@entity_id_url_regexp = KEYPRED_URL_REGEXP
|
424
|
+
end
|
425
|
+
end
|
426
|
+
|
427
|
+
def prepare_fields
|
428
|
+
# columns as strings
|
429
|
+
@columns_str = @columns.map(&:to_s)
|
430
|
+
|
431
|
+
@data_fields = db_schema.map do |col, cattr|
|
432
|
+
cattr[:primary_key] ? nil : col
|
433
|
+
end.select { |col| col }
|
434
|
+
end
|
435
|
+
|
436
|
+
def invalid_hash_data?(data)
|
437
|
+
data.keys.map(&:to_sym).find { |ksym| !(@columns.include? ksym) }
|
438
|
+
end
|
439
|
+
|
440
|
+
## A regexp matching all allowed attributes of the Entity
|
441
|
+
## (eg ID|name|size etc... ) at start position and returning the rest
|
442
|
+
def transition_attribute_regexp
|
443
|
+
# db_schema.map { |sch| sch[0] }.join('|')
|
444
|
+
# @columns is from Sequel Model
|
445
|
+
%r{\A/(#{@columns.join('|')})(.*)\z}
|
446
|
+
end
|
447
|
+
|
448
|
+
# super-minimal type check, but better as nothing
|
449
|
+
def cast_odata_val(val, pk_cast)
|
450
|
+
pk_cast ? Contract.valid(pk_cast.call(val)) : Contract.valid(val) # no cast needed, eg for string
|
451
|
+
rescue StandardError => e
|
452
|
+
RubyStandardErrorException.new(e)
|
453
|
+
end
|
454
|
+
|
455
|
+
CREATE_AND_SAVE_ENTY_AND_REL = lambda do |new_entity, assoc, parent|
|
456
|
+
# in-changeset requests get their own transaction
|
457
|
+
case assoc[:type]
|
458
|
+
when :one_to_many, :one_to_one
|
459
|
+
Safrano.create_nav_relation(new_entity, assoc, parent)
|
460
|
+
new_entity.save(transaction: false)
|
461
|
+
when :many_to_one
|
462
|
+
new_entity.save(transaction: false)
|
463
|
+
Safrano.create_nav_relation(new_entity, assoc, parent)
|
464
|
+
parent.save(transaction: false)
|
465
|
+
# else # not supported
|
466
|
+
end
|
467
|
+
end
|
468
|
+
def odata_create_save_entity_and_rel(req, new_entity, assoc, parent)
|
469
|
+
if req.in_changeset
|
470
|
+
# in-changeset requests get their own transaction
|
471
|
+
CREATE_AND_SAVE_ENTY_AND_REL.call(new_entity, assoc, parent)
|
472
|
+
else
|
473
|
+
db.transaction do
|
474
|
+
CREATE_AND_SAVE_ENTY_AND_REL.call(new_entity, assoc, parent)
|
475
|
+
end
|
476
|
+
end
|
477
|
+
end
|
478
|
+
# methods related to transitions to next state (cf. walker)
|
479
|
+
module Transitions
|
480
|
+
def allowed_transitions
|
481
|
+
@allowed_transitions
|
482
|
+
end
|
483
|
+
|
484
|
+
def entity_allowed_transitions
|
485
|
+
@entity_allowed_transitions
|
486
|
+
end
|
487
|
+
|
488
|
+
def build_allowed_transitions
|
489
|
+
@allowed_transitions = [Safrano::TransitionEnd,
|
490
|
+
Safrano::TransitionCount,
|
491
|
+
Safrano::Transition.new(entity_id_url_regexp,
|
492
|
+
trans: 'transition_id')].freeze
|
493
|
+
end
|
494
|
+
|
495
|
+
def build_entity_allowed_transitions
|
496
|
+
@entity_allowed_transitions = [
|
497
|
+
Safrano::TransitionEnd,
|
498
|
+
Safrano::TransitionCount,
|
499
|
+
Safrano::TransitionLinks,
|
500
|
+
Safrano::TransitionValue,
|
501
|
+
Safrano::Transition.new(transition_attribute_regexp, trans: 'transition_attribute')
|
502
|
+
]
|
503
|
+
if (ncurgx = @nav_collection_url_regexp)
|
504
|
+
@entity_allowed_transitions <<
|
505
|
+
Safrano::Transition.new(%r{\A/(#{ncurgx})(.*)\z}, trans: 'transition_nav_collection')
|
506
|
+
end
|
507
|
+
if (neurgx = @nav_entity_url_regexp)
|
508
|
+
@entity_allowed_transitions <<
|
509
|
+
Safrano::Transition.new(%r{\A/(#{neurgx})(.*)\z}, trans: 'transition_nav_entity')
|
510
|
+
end
|
511
|
+
@entity_allowed_transitions << Safrano::Transition.new(%r{\A/(\w+)(.*)\z}, trans: 'transition_invalid_attribute')
|
512
|
+
@entity_allowed_transitions.freeze
|
513
|
+
@entity_allowed_transitions
|
514
|
+
end
|
515
|
+
end
|
516
|
+
include Transitions
|
517
|
+
end
|
518
|
+
|
519
|
+
# special handling for composite key
|
520
|
+
module EntityClassMultiPK
|
521
|
+
include EntityClassBase
|
522
|
+
def pk_lookup_expr(ids)
|
523
|
+
primary_key.zip(ids)
|
524
|
+
end
|
525
|
+
|
526
|
+
# input fx='aas',fy_w='0001'
|
527
|
+
# output true, ['aas', '0001'] ... or false when typ-error
|
528
|
+
def parse_odata_key(mid)
|
529
|
+
# @iuk_rgx is (needs to be) built on start with
|
530
|
+
# collklass.prepare_pk
|
531
|
+
|
532
|
+
# first try to match single regex assuming orderd key fields
|
533
|
+
if (md = @iuk_rgx.match(mid))
|
534
|
+
md = md.captures
|
535
|
+
mdc = []
|
536
|
+
primary_key.each_with_index do |pk, i|
|
537
|
+
mdc << if (pk_cast = @pk_cast_from_string[pk])
|
538
|
+
pk_cast.call(md[i])
|
539
|
+
else
|
540
|
+
md[i] # no cast needed, eg for string
|
541
|
+
end
|
542
|
+
end
|
543
|
+
|
544
|
+
else
|
545
|
+
|
546
|
+
# order key fields didnt match--> try and collect/check each parts unordered
|
547
|
+
scan_rgx_parts = @iuk_rgx_parts.dup
|
548
|
+
mdch = {}
|
549
|
+
|
550
|
+
mid.split(/\s*,\s*/).each { |midpart|
|
551
|
+
mval = nil
|
552
|
+
mpk, mrgx = scan_rgx_parts.find { |pk, rgx|
|
553
|
+
if (md = rgx.match(midpart))
|
554
|
+
mval = md[1]
|
555
|
+
end
|
556
|
+
}
|
557
|
+
if mpk and mval
|
558
|
+
mdch[mpk] = if (pk_cast = @pk_cast_from_string[mpk])
|
559
|
+
pk_cast.call(mval)
|
560
|
+
else
|
561
|
+
mval # no cast needed, eg for string
|
562
|
+
end
|
563
|
+
scan_rgx_parts.delete(mpk)
|
564
|
+
else
|
565
|
+
return Contract::NOK
|
566
|
+
end
|
567
|
+
}
|
568
|
+
# normally arriving here we have mdch filled with key values pairs,
|
569
|
+
# but not in the model key ordering. lets just re-order the values
|
570
|
+
mdc = @iuk_rgx_parts.keys.map { |pk| mdch[pk] }
|
571
|
+
|
572
|
+
end
|
573
|
+
Contract.valid(mdc)
|
574
|
+
# catch remaining convertion errors that we failed to prevent
|
575
|
+
rescue StandardError => e
|
576
|
+
RubyStandardErrorException.new(e)
|
577
|
+
end
|
578
|
+
end
|
579
|
+
|
580
|
+
# special handling for single key
|
581
|
+
module EntityClassSinglePK
|
582
|
+
include EntityClassBase
|
583
|
+
|
584
|
+
def parse_odata_key(rawid)
|
585
|
+
if (md = @iuk_rgx.match(rawid))
|
586
|
+
if (@pk_cast_from_string)
|
587
|
+
Contract.valid(@pk_cast_from_string.call(md[1]))
|
588
|
+
else
|
589
|
+
Contract.valid(md[1]) # no cast needed, eg for string
|
590
|
+
end
|
591
|
+
else
|
592
|
+
Contract::NOK
|
593
|
+
end
|
594
|
+
rescue StandardError => e
|
595
|
+
RubyStandardErrorException.new(e)
|
596
|
+
end
|
597
|
+
|
598
|
+
def pk_lookup_expr(id)
|
599
|
+
id
|
600
|
+
end
|
601
|
+
end
|
602
|
+
|
603
|
+
# normal handling for non-media entity
|
604
|
+
module EntityClassNonMedia
|
605
|
+
# POST for non-media entity collection -->
|
606
|
+
# 1. Create and add entity from payload
|
607
|
+
# 2. Create relationship if needed
|
608
|
+
def odata_create_entity_and_relation(req, assoc = nil, parent = nil)
|
609
|
+
# TODO: this is for v2 only...
|
610
|
+
req.with_parsed_data do |data|
|
611
|
+
data.delete('__metadata')
|
612
|
+
|
613
|
+
# validate payload column names
|
614
|
+
if (invalid = invalid_hash_data?(data))
|
615
|
+
::Safrano::Request::ON_CGST_ERROR.call(req)
|
616
|
+
return [422, EMPTY_HASH, ['Invalid attribute name: ', invalid.to_s]]
|
617
|
+
end
|
618
|
+
|
619
|
+
if req.accept?(APPJSON)
|
620
|
+
new_entity = new_from_hson_h(data)
|
621
|
+
if parent
|
622
|
+
odata_create_save_entity_and_rel(req, new_entity, assoc, parent)
|
623
|
+
else
|
624
|
+
# in-changeset requests get their own transaction
|
625
|
+
new_entity.save(transaction: !req.in_changeset)
|
626
|
+
end
|
627
|
+
req.register_content_id_ref(new_entity)
|
628
|
+
new_entity.copy_request_infos(req)
|
629
|
+
# json is default content type so we dont need to specify it here again
|
630
|
+
# TODO quirks array mode !
|
631
|
+
# [201, EMPTY_HASH, new_entity.to_odata_post_json(service: req.service)]
|
632
|
+
[201, EMPTY_HASH, new_entity.to_odata_create_json(request: req)]
|
633
|
+
else # TODO: other formats
|
634
|
+
415
|
635
|
+
end
|
636
|
+
end
|
637
|
+
end
|
638
|
+
end
|
639
|
+
end
|