app/services/forest_liana/schema_adapter.rb
module ForestLiana
class SchemaAdapter
def initialize(model)
@model = model
end
def perform
add_columns
add_associations
collection.fields.sort_by!.with_index { |k, idx| [k[:field].to_s, idx] }
# NOTICE: Add ActsAsTaggable fields
if @model.try(:taggable?) && @model.respond_to?(:acts_as_taggable) &&
@model.acts_as_taggable.respond_to?(:to_a)
@model.acts_as_taggable.to_a.each do |key, value|
field = collection.fields.find { |x| x[:field] == key.to_s }
if field
field[:type] = 'String'
field[:reference] = nil
field[:inverse_of] = nil
collection.fields.delete_if do |f|
['taggings', 'base_tags', 'tag_taggings'].include?(f[:field])
end
end
end
end
# NOTICE: Add Devise fields
if @model.respond_to?(:devise_modules?)
collection.actions << ForestLiana::Model::Action.new({
id: "#{collection.name}.Change password",
name: "Change password",
fields: [{
field: 'New password',
type: 'String'
}]
})
collection.fields.each do |field|
if field[:field] == 'encrypted_password'
field[:field] = 'password'
end
end
end
# NOTICE: Define an automatic segment for each STI child model.
if is_sti_parent?
if @model.descendants.empty?
FOREST_LOGGER.warn "Looks like your Rails STI parent model named \"#{@model.name}\" " +
"does not have any child model. If you want to deactivate the STI feature, add " +
"\"self.inheritance_column = nil\" in the model."
end
column_type = @model.inheritance_column
@model.descendants.each do |submodel_sti|
type = submodel_sti.sti_name
name = type.pluralize
collection.segments << ForestLiana::Model::Segment.new({
id: name,
name: name,
where: lambda { { column_type => type } }
})
end
end
collection
end
private
def collection
@collection ||= begin
collection = ForestLiana.apimap.find do |object|
object.name.to_s == ForestLiana.name_for(@model)
end
if collection.blank?
collection = ForestLiana::Model::Collection.new({
name: ForestLiana.name_for(@model),
# TODO: Remove once lianas prior to 2.0.0 are not supported anymore.
name_old: ForestLiana.name_old_for(@model),
fields: []
})
ForestLiana.apimap << collection
else
# NOTICE: If the collection has Smart customisation (Fields, Action,
# ...), we force the is_virtual to false to handle the case
# when lib/forest_liana is loaded before the models.
collection.is_virtual = false
end
collection
end
end
def add_columns
@model.columns.each do |column|
unless is_sti_column_of_child_model?(column)
field_schema = get_schema_for_column(column)
collection.fields << field_schema unless field_schema.nil?
end
end
# NOTICE: Add Intercom fields
if ForestLiana.integrations.try(:[], :intercom)
.try(:[], :mapping).try(:include?, @model.name)
model_name = ForestLiana.name_for(@model)
collection.fields << {
field: :intercom_conversations,
type: ['String'],
relationship: 'HasMany',
reference: "#{model_name}_intercom_conversations.id",
column: nil,
is_filterable: false,
integration: 'intercom'
}
collection.fields << {
field: :intercom_attributes,
type: 'String',
relationship: 'HasOne',
reference: "#{model_name}_intercom_attributes.id",
column: nil,
is_filterable: false,
integration: 'intercom'
}
end
# NOTICE: Add Stripe fields
stripe_mapping = ForestLiana.integrations.try(:[], :stripe)
.try(:[], :mapping)
if stripe_mapping
if stripe_mapping
.select { |mapping| mapping.split('.')[0] == @model.name }
.size > 0
model_name = ForestLiana.name_for(@model)
collection.fields << {
field: :stripe_payments,
type: ['String'],
relationship: 'HasMany',
reference: "#{model_name}_stripe_payments.id",
column: nil,
is_filterable: false,
integration: 'stripe'
}
collection.fields << {
field: :stripe_invoices,
type: ['String'],
relationship: 'HasMany',
reference: "#{model_name}_stripe_invoices.id",
column: nil,
is_filterable: false,
integration: 'stripe'
}
collection.fields << {
field: :stripe_cards,
type: ['String'],
relationship: 'HasMany',
reference: "#{model_name}_stripe_cards.id",
column: nil,
is_filterable: false,
integration: 'stripe'
}
collection.fields << {
field: :stripe_subscriptions,
type: ['String'],
relationship: 'HasMany',
reference: "#{model_name}_stripe_subscriptions.id",
column: nil,
is_filterable: false,
integration: 'stripe'
}
collection.fields << {
field: :stripe_bank_accounts,
type: ['String'],
relationship: 'HasMany',
reference: "#{model_name}_stripe_bank_accounts.id",
column: nil,
is_filterable: false,
integration: 'stripe'
}
end
end
# NOTICE: Add Mixpanel field
mixpanel_mapping = ForestLiana.integrations
.try(:[], :mixpanel)
.try(:[], :mapping)
if mixpanel_mapping && mixpanel_mapping
.select { |mapping| mapping.split('.')[0] == @model.name }
.size > 0
model_name = ForestLiana.name_for(@model)
collection.fields << {
field: :mixpanel_last_events,
type: ['String'],
relationship: 'HasMany',
reference: "#{model_name}_mixpanel_events.id",
column: nil,
is_filterable: false,
integration: 'mixpanel',
}
end
# NOTICE: Add Paperclip url attributes
if @model.respond_to?(:attachment_definitions)
@model.attachment_definitions.each do |key, value|
collection.fields << { field: key, type: 'File' }
collection.fields.delete_if do |f|
["#{key}_file_name", "#{key}_file_size", "#{key}_content_type",
"#{key}_updated_at"].include?(f[:field])
end
end
end
# NOTICE: Add CarrierWave attributes
if @model.respond_to?(:uploaders)
@model.uploaders.each do |key, value|
field = collection.fields.find { |x| x[:field] == key.to_s }
field[:type] = 'File' if field
end
end
end
def add_associations
SchemaUtils.associations(@model).each do |association|
begin
if SchemaUtils.polymorphic?(association) &&
collection.fields << {
field: association.name.to_s,
type: get_type_for_association(association),
relationship: get_relationship_type(association),
reference: "#{association.name.to_s}.id",
inverse_of: @model.name.demodulize.underscore,
is_filterable: false,
is_sortable: true,
is_read_only: false,
is_required: false,
is_virtual: false,
default_value: nil,
integration: nil,
relationships: nil,
widget: nil,
validations: [],
polymorphic_referenced_models: get_polymorphic_types(association)
}
collection.fields = collection.fields.reject do |field|
field[:field] == association.foreign_key || field[:field] == association.foreign_type
end
# NOTICE: Delete the association if the targeted model is excluded.
elsif !SchemaUtils.model_included?(association.klass)
field = collection.fields.find do |x|
x[:field] == association.foreign_key
end
collection.fields.delete(field) if field
# NOTICE: The foreign key exists, so it's a belongsTo relationship.
elsif (field = column_association(collection, association)) &&
[:has_one, :belongs_to].include?(association.macro)
field[:reference] = get_reference_for(association)
field[:field] = association.name
field[:inverse_of] = inverse_of(association)
field[:relationship] = get_relationship_type(association)
# NOTICE: Create the fields of hasOne, HasMany, … relationships.
else
collection.fields << get_schema_for_association(association)
end
rescue NameError
FOREST_LOGGER.warn "The association \"#{association.name.to_s}\" " \
"does not seem to exist for model \"#{@model.name}\"."
rescue => exception
FOREST_REPORTER.report exception
FOREST_LOGGER.error "An error occured trying to add " \
"\"#{association.name.to_s}\" association:\n#{exception}"
end
end
end
def inverse_of(association)
association.inverse_of.try(:name).try(:to_s) ||
automatic_inverse_of(association)
end
def get_polymorphic_types(relation)
types = []
ForestLiana.models.each do |model|
unless model.reflect_on_all_associations.select { |association| association.options[:as] == relation.name.to_sym }.empty?
types << model.name
end
end
types
end
def automatic_inverse_of(association)
name = association.active_record.name.demodulize.underscore
inverse_association = association.klass.reflections.keys.find do |k|
k.to_s == name || k.to_s == name.pluralize
end
inverse_association.try(:to_s)
end
def get_schema_for_column(column)
column_type = get_type_for(column)
return nil if column_type.nil?
schema = {
field: column.name,
type: column_type,
is_filterable: true,
is_sortable: true,
is_read_only: false,
is_required: false,
is_virtual: false,
default_value: nil,
integration: nil,
reference: nil,
inverse_of: nil,
relationships: nil,
widget: nil,
validations: []
}
add_enum_values_if_is_enum(schema, column)
add_enum_values_if_is_sti_model(schema, column)
add_default_value(schema, column)
add_validations(schema, column)
end
def get_schema_for_association(association)
{
field: association.name.to_s,
type: get_type_for_association(association),
relationship: get_relationship_type(association),
reference: "#{ForestLiana.name_for(association.klass)}.id",
inverse_of: inverse_of(association),
is_filterable: !is_many_association(association),
is_sortable: true,
is_read_only: false,
is_required: false,
is_virtual: false,
default_value: nil,
integration: nil,
relationships: nil,
widget: nil,
validations: []
}
end
def get_relationship_type(association)
association.macro.to_s.camelize
end
def get_type_for(column)
# NOTICE: Rails 3 do not have a defined_enums method
if @model.respond_to?(:defined_enums) &&
@model.defined_enums.has_key?(column.name)
return 'Enum'
end
case column.type
when :boolean
type = 'Boolean'
when :datetime
type = 'Date'
when :date
type = 'Dateonly'
when :integer, :float, :decimal
type = 'Number'
when :json, :jsonb, :hstore
type = 'Json'
when :string, :text, :citext
type = 'String'
when :time
type = 'Time'
when :uuid
type = 'Uuid'
end
is_array = (column.respond_to?(:array) && column.array == true)
is_array ? [type] : type
end
def add_enum_values_if_is_enum(column_schema, column)
if column_schema[:type] == 'Enum'
column_schema[:enums] = []
@model.defined_enums[column.name].each do |name, value|
column_schema[:enums] << name
end
end
column_schema
end
def add_enum_values_if_is_sti_model(column_schema, column)
if sti_column?(column)
column_schema[:enums] = []
column_schema[:type] = 'Enum'
@model.descendants.each do |sti_model|
column_schema[:enums] << sti_model.name
end
end
column_schema
end
def sti_column?(column)
@model.inheritance_column && column.name == @model.inheritance_column
end
def is_sti_parent?
@model.try(:table_exists?) &&
@model.inheritance_column &&
@model.columns.any? { |column| sti_column?(column) } &&
@model.name == @model.base_class.to_s
end
def is_sti_column_of_child_model?(column)
sti_column?(column) && !is_sti_parent? && @model.descendants.empty?
end
def add_default_value(column_schema, column)
# TODO: detect/introspect the attribute default value with Rails 5
# ex: attribute :email, :string, default: 'arnaud@forestadmin.com'
column_schema[:default_value] = column.default if column.default
end
def add_validations(column_schema, column)
# NOTICE: Do not consider validations if a before_validation Active Records
# Callback is detected.
if @model._validation_callbacks.map(&:kind).include? :before
return column_schema
end
if @model._validators? && @model._validators[column.name.to_sym].size > 0
@model._validators[column.name.to_sym].each do |validator|
# NOTICE: Do not consider conditional validations
next if validator.options[:if] || validator.options[:unless] || validator.options[:on]
case validator
when ActiveRecord::Validations::PresenceValidator
column_schema[:validations] << {
type: 'is present',
message: validator.options[:message]
}
column_schema[:is_required] = true
when ActiveModel::Validations::NumericalityValidator
validator.options.each do |option, value|
case option
when :greater_than, :greater_than_or_equal_to
column_schema[:validations] << {
type: 'is greater than',
value: value,
message: validator.options[:message]
}
when :less_than, :less_than_or_equal_to
column_schema[:validations] << {
type: 'is less than',
value: value,
message: validator.options[:message]
}
end
end
when ActiveModel::Validations::LengthValidator
if column_schema[:type] == 'String'
validator.options.each do |option, value|
case option
when :minimum
column_schema[:validations] << {
type: 'is longer than',
value: value,
message: validator.options[:message]
}
when :maximum
column_schema[:validations] << {
type: 'is shorter than',
value: value,
message: validator.options[:message]
}
when :is
column_schema[:validations] << {
type: 'is longer than',
value: value,
message: validator.options[:message]
}
column_schema[:validations] << {
type: 'is shorter than',
value: value,
message: validator.options[:message]
}
end
end
end
when ActiveModel::Validations::FormatValidator
validator.options.each do |option, value|
case option
when :with
options = /\?([imx]){0,3}/.match(validator.options[:with].to_s)
options = options && options[1] ? options[1] : ''
regex = value.source
# NOTICE: Transform a Ruby regex into a JS one
regex = regex.sub('\\A' , '^').sub('\\Z' , '$').sub('\\z' , '$').gsub(/\n+|\s+/, '')
column_schema[:validations] << {
type: 'is like',
value: "/#{regex}/#{options}",
message: validator.options[:message]
}
end
end
end
end
if column_schema[:validations].size == 0
column_schema.delete(:validations)
end
end
column_schema
end
def get_reference_for(association)
if association.options[:polymorphic] == true
'*.id'
else
"#{ForestLiana.name_for(association.klass)}.id"
end
end
def column_association(collection, field)
collection.fields.find {|x| x[:field] == field.foreign_key }
end
def is_many_association(association)
association.macro == :has_many ||
association.macro == :has_and_belongs_to_many
end
def get_type_for_association(association)
if is_many_association(association)
['Number']
else
'Number'
end
end
def deforeign_key(column_name)
if column_name[-3..-1] == '_id'
column_name[0..-4]
else
column_name
end
end
end
end