Module: Vivlio::Starter::CLI::PreProcessCommands::DataRender::QueryStreamParser

Defined in:
lib/vivlio/starter/cli/pre_process/data_render/query_stream_parser.rb

Overview

QueryStream 記法のパーサー

Constant Summary collapse

PRIMARY_KEY_FIELDS =

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

%i[id no code 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)



262
263
264
265
# File 'lib/vivlio/starter/cli/pre_process/data_render/query_stream_parser.rb', line 262

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, single_lookup: false) ⇒ Object

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



268
269
270
# File 'lib/vivlio/starter/cli/pre_process/data_render/query_stream_parser.rb', line 268

def build_result(source:, filters: [], sort: nil, limit: nil, style: nil, single_lookup: false)
  { source:, filters:, sort:, limit:, style:, 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>)

    分類済みトークン



93
94
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
# File 'lib/vivlio/starter/cli/pre_process/data_render/query_stream_parser.rb', line 93

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

  # スタイル(:stylename)
  if segment.match?(/\A:[a-zA-Z0-9_-]+\z/)
    tokens << { type: :style, value: segment.delete_prefix(':') }
    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] スタイル名

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



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
# File 'lib/vivlio/starter/cli/pre_process/data_render/query_stream_parser.rb', line 46

def parse(line)
  # "= books | tags=ruby | :full" → ["books", "tags=ruby", ":full"]
  raw = line.sub(/\A=\s+/, '').strip
  segments = raw.split('|').map(&:strip)

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

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

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

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

    classified = classify_tokens(segment, primary_context: singular_source && idx.zero?)
    classified.each do |token|
      case token
      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:, 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>)

    フィルタ条件



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/vivlio/starter/cli/pre_process/data_render/query_stream_parser.rb', line 213

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>)

    フィルタ条件のリスト



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/vivlio/starter/cli/pre_process/data_render/query_stream_parser.rb', line 155

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)

    変換後の値



249
250
251
252
253
254
255
256
# File 'lib/vivlio/starter/cli/pre_process/data_render/query_stream_parser.rb', line 249

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つまたは複数)



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/vivlio/starter/cli/pre_process/data_render/query_stream_parser.rb', line 188

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

.parse_sort(token) ⇒ Hash

ソート指定をパースする

Parameters:

  • token (String)

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

Returns:

  • (Hash)

    { field:, direction: }



135
136
137
138
139
140
141
142
# File 'lib/vivlio/starter/cli/pre_process/data_render/query_stream_parser.rb', line 135

def parse_sort(token)
  case token
  in /\A-(.+)\z/
    { field: ::Regexp.last_match(1).to_sym, direction: :desc }
  in /\A\+?(.+)\z/
    { field: ::Regexp.last_match(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>)

    フィルタ条件



148
149
150
# File 'lib/vivlio/starter/cli/pre_process/data_render/query_stream_parser.rb', line 148

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”


242
243
244
# File 'lib/vivlio/starter/cli/pre_process/data_render/query_stream_parser.rb', line 242

def parse_values(str)
  str.split(',').map(&:strip)
end