cocina-models 0.68.0 → 0.69.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d958e46bc006eefe8d06d8d1d82e877ba59b16efb805668fb3f269c04bfb200b
4
- data.tar.gz: f5bdcce64f7471c944e39ddd9406f4ca93d87279cf95a1d0539943ce9481edf5
3
+ metadata.gz: 191508cff01be391223c83aba3e7c085e99b581a0f12a95dcd627572fbe5be36
4
+ data.tar.gz: b4eb3927706c3a5316f30d03d0352e742b3b0a72bf966cb9b0ed6bb593a0d48d
5
5
  SHA512:
6
- metadata.gz: 40c18189082759e6611b18164ab793e26a8add49fdb8bc74b9e333e59d7be73a3268e3d98bc11e066a924206dc8501c52e56bcde64f1ef38d8b40d943ff3ef75
7
- data.tar.gz: 0660a591a00e07846274f123f2a6f3e1715244e24c9a5998a5ed7e36753c6d6ab302365b1a09bf5368c61aeb7a15ad32b9656bd136cc4e194883345abf6e6dbd
6
+ metadata.gz: 8a4e9c462003e94e13992d20e2adc1a88515185820c11f851b0e709913addb541efadb35d11a0c841cb563e26d0b98cea15b0c0cc9d14aeddeb93f5bc953b147
7
+ data.tar.gz: 501f90b85f9386ff832b4147483d6e46fd6e8735fe87de6b868e7f68e2c6a02f1cf71759cb649103bcff93fb9e5830c11cd3ac0387e85336821237f8add0ee71
data/.rubocop.yml CHANGED
@@ -78,6 +78,7 @@ Metrics/BlockLength:
78
78
  Exclude:
79
79
  - cocina-models.gemspec
80
80
  - spec/cocina/**/*
81
+ - lib/cocina/rspec/matchers.rb
81
82
 
82
83
  Metrics/MethodLength:
83
84
  Max: 14
@@ -105,6 +106,9 @@ RSpec/MultipleExpectations:
105
106
  RSpec/StubbedMock: # (new in 1.44)
106
107
  Enabled: true
107
108
 
109
+ RSpec/MultipleMemoizedHelpers:
110
+ Max: 6
111
+
108
112
  # ----- Style ------
109
113
 
110
114
  Style/Documentation:
@@ -276,3 +280,5 @@ RSpec/FactoryBot/SyntaxMethods: # new in 2.7
276
280
  Enabled: true
277
281
  RSpec/Rails/AvoidSetupHook: # new in 2.4
278
282
  Enabled: true
283
+ Style/NestedFileDirname: # new in 1.26
284
+ Enabled: true
data/README.md CHANGED
@@ -103,3 +103,17 @@ The following are the recommended naming conventions for code using Cocina model
103
103
  * `cocina_admin_policy`: `Cocina::Models::AdminPolicy` instance
104
104
  * `cocina_collection`: `Cocina::Models::Collection` instance
105
105
  * `cocina_object`: `Cocina::Models::DRO` or `Cocina::Models::AdminPolicy` or `Cocina::Models::Collection` instance
106
+
107
+ ## RSpec matchers
108
+
109
+ As of the 0.69.0 release, the `cocina-models` gem provides RSpec matchers for downstream apps to make it easier to compare Cocina data structures. The matchers provided include:
110
+
111
+ * `equal_cocina_model`: Compare a Cocina JSON string with a model instance. This matcher is especially valuable coupled with the `super_diff` gem (a dependency of `cocina-models` since the 0.69.0 release). Example usage:
112
+ * `expect(http_response_body_with_cocina_json).to equal_cocina_model(cocina_instance)`
113
+ * `cocina_object_with` (AKA `match_cocina_object_with`): Compare a Cocina model instance with a hash containining part of the structure of a Cocina object. Example usage:
114
+ * `expect(CocinaObjectStore).to have_received(:save).with(cocina_object_with(access: { view: 'world' }, structural: { contains: [...] }))`
115
+ * expect(updated_cocina_item).to match_cocina_object_with(structural: { hasMemberOrders: [] })
116
+ * `cocina_object_with_types`: Check a Cocina object's type information. Example usage:
117
+ * `expect(object_client).to have_received(:update).with(params: cocina_object_with_types(content_type: Cocina::Models::ObjectType.book, viewing_direction: 'left-to-right'))`
118
+ * `cocina_admin_policy_with_registration_collections`: Check a Cocina admin policy's collections. Example usage:
119
+ * `expect(object_client).to have_received(:update).with(params: cocina_admin_policy_with_registration_collections([collection_id]))`
@@ -31,6 +31,7 @@ Gem::Specification.new do |spec|
31
31
  # Match these version requirements to what committee wants,
