cocina-models 0.128.0 → 0.130.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.
@@ -18,7 +18,7 @@ module Cocina
18
18
  def initialize(clazz, attributes, validators: VALIDATORS)
19
19
  @clazz = clazz
20
20
  @attributes = attributes
21
- @validators = validators.map { |v| v.new(attributes) }
21
+ @validators = meets_preconditions? ? validators.map { |v| v.new(attributes) } : []
22
22
  end
23
23
 
24
24
  def validate
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Validators
6
+ # Validates form.source.code values against form_source_codes.yml.
7
+ class DescriptionFormSourceCodeVisitorValidator < BaseDescriptionVisitorValidator
8
+ def validate!
9
+ return if error_paths.empty?
10
+
11
+ raise ValidationError, "Unrecognized form source codes in description: #{error_paths.join(', ')}"
12
+ end
13
+
14
+ def visit_hash(hash:, path:)
15
+ return unless form_entry_path?(path)
16
+
17
+ source_code = hash.dig(:source, :code)
18
+ return unless source_code
19
+
20
+ error_paths << "#{path_to_s(path)}.source.code (#{source_code})" unless valid_codes.include?(source_code.downcase)
21
+ end
22
+
23
+ private
24
+
25
+ def error_paths
26
+ @error_paths ||= []
27
+ end
28
+
29
+ def form_entry_path?(path)
30
+ path.length >= 2 && path[-1].is_a?(Integer) && path[-2].to_s == 'form'
31
+ end
32
+
33
+ # rubocop:disable Style/ClassVars
34
+ def valid_codes
35
+ @@valid_codes ||= YAML.load_file(::File.expand_path('../../../../form_source_codes.yml', __dir__)).to_set(&:downcase)
36
+ end
37
+ # rubocop:enable Style/ClassVars
38
+ end
39
+ end
40
+ end
41
+ end
@@ -41,7 +41,7 @@ module Cocina
41
41
  # Some part of the path are ignored for the purpose of matching.
42
42
  def clean_path(path)
43
43
  new_path = path.reject do |part|
44
- part.is_a?(Integer) || %i[parallelValue parallelEvent].include?(part.to_sym)
44
+ part.is_a?(Integer) || %i[parallelValue].include?(part.to_sym)
45
45
  end.map(&:to_sym)
46
46
  # This needs to happen after parallelValue is removed
47
47
  # to handle structuredValue > parallelValue > structuredValue
@@ -25,6 +25,11 @@ module Cocina
25
25
 
26
26
  # @see #validate
27
27
  def self.validate(...)
28
+ # Skip when nested inside another Validatable's construction: the enclosing object's own
29
+ # schema check already covers this, so re-evaluating it here would be
30
+ # redundant. See Validatable#new.
31
+ return if Thread.current[:cocina_construction_depth].to_i.positive?
32
+
28
33
  new(...).validate
29
34
  end
30
35
 
@@ -67,7 +72,7 @@ module Cocina
67
72
  evaluation = self.class.validator_for(method_name).evaluate(attributes.as_json)
68
73
  return if evaluation.valid?
69
74
 
70
- raise ValidationError, "When validating #{method_name}: " + filtered_error_messages(evaluation).join(', ')
75
+ raise ValidationError, "When validating #{method_name}: " + filtered_error_messages(evaluation).join('; ')
71
76
  end
72
77
 
73
78
  private
@@ -90,7 +95,7 @@ module Cocina
90
95
  denoised.flat_map { |d| format_detail(d) }.uniq
91
96
  end
92
97
 
93
- # Removes two categories of cascade noise produced by `unevaluatedProperties: false`:
98
+ # Removes three categories of cascade noise from errors:
94
99
  #
95
100
  # 1. falseSchema errors — every `unevaluatedProperties: false` on a sub-path emits
96
101
  # a "False schema does not allow …" entry for each matched value. These are
@@ -100,8 +105,28 @@ module Cocina
100
105
  # is a known model attribute — these arise because allOf/$ref composition means
101
106
  # the validator can't prove top-level properties were "evaluated", even though
102
107
  # they are valid model fields.
108
+ #
109
+ # 3. anyOf branch noise — when each branch requires a different property, all failing
110
+ # branches emit a separate "required" error at the same path. Collapse into one
111
+ # "needs to include at least one of: …" message.
103
112
  def denoise(details)
