column_sifter 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2ad6e4ce81ed31d83084bdacbb06b7583d8fb148a894e331d7a3384e136e781b
4
+ data.tar.gz: 62b2bcc42128031d411aa7038e0e90feab92fa8f89e83cddc679f06239e43e34
5
+ SHA512:
6
+ metadata.gz: 5a19e774198b0c5a6f1fad47acac3abbb00f3629ba81c36438f719f2664dd71cd66db0e6088f8b8c17d6bdbf9fc7d523f2cbbf0e13bb7e2eb79944d800df5575
7
+ data.tar.gz: 46e2ecd71a3f21ee6b09163a53678526e5b511fd9c3adbf6c6f3f5609e7e503a79bd9d7f89e75b2d76efa4d8f5ace489e6d722a35400de2058d1aad380fe2f0a
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 kyuuri1791
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 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.
data/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # ColumnSifter
2
+
3
+ ColumnSifter provides SQL-like column filtering for admin screens: it turns a `WHERE`-like expression string into a SQL-injection-safe ActiveRecord `where` condition.
4
+
5
+ ![Admin screen filtering articles with a SQL-like query](demo.png)
6
+
7
+ User input is run through a custom lexer → parser → compiler, and the condition is assembled so that:
8
+
9
+ - values → passed via bind placeholders (`?`)
10
+ - column names → only canonical names matched against a whitelist (`searchable_columns`) are emitted
11
+ - operators → fixed tokens only
12
+
13
+ so no raw input string leaks into the SQL.
14
+
15
+ ## Installation
16
+
17
+ ```ruby
18
+ # Gemfile
19
+ gem 'column_sifter'
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ Subclass `ColumnSifter::Base` and declare the columns you allow filtering on with `searchable_columns`.
25
+ The target model is resolved from the class name with the `Sifter` suffix stripped (e.g. `ArticleSifter` → `Article`).
26
+
27
+ ```ruby
28
+ # app/column_sifters/article_sifter.rb
29
+ class ArticleSifter < ColumnSifter::Base
30
+ searchable_columns(:id, :author_id, :status, :published_at)
31
+ end
32
+ ```
33
+
34
+ If the model can't be resolved from the class name (different name, namespaced, etc.), declare it explicitly with `model`:
35
+
36
+ ```ruby
37
+ class ArticleSifter < ColumnSifter::Base
38
+ model BlogPost
39
+ searchable_columns(:id, :author_id, :status, :published_at)
40
+ end
41
+ ```
42
+
43
+ Instantiate the sifter, pass the expression to `sift!`, and use the result to build the query:
44
+
45
+ ```ruby
46
+ class ArticlesController < ApplicationController
47
+ def index
48
+ @sifter = ArticleSifter.new
49
+ @sifter.sift!(params[:sift_query])
50
+
51
+ @articles =
52
+ if @sifter.error.blank?
53
+ Article.where(@sifter.sql_template, *@sifter.binds)
54
+ else
55
+ Article.none
56
+ end
57
+ end
58
+ end
59
+ ```
60
+
61
+ `sift!` is a bang method that mutates the instance, storing the result internally. You can then read:
62
+
63
+ - `sql_template` … the SQL fragment with placeholders
64
+ - `binds` … the array of values for the placeholders
65
+ - `input` … the given input string
66
+ - `error` … the parse error message (`nil` on success)
67
+
68
+ A search form for the expression:
69
+
70
+ ```erb
71
+ <%= form_with url: articles_path, method: :get do %>
72
+ <%= text_field_tag :sift_query, @sifter&.input,
73
+ placeholder: "status = 1 AND author_id IN (1, 2, 3)" %>
74
+ <% if @sifter&.error %>
75
+ <div><%= @sifter.error %></div>
76
+ <% end %>
77
+ <small>searchable columns: <%= ArticleSifter.searchable_columns.join(', ') %></small>
78
+ <%= submit_tag 'Search' %>
79
+ <% end %>
80
+ ```
81
+
82
+ ## Supported syntax
83
+
84
+ - comparison operators: `=` `!=` `<>` `>` `>=` `<` `<=`
85
+ - `IN (...)`
86
+ - `IS NULL` / `IS NOT NULL`
87
+ - combinators: `AND` `OR` `NOT` and parentheses `()`
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColumnSifter
4
+ class Base
5
+ class Compiler
6
+ TEMPORAL_TYPES = %i[datetime date time].freeze
7
+
8
+ def initialize(allowed_columns, model)
9
+ @allowed_columns = allowed_columns
10
+ @model = model
11
+ end
12
+
13
+ def compile(node)
14
+ case node[:type]
15
+ when :and then combine('AND', node[:children])
16
+ when :or then combine('OR', node[:children])
17
+ when :not then compile_not(node)
18
+ when :cmp then compile_cmp(node)
19
+ when :in then compile_in(node)
20
+ when :is_null then compile_is_null(node)
21
+ else
22
+ raise ParseError, "unknown node: #{node[:type]}"
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def combine(keyword, children)
29
+ sqls = []
30
+ binds = []
31
+ children.each do |child|
32
+ sql, *child_binds = compile(child)
33
+ sqls << "(#{sql})"
34
+ binds.concat(child_binds)
35
+ end
36
+ [sqls.join(" #{keyword} ").to_s, *binds]
37
+ end
38
+
39
+ def compile_not(node)
40
+ sql, *binds = compile(node[:child])
41
+ ["NOT (#{sql})", *binds]
42
+ end
43
+
44
+ def compile_cmp(node)
45
+ column = column!(node[:column])
46
+ validate_value!(column, node[:value])
47
+ ["#{qualify(column)} #{node[:op]} ?", node[:value]]
48
+ end
49
+
50
+ def compile_in(node)
51
+ column = column!(node[:column])
52
+ node[:values].each { |value| validate_value!(column, value) }
53
+ placeholders = Array.new(node[:values].size, '?').join(', ')
54
+ ["#{qualify(column)} IN (#{placeholders})", *node[:values]]
55
+ end
56
+
57
+ def compile_is_null(node)
58
+ ["#{qualify(column!(node[:column]))} IS #{node[:negated] ? 'NOT ' : ''}NULL"]
59
+ end
60
+
61
+ def column!(name)
62
+ column = @allowed_columns.find { |allowed| allowed.casecmp?(name) }
63
+ raise ParseError, "column not searchable: #{name} (searchable: #{@allowed_columns.join(', ')})" unless column
64
+
65
+ column
66
+ end
67
+
68
+ def qualify(column)
69
+ "#{@model.table_name}.#{column}"
70
+ end
71
+
72
+ def validate_value!(column, value)
73
+ type = @model.type_for_attribute(column)
74
+ return unless TEMPORAL_TYPES.include?(type.type)
75
+ return if type.cast(value)
76
+
77
+ raise ParseError, "invalid date/time value for #{column}: #{value.inspect}"
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColumnSifter
4
+ class Base
5
+ class Lexer
6
+ Token = Struct.new(:type, :value)
7
+ KEYWORDS = %w[AND OR NOT IS NULL IN].freeze
8
+ PATTERNS = [
9
+ [:ws, /\A\s+/],
10
+ [:lparen, /\A\(/],
11
+ [:rparen, /\A\)/],
12
+ [:comma, /\A,/],
13
+ [:op, /\A(?:<=|>=|<>|!=|=|<|>)/],
14
+ [:number, /\A-?\d+(?:\.\d+)?/],
15
+ [:string, /\A'(?:[^']|'')*'/],
16
+ [:string, /\A"(?:[^"]|"")*"/],
17
+ [:ident, /\A[A-Za-z_][A-Za-z0-9_]*/]
18
+ ].freeze
19
+
20
+ def initialize(input)
21
+ @input = input.to_s
22
+ end
23
+
24
+ def tokenize
25
+ rest = @input
26
+ tokens = []
27
+
28
+ until rest.empty?
29
+ type, matched = match_token(rest)
30
+ raise ParseError, "unable to parse near: #{rest[0..10].inspect}" if matched.nil?
31
+
32
+ rest = rest[matched.length..]
33
+ next if type == :ws
34
+
35
+ tokens << build_token(type, matched)
36
+ end
37
+
38
+ tokens
39
+ end
40
+
41
+ private
42
+
43
+ def match_token(rest)
44
+ PATTERNS.each do |type, pattern|
45
+ m = rest.match(pattern)
46
+ return [type, m[0]] if m
47
+ end
48
+ [nil, nil]
49
+ end
50
+
51
+ def build_token(type, matched)
52
+ case type
53
+ when :number
54
+ Token.new(:number, matched.include?('.') ? matched.to_f : matched.to_i)
55
+ when :string
56
+ Token.new(:string, unquote(matched))
57
+ when :ident
58
+ keyword?(matched) ? Token.new(:keyword, matched.upcase) : Token.new(:ident, matched)
59
+ else
60
+ Token.new(type, matched)
61
+ end
62
+ end
63
+
64
+ def keyword?(ident)
65
+ KEYWORDS.include?(ident.upcase)
66
+ end
67
+
68
+ def unquote(literal)
69
+ quote = literal[0]
70
+ literal[1..-2].gsub(quote * 2, quote)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColumnSifter
4
+ class Base
5
+ class Parser
6
+ MAX_DEPTH = 100
7
+
8
+ def initialize(tokens)
9
+ @tokens = tokens
10
+ @pos = 0
11
+ @depth = 0
12
+ end
13
+
14
+ def parse
15
+ raise ParseError, 'query is empty' if @tokens.empty?
16
+
17
+ node = parse_or
18
+ raise ParseError, "unexpected token: #{peek.value.inspect}" unless at_end?
19
+
20
+ node
21
+ end
22
+
23
+ private
24
+
25
+ def parse_or
26
+ nodes = [parse_and]
27
+ nodes << parse_and while accept_keyword('OR')
28
+ nodes.size == 1 ? nodes.first : { type: :or, children: nodes }
29
+ end
30
+
31
+ def parse_and
32
+ nodes = [parse_not]
33
+ nodes << parse_not while accept_keyword('AND')
34
+ nodes.size == 1 ? nodes.first : { type: :and, children: nodes }
35
+ end
36
+
37
+ def parse_not
38
+ return descend { { type: :not, child: parse_not } } if accept_keyword('NOT')
39
+
40
+ parse_primary
41
+ end
42
+
43
+ def parse_primary
44
+ if accept(:lparen)
45
+ node = descend { parse_or }
46
+ expect(:rparen)
47
+ return node
48
+ end
49
+
50
+ parse_condition
51
+ end
52
+
53
+ def descend
54
+ @depth += 1
55
+ raise ParseError, 'expression is too deeply nested' if @depth > MAX_DEPTH
56
+
57
+ result = yield
58
+ @depth -= 1
59
+ result
60
+ end
61
+
62
+ def parse_condition
63
+ column = expect(:ident).value
64
+
65
+ if accept_keyword('IS')
66
+ negated = accept_keyword('NOT') ? true : false
67
+ expect_keyword('NULL')
68
+ return { type: :is_null, column: column, negated: negated }
69
+ end
70
+
71
+ if accept_keyword('IN')
72
+ expect(:lparen)
73
+ values = [expect_value]
74
+ values << expect_value while accept(:comma)
75
+ expect(:rparen)
76
+ return { type: :in, column: column, values: values }
77
+ end
78
+
79
+ op = expect(:op).value
80
+ { type: :cmp, column: column, op: op, value: expect_value }
81
+ end
82
+
83
+ def expect_value
84
+ token = peek
85
+ unless token && %i[number string].include?(token.type)
86
+ raise ParseError, "a value (number or 'string') is required; use IS NULL to compare with NULL"
87
+ end
88
+
89
+ advance.value
90
+ end
91
+
92
+ def peek
93
+ @tokens[@pos]
94
+ end
95
+
96
+ def at_end?
97
+ @pos >= @tokens.length
98
+ end
99
+
100
+ def advance
101
+ token = @tokens[@pos]
102
+ @pos += 1
103
+ token
104
+ end
105
+
106
+ def accept(type)
107
+ return nil unless peek&.type == type
108
+
109
+ advance
110
+ end
111
+
112
+ def accept_keyword(keyword)
113
+ return nil unless peek&.type == :keyword && peek.value == keyword
114
+
115
+ advance
116
+ end
117
+
118
+ def expect(type)
119
+ token = accept(type)
120
+ raise ParseError, "expected #{type} but got #{describe(peek)}" unless token
121
+
122
+ token
123
+ end
124
+
125
+ def expect_keyword(keyword)
126
+ token = accept_keyword(keyword)
127
+ raise ParseError, "expected #{keyword} but got #{describe(peek)}" unless token
128
+
129
+ token
130
+ end
131
+
132
+ def describe(token)
133
+ token ? token.value.inspect : 'end of input'
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColumnSifter
4
+ class Base
5
+ class Result
6
+ attr_accessor :input, :error, :sql_template, :binds
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module ColumnSifter
6
+ class Base
7
+ extend Forwardable
8
+
9
+ class ParseError < StandardError; end
10
+
11
+ def_delegators :@result, :input, :error, :sql_template, :binds
12
+
13
+ def initialize
14
+ @result = Result.new
15
+ end
16
+
17
+ def sift!(input)
18
+ tokens = Lexer.new(input).tokenize
19
+ ast = Parser.new(tokens).parse
20
+ sql_template, *binds = Compiler.new(self.class.searchable_columns, self.class.model).compile(ast)
21
+ @result.input = input
22
+ @result.sql_template = sql_template
23
+ @result.binds = binds
24
+ rescue ParseError => e
25
+ @result.input = input
26
+ @result.error = e.message
27
+ end
28
+
29
+ class << self
30
+ def model(model = nil)
31
+ @model = model if model
32
+ @model ||= Object.const_get(name.split('::').last.delete_suffix('Sifter'))
33
+ end
34
+
35
+ def searchable_columns(*columns)
36
+ @searchable_columns = columns.map(&:to_s).freeze if columns.any?
37
+ @searchable_columns || raise(NotImplementedError, "#{self}.searchable_columns must be declared")
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ColumnSifter
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'column_sifter/version'
4
+ require_relative 'column_sifter/base'
5
+ require_relative 'column_sifter/base/result'
6
+ require_relative 'column_sifter/base/lexer'
7
+ require_relative 'column_sifter/base/parser'
8
+ require_relative 'column_sifter/base/compiler'
metadata ADDED
@@ -0,0 +1,68 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: column_sifter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - kyuuri1791
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ description: Turns a SQL WHERE-like expression string into a SQL-injection-safe ActiveRecord
27
+ where condition. Intended for admin screens that need flexible filtering over a
28
+ whitelisted set of columns.
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - LICENSE.txt
34
+ - README.md
35
+ - lib/column_sifter.rb
36
+ - lib/column_sifter/base.rb
37
+ - lib/column_sifter/base/compiler.rb
38
+ - lib/column_sifter/base/lexer.rb
39
+ - lib/column_sifter/base/parser.rb
40
+ - lib/column_sifter/base/result.rb
41
+ - lib/column_sifter/version.rb
42
+ homepage: https://github.com/kyuuri1791/column_sifter
43
+ licenses:
44
+ - MIT
45
+ metadata:
46
+ homepage_uri: https://github.com/kyuuri1791/column_sifter
47
+ source_code_uri: https://github.com/kyuuri1791/column_sifter
48
+ bug_tracker_uri: https://github.com/kyuuri1791/column_sifter/issues
49
+ changelog_uri: https://github.com/kyuuri1791/column_sifter/blob/main/CHANGELOG.md
50
+ rubygems_mfa_required: 'true'
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '3.1'
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 3.6.9
66
+ specification_version: 4
67
+ summary: SQL-like, injection-safe column filtering for ActiveRecord.
68
+ test_files: []