32
32
  # so that our client (non-committee) users have the same dependencies.
33
33
  spec.add_dependency 'openapi_parser', '>= 0.11.1', '< 1.0'
34
+ spec.add_dependency 'super_diff'
34
35
  spec.add_dependency 'thor'
35
36
  spec.add_dependency 'zeitwerk', '~> 2.1'
36
37
 
@@ -71,18 +71,26 @@ module Cocina
71
71
  run("rubocop -a #{filepath} > /dev/null")
72
72
  end
73
73
 
74
- # rubocop:disable Metrics/AbcSize
74
+ NO_CLEAN = [
75
+ 'checkable.rb',
76
+ 'dro_rights_description_builder.rb',
77
+ 'license.rb',
78
+ 'rights_description_builder.rb',
79
+ 'title_builder.rb',
80
+ 'validatable.rb',
81
+ 'validator.rb',
82
+ 'version.rb',
83
+ 'vocabulary.rb'
84
+ ].freeze
85
+
75
86
  def clean_output
76
87
  FileUtils.mkdir_p(options[:output])
77
88
  files = Dir.glob("#{options[:output]}/*.rb")
78
89
  # Leave alone
79
- files.delete("#{options[:output]}/version.rb")
80
- files.delete("#{options[:output]}/checkable.rb")
81
- files.delete("#{options[:output]}/validator.rb")
82
- files.delete("#{options[:output]}/validatable.rb")
90
+ NO_CLEAN.each { |filename| files.delete("#{options[:output]}/#{filename}") }
91
+
83
92
  FileUtils.rm_f(files)
84
93
  end
85
- # rubocop:enable Metrics/AbcSize
86
94
  end
87
95
  end
88
96
  end
@@ -32,7 +32,7 @@ module Cocina
32
32
  module Cocina
33
33
  module Models
34
34
  # #{CLASS_COMMENT.fetch(namespace)}
35
- class #{namespace}
35
+ class #{namespace} < Vocabulary('#{URIS.fetch(namespace)}')
36
36
  #{draw_ruby_methods(methods, 6)}
37
37
  end
38
38
  end
@@ -45,6 +45,10 @@ module Cocina
45
45
  attr_reader :schemas, :output_dir
46
46
 
47
47
  BASE = 'https://cocina.sul.stanford.edu/models/'
48
+ URIS = {
49
+ 'ObjectType' => BASE,
50
+ 'FileSetType' => "#{BASE}resources/"
51
+ }.freeze
48
52
 
49
53
  def vocabs
50
54
  type_properties = schemas.values.map { |schema| schema.properties['type'] }.compact
@@ -56,21 +60,24 @@ module Cocina
56
60
 
57
61
  def names
58
62
  @names ||= vocabs.each_with_object({}) do |vocab, object|
59
- # Note special handling of 3d
60
63
  namespaced = vocab.delete_prefix(BASE)
61
- .gsub('-', '_').gsub('3d', 'three_dimensional')
62
64
  namespace, name = namespaced.include?('/') ? namespaced.split('/') : ['ObjectType', namespaced]
63
65
  namespace = 'FileSetType' if namespace == 'resources'
64
- object[namespace] ||= {}
65
- object[namespace][name] = vocab
66
+ object[namespace] ||= []
67
+ object[namespace] << name
66
68
  end
67
69
  end
68
70
 
69
71
  def draw_ruby_methods(methods, indent)
70
72
  spaces = ' ' * indent
71
- methods.map do |name, vocab|
72
- "#{spaces}def self.#{name}\n#{spaces} '#{vocab}'\n#{spaces}end"
73
- end.join("\n\n")
73
+ methods.map do |name|
74
+ if name == '3d'
75
+ "#{spaces}property :'3d', method_name: :three_dimensional"
76
+ else
77
+ name = "'#{name}'" if name.match?(/(^\d)|-/)
78
+ "#{spaces}property :#{name}"
79
+ end
80
+ end.join("\n")
74
81
  end
75
82
  end
