Skip to content

Commit

Permalink
Add taxonomy mapping models and the logic to generate mappings from a…
Browse files Browse the repository at this point in the history
… list of mapping rules
  • Loading branch information
chesterbot01 committed Mar 25, 2024
1 parent fdbde72 commit e7cd970
Show file tree
Hide file tree
Showing 15 changed files with 41,298 additions and 0 deletions.
5 changes: 5 additions & 0 deletions app/models/google_product.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class GoogleProduct < Product
store :payload, coder: JSON, accessors: [:product_category_id]
end
6 changes: 6 additions & 0 deletions app/models/integration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

class Integration < ApplicationRecord
has_many :mapping_rules
validates :name, presence: true
end
49 changes: 49 additions & 0 deletions app/models/mapping_rule.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

class MappingRule < ApplicationRecord
belongs_to :integration
belongs_to :input, polymorphic: true
belongs_to :output, polymorphic: true

PRESENT = "present"

def apply(output_acc)
output_hash = output.payload.as_json
output_hash.delete_if { |_, value| value == [] }
output_acc.merge(output_hash) do |key, old_val, new_val|
intersection = old_val.intersection(new_val)
if !old_val.empty? && intersection.empty?
raise "Rules conflicted, please review the rules.
rule: #{as_json}, key: #{key}, old_val: #{old_val}, new_val: #{new_val}"
end

intersection
end
end

def match?(product_input)
rule_input = input

return false unless product_input[:product_category_id] == rule_input.product_category_id
return true if rule_input.properties.blank?

rule_input.properties.all? do |rule_attribute|
product_input[:attributes].any? do |attribute|
if attribute[:name] == rule_attribute["name"]
values_match = attribute[:value] == rule_attribute["value"]
# TODO: Handle inputs that are lists/multi-selections
value_included = if rule_attribute["value"].is_a?(Array)
rule_attribute["value"].include?(attribute[:value])
else
false
end
value_present = attribute[:value].present? && rule_attribute["value"] == PRESENT

values_match || value_included || value_present
else
false
end
end
end
end
end
10 changes: 10 additions & 0 deletions app/models/product.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true

class Product < ApplicationRecord
has_many :mapping_rules
serialize :payload, coder: JSON

def as_json(options = {})
super(options.merge({ methods: :type }))
end
end
5 changes: 5 additions & 0 deletions app/models/shopify_product.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

class ShopifyProduct < Product
store :payload, coder: JSON, accessors: [:product_category_id, :properties]
end
25 changes: 25 additions & 0 deletions app/serializers/source_data/product_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module SourceData
class ProductSerializer < ObjectSerializer
def serialize(product)
payload = {}
if product.is_a?(ShopifyProduct)
payload[:product_category_id] = product.product_category_id
payload[:attributes] = product.properties.map(&:deep_symbolize_keys) if product.properties.present?
end
{
payload: payload,
type: product.type,
}
end

def deserialize(payload, product_type)
if product_type == "ShopifyProduct"
properties = payload.delete("attributes")
payload["properties"] = properties
end
Product.new(payload: payload, type: product_type)
end
end
end
52 changes: 52 additions & 0 deletions app/services/input_product_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

class InputProductGenerator
class << self
def generate_inputs_for_categories(categories, rules)
all_inputs = []
relevant_attributes = rules.flat_map do |rule|
rule.input.properties&.map { |attribute| attribute["name"] }
end.uniq

categories.each do |category|
category_attributes = category.properties
.filter { |attribute| relevant_attributes.include?(attribute.id) }
all_inputs << generate_inputs_from_category(category, category_attributes)
end

all_inputs.flatten
end

private

def generate_inputs_from_category(category, relevant_category_attributes)
# NOTE: Shopify attributes are optional so we need to account for that in the combination
all_attributes_with_values = []
relevant_category_attributes.each do |attribute|
all_attributes_with_values.push(
attribute.property_values
.map { |value| [attribute.id, value.id] }
.append([attribute.id, nil]),
)
end

attribute_combinations = if all_attributes_with_values.present?
all_attributes_with_values[0].product(*all_attributes_with_values[1..])
else
[[]] # Empty attribute set
end

attribute_combinations.map do |combination|
{
product_category_id: category[:id],
attributes: combination.map do |e|
{
name: e.first,
value: e.last,
}
end,
}
end
end
end
end
37 changes: 37 additions & 0 deletions app/services/mapping_builder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true

class MappingBuilder
class << self
def build_mappings_for_vertical(mapping_rules:, vertical:)
puts " → for #{Integration.find(mapping_rules.first.integration_id).name} in #{vertical.name}..."
relevant_rules = mapping_rules.select { |rule| rule.input.product_category_id.start_with?(vertical.id) }
return if relevant_rules.count.zero?

inputs = InputProductGenerator.generate_inputs_for_categories(vertical.descendants_and_self, relevant_rules)

build_mappings_from_inputs_and_rules(inputs, relevant_rules)
end

private

def build_mappings_from_inputs_and_rules(inputs, rules)
inputs.map do |input|
mapping = {
input: input,
output: rules.filter { |rule| rule.match?(input) }.reduce({}) do |final_output, rule|
rule.apply(final_output)
end,
}

if mapping[:output].empty?
nil
elsif mapping[:input][:attributes].empty?
mapping[:input].delete(:attributes)
mapping
else
mapping
end
end
end
end
end
1 change: 1 addition & 0 deletions application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
LOADER = Zeitwerk::Loader.new
LOADER.push_dir("#{__dir__}/app/models")
LOADER.push_dir("#{__dir__}/app/serializers")
LOADER.push_dir("#{__dir__}/app/services")

LOADER.inflector.inflect(
"json" => "JSON",
Expand Down
4 changes: 4 additions & 0 deletions bin/seed
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ cli.options_status
attributes_data = cli.parse_yaml("data/attributes/attributes.yml")
category_files = Dir.glob("#{CLI.root}/data/categories/*.yml")
verticals_data = category_files.map { cli.parse_yaml(_1) }
integrations_data = YAML.load_file("#{Application.root}/data/integrations/integrations.yml")
mapping_rule_files = Dir.glob("#{Application.root}/data/integrations/*/mappings/*_shopify.yml")

Application.establish_db_connection!
Application.load_and_reset_schema!

seed = DB::Seed.new(verbose: cli.options.verbose)
seed.attributes_from(attributes_data)
seed.categories_from(verticals_data)
seed.integrations_from(integrations_data)
seed.mapping_rules_from(mapping_rule_files)
Loading

0 comments on commit e7cd970

Please sign in to comment.