Skip to content
This repository has been archived by the owner on May 11, 2022. It is now read-only.

Commit

Permalink
Merge pull request #446 from sul-dlss/tag-service
Browse files Browse the repository at this point in the history
Extract TagService from Identifiable
  • Loading branch information
justinlittman committed Dec 21, 2018
2 parents 602e0a8 + 24d419e commit 2473154
Show file tree
Hide file tree
Showing 5 changed files with 216 additions and 105 deletions.
1 change: 1 addition & 0 deletions lib/dor-services.rb
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ def logger
autoload :CreativeCommonsLicenseService
autoload :OpenDataLicenseService
autoload :VersionService
autoload :TagService
end

# Workflow Classes
Expand Down
63 changes: 6 additions & 57 deletions lib/dor/models/concerns/identifiable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -107,73 +107,22 @@ def remove_other_Id(type, val = nil)
.any?
end

# turns a tag string into an array with one element per tag part.
# split on ":", disregard leading and trailing whitespace on tokens.
def split_tag_to_arr(tag_str)
tag_str.split(':').map(&:strip)
end

# turn a tag array back into a tag string with a standard format
def normalize_tag_arr(tag_arr)
tag_arr.join(' : ')
end

# take a tag string and return a normalized tag string
def normalize_tag(tag_str)
normalize_tag_arr(split_tag_to_arr(tag_str))
end

# take a proposed tag string and a list of the existing tags for the object being edited. if
# the proposed tag is valid, return it in normalized form. if not, raise an exception with an
# explanatory message.
def validate_and_normalize_tag(tag_str, existing_tag_list)
tag_arr = validate_tag_format(tag_str)

# note that the comparison for duplicate tags is case-insensitive, but we don't change case as part of the normalized version
# we return, because we want to preserve the user's intended case.
normalized_tag = normalize_tag_arr(tag_arr)
dupe_existing_tag = existing_tag_list.detect { |existing_tag| normalize_tag(existing_tag).casecmp(normalized_tag) == 0 }
raise "An existing tag (#{dupe_existing_tag}) is the same, consider using update_tag?" if dupe_existing_tag

normalized_tag
end

# Ensure that an administrative tag meets the proper mininum format
# @param tag_str [String] the tag
# @return [Array] the tag split into an array via ':'
def validate_tag_format(tag_str)
tag_arr = split_tag_to_arr(tag_str)
raise ArgumentError, "Invalid tag structure: tag '#{tag_str}' must have at least 2 elements" if tag_arr.length < 2
raise ArgumentError, "Invalid tag structure: tag '#{tag_str}' contains empty elements" if tag_arr.detect(&:empty?)

tag_arr
end

# Add an administrative tag to an item, you will need to seperately save the item to write it to fedora
# @param tag [string] The tag you wish to add
def add_tag(tag)
identity_metadata_ds = identityMetadata
normalized_tag = validate_and_normalize_tag(tag, identity_metadata_ds.tags)
identity_metadata_ds.add_value(:tag, normalized_tag)
TagService.add(self, tag)
end
deprecation_deprecate add_tag: 'Call TagService.add instead'

def remove_tag(tag)
normtag = normalize_tag(tag)
identityMetadata.ng_xml.search('//tag')
.select { |node| normalize_tag(node.content) == normtag }
.each { identityMetadata.ng_xml_will_change! }
.each(&:remove)
.any?
TagService.remove(self, tag)
end
deprecation_deprecate remove_tag: 'Call TagService.remove instead'

def update_tag(old_tag, new_tag)
normtag = normalize_tag(old_tag)
identityMetadata.ng_xml.search('//tag')
.select { |node| normalize_tag(node.content) == normtag }
.each { identityMetadata.ng_xml_will_change! }
.each { |node| node.content = normalize_tag(new_tag) }
.any?
TagService.update(self, old_tag, new_tag)
end
deprecation_deprecate update_tag: 'Call TagService.update instead'

# a regex that can be used to identify the last part of a druid (e.g. oo000oo0001)
# @return [Regex] a regular expression to identify the ID part of the druid
Expand Down
100 changes: 100 additions & 0 deletions lib/dor/services/tag_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# frozen_string_literal: true

module Dor
# Manage tags on an object
class TagService
def self.add(item, tag)
new(item).add(tag)
end

def self.remove(item, tag)
new(item).remove(tag)
end

def self.update(item, old_tag, new_tag)
new(item).update(old_tag, new_tag)
end

def initialize(item)
@item = item
end

# Add an administrative tag to an item, you will need to seperately save the item to write it to fedora
# @param tag [string] The tag you wish to add
def add(tag)
normalized_tag = validate_and_normalize_tag(tag, identity_metadata.tags)
identity_metadata.add_value(:tag, normalized_tag)
end

def remove(tag)
normtag = normalize_tag(tag)
tag_nodes
.select { |node| normalize_tag(node.content) == normtag }
.each { identity_metadata.ng_xml_will_change! }
.each(&:remove)
.any?
end

