Skip to content

Commit

Permalink
Apply a schema to the holding model
Browse files Browse the repository at this point in the history
This helps us enforce the shape of the data and highlights the fields we think are important.
  • Loading branch information
jcoyne committed May 16, 2023
1 parent a989b41 commit 51e0930
Show file tree
Hide file tree
Showing 6 changed files with 135 additions and 14 deletions.
2 changes: 2 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,5 @@ group :deployment do
end

gem 'activesupport', '~> 7.0'

gem 'dry-struct', '~> 1.6'
7 changes: 7 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ GEM
dry-logic (>= 1.4, < 2)
dry-types (>= 1.7, < 2)
zeitwerk (~> 2.6)
dry-struct (1.6.0)
dry-core (~> 1.0, < 2)
dry-types (>= 1.7, < 2)
ice_nine (~> 0.11)
zeitwerk (~> 2.6)
dry-types (1.7.1)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
Expand Down Expand Up @@ -113,6 +118,7 @@ GEM
httpclient (2.8.3)
i18n (1.13.0)
concurrent-ruby (~> 1.0)
ice_nine (0.11.2)
io-console (0.6.0)
io-console (0.6.0-java)
irb (1.6.4)
Expand Down Expand Up @@ -277,6 +283,7 @@ DEPENDENCIES
debug
dlss-capistrano
dor-rights-auth
dry-struct (~> 1.6)
honeybadger
http
i18n
Expand Down
109 changes: 109 additions & 0 deletions lib/folio/holding.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

# This code may look unusually verbose for Ruby (and it is), but
# it performs some subtle and complex validation of JSON data.
#
# To parse this JSON, add 'dry-struct' and 'dry-types' gems, then do:
#
# holding = Holding.from_json! "{…}"
# puts holding.tags&.tag_list&.first
#
# If from_json! succeeds, the value returned matches the schema.

require 'json'
require 'dry-types'
require 'dry-struct'

module Folio
module Holding
module Types
include Dry.Types(default: :nominal)

Integer = Strict::Integer
Bool = Strict::Bool
Hash = Strict::Hash
String = Strict::String
end

class HoldingsType < Dry::Struct
# The identifier, a UUID
attribute :id, Types::String

attribute :name, Types::String

attribute :source, Types::String

def self.from_dynamic!(dyn)
dyn = Types::Hash[dyn]
new(id: dyn.fetch('id'),
name: dyn.fetch('name'),
source: dyn.fetch('source'))
end
end

class HoldingsStatement < Dry::Struct
# Note attached to a holdings statement
attribute :note, Types::String.optional

# Private note attached to a holdings statment
attribute :staff_note, Types::String.optional

# Specifices the exact content to which the library has access, typically for continuing
# publications.
attribute :statement, Types::String.optional

def self.from_dynamic!(dyn)
dyn = Types::Hash[dyn]
new(
note: dyn['note'],
staff_note: dyn['staffNote'],
statement: dyn['statement']
)
end

def self.from_json!(json)
from_dynamic!(JSON.parse(json))
end
end

# A holdings record
class Holding < Dry::Struct
# Notes about action, copy, binding etc.
attribute :holdings_statements, Types.Array(HoldingsStatement)

# Holdings record indexes statements
attribute :holdings_statements_for_indexes, Types.Array(HoldingsStatement)

# Holdings record supplements statements
attribute :holdings_statements_for_supplements, Types.Array(HoldingsStatement)

# The description of the holding type
attribute :holdings_type, HoldingsType

# the unique ID of the holdings record; UUID
attribute :id, Types::String

attribute :discovery_suppress, Types::Bool

# The effective shelving location in which an item resides
attribute :effective_location, Types::Hash.meta(of: Types::Any).optional

def self.from_dynamic!(dyn)
d = Types::Hash[dyn]
new(
holdings_statements: d.fetch('holdingsStatements').map { |x| HoldingsStatement.from_dynamic!(x) },
holdings_statements_for_indexes: d.fetch('holdingsStatementsForIndexes').map { |x| HoldingsStatement.from_dynamic!(x) },
holdings_statements_for_supplements: d.fetch('holdingsStatementsForSupplements').map { |x| HoldingsStatement.from_dynamic!(x) },
holdings_type: HoldingsType.from_dynamic!(d['holdingsType']),
id: d.fetch('id'),
discovery_suppress: d.fetch('suppressFromDiscovery'),
effective_location: Types::Hash.optional[d.dig('location', 'effectiveLocation')]&.transform_values { |v| Types::Any[v] }
)
end

def self.from_json!(json)
from_dynamic!(JSON.parse(json))
end
end
end
end
20 changes: 10 additions & 10 deletions lib/folio/mhld_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ def build
# Remove suppressed record and electronic records
def filtered_holdings
holdings.filter_map do |holding|
next if holding['suppressFromDiscovery'] || holding['holdingsType'] == 'Electronic'
next if holding.discovery_suppress || holding.holdings_type.name == 'Electronic'

