Class: Pubid::Identifier

Inherits:
Lutaml::Model::Serializable
  • Object
show all
Defined in:
lib/pubid/identifier.rb

Class Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(attrs = {}, options = {}) ⇒ Identifier

Returns a new instance of Identifier.



245
246
247
248
249
# File 'lib/pubid/identifier.rb', line 245

def initialize(attrs = {}, options = {})
  attrs = attrs.dup
  attrs[:_type] ||= self.class.polymorphic_name
  super
end

Class Attribute Details

.format_registryObject



6
7
8
# File 'lib/pubid/identifier.rb', line 6

def format_registry
  @format_registry || superclass&.format_registry
end

Class Method Details

.apply_mappings(doc, format, options = {}) ⇒ Object

lutaml's nested polymorphic cast (Attribute#cast → apply_mappings) bypasses from_hash, so cross-flavor dispatch wouldn't fire for a nested attribute that carries another flavor's _type. Intercept apply_mappings and route to from_hash so the concrete class's own mappings (with their flavor-specific custom cast methods, e.g. ISO's number_from_kv) drive deserialization.



37
38
39
40
41
42
43
44
# File 'lib/pubid/identifier.rb', line 37

def apply_mappings(doc, format, options = {})
  return super unless hash_with_type?(doc, format)

  klass = concrete_class_for(doc)
  return super unless klass && klass != self

  klass.from_hash(doc, options)
end

.concrete_class_for(data) ⇒ Object

Resolve a polymorphic _type to the concrete class that owns it: this flavor's own map first, then any registered flavor's map via TypeResolver. Returns nil for blank or unknown types.



49
50
51
52
53
54
# File 'lib/pubid/identifier.rb', line 49

def concrete_class_for(data)
  type = data && (data["_type"] || data[:_type])
  return nil unless type

  polymorphic_type_map[type] || ::Pubid::TypeResolver.resolve(type)
end

.from_hash(data, options = {}) ⇒ Object

Polymorphic deserialization shared by every flavor base. lutaml's key_value polymorphic_map reads _type only to VALIDATE; it does not re-instantiate the concrete subclass. So a base-class from_hash would return a bare base object and drop subtype-specific attributes (e.g. a supplement's base). Route by _type to the concrete class named in polymorphic_type_map, then let its inherited from_hash (this method again, where klass == self) fall through to lutaml's real work.

This generalizes the per-flavor *_TYPE_MAP dispatch that ISO, JIS, IEC, CCSDS and IHO each hand-rolled — one implementation, inherited by every flavor base. Cross-flavor dispatch (a nested adopted identifier whose _type belongs to a different flavor) is delegated to TypeResolver, which knows about every registered flavor.



25
26
27
28
29
# File 'lib/pubid/identifier.rb', line 25

def from_hash(data, options = {})
  klass = concrete_class_for(data)
  return klass.from_hash(data, options) if klass && klass != self
  super
end

.polymorphic_nameObject



251
252
253
254
255
256
257
258
259
260
261
# File 'lib/pubid/identifier.rb', line 251

def self.polymorphic_name
  return nil unless name

  parts = name.split("::")
  flavor = parts[1]&.downcase
  type_kebab = parts.last
    .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
    .gsub(/([a-z\d])([A-Z])/, '\1-\2')
    .downcase
  "pubid:#{flavor}:#{type_kebab}"
end

.polymorphic_type_mapObject

