shale 0.8.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../error'
4
+ require_relative '../mapping/delegates'
4
5
  require_relative '../mapping/group/dict_grouping'
5
6
  require_relative '../mapping/group/xml_grouping'
6
7
  require_relative 'value'
@@ -13,11 +14,11 @@ module Shale
13
14
  # @api private
14
15
  class Complex < Value
15
16
  class << self
16
- %i[hash json yaml toml].each do |format|
17
+ %i[hash json yaml toml csv].each do |format|
17
18
  class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
18
19
  # Convert Hash to Object using Hash/JSON/YAML/TOML mapping
19
20
  #
20
- # @param [Hash] hash Hash to convert
21
+ # @param [Hash, Array] hash Hash to convert
21
22
  # @param [Array<Symbol>] only
22
23
  # @param [Array<Symbol>] except
23
24
  # @param [any] context
@@ -26,6 +27,18 @@ module Shale
26
27
  #
27
28
  # @api public
28
29
  def of_#{format}(hash, only: nil, except: nil, context: nil)
30
+ #{
31
+ if format != :toml
32
+ <<~CODE
33
+ if hash.is_a?(Array)
34
+ return hash.map do |item|
35
+ of_#{format}(item, only: only, except: except, context: context)
36
+ end
37
+ end
38
+ CODE
39
+ end
40
+ }
41
+
29
42
  instance = model.new
30
43
 
31
44
  attributes
@@ -35,6 +48,7 @@ module Shale
35
48
 
36
49
  mapping_keys = #{format}_mapping.keys
37
50
  grouping = Shale::Mapping::Group::DictGrouping.new
51
+ delegates = Shale::Mapping::Delegates.new
38
52
 
39
53
  only = to_partial_render_attributes(only)
40
54
  except = to_partial_render_attributes(except)
@@ -54,7 +68,8 @@ module Shale
54
68
  mapper.send(mapping.method_from, instance, value)
55
69
  end
56
70
  else
57
- attribute = attributes[mapping.attribute]
71
+ receiver_attributes = get_receiver_attributes(mapping)
72
+ attribute = receiver_attributes[mapping.attribute]
58
73
  next unless attribute
59
74
 
60
75
  if only
@@ -67,20 +82,22 @@ module Shale
67
82
  next if except.key?(attribute.name) && attribute_except.nil?
68
83
  end
69
84
 
85
+ casted_value = nil
86
+
70
87
  if value.nil?
71
- instance.send(attribute.setter, nil)
88
+ casted_value = nil
72
89
  elsif attribute.collection?
73
- [*value].each do |val|
74
- if val
90
+ casted_value = (value.is_a?(Array) ? value : [value]).map do |val|
91
+ unless val.nil?
75
92
  val = attribute.type.of_#{format}(
76
93
  val,
77
94
  only: attribute_only,
78
95
  except: attribute_except,
79
96
  context: context
80
97
  )
81
- end
82
98
 
83
- instance.send(attribute.name) << attribute.type.cast(val)
99
+ attribute.type.cast(val)
100
+ end
84
101
  end
85
102
  else
86
103
  val = attribute.type.of_#{format}(
@@ -89,11 +106,23 @@ module Shale
89
106
  except: attribute_except,
90
107
  context: context
91
108
  )
92
- instance.send(attribute.setter, attribute.type.cast(val))
109
+
110
+ casted_value = attribute.type.cast(val)
111
+ end
112
+
113
+ if mapping.receiver
114
+ delegates.add(attributes[mapping.receiver], attribute.setter, casted_value)
115
+ else
116
+ instance.send(attribute.setter, casted_value)
93
117
  end
94
118
  end
95
119
  end
96
120
 
121
+ delegates.each do |delegate|
122
+ receiver = get_receiver(instance, delegate.receiver_attribute)
123
+ receiver.send(delegate.setter, delegate.value)
124
+ end
125
+
97
126
  grouping.each do |group|
98
127
  mapper = new
99
128
 
@@ -109,7 +138,7 @@ module Shale
109
138
 
110
139
  # Convert Object to Hash using Hash/JSON/YAML/TOML mapping
111
140
  #