76
83
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ # Rights description builder for items
6
+ class DroRightsDescriptionBuilder < RightsDescriptionBuilder
7
+ # @param [Cocina::Models::DRO] cocina_item
8
+
9
+ # This overrides the superclass
10
+ # @return [Cocina::Models::DROAccess]
11
+ def object_access
12
+ @object_access ||= cocina.access
13
+ end
14
+
15
+ private
16
+
17
+ def object_level_access
18
+ super + access_level_from_files.uniq.map { |str| "#{str} (file)" }
19
+ end
20
+
21
+ def access_level_from_files
22
+ # dark access doesn't permit any file access
23
+ return [] if object_access.view == 'dark'
24
+
25
+ file_access_nodes.reject { |fa| same_as_object_access?(fa) }.flat_map do |fa|
26
+ file_access_from_file(fa)
27
+ end
28
+ end
29
+
30
+ # rubocop:disable Metrics/MethodLength
31
+ def file_access_from_file(file_access)
32
+ basic_access = if file_access[:view] == 'location-based'
33
+ "location: #{file_access[:location]}"
34
+ else
35
+ file_access[:view]
36
+ end
37
+
38
+ return [basic_access] if file_access[:view] == file_access[:download]
39
+
40
+ basic_access += ' (no-download)' if file_access[:view] != 'dark'
41
+
42
+ case file_access[:download]
43
+ when 'stanford'
44
+ [basic_access, 'stanford']
45
+ when 'location-based'
46
+ # Here we're using location to mean download location.
47
+ [basic_access, "location: #{file_access[:location]}"]
48
+ else
49
+ [basic_access]
50
+ end
51
+ end
52
+ # rubocop:enable Metrics/MethodLength
53
+
54
+ def same_as_object_access?(file_access)
55
+ (file_access[:view] == object_access.view && file_access[:download] == object_access.download) ||
56
+ (object_access.view == 'citation-only' && file_access[:view] == 'dark')
57
+ end
58
+
59
+ def file_access_nodes
60
+ Array(cocina.structural.contains)
61
+ .flat_map { |fs| Array(fs.structural.contains) }
62
+ .map { |file| file.access.to_h }
63
+ .uniq
64
+ end
65
+ end
66
+ end
67
+ end
@@ -3,70 +3,23 @@
3
3
  module Cocina
4
4
  module Models
5
5
  # This vocabulary defines the types of file sets
6
- class FileSetType
7
- def self.three_dimensional
8
- 'https://cocina.sul.stanford.edu/models/resources/3d'
9
- end
10
-
11
- def self.attachment
12
- 'https://cocina.sul.stanford.edu/models/resources/attachment'
13
- end
14
-
15
- def self.audio
16
- 'https://cocina.sul.stanford.edu/models/resources/audio'
17
- end
18
-
19
- def self.document
20
- 'https://cocina.sul.stanford.edu/models/resources/document'
21
- end
22
-
23
- def self.file
24
- 'https://cocina.sul.stanford.edu/models/resources/file'
25
- end
26
-
27
- def self.image
28
- 'https://cocina.sul.stanford.edu/models/resources/image'
29
- end
30
-
31
- def self.main_augmented
32
- 'https://cocina.sul.stanford.edu/models/resources/main-augmented'
33
- end
34
-
35
- def self.main_original
36
- 'https://cocina.sul.stanford.edu/models/resources/main-original'
37
- end
38
-
39
- def self.media
40
- 'https://cocina.sul.stanford.edu/models/resources/media'
41
- end
42
-
43
- def self.object
44
- 'https://cocina.sul.stanford.edu/models/resources/object'
45
- end
46
-
47
- def self.page
48
- 'https://cocina.sul.stanford.edu/models/resources/page'
49
- end
50
-
51
- def self.permissions
52
- 'https://cocina.sul.stanford.edu/models/resources/permissions'
53
- end
54
-
55
- def self.preview
56
- 'https://cocina.sul.stanford.edu/models/resources/preview'
57
- end
58
-
59
- def self.supplement
60
- 'https://cocina.sul.stanford.edu/models/resources/supplement'
61
- end
62
-
63
- def self.thumb
64
- 'https://cocina.sul.stanford.edu/models/resources/thumb'
65
- end
66
-
67
- def self.video
68
- 'https://cocina.sul.stanford.edu/models/resources/video'
69
- end
6
+ class FileSetType < Vocabulary('https://cocina.sul.stanford.edu/models/resources/')
7
+ property :'3d', method_name: :three_dimensional
8
+ property :attachment
9
+ property :audio
10
+ property :document
11
+ property :file
12
+ property :image
13
+ property :'main-augmented'
14
+ property :'main-original'
15
+ property :media
16
+ property :object
17
+ property :page
18
+ property :permissions
19
+ property :preview
20
+ property :supplement
21
+ property :thumb
22
+ property :video
70
23
  end
71
24
  end
72
25
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ # This vocabulary defines some of the supported licenses
6
+ class License < Vocabulary('https://cocina.sul.stanford.edu/licenses/')
7
+ property :none
8
+ end
9
+ end
10
+ end
@@ -3,94 +3,29 @@
3
3
  module Cocina
4
4
  module Models
5
5
  # This vocabulary defines the top level object type
