Module: QueryStream::QueryStreamParser

Defined in:
lib/query_stream/query_stream_parser.rb

Overview

QueryStream 記法のパーサー

Constant Summary collapse

PRIMARY_KEY_FIELDS =

主キー候補フィールド(優先順位順)

%i[id no code slug name title].freeze
AND_PATTERN =

AND 条件の区切りパターン

/\s+(?:AND|and|&&)\s+/

Class Method Summary collapse

Class Method Details

.build_primary_lookup(value) ⇒ Array<Hash>

主キー候補フィールドによる一件検索フィルタを構築するすべての主キー候補に対して OR 的に検索する

Parameters:

  • value (String)

    検索値

Returns:

  • (Array<Hash>)

    フィルタ条件(特殊な primary_key_lookup)



270
271
272
273
# File 'lib/query_stream/query_stream_parser.rb', line 270

def build_primary_lookup(value)
  parsed = parse_numeric(value)
  [{ field: :_primary_key, op: :eq, value: parsed }]
end

.build_result(source:, filters: [], sort: nil, limit: nil, style: nil, format: nil, single_lookup: false) ⇒ Object

パース結果のハッシュを構築する



276
277
278
# File 'lib/query_stream/query_stream_parser.rb', line 276

def build_result(source:, filters: [], sort: nil, limit: nil, style: nil, format: nil, single_lookup: false)
  { source:, filters:, sort:, limit:, style:, format:, single_lookup: }
end

.classify_tokens(segment, primary_context: false) ⇒ Array<Hash>

セグメント内のトークンを分類するAND で分割された複合条件、スタイル、ソート、件数を判別

Parameters:

  • segment (String)

    パイプで区切られた1セグメント

  • primary_context (Boolean) (defaults to: false)

    主キー検索コンテキスト(単数形源泉の最初のセグメント)

Returns:

  • (Array<Hash>)

    分類済みトークン



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/query_stream/query_stream_parser.rb', line 95

def classify_tokens(segment, primary_context: false)
  tokens = []

  # スタイル(:stylename または :stylename.ext)
  if segment.match?(/\A:[a-zA-Z0-9_.-]+\z/)
    style_str = segment.delete_prefix(':')
    # 拡張子の検出(:table.html → style=table, format=html)
    if style_str.match(/\A([a-zA-Z0-9_-]+)\.([a-zA-Z0-9]+)\z/)
      tokens << { type: :style, value: $1, format: $2 }
    else
      tokens << { type: :style, value: style_str }
    end
    return tokens
  end

  # ソート(-field / +field)
  if segment.match?(/\A[+-][a-zA-Z_]/)
    tokens << { type: :sort, value: parse_sort(segment) }
    return tokens
  end

  # フィルタ条件(field=value, 比較演算子, AND/OR 複合条件)
  if segment.match?(/[=!<>]/) || segment.match?(AND_PATTERN)
    tokens << { type: :filter, value: parse_filter_expression(segment) }
    return tokens
  end

  # 主キー検索コンテキスト(単数形源泉の最初のセグメント)では
  # 数値も主キー検索値として扱う(code=13 のような検索に対応)
  if primary_context
    tokens << { type: :primary_lookup, value: build_primary_lookup(segment) }
    return tokens
  end

  # 件数(正の整数のみ)
  if segment.match?(/\A\d+\z/)
    tokens << { type: :limit, value: segment.to_i }
    return tokens
  end

  # 上記いずれにも該当しない場合は主キー検索
  tokens << { type: :primary_lookup, value: build_primary_lookup(segment) }
  tokens
end

.parse(line) ⇒ Hash

QueryStream 記法をパースして構造化ハッシュを返す

Parameters:

  • line (String)

    QueryStream 行(例: “= books | tags=ruby | :full”)

