feat(recipes): add recipe repository (insert/get/delete with FTS refresh)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
157
src/lib/server/recipes/repository.ts
Normal file
157
src/lib/server/recipes/repository.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import type { Ingredient, Recipe, Step } from '$lib/types';
|
||||||
|
|
||||||
|
type RecipeRow = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
source_url: string | null;
|
||||||
|
source_domain: string | null;
|
||||||
|
image_path: string | null;
|
||||||
|
servings_default: number | null;
|
||||||
|
servings_unit: string | null;
|
||||||
|
prep_time_min: number | null;
|
||||||
|
cook_time_min: number | null;
|
||||||
|
total_time_min: number | null;
|
||||||
|
cuisine: string | null;
|
||||||
|
category: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureTagIds(db: Database.Database, names: string[]): number[] {
|
||||||
|
const insert = db.prepare('INSERT OR IGNORE INTO tag(name) VALUES (?)');
|
||||||
|
const select = db.prepare('SELECT id FROM tag WHERE name = ?');
|
||||||
|
const ids: number[] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
insert.run(trimmed);
|
||||||
|
const row = select.get(trimmed) as { id: number };
|
||||||
|
ids.push(row.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshFts(db: Database.Database, recipeId: number): void {
|
||||||
|
// Trigger the AFTER UPDATE trigger which rebuilds the FTS row with current ingredients + tags.
|
||||||
|
db.prepare('UPDATE recipe SET title = title WHERE id = ?').run(recipeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertRecipe(db: Database.Database, recipe: Recipe): number {
|
||||||
|
const tx = db.transaction((): number => {
|
||||||
|
const info = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO recipe
|
||||||
|
(title, description, source_url, source_domain, image_path,
|
||||||
|
servings_default, servings_unit,
|
||||||
|
prep_time_min, cook_time_min, total_time_min,
|
||||||
|
cuisine, category)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
recipe.title,
|
||||||
|
recipe.description,
|
||||||
|
recipe.source_url,
|
||||||
|
recipe.source_domain,
|
||||||
|
recipe.image_path,
|
||||||
|
recipe.servings_default,
|
||||||
|
recipe.servings_unit,
|
||||||
|
recipe.prep_time_min,
|
||||||
|
recipe.cook_time_min,
|
||||||
|
recipe.total_time_min,
|
||||||
|
recipe.cuisine,
|
||||||
|
recipe.category
|
||||||
|
);
|
||||||
|
const id = Number(info.lastInsertRowid);
|
||||||
|
|
||||||
|
const insIng = db.prepare(
|
||||||
|
`INSERT INTO ingredient(recipe_id, position, quantity, unit, name, note, raw_text)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
);
|
||||||
|
for (const ing of recipe.ingredients) {
|
||||||
|
insIng.run(id, ing.position, ing.quantity, ing.unit, ing.name, ing.note, ing.raw_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const insStep = db.prepare(
|
||||||
|
'INSERT INTO step(recipe_id, position, text) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
for (const step of recipe.steps) {
|
||||||
|
insStep.run(id, step.position, step.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagIds = ensureTagIds(db, recipe.tags);
|
||||||
|
const linkTag = db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO recipe_tag(recipe_id, tag_id) VALUES (?, ?)'
|
||||||
|
);
|
||||||
|
for (const tid of tagIds) linkTag.run(id, tid);
|
||||||
|
|
||||||
|
refreshFts(db, id);
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
return tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecipeById(db: Database.Database, id: number): Recipe | null {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, title, description, source_url, source_domain, image_path,
|
||||||
|
servings_default, servings_unit,
|
||||||
|
prep_time_min, cook_time_min, total_time_min,
|
||||||
|
cuisine, category
|
||||||
|
FROM recipe WHERE id = ?`
|
||||||
|
)
|
||||||
|
.get(id) as RecipeRow | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
const ingredients = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT position, quantity, unit, name, note, raw_text
|
||||||
|
FROM ingredient WHERE recipe_id = ? ORDER BY position`
|
||||||
|
)
|
||||||
|
.all(id) as Ingredient[];
|
||||||
|
|
||||||
|
const steps = db
|
||||||
|
.prepare('SELECT position, text FROM step WHERE recipe_id = ? ORDER BY position')
|
||||||
|
.all(id) as Step[];
|
||||||
|
|
||||||
|
const tagRows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.name FROM tag t
|
||||||
|
JOIN recipe_tag rt ON rt.tag_id = t.id
|
||||||
|
WHERE rt.recipe_id = ?
|
||||||
|
ORDER BY t.name`
|
||||||
|
)
|
||||||
|
.all(id) as { name: string }[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
source_url: row.source_url,
|
||||||
|
source_domain: row.source_domain,
|
||||||
|
image_path: row.image_path,
|
||||||
|
servings_default: row.servings_default,
|
||||||
|
servings_unit: row.servings_unit,
|
||||||
|
prep_time_min: row.prep_time_min,
|
||||||
|
cook_time_min: row.cook_time_min,
|
||||||
|
total_time_min: row.total_time_min,
|
||||||
|
cuisine: row.cuisine,
|
||||||
|
category: row.category,
|
||||||
|
ingredients,
|
||||||
|
steps,
|
||||||
|
tags: tagRows.map((t) => t.name)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecipeIdBySourceUrl(
|
||||||
|
db: Database.Database,
|
||||||
|
url: string
|
||||||
|
): number | null {
|
||||||
|
const row = db.prepare('SELECT id FROM recipe WHERE source_url = ?').get(url) as
|
||||||
|
| { id: number }
|
||||||
|
| undefined;
|
||||||
|
return row?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteRecipe(db: Database.Database, id: number): void {
|
||||||
|
db.prepare('DELETE FROM recipe WHERE id = ?').run(id);
|
||||||
|
}
|
||||||
100
tests/integration/recipe-repository.test.ts
Normal file
100
tests/integration/recipe-repository.test.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { openInMemoryForTest } from '../../src/lib/server/db';
|
||||||
|
import {
|
||||||
|
insertRecipe,
|
||||||
|
getRecipeById,
|
||||||
|
getRecipeIdBySourceUrl,
|
||||||
|
deleteRecipe
|
||||||
|
} from '../../src/lib/server/recipes/repository';
|
||||||
|
import { extractRecipeFromHtml } from '../../src/lib/server/parsers/json-ld-recipe';
|
||||||
|
import type { Recipe } from '../../src/lib/types';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const fixture = (name: string): string =>
|
||||||
|
readFileSync(join(here, '../fixtures', name), 'utf8');
|
||||||
|
|
||||||
|
function baseRecipe(overrides: Partial<Recipe> = {}): Recipe {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
title: 'Test',
|
||||||
|
description: null,
|
||||||
|
source_url: null,
|
||||||
|
source_domain: null,
|
||||||
|
image_path: null,
|
||||||
|
servings_default: 4,
|
||||||
|
servings_unit: null,
|
||||||
|
prep_time_min: null,
|
||||||
|
cook_time_min: null,
|
||||||
|
total_time_min: null,
|
||||||
|
cuisine: null,
|
||||||
|
category: null,
|
||||||
|
ingredients: [],
|
||||||
|
steps: [],
|
||||||
|
tags: [],
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('recipe repository', () => {
|
||||||
|
it('round-trips an imported chefkoch recipe', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const parsed = extractRecipeFromHtml(fixture('chefkoch-schupfnudeln.html'));
|
||||||
|
expect(parsed).not.toBeNull();
|
||||||
|
parsed!.source_url = 'https://www.chefkoch.de/rezepte/4094871643016343/x.html';
|
||||||
|
parsed!.source_domain = 'chefkoch.de';
|
||||||
|
const id = insertRecipe(db, parsed!);
|
||||||
|
const loaded = getRecipeById(db, id);
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded!.title).toBe(parsed!.title);
|
||||||
|
expect(loaded!.ingredients.length).toBe(parsed!.ingredients.length);
|
||||||
|
expect(loaded!.steps.length).toBe(parsed!.steps.length);
|
||||||
|
expect(loaded!.ingredients[0].name).toBe(parsed!.ingredients[0].name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FTS finds recipe by ingredient after refresh', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
insertRecipe(
|
||||||
|
db,
|
||||||
|
baseRecipe({
|
||||||
|
title: 'Spaghetti Carbonara',
|
||||||
|
ingredients: [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
quantity: 200,
|
||||||
|
unit: 'g',
|
||||||
|
name: 'Pancetta',
|
||||||
|
note: null,
|
||||||
|
raw_text: '200 g Pancetta'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tags: ['Italienisch']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const byIngredient = db
|
||||||
|
.prepare("SELECT rowid FROM recipe_fts WHERE recipe_fts MATCH 'pancetta'")
|
||||||
|
.all();
|
||||||
|
expect(byIngredient.length).toBe(1);
|
||||||
|
const byTag = db
|
||||||
|
.prepare("SELECT rowid FROM recipe_fts WHERE recipe_fts MATCH 'italienisch'")
|
||||||
|
.all();
|
||||||
|
expect(byTag.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('looks up by source_url and deletes cascading', () => {
|
||||||
|
const db = openInMemoryForTest();
|
||||||
|
const id = insertRecipe(
|
||||||
|
db,
|
||||||
|
baseRecipe({
|
||||||
|
title: 'Foo',
|
||||||
|
source_url: 'https://example.com/foo',
|
||||||
|
source_domain: 'example.com'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(getRecipeIdBySourceUrl(db, 'https://example.com/foo')).toBe(id);
|
||||||
|
deleteRecipe(db, id);
|
||||||
|
expect(getRecipeById(db, id)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user