Функціональна СУБД

Світ баз даних давно захоплений реляційними СУБД, у яких використовується мова SQL. Настільки сильно, що різновиди, що з'являються, називають NoSQL. Їм удалося відбити собі певне місце на цьому ринку, але реляційні СУБД помирати не збираються, і продовжують активно використовуватись для своїх цілей.

У цій статті хочу описати концепцію функціональної бази даних. Для кращого розуміння, я робитиму це шляхом порівняння з класичною реляційною моделлю. Як приклади будуть використовуватися завдання з різних тестів SQL, знайдені в інтернеті.

Запровадження

Реляційні бази даних оперують таблицями та полями. У функціональній базі даних замість них використовуватимуться класи та функції відповідно. Поле таблиці з N ключами буде представлено як функція від N параметрів. Замість зв'язків між таблицями використовуватимуться функції, які повертають об'єкти класу, який йде зв'язок. Замість JOIN використовуватиметься композиція функцій.

Перед тим, як перейти безпосередньо до завдань, опишу завдання доменної логіки. Для DDL я використовуватиму синтаксис PostgreSQL. Для функціональної синтаксис.

Таблиці та поля

Простий об'єкт Sku з полями найменування та ціна:

Реляційна

CREATE TABLE Sku
(
    id bigint NOT NULL,
    name character varying(100),
    price numeric(10,5),
    CONSTRAINT id_pkey PRIMARY KEY (id)
)

функціональна

CLASS Sku;
name = DATA STRING[100] (Sku);
price = DATA NUMERIC[10,5] (Sku);

Ми оголошуємо дві функціїякі приймають на вхід один параметр Sku, і повертають примітивний тип.

Передбачається, що у функціональній СУБД у кожного об'єкта буде якийсь внутрішній код, який автоматично генерується, і до якого за потреби можна звернутися.

Задамо ціну для товару/магазину/постачальника. Вона може змінюватися згодом, тому додамо до таблиці поле час. Оголошення таблиць для довідників у реляційній базі даних пропущу, щоб скоротити код:

Реляційна

CREATE TABLE prices
(
    skuId bigint NOT NULL,
    storeId bigint NOT NULL,
    supplierId bigint NOT NULL,
    dateTime timestamp without time zone,
    price numeric(10,5),
    CONSTRAINT prices_pkey PRIMARY KEY (skuId, storeId, supplierId)
)

функціональна

CLASS Sku;
CLASS Store;
CLASS Supplier;
dateTime = DATA DATETIME (Sku, Store, Supplier);
price = DATA NUMERIC[10,5] (Sku, Store, Supplier);

Індекси

Для останнього прикладу побудуємо індекс за всіма ключами та датою, щоб можна було швидко знаходити ціну на певний час.

Реляційна

CREATE INDEX prices_date
    ON prices
    (skuId, storeId, supplierId, dateTime)

функціональна

INDEX Sku sk, Store st, Supplier sp, dateTime(sk, st, sp);

Завдання

Почнемо з відносно простих завдань, взятих із відповідної статті на Хабрі.

Спочатку оголосимо доменну логіку (для реляційної бази це зроблено у наведеній статті).

CLASS Department;
name = DATA STRING[100] (Department);

CLASS Employee;
department = DATA Department (Employee);
chief = DATA Employee (Employee);
name = DATA STRING[100] (Employee);
salary = DATA NUMERIC[14,2] (Employee);

завдання 1.1

Вивести список співробітників, які отримують заробітну плату більшу, ніж у безпосереднього керівника.

Реляційна

select a.*
from   employee a, employee b
where  b.id = a.chief_id
and    a.salary > b.salary

функціональна

SELECT name(Employee a) WHERE salary(a) > salary(chief(a));

завдання 1.2

Вивести список співробітників, які отримують максимальну заробітну плату у своєму відділі

Реляційна

select a.*
from   employee a
where  a.salary = ( select max(salary) from employee b
                    where  b.department_id = a.department_id )

функціональна

maxSalary 'Максимальная зарплата' (Department s) = 
    GROUP MAX salary(Employee e) IF department(e) = s;
SELECT name(Employee a) WHERE salary(a) = maxSalary(department(a));

// или если "заинлайнить"
SELECT name(Employee a) WHERE 
    salary(a) = maxSalary(GROUP MAX salary(Employee e) IF department(e) = department(a));