6
- class ObjectType
7
- def self.three_dimensional
8
- 'https://cocina.sul.stanford.edu/models/3d'
9
- end
10
-
11
- def self.admin_policy
12
- 'https://cocina.sul.stanford.edu/models/admin_policy'
13
- end
14
-
15
- def self.agreement
16
- 'https://cocina.sul.stanford.edu/models/agreement'
17
- end
18
-
19
- def self.book
20
- 'https://cocina.sul.stanford.edu/models/book'
21
- end
22
-
23
- def self.collection
24
- 'https://cocina.sul.stanford.edu/models/collection'
25
- end
26
-
27
- def self.curated_collection
28
- 'https://cocina.sul.stanford.edu/models/curated-collection'
29
- end
30
-
31
- def self.document
32
- 'https://cocina.sul.stanford.edu/models/document'
33
- end
34
-
35
- def self.exhibit
36
- 'https://cocina.sul.stanford.edu/models/exhibit'
37
- end
38
-
39
- def self.file
40
- 'https://cocina.sul.stanford.edu/models/file'
41
- end
42
-
43
- def self.geo
44
- 'https://cocina.sul.stanford.edu/models/geo'
45
- end
46
-
47
- def self.image
48
- 'https://cocina.sul.stanford.edu/models/image'
49
- end
50
-
51
- def self.manuscript
52
- 'https://cocina.sul.stanford.edu/models/manuscript'
53
- end
54
-
55
- def self.map
56
- 'https://cocina.sul.stanford.edu/models/map'
57
- end
58
-
59
- def self.media
60
- 'https://cocina.sul.stanford.edu/models/media'
61
- end
62
-
63
- def self.object
64
- 'https://cocina.sul.stanford.edu/models/object'
65
- end
66
-
67
- def self.page
68
- 'https://cocina.sul.stanford.edu/models/page'
69
- end
70
-
71
- def self.photograph
72
- 'https://cocina.sul.stanford.edu/models/photograph'
73
- end
74
-
75
- def self.series
76
- 'https://cocina.sul.stanford.edu/models/series'
77
- end
78
-
79
- def self.track
80
- 'https://cocina.sul.stanford.edu/models/track'
81
- end
82
-
83
- def self.user_collection
84
- 'https://cocina.sul.stanford.edu/models/user-collection'
85
- end
86
-
87
- def self.webarchive_binary
88
- 'https://cocina.sul.stanford.edu/models/webarchive-binary'
89
- end
90
-
91
- def self.webarchive_seed
92
- 'https://cocina.sul.stanford.edu/models/webarchive-seed'
93
- end
6
+ class ObjectType < Vocabulary('https://cocina.sul.stanford.edu/models/')
7
+ property :'3d', method_name: :three_dimensional
8
+ property :admin_policy
9
+ property :agreement
10
+ property :book
11
+ property :collection
12
+ property :'curated-collection'
13
+ property :document
14
+ property :exhibit
15
+ property :file
16
+ property :geo
17
+ property :image
18
+ property :manuscript
19
+ property :map
20
+ property :media
21
+ property :object
22
+ property :page
23
+ property :photograph
24
+ property :series
25
+ property :track
26
+ property :'user-collection'
27
+ property :'webarchive-binary'
28
+ property :'webarchive-seed'
94
29
  end
95
30
  end
