query-stream 1.4.0 → 1.6.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.
@@ -2,8 +2,6 @@
2
2
 
3
3
  require 'date'
4
4
 
5
- # ================================================================
6
- # File: lib/query_stream/filter_engine.rb
7
5
  # ================================================================
8
6
  # 責務:
9
7
  # AND/OR/比較/Range によるフィルタリングとソート処理を行う。
@@ -13,6 +11,21 @@ require 'date'
13
11
  module QueryStream
14
12
  # フィルタリング&ソートエンジンモジュール
15
13
  module FilterEngine
14
+ # 西暦の日付(2024-01-01)
15
+ SEIREKI_DATE = /\A\d{4}-\d{2}-\d{2}\z/
16
+
17
+ # 和暦の日付(R06-01-01)。元号は頭文字で書く(M 明治・T 大正・S 昭和・H 平成・R 令和)。
18
+ # 年は 1 桁でもよい(R6-01-01)。
19
+ WAREKI_DATE = /\A(?<era>[MTSHR])(?<year>\d{1,2})-(?<month>\d{2})-(?<day>\d{2})\z/
20
+
21
+ # 大小の条件と、<=> の結果の判定
22
+ ORDER_TESTS = {
23
+ gt: ->(order) { order.positive? },
24
+ gte: ->(order) { order >= 0 },
25
+ lt: ->(order) { order.negative? },
26
+ lte: ->(order) { order <= 0 }
27
+ }.freeze
28
+
16
29
  module_function
17
30
 
18
31
  # フィルタ条件をレコード群に適用する
@@ -27,13 +40,56 @@ module QueryStream
27
40
  end
28
41
  end
29
42
 
30
- # ソート条件を適用する
43
+ # ソート条件を適用する。
44
+ # 比べるのは最も多い種類の値で、それ以外の値と、値が無いレコードは、向きによらず末尾に置く。
31
45
  # @param records [Array<Hash>] レコード群
32
46
  # @param sort [Hash] ソート条件 { field:, direction: }
33
47
  # @return [Array<Hash>] ソート後のレコード群
34
48
  def apply_sort(records, sort)
35
- sorted = records.sort_by { to_comparable(it[sort[:field]]) }
36
- sort[:direction] == :desc ? sorted.reverse : sorted
49
+ keyed = records.map do |record|
50
+ value = value_at(record, sort[:field])
51
+ [record, missing_value?(value) ? nil : to_comparable(value)]
52
+ end
53
+ kind = keyed.filter_map { |_, key| sort_kind(key) }.tally.max_by { |_, count| count }&.first
54
+
55
+ sortable, others = keyed.partition { |_, key| sort_kind(key) == kind }
56
+ sorted = sortable.sort_by(&:last).map(&:first)
57
+ (sort[:direction] == :desc ? sorted.reverse : sorted) + others.map(&:first)
58
+ end
59
+
60
+ # 抽出条件と並べ替えの項目が、どれか 1 件のレコードにあるかを確かめる。
61
+ # 無い項目(`tag=ruby` の打ち間違い)で絞ると 0 件になり、並べ替えは定義順のままになる。
62
+ # どちらも展開自体は成功するので、書いた本人は気づけない。テンプレートのキーと同じく、
63
+ # 見るのは一番上の階層のキーだけで、どれか 1 件にあれば正しい項目とみなす。
64
+ # @param records [Array<Hash>] 絞り込む前のレコード群
65
+ # @param parsed [Hash] QueryStreamParser.parse の結果
66
+ # @raise [InvalidQueryError] どのレコードにも無い項目を指している
67
+ def validate_fields!(records, parsed)
68
+ available_keys = records.flat_map(&:keys).uniq
69
+ return if available_keys.empty?
70
+
71
+ fields = condition_fields(parsed[:filters])
72
+ fields << parsed[:sort][:field] if parsed[:sort]
73
+ unknown = fields.find { !available_keys.include?(it.to_s.split('.').first.to_sym) }
74
+ return unless unknown
75
+
76
+ raise InvalidQueryError.new(reason: :unknown_field, condition: unknown.to_s, available_keys:)
77
+ end
78
+
79
+ # 条件が指す項目名を集める。OR の枝の中も見る。主キー検索は項目名を持たないので除く。
80
+ def condition_fields(filters)
81
+ filters.flat_map do |filter|
82
+ case filter
83
+ in { op: :or, branches: } then branches.flat_map { condition_fields(it) }
84
+ in { field: :_primary_key } then []
85
+ in { field: } then [field]
86
+ end
87
+ end
88
+ end
89
+
90
+ # ドットでつないだ項目名(:"author.name")をたどって値を返す。途中で途切れたら nil。
91
+ def value_at(record, field)
92
+ field.to_s.split('.').reduce(record) { |value, key| value.is_a?(Hash) ? value[key.to_sym] : nil }
37
93
  end