Обидві реалізації еквівалентні. Для першого випадку в реляційній базі можна використовувати CREATE VIEW, який таким чином спочатку вважатиме для конкретного відділу максимальну зарплату в ньому. Надалі я для наочності використовуватиму перший випадок, тому що він краще відображає рішення.

завдання 1.3

Вивести список ID відділів, кількість співробітників у яких не перевищує 3 осіб.

Реляційна

select department_id
from   employee
group  by department_id
having count(*) <= 3

функціональна

countEmployees 'Количество сотрудников' (Department d) = 
    GROUP SUM 1 IF department(Employee e) = d;
SELECT Department d WHERE countEmployees(d) <= 3;

завдання 1.4

Вивести список співробітників, які мають призначеного керівника, працюючого у тому самому відділі.

Реляційна

select a.*
from   employee a
left   join employee b on (b.id = a.chief_id and b.department_id = a.department_id)
where  b.id is null

функціональна

SELECT name(Employee a) WHERE NOT (department(chief(a)) = department(a));

завдання 1.5

Знайти список ID відділів із максимальною сумарною зарплатою співробітників.

Реляційна

with sum_salary as
  ( select department_id, sum(salary) salary
    from   employee
    group  by department_id )
select department_id
from   sum_salary a       
where  a.salary = ( select max(salary) from sum_salary )

функціональна

salarySum 'Максимальная зарплата' (Department d) = 
    GROUP SUM salary(Employee e) IF department(e) = d;
maxSalarySum 'Максимальная зарплата отделов' () = 
    GROUP MAX salarySum(Department d);
SELECT Department d WHERE salarySum(d) = maxSalarySum();

Перейдемо до більш складних завдань з іншого статті. У ній є докладний аналіз того, як реалізовувати це завдання на MS SQL.

завдання 2.1

Які продавці продали 1997 року понад 30 штук товару №1?

Доменна логіка (як і раніше на РСУБД пропускаємо оголошення):

CLASS Employee 'Продавец';
lastName 'Фамилия' = DATA STRING[100] (Employee);

CLASS Product 'Продукт';
id = DATA INTEGER (Product);
name = DATA STRING[100] (Product);

CLASS Order 'Заказ';
date = DATA DATE (Order);
employee = DATA Employee (Order);

CLASS Detail 'Строка заказа';

order = DATA Order (Detail);
product = DATA Product (Detail);
quantity = DATA NUMERIC[10,5] (Detail);

Реляційна

select LastName
from Employees as e
where (
  select sum(od.Quantity)
  from [Order Details] as od
  where od.ProductID = 1 and od.OrderID in (
    select o.OrderID
    from Orders as o
    where year(o.OrderDate) = 1997 and e.EmployeeID = o.EmployeeID)
) > 30

функціональна

sold (Employee e, INTEGER productId, INTEGER year) = 
    GROUP SUM quantity(OrderDetail d) IF 
        employee(order(d)) = e AND 
        id(product(d)) = productId AND 
        extractYear(date(order(d))) = year;
SELECT lastName(Employee e) WHERE sold(e, 1, 1997) > 30;

завдання 2.2