104
- details.reject { |d| false_schema_noise?(d) || root_unevaluated_noise?(d) }
113
+ filtered = details.reject { |detail| false_schema_noise?(detail) || root_unevaluated_noise?(detail) }
114
+ collapse_anyof_required(filtered)
115
+ end
116
+
117
+ def collapse_anyof_required(details)
118
+ # instanceLocation is the input data that failed. Group errors for each failing data location.
119
+ details.group_by { |detail| detail[:instanceLocation] }.flat_map do |loc, group|
120
+ # splits errors into "required" anyOf errors and others (minItems, unevaluatedProperties)
121
+ required, others = group.partition { |detail| detail[:errors].keys == ['required'] }
122
+ next group if required.size <= 1
123
+
124
+ # Extract property name from "required" messages such as, '"url" is a required property'
125
+ props = required.filter_map { |detail| detail[:errors]['required'][/"([^"]+)"/, 1] }.uniq
126
+ # Put the data path at the start of the message; blank instanceLocation so format_detail won't also append it
127
+ msg = "#{loc} needs to include at least one of the following: #{props.join(', ')}"
128
+ others + [required.first.merge(instanceLocation: '', errors: { 'required' => msg })]
129
+ end
105
130
  end
106
131
 
107
132
  def false_schema_noise?(detail)
@@ -120,12 +145,32 @@ module Cocina
120
145
  unexpected.all? { |prop| known_root_properties.include?(prop) }
121
146
  end
122
147
 
148
+ # Formats error details hash into more user-friendly messages
149
+ # A detail can carry multiple errors (one per JSON Schema keyword), so this returns an array.
150
+ # minItems errors get a dedicated formatter; everything else uses the generic one.
123
151
  def format_detail(detail)
124
152
  loc = detail[:instanceLocation]
125
- detail[:errors].map do |_keyword, message|
126
- loc.empty? ? message : "#{message} at #{loc}"
153
+ detail[:errors].map do |keyword, message|
154
+ keyword == 'minItems' ? format_min_items(message, loc) : format_generic(message, loc)
127
155
  end
128
156
  end
157
+
158
+ # Rewrites messages regarding minItems validation
159
+ # "[] has less than 1 item at /event/0/date " is rewritten to "/event/0/date is empty but should have at least 1 item"
160
+ # Arrays with items but fewer than the minimum will be rewritten to "/event/0/date should have at least 2 items"
161
+ def format_min_items(message, loc)
162
+ # extract minimum number of items from message
163
+ min = message[/less than (\d+)/, 1] || '1'
164
+ # pluralize "item" when appropriate
165
+ items = min == '1' ? 'item' : 'items'
166
+ # determine if the message is about an empty array or an non-empty array without enough items
167
+ prefix = message.start_with?('[]') ? "#{loc} is empty but" : loc.to_s
168
+ "#{prefix} should have at least #{min} #{items}"
169
+ end
170
+
171
+ def format_generic(message, loc)
172
+ loc.empty? ? message : "#{message} at #{loc}"
173
+ end
129
174
  end
130
175
  end
131
176
  end
@@ -16,12 +16,22 @@ module Cocina
16
16
  ].freeze
17
17
 
18
18
  def self.validate(clazz, attributes, validators: VALIDATORS)
19
- # This gets rid of nested model objects.
20
- attributes_hash = attributes.to_h.deep_transform_values do |value|
19
+ hash = attributes_hash(attributes)
20
+ validators.each { |validator| validator.validate(clazz, hash) }
21
+ end
22
+
23
+ # @return [Hash] attributes with any embedded Cocina model instances flattened to hashes
24
+ def self.attributes_hash(attributes)
25
+ # Dry::Struct#to_h is already fully recursive (Dry::Struct::Hashify flattens nested
26
+ # structs all the way down), so a Cocina model instance's #to_h has no embedded
27
+ # Cocina model instances left to flatten and the deep walk below would be a no-op.
28
+ return attributes.to_h.with_indifferent_access if attributes.class.name.starts_with?('Cocina::Models')
29
+
30
+ attributes.to_h.deep_transform_values do |value|
21
31
  value.class.name.starts_with?('Cocina::Models') ? value.to_h : value
22
32
  end.with_indifferent_access
23
- validators.each { |validator| validator.validate(clazz, attributes_hash) }
24
33
  end
34
+ private_class_method :attributes_hash
25
35
 
26
36
  def self.deep_transform_values(object, ...)
27
37
  case object
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Cocina
4
4
  module Models
5
- VERSION = '0.128.0'
5
+ VERSION = '0.130.0'
6
6
  end
7
7
  end
data/schema.json CHANGED
@@ -1270,70 +1270,6 @@
1270
1270
  }
1271
1271
  }
1272
1272
  },