112
- # @param [any] instance Object to convert
141
+ # @param [any, Array<any>] instance Object to convert
113
142
  # @param [Array<Symbol>] only
114
143
  # @param [Array<Symbol>] except
115
144
  # @param [any] context
@@ -120,6 +149,18 @@ module Shale
120
149
  #
121
150
  # @api public
122
151
  def as_#{format}(instance, only: nil, except: nil, context: nil)
152
+ #{
153
+ if format != :toml
154
+ <<~CODE
155
+ if instance.is_a?(Array)
156
+ return instance.map do |item|
157
+ as_#{format}(item, only: only, except: except, context: context)
158
+ end
159
+ end
160
+ CODE
161
+ end
162
+ }
163
+
123
164
  unless instance.is_a?(model)
124
165
  msg = "argument is a '\#{instance.class}' but should be a '\#{model}'"
125
166
  raise IncorrectModelError, msg
@@ -143,7 +184,14 @@ module Shale
143
184
  mapper.send(mapping.method_to, instance, hash)
144
185
  end
145
186
  else
146
- attribute = attributes[mapping.attribute]
187
+ if mapping.receiver
188
+ receiver = instance.send(mapping.receiver)
189
+ else
190
+ receiver = instance
191
+ end
192
+
193
+ receiver_attributes = get_receiver_attributes(mapping)
194
+ attribute = receiver_attributes[mapping.attribute]
147
195
  next unless attribute
148
196
 
149
197
  if only
@@ -156,7 +204,7 @@ module Shale
156
204
  next if except.key?(attribute.name) && attribute_except.nil?
157
205
  end
158
206
 
159
- value = instance.send(attribute.name)
207
+ value = receiver.send(attribute.name) if receiver
160
208
 
161
209
  if value.nil?
162
210
  hash[mapping.name] = nil if mapping.render_nil?
@@ -312,6 +360,65 @@ module Shale
312
360
  )
313
361
  end
314
362
 
363
+ # Convert CSV to Object
364
+ #
365
+ # @param [String] csv CSV to convert
366
+ # @param [Array<Symbol>] only
367
+ # @param [Array<Symbol>] except
368
+ # @param [any] context
369
+ # @param [true, false] headers
370
+ # @param [Hash] csv_options
371
+ #
372
+ # @return [model instance]
373
+ #
374
+ # @api public
375
+ def from_csv(csv, only: nil, except: nil, context: nil, headers: false, **csv_options)
376
+ data = Shale.csv_adapter.load(csv, **csv_options.merge(headers: csv_mapping.keys.keys))
377
+
378
+ data.shift if headers
379
+
380
+ of_csv(
381
+ data,
382
+ only: only,
383
+ except: except,
384
+ context: context
385
+ )
386
+ end
387
+
388
+ # Convert Object to CSV
389
+ #
390
+ # @param [model instance] instance Object to convert
391
+ # @param [Array<Symbol>] only
392
+ # @param [Array<Symbol>] except
393
+ # @param [any] context
394
+ # @param [true, false] headers
395
+ # @param [Hash] csv_options
396
+ #
397
+ # @return [String]
398
+ #
399
+ # @api public
400
+ def to_csv(instance, only: nil, except: nil, context: nil, headers: false, **csv_options)
401
+ data = as_csv([*instance], only: only, except: except, context: context)
402
+
403
+ cols = csv_mapping.keys.values
404
+
405
+ if only
406
+ cols = cols.select { |e| only.include?(e.attribute) }
407
+ end
408
+
409
+ if except
410
+ cols = cols.reject { |e| except.include?(e.attribute) }
411
+ end
412
+
413
+ cols = cols.map(&:name)
414
+
415
+ if headers
416
+ data.prepend(cols.to_h { |e| [e, e] })
417
+ end
418
+
419
+ Shale.csv_adapter.dump(data, **csv_options.merge(headers: cols))
420
+ end
421
+
315
422
  # Convert XML document to Object
316
423
  #
317
424
  # @param [Shale::Adapter::<XML adapter>::Node] element
@@ -331,6 +438,7 @@ module Shale
331
438
  .each { |attr| instance.send(attr.setter, attr.default.call) }
332
439
 
333
440
  grouping = Shale::Mapping::Group::XmlGrouping.new