Returns:

  • (Hash)

    パース結果

    • :source [String] データ名(複数形のまま)

    • :filters [Array<Hash>] フィルタ条件

    • :sort [Hash, nil] ソート条件 { field:, direction: }

    • :limit [Integer, nil] 件数制限

    • :style [String, nil] スタイル名

    • :format [String, nil] 出力形式(スタイルに拡張子がある場合)

    • :single_lookup [Boolean] 主キーによる一件検索か



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/query_stream/query_stream_parser.rb', line 43

def parse(line)
  # "= books | tags=ruby | :full" → ["books", "tags=ruby", ":full"]
  # "=books | :table" も許容
  raw = line.sub(/\A=\s*/, '').strip
  segments = raw.split('|').map { it.strip }

  source = segments.shift
  return build_result(source:) if segments.empty?

  # 源泉名が単数形かどうかを判定
  # 単数形の場合、最初の非修飾セグメントは主キー検索として解釈する
  singular_source = (Singularize.call(source) == source)

  filters = []
  sort = nil
  limit = nil
  style = nil
  format = nil
  single_lookup = false

  segments.each_with_index do |segment, idx|
    next if segment.empty?

    classified = classify_tokens(segment, primary_context: singular_source && idx == 0)
    classified.each do |token|
      case token
      in { type: :style, value:, format: fmt }
        style = value
        format = fmt
      in { type: :style, value: }
        style = value
      in { type: :limit, value: }
        limit = value
      in { type: :sort, value: }
        sort = value
      in { type: :filter, value: }
        filters.concat(value)
      in { type: :primary_lookup, value: }
        filters.concat(value)
        single_lookup = true
      end
    end
  end

  build_result(source:, filters:, sort:, limit:, style:, format:, single_lookup:)
end

.parse_eq_condition(field, value_str) ⇒ Array<Hash>

等値/範囲条件をパースする

Parameters:

  • field (String)

    フィールド名

  • value_str (String)

    “ruby, javascript” / “20..25” / “20…25”

Returns:

  • (Array<Hash>)

    フィルタ条件



221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/query_stream/query_stream_parser.rb', line 221

def parse_eq_condition(field, value_str)
  field_sym = field.to_sym

  # 範囲指定の検出
  # 開始なし(...N / ..N)を先にチェックし、次に両端あり(N...M / N..M)をチェック
  if (m = value_str.match(/\A\.\.\.(\S+)\z/))
    # 上限のみ・排他的(field=...25 → 25未満)
    [{ field: field_sym, op: :lt, value: parse_numeric(m[1]) }]
  elsif (m = value_str.match(/\A\.\.(\S+)\z/))
    # 上限のみ(field=..25)
    [{ field: field_sym, op: :lte, value: parse_numeric(m[1]) }]
  elsif (m = value_str.match(/\A([^.]\S*)\.\.\.(\S+)\z/))
    # 排他的範囲(終端除く): 20...25
    [{ field: field_sym, op: :range, value: parse_numeric(m[1])...parse_numeric(m[2]) }]
  elsif (m = value_str.match(/\A([^.]\S*)\.\.(\S+)\z/))
    # 包括的範囲: 20..25
    [{ field: field_sym, op: :range, value: parse_numeric(m[1])..parse_numeric(m[2]) }]
  elsif (m = value_str.match(/\A([^.]\S*)\.\.\z/))
    # 下限のみ(field=20..)
    [{ field: field_sym, op: :gte, value: parse_numeric(m[1]) }]
  else
    # 通常の等値条件(カンマ区切りでOR)
    [{ field: field_sym, op: :eq, value: parse_values(value_str) }]
  end
end

.parse_filter_expression(expression) ⇒ Array<Hash>

AND で接続されたフィルタ式をパースする

Parameters:

  • expression (String)

    “tags=ruby && category=web”

Returns:

  • (Array<Hash>)

    フィルタ条件のリスト



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/query_stream/query_stream_parser.rb', line 163

