query-stream 1.2.2 → 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.
@@ -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 のみ提供する。
@@ -17,6 +15,7 @@ module QueryStream
17
15
 
18
16
  options do
19
17
  option '--version', 'Print version and exit'
18
+ option '--help', 'Print this message and exit'
20
19
  end
21
20
 
22
21
  # コマンド実行
@@ -28,12 +27,16 @@ module QueryStream
28
27
  end
29
28
  end
30
29
 
31
- private
32
-
33
- def print_usage
34
- puts self.class.description
35
- puts "Usage: query-stream [options]"
36
- puts " --version Print version and exit"
30
+ # 使い方を表示する。
31
+ #
32
+ # public かつ `output:` を受ける形にする。Samovar は引数の解析に失敗すると、外から
33
+ # `error.command.print_usage(output: output)` を呼ぶ。
34
+ # @param output [IO] 出力先(Samovar からはエラー出力が渡される)
35
+ def print_usage(output: $stdout)
36
+ output.puts self.class.description
37
+ output.puts 'Usage: query-stream [options]'
38
+ output.puts ' --version Print version and exit'
39
+ output.puts ' --help Print this message and exit'
37
40
  end
38
41
  end
39
42
  end
@@ -1,13 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'logger'
4
-
5
- # ================================================================
6
- # File: lib/query_stream/configuration.rb
7
3
  # ================================================================
8
4
  # 責務:
9
5
  # QueryStream のグローバル設定を管理する。
10
- # data_dir, templates_dir, default_format, logger の4項目。
6
+ # data_dir, templates_dir, default_format, post_render の4項目。
7
+ #
8
+ # ログ出力の設定は持たない。gem 自身がログを出さないためである
9
+ # (失敗はすべて構造化例外として on_error / on_warning へ渡す)。
11
10
  # ================================================================
12
11
 
13
12
  module QueryStream
@@ -22,14 +21,16 @@ module QueryStream
22
21
  # @return [Symbol] スタイル省略時のデフォルト出力形式(:md / :html / :json)
23
22
  attr_accessor :default_format
24
23
 
25
- # @return [Logger] ログ出力先
26
- attr_accessor :logger
24
+ # 1 記法の展開結果を、コンテキスト付きで呼び出し元へ通す後段フィルタ。
25
+ # gem 自身は用途を規定しない(画像パス解決など呼び出し元固有の後処理を担わせる)。
26
+ # @return [Proc, nil] (text, context) -> String。String 以外を返した場合は元の展開結果を採用する
27
+ attr_accessor :post_render
27
28
 
28
29
  def initialize
29
30
  @data_dir = 'data'
30
31
  @templates_dir = 'templates'
31
32
  @default_format = :md
32
- @logger = Logger.new($stdout)
33
+ @post_render = nil
33
34
  end
34
35
  end
35
36
  end
@@ -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
  # データファイルの探索・単数形/複数形の自動解決を行う。
@@ -77,8 +75,7 @@ module QueryStream
77
75
  symbolize_names: true)
78
76
  end
79
77
 
80
- records = [records] if records.is_a?(Hash)
81
- records
78
+ normalize_records(records, file_path)
82
79
  rescue Psych::DisallowedClass => e
83
80
  raise DataLoadError.new(
84
81
  "データファイルに許可されていないクラス/タグが含まれています: #{e.message} (#{file_path})",
@@ -96,6 +93,28 @@ module QueryStream
96
93
  )
97
94
  end
98
95
 
96
+ # 読み込み結果をレコード配列へ正規化する。
97
+ #
98
+ # **常に配列を返す。** 空のデータファイルは YAML が `nil` を返すので、空配列に変換する
99
+ # (呼び出し先の TemplateCompiler は配列を前提にしている)。
100
+ #
101
+ # 配列でもハッシュでもない中身(先頭が文字列や数値だけの YAML)は、
102
+ # レコード群として解釈しようがないので DataLoadError で理由を伝える。
103
+ # @return [Array<Hash>]
104
+ def normalize_records(records, file_path)
105
+ case records
106
+ when nil then []
107
+ when Hash then [records]
108
+ when Array then records
109
+ else
110
+ raise DataLoadError.new(
111
+ "データファイルはレコードの配列(またはハッシュ 1 件)である必要があります" \
112
+ "(#{records.class} が入っています): #{file_path}",
113
+ file_path: file_path
114
+ )
115
+ end
116
+ end
117
+
99
118
  # 指定名ですべての拡張子を試行する
100
119
  # @param base_name [String] 拡張子なしファイル名
101
120
  # @param data_dir [String] データディレクトリ
@@ -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 の例外クラス体系を定義する。
@@ -51,7 +49,30 @@ module QueryStream
51
49
  end
52
50
  end
53
51
 
54
- class UnknownKeyError < Error; end # テンプレート内に存在しないキー
52
+ # テンプレートに、データレコードが持たないキーが書かれている
53
+ #
54
+ # 「どのキーが無いか」だけでなく「代わりに何が使えるか」「どのファイルを直すか」まで
55
+ # 属性で渡す。呼び出し元はこれを使って「もしかして =author?」のような案内を組み立てられる。
56
+ #
57
+ # location(記法の位置)と template_path(雛形の位置)は別物である。
58
+ # 直すのは雛形なので、記法の位置だけでは著者はファイルを開けない。
59
+ #
60
+ # @attr_reader key_path [String] テンプレートに書かれていたキー("author.name" 等)
61
+ # @attr_reader available_keys [Array<Symbol>] レコードが実際に持つキー
62
+ # @attr_reader template_path [String, nil] 打ち間違いがあるテンプレートのパス
63
+ # @attr_reader location [String] 記法が書かれていたソースファイル名と行番号
64
+ class UnknownKeyError < Error
65
+ attr_reader :key_path, :available_keys, :template_path, :location
66
+
67
+ def initialize(msg = nil, key_path: nil, available_keys: [], template_path: nil, location: nil)
68
+ super(msg || "テンプレートに存在しないキーが記述されています: #{key_path}")
69
+ @key_path = key_path
70
+ @available_keys = Array(available_keys)
71
+ @template_path = template_path
72
+ @location = location
73
+ end
74
+ end
75
+
55
76
  class InvalidDateError < Error; end # 無効な日付
56
77
 
57
78
  # データファイルの読み込みに失敗した
@@ -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
  # 英単語の複数形を単数形に変換する軽量ヘルパー。