1273
- "DescriptiveParallelEvent": {
1274
- "description": "Value model for multiple representations of information about the same event (e.g. in different languages).",
1275
- "type": "object",
1276
- "allOf": [
1277
- {
1278
- "$ref": "#/$defs/DescriptiveStructuredValue"
1279
- },
1280
- {
1281
- "type": "object",
1282
- "properties": {
1283
- "type": {
1284
- "description": "Description of the event (creation, publication, etc.).",
1285
- "type": "string"
1286
- },
1287
- "displayLabel": {
1288
- "description": "The preferred display label to use for the event in access systems.",
1289
- "type": [
1290
- "string",
1291
- "null"
1292
- ]
1293
- },
1294
- "date": {
1295
- "description": "Dates associated with the event.",
1296
- "type": "array",
1297
- "items": {
1298
- "$ref": "#/$defs/DescriptiveValue"
1299
- }
1300
- },
1301
- "contributor": {
1302
- "description": "Contributors associated with the event.",
1303
- "type": "array",
1304
- "items": {
1305
- "$ref": "#/$defs/Contributor"
1306
- }
1307
- },
1308
- "location": {
1309
- "description": "Locations associated with the event.",
1310
- "type": "array",
1311
- "items": {
1312
- "$ref": "#/$defs/DescriptiveValue"
1313
- }
1314
- },
1315
- "identifier": {
1316
- "description": "Identifiers and URIs associated with the event.",
1317
- "type": "array",
1318
- "items": {
1319
- "$ref": "#/$defs/DescriptiveIdentifier"
1320
- }
1321
- },
1322
- "note": {
1323
- "description": "Other information about the event.",
1324
- "type": "array",
1325
- "items": {
1326
- "$ref": "#/$defs/DescriptiveValue"
1327
- }
1328
- },
1329
- "valueLanguage": {
1330
- "$ref": "#/$defs/DescriptiveValueLanguage"
1331
- }
1332
- }
1333
- }
1334
- ],
1335
- "unevaluatedProperties": false
1336
- },
1337
1273
  "DescriptiveParallelValue": {
1338
1274
  "description": "Value model for multiple representations of the same information (e.g. in different languages).",
1339
1275
  "type": "object",
@@ -1529,13 +1465,6 @@
1529
1465
  },
1530
1466
  "valueLanguage": {
1531
1467
  "$ref": "#/$defs/DescriptiveValueLanguage"
1532
- },
1533
- "parallelEvent": {
1534
- "description": "For multiple representations of information about the same event (e.g. in different languages)",
1535
- "type": "array",
1536
- "items": {
1537
- "$ref": "#/$defs/DescriptiveParallelEvent"
1538
- }
1539
1468
  }
1540
1469
  }
1541
1470
  }
@@ -1589,14 +1518,6 @@
1589
1518
  "minItems": 1
1590
1519
  }
1591
1520
  }
1592
- },
1593
- {
1594
- "required": ["parallelEvent"],
1595
- "properties": {
1596
- "parallelEvent": {
1597
- "minItems": 1
1598
- }
1599
- }
1600
1521
  }
1601
1522
  ]
1602
1523
  },
@@ -2546,9 +2467,21 @@
2546
2467
  "required": [
2547
2468
  "cocinaVersion",
2548
2469
  "administrative",
2470
+ "description",
2549
2471
  "type",
2550
2472
  "version"
2551
2473
  ],
2474
+ "allOf": [
2475
+ {
2476
+ "properties": {
2477
+ "description": {
2478
+ "required": [
2479
+ "title"
2480
+ ]
2481
+ }
2482
+ }
2483
+ }
2484
+ ],
2552
2485
  "unevaluatedProperties": false
2553
2486
  },
2554
2487
  "RequestAdministrative": {
@@ -2616,6 +2549,47 @@
2616
2549
  "type",
2617
2550
  "version"
2618
2551
  ],
2552
+ "anyOf": [
2553
+ {
2554
+ "required": [
2555
+ "description"
2556
+ ],
2557
+ "properties": {
2558
+ "description": {
2559
+ "required": [
2560
+ "title"
2561
+ ]
2562
+ }
2563
+ }
2564
+ },
2565
+ {
2566
+ "required": [
2567
+ "identification"
2568
+ ],
2569
+ "properties": {
2570
+ "identification": {
2571
+ "required": [
2572
+ "catalogLinks"
2573
+ ],
2574
+ "properties": {
2575
+ "catalogLinks": {
2576
+ "contains": {
2577
+ "type": "object",
2578
+ "required": [
2579
+ "refresh"
2580
+ ],
2581
+ "properties": {
2582
+ "refresh": {
2583
+ "const": true
2584
+ }
2585
+ }
2586
+ }
2587
+ }
2588
+ }
2589
+ }
2590
+ }
2591
+ }
2592
+ ],
2619
2593
  "unevaluatedProperties": false
