1

Please advise on a better formulation of the question.

Basis

I have SQL tables R=recipes, I=ingredients. (minimum example):

  • R (id)
  • RI (id, r_id, i_id)
  • I (id, description) where RI is intermediate table connecting R and I (otherwise in m,n-relationship).

You may skip this

In HTML I have an input for filtering recipes by ingredients. Input is sent to PHP with JavaScript's Fetch API (body: JSON.stringify({ input: filter.value.trim() })).

In PHP I clean it up and explode it into array of words so %&/(Oh/$#/?Danny;:¤ boy! gets converted to ['Oh', 'Danny', 'boy']

$filterParams = preg_replace('/[_\W]/', ' ', $data['input']);
$filterParams = preg_replace('/\s\s+/', ' ', $filterParams);
$filterParams = trim($filterParams);
$filterParams = explode(' ', $filterParams);

What I want

I need an SQL query for all recipe IDs that require all of the ingredients from the input. Consider these two recipes:

ID   RECIPE    INGREDIENTS
 1   pancake   egg, flour, milk
 2   egg       egg

Filtering for "eg, ilk" should only return 1 but not 2.

What I have #1

This gives me all recipes that require any of the ingredients, therefore it returns 1 and 2.

$recipeFilters = array_map(function ($param) {
    return "ri.description LIKE '%{$param}%'";
}, $filterParams);

$recipeFilter = implode(' OR ', $recipeFilters);

$selRecipes = <<<SQL

    SELECT DISTINCT rr.id
    FROM
        recipe_ingredient ri LEFT JOIN
        recipe_intermediate_ingredient_recipe riir ON riir.ingredient_id = ri.id LEFT JOIN
        recipe_recipe rr ON rr.id = riir.recipe_id
    WHERE
        {$recipeFilter} AND
        rr.id IS NOT NULL

SQL;

$recipes = data_select($selRecipes); // Custom function that prepares statement, binds data (not in this case), and eventually after all error checking returns $statement->get_result()->fetch_all(MYSQLI_ASSOC)

$ids = [];
foreach ($recipes as $recipe)
    $ids[] = "{$recipe['id']}";

What I have #2

Replacing OR with AND in the fifth line returns neither 1 nor 2, because no ingredient has both eggs and milk (ie. eg, ilk) in it's name.

...
$recipeFilter = implode(' AND ', $recipeFilters);
...

Suboptimal solution

I know I can simply query for each ingredient separately and then with some simple array manipulations get what I desire.

Is it possible to do it in just one query, and how?

s3c
  • 1,481
  • 19
  • 28
  • read about mysql `having` – splash58 Nov 08 '21 at 16:17
  • **Warning:** You are wide open to [SQL Injections](https://php.net/manual/en/security.database.sql-injection.php) and should use parameterized **prepared statements** instead of manually building your queries. They are provided by [PDO](https://php.net/manual/pdo.prepared-statements.php) or by [MySQLi](https://php.net/manual/mysqli.quickstart.prepared-statements.php). Never trust any kind of input! Even when your queries are executed only by trusted users, [you are still in risk of corrupting your data](http://bobby-tables.com/). [Escaping is not enough!](https://stackoverflow.com/q/5741187) – Dharman Nov 08 '21 at 16:24
  • I know, thanks for the warning. As I've said, my `data_select($query, ...$bindParams)` function handle all of that exactly (MySQLi, not PDO), I just wanted to make the code a bit more minimalistic here, instead of putting 3 extra lines in for getting values in the function (and replacing values shown with `?`. That being said, the as is allows only alphanumerics and spaces, so at least something like `OR 1=1` is out of the question, since `=` is removed. Thanks again! – s3c Nov 09 '21 at 08:26

1 Answers1

0

You could use a query with grouping in combination with HAVING COUNT() to get the desired result.

recipe

id name
1 pancakes
2 eggs

ingredient

id name
1 eggs
2 flour
3 milk

recipe_ingredient

rid iid
1 1
1 2
1 3
2 1

Consider this query:

SELECT r.id, r.name FROM recipe r
  JOIN recipe_ingredient ri ON r.id = ri.rid
  JOIN ingredient i ON i.id = ri.iid
WHERE i.name LIKE '%ilk%' OR i.name LIKE '%eg%'
GROUP BY r.id
HAVING COUNT(i.name) = 2;

This query selects the recipes, joins the ingredients, and groups by recipe ID, using HAVING COUNT(i.name) to count the ingredients matching the OR filters, which gives you:

id name
1 pancakes
majusebetter
  • 1,458
  • 1
  • 4
  • 13
  • Thanks! Got it working for simple input as presented in question. Now I have a different problem. Having 2 ingredients with descriptions (names) `cow milk` `goat milk`, causes an issue if the input is "egg, cow milk". This make three parameters `['egg', 'cow', 'milk']` which breaks (not really breaks) the query on `HAVING COUNT()` line. Any workarounds? – s3c Nov 09 '21 at 09:18
  • You're welcome. I'm not sure I understand the problem. It's the nature of the `LIKE` operator when using wildcards. I don't know your UI and how it is supposed to work, but if you replace the `LIKE` operations by exact match `=`, it should work. So, if you want to get all recipes with eggs and cow milk, you would use `WHERE i.name = 'cow milk' OR i.name = 'eggs'`. It might be a good idea to create a new question. – majusebetter Nov 09 '21 at 09:55
  • You could, for example, provide a search field with suggestions on the UI side (e.g. using a chip input component or similar). For the suggestions you query the `ingredients` table using the input text as the like filter. Selecting an entry then creates a chip with the full ingredient name, which then can be used for filtering in the final query (using the `=` operator). – majusebetter Nov 09 '21 at 10:00