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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -1
- data/README.md +169 -7
- data/RELEASE_NOTE.md +54 -0
- data/lib/query_stream/command.rb +2 -7
- data/lib/query_stream/configuration.rb +7 -3
- data/lib/query_stream/data_resolver.rb +2 -6
- data/lib/query_stream/errors.rb +68 -4
- data/lib/query_stream/filter_engine.rb +138 -26
- data/lib/query_stream/query_stream_parser.rb +69 -41
- data/lib/query_stream/singularize.rb +0 -2
- data/lib/query_stream/template_compiler.rb +121 -16
- data/lib/query_stream/version.rb +1 -1
- data/lib/query_stream.rb +14 -25
- data/query-stream.gemspec +7 -8
- metadata +21 -8
- data/Gemfile +0 -5
|
@@ -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
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
#
|
|
162
|
-
# @
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
191
|
+
branches.size > 1 ? [{ op: :or, branches: }] : branches.flatten
|
|
192
|
+
end
|
|
174
193
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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(
|
|
217
|
+
in /\A(#{FIELD})\s*!=\s*(.+)\z/
|
|
200
218
|
[{ field: $1.to_sym, op: :neq, value: parse_values($2) }]
|
|
201
|
-
in /\A(
|
|
219
|
+
in /\A(#{FIELD})\s*>=\s*(.+)\z/
|
|
202
220
|
[{ field: $1.to_sym, op: :gte, value: parse_numeric($2.strip) }]
|
|
203
|
-
in /\A(
|
|
221
|
+
in /\A(#{FIELD})\s*<=\s*(.+)\z/
|
|
204
222
|
[{ field: $1.to_sym, op: :lte, value: parse_numeric($2.strip) }]
|
|
205
|
-
in /\A(
|
|
223
|
+
in /\A(#{FIELD})\s*>\s*(.+)\z/
|
|
206
224
|
[{ field: $1.to_sym, op: :gt, value: parse_numeric($2.strip) }]
|
|
207
|
-
in /\A(
|
|
225
|
+
in /\A(#{FIELD})\s*<\s*(.+)\z/
|
|
208
226
|
[{ field: $1.to_sym, op: :lt, value: parse_numeric($2.strip) }]
|
|
209
|
-
in /\A(
|
|
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/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
|
# - `` → 変数展開(拡張子なし → 変数、拡張子あり → リテラル)
|
|
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
|
|
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
|
|
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
|
-
|
|
260
|
-
if
|
|
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
|
-
"#{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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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,
|
|
312
|
-
|
|
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|
|
data/lib/query_stream/version.rb
CHANGED