query-stream 1.4.0 → 1.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 399e9fb8aea90ac45fa0d7837c9891c7db5785b2bd81f6976c697eaa1d92f35c
4
- data.tar.gz: 6587887f665b0a6ffde78dc029e2988bd647debcd92f0740766805b273f2ea61
3
+ metadata.gz: '0386ceba9bb9c31fb12bc7d02a7ef3ab23e1df189738e496e6bca25dd14b1cc9'
4
+ data.tar.gz: 1bc4884b4426a0bd2bd76ffa5c1f865742857905f7ba2c4dd2a85e57c1b5aaab
5
5
  SHA512:
6
- metadata.gz: 47693f041e99aa59784a2d50a55fca8d1aa50c18798a16862a946a5c6ed1faedeafbc89a4fa2113d80fb793d2ef97d031a2e578ba64ab695f42e2ae3c946013a
7
- data.tar.gz: '095ed49dca369ea7420c037ddf95d77bbf2833b85d5b9a312393f31694632ee9744b6bfc319dfdabd55b5025fcd82f9921d0d2e80b3d988a407145e2902b3132'
6
+ metadata.gz: 9157468004852779de4ceb88f3ee7cf248ab2c88da7a97b6a2126831f9eaf2e24016909cea8283ac11bbdcbd909da15d576fd121cfea08403f832e4d996afb48
7
+ data.tar.gz: 1a92018c0677b067a487d07a51c6f337daa7bdb66ec3282aa0e75fec2e2f402ba090c23d02a0ec1f5746ecd4b5fb17c74137a4114793fea626dc251dd55c6481
data/CHANGELOG.md CHANGED
@@ -10,6 +10,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
  - パフォーマンス最適化
11
11
  - 追加のテンプレートスタイル
12
12
 
13
+ ## [Unreleased]
14
+
15
+ ## [1.5.0] - 2026-11-01
16
+
17
+ ### Added
18
+ - **値の無い項目の部分だけを消せるようにした** (`template_compiler.rb`): 変数が 2 つ以上ある行は、区切り(`/`・`・`・`、`・`,`・`,` と、前後に空白のある ` / `)と、変数を含む括弧(`(…)`・`(…)`)で部品に分け、値の無い変数を含む部品だけを、区切りを詰めて消す。`= author.name 著/= publisher/= year 年` は、発行年の分からない本でも `高橋征義 著/SBクリエイティブ` と出る(これまでは著者まで行ごと消えていた)。`= author.name 著(= year年)` も、発行年が無ければ `高橋征義 著` になる。部品が 1 つも残らなければ、今までどおり行ごと消す。すべての値がそろった行の出力は変わらない。
19
+ - 変数が 1 つだけの行と、表の行(`|` で始まる行)は今までどおり行ごと消す(セルを消すと列がずれる)。
20
+ - 行頭の Markdown の目印(`- `・`1. `・`> `・`# `)は、先頭の部品が消えても残す。
21
+ - 空白なしの半角の `/` は区切りにせず(URL を割らない)、`]` の直後の括弧(リンク先)も部品にしない。
22
+ - **抽出条件の数を、アンダースコアで桁区切りして書けるようにした** (`query_stream_parser.rb` / `filter_engine.rb`): `population>=9_000_000` や `population=900_0000..` のように書ける。Ruby の数値リテラルと同じく区切りは数字と数字のあいだにだけ置け、区切る位置は問わない(万の位で区切る `900_0000` も書ける)。比較(`>=` など)・範囲(`..`)・主キー検索に加え、等値(`=`)でも区切りを外して比べる。等値ではデータ側の値からも区切りを外すので、データに `"2024_01"` のような文字列があっても同じ綴りの条件で一致する。`snake_case` のような数でない値のアンダースコアはそのまま残す。
23
+ - **日付を和暦(`R06-01-01`)でも書けるようにした** (`filter_engine.rb`): データでも抽出条件でも、西暦の `2024-01-01` と同じ日付として比べ、並べ替える。元号は頭文字(M・T・S・H・R)で書き、年は 1 桁でもよい(`R6-01-01`)。等値(`=`)でも和暦と西暦を同じ日として扱う。変換は `Date.jisx0301` に任せた——`Date.parse` は `"R06-01-01"` を 2006-01-01 と読み違えるため使えない。元号の境目は検査しない(`H31-05-01` も 2019-05-01 として読む)。存在しない日(`R06-02-30`)は、西暦と同じく `InvalidDateError` になる。
24
+
25
+ - **抽出条件に `OR` / `or` を足した** (`query_stream_parser.rb` / `filter_engine.rb`): `tags=ruby OR year>=2020` のように、別の項目の条件もつなげる。AND は OR より強く結び付き、`a=1 AND b=2 OR c=3` は「a かつ b」または「c」になる。2 件目以降の項目名は省ける(`tags=ruby OR go`)。`||` は記法の区切りの `|` と衝突するので扱わない。前後に空白のある `OR` / `or` は区切りになるので、抽出条件の値に書けなくなった(`title=Rock or Pop`)。値だけの主キー検索(`= book | Rock or Pop`)は今までどおり 1 つの検索語として扱う。
26
+
27
+ ### Fixed
28
+ - **項目名を省いた AND が OR として動いていた** (`query_stream_parser.rb`): `tags=ruby AND beginner` は、`ruby` と `beginner` の両方を含むレコードを選ぶ仕様だが、値を前の条件の値リストへ足していたため、どちらかを含むレコードを選んでいた。別の条件として組み立てる。誤った動きを期待していたテストも直した。
29
+ - **比較とソートの対象の項目に値が無いレコードや比較できない値があると、`ArgumentError` が `render` の外へ出ていた** (`filter_engine.rb`): `on_error` にも渡らず、展開全体が止まった。比較では該当のレコードを除き、ソートでは最も多い種類の値を比べ、それ以外の値と値が無いレコードを昇順でも降順でも末尾に置く。
30
+ - **両端を比較できない範囲で `ArgumentError` が出ていた** (`query_stream_parser.rb` / `filter_engine.rb`): `year=2020..abc` は `Range` を作れない。文字列の値として扱い、どのレコードにも一致しない。
31
+ - **省ける項目が最初の 1 件に無いと、打ち間違いとして止まっていた** (`template_compiler.rb`): テンプレートのキーを確かめるとき、最初の 1 件のキーしか見ていなかった。1 冊目に `year` が無いだけで `UnknownKeyError` になり、値の無い項目を消す仕組みが働く前に止まった。どれか 1 件にあるキーは正しいキーとみなす。どの件にも無いキーは、今までどおり打ち間違いとして知らせる。
32
+ - **日付の範囲指定が 1 件も返さなかった** (`filter_engine.rb`): `date=2024-06-01..2024-06-30` のような範囲は、両端が文字列のまま比べられ、日付に直したデータの値を含まなかった。両端も比較用にそろえてから判定する。数の範囲は両端が最初から数なので影響を受けていなかった。
33
+ - **昇順ソートの書き方が README に無かった** (`README.md`): 例に並ぶのは降順(`-title`)だけで、昇順の `+title` はどこにも出てこない。実装は昇順を持っている——出口が塞がっていただけである。
34
+
35
+ 自然に思いつくのは符号を付けない `= books | title` のほうだが、これは**主キー検索**として解釈され、該当なしの警告とともに何も展開されない(`= book | 楽しいRuby` と綴りが同じで、符号の有無だけが見分ける手掛かりになっている)。警告は出るので黙って壊れはしないが、書いた本人は「ソートしたつもり」なので、読んで気づける場所に書いておく必要があった。例へ `+title` / `-year` を足し、符号が必須である理由も添えた。
36
+
37
+ - **一度も実行されない分岐が残っていた** (`query_stream_parser.rb`): `parse_sort` は符号を省いた `title` を昇順として扱う分岐(`/\A?(.+)\z/`)を持ち、コメントも受け付ける綴りとして `"title"` を挙げていた。しかし振り分け側(`parse_segment`)は `/\A[+-][a-zA-Z_]/` に一致した綴りしか渡さないため、**符号なしはここへ届かない**。
38
+
39
+ 素の名前を昇順にすると主キー検索と区別が付かなくなるので、**いまの仕様のほうが正しい**。分岐とコメントが古かったので、符号が必須であることを前提に書き直した。挙動は変えていない(`+` / `-` の両方を実測で確認)。
40
+
41
+ ### Changed
42
+ - **対応 Ruby を 3.4.10 / 4.0.9 にした**。4.0.9 は 2026-10-23 公開。
43
+ - **依存を最新へ上げた**: Minitest → 6.0.6、samovar 2.4.1 → 2.5.1、rake 13.3.1 → 13.4.2。gemspec の minitest は `~> 5.22` から `>= 5.22` へ緩めた——5 系に固定する理由が無く、6 でも 158 件すべて通る(`stub` / `mock` を使っていないため、6 での `minitest/mock` 分離の影響も受けない)。
44
+ - **rubocop を入れた**(`.rubocop.yml`・`rake rubocop`・CI)。見るのは `Lint` と `Security` だけで、引用符や行数は見ない。他の 2 つの gem と同じ構えである。現状 0 件。
45
+ - **README と CHANGELOG の「vivlio-style」を「Vivlio Starter」にした**。製品名を崩した綴りだった。
46
+
13
47
  ## [1.4.0] - 2026-08-07