def parse_filter_expression(expression)
  # AND で分割
  clauses = expression.split(AND_PATTERN)
  previous_field = nil
  previous_filter = nil

  clauses.flat_map do |clause|
    clause = clause.strip
    next [] if clause.empty?

    parsed = parse_single_condition(clause)

    if parsed.empty? && previous_field
      if previous_filter && previous_filter[:field] == previous_field && previous_filter[:op] == :eq
        additional = parse_values(clause)
        previous_filter[:value] = Array(previous_filter[:value]) + additional
        next []
      else
        parsed = parse_value_only_condition(previous_field, clause)
      end
    end

    last_filter = parsed.last
    previous_field = last_filter&.[](:field)
    previous_filter = last_filter if last_filter&.[](:op) == :eq

    parsed
  end
end

.parse_numeric(str) ⇒ Integer, ...

数値文字列を適切な型に変換する

Parameters:

  • str (String)

    “20” / “3.14” / “東京”

Returns:

  • (Integer, Float, String)

    変換後の値



257
258
259
260
261
262
263
264
# File 'lib/query_stream/query_stream_parser.rb', line 257

def parse_numeric(str)
  s = str.strip
  case s
  when /\A-?\d+\z/    then s.to_i
  when /\A-?\d+\.\d+\z/ then s.to_f
  else s
  end
end

.parse_single_condition(condition) ⇒ Array<Hash>

単一条件をパースする(OR はカンマ区切りで表現)

Parameters:

  • condition (String)

    “tags=ruby, javascript” / “temp>=20”

Returns:

  • (Array<Hash>)

    フィルタ条件(1つまたは複数)



196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
# File 'lib/query_stream/query_stream_parser.rb', line 196

def parse_single_condition(condition)
  # 比較演算子の検出(!=, >=, <=, >, <, ==, = の順で試行)
  case condition.strip
  in /\A([a-zA-Z_]+)\s*!=\s*(.+)\z/
    [{ field: $1.to_sym, op: :neq, value: parse_values($2) }]
  in /\A([a-zA-Z_]+)\s*>=\s*(.+)\z/
    [{ field: $1.to_sym, op: :gte, value: parse_numeric($2.strip) }]
  in /\A([a-zA-Z_]+)\s*<=\s*(.+)\z/
    [{ field: $1.to_sym, op: :lte, value: parse_numeric($2.strip) }]
  in /\A([a-zA-Z_]+)\s*>\s*(.+)\z/
    [{ field: $1.to_sym, op: :gt, value: parse_numeric($2.strip) }]
  in /\A([a-zA-Z_]+)\s*<\s*(.+)\z/
    [{ field: $1.to_sym, op: :lt, value: parse_numeric($2.strip) }]
  in /\A([a-zA-Z_]+)\s*={1,2}\s*(.+)\z/
    parse_eq_condition($1.strip, $2.strip)
  else
    # パースできない場合は空で返す
    []
  end
end

.parse_sort(token) ⇒ Hash

ソート指定をパースする

Parameters:

  • token (String)

    “-title” / “+title” / “title”

Returns:

  • (Hash)

    { field:, direction: }



143
144
145
146
147
148
149
150
# File 'lib/query_stream/query_stream_parser.rb', line 143

def parse_sort(token)
  case token
  in /\A-(.+)\z/
    { field: $1.to_sym, direction: :desc }
  in /\A\+?(.+)\z/
    { field: $1.to_sym, direction: :asc }
  end
end

.parse_value_only_condition(field, value_str) ⇒ Array<Hash>

直前フィールドを引き継いで値のみの条件をパースする

Parameters:

  • field (Symbol, String)

    直前フィールド

  • value_str (String)

    “ruby” / “ruby, beginner”

Returns:

  • (Array<Hash>)

    フィルタ条件



156
157
158
# File 'lib/query_stream/query_stream_parser.rb', line 156

def parse_value_only_condition(field, value_str)
  parse_eq_condition(field, value_str)
end

.parse_values(str) ⇒ Array<String>

カンマ区切りの値をリストとしてパースする

Parameters:

  • str (String)

    “ruby, javascript”

Returns:

  • (Array<String>)
    “ruby”, “javascript”


250
251
252
# File 'lib/query_stream/query_stream_parser.rb', line 250

def parse_values(str)
  str.split(',').map { it.strip }
end