2620
2594
  },
2621
2595
  "RequestDRO": {
@@ -2682,6 +2656,47 @@
2682
2656
  "type",
2683
2657
  "version"
2684
2658
  ],
2659
+ "anyOf": [
2660
+ {
2661
+ "required": [
2662
+ "description"
2663
+ ],
2664
+ "properties": {
2665
+ "description": {
2666
+ "required": [
2667
+ "title"
2668
+ ]
2669
+ }
2670
+ }
2671
+ },
2672
+ {
2673
+ "required": [
2674
+ "identification"
2675
+ ],
2676
+ "properties": {
2677
+ "identification": {
2678
+ "required": [
2679
+ "catalogLinks"
2680
+ ],
2681
+ "properties": {
2682
+ "catalogLinks": {
2683
+ "contains": {
2684
+ "type": "object",
2685
+ "required": [
2686
+ "refresh"
2687
+ ],
2688
+ "properties": {
2689
+ "refresh": {
2690
+ "const": true
2691
+ }
2692
+ }
2693
+ }
2694
+ }
2695
+ }
2696
+ }
2697
+ }
2698
+ }
2699
+ ],
2685
2700
  "unevaluatedProperties": false
2686
2701
  },
2687
2702
  "RequestDROStructural": {
@@ -2803,9 +2818,6 @@
2803
2818
  "type": "string"
2804
2819
  }
2805
2820
  },
2806
- "required": [
2807
- "title"
2808
- ],
2809
2821
  "unevaluatedProperties": false
2810
2822
  },
2811
2823
  "RequestFile": {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cocina-models
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.128.0
4
+ version: 0.130.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Coyne
@@ -326,6 +326,7 @@ files:
326
326
  - docs/cocina-base.jsonld
327
327
  - docs/description_types.md
328
328
  - exe/generator
329
+ - form_source_codes.yml
329
330
  - identifier_source_codes.yml
330
331
  - iso15924_codes.yml
331
332
  - language_uri_iso639_2_codes.yml
@@ -381,7 +382,6 @@ files:
381
382
  - lib/cocina/models/descriptive_geographic_metadata.rb
382
383
  - lib/cocina/models/descriptive_grouped_value.rb
383
384
  - lib/cocina/models/descriptive_identifier.rb
384
- - lib/cocina/models/descriptive_parallel_event.rb
385
385
  - lib/cocina/models/descriptive_parallel_value.rb
386
386
  - lib/cocina/models/descriptive_structured_value.rb
387
387
  - lib/cocina/models/descriptive_value.rb
@@ -485,6 +485,7 @@ files:
485
485
  - lib/cocina/models/validators/description_date_time_visitor_validator.rb
486
486
  - lib/cocina/models/validators/description_event_date_visitor_validator.rb
487
487
  - lib/cocina/models/validators/description_form_resource_type_visitor_validator.rb
488
+ - lib/cocina/models/validators/description_form_source_code_visitor_validator.rb
488
489
  - lib/cocina/models/validators/description_identifier_source_code_visitor_validator.rb
489
490
  - lib/cocina/models/validators/description_language_code_visitor_validator.rb
490
491
  - lib/cocina/models/validators/description_language_uri_visitor_validator.rb
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Cocina
4
- module Models
5
- # Value model for multiple representations of information about the same event (e.g.
6
- # in different languages).
7
- class DescriptiveParallelEvent < Struct
8
- attribute :structuredValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
9
- # Description of the event (creation, publication, etc.).
10
- attribute? :type, Types::Strict::String
11
- # The preferred display label to use for the event in access systems.
12
- attribute? :displayLabel, Types::Strict::String.optional
13
- # Dates associated with the event.
14
- attribute :date, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
15
- # Contributors associated with the event.
16
- attribute :contributor, Types::Strict::Array.of(Contributor).default([].freeze)
17
- # Locations associated with the event.
18
- attribute :location, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
19
- # Identifiers and URIs associated with the event.
20
- attribute :identifier, Types::Strict::Array.of(DescriptiveIdentifier).default([].freeze)
21
- # Other information about the event.
22
- attribute :note, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
23
- # Language of the descriptive element value
24
- attribute? :valueLanguage, DescriptiveValueLanguage.optional
25
- end
26
- end
27
- end