Map of polymorphic_name ("pubid:iso:corrigendum") => concrete class for the flavor this base belongs to. Built once by scanning the flavor's Identifiers namespace for Pubid::Identifier descendants and unioning the flavor's identifier_types registry (which may register classes living outside that namespace, e.g. ISO's BundledIdentifier). Memoized per class. Public so TypeResolver can read another flavor's map for cross-flavor polymorphic dispatch.



63
64
65
66
67
68
69
# File 'lib/pubid/identifier.rb', line 63

def polymorphic_type_map
  @polymorphic_type_map ||=
    identifier_registry_classes.each_with_object({}) do |klass, map|
      poly = klass.polymorphic_name
      map[poly] ||= klass if poly
    end
end

Instance Method Details

#baseObject

base is declared by supplement subclasses with proper type



151
152
153
# File 'lib/pubid/identifier.rb', line 151

def base
  nil
end

#base_documentObject

The underlying standard, with amendment / corrigendum / Expert-commentary / Flex-version wrappers peeled recursively. Wrapper subclasses override this; a plain identifier is its own base document.



158
159
160
# File 'lib/pubid/identifier.rb', line 158

def base_document
  self
end

#dated_version_of?(other) ⇒ Boolean

Self and other refer to the same document but differ only in the trailing date / year — the "dated vs undated" reference pattern.

Returns:

  • (Boolean)


524
525
526
527
528
# File 'lib/pubid/identifier.rb', line 524

def dated_version_of?(other)
  return false unless other.is_a?(::Pubid::Identifier)

  matches?(other, ignore: [:date, :year]) && self != other
end

#draft_of?(other) ⇒ Boolean

Self is a draft version of other (the published identifier). IEEE uses draft_status + draft; ISO/IEC use typed_stage.

Returns:

  • (Boolean)


551
552
553
554
555
556
557
# File 'lib/pubid/identifier.rb', line 551

def draft_of?(other)
  return false unless other.is_a?(::Pubid::Identifier)

  root == other.root &&
    self != other &&
    matches?(other, ignore: [:date, :year, :stage, :typed_stage])
end

#drop_supplementsObject

The standard this identifier supplements, dropping its own supplement layer (one level). Overridden by ConsolidatedIdentifier / Amendment / Corrigendum; a non-supplement identifier supplements nothing, so it is returned unchanged.



458
459
460
# File 'lib/pubid/identifier.rb', line 458

def drop_supplements
  self
end

#edition_of?(other) ⇒ Boolean

Self and other have the same publisher + number + part but differ in edition / revision marker.

Returns:

  • (Boolean)


561
562
563
564
565
566
567
568
# File 'lib/pubid/identifier.rb', line 561

def edition_of?(other)
  return false unless other.is_a?(::Pubid::Identifier)

  publisher == other.publisher &&
    number == other.number &&
    part == other.part &&
    self != other
end

#eql?(other) ⇒ Boolean

Returns:

  • (Boolean)


582
583
584
585
586
# File 'lib/pubid/identifier.rb', line 582

def eql?(other)
  return false unless other.is_a?(self.class)

  hash == other.hash && self == other
end

#exclude(*args) ⇒ Object

Excluded attributes are nilled; every other value is passed through #exclude_from_nested so the exclusion also propagates into nested identifiers — wrapper types (adopted standards, consolidated amendments, expert-commentary wrappers) delegate their date to an inner identifier rather than storing it in their own attribute.



428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
# File 'lib/pubid/identifier.rb', line 428

def exclude(*args)
  # :amendment / :supplement are structural, not attributes — they reduce
  # a supplemented identifier to the standard it wraps (#drop_supplements).
  supplement_keys = args & %i[amendment supplement]
  unless supplement_keys.empty?
    return drop_supplements.exclude(*(args - supplement_keys))
  end

  excluded_args = args.dup
  # Map :year to :date (year-in-date flavors), but ALSO keep :year so a
  # flavor that models the edition as a plain `year` attribute (e.g. GOST)
  # has it excluded too. The loop below nils whichever name the flavor has.
  excluded_args << :date if excluded_args.include?(:year)

  attrs = self.class.attributes.each_with_object({}) do |(name, _), h|
    value = excluded_args.include?(name) ? nil : public_send(name)
    h[name] = exclude_from_nested(value, args)
  end
  # Splat the rebuilt attributes as keywords. Flavors whose identifier
  # #initialize is keyword-only (ITU, IEEE, …) would raise ArgumentError on
  # a positional hash under Ruby 3 kwarg separation; the base
  # initialize(attrs = {}, options = {}) still accepts **attrs because Ruby
  # passes keywords to a no-keyword method as a trailing positional Hash.
  self.class.new(**attrs)
end

#has_supplement?(other) ⇒ Boolean

Inverse of #supplement_of? — self is the base document and other is a supplement attached to it.

Returns:

  • (Boolean)


518
519
520
# File 'lib/pubid/identifier.rb', line 518

def has_supplement?(other)
  other.is_a?(::Pubid::Identifier) && other.supplement_of?(self)
end

#hashObject



578
579
580
# File 'lib/pubid/identifier.rb', line 578

def hash
  @hash ||= compute_hash
end

#includes?(other) ⇒ Boolean

Self is an all-parts collection that covers other.

Returns:

  • (Boolean)


542
543
544
545
546
547
# File 'lib/pubid/identifier.rb', line 542

def includes?(other)
  return false unless other.is_a?(::Pubid::Identifier)
  return false unless all_parts

  root == other.root
end

#matches?(other, ignore: []) ⇒ Boolean

Fuzz-level equality: two identifiers match when they are equal after excluding the given aspects. ignore accepts the same symbols as #exclude (e.g. :date, :edition, :amendment). This is the primitive relaton uses to match a reference against catalogue hits at varying strictness.

Returns:

  • (Boolean)


466
467
468
469
470
# File 'lib/pubid/identifier.rb', line 466

def matches?(other, ignore: [])
  return false unless other.is_a?(::Pubid::Identifier)

  exclude(*ignore) == other.exclude(*ignore)
end

#mr_all_partsObject



382
383
384
385
386
# File 'lib/pubid/identifier.rb', line 382

def mr_all_parts
  return nil unless all_parts

  "all-parts"
end

#mr_editionObject



369
370
371
372
373
# File 'lib/pubid/identifier.rb', line 369

def mr_edition
  return nil unless edition&.number

  "ed#{edition.number}"
end

#mr_languagesObject



375
376
377
378
379
380
# File 'lib/pubid/identifier.rb', line 375

def mr_languages
  return nil unless languages&.any?

  # Hyphen-joined, no parens — parens are shell-/URL-unsafe.
  languages.map { |l| l.code.to_s.downcase }.join("-")
end

#mr_numberObject



346
347
348
# File 'lib/pubid/identifier.rb', line 346

def mr_number
  number&.to_s&.downcase
end

#mr_number_with_partObject



336
337
338
339
340
341
342
343
344
# File 'lib/pubid/identifier.rb', line 336

def mr_number_with_part
  num = mr_number
  return nil unless num

  segments = [num]
  segments << mr_part if part
  segments << mr_subpart if subpart
  segments.compact.join("-").downcase
end

#mr_partObject



350
351
352
# File 'lib/pubid/identifier.rb', line 350

def mr_part
  part&.to_s&.downcase
end

#mr_publisherObject

MR string template methods — flavors override as needed.

The MR format is a lossless, dot-separated, all-lowercase, filename-safe slug mirroring to_s's structure:

{publisher}[-{copublisher}…][.{type}].{number}[-{part}[-{subpart}…]]
[.{year}[-{month}[-{day}]]|’--’}][.{edition}][.{language}-{language}…]
[.all-parts][_supplements…]

