Skip to content

Commit f40aa20

Browse files
committed
Support attribute-level dependencies on item collections
1 parent 0bf1929 commit f40aa20

File tree

10 files changed

+257
-5
lines changed

10 files changed

+257
-5
lines changed

nanoc-cli/lib/nanoc/cli/commands/show-data.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,17 @@ def describe_identifiable_collection_dependency(dep)
151151
outcome << 'matching any attribute'
152152
when Set
153153
dep.props.attributes.each do |elem|
154-
outcome << "matching attribute #{elem.inspect}"
154+
case elem
155+
when Symbol
156+
outcome << "matching attribute #{elem.inspect} (any value)"
157+
when Array
158+
outcome << "matching attribute pair #{elem[0].inspect} => #{elem[1].inspect}"
159+
else
160+
raise(
161+
Nanoc::Core::Errors::InternalInconsistency,
162+
"unexpected prop attribute element #{elem.inspect}",
163+
)
164+
end
155165
end
156166
else
157167
raise(

nanoc-core/lib/nanoc/core/dependency_props.rb

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,18 @@ class DependencyProps
1919

2020
C_ATTR =
2121
C::Or[
22-
C::SetOf[Symbol],
23-
C::ArrayOf[Symbol],
22+
C::SetOf[
23+
C::Or[
24+
Symbol, # any value
25+
[Symbol, C::Any] # pair (specific value)
26+
],
27+
],
28+
C::ArrayOf[
29+
C::Or[
30+
Symbol, # any value
31+
[Symbol, C::Any] # pair (specific value)
32+
],
33+
],
2434
C::Bool
2535
]
2636

@@ -66,6 +76,23 @@ def inspect
6676
s << (attributes? ? 'a' : '_')
6777
s << (compiled_content? ? 'c' : '_')
6878
s << (path? ? 'p' : '_')
79+
80+
if @raw_content.is_a?(Set)
81+
@raw_content.each do |elem|
82+
s << '; raw_content('
83+
s << elem.inspect
84+
s << ')'
85+
end
86+
end
87+
88+
if @attributes.is_a?(Set)
89+
@attributes.each do |elem|
90+
s << '; attr('
91+
s << elem.inspect
92+
s << ')'
93+
end
94+
end
95+
6996
s << ')'
7097
end
7198
end
@@ -156,6 +183,15 @@ def active
156183
end
157184
end
158185

186+
def attribute_keys
187+
case @attributes
188+
when Enumerable
189+
@attributes.map { |a| a.is_a?(Array) ? a.first : a }
190+
else
191+
[]
192+
end
193+
end
194+
159195
contract C::None => Hash
160196
def to_h
161197
{

nanoc-core/lib/nanoc/core/dependency_store.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class DependencyStore < ::Nanoc::Core::Store
1515
C_ATTR =
1616
C::Or[
1717
C::ArrayOf[Symbol],
18+
C::HashOf[Symbol => C::Any],
1819
C::Bool
1920
]
2021

@@ -140,6 +141,11 @@ def record_dependency(src, dst, raw_content: false, attributes: false, compiled_
140141
src_ref = obj2ref(src)
141142
dst_ref = obj2ref(dst)
142143

144+
# Convert attributes into key-value pairs, if necessary
145+
if attributes.is_a?(Hash)
146+
attributes = attributes.to_a
147+
end
148+
143149
existing_props = @graph.props_for(dst_ref, src_ref)
144150
new_props = Nanoc::Core::DependencyProps.new(raw_content: raw_content, attributes: attributes, compiled_content: compiled_content, path: path)
145151
props = existing_props ? existing_props.merge(new_props) : new_props

nanoc-core/lib/nanoc/core/dependency_tracker.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class DependencyTracker
2323
C_ATTR =
2424
C::Or[
2525
C::ArrayOf[Symbol],
26+
C::HashOf[Symbol => C::Any],
2627
C::Bool
2728
]
2829

nanoc-core/lib/nanoc/core/feature.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,5 @@ def self.all_outdated
9090
end
9191
end
9292
end
93+
94+
Nanoc::Core::Feature.define('where', version: '4.12')

nanoc-core/lib/nanoc/core/identifiable_collection_view.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,34 @@ def find_all(arg = NOTHING, &block)
6969
@objects.find_all(arg).map { |i| view_class.new(i, @context) }
7070
end
7171

72+
# Finds all objects that have the given attribute key/value pair.
73+
#
74+
# @example
75+
#
76+
# @items.where(kind: 'article')
77+
# @items.where(kind: 'article', year: 2020)
78+
#
79+
# @return [Enumerable]
80+
def where(**hash)
81+
unless Nanoc::Core::Feature.enabled?(Nanoc::Core::Feature::WHERE)
82+
raise(
83+
Nanoc::Core::TrivialError,
84+
'#where is experimental, and not yet available unless the corresponding feature flag is enabled. Set NANOC_FEATURES=where or NANOC_FEATURES=all to enable its usage.',
85+
)
86+
end
87+
88+
@context.dependency_tracker.bounce(_unwrap, attributes: hash)
89+
90+
# IDEA: Nanoc could remember (from the previous compilation) how many
91+
# times #where is called with a given attribute key, and memoize the
92+
# key-to-identifiers list.
93+
found_objects = @objects.select do |i|
94+
hash.all? { |k, v| i.attributes[k] == v }
95+
end
96+
97+
found_objects.map { |i| view_class.new(i, @context) }
98+
end
99+
72100
# @overload [](string)
73101
#
74102
# Finds the object whose identifier matches the given string.

nanoc-core/lib/nanoc/core/outdatedness_checker.rb

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,9 @@ def dependency_causes_outdatedness?(dependency)
187187
true
188188
when Nanoc::Core::ItemCollection, Nanoc::Core::LayoutCollection
189189
all_objects = dependency.from
190-
raw_content_prop_causes_outdatedness?(all_objects, dependency.props.raw_content)
190+
191+
raw_content_prop_causes_outdatedness?(all_objects, dependency.props.raw_content) ||
192+
attributes_prop_causes_outdatedness?(all_objects, dependency.props.attributes)
191193
else
192194
status = basic.outdatedness_status_for(dependency.from)
193195

@@ -200,10 +202,12 @@ def dependency_causes_outdatedness?(dependency)
200202

201203
def attributes_unaffected?(status, dependency)
202204
reason = status.reasons.find { |r| r.is_a?(Nanoc::Core::OutdatednessReasons::AttributesModified) }
203-
reason && dependency.props.attributes.is_a?(Enumerable) && (dependency.props.attributes & reason.attributes).empty?
205+
reason && dependency.props.attribute_keys.any? && (dependency.props.attribute_keys & reason.attributes).empty?
204206
end
205207

206208
def raw_content_prop_causes_outdatedness?(objects, raw_content_prop)
209+
return false unless raw_content_prop
210+
207211
matching_objects =
208212
case raw_content_prop
209213
when true
@@ -236,6 +240,49 @@ def raw_content_prop_causes_outdatedness?(objects, raw_content_prop)
236240
status.reasons.any? { |r| Nanoc::Core::OutdatednessReasons::DocumentAdded == r }
237241
end
238242
end
243+
244+
def attributes_prop_causes_outdatedness?(objects, attributes_prop)
245+
return false unless attributes_prop
246+
247+
unless attributes_prop.is_a?(Set)
248+
raise 'not expected'
249+
end
250+
251+
pairs = attributes_prop.select { |a| a.is_a?(Array) }.to_h
252+
253+
unless pairs.any?
254+
raise 'not expected'
255+
end
256+
257+
dep_checksums = pairs.transform_values { |value| Nanoc::Core::Checksummer.calc(value) }
258+
259+
objects.any? do |object|
260+
# Find old and new attribute checksums for the object
261+
old_object_checksums = checksum_store.attributes_checksum_for(object)
262+
next false unless old_object_checksums
263+
264+
new_object_checksums = checksums.attributes_checksum_for(object)
265+
266+
# Ignore any attribute not mentioned in the dependency
267+
old_object_checksums = old_object_checksums.select { |k, _v| dep_checksums.key?(k) }
268+
new_object_checksums = new_object_checksums.select { |k, _v| dep_checksums.key?(k) }
269+
270+
dep_checksums.any? do |key, dep_value|
271+
# Get old and new checksum for this particular attribute
272+
old_value = old_object_checksums[key]
273+
new_value = new_object_checksums[key]
274+
275+
# If either the old or new vale match the value in the dependency,
276+
# then a potential change is relevant to us, and can cause
277+
# outdatedness.
278+
is_match = [old_value, new_value].include?(dep_value)
279+
280+
is_changed = old_value != new_value
281+
282+
is_match && is_changed
283+
end
284+
end
285+
end
239286
end
240287
end
241288
end

nanoc-core/lib/nanoc/core/outdatedness_status.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ def update(reason)
2222
props: @props.merge(reason.props),
2323
)
2424
end
25+
26+
def inspect
27+
"<#{self.class} reasons=#{@reasons.inspect} props=#{@props.inspect}>"
28+
end
2529
end
2630
end
2731
end

nanoc-core/spec/nanoc/core/outdatedness_checker_spec.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,56 @@
675675

676676
it { is_expected.to be(false) }
677677
end
678+
679+
context 'dependency on specific new items (attribute)' do
680+
before do
681+
dependency_tracker = Nanoc::Core::DependencyTracker.new(dependency_store)
682+
dependency_tracker.enter(item)
683+
dependency_tracker.bounce(items, attributes: { kind: 'note' })
684+
dependency_store.store
685+
end
686+
687+
context 'nothing changed' do
688+
it { is_expected.to be(false) }
689+
end
690+
691+
context 'matching item added' do
692+
let(:new_item) { Nanoc::Core::Item.new('stuff', { kind: 'note' }, '/new-note.md') }
693+
let(:new_item_rep) { Nanoc::Core::ItemRep.new(new_item, :default) }
694+
695+
let(:action_sequences) do
696+
super().merge({ new_item_rep => old_action_sequence_for_item_rep })
697+
end
698+
699+
before do
700+
reps << new_item_rep
701+
702+
dependency_store.items = Nanoc::Core::ItemCollection.new(config, items.to_a + [new_item])
703+
dependency_store.load
704+
end
705+
706+
it { is_expected.to be(true) }
707+
end
708+
709+
context 'non-matching item added' do
710+
before do
711+
new_item = Nanoc::Core::Item.new('stuff', { kind: 'article' }, '/new-article.md')
712+
dependency_store.items = Nanoc::Core::ItemCollection.new(config, items.to_a + [new_item])
713+
dependency_store.load
714+
end
715+
716+
it { is_expected.to be(false) }
717+
end
718+
719+
context 'item removed' do
720+
before do
721+
dependency_store.items = Nanoc::Core::ItemCollection.new(config, [])
722+
dependency_store.load
723+
end
724+
725+
it { is_expected.to be(false) }
726+
end
727+
end
678728
end
679729
end
680730

nanoc-core/spec/nanoc/core/support/identifiable_collection_view_examples.rb

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,4 +328,72 @@ def initialize; end
328328
end
329329
end
330330
end
331+
332+
describe '#where' do
333+
around do |ex|
334+
Nanoc::Core::Feature.enable('where') { ex.run }
335+
end
336+
337+
let(:wrapped) do
338+
collection_class.new(
339+
config,
340+
[
341+
double(
342+
:identifiable,
343+
identifier: Nanoc::Core::Identifier.new('/bare.md'),
344+
attributes: {},
345+
),
346+
double(
347+
:identifiable,
348+
identifier: Nanoc::Core::Identifier.new('/note.md'),
349+
attributes: { kind: 'note' },
350+
),
351+
double(
352+
:identifiable,
353+
identifier: Nanoc::Core::Identifier.new('/note-2020.md'),
354+
attributes: { kind: 'note', year: 2020 },
355+
),
356+
double(
357+
:identifiable,
358+
identifier: Nanoc::Core::Identifier.new('/note-2021.md'),
359+
attributes: { kind: 'note', year: 2021 },
360+
),
361+
],
362+
)
363+
end
364+
365+
context 'with one attribute' do
366+
subject { view.where(kind: 'note') }
367+
368+
it 'creates dependency' do
369+
expect(dependency_tracker).to receive(:bounce).with(wrapped, attributes: { kind: 'note' })
370+
subject
371+
end
372+
373+
it 'contains views' do
374+
expect(subject.size).to be(3)
375+
note = subject.find { |iv| iv.identifier == '/note.md' }
376+
note2020 = subject.find { |iv| iv.identifier == '/note-2020.md' }
377+
note2021 = subject.find { |iv| iv.identifier == '/note-2021.md' }
378+
expect(note.class).to equal(view_class)
379+
expect(note2020.class).to equal(view_class)
380+
expect(note2021.class).to equal(view_class)
381+
end
382+
end
383+
384+
context 'with two attributes' do
385+
subject { view.where(kind: 'note', year: 2020) }
386+
387+
it 'creates dependency' do
388+
expect(dependency_tracker).to receive(:bounce).with(wrapped, attributes: { kind: 'note', year: 2020 })
389+
subject
390+
end
391+
392+
it 'contains views' do
393+
expect(subject.size).to be(1)
394+
note2020 = subject.find { |iv| iv.identifier == '/note-2020.md' }
395+
expect(note2020.class).to equal(view_class)
396+
end
397+
end
398+
end
331399
end

0 commit comments

Comments
 (0)