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

---

## Solution notes

### Interpretation of the source material

The implementation keeps the source contract as written, with the following
explicit interpretations:

- `employee_id` is used throughout. The CSV header and every downstream task use
that spelling, so the single `exployee_id` occurrence is treated as a typo.
- `invoice_ammount` is deliberately preserved because it is the column name in
the required `INVOICE` schema.
- The cycle report returns one row for each employee who is actually in a
manager cycle. Each row includes a deterministic loop that starts and ends
with that employee; employees that merely lead into a cycle are excluded.
- Invoice due dates are calculated relative to Trino's `current_date` at load
time. They are not frozen to dates from the development machine.
- An invoice due in `N` months is paid in `N` month-end instalments, using
offsets `0` through `N - 1`. This starts payments at the end of the current
month and matches the supplied Catering Plus example.
- Receipt and invoice item descriptions are intentionally not loaded because
the required schemas do not define columns for them.

The same decisions are repeated as focused comments in the SQL files where
they affect behavior.

### Payment rounding

Money is converted to integer cents before an invoice is divided into monthly
instalments. When division leaves a remainder, one extra cent is assigned to
each of the earliest instalments until the remainder is exhausted. For example,
`4000.00` over six payments becomes four payments of `666.67` followed by two
payments of `666.66`. This policy:

- keeps every instalment within one cent of every other instalment;
- is deterministic; and
- reconciles every invoice exactly, without floating-point arithmetic.

Instalments are grouped by supplier and payment month before the running
balance is calculated, ensuring that a supplier receives at most one payment
per month.

### Run the SQL

The validated environment uses Trino `483`, the `memory.default` catalog and
schema, and a UTC Trino session. The container image used for validation was:

```text
trinodb/trino@sha256:db58cc93e593a2706553745f276bb119c9810e69918be56ecde088ba7ccb0534
```

From a WSL shell in the repository root, start Trino as described above, wait
until it is ready, then run:

```sh
docker exec -i sexi-silverbullet trino \
--catalog memory --schema default --timezone UTC < create_employees.sql

docker exec -i sexi-silverbullet trino \
--catalog memory --schema default --timezone UTC < create_expenses.sql

docker exec -i sexi-silverbullet trino \
--catalog memory --schema default --timezone UTC < create_invoices.sql

docker exec -i sexi-silverbullet trino \
--catalog memory --schema default --timezone UTC < find_manager_cycles.sql

docker exec -i sexi-silverbullet trino \
--catalog memory --schema default --timezone UTC < calculate_largest_expensors.sql

docker exec -i sexi-silverbullet trino \
--catalog memory --schema default --timezone UTC < generate_supplier_payment_plans.sql
```

The loaders are rerunnable: each drops and recreates only the tables it owns.
No command uses `--ignore-errors`, so a SQL failure produces a nonzero exit.

### Expected baseline results

- `EMPLOYEE`: 9 rows.
- `EXPENSE`: 7 rows totaling `2412.00`.
- `SUPPLIER`: 5 rows.
- `INVOICE`: 6 rows totaling `20000.00`.
- Manager cycle members: employee IDs `1`, `2`, and `4`.
- Largest-expensors report: Alex Jacobson, managed by Umberto Torrielli, with
`1682.00`.
- Catering Plus payments: `1500.00`, `1500.00`, and `500.00`, with balances
`2000.00`, `500.00`, and `0.00`.

### Automated tests

The pytest suite executes the submitted SQL files rather than reproducing their
queries in Python. It resets its Trino fixtures before and after each
state-mutating test and checks:

- exact baseline rows, columns, and decimal values;
- aggregation of multiple expenses;
- strict exclusion at exactly `1000.00`;
- inclusion immediately above the threshold;
- descending ordering and deterministic tie-breaking;
- exclusion of employees below the threshold or without expenses;
- loaded table schemas, fixture data, and reference integrity; and
- lightweight cycle-report and payment-plan solution oracles.

See [`tests/README.md`](tests/README.md) for the isolated environment and exact
commands. Tests must run serially because they share Trino's in-memory
`memory.default` schema. Missing files, connection/setup failures, SQL errors,
and assertion failures are intentionally not skipped and produce a nonzero
pytest exit.

### Known limitations

- Invoice dates are intentionally relative to the Trino session date, so
reloading in a later month produces later due and payment dates.
- The cycle query sets `max_recursion_depth` to five because that is the longest
non-repeating manager chain in the supplied data. Raise the documented limit
deliberately if the query is reused with a deeper hierarchy.
- The Memory connector is ephemeral. Restarting the container clears the data,
and the loader files must be rerun.
- The test suite is deliberately serial because parallel tests would contend
for the same in-memory tables.
36 changes: 36 additions & 0 deletions calculate_largest_expensors.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
USE memory.default;

-- Aggregate the expense fact table once, using DECIMAL arithmetic throughout,
-- before joining employee names or applying the strict reporting threshold.
WITH expense_totals AS (
SELECT
employee_id,
SUM(
unit_price * CAST(quantity AS DECIMAL(3, 0))
) AS total_expensed_amount
FROM EXPENSE
GROUP BY employee_id
),
offenders AS (
SELECT
employee_id,
total_expensed_amount
FROM expense_totals
WHERE total_expensed_amount > DECIMAL '1000.00'
)
SELECT
offenders.employee_id,
CONCAT(employee.first_name, ' ', employee.last_name) AS employee_name,
employee.manager_id,
CONCAT(manager.first_name, ' ', manager.last_name) AS manager_name,
offenders.total_expensed_amount
FROM offenders
JOIN EMPLOYEE AS employee
ON employee.employee_id = offenders.employee_id
-- Source-data validation requires every manager reference to resolve, so an
-- inner self-join keeps malformed hierarchy data from masquerading as valid.
JOIN EMPLOYEE AS manager
ON manager.employee_id = employee.manager_id
ORDER BY
offenders.total_expensed_amount DESC,
offenders.employee_id ASC;
31 changes: 31 additions & 0 deletions create_employees.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
USE memory.default;

