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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +65 -0
  6. data/app/assets/javascripts/airview/application.js +425 -0
  7. data/app/assets/stylesheets/airview/application.css +1239 -0
  8. data/app/controllers/airview/application_controller.rb +7 -0
  9. data/app/controllers/airview/resources_controller.rb +261 -0
  10. data/app/controllers/airview/setup_controller.rb +90 -0
  11. data/app/controllers/airview/views_controller.rb +76 -0
  12. data/app/helpers/airview/application_helper.rb +479 -0
  13. data/app/javascript/airview/controllers/grid_controller.js +7 -0
  14. data/app/models/airview/application_record.rb +7 -0
  15. data/app/models/airview/field_definition.rb +22 -0
  16. data/app/models/airview/resource_definition.rb +48 -0
  17. data/app/models/airview/view.rb +27 -0
  18. data/app/views/airview/resources/_delete_modal.html.erb +49 -0
  19. data/app/views/airview/resources/_record_modal.html.erb +62 -0
  20. data/app/views/airview/resources/empty.html.erb +5 -0
  21. data/app/views/airview/resources/show.html.erb +258 -0
  22. data/app/views/airview/setup/_form.html.erb +73 -0
  23. data/app/views/airview/setup/edit.html.erb +16 -0
  24. data/app/views/airview/setup/index.html.erb +52 -0
  25. data/app/views/airview/setup/new.html.erb +14 -0
  26. data/app/views/airview/shared/_sidebar.html.erb +76 -0
  27. data/app/views/layouts/airview/application.html.erb +21 -0
  28. data/config/routes.rb +23 -0
  29. data/db/migrate/20260806000000_create_airview_views.rb +57 -0
  30. data/lib/airview/configuration.rb +6 -0
  31. data/lib/airview/engine.rb +15 -0
  32. data/lib/airview/field.rb +81 -0
  33. data/lib/airview/model_discovery.rb +38 -0
  34. data/lib/airview/query.rb +194 -0
  35. data/lib/airview/resource.rb +51 -0
  36. data/lib/airview/resource_builder.rb +62 -0
  37. data/lib/airview/schema_inference.rb +196 -0
  38. data/lib/airview/version.rb +5 -0
  39. data/lib/airview.rb +93 -0
  40. data/lib/generators/airview/install/install_generator.rb +38 -0
  41. data/lib/generators/airview/install/templates/add_folders_to_airview_views.rb +8 -0
  42. data/lib/generators/airview/install/templates/airview.rb +12 -0
  43. data/lib/generators/airview/install/templates/create_airview_views.rb +57 -0
  44. data/sig/airview.rbs +54 -0
  45. 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airview
4
+ class Configuration
5
+ end
6
+ 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airview
4
+ VERSION = "0.1.0"
5
+ end