shale 0.8.0 → 1.0.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.
@@ -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