38
94
 
39
95
  # 単一フィルタ条件をレコードに対して評価する
@@ -46,29 +102,62 @@ module QueryStream
46
102
  return evaluate_primary_key_lookup(record, filter[:value])
47
103
  end
48
104
 
49
- field_value = record[filter[:field]]
105
+ field_value = value_at(record, filter[:field])
50
106
 
51
107
  case filter[:op]
52
108
  when :eq
53
109
  match_eq(field_value, filter[:value])
54
110
  when :neq
55
111
  !match_eq(field_value, filter[:value])
56
- when :gt
57
- to_comparable(field_value) > to_comparable(filter[:value])
58
- when :gte
59
- to_comparable(field_value) >= to_comparable(filter[:value])
60
- when :lt
61
- to_comparable(field_value) < to_comparable(filter[:value])
62
- when :lte
63
- to_comparable(field_value) <= to_comparable(filter[:value])
112
+ when :gt, :gte, :lt, :lte
113
+ satisfies_order?(field_value, filter)
64
114
  when :range
65
- range = filter[:value]
66
- range.cover?(to_comparable(field_value))
115
+ within_range?(field_value, filter[:value])
116
+ when :or
117
+ filter[:branches].any? { |branch| branch.all? { evaluate_filter(record, it) } }
67
118
  else
68
119
  false
69
120
  end
70
121
  end
71
122
 
123
+ # 大小の条件を満たすか。値が無いレコードと、比較できない値のレコードは満たさない
124
+ # @param field_value [Object] レコードの値
125
+ # @param filter [Hash] フィルタ条件 { op:, value: }
126
+ # @return [Boolean]
127
+ def satisfies_order?(field_value, filter)
128
+ return false if missing_value?(field_value)
129
+
130
+ order = to_comparable(field_value) <=> to_comparable(filter[:value])
131
+ !order.nil? && ORDER_TESTS.fetch(filter[:op]).call(order)
132
+ end
133
+
134
+ # 範囲に含まれるか。両端も比較用にそろえる。パーサーは "2024-06-01".."2024-06-30" のような
135
+ # 文字列の範囲を返すので、そのままでは Date の値を含まない。
136
+ # @param field_value [Object] レコードの値
137
+ # @param range [Range] 範囲
138
+ # @return [Boolean]
139
+ def within_range?(field_value, range)
140
+ Range.new(to_comparable(range.begin), to_comparable(range.end), range.exclude_end?)
141
+ .cover?(to_comparable(field_value))
142
+ rescue ArgumentError
143
+ # 両端を比較できない範囲(Range.new が受け付けない)
144
+ false
145
+ end
146
+
147
+ # 値が無いか(キーが無い・nil・空文字列)
148
+ def missing_value?(value) = value.nil? || value == ''
149
+
150
+ # ソートで比べられる値の種類。値が無いときは nil
151
+ def sort_kind(key)
152
+ case key
153
+ when nil then nil
154
+ when Numeric then :number
155
+ when Date then :date
156
+ when Time then :time
157
+ else :string
158
+ end
159
+ end
160
+
72
161
  # 主キー候補フィールドを順番に走査して一致するものを探す
73
162
  # @param record [Hash] 単一レコード
74
163
  # @param query_value [Object] 検索値
@@ -82,13 +171,25 @@ module QueryStream
82
171
  # 等値比較(配列・カンマ区切り文字列を透過的に扱う)
83
172
  # データ側が配列/カンマ区切りの場合、ORの値リストと交差判定する
84
173
  def match_eq(field_value, filter_values)
85
- field_list = normalize_to_list(field_value)
86
- value_list = Array(filter_values).map { it.to_s.strip }
174
+ # 綴りの揺れは両側でそろえる。条件側だけそろえると、データに "2024_01" や
175
+ # "R06-01-01" のような文字列があるとき、同じ綴りの条件で一致しなくなる。
176
+ field_list = normalize_to_list(field_value).map { canonical_token(it) }
177
+ value_list = Array(filter_values).map { canonical_token(it.to_s.strip) }
87
178
 
88
179
  # フィールド側の値リストと条件値リストに交差があれば一致
89
180
  (field_list & value_list).any?
90
181
  end
91
182
 
183
+ # 等値比較のため、値を綴りの揺れのない形へそろえる。
184
+ # 数は桁区切りを外し(9_000_000 → 9000000)、日付は西暦にする(R06-01-01 → 2024-01-01)。
185
+ # 存在しない日付は、等値比較では文字列のまま比べる。
186
+ def canonical_token(str)
187
+ date = parse_date(str)
188
+ date ? date.iso8601 : QueryStreamParser.strip_digit_separators(str)
189
+ rescue InvalidDateError
190
+ str
191
+ end
192
+
92
193
  # フィールド値をリスト化する(配列/カンマ区切り/単値を統一)
93
194
  def normalize_to_list(value)
94
195
  case value
@@ -103,20 +204,31 @@ module QueryStream
103
204
  end
104
205
  end
105
206
 
207
+ # 西暦・和暦の日付を Date に直す。日付の形でなければ nil を返す。
208
+ # 和暦は Date.jisx0301 に任せる。Date.parse は "R06-01-01" を 2006-01-01 と
209
+ # 読み違えるので使えない。jisx0301 の区切りはドットで、年は 2 桁が要る。
210
+ # 元号の境目は検査しない(H31-05-01 も 2019-05-01 として読む)。
211
+ # @raise [InvalidDateError] 日付の形だが存在しない日(2024-02-30 など)
212
+ def parse_date(value)
213
+ if value.match?(SEIREKI_DATE)
214
+ Date.parse(value)
215
+ elsif (m = value.match(WAREKI_DATE))
216
+ Date.jisx0301("#{m[:era]}#{m[:year].rjust(2, '0')}.#{m[:month]}.#{m[:day]}")
217
+ end
218
+ rescue Date::Error
219
+ raise InvalidDateError, "無効な日付: #{value}"
220
+ end
221
+
106
222
  # 比較用に値を正規化する(数値変換・日付変換を試みる)
107
223
  def to_comparable(value)
108
224
  case value
109
225
  when Date, Time
110
226
  value
111
227
  when String
112
- # 日付形式(YYYY-MM-DD)を検出して Date に変換
113
- if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
114
- begin
115
- return Date.parse(value)
116
- rescue Date::Error
117
- raise InvalidDateError, "無効な日付: #{value}"
118
- end
119
- end
228
+ # 日付形式(西暦・和暦)を検出して Date に変換
229
+ date = parse_date(value)
230
+ return date if date
231
+
120
232
  # 数値変換を試みる
121
233
  case value
122
234
  when /\A-?\d+\z/
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # ================================================================
4
- # File: lib/query_stream/query_stream_parser.rb
5
3
  # ================================================================
6
4
  # 責務:
7
5
  # QueryStream 記法(= books | tags=ruby | -title | 5 | :full)を
@@ -27,6 +25,20 @@ module QueryStream
27
25
  # AND 条件の区切りパターン
28
26
  AND_PATTERN = /\s+(?:AND|and|&&)\s+/
29
27
 