def update(old_tag, new_tag)
normtag = normalize_tag(old_tag)
tag_nodes
.select { |node| normalize_tag(node.content) == normtag }
.each { identity_metadata.ng_xml_will_change! }
.each { |node| node.content = normalize_tag(new_tag) }
.any?
end

private

attr_reader :item
def identity_metadata
item.identityMetadata
end

def tag_nodes
identity_metadata.ng_xml.search('//tag')
end

# turns a tag string into an array with one element per tag part.
# split on ":", disregard leading and trailing whitespace on tokens.
def split_tag_to_arr(tag_str)
tag_str.split(':').map(&:strip)
end

# turn a tag array back into a tag string with a standard format
def normalize_tag_arr(tag_arr)
tag_arr.join(' : ')
end

# take a tag string and return a normalized tag string
def normalize_tag(tag_str)
normalize_tag_arr(split_tag_to_arr(tag_str))
end

# take a proposed tag string and a list of the existing tags for the object being edited. if
# the proposed tag is valid, return it in normalized form. if not, raise an exception with an
# explanatory message.
def validate_and_normalize_tag(tag_str, existing_tag_list)
tag_arr = validate_tag_format(tag_str)

# note that the comparison for duplicate tags is case-insensitive, but we don't change case as part of the normalized version
# we return, because we want to preserve the user's intended case.
normalized_tag = normalize_tag_arr(tag_arr)
dupe_existing_tag = existing_tag_list.detect { |existing_tag| normalize_tag(existing_tag).casecmp(normalized_tag) == 0 }
raise "An existing tag (#{dupe_existing_tag}) is the same, consider using update_tag?" if dupe_existing_tag

normalized_tag
end

# Ensure that an administrative tag meets the proper mininum format
# @param tag_str [String] the tag
# @return [Array] the tag split into an array via ':'
def validate_tag_format(tag_str)
tag_arr = split_tag_to_arr(tag_str)
raise ArgumentError, "Invalid tag structure: tag '#{tag_str}' must have at least 2 elements" if tag_arr.length < 2
raise ArgumentError, "Invalid tag structure: tag '#{tag_str}' contains empty elements" if tag_arr.detect(&:empty?)

tag_arr
end
end
end
61 changes: 13 additions & 48 deletions spec/models/concerns/identifiable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ class IdentifiableItem < ActiveFedora::Base
include Dor::Identifiable
end

describe Dor::Identifiable do
before(:each) { stub_config }
after(:each) { unstub_config }
RSpec.describe Dor::Identifiable do
before { stub_config }
after { unstub_config }

let(:item) do
item = instantiate_fixture('druid:ab123cd4567', IdentifiableItem)
Expand Down Expand Up @@ -185,30 +185,18 @@ class IdentifiableItem < ActiveFedora::Base
# when doing the add/update/removal, specify the tag in non-normalized form so that the
# normalization mechanism actually gets tested.
describe 'add_tag' do
it 'should add a new tag' do
it 'delegates to TagService' do
expect(Deprecation).to receive(:warn)
expect(Dor::TagService).to receive(:add).with(item, 'sometag:someval')
item.add_tag('sometag:someval')
expect(item.identityMetadata.tags.include?('sometag : someval')).to be_truthy
expect(item.identityMetadata).to be_changed
end
it 'should raise an exception if there is an existing tag like it' do
item.add_tag('sometag:someval')
expect(item.identityMetadata.tags.include?('sometag : someval')).to be_truthy
expect { item.add_tag('sometag: someval') }.to raise_error(RuntimeError)
end
end

describe 'update_tag' do
it 'should update a tag' do
item.add_tag('sometag:someval')
expect(item.identityMetadata.tags.include?('sometag : someval')).to be_truthy
expect(item.update_tag('sometag :someval', 'new :tag')).to be_truthy
expect(item.identityMetadata.tags.include?('sometag : someval')).to be_falsey
expect(item.identityMetadata.tags.include?('new : tag')).to be_truthy
expect(item.identityMetadata).to be_changed
end
it 'should return false if there is no matching tag to update' do
expect(item.update_tag('sometag:someval', 'new:tag')).to be_falsey
expect(item.identityMetadata).not_to be_changed
it 'delegates to TagService' do
expect(Deprecation).to receive(:warn)
expect(Dor::TagService).to receive(:update).with(item, 'sometag :someval', 'new :tag')
item.update_tag('sometag :someval', 'new :tag')
end
end

Expand Down Expand Up @@ -255,33 +243,10 @@ class IdentifiableItem < ActiveFedora::Base
end

describe 'remove_tag' do
it 'should delete a tag' do
it 'delegates to TagService' do
expect(Deprecation).to receive(:warn)
expect(Dor::TagService).to receive(:add).with(item, 'sometag:someval')
item.add_tag('sometag:someval')
expect(item.identityMetadata.tags.include?('sometag : someval')).to be_truthy
expect(item.remove_tag('sometag:someval')).to be_truthy
expect(item.identityMetadata.tags.include?('sometag : someval')).to be_falsey
expect(item.identityMetadata).to be_changed
end
end

