From e1ebdf113b18233d896cca5bdc22aa855b0e762f Mon Sep 17 00:00:00 2001 From: Quang Do Date: Fri, 14 Aug 2026 15:02:23 -0600 Subject: [PATCH] feat: add lab --- .devcontainer/Dockerfile | 11 +++ .devcontainer/devcontainer.json | 7 +- .devcontainer/php-memory-limit.ini | 1 + .gitignore | 4 + README.md | 99 +++++++++++++++++--- data/.gitkeep | 0 exercises/01_user_maintenance.php | 31 +++++++ exercises/02_order_processing.php | 70 ++++++++++++++ index.php | 5 - lab.php | 143 +++++++++++++++++++++++++++++ support/Args.php | 35 +++++++ support/Database.php | 22 +++++ support/Schema.php | 66 +++++++++++++ 13 files changed, 474 insertions(+), 20 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/php-memory-limit.ini create mode 100644 .gitignore create mode 100644 data/.gitkeep create mode 100644 exercises/01_user_maintenance.php create mode 100644 exercises/02_order_processing.php delete mode 100644 index.php create mode 100644 lab.php create mode 100644 support/Args.php create mode 100644 support/Database.php create mode 100644 support/Schema.php diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..e356e62 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,11 @@ +ARG VARIANT=8.5-bookworm +FROM mcr.microsoft.com/devcontainers/php:4-${VARIANT} + +RUN apt-get update \ + && export DEBIAN_FRONTEND=noninteractive \ + && apt-get install -y --no-install-recommends libsqlite3-dev \ + && docker-php-ext-install pdo_sqlite \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY php-memory-limit.ini /usr/local/etc/php/conf.d/php-memory-limit.ini diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 00d06f4..9da10c5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,11 @@ { "name": "PHP Technical Assessment", - "image": "mcr.microsoft.com/devcontainers/php:4-8.3-bookworm", + "build": { + "dockerfile": "Dockerfile", + "args": { + "VARIANT": "8.5-bookworm" + } + }, "features": { "ghcr.io/devcontainers/features/github-cli:1": {}, diff --git a/.devcontainer/php-memory-limit.ini b/.devcontainer/php-memory-limit.ini new file mode 100644 index 0000000..6055f64 --- /dev/null +++ b/.devcontainer/php-memory-limit.ini @@ -0,0 +1 @@ +memory_limit = 32M diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a1502b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/data/*.sqlite +/data/*.sqlite-shm +/data/*.sqlite-wal +/data/*.jsonl \ No newline at end of file diff --git a/README.md b/README.md index b349633..0f76de2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -# PHP Technical Assessment - -This repository contains the starter project for the technical interview. +# Senior Interview Lab ## Getting started @@ -8,25 +6,98 @@ This repository contains the starter project for the technical interview. 2. Wait for the Codespace setup to finish. 3. Open a terminal in the Codespace. -Run the starter program: +## Introduction + +This repository contains two independent PHP exercises: + +1. **User maintenance** +2. **Order processing** + +The lab is dependency-free and uses file-backed **SQLite** databases. You do not +need Docker, MySQL, PostgreSQL, Composer, or any third-party packages. + +You can also list the available lab commands: + +```bash +php lab.php help +``` + +## Candidate instructions + +For each exercise: -```sh -php index.php +1. Explain how the existing implementation works. +2. Identify anything you think should be improved. +3. Make appropriate changes while preserving intended behavior. +4. Explain your decisions and trade-offs. + +You are encouraged to use your judgment about what to investigate and how far to refactor the +exercise. You may make code changes wherever appropriate. + +Do not add external dependencies or require additional infrastructure. + +The exercises are independent. Complete them in the order directed by your interviewer. + +## Exercise 1 — User maintenance + +Prepare a fresh user database: + +```bash +php lab.php setup:users ``` -The expected starter output is: +Review and improve: ```text -Hello, World! +code exercises/01_user_maintenance.php ``` -## Coding agent +Run the exercise: + +```bash +php exercises/01_user_maintenance.php +``` -Codex is available from the Codespace terminal: +Inspect the resulting user statuses: -```sh -codex +```bash +php lab.php inspect:users ``` -Follow the interviewer's instructions for the assessment and submission -process. +Re-run the reset command whenever you need to restore the original dataset: + +```bash +php lab.php reset:users +``` + +## Exercise 2 — Order processing + +Prepare a fresh order database: + +```bash +php lab.php setup:orders +``` + +Review and improve: + +```text +code exercises/02_order_processing.php +``` + +Run the exercise: + +```bash +php exercises/02_order_processing.php +``` + +Inspect products, orders, order items, and payments: + +```bash +php lab.php inspect:orders +``` + +Re-run the setup command whenever you need a clean database: + +```bash +php lab.php reset:orders +``` \ No newline at end of file diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/exercises/01_user_maintenance.php b/exercises/01_user_maintenance.php new file mode 100644 index 0000000..1240e88 --- /dev/null +++ b/exercises/01_user_maintenance.php @@ -0,0 +1,31 @@ +query(<<<'SQL' + SELECT * + FROM users + WHERE status = 'active' + AND last_login_at < '2025-08-13' + ORDER BY id +SQL)->fetchAll(); + +foreach ($users as $user) { + $stmt = $pdo->prepare("UPDATE users SET status = 'inactive' WHERE id = ?"); + $stmt->execute([$user['id']]); +} \ No newline at end of file diff --git a/exercises/02_order_processing.php b/exercises/02_order_processing.php new file mode 100644 index 0000000..4f75ce0 --- /dev/null +++ b/exercises/02_order_processing.php @@ -0,0 +1,70 @@ + 1, 'quantity' => 2], + ['product_id' => 2, 'quantity' => 1], + ['product_id' => 3, 'quantity' => 3], +]; + +// Start processing the order +try { + $pdo->exec("INSERT INTO orders(status, total_cents, created_at) VALUES ('pending', 0, datetime('now'))"); + $orderId = (int) $pdo->lastInsertId(); + + $total = 0; + foreach ($items as $item) { + $productStmt = $pdo->prepare('SELECT price_cents, stock FROM products WHERE id = ?'); + $productStmt->execute([$item['product_id']]); + $product = $productStmt->fetch(); + + if (!$product) { + throw new RuntimeException('Product not found'); + } + + if ((int) $product['stock'] < $item['quantity']) { + throw new RuntimeException('Insufficient stock'); + } + + $lineTotal = (int) $product['price_cents'] * $item['quantity']; + $total += $lineTotal; + + $insertItem = $pdo->prepare(<<<'SQL' + INSERT INTO order_items(order_id, product_id, quantity, unit_price_cents) + VALUES (?, ?, ?, ?) + SQL); + $insertItem->execute([$orderId, $item['product_id'], $item['quantity'], $product['price_cents']]); + + $newStock = (int) $product['stock'] - $item['quantity']; + $updateStock = $pdo->prepare('UPDATE products SET stock = ? WHERE id = ?'); + $updateStock->execute([$newStock, $item['product_id']]); + } + + $updateOrder = $pdo->prepare("UPDATE orders SET total_cents = ?, status = 'ready_for_payment' WHERE id = ?"); + $updateOrder->execute([$total, $orderId]); + + $payment = $pdo->prepare("INSERT INTO payments(order_id, amount_cents, status) VALUES (?, ?, 'pending')"); + $payment->execute([$orderId, $total]); + + echo "Order {$orderId} completed successfully.\n"; +} catch (Throwable $e) { + fwrite(STDERR, "FAILED: {$e->getMessage()}\n"); + fwrite(STDERR, "Inspect the DB now. Partial writes may remain.\n"); + exit(1); +} diff --git a/index.php b/index.php deleted file mode 100644 index 773cb8b..0000000 --- a/index.php +++ /dev/null @@ -1,5 +0,0 @@ -beginTransaction(); + $stmt = $pdo->prepare('INSERT INTO users(email, status, last_login_at, profile_blob) VALUES (?, ?, ?, ?)'); + for ($i = 1; $i <= $count; $i++) { + // Roughly 80% are old enough to match the exercise query. + $old = ($i % 5 !== 0); + $date = $old ? '2024-01-01' : '2026-01-01'; + $stmt->execute(["user{$i}@example.test", 'active', $date, $blob]); + if ($i % 10000 === 0) { + echo " {$i}\n"; + } + } + $pdo->commit(); + echo "User DB ready: data/users.sqlite\n"; + break; + } + + case 'inspect:users': { + $pdo = Database::connect(__DIR__ . '/data/users.sqlite'); + $rows = $pdo->query("SELECT status, COUNT(*) AS count FROM users GROUP BY status ORDER BY status")->fetchAll(); + foreach ($rows as $row) { + printf("%-10s %d\n", $row['status'], $row['count']); + } + $eligible = $pdo->query("SELECT COUNT(*) FROM users WHERE status='active' AND last_login_at < '2025-08-13'")->fetchColumn(); + echo "Eligible active users remaining: {$eligible}\n"; + break; + } + + case 'setup:orders': + case 'reset:orders': { + $pdo = Database::connect(__DIR__ . '/data/orders.sqlite'); + Schema::createOrders($pdo); + $stmt = $pdo->prepare('INSERT INTO products(id, name, stock, price_cents) VALUES (?, ?, ?, ?)'); + foreach ([ + [1, 'Keyboard', 10, 5000], + [2, 'Mouse', 10, 2500], + [3, 'USB-C Cable', 10, 1200], + ] as $row) { + $stmt->execute($row); + } + echo "Order DB reset and seeded.\n"; + break; + } + + case 'inspect:orders': { + $pdo = Database::connect(__DIR__ . '/data/orders.sqlite'); + echo "\nPRODUCTS\n"; + foreach ($pdo->query('SELECT * FROM products ORDER BY id') as $row) { + printf(" #%d %-15s stock=%d price=%d\n", $row['id'], $row['name'], $row['stock'], $row['price_cents']); + } + echo "\nORDERS\n"; + $rows = $pdo->query('SELECT * FROM orders ORDER BY id')->fetchAll(); + echo $rows ? '' : " (none)\n"; + foreach ($rows as $row) { + printf(" #%d status=%s total=%d\n", $row['id'], $row['status'], $row['total_cents']); + } + echo "\nORDER ITEMS\n"; + $rows = $pdo->query('SELECT * FROM order_items ORDER BY id')->fetchAll(); + echo $rows ? '' : " (none)\n"; + foreach ($rows as $row) { + printf(" #%d order=%d product=%d qty=%d\n", $row['id'], $row['order_id'], $row['product_id'], $row['quantity']); + } + echo "\nPAYMENTS\n"; + $rows = $pdo->query('SELECT * FROM payments ORDER BY id')->fetchAll(); + echo $rows ? '' : " (none)\n"; + foreach ($rows as $row) { + printf(" #%d order=%d amount=%d status=%s\n", $row['id'], $row['order_id'], $row['amount_cents'], $row['status']); + } + break; + } + + case 'help': + default: + printHelp(); +} diff --git a/support/Args.php b/support/Args.php new file mode 100644 index 0000000..3f92570 --- /dev/null +++ b/support/Args.php @@ -0,0 +1,35 @@ + */ + public static function parse(array $argv): array + { + $out = []; + foreach (array_slice($argv, 1) as $arg) { + if (!str_starts_with($arg, '--')) { + continue; + } + $arg = substr($arg, 2); + if (str_contains($arg, '=')) { + [$key, $value] = explode('=', $arg, 2); + $out[$key] = $value; + } else { + $out[$arg] = true; + } + } + return $out; + } + + public static function int(array $args, string $key, int $default): int + { + return isset($args[$key]) ? (int) $args[$key] : $default; + } + + public static function string(array $args, string $key, string $default): string + { + return isset($args[$key]) ? (string) $args[$key] : $default; + } +} diff --git a/support/Database.php b/support/Database.php new file mode 100644 index 0000000..54350b5 --- /dev/null +++ b/support/Database.php @@ -0,0 +1,22 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + $pdo->exec('PRAGMA foreign_keys = ON'); + $pdo->exec('PRAGMA journal_mode = WAL'); + return $pdo; + } +} diff --git a/support/Schema.php b/support/Schema.php new file mode 100644 index 0000000..412413e --- /dev/null +++ b/support/Schema.php @@ -0,0 +1,66 @@ +exec('DROP TABLE IF EXISTS users'); + $pdo->exec(<<<'SQL' + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + last_login_at TEXT NOT NULL, + profile_blob TEXT NOT NULL + ) + SQL); + $pdo->exec('CREATE INDEX idx_users_status_login ON users(status, last_login_at)'); + } + + public static function createOrders(PDO $pdo): void + { + $pdo->exec('DROP TABLE IF EXISTS payments'); + $pdo->exec('DROP TABLE IF EXISTS order_items'); + $pdo->exec('DROP TABLE IF EXISTS orders'); + $pdo->exec('DROP TABLE IF EXISTS products'); + + $pdo->exec(<<<'SQL' + CREATE TABLE products ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + stock INTEGER NOT NULL CHECK(stock >= 0), + price_cents INTEGER NOT NULL + ) + SQL); + + $pdo->exec(<<<'SQL' + CREATE TABLE orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + status TEXT NOT NULL, + total_cents INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + SQL); + + $pdo->exec(<<<'SQL' + CREATE TABLE order_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL REFERENCES orders(id), + product_id INTEGER NOT NULL REFERENCES products(id), + quantity INTEGER NOT NULL, + unit_price_cents INTEGER NOT NULL + ) + SQL); + + $pdo->exec(<<<'SQL' + CREATE TABLE payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL REFERENCES orders(id), + amount_cents INTEGER NOT NULL, + status TEXT NOT NULL + ) + SQL); + } +}