28
+ # OR 条件の区切りパターン。`||` は記法の区切りの `|` と衝突するので使わない
29
+ OR_PATTERN = /\s+(?:OR|or)\s+/
30
+
31
+ # 桁をアンダースコアで区切った数(9_000_000 / 900_0000 / 1_234.5)。
32
+ # Ruby の数値リテラルと同じく、区切りは数字と数字のあいだにだけ置ける。
33
+ # 区切り方は問わないので、万の位で区切る 900_0000 も書ける。
34
+ NUMBER_WITH_SEPARATORS = /\A-?\d+(?:_\d+)*(?:\.\d+(?:_\d+)*)?\z/
35
+
36
+ # 抽出条件・並べ替えの項目名。テンプレートの `= author.name` と同じく、ドットでネストした値を指せる。
37
+ FIELD = /[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*/
38
+
39
+ # 演算子の文字。値だけの節(項目名を省いた `beginner`)に含まれていれば、読み損ねた条件である。
40
+ OPERATOR_CHARS = /[=!<>]/
41
+
30
42
  module_function
31
43
 
32
44
  # QueryStream 記法をパースして構造化ハッシュを返す
@@ -137,16 +149,20 @@ module QueryStream
137
149
  tokens
138
150
  end
139
151
 
140
- # ソート指定をパースする
141
- # @param token [String] "-title" / "+title" / "title"
152
+ # ソート指定をパースする。
153
+ #
154
+ # **符号は必須である。** 呼び出し側(classify_tokens)が `+` か `-` で始まることを確かめてから渡す。
155
+ # 符号なしの `title` は、`= book | たのしいRuby` と区別できないので主キー検索として扱う。
156
+ # 項目名として読めない綴り(`-title foo`)は、並べ替えずに出すと気づけないのでエラーにする。
157
+ #
158
+ # @param token [String] "-title" / "+author.name"
142
159
  # @return [Hash] { field:, direction: }
160
+ # @raise [InvalidQueryError] 項目名として読めない
143
161
  def parse_sort(token)
144
- case token
145
- in /\A-(.+)\z/
146
- { field: $1.to_sym, direction: :desc }
147
- in /\A\+?(.+)\z/
148
- { field: $1.to_sym, direction: :asc }
149
- end
162
+ field = token[1..].strip
163
+ raise InvalidQueryError.new(condition: token) unless field.match?(/\A#{FIELD}\z/o)
164
+
165
+ { field: field.to_sym, direction: token.start_with?('-') ? :desc : :asc }
150
166
  end
151
167
 
152
168
  # 直前フィールドを引き継いで値のみの条件をパースする
@@ -157,37 +173,39 @@ module QueryStream
157
173
  parse_eq_condition(field, value_str)
158
174
  end
159
175
 
160
- # AND で接続されたフィルタ式をパースする
161
- # @param expression [String] "tags=ruby && category=web"
162
- # @return [Array<Hash>] フィルタ条件のリスト
176
+ # AND / OR で接続されたフィルタ式をパースする。AND は OR より強く結び付く。
177
+ # 項目名を省いた値は、直前の条件の項目を引き継ぐ(tags=ruby AND beginner)。
178
+ # @param expression [String] "tags=ruby && category=web" / "tags=ruby OR tags=go"
179
+ # @return [Array<Hash>] すべて満たすフィルタ条件のリスト。OR は { op: :or, branches: } の 1 条件にまとめる
163
180
  def parse_filter_expression(expression)
164
- # AND で分割
165
- clauses = expression.split(AND_PATTERN)
166
181
  previous_field = nil
167
- previous_filter = nil
168
182
 
169
- clauses.flat_map do |clause|
170
- clause = clause.strip
171
- next [] if clause.empty?
183
+ branches = expression.split(OR_PATTERN).map do |branch|
184
+ branch.split(AND_PATTERN).flat_map do |clause|
185
+ parsed = parse_clause(clause.strip, previous_field)
186
+ previous_field = parsed.last&.[](:field) || previous_field
187
+ parsed
188
+ end
189
+ end.reject(&:empty?)
172
190
 
173
- parsed = parse_single_condition(clause)
191
+ branches.size > 1 ? [{ op: :or, branches: }] : branches.flatten
192
+ end
174
193
 
175
- if parsed.empty? && previous_field
176
- if previous_filter && previous_filter[:field] == previous_field && previous_filter[:op] == :eq
177
- additional = parse_values(clause)
178
- previous_filter[:value] = Array(previous_filter[:value]) + additional
179
- next []
180
- else
181
- parsed = parse_value_only_condition(previous_field, clause)
182
- end
183
- end
194
+ # AND / OR で区切られた 1 節をパースする。
195
+ # 読めない節を空の条件として捨てると、全件が出て絞り込みが黙って外れるので、エラーにする。
196
+ # 値だけの節は、直前の項目があり、演算子を含まないときだけ受け付ける(`年=2019` を値にしない)。
197
+ # @param clause [String] "tags=ruby" / "beginner"
198
+ # @param previous_field [Symbol, nil] 項目名を省いたときに引き継ぐ項目
199
+ # @return [Array<Hash>] フィルタ条件
200
+ # @raise [InvalidQueryError] 条件として読めない
201
+ def parse_clause(clause, previous_field)
202
+ return [] if clause.empty?
184
203
 
185
- last_filter = parsed.last
186
- previous_field = last_filter&.[](:field)
187
- previous_filter = last_filter if last_filter&.[](:op) == :eq
204
+ parsed = parse_single_condition(clause)
205
+ return parsed unless parsed.empty?
206
+ return parse_value_only_condition(previous_field, clause) if previous_field && !clause.match?(OPERATOR_CHARS)
188
207
 
189
- parsed
190
- end
208
+ raise InvalidQueryError.new(condition: clause)
191
209
  end
192
210
 
193
211
  # 単一条件をパースする(OR はカンマ区切りで表現)
@@ -196,17 +214,17 @@ module QueryStream
196
214
  def parse_single_condition(condition)
197
215
  # 比較演算子の検出(!=, >=, <=, >, <, ==, = の順で試行)
198
216
  case condition.strip
199
- in /\A([a-zA-Z_]+)\s*!=\s*(.+)\z/
217
+ in /\A(#{FIELD})\s*!=\s*(.+)\z/
200
218
  [{ field: $1.to_sym, op: :neq, value: parse_values($2) }]
201
- in /\A([a-zA-Z_]+)\s*>=\s*(.+)\z/
219
+ in /\A(#{FIELD})\s*>=\s*(.+)\z/
202
220
  [{ field: $1.to_sym, op: :gte, value: parse_numeric($2.strip) }]
203
- in /\A([a-zA-Z_]+)\s*<=\s*(.+)\z/
221
+ in /\A(#{FIELD})\s*<=\s*(.+)\z/
204
222
  [{ field: $1.to_sym, op: :lte, value: parse_numeric($2.strip) }]
205
- in /\A([a-zA-Z_]+)\s*>\s*(.+)\z/
223
+ in /\A(#{FIELD})\s*>\s*(.+)\z/
206
224
  [{ field: $1.to_sym, op: :gt, value: parse_numeric($2.strip) }]
207
- in /\A([a-zA-Z_]+)\s*<\s*(.+)\z/
225
+ in /\A(#{FIELD})\s*<\s*(.+)\z/
208
226
  [{ field: $1.to_sym, op: :lt, value: parse_numeric($2.strip) }]
209
- in /\A([a-zA-Z_]+)\s*={1,2}\s*(.+)\z/
227
+ in /\A(#{FIELD})\s*={1,2}\s*(.+)\z/
210
228
  parse_eq_condition($1.strip, $2.strip)
211
229
  else
212
230
  # パースできない場合は空で返す
@@ -242,6 +260,9 @@ module QueryStream
242
260
  # 通常の等値条件(カンマ区切りでOR)
243
261
  [{ field: field_sym, op: :eq, value: parse_values(value_str) }]
244
262
  end
263
+ rescue ArgumentError
264
+ # 両端を比較できない範囲(2020..abc)は Range を作れないので、文字列の値として扱う
265
+ [{ field: field_sym, op: :eq, value: parse_values(value_str) }]
245
266
  end
246
267
 
247
268
  # カンマ区切りの値をリストとしてパースする
@@ -255,7 +276,7 @@ module QueryStream
255
276
  # @param str [String] "20" / "3.14" / "東京"
256
277
  # @return [Integer, Float, String] 変換後の値
257
278
  def parse_numeric(str)
258
- s = str.strip
279
+ s = strip_digit_separators(str.strip)
259
280
  case s
260
281
  when /\A-?\d+\z/ then s.to_i
261
282
  when /\A-?\d+\.\d+\z/ then s.to_f
@@ -263,6 +284,13 @@ module QueryStream
263
284
  end
264
285
  end
265
286
 
287
+ # 数の桁区切り(アンダースコア)を外す。数でない値(tag_name など)はそのまま返す。
288
+ # @param str [String] "9_000_000" / "ruby"
289
+ # @return [String] "9000000" / "ruby"
290
+ def strip_digit_separators(str)
291
+ str.match?(NUMBER_WITH_SEPARATORS) ? str.delete('_') : str
292
+ end
293
+
266
294
  # 主キー候補フィールドによる一件検索フィルタを構築する
267
295
  # すべての主キー候補に対して OR 的に検索する
268
296
  # @param value [String] 検索値
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # ================================================================
4
- # File: lib/query_stream/singularize.rb
5
3
  # ================================================================
6
4
  # 責務:
7
5
  # 英単語の複数形を単数形に変換する軽量ヘルパー。
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # ================================================================
4
- # File: lib/query_stream/template_compiler.rb
5
3
  # ================================================================
6
4
  # 責務:
7
5
  # テンプレートファイル(_book.md 等)にデータを流し込み、
@@ -9,6 +7,8 @@
9
7
  #
10
8
  # 変換ルール:
11
9
  # - `= key` のみの行 → key の値を展開(nil/空文字なら行スキップ)
10
+ # - `= key` が 2 つ以上ある行 → 区切り(/・、,,)と括弧で部品に分け、
11
+ # 空のキーを含む部品だけを消す(部品が全部消えたら行スキップ。表の行は対象外)
12
12
  # - `prefix = key` → prefix を残してkey の値を展開
13
13
  # - `![](key)` → 変数展開(拡張子なし → 変数、拡張子あり → リテラル)
14
14
  # - `= key` を含まない行 → リテラル出力(ヘッダー等は一度だけ出力)
@@ -44,6 +44,21 @@ module QueryStream
44
44
  # VFM フェンス終了行: 単独の :::
45
45
  FENCE_CLOSE_PATTERN = /\A:::\s*\z/
46
46
 
47
+ # 部品の区切り: 全角の斜線・中黒・読点・コンマと半角のコンマ(前後の空白ごと)、
48
+ # 前後に空白のある半角の斜線。空白なしの半角の斜線を区切りにしないのは、
49
+ # URL(https://example.com/books/= id)を割らないため。
50
+ PART_SEPARATOR = %r{\s*[/・、,,]\s*|\s+/\s+}
51
+
52
+ # 変数を含みうる括弧(全角・半角)。中身ごと 1 つの部品として扱う(直前の空白も含める)。
53
+ # `]` の直後の括弧は Markdown のリンク先(`[= title](= url)`)なので含めない。
54
+ OPTIONAL_GROUP = /\s*(?<!\])[((][^()()]*[))]/
55
+
56
+ # 行頭の Markdown の目印(箇条書き・番号・引用・見出し)。部品が消えても残す。
57
+ LINE_MARKER = /\A\s*(?:(?:[-*+]|\d+\.)\s+|>\s?|\#{1,6}\s+)*/
58
+
59
+ # 括弧を部品として退避するときの目印(テンプレートに現れない制御文字で挟む)
60
+ GROUP_PLACEHOLDER = /\u0000(\d+)\u0000/
61
+
47
62
  module_function
48
63
 
49
64
  # テンプレートにレコード群を流し込んでテキストを生成する
@@ -55,7 +70,7 @@ module QueryStream
55
70
  # @return [String] 展開後のテキスト
56
71
  def render(template, records, source_filename: nil, line_number: nil, template_path: nil)
57
72
  lines = template.lines
58
- validate_template_keys!(lines, records.first, source_filename:, line_number:, template_path:) if records.any?
73
+ validate_template_keys!(lines, records, source_filename:, line_number:, template_path:) if records.any?
59
74
 
60
75
  parts = classify_lines(lines)
61
76
  return '' if records.empty?
@@ -124,6 +139,9 @@ module QueryStream
124
139
  end
125
140
 
126
141
  output.join
142
+ rescue NestedValueError => e
143
+ # 値を表示するときに見つかるので、ここで直すテンプレートと記法の位置を付け足す
144
+ raise e.with_context(template_path:, location: source_filename ? "#{source_filename}:#{line_number}" : '')
127
145
  end
128
146
 
129
147
  # テンプレート行を分類する
@@ -219,7 +237,7 @@ module QueryStream
219
237
  end
220
238
 
221
239
  # テンプレート行をレコードデータで展開する
222
- # nil/空文字のキーがあれば行ごとスキップ(nil を返す)
240
+ # 行ごと消すときは nil を返す
223
241
  # @param line [String] テンプレート行
224
242
  # @param record [Hash] データレコード
225
243
  # @return [String, nil] 展開後の行、またはスキップ時 nil
@@ -256,12 +274,12 @@ module QueryStream
256
274
  match
257
275
  else
258
276
  # 変数として展開(ドット記法対応)
259
- value = resolve_nested_value(record, src)
260
- if value.nil? || value.to_s.strip.empty?
277
+ text = display_text(resolve_nested_value(record, src), src)
278
+ if text.strip.empty?
261
279
  skip = true
262
280
  match # gsub のブロックからは文字列を返す必要がある
263
281
  else
264
- "![#{alt}](#{value})#{attr}"
282
+ "![#{alt}](#{text})#{attr}"
265
283
  end
266
284
  end
267
285
  end
@@ -270,24 +288,108 @@ module QueryStream
270
288
  end
271
289
 
272
290
  # = key パターンの変数を展開する(ドット記法対応)
291
+ #
292
+ # 変数が 2 つ以上ある行は、空のキーを含む部品だけを消す(expand_parts)。
293
+ # `= author.name 著/= publisher/= year 年` で発行年だけが無い本でも、著者と出版社は出る。
294
+ # 変数が 1 つの行と表の行(セルを消すと列がずれる)は、空なら行ごと消す。
273
295
  # @param line [String] テンプレート行
274
296
  # @param record [Hash] データレコード
275
297
  # @return [String, nil] 展開後の行、またはスキップ時 nil
276
298
  def expand_variables(line, record)
299
+ return expand_parts(line, record) if partial_line?(line)
300
+
301
+ expand_whole(line, record)
302
+ end
303
+
304
+ # 部品ごとに消せる行か。変数が 2 つ以上あり、表の行でないもの。
305
+ def partial_line?(line)
306
+ !line.lstrip.start_with?('|') && line.scan(VARIABLE_PATTERN).size >= 2
307
+ end
308
+
309
+ # 区切りと括弧で行を部品に分け、空のキーを含む部品だけを消す。
310
+ #
311
+ # - 変数を含む括弧(`(= year 年)`)は 1 つの部品。空なら直前の空白ごと消す。
312
+ # - 括弧の外は PART_SEPARATOR で分ける。残った部品は、前の部品の後ろにあった区切りでつなぐ。
313
+ # - 行頭の Markdown の目印(`- ` など)と行末の改行は、部品が消えても残す。
314
+ # - 変数を含む部品が 1 つも残らなければ nil(行ごと消す)。
315
+ # キーがすべて埋まっていれば、区切りも括弧も元のまま戻るので、出力は expand_whole と同じになる。
316
+ # @param line [String] テンプレート行
317
+ # @param record [Hash] データレコード
318
+ # @return [String, nil] 展開後の行、またはスキップ時 nil
319
+ def expand_parts(line, record)
320
+ newline = line[/\R\z/].to_s
321
+ body = line.chomp
322
+ marker = body[LINE_MARKER]
323
+ body = body.delete_prefix(marker)
324
+
325
+ groups = []
326
+ masked = body.gsub(OPTIONAL_GROUP) do |group|
327
+ next group unless group.match?(VARIABLE_PATTERN)
328
+
329
+ groups << group
330
+ "\u0000#{groups.size - 1}\u0000"
331
+ end
332
+
333
+ kept = masked.split(/(#{PART_SEPARATOR})/).each_slice(2).filter_map do |part, separator|
334
+ text = expand_part(part, groups, record)
335
+ text && [text, separator.to_s, variable_part?(part)]
336
+ end
337
+ return nil unless kept.any? { |_, _, variable| variable }
338
+
339
+ joined = kept.each_with_index.map { |(text, separator, _), i| i == kept.size - 1 ? text : text + separator }.join
340
+ "#{marker}#{joined}#{newline}"
341
+ end
342
+
343
+ # 部品 1 つを展開する。空のキーを含めば nil。括弧は中のキーが空ならそこだけ消す。
344
+ # 括弧の外を先に展開するのは、値の中の `= …` をもう一度展開しないため(括弧は退避中)。
345
+ def expand_part(part, groups, record)
346
+ outer = expand_whole(part, record)
347
+ return nil unless outer
348
+
349
+ text = outer.gsub(GROUP_PLACEHOLDER) { expand_whole(groups[::Regexp.last_match(1).to_i], record).to_s }
350
+ text.strip.empty? ? nil : text
351
+ end
352
+
353
+ def variable_part?(part) = part.match?(VARIABLE_PATTERN) || part.match?(GROUP_PLACEHOLDER)
354
+
355
+ # 行の変数をすべて展開する。空のキーが 1 つでもあれば nil(行ごと消す)。
356
+ def expand_whole(line, record)
277
357
  result = line.dup
278
358
 
279
359
  result.gsub!(VARIABLE_PATTERN) do |_match|
280
360
  key_path = $1
281
- value = resolve_nested_value(record, key_path)
282
- if value.nil? || value.to_s.strip.empty?
283
- return nil # 行ごとスキップ
284
- end
285
- value.to_s
361
+ text = display_text(resolve_nested_value(record, key_path), key_path)
362
+ return nil if text.strip.empty? # 行ごとスキップ
363
+
364
+ text
286
365
  end
287
366
 
288
367
  result
289
368
  end
290
369
 
370
+ # 値を、テンプレートに埋め込む文字列にする。値が無いときは空文字列(呼び出し側が行・部品を消す)。
371
+ #
372
+ # 配列(tags: [ruby, beginner])は、抽出条件と同じく複数の値として扱い、list_separator でつなぐ。
373
+ # 空の要素は落とし、全部空なら値が無いのと同じにする。
374
+ # ハッシュと、ハッシュや配列を含む配列は、どう並べて出すかを gem が決められない。
375
+ # 黙って Ruby の表記({name: "…"})を出さず、NestedValueError で書き方を直してもらう。
376
+ # `= author` は `= author.name` の書き間違いであることがほとんどなので、中のキーを渡す。
377
+ # @param value [Object, nil] resolve_nested_value の結果
378
+ # @param key_path [String] テンプレートに書かれたキー(エラーの報告用)
379
+ # @return [String]
380
+ # @raise [NestedValueError] ハッシュ、またはハッシュや配列を含む配列
381
+ def display_text(value, key_path)
382
+ case value
383
+ in nil then ''
384
+ in Hash then raise NestedValueError.new(key_path:, available_keys: value.keys)
385
+ in Array if value.any? { it.is_a?(Hash) || it.is_a?(Array) }
386
+ raise NestedValueError.new(key_path:, available_keys: [], list: true)
387
+ in Array
388
+ value.map { it.to_s.strip }.reject(&:empty?).join(QueryStream.configuration.list_separator)
389
+ else value.to_s
390
+ end
391
+ end
392
+
291
393
  # ドット記法のキーパスをたどってネストされた値を取得する
292
394
  # @param record [Hash] データレコード
293
395
  # @param key_path [String] キーパス(例: "author.name")
@@ -303,16 +405,19 @@ module QueryStream
303
405
  end
304
406
 
305
407
  # テンプレート内のキーがデータに存在するかを検証する
408
+ #
409
+ # どれか 1 件にでもあるキーは正しいキーとみなす。省ける項目(発行年など)が最初の 1 件に
410
+ # 無いだけで、打ち間違いとして止めないため。値の無い件では、その行・部品を消す(expand_variables)。
306
411
  # @param lines [Array<String>] テンプレートの行リスト
307
- # @param sample_record [Hash] サンプルレコード(最初の1件)
412
+ # @param records [Array<Hash>] レコード群
308
413
  # @param source_filename [String, nil] エラー報告用ファイル名
309
414
  # @param line_number [Integer, nil] エラー報告用行番号
310
415
  # @param template_path [String, nil] エラー報告用テンプレートパス
311
- def validate_template_keys!(lines, sample_record, source_filename: nil, line_number: nil, template_path: nil)
312
- return unless sample_record
416
+ def validate_template_keys!(lines, records, source_filename: nil, line_number: nil, template_path: nil)
417
+ available_keys = records.flat_map(&:keys).uniq
418
+ return if available_keys.empty?
313
419
 
314
420
  location = source_filename ? "#{source_filename}:#{line_number}" : ''
315
- available_keys = sample_record.keys
316
421
  in_code_block = false
317
422
 
318
423
  lines.each do |line|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module QueryStream
4
- VERSION = '1.4.0'
4
+ VERSION = '1.6.0'
5
5
  end