note = holding.fetch('holdingsStatements').find { |statement| statement.key?('note') && !statement.key?('statement') }&.fetch('note')
note = holding.holdings_statements.find { |statement| statement.note && !statement.statement }&.note

library_has_for_holding(holding).map do |library_has|
{
id: holding.fetch('id'),
location: holding.dig('location', 'effectiveLocation'),
id: holding.id,
location: holding.effective_location,
note:,
library_has:
}
Expand All @@ -53,21 +53,21 @@ def library_has_for_holding(holding)

# @return [Array<String>] the list of statements
def statements_for_holding(holding)
holding.fetch('holdingsStatements').select { |statement| statement.key?('statement') }.map do |statement|
if statement['note'].present?
"#{statement.fetch('statement')} #{statement.fetch('note')}"
holding.holdings_statements.select(&:statement).map do |statement|
if statement.note.present?
"#{statement.statement} #{statement.note}"
else
statement.fetch('statement')
statement.statement
end
end + statments_for_index(holding) + statements_for_supplements(holding)
end

def statments_for_index(holding)
holding.fetch('holdingsStatementsForIndexes').filter_map { |statement| "Index: #{statement.fetch('statement')}" if statement.key?('statement') }
holding.holdings_statements_for_indexes.filter_map { |statement| "Index: #{statement.statement}" if statement.statement }
end

def statements_for_supplements(holding)
holding.fetch('holdingsStatementsForSupplements').filter_map { |statement| "Supplement: #{statement.fetch('statement')}" if statement.key?('statement') }
holding.holdings_statements_for_supplements.filter_map { |statement| "Supplement: #{statement.statement}" if statement.statement }
end

# @return [String] the latest received piece for a holding
Expand Down
3 changes: 2 additions & 1 deletion lib/folio_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require_relative 'locations_map'
require_relative 'folio/eresource_holdings_builder'
require_relative 'folio/mhld_builder'
require_relative 'folio/holding'

# rubocop:disable Metrics/ClassLength
class FolioRecord
Expand Down Expand Up @@ -89,7 +90,7 @@ def call_number_type_map(name)
# Creates the mhld_display value. This drives the holding display in searchworks.
# This packed format mimics how we indexed this data when we used Symphony.
def mhld
holdings.present? ? Folio::MhldBuilder.build(holdings, pieces) : []
holdings.present? ? Folio::MhldBuilder.build(holdings.map { |dyn| Folio::Holding::Holding.from_dynamic!(dyn) }, pieces) : []
end

def items
Expand Down
8 changes: 5 additions & 3 deletions spec/integration/folio_config_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
{ 'code' => 'SUL-ELECTRONIC' } },
'suppressFromDiscovery' => false,
'id' => '81a56270-e8dd-5759-8083-5cc96cdf0045',
'holdingsType' => { 'id' => '12312', 'name' => 'Unknown', 'source' => 'folio' },
'holdingsStatements' => [],
'holdingsStatementsForIndexes' => [],
'holdingsStatementsForSupplements' => [] }] }
Expand Down Expand Up @@ -135,6 +136,7 @@
{ 'code' => 'LAW-ELECTRONIC' } },
'suppressFromDiscovery' => false,
'id' => '81a56270-e8dd-5759-8083-5cc96cdf0045',
'holdingsType' => { 'id' => '12312', 'name' => 'Unknown', 'source' => 'folio' },
'holdingsStatements' => [],
'holdingsStatementsForIndexes' => [],
'holdingsStatementsForSupplements' => [] }] }
Expand Down Expand Up @@ -198,6 +200,7 @@
{ 'code' => 'LAW-BASEMENT' } },
'suppressFromDiscovery' => false,
'id' => '81a56270-e8dd-5759-8083-5cc96cdf0045',
'holdingsType' => { 'id' => '12312', 'name' => 'Unknown', 'source' => 'folio' },
'holdingsStatements' => [],
'holdingsStatementsForIndexes' => [],
'holdingsStatementsForSupplements' => [] }]
Expand Down Expand Up @@ -232,8 +235,7 @@
'institutionName' => 'Stanford University' },
'temporaryLocation' => {} },
'formerIds' => [],
'callNumber' => {},
'holdingsType' => 'Unknown',
'holdingsType' => { 'id' => '12312', 'name' => 'Unknown', 'source' => 'folio' },
'electronicAccess' => [],
'receivingHistory' => { 'entries' => [] },
'statisticalCodes' => [],
Expand Down Expand Up @@ -394,7 +396,7 @@
'temporaryLocation' => {} },
'formerIds' => [],
'callNumber' => { 'typeId' => '95467209-6d7b-468b-94df-0f5d7ad2747d', 'typeName' => 'Library of Congress classification', 'callNumber' => 'G1 .N27' },
'holdingsType' => 'Monograph',
'holdingsType' => { 'name' => 'Monograph', 'id' => '2131', 'source' => 'folio' },
'electronicAccess' => [],
'receivingHistory' => { 'entries' => [{ 'enumeration' => 'TEST', 'publicDisplay' => true }, nil] },
'statisticalCodes' => [],
Expand Down

0 comments on commit 51e0930

Please sign in to comment.