Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add taxonomy mapping models and the logic to generate mappings from a list of mapping rules #121

Merged
merged 1 commit into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading