Skip to content
Open
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
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,74 @@ 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.
2. Share your branch name with your recruiting contact, who will be in touch regarding the results of your test.

---

## Setup and Execution

### Prerequisites

- Docker Desktop installed and running
- Python 3.9+ with pip

### Start the Trino container

The container must be started with host port mapping so the Python test client can connect:

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

Wait ~30 seconds for startup, then verify:

```bash
docker exec -it sexi-silverbullet trino --execute "SELECT 'ready'"
```

To reset the in-memory database at any time, restart the container:

```bash
docker restart sexi-silverbullet
```

### Execute the SQL files

Run in dependency order (employees before expenses, supplier before invoice):

```bash
cat create_employees.sql | docker exec -i sexi-silverbullet trino --catalog memory --schema default
cat create_expenses.sql | docker exec -i sexi-silverbullet trino --catalog memory --schema default
cat create_invoices.sql | docker exec -i sexi-silverbullet trino --catalog memory --schema default
cat find_manager_cycles.sql | docker exec -i sexi-silverbullet trino --catalog memory --schema default
cat calculate_largest_expensors.sql | docker exec -i sexi-silverbullet trino --catalog memory --schema default
cat generate_supplier_payment_plans.sql | docker exec -i sexi-silverbullet trino --catalog memory --schema default
```

### Run the automated tests

```bash
pip install -r tests/requirements.txt
pytest tests/ -v
```

The suite covers two SQL solutions across 21 tests:

| Test file | SQL under test |
|---|---|
| `test_calculate_largest_expensors.py` | `calculate_largest_expensors.sql` |
| `test_find_manager_cycles.py` | `find_manager_cycles.sql` |

Each test controls the relevant tables directly so tests are fully isolated. Shared helpers (`run_sql_file`, `load_employees`, `column_names`) live in `conftest.py`. If the container is not reachable, pytest fails immediately with a connection error.

### Catalog and schema

All tables use `memory.default`. The in-memory catalog is reset whenever the container restarts; re-run the SQL files after any restart.

### Key assumptions

- `"N months from now"` means the last calendar day of the month that is N months after the current date (`last_day_of_month(date_add('month', N, current_date))`). This matches the Catering Plus sample in the README exactly.
- `invoice_ammount` (double-m) is the authoritative column name as specified in the README.
- Payment instalments are truncated (not rounded) to the nearest cent; the final instalment absorbs any remainder so each invoice sums to zero exactly.
- Supplier IDs are assigned by case-insensitive alphabetical order of company name.
- The manager-cycle query requires Trino 415 or later (supports `WITH RECURSIVE`). The `trinodb/trino:latest` image satisfies this requirement.
- The Trino Python client does not accept a trailing semicolon; `run_query()` strips it before executing the SQL file.
20 changes: 20 additions & 0 deletions calculate_largest_expensors.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
SELECT
e.employee_id,
e.first_name || ' ' || e.last_name AS employee_name,
e.manager_id,
m.first_name || ' ' || m.last_name AS manager_name,
CAST(SUM(ex.unit_price * ex.quantity) AS DECIMAL(12, 2)) AS total_expensed_amount
FROM memory.default.expense AS ex
JOIN memory.default.employee AS e ON e.employee_id = ex.employee_id
LEFT JOIN memory.default.employee AS m ON m.employee_id = e.manager_id
GROUP BY
e.employee_id,
e.first_name,
e.last_name,
e.manager_id,
m.first_name,
m.last_name
HAVING SUM(ex.unit_price * ex.quantity) > DECIMAL '1000.00'
ORDER BY
total_expensed_amount DESC,
e.employee_id ASC;
24 changes: 24 additions & 0 deletions create_employees.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
USE memory.default;

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
(1, 'Ian', 'James', 'CEO', 4),
(2, 'Umberto', 'Torrielli', 'CSO', 1),
(3, 'Alex', 'Jacobson', 'MD EMEA', 2),
(4, 'Darren', 'Poynton', 'CFO', 2),
(5, 'Tim', 'Beard', 'MD APAC', 2),
(6, 'Gemma', 'Dodd', 'COS', 1),
(7, 'Lisa', 'Platten', 'CHR', 6),
(8, 'Stefano', 'Camisaca', 'GM Activation', 2),
(9, 'Andrea', 'Ghibaudi', 'MD NAM', 2);
20 changes: 20 additions & 0 deletions create_expenses.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
USE memory.default;

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
(3, DECIMAL '6.50', 14), -- Alex Jacobson: drinkies.txt
(3, DECIMAL '11.00', 20), -- Alex Jacobson: drinks.txt
(3, DECIMAL '22.00', 18), -- Alex Jacobson: drinkss.txt
(3, DECIMAL '13.00', 75), -- Alex Jacobson: duh_i_think_i_got_too_many.txt
(9, DECIMAL '300.00', 1), -- Andrea Ghibaudi: i_got_lost_on_the_way_home_and_now_im_in_mexico.txt
(4, DECIMAL '40.00', 9), -- Darren Poynton: ubers.txt
(2, DECIMAL '17.50', 4); -- Umberto Torrielli: we_stopped_for_a_kebabs.txt
35 changes: 35 additions & 0 deletions create_invoices.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
USE memory.default;