96
31
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ # RightsDescriptionBuilder
6
+ class RightsDescriptionBuilder
7
+ # @param [Cocina::Models::AdminPolicy, Cocina::Models::DRO] cocina_object
8
+ def self.build(cocina_object)
9
+ new(cocina_object).build
10
+ end
11
+
12
+ def initialize(cocina_object)
13
+ @cocina = cocina_object
14
+ end
15
+
16
+ # This is set up to work for APOs, but this method is to be overridden on sub classes
17
+ # @return [Cocina::Models::AdminPolicyDefaultAccess]
18
+ def object_access
19
+ @object_access ||= cocina.administrative.accessTemplate
20
+ end
21
+
22
+ def build
23
+ return 'controlled digital lending' if object_access.controlledDigitalLending
24
+
25
+ return ['dark'] if object_access.view == 'dark'
26
+
27
+ object_level_access
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :cocina
33
+
34
+ # rubocop:disable Metrics/MethodLength
35
+ def object_level_access
36
+ case object_access.view
37
+ when 'citation-only'
38
+ ['citation']
39
+ when 'world'
40
+ world_object_access
41
+ when 'location-based'
42
+ case object_access.download
43
+ when 'none'
44
+ ["location: #{object_access.location} (no-download)"]
45
+ else
46
+ ["location: #{object_access.location}"]
47
+ end
48
+ when 'stanford'
49
+ stanford_object_access
50
+ end
51
+ end
52
+ # rubocop:enable Metrics/MethodLength
53
+
54
+ def stanford_object_access
55
+ case object_access.download
56
+ when 'none'
57
+ ['stanford (no-download)']
58
+ when 'location-based'
59
+ # this is an odd case we might want to move away from. See https://github.com/sul-dlss/cocina-models/issues/258
60
+ ['stanford (no-download)', "location: #{object_access.location}"]
61
+ else
62
+ ['stanford']
63
+ end
64
+ end
65
+
66
+ def world_object_access
67
+ case object_access.download
68
+ when 'stanford'
69
+ ['stanford', 'world (no-download)']
70
+ when 'none'
71
+ ['world (no-download)']
72
+ when 'world'
73
+ ['world']
74
+ when 'location-based'
75
+ # this is an odd case we might want to move away from. See https://github.com/sul-dlss/cocina-models/issues/258
76
+ ['world (no-download)', "location: #{object_access.location}"]
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ # TitleBuilder selects the prefered title from the cocina object for solr indexing
6
+ # rubocop:disable Metrics/ClassLength
7
+ class TitleBuilder
8
+ # @param [Cocina::Models::D*] cocina_object is the object to extract the title for
9
+ # @param [Symbol] strategy ":first" is the strategy for selection when primary or display title are missing
10
+ # @param [Boolean] add_punctuation determines if the title should be formmated with punctuation
11
+ # @return [String] the title value for Solr
12
+ def self.build(cocina_object, strategy: :first, add_punctuation: true)
13
+ new(cocina_object, strategy: strategy, add_punctuation: add_punctuation).build_title
14
+ end
15
+
16
+ def initialize(cocina_object, strategy:, add_punctuation:)
17
+ @cocina_object = cocina_object
18
+ @strategy = strategy
19
+ @add_punctuation = add_punctuation
20
+ end
21
+
22
+ # @return [String] the title value for Solr
23
+ def build_title
24
+ @titles = cocina_object.description.title
25
+
26
+ cocina_title = primary_title || untyped_title
27
+ cocina_title = other_title if cocina_title.blank?
28
+
29
+ if strategy == :first
30
+ extract_title(cocina_title)
31
+ else
32
+ cocina_title.map { |one| extract_title(one) }
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :cocina_object, :strategy, :titles
39
+
40
+ def extract_title(cocina_title)
41
+ result = if cocina_title.value
42
+ cocina_title.value
43
+ elsif cocina_title.structuredValue.present?
44
+ title_from_structured_values(cocina_title.structuredValue,
45
+ non_sorting_char_count(cocina_title))
46
+ elsif cocina_title.parallelValue.present?
47
+ return cocina_title.parallelValue.first.value
48
+ end
49
+ remove_trailing_punctuation(result.strip) if result.present?
50
+ end
51
+
52
+ def add_punctuation?
53
+ @add_punctuation
54
+ end
55
+
56
+ # @return [Cocina::Models::Title, nil] title that has status=primary
57
+ def primary_title
58
+ primary_title = titles.find do |title|
59
+ title.status == 'primary'
60
+ end
61
+ return primary_title if primary_title.present?
62
+
63
+ # NOTE: structuredValues would only have status primary assigned as a sibling, not as an attribute
64
+
65
+ titles.find do |title|
66
+ title.parallelValue&.find do |parallel_title|
67
+ parallel_title.status == 'primary'
68
+ end
69
+ end
70
+ end
71
+
72
+ def untyped_title
73
+ method = strategy == :first ? :find : :select
74
+ untyped_title_for(titles.public_send(method))
75
+ end
76
+
77
+ # @return [Array[Cocina::Models::Title]] first title that has no type attribute
78
+ def untyped_title_for(titles)
79
+ titles.each do |title|
80
+ if title.parallelValue.present?
81
+ untyped_title_for(title.parallelValue)
82
+ else
83
+ title.type.nil? || title.type == 'title'
84
+ end
85
+ end
86
+ end
87
+
88
+ # This handles 'main title', 'uniform' or 'translated'
89
+ def other_title
90
+ if strategy == :first
91
+ titles.first
92
+ else
93
+ titles
94
+ end
95
+ end
96
+
97
+ # rubocop:disable Metrics/BlockLength
98
+ # rubocop:disable Metrics/CyclomaticComplexity
99
+ # rubocop:disable Metrics/PerceivedComplexity
100
+ # rubocop:disable Metrics/MethodLength
101
+ # rubocop:disable Metrics/AbcSize
102
+ # @param [Array<Cocina::Models::StructuredValue>] structured_values - the pieces of a structuredValue
103
+ # @param [Integer] the length of the non_sorting_characters
104
+ # @return [String] the title value from combining the pieces of the structured_values by type and order
105
+ # with desired punctuation per specs
106
+ def title_from_structured_values(structured_values, non_sorting_char_count)
107
+ structured_title = ''
108
+ part_name_number = ''
109
+ # combine pieces of the cocina structuredValue into a single title
110
+ structured_values.each do |structured_value|
111
+ # There can be a structuredValue inside a structuredValue. For example,
112
+ # a uniform title where both the name and the title have internal StructuredValue
113
+ if structured_value.structuredValue.present?
114
+ return title_from_structured_values(structured_value.structuredValue, non_sorting_char_count)
115
+ end
116
+
117
+ value = structured_value.value&.strip
118
+ next unless value
119
+
120
+ # additional types: name, uniform ...
121
+ case structured_value.type&.downcase
122
+ when 'nonsorting characters'
123
+ non_sorting_size = [non_sorting_char_count - (value&.size || 0), 0].max
124
+ non_sort_value = "#{value}#{' ' * non_sorting_size}"
125
+ structured_title = if structured_title.present?
126
+ "#{structured_title}#{non_sort_value}"
127
+ else
128
+ non_sort_value
129
+ end
130
+ when 'part name', 'part number'
131
+ if part_name_number.blank?
132
+ part_name_number = part_name_number(structured_values)
133
+ structured_title = if !add_punctuation?
134
+ [structured_title, part_name_number].join(' ')
135
+ elsif structured_title.present?
136
+ "#{structured_title.sub(/[ .,]*$/, '')}. #{part_name_number}. "
137
+ else
138
+ "#{part_name_number}. "
139
+ end
140
+ end
141
+ when 'main title', 'title'
142
+ structured_title = "#{structured_title}#{value}"
143
+ when 'subtitle'
144
+ # subtitle is preceded by space colon space, unless it is at the beginning of the title string
145
+ structured_title = if !add_punctuation?
146
+ [structured_title, value].join(' ')
147
+ elsif structured_title.present?
148
+ "#{structured_title.sub(/[. :]+$/, '')} : #{value.sub(/^:/, '').strip}"
149
+ else
150
+ structured_title = value.sub(/^:/, '').strip
151
+ end
152
+ end
153
+ end
154
+ structured_title
155
+ end
156
+ # rubocop:enable Metrics/AbcSize
157
+ # rubocop:enable Metrics/MethodLength
158
+ # rubocop:enable Metrics/BlockLength
159
+ # rubocop:enable Metrics/CyclomaticComplexity
160
+ # rubocop:enable Metrics/PerceivedComplexity
161
+
162
+ def remove_trailing_punctuation(title)
163
+ title.sub(%r{[ .,;:/\\]+$}, '')
164
+ end
165
+
166
+ def non_sorting_char_count(title)
167
+ non_sort_note = title.note&.find { |note| note.type&.downcase == 'nonsorting character count' }
168
+ return 0 unless non_sort_note
169
+
170
+ non_sort_note.value.to_i
171
+ end
172
+
173
+ # combine part name and part number:
174
+ # respect order of occurrence
175
+ # separated from each other by comma space
176
+ def part_name_number(structured_values)
177
+ title_from_part = ''
178
+ structured_values.each do |structured_value|
179
+ case structured_value.type&.downcase
180
+ when 'part name', 'part number'
181
+ value = structured_value.value&.strip
182
+ next unless value
183
+
184
+ title_from_part = append_part_to_title(title_from_part, value)
185
+
186
+ end
187
+ end
188
+ title_from_part
189
+ end
190
+
191
+ def append_part_to_title(title_from_part, value)
192
+ if !add_punctuation?
193
+ [title_from_part, value].select(&:presence).join(' ')
194
+ elsif title_from_part.strip.present?
195
+ "#{title_from_part.sub(/[ .,]*$/, '')}, #{value}"
196
+ else
197
+ value
198
+ end
199
+ end
200
+ end
201
+ # rubocop:enable Metrics/ClassLength
202
+ end
203
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Cocina
4
4
  module Models