441
+ delegates = Shale::Mapping::Delegates.new
334
442
 
335
443
  only = to_partial_render_attributes(only)
336
444
  except = to_partial_render_attributes(except)
@@ -350,16 +458,29 @@ module Shale
350
458
  mapper.send(mapping.method_from, instance, value)
351
459
  end
352
460
  else
353
- attribute = attributes[mapping.attribute]
461
+ receiver_attributes = get_receiver_attributes(mapping)
462
+ attribute = receiver_attributes[mapping.attribute]
354
463
  next unless attribute
355
464
 
356
465
  next if only && !only.key?(attribute.name)
357
466
  next if except&.key?(attribute.name)
358
467
 
468
+ casted_value = attribute.type.cast(value)
469
+
359
470
  if attribute.collection?
360
- instance.send(attribute.name) << attribute.type.cast(value)
471
+ if mapping.receiver
472
+ delegates.add_collection(
473
+ attributes[mapping.receiver],
474
+ attribute.setter,
475
+ casted_value
476
+ )
477
+ else
478
+ instance.send(attribute.name) << casted_value
479
+ end
480
+ elsif mapping.receiver
481
+ delegates.add(attributes[mapping.receiver], attribute.setter, casted_value)
361
482
  else
362
- instance.send(attribute.setter, attribute.type.cast(value))
483
+ instance.send(attribute.setter, casted_value)
363
484
  end
364
485
  end
365
486
  end
@@ -378,7 +499,8 @@ module Shale
378
499
  mapper.send(content_mapping.method_from, instance, element)
379
500
  end
380
501
  else
381
- attribute = attributes[content_mapping.attribute]
502
+ receiver_attributes = get_receiver_attributes(content_mapping)
503
+ attribute = receiver_attributes[content_mapping.attribute]
382
504
 
383
505
  if attribute
384
506
  skip = false
@@ -388,8 +510,13 @@ module Shale
388
510
  skip = true if except&.key?(attribute.name)
389
511
 
390
512
  unless skip
391
- value = attribute.type.of_xml(element)
392
- instance.send(attribute.setter, attribute.type.cast(value))
513
+ value = attribute.type.cast(attribute.type.of_xml(element))
514
+
515
+ if content_mapping.receiver
516
+ delegates.add(attributes[content_mapping.receiver], attribute.setter, value)
517
+ else
518
+ instance.send(attribute.setter, value)
519
+ end
393
520
  end
394
521
  # rubocop:enable Metrics/BlockNesting
395
522
  end
@@ -411,7 +538,8 @@ module Shale
411
538
  mapper.send(mapping.method_from, instance, node)
412
539
  end
413
540
  else
414
- attribute = attributes[mapping.attribute]
541
+ receiver_attributes = get_receiver_attributes(mapping)
542
+ attribute = receiver_attributes[mapping.attribute]
415
543
  next unless attribute
416
544
 
417
545
  if only
@@ -431,14 +559,31 @@ module Shale
431
559
  context: context
432
560
  )
433
561
 
562
+ casted_value = attribute.type.cast(value)
563
+
434
564
  if attribute.collection?
435
- instance.send(attribute.name) << attribute.type.cast(value)
565
+ if mapping.receiver
566
+ delegates.add_collection(
567
+ attributes[mapping.receiver],
568
+ attribute.setter,
569
+ casted_value
570
+ )
571
+ else
572
+ instance.send(attribute.name) << casted_value
573
+ end
574
+ elsif mapping.receiver
575
+ delegates.add(attributes[mapping.receiver], attribute.setter, casted_value)
436
576
  else
437
- instance.send(attribute.setter, attribute.type.cast(value))
577
+ instance.send(attribute.setter, casted_value)
438
578
  end
439
579
  end
440
580
  end
441
581
 
582
+ delegates.each do |delegate|
583
+ receiver = get_receiver(instance, delegate.receiver_attribute)
584
+ receiver.send(delegate.setter, delegate.value)
585
+ end
586
+
442
587
  grouping.each do |group|
443
588
  mapper = new
444
589
 
@@ -495,7 +640,8 @@ module Shale
495
640
  _cdata = nil,
496
641
  only: nil,
497
642
  except: nil,