DROP TABLE IF EXISTS memory.default.invoice;
DROP TABLE IF EXISTS memory.default.supplier;

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

-- Suppliers assigned IDs in case-insensitive alphabetical order of name.
INSERT INTO memory.default.supplier (supplier_id, name)
VALUES
(1, 'Catering Plus'),
(2, 'Dave''s Discos'),
(3, 'Entertainment tonight'),
(4, 'Ice Ice Baby'),
(5, 'Party Animals');

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

-- Due dates computed as the last day of the month N months from today.
-- "N months from now" means the month-end N calendar months after current_date.
INSERT INTO memory.default.invoice (supplier_id, invoice_ammount, due_date)
VALUES
(1, DECIMAL '2000.00', last_day_of_month(date_add('month', 2, current_date))), -- brilliant_bottles.txt
(1, DECIMAL '1500.00', last_day_of_month(date_add('month', 3, current_date))), -- crazy_catering.txt
(2, DECIMAL '500.00', last_day_of_month(date_add('month', 1, current_date))), -- disco_dj.txt
(3, DECIMAL '6000.00', last_day_of_month(date_add('month', 3, current_date))), -- excellent_entertainment.txt
(4, DECIMAL '4000.00', last_day_of_month(date_add('month', 6, current_date))), -- fantastic_ice_sculptures.txt
(5, DECIMAL '6000.00', last_day_of_month(date_add('month', 3, current_date))); -- awesome_animals.txt
48 changes: 48 additions & 0 deletions find_manager_cycles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
-- Detect manager approval cycles using recursive CTE traversal.
-- Each traversal starts from one employee and follows manager_id links,
-- recording visited nodes to prevent infinite recursion.
-- A cycle is detected when the next step would revisit a node already in the path.
-- Only the traversal starting from the minimum employee_id in the cycle is kept
-- each cycle is reported exactly once.
WITH RECURSIVE manager_chain (start_id, current_id, next_id, visited) AS (
-- Base: begin a separate traversal from every employee that has a manager.
SELECT
employee_id AS start_id,
employee_id AS current_id,
manager_id AS next_id,
ARRAY[employee_id] AS visited
FROM memory.default.employee
WHERE manager_id IS NOT NULL

UNION ALL

-- Recursive: advance one step along the manager chain, stopping before
-- revisiting any node already in the visited array.
SELECT
mc.start_id,
mc.next_id AS current_id,
e.manager_id AS next_id,
mc.visited || ARRAY[mc.next_id] AS visited
FROM manager_chain AS mc
JOIN memory.default.employee AS e ON e.employee_id = mc.next_id
WHERE mc.next_id IS NOT NULL
AND NOT contains(mc.visited, mc.next_id)
),
canonical_cycle (cycle_members, cycle_string) AS (
-- Rows where the chain has returned to its own start_id complete a full cycle.
-- array_min(visited) = start_id ensures each cycle is represented only once,
-- anchored to its smallest member.
SELECT
visited AS cycle_members,
array_join(visited, ' -> ') AS cycle_string
FROM manager_chain
WHERE next_id IS NOT NULL
AND next_id = start_id
AND start_id = array_min(visited)
)
SELECT
member AS employee_id,
cycle_string AS cycle
FROM canonical_cycle
CROSS JOIN UNNEST(cycle_members) AS t(member)
ORDER BY employee_id;
67 changes: 67 additions & 0 deletions generate_supplier_payment_plans.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
WITH
-- For each invoice, compute N payment periods and generate one row per period (0 through N-1).
-- N = number of months between end of current month and the invoice due date.
invoice_periods AS (
SELECT
i.supplier_id,
i.invoice_ammount,
date_diff('month', last_day_of_month(current_date), i.due_date) AS n_periods,
period
FROM memory.default.invoice AS i
CROSS JOIN UNNEST(SEQUENCE(
CAST(0 AS BIGINT),
date_diff('month', last_day_of_month(current_date), i.due_date) - 1
)) AS t(period)
),
-- Calculate the payment amount for each invoice in each period.
-- Base amount is truncated (floored) to the cent; the final period absorbs the remainder
-- so the invoice total is paid exactly.
invoice_payments AS (
SELECT
supplier_id,
last_day_of_month(date_add('month', CAST(period AS INTEGER), current_date)) AS payment_date,
CASE
WHEN period = n_periods - 1
THEN invoice_ammount
- CAST(n_periods - 1 AS DECIMAL(10, 0))
* TRUNCATE(invoice_ammount / CAST(n_periods AS DECIMAL(10, 0)), 2)
ELSE TRUNCATE(invoice_ammount / CAST(n_periods AS DECIMAL(10, 0)), 2)
END AS period_payment
FROM invoice_periods
),
-- Aggregate payments across all invoices for each supplier into one row per month.
monthly_payments AS (
SELECT
supplier_id,
payment_date,
SUM(period_payment) AS payment_amount
FROM invoice_payments
GROUP BY supplier_id, payment_date
),
-- Total invoice balance per supplier, used to compute balance_outstanding.
supplier_totals AS (
SELECT
supplier_id,
SUM(invoice_ammount) AS total_balance
FROM memory.default.invoice
GROUP BY supplier_id
)
SELECT
mp.supplier_id,
s.name AS supplier_name,
CAST(mp.payment_amount AS DECIMAL(10, 2)) AS payment_amount,
-- Running balance: total owed minus cumulative payments up to and including this row.
CAST(
st.total_balance
- SUM(mp.payment_amount) OVER (
PARTITION BY mp.supplier_id
ORDER BY mp.payment_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
)
AS DECIMAL(10, 2)
) AS balance_outstanding,
mp.payment_date
FROM monthly_payments AS mp
JOIN memory.default.supplier AS s ON s.supplier_id = mp.supplier_id
JOIN supplier_totals AS st ON st.supplier_id = mp.supplier_id
ORDER BY mp.supplier_id ASC, mp.payment_date ASC;
88 changes: 88 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Shared fixtures and helpers for the SExI test suite."""

import pathlib
import re

import trino
import pytest

REPO = pathlib.Path(__file__).parent.parent


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture(scope="session")
def trino_conn():
"""Open a single Trino connection for the test session.