describe 'validate_and_normalize_tag' do
it 'should throw an exception if tag has too few elements' do
tag_str = 'just one part'
expected_err_msg = "Invalid tag structure: tag '#{tag_str}' must have at least 2 elements"
expect { item.validate_and_normalize_tag(tag_str, []) }.to raise_error(ArgumentError, expected_err_msg)
end
it 'should throw an exception if tag has empty elements' do
tag_str = 'test part1 : : test part3'
expected_err_msg = "Invalid tag structure: tag '#{tag_str}' contains empty elements"
expect { item.validate_and_normalize_tag(tag_str, []) }.to raise_error(ArgumentError, expected_err_msg)
end
it 'should throw an exception if tag is the same as an existing tag' do
# note that tag_str should match existing_tags[1] because the comparison should happen after normalization, and it should
# be case-insensitive.
tag_str = 'another:multi:part:test'
existing_tags = ['test part1 : test part2', 'Another : Multi : Part : Test', 'one : last_tag']
expected_err_msg = "An existing tag (#{existing_tags[1]}) is the same, consider using update_tag?"
expect { item.validate_and_normalize_tag(tag_str, existing_tags) }.to raise_error(StandardError, expected_err_msg)
end
end

Expand Down
96 changes: 96 additions & 0 deletions spec/services/tag_service_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Dor::TagService do
let(:item) do
item = instantiate_fixture('druid:ab123cd4567', Dor::Item)
allow(item).to receive(:new?).and_return(false)
ds = item.identityMetadata
ds.instance_variable_set(:@datastream_content, item.identityMetadata.content)
allow(ds).to receive(:new?).and_return(false)
item
end

# when looking for tags after addition/update/removal, check for the normalized form.
# when doing the add/update/removal, specify the tag in non-normalized form so that the
# normalization mechanism actually gets tested.
describe '.add' do
subject(:add) { described_class.add(item, 'sometag:someval') }

it 'adds a new tag' do
add
expect(item.identityMetadata.tags.include?('sometag : someval')).to be_truthy
expect(item.identityMetadata).to be_changed
end

it 'raises an exception if there is an existing tag like it' do
described_class.add(item, 'sometag:someval')
expect(item.identityMetadata.tags.include?('sometag : someval')).to be_truthy
expect { add }.to raise_error(RuntimeError)
end
end

describe '.update' do
subject(:update) { described_class.update(item, 'sometag :someval', 'new :tag') }

it 'updates a tag' do
described_class.add(item, 'sometag:someval')
expect(item.identityMetadata.tags.include?('sometag : someval')).to be_truthy
expect(update).to be_truthy
expect(item.identityMetadata.tags.include?('sometag : someval')).to be_falsey
expect(item.identityMetadata.tags.include?('new : tag')).to be_truthy
expect(item.identityMetadata).to be_changed
end

it 'returns false if there is no matching tag to update' do
expect(update).to be_falsey
expect(item.identityMetadata).not_to be_changed
end
end

describe '.remove' do
subject(:remove) { described_class.remove(item, 'sometag:someval') }

it 'deletes a tag' do
described_class.add(item, 'sometag:someval')
expect(item.identityMetadata.tags.include?('sometag : someval')).to be_truthy
expect(remove).to be_truthy
expect(item.identityMetadata.tags.include?('sometag : someval')).to be_falsey
expect(item.identityMetadata).to be_changed
end
end

describe '#validate_and_normalize_tag' do
let(:service) { described_class.new(item) }
subject(:invoke) { service.send(:validate_and_normalize_tag, tag_str, existing_tags) }
let(:existing_tags) { [] }

context 'when the tag has too few elements' do
let(:tag_str) { 'just one part' }

it 'throws an exception' do
expect { invoke }.to raise_error(ArgumentError, "Invalid tag structure: tag '#{tag_str}' must have at least 2 elements")
end
end

context 'when the tag has empty elements' do
let(:tag_str) { 'test part1 : : test part3' }

it 'throws an exception' do
expect { invoke }.to raise_error(ArgumentError, "Invalid tag structure: tag '#{tag_str}' contains empty elements")
end
end

context 'when the tag is the same as an existing tag' do
let(:tag_str) { 'another:multi:part:test' }
let(:existing_tags) { ['test part1 : test part2', 'Another : Multi : Part : Test', 'one : last_tag'] }

it 'throws an exception' do
# note that tag_str should match existing_tags[1] because the comparison should happen after normalization, and it should
# be case-insensitive.
expect { invoke }.to raise_error(RuntimeError, "An existing tag (#{existing_tags[1]}) is the same, consider using update_tag?")
end
end
end
end

0 comments on commit 2473154

Please sign in to comment.