14
48
 
15
49
  ### Removed
@@ -115,7 +149,7 @@ QueryStream 1.0.0 としてリリース!YAML/JSON データファイルとテ
115
149
  ### 📚 ドキュメント整備
116
150
  - 詳細な README と具体例
117
151
  - 完全な CHANGELOG
118
- - vivlio-style での実装例
152
+ - Vivlio Starter での実装例
119
153
 
120
154
  ### 🔧 技術的特徴
121
155
  - **Ruby 4.0+** モダン開発標準準拠
data/README.md CHANGED
@@ -38,12 +38,118 @@ result = QueryStream.render(source, data_dir: 'data', templates_dir: 'templates'
38
38
  ```
39
39
  = books # 全件展開
40
40
  = books | tags=ruby # タグで絞り込み
41
+ = books | +title # タイトルの昇順
42
+ = books | -year # 発行年の降順
41
43
  = books | tags=ruby | -title | 5 # 絞り込み+降順ソート+5件
44
+ = prefectures | population>=9_000_000 # 数は _ で桁を区切れる(900_0000 も可)
45
+ = weather_reports | date>=R06-01-01 # 日付は西暦(2024-01-01)でも和暦でも書ける
42
46
  = book | 楽しいRuby # 主キーで一件検索
43
47
  = books | :full # fullスタイルで展開
44
48
  ```
45
49
 
46
- ### 具体例:vivlio-style での書籍データ展開
50
+ **ソートには符号を必ず付けてください**(`+` が昇順、`-` が降順)。符号を省いた
51
+ `= books | title` はソートではなく**主キー検索**として扱われ、該当なしの警告とともに
52
+ 何も展開されません。`= book | 楽しいRuby` のような一件検索と綴りが同じになるため、
53
+ 符号の有無で見分けています。
54
+
55
+ ソートでは、最も多い種類の値(数・日付・文字列)を比べます。値が無いレコードと、それ以外の
56
+ 種類の値のレコードは、昇順でも降順でも末尾に置きます。
57
+
58
+ 日付は、データでも抽出条件でも `2024-01-01`(西暦)か `R06-01-01`(和暦)で書きます。
59
+ 和暦の元号は頭文字で書き(`M` 明治・`T` 大正・`S` 昭和・`H` 平成・`R` 令和)、年は 1 桁でも
60
+ 構いません(`R6-01-01`)。どちらで書いても同じ日付として比べ、並べ替えます。
61
+
62
+ ### 指定の順序
63
+
64
+ `|` で区切った指定は、省略しても、順序を入れ替えても構いません。形で種類を判別します。
65
+
66
+ | 形 | 種類 |
67
+ |---|---|
68
+ | `:full`・`:card.html` | スタイル |
69
+ | `+title`・`-title` | ソート |
70
+ | `tags=ruby`・`year>=2020` のように演算子を含むもの | 抽出条件 |
71
+ | 正の整数 | 件数 |
72
+ | それ以外の値 | 主キー検索 |
73
+
74
+ 単数形のデータ名に続く最初の指定は、数でも主キー検索として扱います(`= prefecture | 13`)。
75
+
76
+ ### 抽出条件
77
+
78
+ | 書き方 | 意味 |
79
+ |---|---|
80
+ | `tags=ruby`・`tags==ruby` | 等しい |
81
+ | `tags=ruby, go` | どちらかに等しい(OR) |
82
+ | `tags!=ruby` | 等しくない |
83
+ | `tags!=ruby, go` | どちらにも等しくない |
84
+ | `year>2020`・`year>=2020` | より大きい・以上 |
85
+ | `year<2020`・`year<=2020` | より小さい・以下 |
86
+ | `year=2020..2024` | 2020 以上 2024 以下 |
87
+ | `year=2020...2024` | 2020 以上 2024 未満 |
88
+ | `year=2020..` | 2020 以上 |
89
+ | `year=..2024` | 2024 以下 |
90
+ | `year=...2024` | 2024 未満 |
91
+ | `tags=ruby AND year>=2020` | 両方を満たす(`and`・`&&` も同じ) |
92
+ | `tags=ruby AND beginner` | `ruby` と `beginner` の両方を含む(2 件目以降の項目名は省ける) |
93
+ | `tags=ruby OR year>=2020` | どちらかを満たす(`or` も同じ) |
94
+ | `tags=ruby OR go` | どちらかを含む(2 件目以降の項目名は省ける) |
95
+
96
+ 等しいかどうかを調べるとき、データの値が配列やカンマ区切りの文字列なら、要素のどれかが一致すれば条件を満たします。`tags: [ruby, web]` は `tags=ruby` に一致します。
97
+
98
+ AND は OR より強く結び付きます。`tags=ruby AND year>=2020 OR tags=go` は、「`ruby` で 2020 年以降」または「`go`」を選びます。
99
+
100
+ 前後に空白のある `AND`・`and`・`OR`・`or`・`&&` は区切りとして扱うので、値には書けません。`||` は記法の区切りの `|` と重なるので使えません。
101
+
102
+ 比較(`>`・`>=`・`<`・`<=`)と範囲では、値が無いレコードと、比較できない値のレコード(数と比べるときの `不明` など)を除きます。
103
+
104
+ ### 一件検索
105
+
106
+ 値だけを書くと、主キーで検索します。主キーとして調べるのは `id`・`no`・`code`・`slug`・`name`・`title` です。
107
+
108
+ ```
109
+ = book | 楽しいRuby # title が一致するレコード
110
+ = post | hello-world # slug が一致するレコード
111
+ ```
112
+
113
+ 一致するレコードが無ければ `NoResultWarning` を通知し、何も展開しません。複数あれば `AmbiguousQueryWarning` を通知し、一致したレコードをすべて展開します。
114
+
115
+ ### ファイルの探し方
116
+
117
+ データファイルは `data_dir` から次の順に探します。拡張子は `.yml`・`.yaml`・`.json` の順に試します。
118
+
119
+ 1. 書いたままの名前(`books`)
120
+ 2. 末尾に `s` を付けた名前(`book` → `books`)
121
+ 3. 単数形にした名前(`books` → `book`)
122
+
123
+ テンプレートは `templates_dir` にある、単数形の名前のファイルを使います。
124
+
125
+ | 記法 | テンプレート |
126
+ |---|---|
127
+ | `= books` | `_book.md` |
128
+ | `= books \| :full` | `_book.full.md` |
129
+ | `= books \| :card.html` | `_book.card.html` |
130
+
131
+ 拡張子を書かないときの形式は、`config.default_format` で変更できます。
132
+
133
+ ### テンプレートの書き方
134
+
135
+ | 書き方 | 意味 |
136
+ |---|---|
137
+ | `= key`・`=key` | キーの値を展開する |
138
+ | `= author.name` | ネストした値を展開する |
139
+ | `![](cover)`・`![](= cover)` | 画像のパスをキーの値で展開する |
140
+ | `![](logo.png)` | 拡張子のあるパスはそのまま出力する |
141
+
142
+ 変数を含む行はレコードごとに繰り返し、含まない行は 1 回だけ出力します。表は次のように書くと、見出しが 1 回だけ出力されます。
143
+
144
+ ```markdown
145
+ | 書名 | 著者 |
146
+ |---|---|
147
+ | = title | = author.name |
148
+ ```
149
+
150
+ コードブロック(` ``` `)の中の記法は展開しません。
151
+
152
+ ### 具体例:Vivlio Starter での書籍データ展開
47
153
 
48
154
  #### データファイル例 (`data/books.yml`)
49
155
 
@@ -98,11 +204,55 @@ Rubyを楽しく学べる入門書。
98
204
  C言語の定番入門書。
99
205
  :::
100
206
 
207
+
101
208
  ---
102
209
  ```
103
210
 
104
211
  VFMフェンス記法(`:::{.book-card}`)にも対応しており、各レコードが個別のフェンスで囲まれて展開されます。
105
212
 
213
+ ### 値の無い項目
214
+
215
+ 値が無い(キーが無い・空の)項目を含む行は、行ごと出力しません。項目によって情報量の
216
+ 違うデータにも、同じテンプレートが使えます。
217
+
218
+ 1 行に変数が 2 つ以上ある行では、**値の無い項目の部分だけ**を消します。行は区切り
219
+ (`/`・`・`・`、`・`,`・`,` と、前後に空白のある ` / `)と、変数を含む括弧(`(…)`・`(…)`)で
220
+ 部品に分かれ、値の無い変数を含む部品だけが、区切りを詰めて消えます。変数に付いた言葉
221
+ (`著`・`年`)は、変数と一緒に残るか、一緒に消えます。
222
+
223
+ ```markdown
224
+ = author.name 著/= publisher/= year 年
225
+ = author.name 著(= year年)
226
+ ```
227
+
228
+ | データ | 1 行目 | 2 行目 |
229
+ |---|---|---|
230
+ | 全部ある | 高橋征義 著/SBクリエイティブ/2019 年 | 高橋征義 著(2019年) |
231
+ | 発行年が無い | 高橋征義 著/SBクリエイティブ | 高橋征義 著 |
232
+ | 全部無い | (行ごと消える) | (行ごと消える) |
233
+
234
+ - 行頭の箇条書きの `- ` や見出しの `# ` は、先頭の部品が消えても残ります。
235
+ - 表の行(`|` で始まる行)は部品に分けず、値の無い項目があれば行ごと消えます(セルを消すと列がずれるため)。
236
+ - 空白なしの半角の `/` と、リンク先の括弧(`[= title](= url)`)は区切りになりません。
237
+ - テンプレートのキーは、どれか 1 件のデータにあれば正しいキーとみなします。どの件にも無いキーは打ち間違いとして `UnknownKeyError` になります。
238
+
239
+ ## メソッド
240
+
241
+ | メソッド | 役割 |
242
+ |---|---|
243
+ | `QueryStream.render(text, **options)` | テキスト内の記法をすべて展開し、結果を返す |
244
+ | `QueryStream.render_query(query, **options)` | 1 つの記法を展開し、結果を返す |
245
+ | `QueryStream.scan(path_or_text)` | ファイルパスまたはテキストから記法を探し、一覧を返す |
246
+
247
+ ```ruby
248
+ QueryStream.render_query('= books | tags=ruby | :full', data_dir: 'data', templates_dir: 'templates')
249
+
250
+ QueryStream.scan('contents/05-references.md')
251
+ # => ["= books", "= books | :full"]
252
+ ```
253
+
254
+ `render_query` は `on_error` を受け取りません。展開に失敗すると、例外をそのまま投げます。`scan` はコードブロックの中の記法を含めません。
255
+
106
256
  ## 設定
107
257
 
108
258
  ```ruby
@@ -116,7 +266,7 @@ end
116
266
 
117
267
  ## エラー・警告の扱い
118
268
 
119
- **この gem はメッセージを組み立てません。** どう表示するか、どの言語で出すか、そもそもログに残すかは、すべて呼び出し元が決めます。そのため展開時の出来事は、構造化された例外としてコールバックへ渡されます。
269
+ **この gem はログを出力しません。** どう表示するか、どの言語で出すか、ログに残すかは、呼び出し元が決めます。そのため展開時の出来事は、構造化された例外としてコールバックへ渡されます。例外の `message` には日本語の既定の文が入っています。
120
270
 
121
271
  ```ruby
122
272
  QueryStream.render(
@@ -138,12 +288,13 @@ QueryStream.render(
138
288
  | `DataNotFoundError` | `expected_path`, `query`, `location` |
139
289
  | `DataLoadError` | `file_path`, `cause_error` |
140
290
  | `UnknownKeyError` | `key_path`, `available_keys`, `template_path`, `location` |
291
+ | `InvalidDateError` | なし(`message` に日付を含みます) |
141
292
  | `NoResultWarning` | `query`, `location` |
142
293
  | `AmbiguousQueryWarning` | `query`, `location`, `count` |
143
294
 
144
- 前 4 つは `QueryStream::Error`、後 2 つは `QueryStream::Warning` を継承します。**gem の外へ出る例外はこの 2 系統だけ**です。壊れた YAML も、不正な UTF-8 を含む原稿も、生の `ArgumentError` や `NoMethodError` にはなりません。
295
+ 前 5 つは `QueryStream::Error`、後 2 つは `QueryStream::Warning` を継承します。**gem の外へ出る例外はこの 2 系統だけ**です。壊れた YAML も、不正な UTF-8 を含む原稿も、生の `ArgumentError` や `NoMethodError` にはなりません。
145
296
 
146
- たとえば `UnknownKeyError` は「使えるキーの一覧」と「直すべき雛形のパス」まで持って届くので、呼び出し元で打ち間違いの案内を組み立てられます。`location` は記法が書かれた原稿の位置、`template_path` は直す雛形の位置で、**直すのは雛形のほう**です。
297
+ たとえば `UnknownKeyError` は「使えるキーの一覧」と「直すべき雛形のパス」を属性に持つので、呼び出し元で打ち間違いの案内を組み立てられます。`location` は記法が書かれた原稿の位置、`template_path` は直す雛形の位置で、**直すのは雛形のほう**です。
147
298
 
148
299
  ```ruby
149
300
  on_error: lambda do |e|
data/RELEASE_NOTE.md CHANGED
@@ -6,6 +6,45 @@ YAML / JSON のデータファイルとテンプレートを組み合わせて
6
6
 
7
7
  ---
8
8
 
9
+ ## 1.5.0 — 2026-11-01
10
+
11
+ **値の無い項目があっても、行ごと消さずに、その部分だけを消すようになりました。** 書誌のように一行へ複数の値を並べるテンプレートで、どれか一つが欠けると、これまでは行ごと消えていました。
12
+
13
+ ```
14
+ = author.name 著/= publisher/= year 年
15
+ ```
16
+
17
+ 発行年の分からない本でも、`高橋征義 著/SBクリエイティブ` と出ます(1.4 では著者と出版社まで消えていました)。区切り(`/`・`・`・`、` など)と、変数を含む括弧(`(= year 年)`)で行を部品に分け、値の無い部品だけを、区切りを詰めて消します。すべての値がそろった行の出力は変わりません。変数が一つだけの行と表の行は、これまでどおり行ごと消します(表のセルを消すと列がずれるためです)。
18
+
19
+ **抽出条件の書き方を三つ足しました。**
20
+
21
+ - **`OR` で条件をつなげます。** `tags=ruby OR year>=2020` のように、別の項目の条件も書けます。AND は OR より強く結び付きます。前後に空白のある `OR` / `or` は区切りになるので、`title=Rock or Pop` のような値は抽出条件に書けなくなりました
22
+ - **数をアンダースコアで桁区切りして書けます。** `population>=9_000_000` や、万の位で区切る `900_0000` も書けます。比較・範囲・主キー検索・等値のどれでも使えます
23
+ - **日付を和暦(`R06-01-01`)でも書けます。** データでも抽出条件でも、西暦の `2024-01-01` と同じ日として比べ、並べ替えます。元号は頭文字(M・T・S・H・R)で書き、年は 1 桁でも構いません
24
+
25
+ **昇順の並べ替えの書き方を README に追加しました。** 機能は最初からありましたが、README の例は降順(`-title`)だけでした。
26
+
27
+ ```
28
+ = books | +title # タイトルの昇順
29
+ = books | -year # 発行年の降順
30
+ ```
31
+
32
+ **符号は必ず付けてください。** 符号を省いた `= books | title` は、並べ替えではなく**一件検索**として解釈され、「該当なし」の警告を出して何も展開しません。`= book | 楽しいRuby` と同じ形なので、パーサーは符号の有無だけで両者を区別します。README にも理由を記載しました。
33
+
34
+ あわせて、内部に一度も実行されない分岐が残っていたのを整理しました。動きは変わりません。
35
+
36
+ - **対応 Ruby を 3.4.10 / 4.0.9 に更新しました**(4.0.9 は 2026 年 10 月 23 日公開)
37
+ - **Minitest を 6 系へ、samovar を 2.5.1 へ更新しました**
38
+ - **コード検査(rubocop)を導入しました。** 有効にしているのは `Lint` と `Security` の規則だけで、引用符の種類などの様式は検査しません
39
+ - README と CHANGELOG にあった「vivlio-style」という綴りを「Vivlio Starter」に直しました
40
+
41
+ ### 不具合の修正
42
+
43
+ - **項目名を省いた AND が OR として動いていたのを直しました。** `tags=ruby AND beginner` は、`ruby` と `beginner` の両方を含むレコードを選ぶ記法ですが、どちらかを含むレコードを選んでいました。この書き方を使っている場合は、結果の件数が変わります
44
+ - **比較とソートで `ArgumentError` が発生していたのを直しました。** 対象の項目に値が無いレコードや比較できない値のレコードが 1 件でもあると、例外が `render` の外へ出ていました。比較では該当のレコードを除き、ソートでは昇順でも降順でも末尾に置きます。`year=2020..abc` のように両端を比較できない範囲も、例外にならなくなりました
45
+ - **省ける項目が最初の 1 件に無いと、`UnknownKeyError` が発生していたのを直しました。** テンプレートのキーを最初の 1 件だけで確認していました。修正後は、どれか 1 件にあるキーを正しいキーとして扱い、どの件にも無いキーだけを `UnknownKeyError` にします
46
+ - **日付の範囲指定が 1 件も返さなかったのを直しました。** `date=2024-06-01..2024-06-30` のような範囲は、両端が文字列のまま比較され、どのデータにも一致しませんでした。数の範囲は影響を受けていません
47
+
9
48
  ## 1.4.0 — 2026-08-07
10
49
 
11
50
  **細かな不具合修正が中心のリリースです。** いずれも「著者が用意した原稿やデータが壊れている」という、実際に起こりうる場面で想定外の例外が漏れていた問題です。
@@ -2,8 +2,6 @@
2
2
 
3
3
  require 'samovar'
4
4
 
5
- # ================================================================
6
- # File: lib/query_stream/command.rb
7
5
  # ================================================================
8
6
  # 責務:
9
7
  # QueryStream の CLI コマンド。--version のみ提供する。
@@ -31,11 +29,8 @@ module QueryStream
31
29
 
32
30
  # 使い方を表示する。
33
31
  #
34
- # public かつ `output:` を受ける形でなければならない。Samovar は引数の解析に
35
- # 失敗すると、外から `error.command.print_usage(output: output)` を呼んで
36
- # 使い方を出そうとする。private に置いていた頃は、そこで
37
- # `private method 'print_usage' called` の NoMethodError になり、
38
- # **`--help` と未知の引数がスタックトレースで落ちていた**。
32
+ # public かつ `output:` を受ける形にする。Samovar は引数の解析に失敗すると、外から
33
+ # `error.command.print_usage(output: output)` を呼ぶ。
39
34
  # @param output [IO] 出力先(Samovar からはエラー出力が渡される)
40
35
  def print_usage(output: $stdout)
41
36
  output.puts self.class.description
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # ================================================================
4
- # File: lib/query_stream/configuration.rb
5
3
  # ================================================================
6
4
  # 責務:
7
5
  # QueryStream のグローバル設定を管理する。
@@ -3,8 +3,6 @@
3
3
  require 'yaml'
4
4
  require 'json'
5
5
 
6
- # ================================================================
7
- # File: lib/query_stream/data_resolver.rb
8
6
  # ================================================================
9
7
  # 責務:
10
8
  # データファイルの探索・単数形/複数形の自動解決を行う。
@@ -97,10 +95,8 @@ module QueryStream
97
95
 
98
96
  # 読み込み結果をレコード配列へ正規化する。
99
97
  #
100
- # **常に配列を返すのが本メソッドの契約である。** 空のデータファイルは YAML が
101
- # `nil` を返すため、素通しすると呼び出し先(TemplateCompiler)が
102
- # `undefined method 'any?' for nil` で落ちる——データを空にしただけの著者に
103
- # NoMethodError を見せることになる。
98
+ # **常に配列を返す。** 空のデータファイルは YAML が `nil` を返すので、空配列に変換する
99
+ # (呼び出し先の TemplateCompiler は配列を前提にしている)。
104
100
  #
105
101
  # 配列でもハッシュでもない中身(先頭が文字列や数値だけの YAML)は、
106
102
  # レコード群として解釈しようがないので DataLoadError で理由を伝える。
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # ================================================================
4
- # File: lib/query_stream/errors.rb
5
3
  # ================================================================
6
4
  # 責務:
7
5
  # QueryStream の例外クラス体系を定義する。
@@ -55,8 +53,6 @@ module QueryStream
55
53
  #
56
54
  # 「どのキーが無いか」だけでなく「代わりに何が使えるか」「どのファイルを直すか」まで
57
55
  # 属性で渡す。呼び出し元はこれを使って「もしかして =author?」のような案内を組み立てられる。
58
- # 属性が無かった頃はこのクラスだけが gem 内で logger.error を呼んでおり、
59
- # 利用できるキーの一覧を呼び出し元が受け取れなかった。
60
56
  #
61
57
  # location(記法の位置)と template_path(雛形の位置)は別物である。
62
58
  # 直すのは雛形なので、記法の位置だけでは著者はファイルを開けない。
@@ -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,21 @@ 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 = 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)
37
58
  end
38
59
 
39
60
  # 単一フィルタ条件をレコードに対して評価する
@@ -53,22 +74,55 @@ module QueryStream
53
74
  match_eq(field_value, filter[:value])
54
75
  when :neq
55
76
  !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])
77
+ when :gt, :gte, :lt, :lte
78
+ satisfies_order?(field_value, filter)
64
79
  when :range
65
- range = filter[:value]
66
- range.cover?(to_comparable(field_value))
80
+ within_range?(field_value, filter[:value])
81
+ when :or
82
+ filter[:branches].any? { |branch| branch.all? { evaluate_filter(record, it) } }
67
83
  else
68
84
  false
69
85
  end
70
86
  end
71
87
 
88
+ # 大小の条件を満たすか。値が無いレコードと、比較できない値のレコードは満たさない
89
+ # @param field_value [Object] レコードの値
90
+ # @param filter [Hash] フィルタ条件 { op:, value: }
91
+ # @return [Boolean]
92
+ def satisfies_order?(field_value, filter)
93
+ return false if missing_value?(field_value)
94
+
95
+ order = to_comparable(field_value) <=> to_comparable(filter[:value])
96
+ !order.nil? && ORDER_TESTS.fetch(filter[:op]).call(order)
97
+ end
98
+
99
+ # 範囲に含まれるか。両端も比較用にそろえる。パーサーは "2024-06-01".."2024-06-30" のような
100
+ # 文字列の範囲を返すので、そのままでは Date の値を含まない。
101
+ # @param field_value [Object] レコードの値
102
+ # @param range [Range] 範囲
103
+ # @return [Boolean]
104
+ def within_range?(field_value, range)
105
+ Range.new(to_comparable(range.begin), to_comparable(range.end), range.exclude_end?)
106
+ .cover?(to_comparable(field_value))
107
+ rescue ArgumentError
108
+ # 両端を比較できない範囲(Range.new が受け付けない)
109
+ false
110
+ end
111
+
112
+ # 値が無いか(キーが無い・nil・空文字列)
113
+ def missing_value?(value) = value.nil? || value == ''
114
+
115
+ # ソートで比べられる値の種類。値が無いときは nil
116
+ def sort_kind(key)
117
+ case key
118
+ when nil then nil
119
+ when Numeric then :number
120
+ when Date then :date
121
+ when Time then :time
122
+ else :string
123
+ end
124
+ end
125
+
72
126
  # 主キー候補フィールドを順番に走査して一致するものを探す
73
127
  # @param record [Hash] 単一レコード
74
128
  # @param query_value [Object] 検索値
@@ -82,13 +136,25 @@ module QueryStream
82
136
  # 等値比較(配列・カンマ区切り文字列を透過的に扱う)
83
137
  # データ側が配列/カンマ区切りの場合、ORの値リストと交差判定する
84
138
  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 }
139
+ # 綴りの揺れは両側でそろえる。条件側だけそろえると、データに "2024_01" や
140
+ # "R06-01-01" のような文字列があるとき、同じ綴りの条件で一致しなくなる。
141
+ field_list = normalize_to_list(field_value).map { canonical_token(it) }
142
+ value_list = Array(filter_values).map { canonical_token(it.to_s.strip) }
87
143
 
88
144
  # フィールド側の値リストと条件値リストに交差があれば一致
89
145
  (field_list & value_list).any?
90
146
  end
91
147
 
148
+ # 等値比較のため、値を綴りの揺れのない形へそろえる。
149
+ # 数は桁区切りを外し(9_000_000 → 9000000)、日付は西暦にする(R06-01-01 → 2024-01-01)。
150
+ # 存在しない日付は、等値比較では文字列のまま比べる。
151
+ def canonical_token(str)
152
+ date = parse_date(str)
153
+ date ? date.iso8601 : QueryStreamParser.strip_digit_separators(str)
154
+ rescue InvalidDateError
155
+ str
156
+ end
157
+
92
158
  # フィールド値をリスト化する(配列/カンマ区切り/単値を統一)
93
159
  def normalize_to_list(value)
94
160
  case value
@@ -103,20 +169,31 @@ module QueryStream
103
169
  end
104
170
  end
105
171
 
172
+ # 西暦・和暦の日付を Date に直す。日付の形でなければ nil を返す。
173
+ # 和暦は Date.jisx0301 に任せる。Date.parse は "R06-01-01" を 2006-01-01 と
174
+ # 読み違えるので使えない。jisx0301 の区切りはドットで、年は 2 桁が要る。
175
+ # 元号の境目は検査しない(H31-05-01 も 2019-05-01 として読む)。
176
+ # @raise [InvalidDateError] 日付の形だが存在しない日(2024-02-30 など)
177
+ def parse_date(value)
178
+ if value.match?(SEIREKI_DATE)
179
+ Date.parse(value)
180
+ elsif (m = value.match(WAREKI_DATE))
181
+ Date.jisx0301("#{m[:era]}#{m[:year].rjust(2, '0')}.#{m[:month]}.#{m[:day]}")
182
+ end
183
+ rescue Date::Error
184
+ raise InvalidDateError, "無効な日付: #{value}"
185
+ end
186
+
106
187
  # 比較用に値を正規化する(数値変換・日付変換を試みる)
107
188
  def to_comparable(value)
108
189
  case value
109
190
  when Date, Time
110
191
  value
111
192
  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
193
+ # 日付形式(西暦・和暦)を検出して Date に変換
194
+ date = parse_date(value)
195
+ return date if date
196
+
120
197
  # 数値変換を試みる
121
198
  case value
122
199
  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,14 @@ 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
+
30
36
  module_function
31
37
 
32
38
  # QueryStream 記法をパースして構造化ハッシュを返す
@@ -137,16 +143,15 @@ module QueryStream
137
143
  tokens
138
144
  end
139
145
 
140
- # ソート指定をパースする
141
- # @param token [String] "-title" / "+title" / "title"
146
+ # ソート指定をパースする。
147
+ #
148
+ # **符号は必須である。** 呼び出し側(parse_segment)が `+` か `-` で始まることを確かめてから渡す。
149
+ # 符号なしの `title` は、`= book | 楽しいRuby` と区別できないので主キー検索として扱う。
150
+ #
151
+ # @param token [String] "-title" / "+title"
142
152
  # @return [Hash] { field:, direction: }
143
153
  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
154
+ { field: token[1..].to_sym, direction: token.start_with?('-') ? :desc : :asc }
150
155
  end
151
156
 
152
157
  # 直前フィールドを引き継いで値のみの条件をパースする
@@ -157,37 +162,35 @@ module QueryStream
157
162
  parse_eq_condition(field, value_str)
158
163
  end
159
164
 
160
- # AND で接続されたフィルタ式をパースする
161
- # @param expression [String] "tags=ruby && category=web"
162
- # @return [Array<Hash>] フィルタ条件のリスト
165
+ # AND / OR で接続されたフィルタ式をパースする。AND は OR より強く結び付く。
166
+ # 項目名を省いた値は、直前の条件の項目を引き継ぐ(tags=ruby AND beginner)。
167
+ # @param expression [String] "tags=ruby && category=web" / "tags=ruby OR tags=go"
168
+ # @return [Array<Hash>] すべて満たすフィルタ条件のリスト。OR は { op: :or, branches: } の 1 条件にまとめる
163
169
  def parse_filter_expression(expression)
164
- # AND で分割
165
- clauses = expression.split(AND_PATTERN)
166
170
  previous_field = nil
167
- previous_filter = nil
168
171
 
169
- clauses.flat_map do |clause|
170
- clause = clause.strip
171
- next [] if clause.empty?
172
+ branches = expression.split(OR_PATTERN).map do |branch|
173
+ branch.split(AND_PATTERN).flat_map do |clause|
174
+ parsed = parse_clause(clause.strip, previous_field)
175
+ previous_field = parsed.last&.[](:field) || previous_field
176
+ parsed
177
+ end
178
+ end.reject(&:empty?)
172
179
 
173
- parsed = parse_single_condition(clause)
180
+ branches.size > 1 ? [{ op: :or, branches: }] : branches.flatten
181
+ end
174
182
 
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
183
+ # AND / OR で区切られた 1 節をパースする
184
+ # @param clause [String] "tags=ruby" / "beginner"
185
+ # @param previous_field [Symbol, nil] 項目名を省いたときに引き継ぐ項目
186
+ # @return [Array<Hash>] フィルタ条件
187
+ def parse_clause(clause, previous_field)
188
+ return [] if clause.empty?
184
189
 
185
- last_filter = parsed.last
186
- previous_field = last_filter&.[](:field)
187
- previous_filter = last_filter if last_filter&.[](:op) == :eq
190
+ parsed = parse_single_condition(clause)
191
+ return parsed unless parsed.empty? && previous_field
188
192
 
189
- parsed
190
- end
193
+ parse_value_only_condition(previous_field, clause)
191
194
  end
192
195
 
193
196
  # 単一条件をパースする(OR はカンマ区切りで表現)
@@ -242,6 +245,9 @@ module QueryStream
242
245
  # 通常の等値条件(カンマ区切りでOR)
243
246
  [{ field: field_sym, op: :eq, value: parse_values(value_str) }]
244
247
  end
248
+ rescue ArgumentError
249
+ # 両端を比較できない範囲(2020..abc)は Range を作れないので、文字列の値として扱う
250
+ [{ field: field_sym, op: :eq, value: parse_values(value_str) }]
245
251
  end
246
252
 
247
253
  # カンマ区切りの値をリストとしてパースする
@@ -255,7 +261,7 @@ module QueryStream
255
261
  # @param str [String] "20" / "3.14" / "東京"
256
262
  # @return [Integer, Float, String] 変換後の値
257
263
  def parse_numeric(str)
258
- s = str.strip
264
+ s = strip_digit_separators(str.strip)
259
265
  case s
260
266
  when /\A-?\d+\z/ then s.to_i
261
267
  when /\A-?\d+\.\d+\z/ then s.to_f
@@ -263,6 +269,13 @@ module QueryStream
263
269
  end
264
270
  end
265
271
 
272
+ # 数の桁区切り(アンダースコア)を外す。数でない値(tag_name など)はそのまま返す。
273
+ # @param str [String] "9_000_000" / "ruby"
274
+ # @return [String] "9000000" / "ruby"
275
+ def strip_digit_separators(str)
276
+ str.match?(NUMBER_WITH_SEPARATORS) ? str.delete('_') : str
277
+ end
278
+
266
279
  # 主キー候補フィールドによる一件検索フィルタを構築する
267
280
  # すべての主キー候補に対して OR 的に検索する
268
281
  # @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?
@@ -219,7 +234,7 @@ module QueryStream
219
234
  end
220
235
 
221
236
  # テンプレート行をレコードデータで展開する
222
- # nil/空文字のキーがあれば行ごとスキップ(nil を返す)
237
+ # 行ごと消すときは nil を返す
223
238
  # @param line [String] テンプレート行
224
239
  # @param record [Hash] データレコード
225
240
  # @return [String, nil] 展開後の行、またはスキップ時 nil
@@ -270,10 +285,72 @@ module QueryStream
270
285
  end
271
286
 
272
287
  # = key パターンの変数を展開する(ドット記法対応)
288
+ #
289
+ # 変数が 2 つ以上ある行は、空のキーを含む部品だけを消す(expand_parts)。
290
+ # `= author.name 著/= publisher/= year 年` で発行年だけが無い本でも、著者と出版社は出る。
291
+ # 変数が 1 つの行と表の行(セルを消すと列がずれる)は、空なら行ごと消す。
273
292
  # @param line [String] テンプレート行
274
293
  # @param record [Hash] データレコード
275
294
  # @return [String, nil] 展開後の行、またはスキップ時 nil
276
295
  def expand_variables(line, record)
296
+ return expand_parts(line, record) if partial_line?(line)
297
+
298
+ expand_whole(line, record)
299
+ end
300
+
301
+ # 部品ごとに消せる行か。変数が 2 つ以上あり、表の行でないもの。
302
+ def partial_line?(line)
303
+ !line.lstrip.start_with?('|') && line.scan(VARIABLE_PATTERN).size >= 2
304
+ end
305
+
306
+ # 区切りと括弧で行を部品に分け、空のキーを含む部品だけを消す。
307
+ #
308
+ # - 変数を含む括弧(`(= year 年)`)は 1 つの部品。空なら直前の空白ごと消す。
309
+ # - 括弧の外は PART_SEPARATOR で分ける。残った部品は、前の部品の後ろにあった区切りでつなぐ。
310
+ # - 行頭の Markdown の目印(`- ` など)と行末の改行は、部品が消えても残す。
311
+ # - 変数を含む部品が 1 つも残らなければ nil(行ごと消す)。
312
+ # キーがすべて埋まっていれば、区切りも括弧も元のまま戻るので、出力は expand_whole と同じになる。
313
+ # @param line [String] テンプレート行
314
+ # @param record [Hash] データレコード
315
+ # @return [String, nil] 展開後の行、またはスキップ時 nil
316
+ def expand_parts(line, record)
317
+ newline = line[/\R\z/].to_s
318
+ body = line.chomp
319
+ marker = body[LINE_MARKER]
320
+ body = body.delete_prefix(marker)
321
+
322
+ groups = []
323
+ masked = body.gsub(OPTIONAL_GROUP) do |group|
324
+ next group unless group.match?(VARIABLE_PATTERN)
325
+
326
+ groups << group
327
+ "\u0000#{groups.size - 1}\u0000"
328
+ end
329
+
330
+ kept = masked.split(/(#{PART_SEPARATOR})/).each_slice(2).filter_map do |part, separator|
331
+ text = expand_part(part, groups, record)
332
+ text && [text, separator.to_s, variable_part?(part)]
333
+ end
334
+ return nil unless kept.any? { |_, _, variable| variable }
335
+
336
+ joined = kept.each_with_index.map { |(text, separator, _), i| i == kept.size - 1 ? text : text + separator }.join
337
+ "#{marker}#{joined}#{newline}"
338
+ end
339
+
340
+ # 部品 1 つを展開する。空のキーを含めば nil。括弧は中のキーが空ならそこだけ消す。
341
+ # 括弧の外を先に展開するのは、値の中の `= …` をもう一度展開しないため(括弧は退避中)。
342
+ def expand_part(part, groups, record)
343
+ outer = expand_whole(part, record)
344
+ return nil unless outer
345
+
346
+ text = outer.gsub(GROUP_PLACEHOLDER) { expand_whole(groups[::Regexp.last_match(1).to_i], record).to_s }
347
+ text.strip.empty? ? nil : text
348
+ end
349
+
350
+ def variable_part?(part) = part.match?(VARIABLE_PATTERN) || part.match?(GROUP_PLACEHOLDER)
351
+
352
+ # 行の変数をすべて展開する。空のキーが 1 つでもあれば nil(行ごと消す)。
353
+ def expand_whole(line, record)
277
354
  result = line.dup
278
355
 
279
356
  result.gsub!(VARIABLE_PATTERN) do |_match|
@@ -303,16 +380,19 @@ module QueryStream
303
380
  end
304
381
 
305
382
  # テンプレート内のキーがデータに存在するかを検証する
383
+ #
384
+ # どれか 1 件にでもあるキーは正しいキーとみなす。省ける項目(発行年など)が最初の 1 件に
385
+ # 無いだけで、打ち間違いとして止めないため。値の無い件では、その行・部品を消す(expand_variables)。
306
386
  # @param lines [Array<String>] テンプレートの行リスト
307
- # @param sample_record [Hash] サンプルレコード(最初の1件)
387
+ # @param records [Array<Hash>] レコード群
308
388
  # @param source_filename [String, nil] エラー報告用ファイル名
309
389
  # @param line_number [Integer, nil] エラー報告用行番号
310
390
  # @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
391
+ def validate_template_keys!(lines, records, source_filename: nil, line_number: nil, template_path: nil)
392
+ available_keys = records.flat_map(&:keys).uniq
393
+ return if available_keys.empty?
313
394
 
314
395
  location = source_filename ? "#{source_filename}:#{line_number}" : ''
315
- available_keys = sample_record.keys
316
396
  in_code_block = false
317
397
 
318
398
  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.5.0'
5
5
  end
data/lib/query_stream.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # ================================================================
4
- # File: lib/query_stream.rb
5
3
  # ================================================================
6
4
  # 責務:
7
5
  # QueryStream gem のエントリポイント。
@@ -120,8 +118,6 @@ module QueryStream
120
118
  parsed = QueryStreamParser.parse(query)
121
119
 
122
120
  # --- Phase: Load Data ---
123
- # gem 内でログ出力せず、構造化された属性を持つ例外を raise する。
124
- # メッセージの構成・ログ出力・i18n は呼び出し元の責務とする。
125
121
  data_file = DataResolver.resolve(parsed[:source], data_dir)
126
122
  unless data_file
127
123
  expected = File.join(data_dir, "#{parsed[:source]}.yml")
@@ -147,13 +143,11 @@ module QueryStream
147
143
  if parsed[:single_lookup]
148
144
  case records.size
149
145
  when 0
150
- # gem 内でログ出力せず、構造化された属性を持つ警告を呼び出し元に委ねる。
151
146
  on_warning&.call(NoResultWarning.new(query: query, location: location))
152
147
  return ''
153
148
  when 1
154
149
  # 正常
155
150
  else
156
- # gem 内でログ出力せず、構造化された属性を持つ警告を呼び出し元に委ねる。
157
151
  on_warning&.call(
158
152
  AmbiguousQueryWarning.new(query: query, location: location, count: records.size)
159
153
  )
@@ -168,8 +162,6 @@ module QueryStream
168
162
 
169
163
  unless File.exist?(template_path)
170
164
  hint = build_template_hint(singular, style, format, templates_dir)
171
- # gem 内でログ出力せず、構造化された属性を持つ例外を raise する。
172
- # メッセージの構成・ログ出力・i18n は呼び出し元の責務とする。
173
165
  raise TemplateNotFoundError.new(
174
166
  template_path: template_path,
175
167
  query: query,
@@ -185,7 +177,7 @@ module QueryStream
185
177
 
186
178
  # --- Phase: Post-render filter ---
187
179
  # 展開結果を呼び出し元の後段フィルタへ通す(画像パス解決など gem 外の後処理)。
188
- # コールバックが String 以外を返した場合は安全側に倒して元の展開結果を採用する。
180
+ # コールバックが String 以外を返した場合は、元の展開結果を採用する。
189
181
  return rendered unless post_render
190
182
 
191
183
  context = {
@@ -226,16 +218,12 @@ module QueryStream
226
218
 
227
219
  # その行を QueryStream 記法として扱うか。
228
220
  #
229
- # **不正な UTF-8 を含む行は記法として解釈しない。** `match?` はそういう行に
230
- # `ArgumentError: invalid byte sequence in UTF-8` を投げるので、素通しにすると
231
- # 原稿に 1 バイトの壊れが混ざっただけで展開が丸ごと止まる。
232
- # ここでバイト列を書き換えて(scrub して)通す手もあるが採らない——gem が黙って
233
- # 原稿のバイト列を変えると、著者が気付けない形で本文が変わる。
234
- # 記法として読めない行は本文として、そのまま返すのが正しい。
221
+ # 不正な UTF-8 を含む行は記法として解釈せず、本文としてそのまま返す。`match?` はそういう行に
222
+ # `ArgumentError` を投げるので、捕まえないと展開全体が止まる。scrub しないのは、原稿のバイト列を
223
+ # 変えないため。
235
224
  #
236
- # EncodingError と ArgumentError の両方を捕まえるのは意図的である。壊れたバイト列に
237
- # Ruby がどちらを投げるかはメソッド次第で(match? / 先頭破損の lstrip は ArgumentError、
238
- # strip は Encoding::CompatibilityError)、その差で展開が止まってはならない。
225
+ # EncodingError と ArgumentError の両方を捕まえる。壊れたバイト列に Ruby が投げる例外はメソッドで
226
+ # 異なる(match? / 先頭破損の lstrip は ArgumentError、strip は Encoding::CompatibilityError)。
239
227
  def query_line?(line)
240
228
  line.match?(QUERY_STREAM_PATTERN)
241
229
  rescue EncodingError, ArgumentError
@@ -243,9 +231,7 @@ module QueryStream
243
231
  end
244
232
 
245
233
  # コードフェンスの開始/終了行か。
246
- # `lstrip` も不正な UTF-8 で ArgumentError を投げるので、query_line? と同じく
247
- # 「読めない行は本文」へ倒す。ここを素通しにすると、記法判定だけ守っても
248
- # 手前のフェンス判定で落ちる。
234
+ # `lstrip` も不正な UTF-8 で ArgumentError を投げるので、query_line? と同じく本文として扱う。
249
235
  def code_fence?(line)
250
236
  line.lstrip.start_with?('```')
251
237
  rescue EncodingError, ArgumentError
@@ -254,10 +240,9 @@ module QueryStream
254
240
 
255
241
  # 引数をファイルパスとして読むべきか。
256
242
  #
257
- # `scan` はパスと本文のどちらも受け取る API なので、本文をそのまま
258
- # `File.exist?` へ渡すと壊れる——NUL バイトを含めば `ArgumentError`、
259
- # ディレクトリなら「存在する」と判定されて `File.read` が `Errno::EISDIR` を出す。
260
- # パスとして成立しうる形だけを先に選別し、判定自体の失敗も本文扱いに倒す。
243
+ # `scan` はパスと本文のどちらも受け取る。本文を `File.exist?` へ渡すと、NUL バイトを含む場合は
244
+ # `ArgumentError` になり、ディレクトリは `File.read` が `Errno::EISDIR` を出す。
245
+ # パスとして成立する形だけを先に選び、判定の失敗も本文として扱う。
261
246
  def readable_file?(str)
262
247
  s = str.to_s
263
248
  return false if s.empty? || s.include?("\0") || s.include?("\n")
data/query-stream.gemspec CHANGED
@@ -8,9 +8,10 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ['Atelier Mirai']
9
9
  spec.email = ['contact@atelier-mirai.net']
10
10
 
11
- spec.summary = 'QueryStream - YAML/JSON data renderer with template expansion'
12
- spec.description = 'A generic Ruby library that expands QueryStream notation in text content ' \
13
- 'by combining YAML/JSON data files with template files.'
11
+ spec.summary = 'YAML / JSON のデータとテンプレートから、文章中の 1 行の記法を展開する Ruby ライブラリ'
12
+ spec.description = 'QueryStream は、文章中に書いた `= books | tags=ruby | -year | 5` のような 1 行の記法を、' \
13
+ 'YAML / JSON のデータファイルとテンプレートを組み合わせて展開する汎用の Ruby ライブラリです。' \
14
+ '抽出条件、並べ替え、件数、スタイルを指定できます。'
14
15
  spec.homepage = 'https://github.com/Atelier-Mirai/query-stream'
15
16
  spec.license = 'MIT'
16
17
 
@@ -20,18 +21,16 @@ Gem::Specification.new do |spec|
20
21
  spec.required_ruby_version = '>= 3.4'
21
22
 
22
23
  spec.files = Dir.glob('{lib,bin}/**/*') +
23
- %w[README.md CHANGELOG.md RELEASE_NOTE.md LICENSE Gemfile query-stream.gemspec]
24
+ %w[README.md CHANGELOG.md RELEASE_NOTE.md LICENSE query-stream.gemspec]
24
25
  spec.bindir = 'bin'
25
26
  spec.executables = ['query-stream']
26
27
  spec.require_paths = ['lib']
27
28
 
28
- # Runtime dependencies
29
- # logger への依存は 1.4.0 で外した。gem 自身はログを出さず、失敗は構造化例外で伝える。
30
29
  spec.add_dependency 'samovar', '~> 2.1'
31
30
 
32
- # Development dependencies
33
31
  spec.add_development_dependency 'bundler'
34
32
  spec.add_development_dependency 'rake', '~> 13.2'
35
- spec.add_development_dependency 'minitest', '~> 5.22'
33
+ spec.add_development_dependency 'minitest', '>= 5.22'
34
+ spec.add_development_dependency 'rubocop', '~> 1.81'
36
35
  spec.metadata['rubygems_mfa_required'] = 'false'
37
36
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: query-stream
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Atelier Mirai
@@ -55,18 +55,32 @@ dependencies:
55
55
  name: minitest
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
- - - "~>"
58
+ - - ">="
59
59
  - !ruby/object:Gem::Version
60
60
  version: '5.22'
61
61
  type: :development
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
- - - "~>"
65
+ - - ">="
66
66
  - !ruby/object:Gem::Version
67
67
  version: '5.22'
68
- description: A generic Ruby library that expands QueryStream notation in text content
69
- by combining YAML/JSON data files with template files.
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.81'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.81'
82
+ description: QueryStream は、文章中に書いた `= books | tags=ruby | -year | 5` のような 1 行の記法を、YAML
83
+ / JSON のデータファイルとテンプレートを組み合わせて展開する汎用の Ruby ライブラリです。抽出条件、並べ替え、件数、スタイルを指定できます。
70
84
  email:
71
85
  - contact@atelier-mirai.net
72
86
  executables:
@@ -75,7 +89,6 @@ extensions: []
75
89
  extra_rdoc_files: []
76
90
  files:
77
91
  - CHANGELOG.md
78
- - Gemfile
79
92
  - LICENSE
80
93
  - README.md
81
94
  - RELEASE_NOTE.md
@@ -112,7 +125,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
112
125
  - !ruby/object:Gem::Version
113
126
  version: '0'
114
127
  requirements: []
115
- rubygems_version: 4.0.16
128
+ rubygems_version: 4.0.20
116
129
  specification_version: 4
117
- summary: QueryStream - YAML/JSON data renderer with template expansion
130
+ summary: YAML / JSON のデータとテンプレートから、文章中の 1 行の記法を展開する Ruby ライブラリ
118
131
  test_files: []
data/Gemfile DELETED
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- source 'https://rubygems.org'
4
-
5
- gemspec