Requires the SExI Trino container running with port 8080 mapped to the host.
Fails with a connection error if the container is not reachable.
"""
conn = trino.dbapi.connect(
host="localhost",
port=8080,
user="pytest",
catalog="memory",
schema="default",
)
yield conn
conn.close()


@pytest.fixture
def cursor(trino_conn):
"""Return a fresh DB-API cursor for one test."""
cur = trino_conn.cursor()
yield cur
cur.close()


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def run_sql_file(cursor, path: pathlib.Path) -> None:
"""Execute every statement in a SQL file."""
for stmt in path.read_text().split(";"):
# Splitting on ";" can leave trailing fragments that are pure comments;
# strip line comments before deciding whether the segment is executable.
if re.sub(r"--[^\n]*", "", stmt).strip():
cursor.execute(stmt.strip())


def load_employees(cursor, rows: list[tuple]) -> None:
"""Drop, create, and populate EMPLOYEE with the given rows.

Args:
rows: Tuples of (employee_id, first_name, last_name, job_title, manager_id).
manager_id may be None.
"""
cursor.execute("DROP TABLE IF EXISTS memory.default.employee")
cursor.execute("""
CREATE TABLE memory.default.employee (
employee_id TINYINT,
first_name VARCHAR,
last_name VARCHAR,
job_title VARCHAR,
manager_id TINYINT
)
""")
if rows:
value_literals = []
for emp_id, first, last, title, mgr_id in rows:
mgr = "NULL" if mgr_id is None else str(int(mgr_id))
value_literals.append(
f"({int(emp_id)}, '{first}', '{last}', '{title}', {mgr})"
)
cursor.execute(
"INSERT INTO memory.default.employee "
"(employee_id, first_name, last_name, job_title, manager_id) VALUES "
+ ", ".join(value_literals)
)


def column_names(cursor) -> list[str]:
"""Return column names from the last executed query."""
return [d[0] for d in cursor.description]
2 changes: 2 additions & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
trino>=0.320.0
pytest>=7.4
Loading