jekyll-relationships 0.1.1.alpha → 0.2.0.alpha
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.
- checksums.yaml +4 -4
- data/lib/jekyll-relationships/configuration/defaults.rb +1 -0
- data/lib/jekyll-relationships/configuration/frontmatter.rb +28 -1
- data/lib/jekyll-relationships/configuration/parser.rb +20 -3
- data/lib/jekyll-relationships/configuration/prune_rule_settings.rb +33 -5
- data/lib/jekyll-relationships/configuration.rb +43 -8
- data/lib/jekyll-relationships/definitions/normal_relationship.rb +8 -2
- data/lib/jekyll-relationships/definitions/prune_rule.rb +19 -3
- data/lib/jekyll-relationships/definitions/tree_relationship.rb +3 -2
- data/lib/jekyll-relationships/documents/registry.rb +182 -33
- data/lib/jekyll-relationships/engine/normal_seed.rb +9 -4
- data/lib/jekyll-relationships/engine/raw_path_state.rb +44 -22
- data/lib/jekyll-relationships/engine/relationship_state.rb +12 -0
- data/lib/jekyll-relationships/engine/session.rb +89 -15
- data/lib/jekyll-relationships/engine/write_back.rb +1 -0
- data/lib/jekyll-relationships/engine.rb +1 -1
- data/lib/jekyll-relationships/pruning/rule_pruner.rb +7 -7
- data/lib/jekyll-relationships/pruning/tree_phase.rb +29 -11
- data/lib/jekyll-relationships/pruning/tree_provenance.rb +1 -1
- data/lib/jekyll-relationships/references/accumulator.rb +18 -6
- data/lib/jekyll-relationships/references/template.rb +46 -11
- data/lib/jekyll-relationships/resolvers/base.rb +3 -0
- data/lib/jekyll-relationships/support/frontmatter_matcher.rb +76 -0
- data/lib/jekyll-relationships/support/placeholders.rb +1 -0
- data/lib/jekyll-relationships/trees/edge_builder.rb +43 -11
- data/lib/jekyll-relationships/trees/graph.rb +30 -13
- data/lib/jekyll-relationships/version.rb +1 -1
- data/lib/jekyll-relationships.rb +1 -0
- data/readme.md +154 -23
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 583920383adff1b25fb21db2130a379a76c6070780bb6fceddeb04c8eb0c5340
|
|
4
|
+
data.tar.gz: 528d4185f7ee05eced524a732096f19eea13750cb2ca85efa0d9cf325ad532dd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ede20ebf3d937fa6deda8da76f087d4040f7de50204b70675646cfcc3b860014d69929afde3de3c1f243cf8810f31a723ab914373ca8120a68308d6852501b9e
|
|
7
|
+
data.tar.gz: 3a5a9f5b7d9ed651af6bd9485749d6b0cf37d366f4da3a238d05c4f533440374bbc27dcfd3b43643d62c91a2a80646037130a296ecf86b3c59f3c8d38303e958
|
|
@@ -9,9 +9,17 @@ class Configuration
|
|
|
9
9
|
|
|
10
10
|
# Encapsulates one merged frontmatter configuration block.
|
|
11
11
|
#
|
|
12
|
-
# Instances resolve `base`, `primary`, `foreign`, and `output` paths so the
|
|
12
|
+
# Instances resolve `base`, `primary`, `scope`, `foreign`, and `output` paths so the
|
|
13
13
|
# rest of the engine can work with explicit values only.
|
|
14
14
|
class Frontmatter
|
|
15
|
+
# Describes one configured scope field while preserving both its public name and its base-resolved document path.
|
|
16
|
+
ScopeField = Struct.new(:name, :path) do
|
|
17
|
+
# Returns a stable value signature suitable for registry cache keys.
|
|
18
|
+
def signature
|
|
19
|
+
[name, path]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
15
23
|
# Builds one frontmatter configuration helper.
|
|
16
24
|
def initialize(raw_config:, string_array:)
|
|
17
25
|
@raw_config = raw_config.is_a?(Hash) ? raw_config : {}
|
|
@@ -34,6 +42,25 @@ class Configuration
|
|
|
34
42
|
apply_base(raw_primary.to_s)
|
|
35
43
|
end
|
|
36
44
|
|
|
45
|
+
# Returns the configured scope fields with their literal reference keys and resolved document paths.
|
|
46
|
+
def scope_fields
|
|
47
|
+
raw_scope = fetch_value('scope')
|
|
48
|
+
return [] if raw_scope.nil?
|
|
49
|
+
unless raw_scope.is_a?(String) || raw_scope.is_a?(Array)
|
|
50
|
+
raise ConfigurationError, '`frontmatter.scope` must be a string, comma-delimited string, or array of non-empty path strings.'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
raw_paths = raw_scope.is_a?(String) ? @string_array.interpret(raw_scope, split: true, flatten: true) : raw_scope
|
|
54
|
+
if raw_paths.empty? || raw_paths.any? { |path| !path.is_a?(String) || path.strip.empty? }
|
|
55
|
+
raise ConfigurationError, '`frontmatter.scope` must contain one or more non-empty path strings.'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
raw_paths.map do |path|
|
|
59
|
+
name = path.strip
|
|
60
|
+
ScopeField.new(name, apply_base(name)).freeze
|
|
61
|
+
end.uniq { |field| field.name }
|
|
62
|
+
end
|
|
63
|
+
|
|
37
64
|
# Returns the resolved foreign input paths for one target collection.
|
|
38
65
|
def foreign_paths_for(to_collection:)
|
|
39
66
|
raw_foreign = fetch_value('foreign')
|
|
@@ -241,6 +241,7 @@ class Configuration
|
|
|
241
241
|
from_collection: from_collection,
|
|
242
242
|
to_collection: to_collection,
|
|
243
243
|
primary_path: frontmatter.primary_path,
|
|
244
|
+
scope_fields: frontmatter.scope_fields,
|
|
244
245
|
parent_child_pairs: parent_child_pairs(from_collection: from_collection, to_collection: to_collection, mode: mode),
|
|
245
246
|
tree_settings: tree_settings,
|
|
246
247
|
debug: debug,
|
|
@@ -259,6 +260,7 @@ class Configuration
|
|
|
259
260
|
from_collection: from_collection,
|
|
260
261
|
to_collection: to_collection,
|
|
261
262
|
primary_path: frontmatter.primary_path,
|
|
263
|
+
scope_fields: frontmatter.scope_fields,
|
|
262
264
|
foreign_paths: frontmatter.foreign_paths_for(to_collection: to_collection),
|
|
263
265
|
output_path: frontmatter.output_path_for(to_collection: to_collection),
|
|
264
266
|
debug: debug,
|
|
@@ -291,11 +293,25 @@ class Configuration
|
|
|
291
293
|
if prune_configuration.shortcut?
|
|
292
294
|
raise ConfigurationError, "Relationship entry #{entry_index + 1} cannot use `prune: <int>` on a tree relationship."
|
|
293
295
|
end
|
|
294
|
-
if prune_configuration.depth.nil?
|
|
296
|
+
if prune_configuration.min && prune_configuration.depth.nil?
|
|
295
297
|
raise ConfigurationError, "Relationship entry #{entry_index + 1} must define `prune.depth` when pruning a tree relationship."
|
|
296
298
|
end
|
|
297
|
-
|
|
298
|
-
|
|
299
|
+
if prune_configuration.min.nil? && prune_configuration.depth
|
|
300
|
+
raise ConfigurationError, "Relationship entry #{entry_index + 1} must define `prune.min` when defining `prune.depth` on a tree relationship."
|
|
301
|
+
end
|
|
302
|
+
if prune_configuration.min.nil? && prune_configuration.where.nil?
|
|
303
|
+
raise ConfigurationError, "Relationship entry #{entry_index + 1} must define either `prune.where` or both `prune.min` and `prune.depth` when pruning a tree relationship."
|
|
304
|
+
end
|
|
305
|
+
else
|
|
306
|
+
if prune_configuration.where
|
|
307
|
+
raise ConfigurationError, "Relationship entry #{entry_index + 1} cannot define `prune.where` on a normal relationship."
|
|
308
|
+
end
|
|
309
|
+
if !prune_configuration.depth.nil?
|
|
310
|
+
raise ConfigurationError, "Relationship entry #{entry_index + 1} cannot define `prune.depth` on a normal relationship."
|
|
311
|
+
end
|
|
312
|
+
if prune_configuration.min.nil?
|
|
313
|
+
raise ConfigurationError, "Relationship entry #{entry_index + 1} must define `prune.min` when pruning a normal relationship."
|
|
314
|
+
end
|
|
299
315
|
end
|
|
300
316
|
|
|
301
317
|
prune_rules_for_entry(
|
|
@@ -358,6 +374,7 @@ class Configuration
|
|
|
358
374
|
members: members,
|
|
359
375
|
min: prune_configuration.min,
|
|
360
376
|
depth: prune_configuration.depth,
|
|
377
|
+
where: prune_configuration.where,
|
|
361
378
|
inverse: prune_configuration.inverse?,
|
|
362
379
|
entry_index: entry_index
|
|
363
380
|
)
|
|
@@ -11,10 +11,10 @@ class Configuration
|
|
|
11
11
|
#
|
|
12
12
|
# Relationship entries and hash-form targets may opt into pruning by defining
|
|
13
13
|
# a minimum neighbour count and, optionally, switching the subject of the rule
|
|
14
|
-
# to the inverse side of the relationship. Tree prune rules may
|
|
15
|
-
#
|
|
14
|
+
# to the inverse side of the relationship. Tree prune rules may constrain
|
|
15
|
+
# eligible depths, select frontmatter values, or combine both modes.
|
|
16
16
|
class PruneRuleSettings
|
|
17
|
-
attr_reader :mode, :min, :depth
|
|
17
|
+
attr_reader :mode, :min, :depth, :where
|
|
18
18
|
|
|
19
19
|
# Interprets one loose prune config value.
|
|
20
20
|
def self.build(raw_config:, context:)
|
|
@@ -31,6 +31,7 @@ class Configuration
|
|
|
31
31
|
@mode = 'direct'
|
|
32
32
|
@min = normalise_min(raw_config, context: context)
|
|
33
33
|
@depth = nil
|
|
34
|
+
@where = nil
|
|
34
35
|
return
|
|
35
36
|
end
|
|
36
37
|
|
|
@@ -43,11 +44,15 @@ class Configuration
|
|
|
43
44
|
@min = normalise_min(
|
|
44
45
|
Configuration::HashUtilities.fetch_hash_value(raw_config, 'min'),
|
|
45
46
|
context: context
|
|
46
|
-
)
|
|
47
|
+
) if Configuration::HashUtilities.hash_key?(raw_config, 'min')
|
|
47
48
|
@depth = normalise_depth(
|
|
48
49
|
Configuration::HashUtilities.hash_key?(raw_config, 'depth') ? Configuration::HashUtilities.fetch_hash_value(raw_config, 'depth') : nil,
|
|
49
50
|
context: context
|
|
50
51
|
)
|
|
52
|
+
@where = normalise_where(
|
|
53
|
+
Configuration::HashUtilities.fetch_hash_value(raw_config, 'where'),
|
|
54
|
+
context: context
|
|
55
|
+
) if Configuration::HashUtilities.hash_key?(raw_config, 'where')
|
|
51
56
|
end
|
|
52
57
|
|
|
53
58
|
# Returns true when the rule prunes the inverse side of the relationship.
|
|
@@ -73,7 +78,7 @@ class Configuration
|
|
|
73
78
|
raise ConfigurationError, "Unsupported `#{context}.mode` value `#{raw_mode}`."
|
|
74
79
|
end
|
|
75
80
|
|
|
76
|
-
# Resolves the configured minimum count and rejects non-positive values.
|
|
81
|
+
# Resolves the configured minimum count and rejects missing or non-positive values.
|
|
77
82
|
def normalise_min(raw_min, context:)
|
|
78
83
|
raise ConfigurationError, "`#{context}` must define `min`." if raw_min.nil?
|
|
79
84
|
|
|
@@ -92,6 +97,29 @@ class Configuration
|
|
|
92
97
|
|
|
93
98
|
interpreted_depth
|
|
94
99
|
end
|
|
100
|
+
|
|
101
|
+
# Resolves a non-empty hash of valid dot paths to exact expected values.
|
|
102
|
+
def normalise_where(raw_where, context:)
|
|
103
|
+
raise ConfigurationError, "`#{context}.where` must be a non-empty hash." unless raw_where.is_a?(Hash) && !raw_where.empty?
|
|
104
|
+
|
|
105
|
+
raw_where.each_with_object({}) do |(raw_path, expected_value), where|
|
|
106
|
+
path = normalise_where_path(raw_path, context: context)
|
|
107
|
+
raise ConfigurationError, "`#{context}.where` defines path `#{path}` more than once." if where.key?(path)
|
|
108
|
+
|
|
109
|
+
where[path] = expected_value
|
|
110
|
+
end.freeze
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Resolves one dot path and rejects blank segments.
|
|
114
|
+
def normalise_where_path(raw_path, context:)
|
|
115
|
+
path = raw_path.to_s.strip
|
|
116
|
+
segments = path.split('.', -1).map(&:strip)
|
|
117
|
+
if path.empty? || segments.any?(&:empty?)
|
|
118
|
+
raise ConfigurationError, "`#{context}.where` paths must contain non-blank dot-separated fields."
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
segments.join('.')
|
|
122
|
+
end
|
|
95
123
|
end
|
|
96
124
|
end
|
|
97
125
|
|
|
@@ -53,10 +53,6 @@ class Configuration
|
|
|
53
53
|
),
|
|
54
54
|
string_array: @string_array
|
|
55
55
|
)
|
|
56
|
-
@reference_template = References::Template.new(
|
|
57
|
-
config: build_reference_config,
|
|
58
|
-
count_enabled: @multiple_settings.count?
|
|
59
|
-
)
|
|
60
56
|
@tree_settings = TreeSettings.defaults(
|
|
61
57
|
string_array: @string_array
|
|
62
58
|
).merge_level(
|
|
@@ -81,6 +77,11 @@ class Configuration
|
|
|
81
77
|
@configured_relationships = parsed_result.fetch(:configured_relationships)
|
|
82
78
|
@normal_prune_rules = parsed_result.fetch(:normal_prune_rules)
|
|
83
79
|
@tree_prune_rules = parsed_result.fetch(:tree_prune_rules)
|
|
80
|
+
@reference_template = References::Template.new(
|
|
81
|
+
config: build_reference_config(scope_enabled: scope_configured?),
|
|
82
|
+
count_enabled: @multiple_settings.count?
|
|
83
|
+
)
|
|
84
|
+
validate_scope_reference_template!
|
|
84
85
|
attach_resolvers!(parser: parsed_relationships)
|
|
85
86
|
end
|
|
86
87
|
|
|
@@ -119,6 +120,24 @@ class Configuration
|
|
|
119
120
|
(normal_paths + tree_paths).uniq
|
|
120
121
|
end
|
|
121
122
|
|
|
123
|
+
# Returns every distinct primary-key and scope scheme used by configured relationships.
|
|
124
|
+
def identity_schemes
|
|
125
|
+
definitions = @normal_relationships.values.flat_map(&:values) + @tree_relationships
|
|
126
|
+
definitions.group_by do |definition|
|
|
127
|
+
[
|
|
128
|
+
definition.primary_path,
|
|
129
|
+
definition.scope_fields.map(&:signature)
|
|
130
|
+
]
|
|
131
|
+
end.map do |_, matching_definitions|
|
|
132
|
+
{
|
|
133
|
+
primary_path: matching_definitions.first.primary_path,
|
|
134
|
+
scope_fields: matching_definitions.first.scope_fields,
|
|
135
|
+
collections: matching_definitions.flat_map { |definition| [definition.from_collection, definition.to_collection] }.uniq.sort,
|
|
136
|
+
relationships: matching_definitions.map { |definition| "#{definition.from_collection} -> #{definition.to_collection}" }.uniq
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
122
141
|
private
|
|
123
142
|
|
|
124
143
|
# Builds the active global enabled setting.
|
|
@@ -142,7 +161,7 @@ class Configuration
|
|
|
142
161
|
string_array: @string_array
|
|
143
162
|
)
|
|
144
163
|
@reference_template = References::Template.new(
|
|
145
|
-
config: default_reference_config,
|
|
164
|
+
config: default_reference_config(scope_enabled: false),
|
|
146
165
|
count_enabled: @multiple_settings.count?
|
|
147
166
|
)
|
|
148
167
|
@tree_settings = TreeSettings.defaults(string_array: @string_array)
|
|
@@ -195,8 +214,8 @@ class Configuration
|
|
|
195
214
|
end
|
|
196
215
|
|
|
197
216
|
# Builds the resolved reference template config.
|
|
198
|
-
def build_reference_config
|
|
199
|
-
default_references = default_reference_config
|
|
217
|
+
def build_reference_config(scope_enabled:)
|
|
218
|
+
default_references = default_reference_config(scope_enabled: scope_enabled)
|
|
200
219
|
explicit_config = Configuration::HashUtilities.fetch_hash_value(@raw_config, 'references')
|
|
201
220
|
return explicit_config if explicit_config.is_a?(Hash)
|
|
202
221
|
|
|
@@ -212,12 +231,15 @@ class Configuration
|
|
|
212
231
|
end
|
|
213
232
|
|
|
214
233
|
# Builds the default reference template shape for the active duplicate mode.
|
|
215
|
-
def default_reference_config
|
|
234
|
+
def default_reference_config(scope_enabled:)
|
|
216
235
|
default_references = {
|
|
217
236
|
'id' => Jekyll::Plugins::Relationships::Support::Placeholders::KEY,
|
|
218
237
|
'collection' => Jekyll::Plugins::Relationships::Support::Placeholders::COLLECTION,
|
|
219
238
|
'page' => Jekyll::Plugins::Relationships::Support::Placeholders::PAGE
|
|
220
239
|
}
|
|
240
|
+
if scope_enabled
|
|
241
|
+
default_references['scope'] = Jekyll::Plugins::Relationships::Support::Placeholders::SCOPE
|
|
242
|
+
end
|
|
221
243
|
if @multiple_settings.count?
|
|
222
244
|
default_references['count'] = Jekyll::Plugins::Relationships::Support::Placeholders::COUNT
|
|
223
245
|
end
|
|
@@ -225,6 +247,19 @@ class Configuration
|
|
|
225
247
|
default_references
|
|
226
248
|
end
|
|
227
249
|
|
|
250
|
+
# Returns true when at least one concrete relationship uses scoped identity.
|
|
251
|
+
def scope_configured?
|
|
252
|
+
identity_schemes.any? { |scheme| !scheme.fetch(:scope_fields).empty? }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Ensures scoped relationships have a reserved location for their complete effective scope.
|
|
256
|
+
def validate_scope_reference_template!
|
|
257
|
+
return unless scope_configured?
|
|
258
|
+
return if @reference_template.scope_property
|
|
259
|
+
|
|
260
|
+
raise ConfigurationError, 'Reference config must define exactly one <scope> property when any relationship configures `frontmatter.scope`.'
|
|
261
|
+
end
|
|
262
|
+
|
|
228
263
|
# Attaches registered resolver classes to matching concrete relationships.
|
|
229
264
|
def attach_resolvers!(parser:)
|
|
230
265
|
Resolvers::Base.registered_subclasses.each do |resolver_class|
|
|
@@ -11,14 +11,15 @@ module Definitions
|
|
|
11
11
|
# Each instance describes one source collection, one target collection, and the
|
|
12
12
|
# resolved frontmatter rules that apply to links between them.
|
|
13
13
|
class NormalRelationship
|
|
14
|
-
attr_reader :from_collection, :to_collection, :primary_path, :foreign_paths,
|
|
14
|
+
attr_reader :from_collection, :to_collection, :primary_path, :scope_fields, :foreign_paths,
|
|
15
15
|
:output_path, :sequence, :reads_frontmatter, :bidirectional, :resolver_classes
|
|
16
16
|
|
|
17
17
|
# Captures the resolved configuration for one concrete pair.
|
|
18
|
-
def initialize(from_collection:, to_collection:, primary_path:, foreign_paths:, output_path:, debug:, sequence:, reads_frontmatter:, bidirectional:)
|
|
18
|
+
def initialize(from_collection:, to_collection:, primary_path:, scope_fields: [], foreign_paths:, output_path:, debug:, sequence:, reads_frontmatter:, bidirectional:)
|
|
19
19
|
@from_collection = from_collection
|
|
20
20
|
@to_collection = to_collection
|
|
21
21
|
@primary_path = primary_path
|
|
22
|
+
@scope_fields = scope_fields.freeze
|
|
22
23
|
@foreign_paths = foreign_paths
|
|
23
24
|
@output_path = output_path
|
|
24
25
|
@debug = debug
|
|
@@ -38,6 +39,11 @@ class NormalRelationship
|
|
|
38
39
|
@debug.enabled?(area)
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
# Returns true when this relationship is the synthetic inverse of a bidirectional pair.
|
|
43
|
+
def bidirectional_mirror?
|
|
44
|
+
@bidirectional && !@reads_frontmatter
|
|
45
|
+
end
|
|
46
|
+
|
|
41
47
|
# Returns true when one event's related IDs satisfy this pair's debug filter.
|
|
42
48
|
def debug_ids_match?(ids)
|
|
43
49
|
@debug.matches_ids?(ids)
|
|
@@ -11,17 +11,19 @@ module Definitions
|
|
|
11
11
|
# Raw relationship entries may expand into many concrete relationship members.
|
|
12
12
|
# After the global prune settings have been applied, each expanded prune rule
|
|
13
13
|
# targets exactly one subject collection and one set of configured relationship
|
|
14
|
-
# members
|
|
14
|
+
# members. Rules select by neighbour count, frontmatter values, or both.
|
|
15
15
|
class PruneRule
|
|
16
|
-
attr_reader :kind, :subject_collection, :members, :min, :depth, :entry_index
|
|
16
|
+
attr_reader :kind, :subject_collection, :members, :min, :depth, :where, :entry_index
|
|
17
17
|
|
|
18
18
|
# Captures one immutable prune rule.
|
|
19
|
-
def initialize(kind:, subject_collection:, members:, min:, depth:, inverse:, entry_index:)
|
|
19
|
+
def initialize(kind:, subject_collection:, members:, min:, depth:, where:, inverse:, entry_index:)
|
|
20
20
|
@kind = kind
|
|
21
21
|
@subject_collection = subject_collection
|
|
22
22
|
@members = members.sort_by(&:sequence).freeze
|
|
23
23
|
@min = min
|
|
24
24
|
@depth = depth
|
|
25
|
+
@where = where
|
|
26
|
+
@frontmatter_matcher = Support::FrontmatterMatcher.new(expected_values: @where) unless @where.nil?
|
|
25
27
|
@inverse = inverse
|
|
26
28
|
@entry_index = entry_index
|
|
27
29
|
end
|
|
@@ -41,6 +43,20 @@ class PruneRule
|
|
|
41
43
|
@kind == :normal
|
|
42
44
|
end
|
|
43
45
|
|
|
46
|
+
# Returns true when the document satisfies this rule's frontmatter mode.
|
|
47
|
+
def frontmatter_selected?(document)
|
|
48
|
+
return true unless @frontmatter_matcher
|
|
49
|
+
|
|
50
|
+
@frontmatter_matcher.matches?(document.data)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns true when the document satisfies this rule's relationship mode.
|
|
54
|
+
def relationship_selected?(graph:, document:)
|
|
55
|
+
return true if @min.nil?
|
|
56
|
+
|
|
57
|
+
neighbour_documents(graph: graph, document: document).length < @min
|
|
58
|
+
end
|
|
59
|
+
|
|
44
60
|
# Returns the combined neighbour documents for one subject document.
|
|
45
61
|
def neighbour_documents(graph:, document:)
|
|
46
62
|
@members.each_with_object({}) do |member, neighbours|
|
|
@@ -12,13 +12,14 @@ module Definitions
|
|
|
12
12
|
# and which frontmatter, maximum, URL, and primary-key rules should be used to
|
|
13
13
|
# resolve references for this concrete relationship definition.
|
|
14
14
|
class TreeRelationship
|
|
15
|
-
attr_reader :from_collection, :to_collection, :primary_path, :tree_settings, :sequence
|
|
15
|
+
attr_reader :from_collection, :to_collection, :primary_path, :scope_fields, :tree_settings, :sequence
|
|
16
16
|
|
|
17
17
|
# Captures the allowed parent-child directions for one tree definition.
|
|
18
|
-
def initialize(from_collection:, to_collection:, primary_path:, parent_child_pairs:, tree_settings:, debug:, sequence:)
|
|
18
|
+
def initialize(from_collection:, to_collection:, primary_path:, scope_fields: [], parent_child_pairs:, tree_settings:, debug:, sequence:)
|
|
19
19
|
@from_collection = from_collection
|
|
20
20
|
@to_collection = to_collection
|
|
21
21
|
@primary_path = primary_path
|
|
22
|
+
@scope_fields = scope_fields.freeze
|
|
22
23
|
@parent_child_pairs = parent_child_pairs
|
|
23
24
|
@tree_settings = tree_settings
|
|
24
25
|
@debug = debug
|
|
@@ -6,17 +6,18 @@ module Plugins
|
|
|
6
6
|
module Relationships
|
|
7
7
|
module Documents
|
|
8
8
|
|
|
9
|
-
# Indexes participating documents by collection and
|
|
9
|
+
# Indexes participating documents by collection, primary key, and optional complete scope.
|
|
10
10
|
#
|
|
11
|
-
# Indices are built lazily per
|
|
12
|
-
# use different
|
|
11
|
+
# Indices are built lazily per complete identity scheme so relationship definitions can
|
|
12
|
+
# use different primary and scope paths without rebuilding unrelated lookups.
|
|
13
13
|
class Registry
|
|
14
14
|
|
|
15
15
|
# Builds the registry over the collections used by the configuration.
|
|
16
16
|
def initialize(site:, collections:)
|
|
17
17
|
@site = site
|
|
18
18
|
@collections = collections
|
|
19
|
-
@
|
|
19
|
+
@indices_by_identity = {}
|
|
20
|
+
@data_path = Jekyll::Plugins::Relationships::Support::DataPath.new
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
# Returns true when the document belongs to one of the tracked collections.
|
|
@@ -48,7 +49,19 @@ class Registry
|
|
|
48
49
|
# Builds every requested primary-key index so duplicates fail eagerly.
|
|
49
50
|
def validate_primary_paths!(primary_paths:)
|
|
50
51
|
Array(primary_paths).uniq.each do |primary_path|
|
|
51
|
-
index_for(primary_path)
|
|
52
|
+
index_for(primary_path: primary_path, scope_fields: [])
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Builds every requested identity index so missing scope values and duplicate identities fail eagerly.
|
|
57
|
+
def validate_identity_schemes!(identity_schemes:)
|
|
58
|
+
Array(identity_schemes).each do |scheme|
|
|
59
|
+
index_for(
|
|
60
|
+
primary_path: scheme.fetch(:primary_path),
|
|
61
|
+
scope_fields: scheme.fetch(:scope_fields),
|
|
62
|
+
collections: scheme.fetch(:collections),
|
|
63
|
+
relationship: scheme.fetch(:relationships).join(', ')
|
|
64
|
+
)
|
|
52
65
|
end
|
|
53
66
|
end
|
|
54
67
|
|
|
@@ -57,61 +70,157 @@ class Registry
|
|
|
57
70
|
if primary_path.nil?
|
|
58
71
|
default_relative_key(document)
|
|
59
72
|
else
|
|
60
|
-
value =
|
|
73
|
+
value = @data_path.read(document.data, primary_path)
|
|
61
74
|
raise ResolutionError, "Document `#{document.relative_path}` is missing primary key path `#{primary_path}`." if value.nil?
|
|
62
75
|
|
|
63
76
|
value.to_s
|
|
64
77
|
end
|
|
65
78
|
end
|
|
66
79
|
|
|
67
|
-
#
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
# Reads and validates a document's complete scope for one configured identity scheme.
|
|
81
|
+
def scope_for(document, scope_fields:, relationship: nil)
|
|
82
|
+
return nil if scope_fields.empty?
|
|
83
|
+
|
|
84
|
+
scope_fields.each_with_object({}) do |field, scope|
|
|
85
|
+
value = @data_path.read(document.data, field.path)
|
|
86
|
+
if value.nil?
|
|
87
|
+
raise ResolutionError, "#{relationship_prefix(relationship)}document `#{document.relative_path}` is missing required scope field `#{field.name}` at frontmatter path `#{field.path}`."
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
scope[field.name] = value
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Resolves reference overrides over the referring document's scope and validates every configured field.
|
|
95
|
+
def effective_scope_for(reference_scope:, referring_document:, scope_fields:, relationship: nil)
|
|
96
|
+
return nil if scope_fields.empty? && (reference_scope.nil? || reference_scope.empty?)
|
|
97
|
+
|
|
98
|
+
reference_scope ||= {}
|
|
99
|
+
unless reference_scope.is_a?(Hash)
|
|
100
|
+
raise ResolutionError, "#{relationship_prefix(relationship)}reference on document `#{referring_document.relative_path}` must provide scope as a hash."
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
configured_names = scope_fields.map(&:name)
|
|
104
|
+
unknown_names = reference_scope.keys.map(&:to_s) - configured_names
|
|
105
|
+
unless unknown_names.empty?
|
|
106
|
+
raise ResolutionError, "#{relationship_prefix(relationship)}reference on document `#{referring_document.relative_path}` uses unconfigured scope field `#{unknown_names.first}`."
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
scope_fields.each_with_object({}) do |field, effective_scope|
|
|
110
|
+
value = if hash_property?(reference_scope, field.name)
|
|
111
|
+
Jekyll::Plugins::Relationships::Support::FrontmatterPath.read_hash(reference_scope, field.name)
|
|
112
|
+
else
|
|
113
|
+
@data_path.read(referring_document.data, field.path)
|
|
114
|
+
end
|
|
115
|
+
if value.nil?
|
|
116
|
+
raise ResolutionError, "#{relationship_prefix(relationship)}reference on document `#{referring_document.relative_path}` has no effective value for scope field `#{field.name}`."
|
|
117
|
+
end
|
|
73
118
|
|
|
74
|
-
|
|
119
|
+
effective_scope[field.name] = value
|
|
75
120
|
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Ensures one already-selected target contains and exactly matches an effective scope.
|
|
124
|
+
def validate_scope_match!(document:, scope:, scope_fields:, relationship: nil, referring_document: nil)
|
|
125
|
+
return nil if scope_fields.empty?
|
|
76
126
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return matches.first if matches.length == 1
|
|
127
|
+
target_scope = scope_for(document, scope_fields: scope_fields, relationship: relationship)
|
|
128
|
+
scope_fields.each do |field|
|
|
129
|
+
next if target_scope.fetch(field.name) == scope.fetch(field.name)
|
|
81
130
|
|
|
82
|
-
|
|
83
|
-
|
|
131
|
+
raise ResolutionError, scope_mismatch_message(
|
|
132
|
+
document: document,
|
|
133
|
+
field: field,
|
|
134
|
+
expected_value: scope.fetch(field.name),
|
|
135
|
+
actual_value: target_scope.fetch(field.name),
|
|
136
|
+
relationship: relationship,
|
|
137
|
+
referring_document: referring_document
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
target_scope
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Resolves one key and complete scope to a document, optionally constrained to a collection.
|
|
145
|
+
def lookup(key:, primary_path:, collection: nil, scope_fields: [], scope: nil, relationship: nil, referring_document: nil)
|
|
146
|
+
index = index_for(primary_path: primary_path, scope_fields: scope_fields, relationship: relationship)
|
|
147
|
+
candidates = if collection
|
|
148
|
+
fetch_nested(index.fetch(:by_collection), collection, key.to_s) || []
|
|
149
|
+
else
|
|
150
|
+
index.fetch(:by_key).fetch(key.to_s, [])
|
|
151
|
+
end
|
|
152
|
+
if candidates.empty?
|
|
153
|
+
if collection
|
|
154
|
+
raise ResolutionError, "#{relationship_prefix(relationship)}could not resolve key `#{key}` in collection `#{collection}`#{source_suffix(referring_document)}."
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
raise ResolutionError, "#{relationship_prefix(relationship)}could not resolve key-only reference `#{key}`#{source_suffix(referring_document)}."
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
matches = if scope_fields.empty?
|
|
161
|
+
candidates
|
|
162
|
+
else
|
|
163
|
+
candidates.select { |entry| entry.fetch(:scope) == scope }
|
|
164
|
+
end
|
|
165
|
+
if matches.empty?
|
|
166
|
+
raise_scope_mismatch!(
|
|
167
|
+
candidates: candidates,
|
|
168
|
+
scope: scope,
|
|
169
|
+
scope_fields: scope_fields,
|
|
170
|
+
relationship: relationship,
|
|
171
|
+
referring_document: referring_document
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
matched_documents = matches.map { |entry| entry.fetch(:document) }
|
|
176
|
+
return matched_documents.first if collection
|
|
177
|
+
|
|
178
|
+
return matched_documents.first if matched_documents.length == 1
|
|
179
|
+
|
|
180
|
+
collections = matched_documents.map { |document| collection_label(document) }.uniq.sort.join(', ')
|
|
181
|
+
raise ResolutionError, "#{relationship_prefix(relationship)}key-only reference `#{key}` is ambiguous across collections: #{collections}#{source_suffix(referring_document)}."
|
|
84
182
|
end
|
|
85
183
|
|
|
86
184
|
private
|
|
87
185
|
|
|
88
|
-
# Returns or builds the index for one
|
|
89
|
-
def index_for(primary_path)
|
|
90
|
-
signature = primary_path.nil? ? '__relative_path__' : primary_path
|
|
91
|
-
@
|
|
186
|
+
# Returns or builds the index for one complete identity scheme.
|
|
187
|
+
def index_for(primary_path:, scope_fields:, collections: nil, relationship: nil)
|
|
188
|
+
signature = [primary_path.nil? ? '__relative_path__' : primary_path, scope_fields.map(&:signature)]
|
|
189
|
+
@indices_by_identity[signature] ||= build_index(
|
|
190
|
+
primary_path: primary_path,
|
|
191
|
+
scope_fields: scope_fields,
|
|
192
|
+
collections: collections || @collections,
|
|
193
|
+
relationship: relationship
|
|
194
|
+
)
|
|
92
195
|
end
|
|
93
196
|
|
|
94
|
-
# Builds the collection and global indices for one scheme.
|
|
95
|
-
def build_index(primary_path)
|
|
96
|
-
by_collection = Hash.new { |hash, key| hash[key] = {} }
|
|
197
|
+
# Builds the collection and global indices for one complete identity scheme.
|
|
198
|
+
def build_index(primary_path:, scope_fields:, collections:, relationship: nil)
|
|
199
|
+
by_collection = Hash.new { |hash, key| hash[key] = Hash.new { |nested_hash, nested_key| nested_hash[nested_key] = [] } }
|
|
97
200
|
by_key = Hash.new { |hash, key| hash[key] = [] }
|
|
98
201
|
|
|
99
|
-
|
|
202
|
+
collections.each do |collection|
|
|
100
203
|
documents_for(collection).each do |document|
|
|
101
204
|
key = key_for(document, primary_path: primary_path)
|
|
102
|
-
|
|
103
|
-
|
|
205
|
+
scope = scope_for(document, scope_fields: scope_fields, relationship: relationship)
|
|
206
|
+
existing_entry = by_collection[collection][key].find { |entry| entry.fetch(:scope) == scope }
|
|
207
|
+
if existing_entry
|
|
208
|
+
if scope_fields.empty?
|
|
209
|
+
raise ResolutionError, "Primary key `#{key}` is duplicated within collection `#{collection}`."
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
raise ResolutionError, "#{relationship_prefix(relationship)}primary key `#{key}` is duplicated within collection `#{collection}` and scope #{scope.inspect} by documents `#{existing_entry.fetch(:document).relative_path}` and `#{document.relative_path}`."
|
|
104
213
|
end
|
|
105
214
|
|
|
106
|
-
|
|
107
|
-
|
|
215
|
+
entry = { document: document, scope: scope }
|
|
216
|
+
by_collection[collection][key] << entry
|
|
217
|
+
by_key[key] << entry
|
|
108
218
|
end
|
|
109
219
|
end
|
|
110
220
|
|
|
111
221
|
{
|
|
112
222
|
by_collection: by_collection,
|
|
113
|
-
by_key: by_key
|
|
114
|
-
site_wide_unique: by_key.values.all? { |documents| documents.length <= 1 }
|
|
223
|
+
by_key: by_key
|
|
115
224
|
}
|
|
116
225
|
end
|
|
117
226
|
|
|
@@ -123,6 +232,46 @@ class Registry
|
|
|
123
232
|
first_hash[second_key]
|
|
124
233
|
end
|
|
125
234
|
|
|
235
|
+
# Raises a field-specific error when key candidates exist but none match the requested complete scope.
|
|
236
|
+
def raise_scope_mismatch!(candidates:, scope:, scope_fields:, relationship:, referring_document:)
|
|
237
|
+
candidate = candidates.first
|
|
238
|
+
field = scope_fields.find do |configured_field|
|
|
239
|
+
candidate.fetch(:scope).fetch(configured_field.name) != scope.fetch(configured_field.name)
|
|
240
|
+
end
|
|
241
|
+
raise ResolutionError, scope_mismatch_message(
|
|
242
|
+
document: candidate.fetch(:document),
|
|
243
|
+
field: field,
|
|
244
|
+
expected_value: scope.fetch(field.name),
|
|
245
|
+
actual_value: candidate.fetch(:scope).fetch(field.name),
|
|
246
|
+
relationship: relationship,
|
|
247
|
+
referring_document: referring_document
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Builds one detailed exact-match failure for a configured scope field.
|
|
252
|
+
def scope_mismatch_message(document:, field:, expected_value:, actual_value:, relationship:, referring_document:)
|
|
253
|
+
"#{relationship_prefix(relationship)}target document `#{document.relative_path}` has scope field `#{field.name}` value `#{actual_value.inspect}`, not the required `#{expected_value.inspect}`#{source_suffix(referring_document)}."
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Returns true when one literal scope key exists in string or symbol form.
|
|
257
|
+
def hash_property?(hash, property)
|
|
258
|
+
hash.key?(property) || hash.key?(property.to_s) || hash.key?(property.to_s.to_sym)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Prefixes an error with its concrete relationship where available.
|
|
262
|
+
def relationship_prefix(relationship)
|
|
263
|
+
return '' if relationship.nil? || relationship.to_s.empty?
|
|
264
|
+
|
|
265
|
+
"Relationship `#{relationship}`: "
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Adds the referring document to lookup diagnostics where available.
|
|
269
|
+
def source_suffix(document)
|
|
270
|
+
return '' unless document
|
|
271
|
+
|
|
272
|
+
" from document `#{document.relative_path}`"
|
|
273
|
+
end
|
|
274
|
+
|
|
126
275
|
# Implements the README's default relative-path key rule.
|
|
127
276
|
def default_relative_key(document)
|
|
128
277
|
document.relative_path.sub(/\A_/, '').sub(/#{Regexp.escape(document.extname)}\z/, '')
|