498
- context: nil
643
+ context: nil,
644
+ version: nil
499
645
  )
500
646
  unless instance.is_a?(model)
501
647
  msg = "argument is a '#{instance.class}' but should be a '#{model}'"
@@ -503,7 +649,7 @@ module Shale
503
649
  end
504
650
 
505
651
  unless doc
506
- doc = Shale.xml_adapter.create_document
652
+ doc = Shale.xml_adapter.create_document(version)
507
653
 
508
654
  element = as_xml(
509
655
  instance,
@@ -542,13 +688,20 @@ module Shale
542
688
  mapper.send(mapping.method_to, instance, element, doc)
543
689
  end
544
690
  else
545
- attribute = attributes[mapping.attribute]
691
+ if mapping.receiver
692
+ receiver = instance.send(mapping.receiver)
693
+ else
694
+ receiver = instance
695
+ end
696
+
697
+ receiver_attributes = get_receiver_attributes(mapping)
698
+ attribute = receiver_attributes[mapping.attribute]
546
699
  next unless attribute
547
700
 
548
701
  next if only && !only.key?(attribute.name)
549
702
  next if except&.key?(attribute.name)
550
703
 
551
- value = instance.send(attribute.name)
704
+ value = receiver.send(attribute.name) if receiver
552
705
 
553
706
  if mapping.render_nil? || !value.nil?
554
707
  doc.add_namespace(mapping.namespace.prefix, mapping.namespace.name)
@@ -571,7 +724,14 @@ module Shale
571
724
  mapper.send(content_mapping.method_to, instance, element, doc)
572
725
  end
573
726
  else
574
- attribute = attributes[content_mapping.attribute]
727
+ if content_mapping.receiver
728
+ receiver = instance.send(content_mapping.receiver)
729
+ else
730
+ receiver = instance
731
+ end
732
+
733
+ receiver_attributes = get_receiver_attributes(content_mapping)
734
+ attribute = receiver_attributes[content_mapping.attribute]
575
735
 
576
736
  if attribute
577
737
  skip = false
@@ -581,7 +741,7 @@ module Shale
581
741
  skip = true if except&.key?(attribute.name)
582
742
 
583
743
  unless skip
584
- value = instance.send(attribute.name)
744
+ value = receiver.send(attribute.name) if receiver
585
745
 
586
746
  if content_mapping.cdata
587
747
  doc.create_cdata(value.to_s, element)
@@ -606,7 +766,14 @@ module Shale
606
766
  mapper.send(mapping.method_to, instance, element, doc)
607
767
  end
608
768
  else
609
- attribute = attributes[mapping.attribute]
769
+ if mapping.receiver
770
+ receiver = instance.send(mapping.receiver)
771
+ else
772
+ receiver = instance
773
+ end
774
+
775
+ receiver_attributes = get_receiver_attributes(mapping)
776
+ attribute = receiver_attributes[mapping.attribute]
610
777
  next unless attribute
611
778
 
612
779
  if only
@@ -619,7 +786,7 @@ module Shale
619
786
  next if except.key?(attribute.name) && attribute_except.nil?
620
787
  end
621
788
 
622
- value = instance.send(attribute.name)
789
+ value = receiver.send(attribute.name) if receiver
623
790
 
624
791
  if mapping.render_nil? || !value.nil?
625
792
  doc.add_namespace(mapping.namespace.prefix, mapping.namespace.name)
@@ -680,6 +847,7 @@ module Shale
680
847
  # @param [any] context
681
848
  # @param [true, false] pretty
682
849
  # @param [true, false] declaration
850
+ # @param [true, false, String] encoding
683
851
  #
684
852
  # @raise [AdapterError]
685
853
  #
@@ -692,13 +860,15 @@ module Shale
692
860
  except: nil,
693
861
  context: nil,
694
862
  pretty: false,
695
- declaration: false
863
+ declaration: false,
864
+ encoding: false
696
865
  )
697
866
  validate_xml_adapter
698
867
  Shale.xml_adapter.dump(
699
- as_xml(instance, only: only, except: except, context: context),
868
+ as_xml(instance, only: only, except: except, context: context, version: declaration),
700
869
  pretty: pretty,
701
- declaration: declaration
870
+ declaration: declaration,
871
+ encoding: encoding
702
872
  )
