Files
food-ai/backend/migrations/012_refactor_ingredients.sql
dbastrikin a225f6c47a refactor: migrate ingredient aliases/categories to dedicated tables, drop spoonacular_id
- migration 012: create ingredient_categories + ingredient_category_translations
  tables (7 slugs, Russian names); add ingredient_aliases (ingredient_id, lang,
  alias) with GIN trigram index; migrate aliases JSONB from ingredient_mappings
  and ingredient_translations; drop aliases columns and spoonacular_id; add
  UNIQUE (canonical_name) as conflict key
- ingredient/model: remove SpoonacularID, add CategoryName for localized display
- ingredient/repository: conflict on canonical_name; GetByID/Search join category
  translations and aliases lateral; new UpsertAliases (pgx batch),
  UpsertCategoryTranslation; remove GetBySpoonacularID; split scan helpers into
  scanMappingWrite / scanMappingRead
- gemini/recognition: add IngredientTranslation type; IngredientClassification
  now carries Translations []IngredientTranslation instead of CanonicalNameRu;
  update ClassifyIngredient prompt to English with structured translations array
- recognition/handler: update ingredientRepo interface; saveClassification uses
  UpsertAliases and iterates Translations
- recipe/model: remove SpoonacularID from RecipeIngredient
- integration tests: remove SpoonacularID fixtures, replace GetBySpoonacularID
  tests with GetByID, add UpsertAliases and UpsertCategoryTranslation tests
- Flutter: remove canonicalNameRu from IngredientMapping, add categoryName;
  displayName returns server-resolved canonicalName; regenerate .g.dart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 14:40:07 +02:00

90 lines
3.8 KiB
SQL

-- +goose Up
-- 1. ingredient_categories: slug-keyed table (the 7 known slugs)
CREATE TABLE ingredient_categories (
slug VARCHAR(50) PRIMARY KEY,
sort_order SMALLINT NOT NULL DEFAULT 0
);
INSERT INTO ingredient_categories (slug, sort_order) VALUES
('dairy', 1), ('meat', 2), ('produce', 3),
('bakery', 4), ('frozen', 5), ('beverages', 6), ('other', 7);
-- 2. ingredient_category_translations
CREATE TABLE ingredient_category_translations (
category_slug VARCHAR(50) NOT NULL REFERENCES ingredient_categories(slug) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (category_slug, lang)
);
INSERT INTO ingredient_category_translations (category_slug, lang, name) VALUES
('dairy', 'ru', 'Молочные продукты'),
('meat', 'ru', 'Мясо и птица'),
('produce', 'ru', 'Овощи и фрукты'),
('bakery', 'ru', 'Выпечка и хлеб'),
('frozen', 'ru', 'Замороженные'),
('beverages', 'ru', 'Напитки'),
('other', 'ru', 'Прочее');
-- 3. Nullify any unknown category values before adding FK
UPDATE ingredient_mappings
SET category = NULL
WHERE category IS NOT NULL
AND category NOT IN (SELECT slug FROM ingredient_categories);
ALTER TABLE ingredient_mappings
ADD CONSTRAINT fk_ingredient_category
FOREIGN KEY (category) REFERENCES ingredient_categories(slug);
-- 4. ingredient_aliases table
CREATE TABLE ingredient_aliases (
ingredient_id UUID NOT NULL REFERENCES ingredient_mappings(id) ON DELETE CASCADE,
lang VARCHAR(10) NOT NULL,
alias TEXT NOT NULL,
PRIMARY KEY (ingredient_id, lang, alias)
);
CREATE INDEX idx_ingredient_aliases_lookup ON ingredient_aliases (ingredient_id, lang);
CREATE INDEX idx_ingredient_aliases_trgm ON ingredient_aliases USING GIN (alias gin_trgm_ops);
-- 5. Migrate English aliases from ingredient_mappings.aliases
INSERT INTO ingredient_aliases (ingredient_id, lang, alias)
SELECT im.id, 'en', a.val
FROM ingredient_mappings im,
jsonb_array_elements_text(im.aliases) a(val)
ON CONFLICT DO NOTHING;
-- 6. Migrate per-language aliases from ingredient_translations.aliases
INSERT INTO ingredient_aliases (ingredient_id, lang, alias)
SELECT it.ingredient_id, it.lang, a.val
FROM ingredient_translations it,
jsonb_array_elements_text(it.aliases) a(val)
ON CONFLICT DO NOTHING;
-- 7. Drop aliases JSONB columns
DROP INDEX IF EXISTS idx_ingredient_mappings_aliases;
ALTER TABLE ingredient_mappings DROP COLUMN aliases;
ALTER TABLE ingredient_translations DROP COLUMN aliases;
-- 8. Drop spoonacular_id
ALTER TABLE ingredient_mappings DROP COLUMN spoonacular_id;
-- 9. Unique constraint on canonical_name (replaces spoonacular_id as conflict key)
ALTER TABLE ingredient_mappings
ADD CONSTRAINT uq_ingredient_canonical_name UNIQUE (canonical_name);
-- +goose Down
ALTER TABLE ingredient_mappings DROP CONSTRAINT IF EXISTS uq_ingredient_canonical_name;
ALTER TABLE ingredient_mappings DROP CONSTRAINT IF EXISTS fk_ingredient_category;
ALTER TABLE ingredient_mappings ADD COLUMN spoonacular_id INTEGER;
ALTER TABLE ingredient_translations ADD COLUMN aliases JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE ingredient_mappings ADD COLUMN aliases JSONB NOT NULL DEFAULT '[]'::jsonb;
-- Restore aliases JSONB from ingredient_aliases (best-effort)
UPDATE ingredient_mappings im
SET aliases = COALESCE(
(SELECT json_agg(ia.alias) FROM ingredient_aliases ia
WHERE ia.ingredient_id = im.id AND ia.lang = 'en'),
'[]'::json);
DROP TABLE IF EXISTS ingredient_aliases;
DROP TABLE IF EXISTS ingredient_category_translations;
DROP TABLE IF EXISTS ingredient_categories;
CREATE INDEX idx_ingredient_mappings_aliases ON ingredient_mappings USING GIN (aliases);