query-stream 1.2.2

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e3f3bc788221f5c37fb1327f132501ec5fe4607e97f3e673ddbc2a32dfeeb188
4
+ data.tar.gz: 9111b6651c0596097399b9ffb46f8acea17928dd572f3a7af38ef167ddd6a037
5
+ SHA512:
6
+ metadata.gz: 792bbe4684efb730c7d1f122ecdf6cff9dfbaa484254f7bf9830b47790978cba0a5150939845975e07fdd2ac9d24e464a66b2f17ee7085e4e8097f041fecb105
7
+ data.tar.gz: e3de3485e7fdb0f5bdb9d8f728ee2742387540c342f82a27765b80282d9dff1b337a680f419467440fdfbf9b201dfc27414d43110f0f37864e097de36d4f1c04
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,41 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Atelier Mirai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ # 日本語訳(参考)
26
+
27
+ MIT ライセンス
28
+
29
+ Copyright (c) 2025 Atelier Mirai
30
+
31
+ 本ソフトウェアおよび関連ドキュメントファイル(以下「ソフトウェア」)の複製を取得したすべての人に対して、
32
+ ソフトウェアを制限なく扱うことを無償で許可します。これには、ソフトウェアの複製を使用、複製、変更、
33
+ 結合、公表、頒布、サブライセンス、および/または販売する権利、およびソフトウェアを提供された人が
34
+ そうすることを許可する権利も含まれますが、それらに限定されません。
35
+
36
+ 上記の著作権表示およびこの許可表示を、ソフトウェアのすべての複製または重要な部分に含めるものとします。
37
+
38
+ 本ソフトウェアは「現状のまま」提供され、明示または黙示を問わず、商品性、特定目的への適合性、
39
+ および権利の非侵害を含むがそれらに限定されないいかなる保証もありません。
40
+ 著作権者または権利者は、ソフトウェアに関して、契約、不法行為、またはその他の方法で
41
+ 発生するいかなる請求、損害、責任についても責任を負いません。
data/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # QueryStream
2
+
3
+ YAML/JSON データファイルとテンプレートファイルを組み合わせて、テキストコンテンツ内の QueryStream 記法を展開する汎用 Ruby ライブラリ。
4
+
5
+ ## インストール
6
+
7
+ ```ruby
8
+ # Gemfile
9
+ gem 'query-stream'
10
+ ```
11
+
12
+ ```bash
13
+ bundle install
14
+ ```
15
+
16
+ ## 基本的な使い方
17
+
18
+ ```ruby
19
+ require 'query_stream'
20
+
21
+ # テキスト内の QueryStream 記法を展開
22
+ source = File.read('contents/05-references.md')
23
+ result = QueryStream.render(source)
24
+ ```
25
+
26
+ ## QueryStream 記法
27
+
28
+ ```
29
+ = [源泉] | [抽出条件] | [ソート] | [件数] | [スタイル]
30
+ ```
31
+
32
+ ### 例
33
+
34
+ ```
35
+ = books # 全件展開
36
+ = books | tags=ruby # タグで絞り込み
37
+ = books | tags=ruby | -title | 5 # 絞り込み+降順ソート+5件
38
+ = book | 楽しいRuby # 主キーで一件検索
39
+ = books | :full # fullスタイルで展開
40
+ ```
41
+
42
+ ### 具体例:vivlio-style での書籍データ展開
43
+
44
+ #### データファイル例 (`data/books.yml`)
45
+
46
+ ```yaml
47
+ - title: 楽しいRuby
48
+ author:
49
+ name: 高橋征義
50
+ desc: Rubyを楽しく学べる入門書。
51
+ cover: ruby.webp
52
+
53
+ - title: はじめてのC
54
+ author:
55
+ name: 柴田望洋
56
+ desc: C言語の定番入門書。
57
+ cover: c.webp
58
+ ```
59
+
60
+ #### テンプレートファイル例 (`templates/_book.md`)
61
+
62
+ ```markdown
63
+ :::{.book-card}
64
+ ![](cover)
65
+ **=title**
66
+ =desc
67
+ :::
68
+ ```
69
+
70
+ #### Markdown記述
71
+
72
+ ```markdown
73
+ ## 参考書籍
74
+
75
+ = books
76
+
77
+ ---
78
+ ```
79
+
80
+ #### 展開結果
81
+
82
+ ```markdown
83
+ ## 参考書籍
84
+
85
+ :::{.book-card}
86
+ ![](ruby.webp)
87
+ **楽しいRuby**
88
+ Rubyを楽しく学べる入門書。
89
+ :::
90
+
91
+ :::{.book-card}
92
+ ![](c.webp)
93
+ **はじめてのC**
94
+ C言語の定番入門書。
95
+ :::
96
+
97
+ ---
98
+ ```
99
+
100
+ VFMフェンス記法(`:::{.book-card}`)にも対応しており、各レコードが個別のフェンスで囲まれて展開されます。
101
+
102
+ ## 設定
103
+
104
+ ```ruby
105
+ QueryStream.configure do |config|
106
+ config.data_dir = 'data'
107
+ config.templates_dir = 'templates'
108
+ config.default_format = :md
109
+ config.logger = Logger.new($stdout)
110
+ end
111
+ ```
112
+
113
+ ## ライセンス
114
+
115
+ MIT License
data/bin/query-stream ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'query_stream'
5
+ require 'query_stream/command'
6
+
7
+ QueryStream::Command::Top.call(ARGV)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'samovar'
4
+
5
+ # ================================================================
6
+ # File: lib/query_stream/command.rb
7
+ # ================================================================
8
+ # 責務:
9
+ # QueryStream の CLI コマンド。--version のみ提供する。
10
+ # ================================================================
11
+
12
+ module QueryStream
13
+ module Command
14
+ # トップレベルコマンド
15
+ class Top < Samovar::Command
16
+ self.description = 'QueryStream - YAML/JSON data renderer'
17
+
18
+ options do
19
+ option '--version', 'Print version and exit'
20
+ end
21
+
22
+ # コマンド実行
23
+ def call
24
+ if @options[:version]
25
+ puts "query-stream #{QueryStream::VERSION}"
26
+ else
27
+ print_usage
28
+ end
29
+ end
30
+
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"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ # ================================================================
6
+ # File: lib/query_stream/configuration.rb
7
+ # ================================================================
8
+ # 責務:
9
+ # QueryStream のグローバル設定を管理する。
10
+ # data_dir, templates_dir, default_format, logger の4項目。
11
+ # ================================================================
12
+
13
+ module QueryStream
14
+ # グローバル設定クラス
15
+ class Configuration
16
+ # @return [String] データファイルのディレクトリ
17
+ attr_accessor :data_dir
18
+
19
+ # @return [String] テンプレートファイルのディレクトリ
20
+ attr_accessor :templates_dir
21
+
22
+ # @return [Symbol] スタイル省略時のデフォルト出力形式(:md / :html / :json)
23
+ attr_accessor :default_format
24
+
25
+ # @return [Logger] ログ出力先
26
+ attr_accessor :logger
27
+
28
+ def initialize
29
+ @data_dir = 'data'
30
+ @templates_dir = 'templates'
31
+ @default_format = :md
32
+ @logger = Logger.new($stdout)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'json'
5
+
6
+ # ================================================================
7
+ # File: lib/query_stream/data_resolver.rb
8
+ # ================================================================
9
+ # 責務:
10
+ # データファイルの探索・単数形/複数形の自動解決を行う。
11
+ # YAML (.yml, .yaml) と JSON (.json) をサポートする。
12
+ #
13
+ # セキュリティ設計:
14
+ # YAML データファイルは `YAML.safe_load_file` で読み込む。
15
+ # `permitted_classes` は Symbol / Time / Date / DateTime に限定し、
16
+ # `!ruby/object` 等の Ruby オブジェクトタグは `Psych::DisallowedClass`
17
+ # として検出し、`QueryStream::DataLoadError` に変換する。
18
+ # `aliases: true` は DRY な YAML 記述のために許可するが、
19
+ # Psych 5.x の Billion Laughs 対策により DoS 耐性がある。
20
+ # ================================================================
21
+
22
+ require_relative 'errors'
23
+
24
+ module QueryStream
25
+ # データファイル探索・読み込みモジュール
26
+ module DataResolver
27
+ # サポートする拡張子(優先順位順)
28
+ EXTENSIONS = %w[.yml .yaml .json].freeze
29
+
30
+ # YAML.safe_load_file で許可するクラス
31
+ # Symbol: symbolize_names: true のために必要
32
+ # Time/Date/DateTime: 日付/時刻を含む実用データに対応
33
+ YAML_PERMITTED_CLASSES = [Symbol, Time, Date, DateTime].freeze
34
+
35
+ module_function
36
+
37
+ # データファイルのパスを解決する
38
+ # 指定名そのまま → 複数形(末尾に s を付与)→ 単数形の順で探索する
39
+ # @param source_name [String] データ名(単数形または複数形)
40
+ # @param data_dir [String] データディレクトリ
41
+ # @return [String, nil] 見つかったファイルパス、または nil
42
+ def resolve(source_name, data_dir)
43
+ # そのまま試行
44
+ found = find_with_extensions(source_name, data_dir)
45
+ return found if found
46
+
47
+ # 複数形を試行(単数形→複数形: book → books)
48
+ found = find_with_extensions("#{source_name}s", data_dir)
49
+ return found if found
50
+
51
+ # 単数形を試行(複数形→単数形: books → book)
52
+ singular = Singularize.call(source_name)
53
+ if singular != source_name
54
+ found = find_with_extensions(singular, data_dir)
55
+ return found if found
56
+ end
57
+
58
+ nil
59
+ end
60
+
61
+ # レコード群をファイルから読み込む
62
+ #
63
+ # YAML は `safe_load_file` で読み込み、`!ruby/object` 等の危険なタグを
64
+ # `QueryStream::DataLoadError` に変換して呼び出し元に通知する。
65
+ #
66
+ # @param file_path [String] データファイルパス
67
+ # @return [Array<Hash>] レコード群(シンボルキー)
68
+ # @raise [DataLoadError] YAML/JSON の構文エラー、許可されていないクラス等
69
+ def load_records(file_path)
70
+ records = case File.extname(file_path).downcase
71
+ when '.json'
72
+ JSON.parse(File.read(file_path, encoding: 'utf-8'), symbolize_names: true)
73
+ else
74
+ YAML.safe_load_file(file_path,
75
+ permitted_classes: YAML_PERMITTED_CLASSES,
76
+ aliases: true,
77
+ symbolize_names: true)
78
+ end
79
+
80
+ records = [records] if records.is_a?(Hash)
81
+ records
82
+ rescue Psych::DisallowedClass => e
83
+ raise DataLoadError.new(
84
+ "データファイルに許可されていないクラス/タグが含まれています: #{e.message} (#{file_path})",
85
+ file_path: file_path, cause_error: e
86
+ )
87
+ rescue Psych::SyntaxError => e
88
+ raise DataLoadError.new(
89
+ "データファイルの YAML 構文エラー: #{e.message} (#{file_path})",
90
+ file_path: file_path, cause_error: e
91
+ )
92
+ rescue JSON::ParserError => e
93
+ raise DataLoadError.new(
94
+ "データファイルの JSON 構文エラー: #{e.message} (#{file_path})",
95
+ file_path: file_path, cause_error: e
96
+ )
97
+ end
98
+
99
+ # 指定名ですべての拡張子を試行する
100
+ # @param base_name [String] 拡張子なしファイル名
101
+ # @param data_dir [String] データディレクトリ
102
+ # @return [String, nil] 見つかったファイルパス、または nil
103
+ def find_with_extensions(base_name, data_dir)
104
+ EXTENSIONS.each do |ext|
105
+ path = File.join(data_dir, "#{base_name}#{ext}")
106
+ return path if File.exist?(path)
107
+ end
108
+ nil
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ================================================================
4
+ # File: lib/query_stream/errors.rb
5
+ # ================================================================
6
+ # 責務:
7
+ # QueryStream の例外クラス体系を定義する。
8
+ # ERROR系は処理を中断し、WARNING系は処理を続行する。
9
+ # ================================================================
10
+
11
+ module QueryStream
12
+ # 基底クラス
13
+ class Error < StandardError; end
14
+ class Warning < StandardError; end
15
+
16
+ # ERROR系(処理を中断)
17
+ #
18
+ # 各エラークラスは構造化された属性を持つ。
19
+ # gem 内ではログ出力を行わず、呼び出し元が属性を使って
20
+ # 独自のメッセージ・i18n・フォーマットを構成する。
21
+
22
+ # テンプレートファイルが存在しない
23
+ # @attr_reader template_path [String] 期待されたテンプレートパス
24
+ # @attr_reader query [String] 元の QueryStream 記法
25
+ # @attr_reader location [String] ソースファイル名と行番号
26
+ # @attr_reader hint [String, nil] 修正ヒント
27
+ class TemplateNotFoundError < Error
28
+ attr_reader :template_path, :query, :location, :hint
29
+
30
+ def initialize(msg = nil, template_path: nil, query: nil, location: nil, hint: nil)
31
+ super(msg || "テンプレートファイルが見つかりません: #{template_path}")
32
+ @template_path = template_path
33
+ @query = query
34
+ @location = location
35
+ @hint = hint
36
+ end
37
+ end
38
+
39
+ # データファイルが存在しない
40
+ # @attr_reader expected_path [String] 期待されたデータファイルパス
41
+ # @attr_reader query [String] 元の QueryStream 記法
42
+ # @attr_reader location [String] ソースファイル名と行番号
43
+ class DataNotFoundError < Error
44
+ attr_reader :expected_path, :query, :location
45
+
46
+ def initialize(msg = nil, expected_path: nil, query: nil, location: nil)
47
+ super(msg || "データファイルが見つかりません: #{expected_path}")
48
+ @expected_path = expected_path
49
+ @query = query
50
+ @location = location
51
+ end
52
+ end
53
+
54
+ class UnknownKeyError < Error; end # テンプレート内に存在しないキー
55
+ class InvalidDateError < Error; end # 無効な日付
56
+
57
+ # データファイルの読み込みに失敗した
58
+ # (YAML/JSON 構文エラー、YAML で許可されていないクラス/タグ等)
59
+ #
60
+ # セキュリティ設計:
61
+ # YAML データファイルは `YAML.safe_load_file` で読み込まれ、
62
+ # `!ruby/object` など危険な Ruby オブジェクトタグは
63
+ # `Psych::DisallowedClass` として検出され、本例外に変換される。
64
+ #
65
+ # @attr_reader file_path [String] 読み込みに失敗したファイルパス
66
+ # @attr_reader cause_error [StandardError, nil] 元の例外(Psych::DisallowedClass 等)
67
+ class DataLoadError < Error
68
+ attr_reader :file_path, :cause_error
69
+
70
+ def initialize(msg = nil, file_path: nil, cause_error: nil)
71
+ super(msg || "データファイルの読み込みに失敗しました: #{file_path}")
72
+ @file_path = file_path
73
+ @cause_error = cause_error
74
+ end
75
+ end
76
+
77
+ # WARNING系(処理を続行)
78
+ #
79
+ # ERROR と同様に構造化された属性を持ち、gem 内ではログ出力を行わず、
80
+ # 呼び出し元が on_warning コールバックで独自メッセージを構成する。
81
+
82
+ # 一件検索で0件ヒット
83
+ # @attr_reader query [String] 元の QueryStream 記法
84
+ # @attr_reader location [String] ソースファイル名と行番号
85
+ class NoResultWarning < Warning
86
+ attr_reader :query, :location
87
+
88
+ def initialize(msg = nil, query: nil, location: nil)
89
+ super(msg || "一件検索で該当なし: #{query}")
90
+ @query = query
91
+ @location = location
92
+ end
93
+ end
94
+
95
+ # 一件検索で複数件ヒット
96
+ # @attr_reader query [String] 元の QueryStream 記法
97
+ # @attr_reader location [String] ソースファイル名と行番号
98
+ # @attr_reader count [Integer] ヒット件数
99
+ class AmbiguousQueryWarning < Warning
100
+ attr_reader :query, :location, :count
101
+
102
+ def initialize(msg = nil, query: nil, location: nil, count: nil)
103
+ super(msg || "一件検索で複数件ヒット(#{count}件): #{query}")
104
+ @query = query
105
+ @location = location
106
+ @count = count
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ # ================================================================
6
+ # File: lib/query_stream/filter_engine.rb
7
+ # ================================================================
8
+ # 責務:
9
+ # AND/OR/比較/Range によるフィルタリングとソート処理を行う。
10
+ # 日付の自動正規化にも対応する。
11
+ # ================================================================
12
+
13
+ module QueryStream
14
+ # フィルタリング&ソートエンジンモジュール
15
+ module FilterEngine
16
+ module_function
17
+
18
+ # フィルタ条件をレコード群に適用する
19
+ # @param records [Array<Hash>] レコード群
20
+ # @param filters [Array<Hash>] フィルタ条件の配列
21
+ # @return [Array<Hash>] フィルタ後のレコード群
22
+ def apply_filters(records, filters)
23
+ return records if filters.nil? || filters.empty?
24
+
25
+ records.select do |record|
26
+ filters.all? { evaluate_filter(record, it) }
27
+ end
28
+ end
29
+
30
+ # ソート条件を適用する
31
+ # @param records [Array<Hash>] レコード群
32
+ # @param sort [Hash] ソート条件 { field:, direction: }
33
+ # @return [Array<Hash>] ソート後のレコード群
34
+ def apply_sort(records, sort)
35
+ sorted = records.sort_by { to_comparable(it[sort[:field]]) }
36
+ sort[:direction] == :desc ? sorted.reverse : sorted
37
+ end
38
+
39
+ # 単一フィルタ条件をレコードに対して評価する
40
+ # @param record [Hash] 単一レコード
41
+ # @param filter [Hash] フィルタ条件 { field:, op:, value: }
42
+ # @return [Boolean] 条件に合致するか
43
+ def evaluate_filter(record, filter)
44
+ # 主キー検索(_primary_key)の特別処理
45
+ if filter[:field] == :_primary_key
46
+ return evaluate_primary_key_lookup(record, filter[:value])
47
+ end
48
+
49
+ field_value = record[filter[:field]]
50
+
51
+ case filter[:op]
52
+ when :eq
53
+ match_eq(field_value, filter[:value])
54
+ when :neq
55
+ !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])
64
+ when :range
65
+ range = filter[:value]
66
+ range.cover?(to_comparable(field_value))
67
+ else
68
+ false
69
+ end
70
+ end
71
+
72
+ # 主キー候補フィールドを順番に走査して一致するものを探す
73
+ # @param record [Hash] 単一レコード
74
+ # @param query_value [Object] 検索値
75
+ # @return [Boolean] いずれかの主キー候補と一致するか
76
+ def evaluate_primary_key_lookup(record, query_value)
77
+ QueryStreamParser::PRIMARY_KEY_FIELDS.any? do |key|
78
+ record[key]&.to_s == query_value.to_s
79
+ end
80
+ end
81
+
82
+ # 等値比較(配列・カンマ区切り文字列を透過的に扱う)
83
+ # データ側が配列/カンマ区切りの場合、ORの値リストと交差判定する
84
+ 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 }
87
+
88
+ # フィールド側の値リストと条件値リストに交差があれば一致
89
+ (field_list & value_list).any?
90
+ end
91
+
92
+ # フィールド値をリスト化する(配列/カンマ区切り/単値を統一)
93
+ def normalize_to_list(value)
94
+ case value
95
+ when Array
96
+ value.map { it.to_s.strip }
97
+ when String
98
+ value.split(',').map { it.strip }
99
+ when nil
100
+ []
101
+ else
102
+ [value.to_s.strip]
103
+ end
104
+ end
105
+
106
+ # 比較用に値を正規化する(数値変換・日付変換を試みる)
107
+ def to_comparable(value)
108
+ case value
109
+ when Date, Time
110
+ value
111
+ 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
120
+ # 数値変換を試みる
121
+ case value
122
+ when /\A-?\d+\z/
123
+ value.to_i
124
+ when /\A-?\d+\.\d+\z/
125
+ value.to_f
126
+ else
127
+ value
128
+ end
129
+ when Integer, Float
130
+ value
131
+ else
132
+ value.to_s
133
+ end
134
+ end
135
+ end
136
+ end