703
873
  end
704
874
 
@@ -740,6 +910,64 @@ module Shale
740
910
  end
741
911
  end.to_h
742
912
  end
913
+
914
+ # Get receiver attributes for given mapping
915
+ #
916
+ # @param [Shale::Mapping::Descriptor::Dict] mapping
917
+ #
918
+ # @raise [AttributeNotDefinedError]
919
+ # @raise [NotAShaleMapperError]
920
+ #
921
+ # @return [Hash<Symbol, Shale::Attribute>]
922
+ #
923
+ # @api private
924
+ def get_receiver_attributes(mapping)
925
+ return attributes unless mapping.receiver
926
+
927
+ receiver_attribute = attributes[mapping.receiver]
928
+
929
+ unless receiver_attribute
930
+ msg = "attribute '#{mapping.receiver}' is not defined on #{self} mapper"
931
+ raise AttributeNotDefinedError, msg
932
+ end
933
+
934
+ unless receiver_attribute.type < Mapper
935
+ msg = "attribute '#{mapping.receiver}' is not a descendant of Shale::Mapper type"
936
+ raise NotAShaleMapperError, msg
937
+ end
938
+
939
+ if receiver_attribute.collection?
940
+ msg = "attribute '#{mapping.receiver}' can't be a collection"
941
+ raise NotAShaleMapperError, msg
942
+ end
943
+
944
+ receiver_attribute.type.attributes
945
+ end
946
+
947
+ # Get receiver for given mapping
948
+ #
949
+ # @param [any] instance
950
+ # @param [Shale::Attribute] receiver_attribute
951
+ #
952
+ # @return [Array]
953
+ #
954
+ # @api private
955
+ def get_receiver(instance, receiver_attribute)
956
+ receiver = instance.send(receiver_attribute.name)
957
+
958
+ unless receiver
959
+ receiver = receiver_attribute.type.model.new
960
+
961
+ receiver_attribute.type.attributes
962
+ .values
963
+ .select { |attr| attr.default }
964
+ .each { |attr| receiver.send(attr.setter, attr.default.call) }
965
+
966
+ instance.send(receiver_attribute.setter, receiver)
967
+ end
968
+
969
+ receiver
970
+ end
743
971
  end
744
972
 
745
973
  # Convert Object to Hash
@@ -801,6 +1029,26 @@ module Shale
801
1029
  self.class.to_toml(self, only: only, except: except, context: context)
802
1030
  end
803
1031
 
1032
+ # Convert Object to CSV
1033
+ #
1034
+ # @param [Array<Symbol>] only
1035
+ # @param [Array<Symbol>] except
1036
+ # @param [any] context
1037
+ #
1038
+ # @return [String]
1039
+ #
1040
+ # @api public
1041
+ def to_csv(only: nil, except: nil, context: nil, headers: false, **csv_options)
1042
+ self.class.to_csv(
1043
+ self,
1044
+ only: only,
1045
+ except: except,
1046
+ context: context,
1047
+ headers: headers,
1048
+ **csv_options
1049
+ )
1050
+ end
1051
+
804
1052
  # Convert Object to XML
805
1053
  #
806
1054
  # @param [Array<Symbol>] only
@@ -808,18 +1056,27 @@ module Shale
808
1056
  # @param [any] context
809
1057
  # @param [true, false] pretty
810
1058
  # @param [true, false] declaration
1059
+ # @param [true, false, String] encoding
811
1060
  #
812
1061
  # @return [String]
813
1062
  #
814
1063
  # @api public
815
- def to_xml(only: nil, except: nil, context: nil, pretty: false, declaration: false)
1064
+ def to_xml(
1065
+ only: nil,
1066
+ except: nil,
1067
+ context: nil,
1068
+ pretty: false,
1069
+ declaration: false,
1070
+ encoding: false
1071
+ )
816
1072
  self.class.to_xml(
817
1073
  self,
818
1074
  only: only,
819
1075
  except: except,
820
1076
  context: context,
821
1077
  pretty: pretty,
822
- declaration: declaration
1078
+ declaration: declaration,
1079
+ encoding: encoding
823
1080
  )
