huginn_datatable 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.
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Huginn
4
+ module Datatable
5
+ # Resolves and validates column references used by datatable filters,
6
+ # orders and ranges.
7
+ #
8
+ # A field is either a plain column ("status"), an association-scoped
9
+ # column ("pessoa.nome", "company.people.age") or — when the model
10
+ # declares `huginn_attributes` — a *public* API alias that maps to the
11
+ # real column.
12
+ #
13
+ # Fields are never interpolated into SQL strings: scoped fields are
14
+ # resolved through `AssociationPath` (direction aware FK/PK linking) and
15
+ # authorization is enforced against the `allowed_paths` allowlist. When
16
+ # the model configures `huginn_attributes(strict: true)` only the
17
+ # declared aliases are accepted, so the schema stays hidden from callers.
18
+ class Validator
19
+ FIELD_PATTERN = /\A[a-z_][a-z0-9_]*\z/
20
+
21
+ def self.call(model, field)
22
+ new(model, field)
23
+ end
24
+
25
+ def initialize(model, field)
26
+ @model = model
27
+ @strict_denied = false
28
+ @field = resolve_alias(field)
29
+ @field = @field.split(".").drop(1).join(".") if own_table_prefix?(@field)
30
+ end
31
+
32
+ def segments
33
+ @segments ||= @field.split(".")
34
+ end
35
+
36
+ def plain?
37
+ segments.size == 1
38
+ end
39
+
40
+ def scoped?
41
+ !plain? && segments.size >= 2
42
+ end
43
+
44
+ def valid?
45
+ return false if strict_denied?
46
+ return false unless segments.all? { |segment| segment.match?(FIELD_PATTERN) }
47
+
48
+ if plain?
49
+ model.columns_hash.key?(column)
50
+ else
51
+ association_path&.valid? && association_path.target_klass.columns_hash.key?(column)
52
+ end
53
+ end
54
+
55
+ # Canonical chain of association reflection names (e.g. ["company",
56
+ # "people"]) used to match the allowlist. Table names and public aliases
57
+ # are normalized through the reflection.
58
+ def authorization_path
59
+ return [] if plain?
60
+
61
+ association_path&.association_names || []
62
+ end
63
+
64
+ def arel_attribute
65
+ return nil unless valid?
66
+
67
+ plain? ? model.arel_table[column] : association_path.target_table[column]
68
+ end
69
+
70
+ def column_type
71
+ return nil unless valid?
72
+
73
+ klass = plain? ? model : association_path.target_klass
74
+ klass.columns_hash[column].type
75
+ end
76
+
77
+ # The AssociationPath for scoped fields (builds filter/order
78
+ # subqueries), or nil for plain columns.
79
+ def association_path
80
+ @association_path ||= (AssociationPath.call(model, @field) if scoped?)
81
+ end
82
+
83
+ # First association name of the chain (backwards compatible alias used
84
+ # by specs), or nil for plain columns.
85
+ def association_name
86
+ return nil if plain?
87
+
88
+ association_path&.association_names&.first&.to_sym
89
+ end
90
+
91
+ def correlated_order_expression
92
+ association_path&.correlated_order_expression
93
+ end
94
+
95
+ def allowlist_key
96
+ return nil if plain?
97
+
98
+ joined = authorization_path.join(".")
99
+ joined.empty? ? nil : joined
100
+ end
101
+
102
+ private
103
+
104
+ attr_reader :model
105
+
106
+ # "people.name" addressed via the model's own table_name is a plain
107
+ # column of the base relation (model.table_name is, actually, "people"
108
+ # for Person).
109
+ def own_table_prefix?(field)
110
+ segments = field.split(".")
111
+ segments.size >= 2 && segments.first == model.table_name
112
+ end
113
+
114
+ def strict_denied?
115
+ @strict_denied
116
+ end
117
+
118
+ def column
119
+ segments.last
120
+ end
121
+
122
+ def resolve_alias(field)
123
+ mapping = model.respond_to?(:huginn_attribute_mapping) ? model.huginn_attribute_mapping : nil
124
+ return field.to_s if mapping.blank?
125
+
126
+ key = field.to_s
127
+ return mapping[key] if mapping.key?(key)
128
+
129
+ @strict_denied = model.huginn_strict_mapping
130
+ key
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Huginn
4
+ module Datatable
5
+ end
6
+ end
7
+
8
+ require_relative "datatable/association_path"
9
+ require_relative "datatable/allowed_paths"
10
+ require_relative "datatable/validator"
11
+ require_relative "datatable/filter_normalizer"
12
+ require_relative "datatable/paginator"
13
+ require_relative "datatable/datatable"
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Huginn
4
+ class Railtie < Rails::Railtie
5
+ initializer "huginn.configure" do |app|
6
+ ActiveSupport.on_load(:active_record) do
7
+ include Huginn::Datatable if Huginn.configuration.auto_include_datatable?
8
+ include Huginn::Searchable if Huginn.configuration.auto_include_searchable?
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Huginn
4
+ module Searchable
5
+ # Builds an Arel predicate for a single searchable column.
6
+ #
7
+ # Strategy chain (driven by Huginn.configuration.search_strategy):
8
+ #
9
+ # :pg_trgm -> trigram similarity OR unaccent+ILIKE (best for typos)
10
+ # :unaccent -> unaccent + ILIKE (case/accents insensitive)
11
+ # :simple -> plain LIKE
12
+ module Fuzzy
13
+ # Gentle helpers to wrap a node in UNACCENT(...).
14
+ module Unaccentable
15
+ private
16
+
17
+ def unaccent(node)
18
+ Arel::Nodes::NamedFunction.new("UNACCENT", [node])
19
+ end
20
+ end
21
+
22
+ class Simple
23
+ def initialize(column, value)
24
+ @column = column
25
+ @value = value
26
+ end
27
+
28
+ def predicate
29
+ @column.matches("%#{escape_like(@value)}%")
30
+ end
31
+
32
+ private
33
+
34
+ def escape_like(value)
35
+ value.to_s.gsub(/[\\%_]/) { |char| "\\#{char}" }
36
+ end
37
+ end
38
+
39
+ class Unaccent
40
+ include Unaccentable
41
+
42
+ def initialize(column, value)
43
+ @column = column
44
+ @value = value
45
+ end
46
+
47
+ def predicate
48
+ Arel::Nodes::InfixOperation.new(
49
+ "ILIKE",
50
+ unaccent(@column),
51
+ unaccent(Arel::Nodes.build_quoted("%#{escape_like(@value)}%", @column))
52
+ )
53
+ end
54
+
55
+ private
56
+
57
+ def escape_like(value)
58
+ value.to_s.gsub(/[\\%_]/) { |char| "\\#{char}" }
59
+ end
60
+ end
61
+
62
+ class Trigram
63
+ include Unaccentable
64
+
65
+ def initialize(column, value, threshold)
66
+ @column = column
67
+ @value = value
68
+ @threshold = threshold
69
+ end
70
+
71
+ def predicate
72
+ similarity = Arel::Nodes::NamedFunction.new("similarity", [
73
+ unaccent(@column),
74
+ unaccent(Arel::Nodes.build_quoted(@value, @column))
75
+ ])
76
+ threshold = Arel::Nodes.build_quoted(@threshold)
77
+ Arel::Nodes::InfixOperation.new(">", similarity, threshold).or(fallback)
78
+ end
79
+
80
+ private
81
+
82
+ def fallback
83
+ Unaccent.new(@column, @value).predicate
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Huginn
4
+ module Searchable
5
+ # Builds a tolerant full-text search relation over a model's searchable
6
+ # columns, optionally crossing associations through left_joins.
7
+ #
8
+ # All matched columns are fused into a single Arel OR predicate, so the
9
+ # whole search stays one query regardless of how many columns (or
10
+ # associations) are involved.
11
+ class Query
12
+ def self.call(model, value, options = {})
13
+ new(model, value, options).call
14
+ end
15
+
16
+ def initialize(model, value, options = {})
17
+ @model = model
18
+ @value = value.to_s
19
+ @options = options
20
+ @distinct = options.fetch(:distinct, true)
21
+ end
22
+
23
+ def call
24
+ relation = @model.all
25
+ return apply_distinct(relation) if @value.strip.empty?
26
+
27
+ resolution = resolve_columns
28
+ return apply_distinct(relation) if resolution.fetch(:attrs).empty?
29
+
30
+ joins = resolution.fetch(:joins)
31
+ relation = relation.left_joins(*joins) if joins.any?
32
+
33
+ predicate = resolution.fetch(:attrs).map { |attr| fuzzy_predicate(attr) }.reduce(&:or)
34
+ apply_distinct(relation.where(predicate))
35
+ end
36
+
37
+ private
38
+
39
+ def resolve_columns
40
+ columns = @model.searchable_columns_config_resolved
41
+ joins = []
42
+ attrs = []
43
+
44
+ Array(columns[:columns]).map(&:to_s).reject(&:blank?).each do |field|
45
+ if field.include?(".")
46
+ name, col = field.split(".", 2)
47
+ push_association(joins, attrs, name, col)
48
+ else
49
+ attrs << @model.arel_table[field] if @model.columns_hash.key?(field)
50
+ end
51
+ end
52
+
53
+ columns[:associations].each do |name, cols|
54
+ Array(cols).each { |col| push_association(joins, attrs, name.to_s, col.to_s) }
55
+ end
56
+
57
+ { joins: joins.uniq, attrs: attrs }
58
+ end
59
+
60
+ def push_association(joins, attrs, name, col)
61
+ association = @model.reflect_on_association(name.to_sym)
62
+ return unless association
63
+
64
+ table = association.klass.arel_table
65
+ attrs << table[col] if association.klass.columns_hash.key?(col)
66
+ joins << name.to_sym unless joins.include?(name.to_sym)
67
+ end
68
+
69
+ def fuzzy_predicate(attr)
70
+ case strategy
71
+ when :pg_trgm
72
+ if pg_trgm_available?
73
+ Fuzzy::Trigram.new(attr, @value, Huginn.configuration.fuzzy_threshold).predicate
74
+ else
75
+ fallback_fuzzy(attr)
76
+ end
77
+ when :unaccent
78
+ Fuzzy::Unaccent.new(attr, @value).predicate
79
+ else
80
+ Fuzzy::Simple.new(attr, @value).predicate
81
+ end
82
+ end
83
+
84
+ # Even with :pg_trgm configured, degrade gracefully to accent/case
85
+ # insensitive matching when the extension is not installed.
86
+ def fallback_fuzzy(attr)
87
+ if unaccent_available?
88
+ Fuzzy::Unaccent.new(attr, @value).predicate
89
+ else
90
+ Fuzzy::Simple.new(attr, @value).predicate
91
+ end
92
+ end
93
+
94
+ def strategy
95
+ Huginn.configuration.search_strategy
96
+ end
97
+
98
+ def pg_trgm_available?
99
+ return false unless postgresql?
100
+ @pg_trgm_available ||= extension_installed?("pg_trgm")
101
+ end
102
+
103
+ def unaccent_available?
104
+ return false unless postgresql?
105
+ @unaccent_available ||= extension_installed?("unaccent")
106
+ end
107
+
108
+ def extension_installed?(name)
109
+ @model.connection
110
+ .select_all(sanitize_extension_query(name))
111
+ .any?
112
+ end
113
+
114
+ def sanitize_extension_query(name)
115
+ ActiveRecord::Base.sanitize_sql_array(
116
+ ["SELECT 1 AS one FROM pg_extension WHERE extname = ?", name]
117
+ )
118
+ end
119
+
120
+ def postgresql?
121
+ @model.connection.adapter_name.downcase.include?("postgres")
122
+ end
123
+
124
+ def apply_distinct(relation)
125
+ @distinct ? relation.distinct : relation
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Huginn
4
+ module Searchable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :searchable_columns_config, default: nil
9
+ end
10
+
11
+ class_methods do
12
+ # Declares which columns (own or association-scoped) are searched.
13
+ #
14
+ # searchable_columns :name, :document
15
+ # searchable_columns :name, person: [:name, :email]
16
+ # searchable_columns "person.name"
17
+ #
18
+ # When nothing is declared, every :string/:text column of the model is
19
+ # searched automatically. Columns may be association-scoped ("person.name")
20
+ # or declared via keyword arguments; scoped columns are reached through a
21
+ # left_join.
22
+ def searchable_columns(*columns, **associations)
23
+ self.searchable_columns_config = {
24
+ columns: columns.flatten.compact.map(&:to_s),
25
+ associations: associations
26
+ }
27
+ searchable_columns_config
28
+ end
29
+
30
+ # Tolerant full-text search (pg_trgm / unaccent / LIKE chain).
31
+ #
32
+ # @param value [String] the raw search term from the frontend
33
+ # @param options [Hash] :distinct (default true), :columns override
34
+ def search(value, options = {})
35
+ Searchable::Query.call(self, value, options)
36
+ end
37
+
38
+ def searchable_columns_config_resolved
39
+ config = searchable_columns_config || {}
40
+ {
41
+ columns: config[:columns].presence || default_searchable_columns,
42
+ associations: config[:associations] || {}
43
+ }
44
+ end
45
+
46
+ private
47
+
48
+ def default_searchable_columns
49
+ columns_hash.each_with_object([]) do |(name, column), acc|
50
+ acc << name if %i[string text].include?(column.type)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Huginn
4
+ module Searchable
5
+ end
6
+ end
7
+
8
+ require_relative "searchable/fuzzy"
9
+ require_relative "searchable/query"
10
+ require_relative "searchable/searchable"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Huginn
4
+ VERSION = "0.1.0"
5
+ end
data/lib/huginn.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/concern"
5
+ require "active_support/notifications"
6
+ require "active_record"
7
+
8
+ require "logger"
9
+
10
+ module Huginn
11
+ NAMESPACE = "huginn"
12
+
13
+ class << self
14
+ def configuration
15
+ @configuration ||= Configuration.new
16
+ end
17
+
18
+ def configure
19
+ yield(configuration)
20
+ end
21
+
22
+ def instrument(event, payload = {})
23
+ ActiveSupport::Notifications.instrument("#{event}.#{NAMESPACE}", payload) do
24
+ yield if block_given?
25
+ end
26
+ end
27
+
28
+ def logger
29
+ @logger ||= if defined?(Rails) && Rails.respond_to?(:logger)
30
+ Rails.logger
31
+ else
32
+ ::Logger.new($stdout)
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ require_relative "huginn/version"
39
+ require_relative "huginn/configuration"
40
+ require_relative "huginn/datatable"
41
+ require_relative "huginn/searchable"
42
+ require_relative "huginn/railtie" if defined?(Rails::Railtie)
data/logo.png ADDED
Binary file
metadata ADDED
@@ -0,0 +1,182 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: huginn_datatable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kayky Marcelo
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: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: activerecord
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '7.1'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '7.1'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9'
52
+ - !ruby/object:Gem::Dependency
53
+ name: actionpack
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '7.1'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '9'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '7.1'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '9'
72
+ - !ruby/object:Gem::Dependency
73
+ name: pagy
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '6.0'
79
+ type: :runtime
80
+ prerelease: false
81
+ version_requirements: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '6.0'
86
+ - !ruby/object:Gem::Dependency
87
+ name: rspec
88
+ requirement: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - "~>"
91
+ - !ruby/object:Gem::Version
92
+ version: '3.12'
93
+ type: :development
94
+ prerelease: false
95
+ version_requirements: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - "~>"
98
+ - !ruby/object:Gem::Version
99
+ version: '3.12'
100
+ - !ruby/object:Gem::Dependency
101
+ name: pry
102
+ requirement: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ type: :development
108
+ prerelease: false
109
+ version_requirements: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ - !ruby/object:Gem::Dependency
115
+ name: appraisal
116
+ requirement: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ type: :development
122
+ prerelease: false
123
+ version_requirements: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '0'
128
+ description: 'Huginn is the raven of Odin that represents thought and remembrance.
129
+ It offers a lightweight, highly performant query layer for ActiveRecord datatables:
130
+ filtered/ordered eager-loaded pagination with an enxuto count and a PostgreSQL fuzzy-search
131
+ builder (pg_trgm, unaccent, ILIKE).'
132
+ email:
133
+ - kaykymarcelo2411@gmail.com
134
+ executables: []
135
+ extensions: []
136
+ extra_rdoc_files: []
137
+ files:
138
+ - CHANGELOG.md
139
+ - LICENSE.txt
140
+ - README.md
141
+ - README.pt-BR.md
142
+ - lib/huginn.rb
143
+ - lib/huginn/configuration.rb
144
+ - lib/huginn/datatable.rb
145
+ - lib/huginn/datatable/allowed_paths.rb
146
+ - lib/huginn/datatable/association_path.rb
147
+ - lib/huginn/datatable/datatable.rb
148
+ - lib/huginn/datatable/filter_normalizer.rb
149
+ - lib/huginn/datatable/paginator.rb
150
+ - lib/huginn/datatable/validator.rb
151
+ - lib/huginn/railtie.rb
152
+ - lib/huginn/searchable.rb
153
+ - lib/huginn/searchable/fuzzy.rb
154
+ - lib/huginn/searchable/query.rb
155
+ - lib/huginn/searchable/searchable.rb
156
+ - lib/huginn/version.rb
157
+ - logo.png
158
+ homepage: https://github.com/KaykyM2411/huginn
159
+ licenses:
160
+ - MIT
161
+ metadata:
162
+ homepage_uri: https://github.com/KaykyM2411/huginn
163
+ source_code_uri: https://github.com/KaykyM2411/huginn
164
+ changelog_uri: https://github.com/KaykyM2411/huginn/blob/main/CHANGELOG.md
165
+ rdoc_options: []
166
+ require_paths:
167
+ - lib
168
+ required_ruby_version: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - ">="
171
+ - !ruby/object:Gem::Version
172
+ version: '3.0'
173
+ required_rubygems_version: !ruby/object:Gem::Requirement
174
+ requirements:
175
+ - - ">="
176
+ - !ruby/object:Gem::Version
177
+ version: '0'
178
+ requirements: []
179
+ rubygems_version: 4.0.6
180
+ specification_version: 4
181
+ summary: Performant and elegant ActiveRecord datatables/data-grids with tolerant search
182
+ test_files: []