Для кожного покупця (ім'я, прізвище) знайти два товари (назву), на які покупець витратив найбільше грошей у 1997 році.

Розширюємо доменну логіку з попереднього прикладу:

CLASS Customer 'Клиент';
contactName 'ФИО' = DATA STRING[100] (Customer);

customer = DATA Customer (Order);

unitPrice = DATA NUMERIC[14,2] (Detail);
discount = DATA NUMERIC[6,2] (Detail);

Реляційна

SELECT ContactName, ProductName FROM (
SELECT c.ContactName, p.ProductName
, ROW_NUMBER() OVER (
    PARTITION BY c.ContactName
    ORDER BY SUM(od.Quantity * od.UnitPrice * (1 - od.Discount)) DESC
) AS RatingByAmt
FROM Customers c
JOIN Orders o ON o.CustomerID = c.CustomerID
JOIN [Order Details] od ON od.OrderID = o.OrderID
JOIN Products p ON p.ProductID = od.ProductID
WHERE YEAR(o.OrderDate) = 1997
GROUP BY c.ContactName, p.ProductName
) t
WHERE RatingByAmt < 3

функціональна

sum (Detail d) = quantity(d) * unitPrice(d) * (1 - discount(d));
bought 'Купил' (Customer c, Product p, INTEGER y) = 
    GROUP SUM sum(Detail d) IF 
        customer(order(d)) = c AND 
        product(d) = p AND 
        extractYear(date(order(d))) = y;
rating 'Рейтинг' (Customer c, Product p, INTEGER y) = 
    PARTITION SUM 1 ORDER DESC bought(c, p, y), p BY c, y;
SELECT contactName(Customer c), name(Product p) WHERE rating(c, p, 1997) < 3;

Оператор PARTITION працює за наступним принципом: він підсумовує вираз, вказаний після SUM (тут 1), всередині зазначених груп (тут Customer і Year, але може бути будь-який вираз), сортуючи всередині груп за виразами, вказаними в ORDER (тут bought, а якщо рівні, то за внутрішнім кодом продукту).

завдання 2.3

Скільки товарів необхідно замовити у постачальників для виконання поточних замовлень.

Знову розширюємо доменну логіку:

CLASS Supplier 'Поставщик';
companyName = DATA STRING[100] (Supplier);

supplier = DATA Supplier (Product);

unitsInStock 'Остаток на складе' = DATA NUMERIC[10,3] (Product);
reorderLevel 'Норма продажи' = DATA NUMERIC[10,3] (Product);

Реляційна

select s.CompanyName, p.ProductName, sum(od.Quantity) + p.ReorderLevel — p.UnitsInStock as ToOrder
from Orders o
join [Order Details] od on o.OrderID = od.OrderID
join Products p on od.ProductID = p.ProductID
join Suppliers s on p.SupplierID = s.SupplierID
where o.ShippedDate is null
group by s.CompanyName, p.ProductName, p.UnitsInStock, p.ReorderLevel
having p.UnitsInStock < sum(od.Quantity) + p.ReorderLevel

функціональна

orderedNotShipped 'Заказано, но не отгружено' (Product p) = 
    GROUP SUM quantity(OrderDetail d) IF product(d) = p;
toOrder 'К заказу' (Product p) = orderedNotShipped(p) + reorderLevel(p) - unitsInStock(p);
SELECT companyName(supplier(Product p)), name(p), toOrder(p) WHERE toOrder(p) > 0;

Завдання із зірочкою

І останній приклад особисто від мене. Є логіка соціальної мережі. Люди можуть дружити один з одним і подобається один одному. З погляду функціональної бази даних це буде виглядати так:

CLASS Person;
likes = DATA BOOLEAN (Person, Person);
friends = DATA BOOLEAN (Person, Person);

Потрібно знайти можливих кандидатів на дружбу. Більше формалізовано потрібно знайти всіх людей A, B, C таких, що A товаришує з B, а B товаришує з C, A подобається C, але A не товаришує з C.
З точки зору функціональної бази даних запит буде виглядати так:

SELECT Person a, Person b, Person c WHERE 
    likes(a, c) AND NOT friends(a, c) AND 
    friends(a, b) AND friends(b, c);

Читачеві пропонується самостійно вирішити це завдання на SQL. Передбачається, що друзів набагато менше, ніж тих, хто подобається. Тому вони лежать у окремих таблицях. У разі успішного вирішення є також завдання із двома зірочками. У ній дружба не симетрична. На функціональній базі даних це виглядатиме так:

SELECT Person a, Person b, Person c WHERE 
    likes(a, c) AND NOT friends(a, c) AND 
    (friends(a, b) OR friends(b, a)) AND 
    (friends(b, c) OR friends(c, b));

UPD: вирішення задачі з першою та другою зірочкою від dss_kalika:

SELECT 
   pl.PersonAID
  ,pf.PersonAID
  ,pff.PersonAID
FROM Persons                 AS p
--Лайки                      
JOIN PersonRelationShip      AS pl ON pl.PersonAID = p.PersonID
                                  AND pl.Relation  = 'Like'
--Друзья                     
JOIN PersonRelationShip      AS pf ON pf.PersonAID = p.PersonID 
                                  AND pf.Relation = 'Friend'
--Друзья Друзей              
JOIN PersonRelationShip      AS pff ON pff.PersonAID = pf.PersonBID
                                   AND pff.PersonBID = pl.PersonBID
                                   AND pff.Relation = 'Friend'
--Ещё не дружат         
LEFT JOIN PersonRelationShip AS pnf ON pnf.PersonAID = p.PersonID
                                   AND pnf.PersonBID = pff.PersonBID
                                   AND pnf.Relation = 'Friend'
WHERE pnf.PersonAID IS NULL 

;WITH PersonRelationShipCollapsed AS (
  SELECT pl.PersonAID
        ,pl.PersonBID
        ,pl.Relation 
  FROM #PersonRelationShip      AS pl 
  
  UNION 

  SELECT pl.PersonBID AS PersonAID
        ,pl.PersonAID AS PersonBID
        ,pl.Relation
  FROM #PersonRelationShip      AS pl 
)
SELECT 
   pl.PersonAID
  ,pf.PersonBID
  ,pff.PersonBID
FROM #Persons                      AS p
--Лайки                      
JOIN PersonRelationShipCollapsed  AS pl ON pl.PersonAID = p.PersonID
                                 AND pl.Relation  = 'Like'                                  
--Друзья                          
JOIN PersonRelationShipCollapsed  AS pf ON pf.PersonAID = p.PersonID 
                                 AND pf.Relation = 'Friend'
--Друзья Друзей                   
JOIN PersonRelationShipCollapsed  AS pff ON pff.PersonAID = pf.PersonBID
                                 AND pff.PersonBID = pl.PersonBID
                                 AND pff.Relation = 'Friend'
--Ещё не дружат                   
LEFT JOIN PersonRelationShipCollapsed AS pnf ON pnf.PersonAID = p.PersonID
                                   AND pnf.PersonBID = pff.PersonBID
                                   AND pnf.Relation = 'Friend'
WHERE pnf.[PersonAID] IS NULL 

Висновок

Слід зазначити, що наведений синтаксис мови — це лише один із варіантів реалізації наведеної концепції. За основу було взято саме SQL, і метою було, щоб він був максимально схожий на нього. Звичайно, комусь можуть не сподобатися назви ключових слів, регістри слів та інше. Тут головне — саме концепція. За бажанням можна зробити і C++, і Python подібний до синтаксису.

Описана концепція бази даних, на мій погляд, має наступні переваги:

  • Простота. Це щодо суб'єктивний показник, який не є очевидним на простих випадках. Але якщо подивитися складніші випадки (наприклад, завдання із зірочками), то, на мій погляд, писати такі запити значно простіше.
  • Інкапсуляція. У деяких прикладах я оголошував проміжні функції (наприклад, проданий, куплений і т.д.), від яких будувалися наступні функції. Це дозволяє при необхідності змінювати логіку певних функцій без зміни логіки, що залежать від них. Наприклад, можна зробити, щоб продаж проданий вважалися від зовсім інших об'єктів, причому решта логіки не зміниться. Так, у РСУБД це можна реалізувати за допомогою CREATE VIEW. Але якщо всю логіку писати таким чином, вона виглядатиме не дуже читабельною.
  • Відсутність семантичного розриву. Така база даних оперує функціями та класами (замість таблиць та полів). Так само, як і в класичному програмуванні (якщо вважати, що метод - це функція з першим параметром у вигляді класу, до якого він належить). Відповідно, «подружити» з універсальними мовами програмування має бути значно простіше. З іншого боку, ця концепція дозволяє реалізовувати набагато складніші функції. Наприклад, можна вбудовувати до бази даних оператори виду:

    CONSTRAINT sold(Employee e, 1, 2019) > 100 IF name(e) = 'Петя' MESSAGE  'Что-то Петя продает слишком много одного товара в 2019 году';

  • Успадкування та поліморфізм. У функціональній базі даних можна ввести множинне спадкування через конструкції CLASS ClassP: Class1, Class2 і реалізувати множинний поліморфізм. Як саме, можливо, напишу в наступних статтях.

Незважаючи на те, що це лише концепція, у нас є вже деяка реалізація на Java, яка транслює всю функціональну логіку в реляційну логіку. Плюс до неї красиво прикручена логіка уявлень та багато чого іншого, завдяки чому виходить ціла платформа. По суті, ми використовуємо РСУБД (поки що тільки PostgreSQL) як «віртуальну машину». За такої трансляції іноді виникають проблеми, оскільки оптимізатор запитів РСУБД не знає певної статистики, яку знає ФСУБД. Теоретично, можна реалізувати систему управління базою даних, яка використовуватиме як сховища певну структуру, адаптовану саме під функціональну логіку.

Джерело: habr.com

Додати коментар або відгук