lumberjack 1.4.2 → 2.0.0
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/ARCHITECTURE.md +524 -176
- data/CHANGELOG.md +89 -0
- data/README.md +604 -211
- data/UPGRADE_GUIDE.md +80 -0
- data/VERSION +1 -1
- data/lib/lumberjack/attribute_formatter.rb +451 -0
- data/lib/lumberjack/attributes_helper.rb +100 -0
- data/lib/lumberjack/context.rb +120 -23
- data/lib/lumberjack/context_logger.rb +620 -0
- data/lib/lumberjack/device/buffer.rb +209 -0
- data/lib/lumberjack/device/date_rolling_log_file.rb +10 -62
- data/lib/lumberjack/device/log_file.rb +76 -29
- data/lib/lumberjack/device/logger_wrapper.rb +137 -0
- data/lib/lumberjack/device/multi.rb +92 -30
- data/lib/lumberjack/device/null.rb +26 -8
- data/lib/lumberjack/device/size_rolling_log_file.rb +13 -54
- data/lib/lumberjack/device/test.rb +337 -0
- data/lib/lumberjack/device/writer.rb +184 -176
- data/lib/lumberjack/device.rb +134 -15
- data/lib/lumberjack/device_registry.rb +90 -0
- data/lib/lumberjack/entry_formatter.rb +357 -0
- data/lib/lumberjack/fiber_locals.rb +55 -0
- data/lib/lumberjack/forked_logger.rb +143 -0
- data/lib/lumberjack/formatter/date_time_formatter.rb +14 -3
- data/lib/lumberjack/formatter/exception_formatter.rb +12 -2
- data/lib/lumberjack/formatter/id_formatter.rb +13 -1
- data/lib/lumberjack/formatter/inspect_formatter.rb +14 -1
- data/lib/lumberjack/formatter/multiply_formatter.rb +10 -0
- data/lib/lumberjack/formatter/object_formatter.rb +13 -1
- data/lib/lumberjack/formatter/pretty_print_formatter.rb +15 -2
- data/lib/lumberjack/formatter/redact_formatter.rb +18 -3
- data/lib/lumberjack/formatter/round_formatter.rb +12 -0
- data/lib/lumberjack/formatter/string_formatter.rb +9 -1
- data/lib/lumberjack/formatter/strip_formatter.rb +13 -1
- data/lib/lumberjack/formatter/structured_formatter.rb +18 -2
- data/lib/lumberjack/formatter/tagged_message.rb +10 -32
- data/lib/lumberjack/formatter/tags_formatter.rb +32 -0
- data/lib/lumberjack/formatter/truncate_formatter.rb +8 -1
- data/lib/lumberjack/formatter.rb +271 -141
- data/lib/lumberjack/formatter_registry.rb +84 -0
- data/lib/lumberjack/io_compatibility.rb +133 -0
- data/lib/lumberjack/local_log_template.rb +209 -0
- data/lib/lumberjack/log_entry.rb +154 -79
- data/lib/lumberjack/log_entry_matcher/score.rb +276 -0
- data/lib/lumberjack/log_entry_matcher.rb +126 -0
- data/lib/lumberjack/logger.rb +328 -556
- data/lib/lumberjack/message_attributes.rb +38 -0
- data/lib/lumberjack/rack/context.rb +66 -15
- data/lib/lumberjack/rack.rb +0 -2
- data/lib/lumberjack/remap_attribute.rb +24 -0
- data/lib/lumberjack/severity.rb +52 -15
- data/lib/lumberjack/tag_context.rb +8 -71
- data/lib/lumberjack/tag_formatter.rb +22 -188
- data/lib/lumberjack/tags.rb +15 -21
- data/lib/lumberjack/template.rb +252 -62
- data/lib/lumberjack/template_registry.rb +60 -0
- data/lib/lumberjack/utils.rb +198 -48
- data/lib/lumberjack.rb +167 -59
- data/lumberjack.gemspec +4 -2
- metadata +41 -15
- data/lib/lumberjack/device/rolling_log_file.rb +0 -145
- data/lib/lumberjack/rack/request_id.rb +0 -31
- data/lib/lumberjack/rack/unit_of_work.rb +0 -21
- data/lib/lumberjack/tagged_logger_support.rb +0 -81
- data/lib/lumberjack/tagged_logging.rb +0 -29
@@ -0,0 +1,276 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Class responsible for scoring and matching log entries against filters.
|
4
|
+
# This class provides fuzzy matching capabilities to find the best matching
|
5
|
+
# log entry when exact matches are not available.
|
6
|
+
class Lumberjack::LogEntryMatcher::Score
|
7
|
+
# Minimum score threshold for considering a match (30% match)
|
8
|
+
MIN_SCORE_THRESHOLD = 0.3
|
9
|
+
|
10
|
+
class << self
|
11
|
+
# Calculate the overall match score for an entry against all provided filters.
|
12
|
+
# Returns a score between 0.0 and 1.0, where 1.0 represents a perfect match.
|
13
|
+
#
|
14
|
+
# @param entry [Lumberjack::LogEntry] The log entry to score.
|
15
|
+
# @param message [String, Regexp, nil] The message filter to match against.
|
16
|
+
# @param severity [Integer, nil] The severity level to match against.
|
17
|
+
# @param attributes [Hash, nil] The attributes hash to match against.
|
18
|
+
# @param progname [String, nil] The program name to match against.
|
19
|
+
# @return [Float] A score between 0.0 and 1.0 indicating match quality.
|
20
|
+
def calculate_match_score(entry, message: nil, severity: nil, attributes: nil, progname: nil)
|
21
|
+
scores = []
|
22
|
+
weights = []
|
23
|
+
|
24
|
+
# Check message match
|
25
|
+
if message
|
26
|
+
message_score = calculate_field_score(entry.message, message)
|
27
|
+
scores << message_score
|
28
|
+
weights << 0.5 # Weight message matching highly
|
29
|
+
end
|
30
|
+
|
31
|
+
# Check severity match
|
32
|
+
if severity
|
33
|
+
severity_score = if entry.severity == severity
|
34
|
+
1.0 # Exact severity match
|
35
|
+
else
|
36
|
+
severity_proximity_score(entry.severity, severity) # Partial severity match
|
37
|
+
end
|
38
|
+
scores << severity_score
|
39
|
+
weights << 0.2
|
40
|
+
end
|
41
|
+
|
42
|
+
# Check progname match
|
43
|
+
if progname
|
44
|
+
progname_score = calculate_field_score(entry.progname, progname)
|
45
|
+
scores << progname_score
|
46
|
+
weights << 0.2
|
47
|
+
end
|
48
|
+
|
49
|
+
# Check attributes match
|
50
|
+
if attributes.is_a?(Hash) && !attributes.empty?
|
51
|
+
attributes_score = calculate_attributes_score(entry.attributes, attributes)
|
52
|
+
scores << attributes_score
|
53
|
+
weights << 0.3
|
54
|
+
end
|
55
|
+
|
56
|
+
# Return 0 if no criteria were provided
|
57
|
+
return 0.0 if scores.empty?
|
58
|
+
|
59
|
+
# Calculate weighted average, but apply a penalty if any score is 0
|
60
|
+
# This ensures that completely failed criteria significantly impact the result
|
61
|
+
total_weighted_score = scores.zip(weights).map { |score, weight| score * weight }.sum
|
62
|
+
total_weight = weights.sum
|
63
|
+
base_score = total_weighted_score / total_weight
|
64
|
+
|
65
|
+
# Apply penalty for zero scores: reduce the score based on how many criteria completely failed
|
66
|
+
zero_scores = scores.count(0.0)
|
67
|
+
if zero_scores > 0
|
68
|
+
penalty_factor = 1.0 - (zero_scores.to_f / scores.length * 0.5) # Up to 50% penalty
|
69
|
+
base_score *= penalty_factor
|
70
|
+
end
|
71
|
+
|
72
|
+
base_score
|
73
|
+
end
|
74
|
+
|
75
|
+
# Calculate score for any field value against a filter.
|
76
|
+
# Returns a score between 0.0 and 1.0 based on how well the value matches the filter.
|
77
|
+
#
|
78
|
+
# @param value [Object] The value to match against the filter.
|
79
|
+
# @param filter [String, Regexp, Object] The filter to match the value against.
|
80
|
+
# @return [Float] A score between 0.0 and 1.0 indicating match quality.
|
81
|
+
def calculate_field_score(value, filter)
|
82
|
+
return 0.0 unless value && filter
|
83
|
+
|
84
|
+
case filter
|
85
|
+
when String
|
86
|
+
value_str = value.to_s
|
87
|
+
if value_str == filter
|
88
|
+
1.0
|
89
|
+
elsif value_str.include?(filter)
|
90
|
+
0.7
|
91
|
+
else
|
92
|
+
# Use string similarity for partial matching
|
93
|
+
similarity = string_similarity(value_str, filter)
|
94
|
+
(similarity > 0.5) ? similarity * 0.6 : 0.0
|
95
|
+
end
|
96
|
+
when Regexp
|
97
|
+
filter.match?(value.to_s) ? 1.0 : 0.0
|
98
|
+
else
|
99
|
+
# For other matchers (like RSpec matchers), try to use === operator
|
100
|
+
begin
|
101
|
+
(filter === value) ? 1.0 : 0.0
|
102
|
+
rescue
|
103
|
+
0.0
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Calculate proximity score based on log severity distance.
|
109
|
+
# Provides partial scoring for severities that are close to the target.
|
110
|
+
#
|
111
|
+
# @param entry_severity [Integer] The severity level of the log entry.
|
112
|
+
# @param filter_severity [Integer] The target severity level to match.
|
113
|
+
# @return [Float] A score between 0.0 and 1.0 based on severity proximity.
|
114
|
+
def severity_proximity_score(entry_severity, filter_severity)
|
115
|
+
severity_diff = (entry_severity - filter_severity).abs
|
116
|
+
case severity_diff
|
117
|
+
when 0 then 1.0
|
118
|
+
when 1 then 0.7
|
119
|
+
when 2 then 0.4
|
120
|
+
else 0.0
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
# Calculate score for attribute matching.
|
125
|
+
# Compares entry attributes against filter attributes and returns a score
|
126
|
+
# based on how many attributes match.
|
127
|
+
#
|
128
|
+
# @param entry_attributes [Hash] The attributes from the log entry.
|
129
|
+
# @param attributes_filter [Hash] The attributes filter to match against.
|
130
|
+
# @return [Float] A score between 0.0 and 1.0 based on attribute matches.
|
131
|
+
def calculate_attributes_score(entry_attributes, attributes_filter)
|
132
|
+
return 0.0 unless entry_attributes && attributes_filter.is_a?(Hash)
|
133
|
+
|
134
|
+
attributes_filter = deep_stringify_keys(Lumberjack::Utils.expand_attributes(attributes_filter))
|
135
|
+
attributes = deep_stringify_keys(Lumberjack::Utils.expand_attributes(entry_attributes))
|
136
|
+
|
137
|
+
total_attribute_filters = count_attribute_filters(attributes_filter)
|
138
|
+
return 0.0 if total_attribute_filters == 0
|
139
|
+
|
140
|
+
matched_attributes = count_matched_attributes(attributes, attributes_filter)
|
141
|
+
matched_attributes.to_f / total_attribute_filters
|
142
|
+
end
|
143
|
+
|
144
|
+
private
|
145
|
+
|
146
|
+
# Calculate string similarity using a simple Levenshtein distance-based approach.
|
147
|
+
# Returns a score between 0.0 and 1.0 where 1.0 is an exact match.
|
148
|
+
#
|
149
|
+
# @param str1 [String] The first string to compare.
|
150
|
+
# @param str2 [String] The second string to compare.
|
151
|
+
# @return [Float] A similarity score between 0.0 and 1.0.
|
152
|
+
def string_similarity(str1, str2)
|
153
|
+
return 1.0 if str1 == str2
|
154
|
+
return 0.0 if str1.nil? || str2.nil? || str1.empty? || str2.empty?
|
155
|
+
|
156
|
+
# Convert to lowercase for case-insensitive comparison
|
157
|
+
s1 = str1.downcase
|
158
|
+
s2 = str2.downcase
|
159
|
+
|
160
|
+
# If one string contains the other, give it a good score
|
161
|
+
if s1.include?(s2) || s2.include?(s1)
|
162
|
+
shorter = [s1.length, s2.length].min
|
163
|
+
longer = [s1.length, s2.length].max
|
164
|
+
return shorter.to_f / longer * 0.8 + 0.2 # Boost score for containment
|
165
|
+
end
|
166
|
+
|
167
|
+
# Calculate Levenshtein distance
|
168
|
+
distance = levenshtein_distance(s1, s2)
|
169
|
+
max_length = [s1.length, s2.length].max
|
170
|
+
|
171
|
+
# Convert distance to similarity score
|
172
|
+
return 0.0 if max_length == 0
|
173
|
+
|
174
|
+
1.0 - (distance.to_f / max_length)
|
175
|
+
end
|
176
|
+
|
177
|
+
# Simple Levenshtein distance implementation.
|
178
|
+
# Calculates the minimum number of single-character edits needed
|
179
|
+
# to change one string into another.
|
180
|
+
#
|
181
|
+
# @param str1 [String] The first string.
|
182
|
+
# @param str2 [String] The second string.
|
183
|
+
# @return [Integer] The Levenshtein distance between the strings.
|
184
|
+
def levenshtein_distance(str1, str2)
|
185
|
+
return str2.length if str1.empty?
|
186
|
+
return str1.length if str2.empty?
|
187
|
+
|
188
|
+
matrix = Array.new(str1.length + 1) { Array.new(str2.length + 1, 0) }
|
189
|
+
|
190
|
+
# Initialize first row and column
|
191
|
+
(0..str1.length).each { |i| matrix[i][0] = i }
|
192
|
+
(0..str2.length).each { |j| matrix[0][j] = j }
|
193
|
+
|
194
|
+
# Fill the matrix
|
195
|
+
(1..str1.length).each do |i|
|
196
|
+
(1..str2.length).each do |j|
|
197
|
+
cost = (str1[i - 1] == str2[j - 1]) ? 0 : 1
|
198
|
+
matrix[i][j] = [
|
199
|
+
matrix[i - 1][j] + 1, # deletion
|
200
|
+
matrix[i][j - 1] + 1, # insertion
|
201
|
+
matrix[i - 1][j - 1] + cost # substitution
|
202
|
+
].min
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
matrix[str1.length][str2.length]
|
207
|
+
end
|
208
|
+
|
209
|
+
# Count the total number of attribute filters in a nested hash structure.
|
210
|
+
#
|
211
|
+
# @param attributes_filter [Hash] The attributes filter hash to count.
|
212
|
+
# @param count [Integer] The current count (used for recursion).
|
213
|
+
# @return [Integer] The total number of filters.
|
214
|
+
def count_attribute_filters(attributes_filter, count = 0)
|
215
|
+
attributes_filter.each do |_name, value_filter|
|
216
|
+
if value_filter.is_a?(Hash)
|
217
|
+
count = count_attribute_filters(value_filter, count)
|
218
|
+
else
|
219
|
+
count += 1
|
220
|
+
end
|
221
|
+
end
|
222
|
+
count
|
223
|
+
end
|
224
|
+
|
225
|
+
# Count the number of matched attributes in a nested structure.
|
226
|
+
#
|
227
|
+
# @param attributes [Hash] The log entry attributes to check.
|
228
|
+
# @param attributes_filter [Hash] The filter attributes to match against.
|
229
|
+
# @param count [Integer] The current count (used for recursion).
|
230
|
+
# @return [Integer] The number of matched attributes.
|
231
|
+
def count_matched_attributes(attributes, attributes_filter, count = 0)
|
232
|
+
return count unless attributes && attributes_filter
|
233
|
+
|
234
|
+
attributes_filter.each do |name, value_filter|
|
235
|
+
name = name.to_s
|
236
|
+
attribute_values = attributes[name]
|
237
|
+
|
238
|
+
if value_filter.is_a?(Hash) && attribute_values.is_a?(Hash)
|
239
|
+
count = count_matched_attributes(attribute_values, value_filter, count)
|
240
|
+
elsif attributes.include?(name) && exact_match?(attribute_values, value_filter)
|
241
|
+
count += 1
|
242
|
+
end
|
243
|
+
end
|
244
|
+
count
|
245
|
+
end
|
246
|
+
|
247
|
+
# Check if a value exactly matches the filter using the === operator.
|
248
|
+
#
|
249
|
+
# @param value [Object] The value to match.
|
250
|
+
# @param filter [Object] The filter to match against.
|
251
|
+
# @return [Boolean] True if the value matches the filter.
|
252
|
+
def exact_match?(value, filter)
|
253
|
+
return true unless filter
|
254
|
+
|
255
|
+
filter === value
|
256
|
+
end
|
257
|
+
|
258
|
+
# Recursively convert all keys in a hash structure to strings.
|
259
|
+
#
|
260
|
+
# @param hash [Hash, Object] The hash to stringify or other object to return as-is.
|
261
|
+
# @return [Hash, Object] The hash with string keys or the original object.
|
262
|
+
def deep_stringify_keys(hash)
|
263
|
+
if hash.is_a?(Hash)
|
264
|
+
hash.each_with_object({}) do |(key, value), result|
|
265
|
+
new_key = key.to_s
|
266
|
+
new_value = deep_stringify_keys(value)
|
267
|
+
result[new_key] = new_value
|
268
|
+
end
|
269
|
+
elsif hash.is_a?(Enumerable)
|
270
|
+
hash.collect { |item| deep_stringify_keys(item) }
|
271
|
+
else
|
272
|
+
hash
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Lumberjack
|
4
|
+
# A flexible matching utility for testing and filtering log entries based on
|
5
|
+
# multiple criteria. This class provides pattern-based matching against log
|
6
|
+
# entry components including message content, severity levels, program names,
|
7
|
+
# and custom attributes with support for nested attribute structures.
|
8
|
+
#
|
9
|
+
# The matcher uses Ruby's case equality operator (===) for flexible matching,
|
10
|
+
# supporting exact values, regular expressions, ranges, classes, and other
|
11
|
+
# pattern matching constructs. It's primarily designed for use with the Test
|
12
|
+
# device in testing scenarios but can be used anywhere log entry filtering
|
13
|
+
# is needed.
|
14
|
+
#
|
15
|
+
# @see Lumberjack::Device::Test
|
16
|
+
class LogEntryMatcher
|
17
|
+
require_relative "log_entry_matcher/score"
|
18
|
+
|
19
|
+
# Create a new log entry matcher with optional filtering criteria. All
|
20
|
+
# parameters are optional and nil values indicate no filtering for that
|
21
|
+
# component. The matcher uses case equality (===) for flexible pattern
|
22
|
+
# matching against each specified criterion.
|
23
|
+
#
|
24
|
+
# @param message [Object, nil] Pattern to match against log entry messages.
|
25
|
+
# Supports strings, regular expressions, or any object responding to ===
|
26
|
+
# @param severity [Integer, String, Symbol, nil] Severity level to match.
|
27
|
+
# Accepts numeric levels or symbolic names (:debug, :info, etc.)
|
28
|
+
# @param progname [Object, nil] Pattern to match against program names.
|
29
|
+
# Supports strings, regular expressions, or any object responding to ===
|
30
|
+
# @param attributes [Hash, nil] Hash of attribute patterns to match against
|
31
|
+
# log entry attributes. Supports nested attribute matching and dot notation
|
32
|
+
def initialize(message: nil, severity: nil, progname: nil, attributes: nil)
|
33
|
+
message = message.strip if message.is_a?(String)
|
34
|
+
@message_filter = message
|
35
|
+
@severity_filter = Severity.coerce(severity) if severity
|
36
|
+
@progname_filter = progname
|
37
|
+
@attributes_filter = Utils.expand_attributes(attributes) if attributes
|
38
|
+
end
|
39
|
+
|
40
|
+
# Test whether a log entry matches all specified criteria. The entry must
|
41
|
+
# satisfy all non-nil filter conditions to be considered a match. Uses
|
42
|
+
# case equality (===) for flexible pattern matching.
|
43
|
+
#
|
44
|
+
# @param entry [Lumberjack::LogEntry] The log entry to test against the matcher
|
45
|
+
# @return [Boolean] True if the entry matches all specified criteria, false otherwise
|
46
|
+
def match?(entry)
|
47
|
+
return false unless match_filter?(entry.message, @message_filter)
|
48
|
+
return false unless match_filter?(entry.severity, @severity_filter)
|
49
|
+
return false unless match_filter?(entry.progname, @progname_filter)
|
50
|
+
|
51
|
+
if @attributes_filter
|
52
|
+
attributes = Utils.expand_attributes(entry.attributes)
|
53
|
+
return false unless match_attributes?(attributes, @attributes_filter)
|
54
|
+
end
|
55
|
+
|
56
|
+
true
|
57
|
+
end
|
58
|
+
|
59
|
+
# Find the closest matching log entry from a list of candidates. This method
|
60
|
+
# scores each entry based on how well it matches the specified criteria and
|
61
|
+
# returns the entry with the highest score, provided it meets a minimum
|
62
|
+
# threshold. If no entries meet the threshold, nil is returned.
|
63
|
+
#
|
64
|
+
# @param entries [Array<Lumberjack::LogEntry>] The list of log entries to evaluate
|
65
|
+
# @return [Lumberjack::LogEntry, nil] The closest matching log entry or nil if none match
|
66
|
+
def closest(entries)
|
67
|
+
scored_entries = entries.map { |entry| [entry, entry_score(entry)] }
|
68
|
+
best_score = scored_entries.max_by { |_, score| score }
|
69
|
+
(best_score&.last.to_f >= Score::MIN_SCORE_THRESHOLD) ? best_score.first : nil
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def entry_score(entry)
|
75
|
+
Score.calculate_match_score(
|
76
|
+
entry,
|
77
|
+
message: @message_filter,
|
78
|
+
severity: @severity_filter,
|
79
|
+
attributes: @attributes_filter,
|
80
|
+
progname: @progname_filter
|
81
|
+
)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Apply a filter pattern against a value using case equality. Returns true
|
85
|
+
# if no filter is specified (nil) or if the filter matches the value.
|
86
|
+
#
|
87
|
+
# @param value [Object] The value to test against the filter
|
88
|
+
# @param filter [Object, nil] The filter pattern, nil means no filtering
|
89
|
+
# @return [Boolean] True if the filter matches or is nil, false otherwise
|
90
|
+
def match_filter?(value, filter)
|
91
|
+
return true if filter.nil?
|
92
|
+
|
93
|
+
filter === value
|
94
|
+
end
|
95
|
+
|
96
|
+
# Recursively match nested attribute structures against filter patterns.
|
97
|
+
# Handles both simple attribute matching and complex nested hash structures
|
98
|
+
# with support for partial matching and empty value detection.
|
99
|
+
#
|
100
|
+
# @param attributes [Hash] The expanded attributes hash from the log entry
|
101
|
+
# @param filter [Hash] The filter patterns to match against attributes
|
102
|
+
# @return [Boolean] True if all filter patterns match their corresponding attributes
|
103
|
+
def match_attributes?(attributes, filter)
|
104
|
+
return true unless filter
|
105
|
+
return false unless attributes
|
106
|
+
|
107
|
+
filter.all? do |name, value_filter|
|
108
|
+
name = name.to_s
|
109
|
+
attribute_values = attributes[name]
|
110
|
+
if attribute_values.is_a?(Hash)
|
111
|
+
if value_filter.is_a?(Hash)
|
112
|
+
match_attributes?(attribute_values, value_filter)
|
113
|
+
else
|
114
|
+
match_filter?(attribute_values, value_filter)
|
115
|
+
end
|
116
|
+
elsif value_filter.nil? || (value_filter.is_a?(Enumerable) && value_filter.empty?)
|
117
|
+
attribute_values.nil? || (attribute_values.is_a?(Array) && attribute_values.empty?)
|
118
|
+
elsif attributes.include?(name)
|
119
|
+
match_filter?(attribute_values, value_filter)
|
120
|
+
else
|
121
|
+
false
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|