airview 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/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +65 -0
- data/app/assets/javascripts/airview/application.js +425 -0
- data/app/assets/stylesheets/airview/application.css +1239 -0
- data/app/controllers/airview/application_controller.rb +7 -0
- data/app/controllers/airview/resources_controller.rb +261 -0
- data/app/controllers/airview/setup_controller.rb +90 -0
- data/app/controllers/airview/views_controller.rb +76 -0
- data/app/helpers/airview/application_helper.rb +479 -0
- data/app/javascript/airview/controllers/grid_controller.js +7 -0
- data/app/models/airview/application_record.rb +7 -0
- data/app/models/airview/field_definition.rb +22 -0
- data/app/models/airview/resource_definition.rb +48 -0
- data/app/models/airview/view.rb +27 -0
- data/app/views/airview/resources/_delete_modal.html.erb +49 -0
- data/app/views/airview/resources/_record_modal.html.erb +62 -0
- data/app/views/airview/resources/empty.html.erb +5 -0
- data/app/views/airview/resources/show.html.erb +258 -0
- data/app/views/airview/setup/_form.html.erb +73 -0
- data/app/views/airview/setup/edit.html.erb +16 -0
- data/app/views/airview/setup/index.html.erb +52 -0
- data/app/views/airview/setup/new.html.erb +14 -0
- data/app/views/airview/shared/_sidebar.html.erb +76 -0
- data/app/views/layouts/airview/application.html.erb +21 -0
- data/config/routes.rb +23 -0
- data/db/migrate/20260806000000_create_airview_views.rb +57 -0
- data/lib/airview/configuration.rb +6 -0
- data/lib/airview/engine.rb +15 -0
- data/lib/airview/field.rb +81 -0
- data/lib/airview/model_discovery.rb +38 -0
- data/lib/airview/query.rb +194 -0
- data/lib/airview/resource.rb +51 -0
- data/lib/airview/resource_builder.rb +62 -0
- data/lib/airview/schema_inference.rb +196 -0
- data/lib/airview/version.rb +5 -0
- data/lib/airview.rb +93 -0
- data/lib/generators/airview/install/install_generator.rb +38 -0
- data/lib/generators/airview/install/templates/add_folders_to_airview_views.rb +8 -0
- data/lib/generators/airview/install/templates/airview.rb +12 -0
- data/lib/generators/airview/install/templates/create_airview_views.rb +57 -0
- data/sig/airview.rbs +54 -0
- metadata +110 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateAirviewViews < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :airview_resources do |t|
|
|
6
|
+
t.string :key, null: false
|
|
7
|
+
t.string :record_class_name, null: false
|
|
8
|
+
t.string :label, null: false
|
|
9
|
+
t.string :label_method
|
|
10
|
+
t.boolean :enabled, null: false, default: false
|
|
11
|
+
t.integer :position, null: false, default: 0
|
|
12
|
+
t.json :metadata, null: false, default: {}
|
|
13
|
+
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
add_index :airview_resources, :key, unique: true
|
|
18
|
+
add_index :airview_resources, :record_class_name, unique: true
|
|
19
|
+
add_index :airview_resources, :enabled
|
|
20
|
+
|
|
21
|
+
create_table :airview_fields do |t|
|
|
22
|
+
t.references :airview_resource, null: false, foreign_key: { to_table: :airview_resources }
|
|
23
|
+
t.string :name, null: false
|
|
24
|
+
t.string :label, null: false
|
|
25
|
+
t.string :field_type, null: false
|
|
26
|
+
t.boolean :visible, null: false, default: true
|
|
27
|
+
t.boolean :read_only, null: false, default: false
|
|
28
|
+
t.integer :position, null: false, default: 0
|
|
29
|
+
t.string :association_name
|
|
30
|
+
t.string :target_model_name
|
|
31
|
+
t.string :target_label_method
|
|
32
|
+
t.json :metadata, null: false, default: {}
|
|
33
|
+
|
|
34
|
+
t.timestamps
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
add_index :airview_fields, %i[airview_resource_id name], unique: true
|
|
38
|
+
add_index :airview_fields, :visible
|
|
39
|
+
|
|
40
|
+
create_table :airview_views do |t|
|
|
41
|
+
t.string :name, null: false
|
|
42
|
+
t.string :resource_key, null: false
|
|
43
|
+
t.string :owner_key
|
|
44
|
+
t.string :folder
|
|
45
|
+
t.json :columns, null: false, default: []
|
|
46
|
+
t.json :filters, null: false, default: {}
|
|
47
|
+
t.json :sorts, null: false, default: {}
|
|
48
|
+
t.json :preferences, null: false, default: {}
|
|
49
|
+
|
|
50
|
+
t.timestamps
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
add_index :airview_views, :resource_key
|
|
54
|
+
add_index :airview_views, %i[resource_key folder]
|
|
55
|
+
add_index :airview_views, %i[resource_key name]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
|
|
5
|
+
module Airview
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace Airview
|
|
8
|
+
|
|
9
|
+
initializer "airview.assets" do |app|
|
|
10
|
+
if app.config.respond_to?(:assets)
|
|
11
|
+
app.config.assets.precompile += %w[airview/application.css airview/application.js]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airview
|
|
4
|
+
class Field
|
|
5
|
+
TYPES = %i[
|
|
6
|
+
string text integer float decimal boolean date datetime select belongs_to has_many attachment json
|
|
7
|
+
].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :name, :label, :type, :options, :model, :label_method
|
|
10
|
+
|
|
11
|
+
def initialize(name, type:, label: nil, readonly: false, options: nil, model: nil, label_method: nil,
|
|
12
|
+
default_visible: true)
|
|
13
|
+
@name = name.to_sym
|
|
14
|
+
@label = label.presence || name.to_s.humanize
|
|
15
|
+
@type = type.to_sym
|
|
16
|
+
@readonly = readonly
|
|
17
|
+
@options = options
|
|
18
|
+
@model = model
|
|
19
|
+
@label_method = label_method || :to_s
|
|
20
|
+
@default_visible = default_visible
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def readonly?
|
|
24
|
+
@readonly
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def editable?
|
|
28
|
+
!readonly? && !display_only?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def default_visible?
|
|
32
|
+
@default_visible
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def association?
|
|
36
|
+
type.in?(%i[belongs_to has_many])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def collection_association?
|
|
40
|
+
type == :has_many
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def attachment?
|
|
44
|
+
type == :attachment
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def display_only?
|
|
48
|
+
collection_association? || attachment?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def filterable?
|
|
52
|
+
!display_only?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def sortable?
|
|
56
|
+
!display_only?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def attribute_name
|
|
60
|
+
type == :belongs_to ? :"#{name}_id" : name
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate!
|
|
64
|
+
return if TYPES.include?(type)
|
|
65
|
+
|
|
66
|
+
raise ConfigurationError, "Unsupported Airview field type #{type.inspect} for #{name}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def model_class
|
|
70
|
+
return nil unless model
|
|
71
|
+
|
|
72
|
+
model.to_s.constantize
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def option_values
|
|
76
|
+
return [] unless options
|
|
77
|
+
|
|
78
|
+
options.respond_to?(:call) ? options.call : options
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airview
|
|
4
|
+
class ModelDiscovery
|
|
5
|
+
EXCLUDED_NAMESPACES = %w[Airview ActiveStorage ActionMailbox ActionText].freeze
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def models
|
|
9
|
+
eager_load_application
|
|
10
|
+
|
|
11
|
+
ActiveRecord::Base.descendants
|
|
12
|
+
.select { |model| eligible?(model) }
|
|
13
|
+
.sort_by(&:name)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def model_named(name)
|
|
17
|
+
models.find { |model| model.name == name.to_s }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def eager_load_application
|
|
23
|
+
Rails.application.eager_load! if defined?(Rails) && Rails.application
|
|
24
|
+
rescue Zeitwerk::NameError
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def eligible?(model)
|
|
29
|
+
model.name.present? &&
|
|
30
|
+
EXCLUDED_NAMESPACES.none? { |namespace| model.name.start_with?("#{namespace}::") } &&
|
|
31
|
+
!model.abstract_class? &&
|
|
32
|
+
model.table_exists?
|
|
33
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airview
|
|
4
|
+
class Query
|
|
5
|
+
DEFAULT_LIMIT = 50
|
|
6
|
+
MAX_LIMIT = 200
|
|
7
|
+
|
|
8
|
+
attr_reader :resource, :params
|
|
9
|
+
|
|
10
|
+
def initialize(resource, params = {})
|
|
11
|
+
@resource = resource
|
|
12
|
+
@params = params || {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def records
|
|
16
|
+
scope = resource.model_class.all
|
|
17
|
+
scope = apply_search(scope)
|
|
18
|
+
scope = apply_filters(scope)
|
|
19
|
+
scope = apply_sort(scope)
|
|
20
|
+
scope.limit(limit).offset(offset)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def count
|
|
24
|
+
apply_filters(apply_search(resource.model_class.all)).count
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def limit
|
|
28
|
+
value = params[:limit].presence || DEFAULT_LIMIT
|
|
29
|
+
[[value.to_i, 1].max, MAX_LIMIT].min
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def page
|
|
33
|
+
[params[:page].to_i, 1].max
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def offset
|
|
37
|
+
(page - 1) * limit
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def apply_search(scope)
|
|
43
|
+
term = params[:q].to_s.strip
|
|
44
|
+
return scope if term.empty?
|
|
45
|
+
|
|
46
|
+
searchable = resource.fields.values.select { |field| %i[string text select].include?(field.type) }
|
|
47
|
+
return scope if searchable.empty?
|
|
48
|
+
|
|
49
|
+
clauses = searchable.map do |field|
|
|
50
|
+
scope.klass.arel_table[field.attribute_name].matches("%#{sanitize_like(term)}%")
|
|
51
|
+
end
|
|
52
|
+
scope.where(clauses.reduce { |query, clause| query.or(clause) })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def apply_filters(scope)
|
|
56
|
+
filters = params[:filters].respond_to?(:to_unsafe_h) ? params[:filters].to_unsafe_h : params[:filters]
|
|
57
|
+
return scope if filters.blank?
|
|
58
|
+
|
|
59
|
+
return apply_structured_filters(scope, filters) if structured_filters?(filters)
|
|
60
|
+
|
|
61
|
+
filters.each do |name, value|
|
|
62
|
+
next if value.blank?
|
|
63
|
+
|
|
64
|
+
field = resource.fields[name.to_sym]
|
|
65
|
+
next unless field
|
|
66
|
+
|
|
67
|
+
scope = apply_filter(scope, field, value)
|
|
68
|
+
end
|
|
69
|
+
scope
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def structured_filters?(filters)
|
|
73
|
+
filters.is_a?(Array) || filters.values.first.respond_to?(:key?)
|
|
74
|
+
rescue NoMethodError
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def apply_structured_filters(scope, filters)
|
|
79
|
+
Array(filters.is_a?(Hash) ? filters.values : filters).each do |condition|
|
|
80
|
+
condition = condition.to_unsafe_h if condition.respond_to?(:to_unsafe_h)
|
|
81
|
+
condition = condition.to_h.stringify_keys
|
|
82
|
+
field = resource.fields[condition["field"].to_s.to_sym]
|
|
83
|
+
next unless field
|
|
84
|
+
next unless field.filterable?
|
|
85
|
+
|
|
86
|
+
scope = apply_structured_filter(scope, field, condition["operator"], condition["value"])
|
|
87
|
+
end
|
|
88
|
+
scope
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def apply_filter(scope, field, value)
|
|
92
|
+
case field.type
|
|
93
|
+
when :string, :text, :select
|
|
94
|
+
scope.where(scope.klass.arel_table[field.attribute_name].matches("%#{sanitize_like(value)}%"))
|
|
95
|
+
when :boolean
|
|
96
|
+
scope.where(field.attribute_name => ActiveModel::Type::Boolean.new.cast(value))
|
|
97
|
+
when :integer
|
|
98
|
+
scope.where(field.attribute_name => value.to_i)
|
|
99
|
+
else
|
|
100
|
+
scope.where(field.attribute_name => value)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def apply_structured_filter(scope, field, operator, value)
|
|
105
|
+
return apply_empty_filter(scope, field, operator) if operator.to_s.in?(%w[is_empty is_not_empty])
|
|
106
|
+
return scope if value.blank? && field.type != :boolean
|
|
107
|
+
|
|
108
|
+
case field.type
|
|
109
|
+
when :string, :text, :select, :json
|
|
110
|
+
apply_text_filter(scope, field, operator, value)
|
|
111
|
+
when :boolean
|
|
112
|
+
apply_boolean_filter(scope, field, operator, value)
|
|
113
|
+
when :integer, :float, :decimal
|
|
114
|
+
apply_numeric_filter(scope, field, operator, value)
|
|
115
|
+
when :date, :datetime
|
|
116
|
+
apply_date_filter(scope, field, operator, value)
|
|
117
|
+
else
|
|
118
|
+
scope.where(field.attribute_name => value)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def apply_empty_filter(scope, field, operator)
|
|
123
|
+
empty_values = %i[string text select json].include?(field.type) ? [nil, ""] : [nil]
|
|
124
|
+
|
|
125
|
+
if operator == "is_not_empty"
|
|
126
|
+
scope.where.not(field.attribute_name => empty_values)
|
|
127
|
+
else
|
|
128
|
+
scope.where(field.attribute_name => empty_values)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def apply_text_filter(scope, field, operator, value)
|
|
133
|
+
column = scope.klass.arel_table[field.attribute_name]
|
|
134
|
+
|
|
135
|
+
case operator
|
|
136
|
+
when "equals"
|
|
137
|
+
scope.where(field.attribute_name => value)
|
|
138
|
+
when "starts_with"
|
|
139
|
+
scope.where(column.matches("#{sanitize_like(value)}%"))
|
|
140
|
+
else
|
|
141
|
+
scope.where(column.matches("%#{sanitize_like(value)}%"))
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def apply_boolean_filter(scope, field, operator, value)
|
|
146
|
+
boolean = case operator
|
|
147
|
+
when "is_true"
|
|
148
|
+
true
|
|
149
|
+
when "is_false"
|
|
150
|
+
false
|
|
151
|
+
else
|
|
152
|
+
ActiveModel::Type::Boolean.new.cast(value)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
scope.where(field.attribute_name => boolean)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def apply_numeric_filter(scope, field, operator, value)
|
|
159
|
+
operators = { "gt" => ">", "lt" => "<", "gte" => ">=", "lte" => "<=" }
|
|
160
|
+
sql_operator = operators[operator.to_s]
|
|
161
|
+
return scope.where(field.attribute_name => value) unless sql_operator
|
|
162
|
+
|
|
163
|
+
column = scope.connection.quote_column_name(field.attribute_name)
|
|
164
|
+
scope.where("#{column} #{sql_operator} ?", value)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def apply_date_filter(scope, field, operator, value)
|
|
168
|
+
case operator
|
|
169
|
+
when "before"
|
|
170
|
+
scope.where(scope.klass.arel_table[field.attribute_name].lt(value))
|
|
171
|
+
when "after"
|
|
172
|
+
scope.where(scope.klass.arel_table[field.attribute_name].gt(value))
|
|
173
|
+
else
|
|
174
|
+
scope.where(field.attribute_name => value)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def apply_sort(scope)
|
|
179
|
+
sort = params[:sort].to_s
|
|
180
|
+
return scope if sort.empty?
|
|
181
|
+
|
|
182
|
+
direction = params[:direction].to_s.downcase == "desc" ? :desc : :asc
|
|
183
|
+
field = resource.fields[sort.to_sym]
|
|
184
|
+
return scope unless field
|
|
185
|
+
return scope unless field.sortable?
|
|
186
|
+
|
|
187
|
+
scope.order(field.attribute_name => direction)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def sanitize_like(value)
|
|
191
|
+
ActiveRecord::Base.sanitize_sql_like(value.to_s)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airview
|
|
4
|
+
class Resource
|
|
5
|
+
attr_accessor :label, :label_method
|
|
6
|
+
attr_reader :key, :model, :fields
|
|
7
|
+
|
|
8
|
+
def initialize(key, model:)
|
|
9
|
+
@key = key.to_sym
|
|
10
|
+
@model = model
|
|
11
|
+
@label = key.to_s.humanize
|
|
12
|
+
@label_method = :to_s
|
|
13
|
+
@fields = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def field(name, type:, label: nil, **options)
|
|
17
|
+
definition = Field.new(name, type:, label:, **options)
|
|
18
|
+
definition.validate!
|
|
19
|
+
fields[definition.name] = definition
|
|
20
|
+
definition
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def model_class
|
|
24
|
+
model.to_s.constantize
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def field!(name)
|
|
28
|
+
fields.fetch(name.to_sym) do
|
|
29
|
+
raise ConfigurationError, "Unknown Airview field #{name.inspect} for #{key}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def editable_fields
|
|
34
|
+
fields.values.select(&:editable?)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def default_fields
|
|
38
|
+
fields.values.select(&:default_visible?)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate!
|
|
42
|
+
raise ConfigurationError, "Airview resource #{key} must define at least one field" if fields.empty?
|
|
43
|
+
|
|
44
|
+
fields.each_value(&:validate!)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def record_label(record)
|
|
48
|
+
Airview.record_label(record, label_method)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airview
|
|
4
|
+
class ResourceBuilder
|
|
5
|
+
class << self
|
|
6
|
+
def enabled_resources
|
|
7
|
+
definitions = Airview::ResourceDefinition.enabled.includes(:field_definitions).order(:position, :label)
|
|
8
|
+
|
|
9
|
+
definitions.each_with_object({}) do |definition, resources|
|
|
10
|
+
resource = build(definition)
|
|
11
|
+
resources[resource.key] = resource
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build(definition)
|
|
16
|
+
Resource.new(definition.key, model: definition.record_class_name).tap do |resource|
|
|
17
|
+
resource.label = definition.label
|
|
18
|
+
resource.label_method = definition.label_method.presence&.to_sym || :to_s
|
|
19
|
+
|
|
20
|
+
definition.field_definitions.visible.order(:position, :name).each do |field|
|
|
21
|
+
add_field(resource, field)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
add_missing_virtual_fields(resource, definition)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def add_field(resource, field)
|
|
31
|
+
resource.field(
|
|
32
|
+
field.name,
|
|
33
|
+
type: field.field_type,
|
|
34
|
+
label: field.label,
|
|
35
|
+
readonly: field.read_only?,
|
|
36
|
+
model: field.target_model_name,
|
|
37
|
+
label_method: field.target_label_method.presence&.to_sym,
|
|
38
|
+
default_visible: field.default_visible?
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def add_missing_virtual_fields(resource, definition)
|
|
43
|
+
defined_names = definition.field_definitions.map { |field| field.name.to_sym }
|
|
44
|
+
|
|
45
|
+
SchemaInference.new(resource.model_class).field_attributes.each do |attributes|
|
|
46
|
+
next unless attributes[:field_type].to_sym.in?(%i[has_many attachment])
|
|
47
|
+
next if defined_names.include?(attributes[:name].to_sym)
|
|
48
|
+
|
|
49
|
+
resource.field(
|
|
50
|
+
attributes[:name],
|
|
51
|
+
type: attributes[:field_type],
|
|
52
|
+
label: attributes[:label],
|
|
53
|
+
readonly: attributes[:read_only],
|
|
54
|
+
model: attributes[:target_model_name],
|
|
55
|
+
label_method: attributes[:target_label_method],
|
|
56
|
+
default_visible: attributes.dig(:metadata, :default_visible)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airview
|
|
4
|
+
class SchemaInference
|
|
5
|
+
SENSITIVE_PATTERNS = [
|
|
6
|
+
/password/i,
|
|
7
|
+
/token/i,
|
|
8
|
+
/secret/i,
|
|
9
|
+
/api[_-]?key/i,
|
|
10
|
+
/credential/i,
|
|
11
|
+
/encrypted/i,
|
|
12
|
+
/digest/i,
|
|
13
|
+
/reset/i,
|
|
14
|
+
/remember/i
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
TYPE_MAP = {
|
|
18
|
+
string: :string,
|
|
19
|
+
text: :text,
|
|
20
|
+
integer: :integer,
|
|
21
|
+
float: :float,
|
|
22
|
+
decimal: :decimal,
|
|
23
|
+
boolean: :boolean,
|
|
24
|
+
date: :date,
|
|
25
|
+
datetime: :datetime,
|
|
26
|
+
timestamp: :datetime,
|
|
27
|
+
json: :json,
|
|
28
|
+
jsonb: :json
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :model
|
|
32
|
+
|
|
33
|
+
def initialize(model)
|
|
34
|
+
@model = model
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def resource_attributes
|
|
38
|
+
{
|
|
39
|
+
key: model.model_name.route_key,
|
|
40
|
+
record_class_name: model.name,
|
|
41
|
+
label: model.model_name.human(count: 2),
|
|
42
|
+
label_method: inferred_label_method,
|
|
43
|
+
enabled: false,
|
|
44
|
+
metadata: {
|
|
45
|
+
table_name: model.table_name,
|
|
46
|
+
inferred_at: Time.current.iso8601
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def field_attributes
|
|
52
|
+
column_fields = model.columns.each_with_index.filter_map do |column, index|
|
|
53
|
+
next if sensitive?(column.name)
|
|
54
|
+
|
|
55
|
+
field_attributes_for(column, index)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
column_fields + virtual_field_attributes(column_fields.length)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def virtual_field_attributes(starting_position)
|
|
64
|
+
collection_fields = collection_field_attributes(starting_position)
|
|
65
|
+
attachment_fields = attachment_field_attributes(starting_position + collection_fields.length)
|
|
66
|
+
|
|
67
|
+
collection_fields + attachment_fields
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def collection_field_attributes(starting_position)
|
|
71
|
+
model.reflect_on_all_associations(:has_many).each_with_index.filter_map do |association, index|
|
|
72
|
+
target_class = association_class(association)
|
|
73
|
+
next unless target_class
|
|
74
|
+
|
|
75
|
+
{
|
|
76
|
+
name: association.name,
|
|
77
|
+
label: association.name.to_s.humanize,
|
|
78
|
+
field_type: :has_many,
|
|
79
|
+
visible: true,
|
|
80
|
+
read_only: true,
|
|
81
|
+
position: starting_position + index,
|
|
82
|
+
association_name: association.name,
|
|
83
|
+
target_model_name: target_class.name,
|
|
84
|
+
target_label_method: label_method_for(target_class),
|
|
85
|
+
metadata: {
|
|
86
|
+
default_visible: false,
|
|
87
|
+
macro: association.macro.to_s
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
rescue NameError
|
|
92
|
+
[]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def attachment_field_attributes(starting_position)
|
|
96
|
+
return [] unless model.respond_to?(:attachment_reflections)
|
|
97
|
+
|
|
98
|
+
model.attachment_reflections.values.each_with_index.map do |reflection, index|
|
|
99
|
+
{
|
|
100
|
+
name: reflection.name,
|
|
101
|
+
label: reflection.name.to_s.humanize,
|
|
102
|
+
field_type: :attachment,
|
|
103
|
+
visible: true,
|
|
104
|
+
read_only: true,
|
|
105
|
+
position: starting_position + index,
|
|
106
|
+
association_name: reflection.name,
|
|
107
|
+
target_model_name: nil,
|
|
108
|
+
target_label_method: nil,
|
|
109
|
+
metadata: {
|
|
110
|
+
default_visible: false,
|
|
111
|
+
macro: reflection.macro.to_s
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def field_attributes_for(column, index)
|
|
118
|
+
association = belongs_to_association_for(column.name)
|
|
119
|
+
target_class = association_class(association)
|
|
120
|
+
name = association&.name || column.name
|
|
121
|
+
association_supported = association && target_class
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
name:,
|
|
125
|
+
label: name.to_s.humanize,
|
|
126
|
+
field_type: inferred_field_type(column, association_supported),
|
|
127
|
+
visible: true,
|
|
128
|
+
read_only: inferred_readonly?(column, association),
|
|
129
|
+
position: index,
|
|
130
|
+
association_name: association_supported ? association.name : nil,
|
|
131
|
+
target_model_name: target_class&.name,
|
|
132
|
+
target_label_method: target_class ? label_method_for(target_class) : nil,
|
|
133
|
+
metadata: column_metadata(column)
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def belongs_to_association_for(column_name)
|
|
138
|
+
return nil unless column_name.end_with?("_id")
|
|
139
|
+
|
|
140
|
+
model.reflect_on_all_associations(:belongs_to).find do |association|
|
|
141
|
+
association.foreign_key.to_s == column_name
|
|
142
|
+
end
|
|
143
|
+
rescue NameError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def association_class(association)
|
|
148
|
+
return nil if association&.polymorphic?
|
|
149
|
+
|
|
150
|
+
association&.klass
|
|
151
|
+
rescue ArgumentError, NameError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def unsupported_association?(association)
|
|
156
|
+
association && !association_class(association)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def field_type(column)
|
|
160
|
+
TYPE_MAP.fetch(column.type, :string)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def inferred_field_type(column, association_supported)
|
|
164
|
+
association_supported ? :belongs_to : field_type(column)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def inferred_readonly?(column, association)
|
|
168
|
+
readonly?(column) || unsupported_association?(association)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def readonly?(column)
|
|
172
|
+
column.name == model.primary_key || column.name.in?(%w[created_at updated_at])
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def sensitive?(name)
|
|
176
|
+
SENSITIVE_PATTERNS.any? { |pattern| name.match?(pattern) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def inferred_label_method
|
|
180
|
+
label_method_for(model)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def label_method_for(klass)
|
|
184
|
+
%i[name title email username slug].find { |method| klass.column_names.include?(method.to_s) } || :to_s
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def column_metadata(column)
|
|
188
|
+
{
|
|
189
|
+
sql_type: column.sql_type,
|
|
190
|
+
null: column.null,
|
|
191
|
+
default: column.default,
|
|
192
|
+
primary_key: column.name == model.primary_key
|
|
193
|
+
}
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|