-- The CSV and every downstream task use employee_id; the README's isolated
-- exployee_id spelling is treated as a typo rather than a separate column.
DROP TABLE IF EXISTS EMPLOYEE;

CREATE TABLE EMPLOYEE (
employee_id TINYINT,
first_name VARCHAR,
last_name VARCHAR,
job_title VARCHAR,
manager_id TINYINT
);

INSERT INTO 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));
25 changes: 25 additions & 0 deletions create_expenses.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
USE memory.default;

DROP TABLE IF EXISTS EXPENSE;

CREATE TABLE EXPENSE (
employee_id TINYINT,
unit_price DECIMAL(8, 2),
quantity TINYINT
);

-- Receipt descriptions are intentionally omitted because the required EXPENSE
-- schema has no description or item column.
INSERT INTO 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));
67 changes: 67 additions & 0 deletions create_invoices.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
USE memory.default;

-- Rebuild the supplier and invoice fixture exactly from the supplied invoice
-- files. INVOICE is dropped first so this remains safe if relationships are
-- enforced when the scripts are later moved from the Memory connector.
DROP TABLE IF EXISTS invoice;
DROP TABLE IF EXISTS supplier;

CREATE TABLE supplier (
supplier_id TINYINT,
name VARCHAR
);

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

-- Supplier IDs are assigned only after names have been deduplicated. Applying
-- ROW_NUMBER before DISTINCT would incorrectly create two Catering Plus IDs.
INSERT INTO supplier (supplier_id, name)
WITH invoice_supplier_names (supplier_name) AS (
VALUES
('Party Animals'),
('Catering Plus'),
('Catering Plus'),
('Dave''s Discos'),
('Entertainment tonight'),
('Ice Ice Baby')
),
distinct_supplier_names AS (
SELECT DISTINCT supplier_name
FROM invoice_supplier_names
)
SELECT
CAST(ROW_NUMBER() OVER (ORDER BY supplier_name) AS TINYINT),
supplier_name
FROM distinct_supplier_names;

-- invoice_ammount intentionally preserves the required schema spelling.
-- Invoice item descriptions are intentionally omitted because neither required
-- table has a column for them. Due dates remain runtime-relative: "N months
-- from now" is the final day of month N in Trino's current session calendar.
INSERT INTO invoice (supplier_id, invoice_ammount, due_date)
WITH invoice_source (supplier_name, invoice_ammount, months_until_due) AS (
VALUES
('Party Animals', DECIMAL '6000.00', CAST(3 AS BIGINT)),
('Catering Plus', DECIMAL '2000.00', CAST(2 AS BIGINT)),
('Catering Plus', DECIMAL '1500.00', CAST(3 AS BIGINT)),
('Dave''s Discos', DECIMAL '500.00', CAST(1 AS BIGINT)),
('Entertainment tonight', DECIMAL '6000.00', CAST(3 AS BIGINT)),
('Ice Ice Baby', DECIMAL '4000.00', CAST(6 AS BIGINT))
)
SELECT
supplier.supplier_id,
invoice_source.invoice_ammount,
last_day_of_month(
date_add(
'month',
invoice_source.months_until_due,
date_trunc('month', current_date)
)
)
FROM invoice_source
JOIN supplier
ON supplier.name = invoice_source.supplier_name;
45 changes: 45 additions & 0 deletions find_manager_cycles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
USE memory.default;

-- The source instruction's "exployee_id" is treated as an isolated typo:
-- employee_id is the CSV header and the name used by every downstream task.
-- Return one deterministic row per actual cycle member, not per employee whose
-- manager chain merely feeds into a cycle.
--
-- Trino expands recursive CTEs while planning. The supplied hierarchy's
-- longest non-repeating manager chain contains five employees, so constraining
-- this session to five recursive steps avoids compiling unused stages. Raise
-- the setting deliberately if this query is reused with a deeper hierarchy.
SET SESSION max_recursion_depth = 5;

WITH RECURSIVE manager_walk (
start_employee_id,
next_manager_id,
path
) AS (
SELECT
employee_id,
manager_id,
ARRAY[employee_id]
FROM employee

UNION ALL

SELECT
manager_walk.start_employee_id,
next_manager.manager_id,
manager_walk.path || next_manager.employee_id
FROM manager_walk
JOIN employee AS next_manager
ON next_manager.employee_id = manager_walk.next_manager_id
-- Stop before revisiting any node. Cycle closure is detected below without
-- allowing an unbounded recursive walk.
WHERE NOT contains(manager_walk.path, next_manager.employee_id)
)
SELECT
manager_walk.start_employee_id AS employee_id,
manager_walk.path || manager_walk.start_employee_id AS loop
FROM manager_walk
-- A start employee is a cycle member only if its own walk closes back to it.
-- Feeders may encounter another repeated node but cannot satisfy this predicate.
WHERE manager_walk.next_manager_id = manager_walk.start_employee_id
ORDER BY employee_id;
Loading