824
1081
  end
825
1082
  end
@@ -47,6 +47,17 @@ module Shale
47
47
  value&.iso8601
48
48
  end
49
49
 
50
+ # Use ISO 8601 format in CSV document
51
+ #
52
+ # @param [Date] value
53
+ #
54
+ # @return [String]
55
+ #
56
+ # @api private
57
+ def self.as_csv(value, **)
58
+ value&.iso8601
59
+ end
60
+
50
61
  # Use ISO 8601 format in XML document
51
62
  #
52
63
  # @param [Date] value Value to convert to XML
@@ -47,6 +47,17 @@ module Shale
47
47
  value&.iso8601
48
48
  end
49
49
 
50
+ # Use ISO 8601 format in CSV document
51
+ #
52
+ # @param [Time] value
53
+ #
54
+ # @return [String]
55
+ #
56
+ # @api private
57
+ def self.as_csv(value, **)
58
+ value&.iso8601
59
+ end
60
+
50
61
  # Use ISO 8601 format in XML document
51
62
  #
52
63
  # @param [Time] value Value to convert to XML
@@ -111,6 +111,28 @@ module Shale
111
111
  value
112
112
  end
113
113
 
114
+ # Extract value from CSV document
115
+ #
116
+ # @param [any] value
117
+ #
118
+ # @return [any]
119
+ #
120
+ # @api private
121
+ def of_csv(value, **)
122
+ value
123
+ end
124
+
125
+ # Convert value to form accepted by CSV document
126
+ #
127
+ # @param [any] value
128
+ #
129
+ # @return [any]
130
+ #
131
+ # @api private
132
+ def as_csv(value, **)
133
+ value
134
+ end
135
+
114
136
  # Extract value from XML document
115
137
  #
116
138
  # @param [Shale::Adapter::<XML adapter>::Node] value
data/lib/shale/utils.rb CHANGED
@@ -5,6 +5,21 @@ module Shale
5
5
  #
6
6
  # @api private
7
7
  module Utils
8
+ # Upcase first letter of a string
9
+ #
10
+ # @param [String] val
11
+ #
12
+ # @example
13
+ # Shale::Utils.upcase_first('fooBar')
14
+ # # => 'FooBar'
15
+ #
16
+ # @api private
17
+ def self.upcase_first(str)
18
+ return '' unless str
19
+ return '' if str.empty?
20
+ str[0].upcase + str[1..-1]
21
+ end
22
+
8
23
  # Convert string to Ruby's class naming convention
9
24
  #
10
25
  # @param [String] val
@@ -15,13 +30,14 @@ module Shale
15
30
  #
16
31
  # @api private
17
32
  def self.classify(str)
18
- str = str.to_s.sub(/.*\./, '')
33
+ # names may include a period, which will need to be stripped out
34
+ str = str.to_s.gsub(/\./, '')
19
35
 
20
- str = str.sub(/^[a-z\d]*/) { |match| match.capitalize || match }
36
+ str = str.sub(/^[a-z\d]*/) { |match| upcase_first(match) || match }
21
37
 
22
- str.gsub(%r{(?:_|-|(/))([a-z\d]*)}i) do
38
+ str.gsub('::', '/').gsub(%r{(?:_|-|(/))([a-z\d]*)}i) do
23
39
  word = Regexp.last_match(2)
24
- substituted = word.capitalize || word
40
+ substituted = upcase_first(word) || word
25
41
  Regexp.last_match(1) ? "::#{substituted}" : substituted
26
42
  end
27
43
  end
@@ -36,6 +52,8 @@ module Shale
36
52
  #
37
53
  # @api private
38
54
  def self.snake_case(str)
55
+ # XML elements allow periods and hyphens
56
+ str = str.to_s.gsub('.', '_')
39
57
  return str.to_s unless /[A-Z-]|::/.match?(str)
40
58
  word = str.to_s.gsub('::', '/')
41
59
  word = word.gsub(/([A-Z]+)(?=[A-Z][a-z])|([a-z\d])(?=[A-Z])/) do
data/lib/shale/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Shale
4
4
  # @api private
5
- VERSION = '0.8.0'
5
+ VERSION = '1.0.0'
6
6
  end