Supplements append _{type}.{number}.{year} recursively, so a chained supplement like …/Amd 3:2016/Cor 1:2017 round-trips as …_amd.3.2016_cor.1.2017. Copublishers join with - (iso-iec.17031-1.2020) so the slug never contains a path separator. Distinct identifiers never collide on to_mr_string (issue #142).



323
324
325
# File 'lib/pubid/identifier.rb', line 323

def mr_publisher
  publisher&.to_s&.downcase&.tr("/", "-")
end

#mr_subpartObject



354
355
356
# File 'lib/pubid/identifier.rb', line 354

def mr_subpart
  subpart&.to_s&.downcase
end

#mr_supplement_suffixObject

Hook: supplement / wrapper subclasses return the {type}.{number}.{year} suffix that distinguishes them from their base. nil for non-supplements. The base MrString renderer recurses into base whenever this returns a non-nil value, so chained supplements (Cor → Amd → IS) render fully. The suffix is appended with _ (not /) so the MR stays filename-safe.



393
394
395
# File 'lib/pubid/identifier.rb', line 393

def mr_supplement_suffix
  nil
end

#mr_typeObject



327
328
329
330
331
332
333
334
# File 'lib/pubid/identifier.rb', line 327

def mr_type
  return nil unless typed_stage

  code = typed_stage.type_code
  return nil if code.nil? || code.empty? || code.to_s == "is"

  code.to_s.downcase
end

#mr_yearObject



358
359
360
361
362
363
364
365
366
367
# File 'lib/pubid/identifier.rb', line 358

def mr_year
  return nil unless date
  return "--" if date.respond_to?(:undated?) && date.undated?
  return nil unless date.year

  result = date.year.to_s
  result += "-#{date.month}" if date.respond_to?(:month) && date.month
  result += "-#{date.day}" if date.respond_to?(:day) && date.day
  result
end

#new_edition_of?(other) ⇒ Boolean

Returns:

  • (Boolean)


472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
# File 'lib/pubid/identifier.rb', line 472

def new_edition_of?(other)
  unless publisher == other.publisher
    raise ArgumentError,
          "Cannot compare edition: different publisher"
  end
  unless number == other.number
    raise ArgumentError,
          "Cannot compare edition: different number"
  end
  unless part == other.part
    raise ArgumentError,
          "Cannot compare edition: different part"
  end

  unless date && other.date
    raise ArgumentError,
          "Cannot compare identifier without date/year"
  end

  return date.year > other.date.year if date.year != other.date.year

  if edition && other.edition
    return edition.number > other.edition.number
  end

  false
end

Any relational predicate holds.

Returns:

  • (Boolean)


571
572
573
574
575
576
# File 'lib/pubid/identifier.rb', line 571

def related_to?(other)
  %i[supplement_of? has_supplement? dated_version_of? edition_of?
     sibling_of? includes? draft_of?].any? do |m|
    public_send(m, other)
  end
end

#render(format: :human, **opts) ⇒ Object

Unified render — delegates to format registry



270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/pubid/identifier.rb', line 270

def render(format: :human, **opts)
  registry = self.class.format_registry
  unless registry
    raise ArgumentError, "No format registry configured on #{self.class}"
  end

  renderer = registry.renderer_for(format)
  unless renderer
    raise ArgumentError, "No renderer registered for format: #{format}"
  end

  # `:trademark` is a render-time flag consumed by the renderer, not the
  # rendering context — and some flavors override build_rendering_context
  # with a strict signature, so strip it here.
  ctx_opts = opts.except(:trademark)
  context = build_rendering_context(renderer, format:, **ctx_opts)
  render_opts = opts.slice(:with_edition, :trademark)
  renderer.new(self).render(context:, **render_opts)
end

#resolve_urn_generatorObject



416
417
418
419
420
421
# File 'lib/pubid/identifier.rb', line 416

def resolve_urn_generator
  flavor = self.class.name.split("::")[1]
  Object.const_get("Pubid::#{flavor}::UrnGenerator")
rescue NameError
  Pubid::UrnGenerator::Base
end

#rootObject



263
264
265
266
267
# File 'lib/pubid/identifier.rb', line 263

def root
  return base.root if base

  self
end

#sibling_of?(other) ⇒ Boolean

Self and other have the same publisher + number but differ in part, subpart, or edition. This is the "sibling documents" pattern: same base standard, different sub-component.

Returns:

  • (Boolean)


533
534
535
536
537
538
539
# File 'lib/pubid/identifier.rb', line 533

def sibling_of?(other)
  return false unless other.is_a?(::Pubid::Identifier)

  publisher == other.publisher &&
    number == other.number &&
    self != other
end

#supplement_of?(other) ⇒ Boolean

Self is a supplement (amendment / corrigendum / errata / supplement) of other. Self must be a wrapper (has a base attribute) whose inner base equals other.

Returns:

  • (Boolean)


509
510
511
512
513
514
# File 'lib/pubid/identifier.rb', line 509

def supplement_of?(other)
  return false unless other.is_a?(::Pubid::Identifier)
  return false unless self.class.attributes.key?(:base)

  base == other
end

#to_hash(*args) ⇒ Object

Canonicalize the serialized hash so it never carries a defaulted attribute still at its default (or empty) value. This makes to_hash a pure function of the identifier's values, independent of how the object was built.

Motivation: lutaml materializes each attribute's default as an explicit assignment during deserialization (flipping using_default? to false), so a naive from_hash(x).to_hash re-emits defaults that parse(x).to_hash omits — breaking the exact-equality round-trip relaton-index relies on (from_hash(raw).to_hash == raw). pubid never consults lutaml's unset tracking and defines no render_default: true, so a defaulted attribute at its default/empty value carries no meaning and does not belong in the canonical hash. Dropping it here (rather than repairing from_hash) fixes the round-trip through every construction path — parse, from_hash, manual.



180
181
182
183
184
# File 'lib/pubid/identifier.rb', line 180

def to_hash(*args)
  hash = super
  canonicalize_hash(self, hash) if hash.is_a?(::Hash)
  hash
end

#to_mr_stringObject



294
295
296
# File 'lib/pubid/identifier.rb', line 294

def to_mr_string
  render(format: :mr_string)
end

#to_s(**opts) ⇒ Object



290
291
292
# File 'lib/pubid/identifier.rb', line 290

def to_s(**opts)
  render(format: :human, **opts)
end

#to_slugObject

Filesystem-/URL-safe slug derived from the MR string. Defaults to to_mr_string because every flavor except NIST already emits an all-lowercase, filename-safe MR (only [a-z0-9.-] characters, plus _ as the supplement separator and - as the copublisher separator). Flavors whose MR is not slug-safe (notably NIST, whose MR format is fixed by the pubid standard) override this to project the MR into a slug-safe form.



305
306
307
# File 'lib/pubid/identifier.rb', line 305

def to_slug
  to_mr_string
end

#to_supplement_s(**opts) ⇒ Object

Supplement rendering hook — flavors override for supplement-specific rendering



407
408
409
# File 'lib/pubid/identifier.rb', line 407

def to_supplement_s(**opts)
  to_s(**opts)
end

#to_urnObject

Default URN generation — resolves flavor's UrnGenerator class



412
413
414
# File 'lib/pubid/identifier.rb', line 412

def to_urn
  resolve_urn_generator.new(self).generate
end

#urn_supplement_typeObject



402
403
404
# File 'lib/pubid/identifier.rb', line 402

def urn_supplement_type
  nil
end

#urn_type_codeObject

URN template methods — flavors override as needed



398
399
400
# File 'lib/pubid/identifier.rb', line 398

def urn_type_code
  nil
end

#yearString?

Returns publication year from the date component.

Returns:

  • (String, nil)

    publication year from the date component



163
164
165
# File 'lib/pubid/identifier.rb', line 163

def year
  date&.year&.to_s
end