# Задание 1
> Представьте, что данных в этих таблицах очень много, сотни миллионов
> записей. Если вам кажется, что с таблицами или данными что-то не так -
> исправьте по своему усмотрению. Опишите в комментариях, что вы сделали
> и почему.
1. Дату в поле `birthday` лучше хранить в специализированных форматах для дат, например в `Date`, эти форматы больше оптимизированы для хранения и обработки дат, к тому же не получим проблему 2038 года (если приложение доживёт). К тому же, в поле день рождения нам не нужно хранить время с точностью до секунды. Формат `INT` занимает 4 байта, а `Date` -- 3 байта.
2. Поле `sex` лучше сделать с типом `BOOLEAN`, потому что биологический пол может быть только мужской или женский. И, наверное, для избежания путаницы можно назвать поле `is_male`, либо же сделать enum. Если же мы говорим про gender, то тут возможно и стоит заложить побольше вариантов: https://young.scot/get-informed/national/gender-identity-terms 😂
3. Для поля `has_work` тип данных тоже лучше сделать `BOOLEAN`.
4. В рамках данной задачи (мы не делаем поиск по имени) хранение ФИО в формате `VARCHAR` -- хорошее решение, оно экономит дисковое пространство, но если нам нужно делать частые поиски по имени, возможно, стоит использовать тип данных `CHAR`.
5. В поле `id` я бы добавил `AUTO_INCREMENT`. В этом случае задача генерации уникальной последовательности будет на стороне СУБД. Если мы, по какой-то причине не хотим использовать `AUTO_INCREMENT`, имеет смысл посмотреть в сторону типа данных `UUID`. В этом случае со стороны кода мы получим возможность знать какой будет идентификатор записи ДО добавления в базу данных.
6. В таблице `loan` нужно добавить `FOREIGN KEY` на поле `account_id` для консистентности данных.
Таким образом, запрос на создание таблиц будет выглядеть вот так:
CREATE TABLE `account` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`fio` VARCHAR(255),
`is_male` bool COMMENT '0 - не указан, 1 - мужчина, 2 - женщина.',
`birthdate` DATE COMMENT 'Дата в unixtime.',
`has_work` bool comment '1 - Есть работа, 0 – нет работы',
PRIMARY KEY (`id`)
);
CREATE TABLE `loan` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`account_id` INT(11),
`amount` INT(11),
CONSTRAINT loan_account_id_fk
FOREIGN KEY (account_id) REFERENCES account (id)
ON DELETE CASCADE ,
PRIMARY KEY (`id`)
);
***
> Напишите запрос, возвращающий имя, количество кредитов и общую сумму
> кредитов для работающих мужчин, имеющих не более 2-х кредитов на общую
> сумму не более 50000 т.р. Отсортируйте по возрастанию общей суммы
> кредитов.
SELECT fio,
count(l.id) AS loan_quantity,
SUM(l.amount) AS loan_sum
FROM account
JOIN loan l ON account.id = l.account_id
WHERE has_work = 1
AND is_male = 1
GROUP BY l.account_id
HAVING loan_quantity <= 2
AND loan_sum <= 50000
ORDER BY loan_sum;
***
> Если вам кажется, что для быстрого выполнения этого запроса таблицы
> нуждаются в оптимизации - проделайте её по своему усмотрению. Опишите
> в комментариях, что вы сделали и почему.
Посмотрим как выполняется запрос без оптимизации.
mysql> explain select fio, count(l.amount) as loan_quantity, SUM(l.amount) as loan_sum from account
-> join loan l on account.id = l.account_id
-> where has_work = 1
-> group by l.account_id
-> having loan_quantity <= 2 and loan_sum <= 50000
-> order by loan_sum;
+----+-------------+---------+------------+------+--------------------+--------------------+---------+---------------------------+------+----------+----------------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+---------+------------+------+--------------------+--------------------+---------+---------------------------+------+----------+----------------------------------------------+
| 1 | SIMPLE | account | NULL | ALL | PRIMARY | NULL | NULL | NULL | 1000 | 10.00 | Using where; Using temporary; Using filesort |
| 1 | SIMPLE | l | NULL | ref | loan_account_id_fk | loan_account_id_fk | 5 | test_andromeda.account.id | 1 | 100.00 | NULL |
+----+-------------+---------+------------+------+--------------------+--------------------+---------+---------------------------+------+----------+----------------------------------------------+
2 rows in set, 1 warning (0.00 sec)
mysql>
Тут мы видим, что в поле `type` в первой таблице стоит значение `ALL`, это значит, что таблица полностью просканирована, так же в поле `rows` мы видим, что обработано 1000 записей. Добавим индексов.
Добавим индекс на поле `has_work`:
ALTER TABLE account ADD INDEX (has_work, is_female)
И снова проверим:
mysql> explain select fio, count(l.id) as loan_quantity, SUM(l.amount) as loan_sum from account join loan l on account.id = l.account_id where has_work = 1 group by l.account_id having loan_quantity <= 2 and loan_sum <= 50000 order by loan_sum;
+----+-------------+---------+------------+------+-----------------------+-----------------------+---------+---------------------------+------+----------+---------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+---------+------------+------+-----------------------+-----------------------+---------+---------------------------+------+----------+---------------------------------+
| 1 | SIMPLE | account | NULL | ref | PRIMARY,has_work | has_work | 2 | const | 317 | 100.00 | Using temporary; Using filesort |
| 1 | SIMPLE | l | NULL | ref | loan_account_id_index | loan_account_id_index | 5 | test_andromeda.account.id | 1 | 100.00 | NULL |
+----+-------------+---------+------------+------+-----------------------+-----------------------+---------+---------------------------+------+----------+---------------------------------+
2 rows in set, 1 warning (0.00 sec)
mysql>
То есть вместо полного сканирования всей таблицы мы сразу же отфильтровали примерно треть записей, при этом в колонке key появился используемый индекс.
# Задание 2
Этот код использует таблицу account из предыдущего примера
function load_account_data( $account_ids ) {
$account_ids = explode(',', $account_ids);
foreach ($account_ids as $user_id) {
$db = mysqli_connect("localhost", "test_user", "123123", "test_andromeda");
$sql = mysqli_query($db, "SELECT * FROM account WHERE id=$user_id");
while($obj = $sql->fetch_object()){
$data[$user_id] = $obj->fio;
}
mysqli_close($db);
}
return $data;
}
// Как правило, в $_GET['account_ids'] должна приходить строка
// с номерами заёмщиков через запятую, например: 1,2,17,48
$data = load_account_data( $_GET['account_ids'] );
foreach ($data as $user_id=>$name) {
echo "<a href=\"/show_user.php?id=$user_id\">$name</a>";
}
Если вам кажется, что с кодом что-то не так - проведите его рефакторинг по своему усмотрению. В комментариях опишите, что вы сделали и почему.
Отрефакторил код вот так:
<?php
CONST HOST = '172.19.0.2';
CONST USER = 'root';
CONST PASSWORD = 'password';
CONST DATABASE = 'test_andromeda';
function load_account_data(string $account_ids): array {
if (validate($account_ids) === false) {
throw new Exception('Invalid data.');
}
$sql = sprintf('SELECT id, fio FROM account WHERE id in (%s)', $account_ids);
$data = [];
foreach (get_data($sql) as $obj) {
$data[$obj->id] = $obj->fio;
}
return $data;
}
function validate(string $account_ids): bool {
if (!preg_match('/^\d+(,\d+)*$/', $account_ids)) {
return false;
}
return true;
}
function get_data(string $query): Generator {
$db = mysqli_connect(HOST, USER, PASSWORD, DATABASE);
$sql = mysqli_query($db, $query);
while($obj = $sql->fetch_object()){
yield $obj;
}
mysqli_close($db);
}
$data = load_account_data('1,3,5,6,123,56,456,12');
foreach ($data as $user_id=>$name) {
echo "<a href=\"/show_user.php?id=$user_id\">$name</a>";
}
Так как в `GET` параметре может быть максимум 2048 символов, то оператор `IN` нам подходит. Для начала отвалидируем данные, чтобы убедиться, что данные пришли именно в том формате, который нам нужен: цифра, запятая между цифрами и отсутствите запятой в конце строки. Проинициализируем переменную `$data`, чтобы в случае, если передался пустой массив мы пустой массив и вернули. Дальше отделим логику обращения в БД в другую функцию, дабы не загромождать основной код низкоуровневыми операциями. Так как мы отвалидировали входную переменную, можно вставлять её в запрос как есть, без экранирования специальных символов. Если не использовать валидацию, то можно вместо `mysqli` использовать `PDO` с возможность создания `PREPARED-STATEMENT`. Для возврата данных в основную функцию используем генераторы. Так, в том числе рекомендуется делать в Doctrine для итерирования по большим объёмам данным без загрузки в память.
# Задание 3
> Имеется урл:
>
> https://www.mydomain.com/controller/test.php?a=45&b=38&c=0&d=12&e=309&f=38&g=zed456&h=test2
>
> Напишите на PHP функцию, принимающую в качестве аргумента урл и
> возвращающую строку в качестве результата, которая:
>
> а) Удалит параметры со значением 38 б) Отсортирует параметры по
> значению в) Перенесет относительный путь к файлу
> (/controller/test.php) из исходной ссылки в новый параметр path
>
> Проверьте себя: в указанном примере функцией должно быть возвращено
>
> https://www.mydomain.com/?c=0&d=12&a=45&e=309&h=test2&g=zed456&path=%2Fcontroller%2Ftest.php
Мы предполагаем, что URL нам передаётся валидный. Поэтому код вот такой:
<?php
declare(strict_types=1);
function formatUrl(string $url): string {
$parsedUrl = parse_url($url);
$queryParams = explode('&', $parsedUrl['query']);
$arrayedQueryParams = [];
foreach ($queryParams as $theQueryParam) {
[$key, $value] = explode('=', $theQueryParam);
if ((int) $value === 38) {
continue;
}
if (is_numeric($value)) {
$value = (int) $value;
}
$arrayedQueryParams[$key] = $value;
}
uasort(
$arrayedQueryParams,
'string_num_sort'
);
$arrayedQueryParams['path'] = $parsedUrl['path'];
return sprintf(
'%s://%s/?%s',
$parsedUrl['scheme'],
$parsedUrl['host'],
http_build_query($arrayedQueryParams)
);
}
function string_num_sort($a, $b): int {
if (!is_numeric($a) && is_numeric($b)) {
return 1;
}
if (is_numeric($a) && !is_numeric($b)) {
return -1;
}
return $a <=> $b;
}
print_r(formatUrl('https://www.mydomain.com/controller/test.php?a=45&b=38&c=0&d=12&e=309&f=38&g=zed456&h=test2'));