5
- VERSION = '0.68.0'
5
+ VERSION = '0.69.2'
6
6
  end
7
7
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ class Vocabulary
6
+ # @private
7
+ # Disabled this cop because we want @@uri to be inheritable.
8
+ # rubocop:disable Style/ClassVars
9
+ def self.create(uri)
10
+ @@uri = uri
11
+ self
12
+ end
13
+ # rubocop:enable Style/ClassVars
14
+
15
+ def self.to_s
16
+ @@uri
17
+ end
18
+
19
+ def self.property(name, method_name: name.to_s.underscore.to_sym)
20
+ uri = [to_s, name].join
21
+ properties[name] = uri
22
+ (class << self; self; end).send(:define_method, method_name) { uri }
23
+ end
24
+
25
+ def self.properties
26
+ @properties ||= {}
27
+ end
28
+ end
29
+ end
30
+ end
data/lib/cocina/models.rb CHANGED
@@ -22,6 +22,7 @@ class CocinaModelsInflector < Zeitwerk::Inflector
22
22
  'dro_access' => 'DROAccess',
23
23
  'dro_structural' => 'DROStructural',
24
24
  'request_dro_structural' => 'RequestDROStructural',
25
+ 'rspec' => 'RSpec',
25
26
  'version' => 'VERSION'
