Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.pyc
.pytest_cache/
65 changes: 63 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ Let's get the repo forked and Trino installed. We can download a shiny new copy

1. Fork this github repo.
2. Download and install docker desktop. Instructions can be found [here](https://www.docker.com/products/docker-desktop/).
3. Start the SExI container, by running `docker run --name=sexi-silverbullet -d trinodb/trino` at a terminal
3. Start the SExI container, by running `docker run --name=sexi-silverbullet -p 8080:8080 -d trinodb/trino` at a
terminal. This publishes trino on `localhost:8080`, so you can connect to it from your own code later on. (If port 8080
is already taken on your machine, use something like `-p 8081:8080` instead.)
4. You can reset the database at any time by running `docker restart sexi-silverbullet` at a terminal
5. You can access a trino SQL shell using `docker exec -it sexi-silverbullet trino`. Here you can run any SQL commands
you like, as long as they're supported by trino.
Expand Down Expand Up @@ -136,9 +138,68 @@ the file is a valid SQL file.

#

Hold on. Before we ship any of this, Tim just asked me — with a straight face — how we know it's all correct. And, well.
He traded the last finance system for mince pies, so I'd rather not repeat that conversation. Let's prove our work.

Don't worry, we don't need to test everything — just pick one thing and do it properly. Let's prove out the expenses
report, since that's the one the Chief of Staff is going to be reading.

1. Write automated tests in Python that validate your `calculate_largest_expensors.sql` query. Please use `pytest` —
it's what we use here, and it keeps things consistent for whoever reviews your work.
2. Put your tests in a `tests/` directory, along with a `tests/requirements.txt` and a line or two on how to run them.
The [`trino`](https://pypi.org/project/trino/) package gives you a client for talking to the database on
`localhost:8080`.

Just the one query is plenty — we're far more interested in *how* you approach testing than in how much you cover. Some
things you might think about:
- What counts as correct here? Consider the amounts, the ordering, and who does and doesn't show up in the results.
- How does your test get the database into a known state before it asserts anything? `docker restart
sexi-silverbullet` resets everything, and your `create_*.sql` files will reload it.
- If something is awkward to test because of the way the SQL is written, say so in your README. That's a useful
finding, not a failure.

#

Awesome! Finance will be so happy with us! Our tech guys are still rebuilding the production database, so upload your
code to github and take the rest of the afternoon off to ~~recover~~ relax!

1. Upload all of your code to your forked github repo in a new branch, and create a pull request with your changes into
the main branch.
the qa_test branch.
2. Share your branch name with your recruiting contact, who will be in touch regarding the results of your test.

## Solution

Tested with Trino 483. `exployee_id` is treated as a typo because the CSV and
reports use `employee_id`. `invoice_ammount` is kept because that exact name is
part of the required schema.

The loaders are repeatable. Invoice dates use Trino's `current_date`. The last
payment absorbs the rounding remainder, so every invoice closes to the cent.
The cycle report returns cycle members only, not employees who lead into one.

Start Trino:

```bash
docker run --name=sexi-silverbullet -p 8080:8080 -d trinodb/trino:483
```

Load the data:

```bash
docker exec -i sexi-silverbullet trino < create_employees.sql
docker exec -i sexi-silverbullet trino < create_expenses.sql
docker exec -i sexi-silverbullet trino < create_invoices.sql
```

Run the reports:

```bash
docker exec -i sexi-silverbullet trino < calculate_largest_expensors.sql
docker exec -i sexi-silverbullet trino < find_manager_cycles.sql
docker exec -i sexi-silverbullet trino < generate_supplier_payment_plans.sql
```

The expense tests rebuild their two tables before each case. They verify the
exact result, aggregation, the strict `> 1000` boundary, manager lookup, and
stable ordering for equal totals.
See [`tests/README.md`](tests/README.md) for the commands.
22 changes: 22 additions & 0 deletions calculate_largest_expensors.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
WITH expense_total AS (
SELECT
employee_id,
SUM(unit_price * quantity) AS total_expensed_amount
FROM memory.default.expense
GROUP BY employee_id
)
SELECT
employee.employee_id,
employee.first_name || ' ' || employee.last_name AS employee_name,
employee.manager_id,
manager.first_name || ' ' || manager.last_name AS manager_name,
expense_total.total_expensed_amount
FROM expense_total
JOIN memory.default.employee AS employee
ON employee.employee_id = expense_total.employee_id
JOIN memory.default.employee AS manager
ON manager.employee_id = employee.manager_id
WHERE expense_total.total_expensed_amount > DECIMAL '1000.00'
ORDER BY
expense_total.total_expensed_amount DESC,
employee.employee_id;
22 changes: 22 additions & 0 deletions create_employees.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
DROP TABLE IF EXISTS memory.default.employee;

CREATE TABLE memory.default.employee (
employee_id TINYINT,
first_name VARCHAR,
last_name VARCHAR,
job_title VARCHAR,
manager_id TINYINT
);

INSERT INTO memory.default.employee
(employee_id, first_name, last_name, job_title, manager_id)
VALUES
(CAST(1 AS TINYINT), 'Ian', 'James', 'CEO', CAST(4 AS TINYINT)),
(CAST(2 AS TINYINT), 'Umberto', 'Torrielli', 'CSO', CAST(1 AS TINYINT)),
(CAST(3 AS TINYINT), 'Alex', 'Jacobson', 'MD EMEA', CAST(2 AS TINYINT)),
(CAST(4 AS TINYINT), 'Darren', 'Poynton', 'CFO', CAST(2 AS TINYINT)),
(CAST(5 AS TINYINT), 'Tim', 'Beard', 'MD APAC', CAST(2 AS TINYINT)),
(CAST(6 AS TINYINT), 'Gemma', 'Dodd', 'COS', CAST(1 AS TINYINT)),
(CAST(7 AS TINYINT), 'Lisa', 'Platten', 'CHR', CAST(6 AS TINYINT)),
(CAST(8 AS TINYINT), 'Stefano', 'Camisaca', 'GM Activation', CAST(2 AS TINYINT)),
(CAST(9 AS TINYINT), 'Andrea', 'Ghibaudi', 'MD NAM', CAST(2 AS TINYINT));
17 changes: 17 additions & 0 deletions create_expenses.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
DROP TABLE IF EXISTS memory.default.expense;

CREATE TABLE memory.default.expense (
employee_id TINYINT,
unit_price DECIMAL(8, 2),
quantity TINYINT
);

INSERT INTO memory.default.expense (employee_id, unit_price, quantity)
VALUES
(CAST(3 AS TINYINT), DECIMAL '6.50', CAST(14 AS TINYINT)),
(CAST(3 AS TINYINT), DECIMAL '11.00', CAST(20 AS TINYINT)),
(CAST(3 AS TINYINT), DECIMAL '22.00', CAST(18 AS TINYINT)),
(CAST(3 AS TINYINT), DECIMAL '13.00', CAST(75 AS TINYINT)),
(CAST(9 AS TINYINT), DECIMAL '300.00', CAST(1 AS TINYINT)),
(CAST(4 AS TINYINT), DECIMAL '40.00', CAST(9 AS TINYINT)),
(CAST(2 AS TINYINT), DECIMAL '17.50', CAST(4 AS TINYINT));
37 changes: 37 additions & 0 deletions create_invoices.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
DROP TABLE IF EXISTS memory.default.invoice;
DROP TABLE IF EXISTS memory.default.supplier;

CREATE TABLE memory.default.supplier (
supplier_id TINYINT,
name VARCHAR
);

-- IDs follow the alphabetical order of the source company names.
INSERT INTO memory.default.supplier (supplier_id, name)
VALUES
(CAST(1 AS TINYINT), 'Catering Plus'),
(CAST(2 AS TINYINT), 'Dave''s Discos'),
(CAST(3 AS TINYINT), 'Entertainment tonight'),
(CAST(4 AS TINYINT), 'Ice Ice Baby'),
(CAST(5 AS TINYINT), 'Party Animals');

CREATE TABLE memory.default.invoice (
supplier_id TINYINT,
invoice_ammount DECIMAL(8, 2),
due_date DATE
);

INSERT INTO memory.default.invoice (supplier_id, invoice_ammount, due_date)
VALUES
(CAST(5 AS TINYINT), DECIMAL '6000.00',
last_day_of_month(date_add('month', 3, current_date))),
(CAST(1 AS TINYINT), DECIMAL '2000.00',
last_day_of_month(date_add('month', 2, current_date))),
(CAST(1 AS TINYINT), DECIMAL '1500.00',
last_day_of_month(date_add('month', 3, current_date))),
(CAST(2 AS TINYINT), DECIMAL '500.00',
last_day_of_month(date_add('month', 1, current_date))),
(CAST(3 AS TINYINT), DECIMAL '6000.00',
last_day_of_month(date_add('month', 3, current_date))),
(CAST(4 AS TINYINT), DECIMAL '4000.00',
last_day_of_month(date_add('month', 6, current_date)));
30 changes: 30 additions & 0 deletions find_manager_cycles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
SET SESSION max_recursion_depth = 5;

WITH RECURSIVE manager_path (
start_employee_id,
manager_id,
path
) AS (
SELECT
employee_id,
manager_id,
ARRAY[employee_id]
FROM memory.default.employee

UNION ALL

SELECT
manager_path.start_employee_id,
manager.manager_id,
manager_path.path || manager.employee_id
FROM manager_path
JOIN memory.default.employee AS manager
ON manager.employee_id = manager_path.manager_id
WHERE NOT contains(manager_path.path, manager.employee_id)
)
SELECT
start_employee_id AS employee_id,
path || start_employee_id AS loop
FROM manager_path
WHERE manager_id = start_employee_id
ORDER BY employee_id;
73 changes: 73 additions & 0 deletions generate_supplier_payment_plans.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
WITH
today AS (
SELECT date_trunc('month', current_date) AS month_start
),
invoice_terms AS (
SELECT
invoice.supplier_id,
invoice.invoice_ammount,
today.month_start,
date_diff(
'month',
today.month_start,
date_trunc('month', invoice.due_date)
) AS months_to_pay
FROM memory.default.invoice AS invoice
CROSS JOIN today
),
invoice_payments AS (
SELECT
invoice_terms.supplier_id,
payment_no,
last_day_of_month(
date_add('month', payment_no, invoice_terms.month_start)
) AS payment_date,
CASE
WHEN payment_no = invoice_terms.months_to_pay - 1 THEN
invoice_terms.invoice_ammount
- ROUND(
invoice_terms.invoice_ammount
/ CAST(invoice_terms.months_to_pay AS DECIMAL(8, 0)),
2
) * CAST(invoice_terms.months_to_pay - 1 AS DECIMAL(8, 0))
ELSE ROUND(
invoice_terms.invoice_ammount
/ CAST(invoice_terms.months_to_pay AS DECIMAL(8, 0)),
2
)
END AS payment_amount
FROM invoice_terms
CROSS JOIN UNNEST(
sequence(CAST(0 AS BIGINT), invoice_terms.months_to_pay - 1)
) AS payment_numbers (payment_no)
),
monthly_supplier_payment AS (
SELECT
supplier_id,
payment_date,
SUM(payment_amount) AS payment_amount
FROM invoice_payments
GROUP BY supplier_id, payment_date
)
SELECT
monthly_supplier_payment.supplier_id,
supplier.name AS supplier_name,
CAST(monthly_supplier_payment.payment_amount AS DECIMAL(12, 2))
AS payment_amount,
CAST(
SUM(monthly_supplier_payment.payment_amount) OVER (
PARTITION BY monthly_supplier_payment.supplier_id
) - SUM(monthly_supplier_payment.payment_amount) OVER (
PARTITION BY monthly_supplier_payment.supplier_id
ORDER BY monthly_supplier_payment.payment_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)
AS DECIMAL(12, 2)
) AS balance_outstanding,
monthly_supplier_payment.payment_date
FROM monthly_supplier_payment
JOIN memory.default.supplier AS supplier
ON supplier.supplier_id = monthly_supplier_payment.supplier_id
ORDER BY
monthly_supplier_payment.supplier_id,
monthly_supplier_payment.payment_date;
13 changes: 13 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Running the expense report tests

Start Trino on `localhost:8080`, then create a virtual environment from the
repository root:

```bash
python3 -m venv .venv
.venv/bin/pip install -r tests/requirements.txt
.venv/bin/pytest -q
```

The tests rebuild `EMPLOYEE` and `EXPENSE` before every case. Do not run them
against a shared Trino memory catalogue.
2 changes: 2 additions & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pytest==9.1.1
trino==0.338.0
83 changes: 83 additions & 0 deletions tests/test_largest_expensors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from decimal import Decimal
from pathlib import Path
from typing import Iterator, cast

import pytest
import trino
from trino.dbapi import Connection, Cursor


ROOT = Path(__file__).resolve().parents[1]
ReportRow = tuple[int, str, int, str, Decimal]
Report = tuple[tuple[str, ...], list[ReportRow]]


def execute_file(cursor: Cursor, filename: str) -> Cursor:
sql = (ROOT / filename).read_text(encoding="utf-8")
for statement in sql.rstrip(";\n").split(";"):
cursor.execute(statement.strip())
return cursor


@pytest.fixture(scope="session")
def trino_connection() -> Iterator[Connection]:
connection = trino.dbapi.connect(
host="localhost",
port=8080,
user="sexi_test",
catalog="memory",
schema="default",
)
yield connection
connection.close()


@pytest.fixture()
def expense_database(trino_connection: Connection) -> Cursor:
cursor = trino_connection.cursor()
execute_file(cursor, "create_employees.sql")
execute_file(cursor, "create_expenses.sql")
return cursor


def read_report(cursor: Cursor) -> Report:
execute_file(cursor, "calculate_largest_expensors.sql")
rows = cast(list[ReportRow], [tuple(row) for row in cursor.fetchall()])
columns = tuple(column[0] for column in cursor.description)
return columns, rows


def test_report_from_source_files(expense_database: Cursor) -> None:
columns, rows = read_report(expense_database)

assert columns == (
"employee_id",
"employee_name",
"manager_id",
"manager_name",
"total_expensed_amount",
)
assert rows == [
(3, "Alex Jacobson", 2, "Umberto Torrielli", Decimal("1682.00"))
]


def test_report_contract(expense_database: Cursor) -> None:
expense_database.execute(
"""
INSERT INTO memory.default.expense VALUES
(CAST(2 AS TINYINT), DECIMAL '1930.00', CAST(1 AS TINYINT)),
(CAST(5 AS TINYINT), DECIMAL '500.00', CAST(2 AS TINYINT)),
(CAST(5 AS TINYINT), DECIMAL '111.11', CAST(1 AS TINYINT)),
(CAST(7 AS TINYINT), DECIMAL '1000.00', CAST(1 AS TINYINT)),
(CAST(8 AS TINYINT), DECIMAL '1111.11', CAST(1 AS TINYINT))
"""
)

_, rows = read_report(expense_database)
assert rows == [
(2, "Umberto Torrielli", 1, "Ian James", Decimal("2000.00")),
(3, "Alex Jacobson", 2, "Umberto Torrielli", Decimal("1682.00")),
(5, "Tim Beard", 2, "Umberto Torrielli", Decimal("1111.11")),
(8, "Stefano Camisaca", 2, "Umberto Torrielli", Decimal("1111.11")),
]