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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -0
- data/README.md +214 -3
- data/RELEASE_NOTE.md +100 -0
- data/lib/query_stream/command.rb +11 -8
- data/lib/query_stream/configuration.rb +9 -8
- data/lib/query_stream/data_resolver.rb +23 -4
- data/lib/query_stream/errors.rb +24 -3
- data/lib/query_stream/filter_engine.rb +102 -25
- data/lib/query_stream/query_stream_parser.rb +48 -35
- data/lib/query_stream/singularize.rb +0 -2
- data/lib/query_stream/template_compiler.rb +93 -17
- data/lib/query_stream/version.rb +1 -1
- data/lib/query_stream.rb +71 -23
- data/query-stream.gemspec +9 -9
- metadata +24 -23
- data/Gemfile +0 -5
data/lib/query_stream/command.rb
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,
|
|
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
|
-
#
|
|
26
|
-
|
|
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
|
-
@
|
|
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
|
|
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] データディレクトリ
|
data/lib/query_stream/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
162
|
-
# @
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
180
|
+
branches.size > 1 ? [{ op: :or, branches: }] : branches.flatten
|
|
181
|
+
end
|
|
174
182
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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] 検索値
|