26
27
  }.freeze
27
28
 
@@ -33,6 +34,8 @@ end
33
34
  loader = Zeitwerk::Loader.new
34
35
  loader.inflector = CocinaModelsInflector.new
35
36
  loader.push_dir(File.absolute_path("#{__FILE__}/../.."))
37
+ loader.ignore("#{__dir__}/rspec.rb")
38
+ loader.ignore("#{__dir__}/rspec/**/*.rb")
36
39
  loader.setup
37
40
 
38
41
  module Cocina
@@ -56,6 +59,17 @@ module Cocina
56
59
  include Dry.Types()
57
60
  end
58
61
 
62
+ ##
63
+ # Alias for `Cocina::Models::Vocabulary.create`.
64
+ #
65
+ # @param (see Cocina::Models::Vocabulary#initialize)
66
+ # @return [Class]
67
+ # rubocop:disable Naming/MethodName
68
+ def self.Vocabulary(uri)
69
+ Vocabulary.create(uri)
70
+ end
71
+ # rubocop:enable Naming/MethodName
72
+
59
73
  # @param [Hash] dyn a ruby hash representation of the JSON serialization of a collection or DRO
60
74
  # @param [boolean] validate
61
75
  # @return [DRO,Collection,AdminPolicy]
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Provides RSpec matchers for Cocina models
4
+ module Cocina
5
+ module RSpec
6
+ # Cocina-related RSpec matchers
7
+ module Matchers
8
+ extend ::RSpec::Matchers::DSL
9
+
10
+ # NOTE: each k/v pair in the hash passed to this matcher will need to be present in actual
11
+ matcher :cocina_object_with do |**kwargs|
12
+ kwargs.each do |cocina_section, expected|
13
+ match do |actual|
14
+ expected.all? do |expected_key, expected_value|
15
+ # NOTE: there's no better method on Hash that I could find for this.
16
+ # #include? and #member? only check keys, not k/v pairs
17
+ actual.public_send(cocina_section).to_h.any? do |actual_key, actual_value|
18
+ if expected_value.is_a?(Hash) && actual_value.is_a?(Hash)
19
+ expected_value.all? { |pair| actual_value.to_a.include?(pair) }
20
+ else
21
+ actual_key == expected_key && actual_value == expected_value
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ alias_matcher :match_cocina_object_with, :cocina_object_with
30
+
31
+ # The `equal_cocina_model` matcher compares a JSON string as actual value
32
+ # against a Cocina model coerced to JSON as expected value. We want to compare
33
+ # these as JSON rather than as hashes, else we'll have to start
34
+ # deep-converting some values within the hashes, thinking of date/time values
35
+ # in particular. This matching behavior continues what dor-services-app was
36
+ # already doing before this custom matcher was written.
37
+ #
38
+ # Note, though, that when actual and expected values do *not* match, we coerce
39
+ # both values to hashes to allow the `super_diff` gem to highlight the areas
40
+ # that differ. This is easier to scan than two giant JSON strings.
41
+ matcher :equal_cocina_model do |expected|
42
+ match do |actual|
43
+ Cocina::Models.build(JSON.parse(actual)).to_json == expected.to_json
44
+ rescue NoMethodError
45
+ warn "Could not match cocina models because expected is not a valid JSON string: #{expected}"
46
+ false
47
+ end
48
+
49
+ failure_message do |actual|
50
+ SuperDiff::EqualityMatchers::Hash.new(
51
+ expected: expected.to_h.deep_symbolize_keys,
52
+ actual: JSON.parse(actual, symbolize_names: true)
53
+ ).fail
54
+ rescue StandardError => e
55
+ "ERROR in CocinaMatchers: #{e}"
56
+ end
57
+ end
58
+
59
+ matcher :cocina_object_with_types do |expected|
60
+ match do |actual|
61
+ return false if expected[:without_order] && !match_no_orders?
62
+
63
+ if expected[:content_type] && expected[:resource_types]
64
+ match_cocina_type?(actual, expected) && match_contained_cocina_types?(actual, expected)
65
+ elsif expected[:content_type] && expected[:viewing_direction]
66
+ match_cocina_type?(actual, expected) && match_cocina_viewing_direction?(actual, expected)
67
+ elsif expected[:content_type]
68
+ match_cocina_type?(actual, expected)
69
+ elsif expected[:resource_types]
70
+ match_contained_cocina_types?(actual, expected)
71
+ else
72
+ raise ArgumentError, 'must provide content_type and/or resource_types keyword args'
73
+ end
74
+ end
75
+
76
+ def match_no_orders?
77
+ actual.structural.hasMemberOrders.blank?
78
+ end
79
+
80
+ def match_cocina_type?(actual, expected)
81
+ actual.type == expected[:content_type]
82
+ end
83
+
84
+ def match_cocina_viewing_direction?(actual, expected)
85
+ actual.structural.hasMemberOrders.map(&:viewingDirection).all? do |viewing_direction|
86
+ viewing_direction == expected[:viewing_direction]
87
+ end
88
+ end
89
+
90
+ def match_contained_cocina_types?(actual, expected)
91
+ Array(actual.structural.contains).map(&:type).all? { |type| type.in?(expected[:resource_types]) }
92
+ end
93
+ end
94
+
95
+ matcher :cocina_admin_policy_with_registration_collections do |expected|
96
+ match do |actual|
97
+ actual.type == Cocina::Models::ObjectType.admin_policy &&
98
+ expected.all? { |collection_id| collection_id.in?(actual.administrative.collectionsForRegistration) }
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core'
4
+ require 'rspec/matchers'
5
+ require 'super_diff/rspec'
6
+ require 'cocina/rspec/matchers'
7
+
8
+ RSpec.configure do |config|
9
+ config.include Cocina::RSpec::Matchers
10
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cocina-models
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.68.0
4
+ version: 0.69.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Coyne
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2022-03-09 00:00:00.000000000 Z
11
+ date: 2022-03-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -86,6 +86,20 @@ dependencies:
86
86
  - - "<"
