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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +91 -0
- data/lib/column_sifter/base/compiler.rb +81 -0
- data/lib/column_sifter/base/lexer.rb +74 -0
- data/lib/column_sifter/base/parser.rb +137 -0
- data/lib/column_sifter/base/result.rb +9 -0
- data/lib/column_sifter/base.rb +41 -0
- data/lib/column_sifter/version.rb +5 -0
- data/lib/column_sifter.rb +8 -0
- metadata +68 -0
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
|
+

|
|
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,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,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: []
|