87
87
  - !ruby/object:Gem::Version
88
88
  version: '1.0'
89
+ - !ruby/object:Gem::Dependency
90
+ name: super_diff
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ type: :runtime
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
89
103
  - !ruby/object:Gem::Dependency
90
104
  name: thor
91
105
  requirement: !ruby/object:Gem::Requirement
@@ -308,6 +322,7 @@ files:
308
322
  - lib/cocina/models/doi.rb
309
323
  - lib/cocina/models/dro.rb
310
324
  - lib/cocina/models/dro_access.rb
325
+ - lib/cocina/models/dro_rights_description_builder.rb
311
326
  - lib/cocina/models/dro_structural.rb
312
327
  - lib/cocina/models/druid.rb
313
328
  - lib/cocina/models/embargo.rb
@@ -322,6 +337,7 @@ files:
322
337
  - lib/cocina/models/identification.rb
323
338
  - lib/cocina/models/lane_medical_barcode.rb
324
339
  - lib/cocina/models/language.rb
340
+ - lib/cocina/models/license.rb
325
341
  - lib/cocina/models/location_based_access.rb
326
342
  - lib/cocina/models/location_based_download_access.rb
327
343
  - lib/cocina/models/message_digest.rb
@@ -340,6 +356,7 @@ files:
340
356
  - lib/cocina/models/request_file_set.rb
341
357
  - lib/cocina/models/request_file_set_structural.rb
342
358
  - lib/cocina/models/request_identification.rb
359
+ - lib/cocina/models/rights_description_builder.rb
343
360
  - lib/cocina/models/sequence.rb
344
361
  - lib/cocina/models/source.rb
345
362
  - lib/cocina/models/source_id.rb
@@ -347,16 +364,20 @@ files:
347
364
  - lib/cocina/models/standard_barcode.rb
348
365
  - lib/cocina/models/stanford_access.rb
349
366
  - lib/cocina/models/title.rb
367
+ - lib/cocina/models/title_builder.rb
350
368
  - lib/cocina/models/validatable.rb
351
369
  - lib/cocina/models/validator.rb
352
370
  - lib/cocina/models/version.rb
371
+ - lib/cocina/models/vocabulary.rb
353
372
  - lib/cocina/models/world_access.rb
373
+ - lib/cocina/rspec.rb
374
+ - lib/cocina/rspec/matchers.rb
354
375
  - openapi.yml
355
376
  homepage: https://github.com/sul-dlss/cocina-models
356
377
  licenses: []
357
378
  metadata:
358
379
  rubygems_mfa_required: 'true'
359
- post_install_message:
380
+ post_install_message:
360
381
  rdoc_options: []
361
382
  require_paths:
362
383
  - lib
@@ -371,8 +392,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
371
392
  - !ruby/object:Gem::Version
372
393
  version: '0'
373
394
  requirements: []
374
- rubygems_version: 3.3.4
375
- signing_key:
395
+ rubygems_version: 3.2.32
396
+ signing_key:
376
397
  specification_version: 4
377
398
  summary: Data models for the SDR
378
399
  test_files: []