MiniRelationalDB is an in-memory relational database engine built from scratch in Python 3.13. It supports a deliberately limited SQL-like subset and implements its own tokenizer, recursive descent parser, execution engine, hash indexes, and two equi-join algorithms. The engine uses only the Python standard library.
This is a Computer Science portfolio project focused on correctness, readable algorithms, and measurable execution behavior. It does not wrap an existing SQL engine and does not claim full SQL compatibility.
- Typed tables with
INT,FLOAT,TEXT, andBOOLcolumns. - SQL table creation, batch insertion, and single-column hash index creation.
- Projection, comparison filters,
AND/OR, and parentheses. - Sorting by multiple columns or aggregate expressions, with
ASC/DESC. - Grouping by one or more columns and
COUNT,SUM,AVG,MIN,MAX. - Explicitly selectable nested-loop and hash inner equi-joins, including chains.
- Equality index access with index maintenance after insertion.
- Structured results, ASCII tables, and measured execution counters.
- Example CSV data, behavior tests, and reproducible small benchmarks.
MiniRelationalDB/
├── src/minidb/
│ ├── __init__.py
│ ├── database.py # Catalog and public SQL/API entry points
│ ├── table.py # Append-only row storage and index maintenance
│ ├── schema.py # Column, DataType, Schema, Value, Row
│ ├── errors.py # Public database error hierarchy
│ ├── result.py # QueryResult, ExecutionStats, ASCII formatting
│ ├── examples.py # Typed CSV example loader
│ ├── parser/
│ │ ├── tokenizer.py # Lexical analysis with source positions
│ │ ├── ast.py # Immutable statement/expression nodes
│ │ └── parser.py # Recursive descent parsing
│ ├── execution/
│ │ ├── binding.py # Column resolution and type compatibility
│ │ ├── filtering.py # Predicate evaluation and safe index candidates
│ │ ├── aggregation.py # Manual groups and aggregates
│ │ ├── joins.py # Both join implementations
│ │ └── executor.py # Execution pipeline
│ └── indexes/
│ └── hash_index.py # value -> row IDs
├── data/examples/ # employees.csv, departments.csv, queries.sql
├── tests/ # Model, lexer/parser, queries, joins/indexes
├── benchmarks/
│ ├── benchmark.py
│ └── RESULTS.md # Recorded local run and its methodology
├── main.py
├── pyproject.toml
├── pytest.ini
├── requirements.txt
└── .gitignore
Package directories also contain __init__.py. Generated CSV measurements are
written to benchmarks/results.csv and ignored by Git.
Each Database.execute() call accepts one statement, with an optional final
semicolon. Keywords and unquoted identifiers are case-insensitive; identifiers
are normalized to lowercase. Identifiers use ASCII letters, digits, and
underscores, beginning with a letter or underscore. Reserved keywords cannot be
used as SQL identifiers. String values are case-sensitive.
CREATE TABLE employees (
id INT, name TEXT, department_id INT, salary FLOAT, active BOOL
);
INSERT INTO employees VALUES
(1, 'Alice', 10, 90000, TRUE),
(2, 'O''Brien', 20, 75000.5, FALSE);
CREATE INDEX employees_id ON employees (id);
SELECT name, salary
FROM employees
WHERE salary >= 70000 AND (active = TRUE OR department_id = 20)
ORDER BY salary DESC, name ASC;
SELECT department_id, COUNT(*), AVG(salary)
FROM employees
GROUP BY department_id
ORDER BY AVG(salary) DESC;
SELECT employees.name, departments.name
FROM employees
INNER JOIN departments ON employees.department_id = departments.id;| Construct | Supported behavior |
|---|---|
CREATE TABLE |
Ordered named columns with the four supported types |
INSERT INTO ... VALUES |
One or more complete rows; no column list |
CREATE INDEX name ON table (column) |
One hash index per column; SQL index names unique in the database |
SELECT * |
All columns; * must be the only select item and cannot be grouped |
SELECT col1, col2 |
Qualified or unqualified column references |
| Aggregates | COUNT(*), COUNT(column), SUM, AVG, MIN, MAX |
WHERE |
=, !=, <, <=, >, >=; column/literal or column/column comparisons |
| Boolean expressions | AND binds more tightly than OR; parentheses override precedence |
GROUP BY |
One or more columns; all selected/ordered nonaggregate columns must be grouped |
ORDER BY |
One or more columns/aggregates, including unselected expressions; default ASC |
INNER JOIN ... ON |
One column equality per join, connecting the new table to a previous table |
| Literals | Signed integers, decimals, scientific notation, single-quoted strings, TRUE, FALSE |
| Escaping/comments | SQL doubled apostrophes ('O''Brien') and -- line comments |
Clause order is SELECT, FROM, joins, WHERE, GROUP BY, ORDER BY.
Aliases, quoted identifiers, arithmetic expressions, and other SQL constructs
outside this subset are rejected. Query results have no promised order without
ORDER BY; duplicate rows and duplicate join keys are preserved.
Database owns a table catalog. Each Table has a name, an immutable Schema,
ordered Column definitions, and an internal append-only list of rows.
Row is an explicit type alias for an immutable tuple of typed Python values;
the schema determines each position's meaning. Row IDs are stable list positions.
| Column type | Accepted Python input | Stored value |
|---|---|---|
INT |
int, excluding bool |
Arbitrary-precision Python integer |
FLOAT |
Finite int or float, excluding bool |
Python float; integer conversion may round |
TEXT |
str |
String |
BOOL |
bool |
Boolean |
Insertion validates arity and types before changing storage. A batch with an
invalid row inserts nothing and leaves indexes unchanged; this is validation
behavior, not a transaction system. There are no NULL input values, uniqueness
constraints, primary keys, or foreign keys. Integer/float comparisons are allowed;
other comparisons require the same type, so TRUE cannot silently match integer
1. Text ordering follows Python's case-sensitive string ordering.
Public table.rows returns an immutable snapshot, which costs O(n) to create;
the executor uses iter_rows() so indexed access never copies the whole table.
Catalog and index mappings are read-only views.
SQL string -> tokenizer -> tokens -> recursive descent parser -> immutable AST
-> executor: bind names/types -> choose scan or hash-index candidates
-> joins -> WHERE -> grouping/aggregation -> projection/sorting
-> QueryResult(columns, rows, stats)
The tokenizer recognizes keywords, identifiers, numeric/string literals, operators, punctuation, comments, and end-of-input. Tokens retain source positions for useful syntax errors. The recursive descent parser constructs dataclass AST nodes, including statements, column references, comparisons, boolean expressions, aggregates, and order keys. It rejects trailing tokens and multiple statements. It has no database/catalog access and calls no SQL parsing library or SQL engine.
The executor binds every column and checks comparison/aggregate types before reading rows, including when tables or intermediate results are empty. Qualified names resolve join collisions; ambiguous unqualified names raise an error.
Filters run through the engine's own predicate evaluator. Grouping builds a
dictionary from key tuples to member rows, then manually evaluates each required
aggregate per group. Equal aggregate expressions are reused within each group.
Grouping without aggregates returns one row per group. SUM and AVG require
numeric columns; MIN and MAX also support text and booleans.
A global aggregate over empty input returns one row: COUNT is 0 and other
aggregates are Python None, formatted as NULL. Grouped empty input returns zero
rows. This output convention does not add SQL NULL literals or three-valued logic.
Sorting uses Python's stable sort, applying keys from right to left for mixed
directions. Projection and sort keys are retained separately, so an unselected
column can be used in ORDER BY. Rows and intermediate query results live in
memory; there is no streaming result API or disk spill.
nested_loop_join() materializes the right relation, then explicitly compares
each left key with every right key. Each matching pair produces a concatenated
row. The engine counts each key comparison; for a single join this is n * m.
result = db.execute(sql, join_strategy="nested_loop")hash_join() builds a Python dictionary of key-to-row-list buckets from the
right relation, then probes it once for each left row. Lists within buckets
preserve all duplicates, including many-to-many matches. It does not use a table's
persistent hash index. The default join strategy is "hash"; the chosen strategy
applies to every join in a statement. There is no smaller-side or cost-based choice.
result = db.execute(sql, join_strategy="hash")HashIndex maps column values to lists of stable row IDs. Building an index scans
existing rows; later inserts append each new row ID to the appropriate bucket.
Indexes support nonunique values.
db.get_table("employees").create_hash_index("id")
indexed = db.execute("SELECT name FROM employees WHERE id = 42")
scanned = db.execute("SELECT name FROM employees WHERE id = 42", use_indexes=False)
assert indexed.rows == scanned.rows
print(indexed.stats)For a single-table query, the executor uses the first available indexed
column/literal equality that is required by the predicate. It supports reversed
equality (42 = id) and safe AND conjuncts. All residual predicates are still
evaluated on candidates. It never extracts an equality from inside an OR branch:
id = 1 OR id = 2 scans, while (id = 1 OR id = 2) AND department_id = 10 may use
an index on department_id. Range predicates and column/column equalities scan.
No index intersection, index union, or index-based join is implemented.
Every query returns its own counters in result.stats:
| Counter | What is actually counted |
|---|---|
rows_scanned |
Base-table rows fetched, including index candidates, summed across tables |
indexes_used |
Qualified column names for actual indexed access, or an empty list |
join_strategy |
Selected algorithm when a join runs, otherwise None |
join_comparisons |
Explicit pairwise equality checks in nested-loop joins |
hash_build_rows |
Rows inserted into hash-join buckets |
hash_probes |
Left rows probing hash-join buckets |
rows_scanned does not count repeated comparisons against a materialized right
relation, and it is not a general CPU-work counter. Hash joins leave
join_comparisons at zero because dictionary-internal collision comparisons are
not instrumented. For chained joins, join-work counters sum across stages.
Let n and m be input row counts, k matching index candidates, r output
rows, g groups, a distinct aggregate expressions, and s sort keys. Bounds
below assume fixed row width, bounded-size keys, and normal hash behavior.
| Operation | Time | Additional storage / nuance |
|---|---|---|
| Tokenize/parse | O(L) for input length L in ordinary inputs | O(L) tokens/AST; nested conditions use recursion |
| Full equality scan | O(n) | Plus O(r) materialized results |
| Hash index construction | O(n) average | O(n) row IDs and buckets |
| Hash index lookup | O(1) average bucket access + O(k) candidate handling | Lookup copies k row IDs; residual filtering/projection still costs work |
| Insert with h indexes | O(h) average index maintenance per row | Plus schema validation and amortized row append |
| Nested-loop join | O(n * m + r) | O(m) right-side buffer, plus materialized output |
| Hash join | O(n + m + r) average | O(m) right-side buckets, plus materialized output |
| Grouping and aggregation | O(n * (1 + a)) average | O(n + g) group membership storage, plus group outputs |
| ORDER BY | O(s * r log r) upper bound | Stable sorting and retained output/sort keys |
Hash operations are not worst-case constant time: collisions and pathological key distributions can degrade them. A many-to-many join can emit O(n * m) rows even with a hash join, so output cost cannot be omitted. Long strings, large integers, wide rows, and many predicates add comparison, hashing, validation, or copying cost. Binding and query parsing also add overhead to short queries.
Use Python 3.13 or newer. From the repository root, on PowerShell:
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements.txtOn macOS/Linux, activate with source .venv/bin/activate instead. If PowerShell
activation is unavailable, use .\.venv\Scripts\python.exe directly in place of
python in the following commands.
The engine has zero runtime dependencies. pytest is the only direct test
dependency; its own dependencies are installed by pip. The demo, benchmarks, and
pytest configuration load the local src tree directly. To import minidb from
other scripts or a Python shell, optionally install the package:
python -m pip install -e .Editable packaging uses setuptools as a build dependency, not a query dependency.
python main.pyThe demo loads data/examples/employees.csv and departments.csv, displays
selection, filtering, sorting, and all five aggregates, then runs both joins. It
checks their equality and verifies indexed lookup before and after insertion.
Additional example statements are in data/examples/queries.sql; execute them
individually rather than passing the whole file to execute().
After an editable installation, the Python API is:
from minidb import Column, Database
from minidb.errors import MiniDBError
db = Database()
employees = db.create_table("employees", [
Column("id", "INT"), Column("name", "TEXT"), Column("salary", "FLOAT")
])
employees.insert((1, "Alice", 90000))
db.execute("INSERT INTO employees VALUES (2, 'Bob', 60000)")
result = db.execute("SELECT name, salary FROM employees WHERE salary > 70000")
assert result.columns == ("name", "salary")
assert result.rows == (("Alice", 90000.0),)
print(result)
print(result.stats)
try:
db.execute("SELECT missing FROM employees")
except MiniDBError as exc:
print(exc)DDL results provide message; insert results also provide affected_rows.
Expected errors have dedicated subclasses for syntax, duplicate/unknown tables,
unknown/ambiguous columns, invalid types, duplicate indexes, and invalid queries.
python -m pytest -q
python main.py
python benchmarks/benchmark.py
python -m pip checkTests cover typed insertion and failed-batch atomic validation; tokenizer tokens,
escaping and AST structure; predicates and precedence; projection and sorting;
all aggregates and empty input; name/type validation; both join algorithms,
duplicate keys and chained joins; index creation, maintenance, actual candidate
reads, scan equivalence, and unsafe OR cases. Deterministic generated joins are
checked against an explicit reference result, preserving duplicate multiplicity.
No test uses another SQL engine as an oracle.
When Git is initialized, also run git diff --check. Repository creation,
commits, and pushes are not performed by project scripts.
python benchmarks/benchmark.py --repeats 7 --output benchmarks/results.csv- Search tables contain 1,000, 10,000, and 50,000 unique IDs. The same equality query selects an existing middle ID with forced scan and with indexed access.
- Join sizes are 100 x 50, 500 x 250, and 1,000 x 500. Right-side keys occur twice, exercising duplicate handling and yielding twice the left input row count.
- Each algorithm gets one untimed warm-up and seven timed repetitions by default.
time.perf_counter()measures each completeDatabase.execute()call, including parsing, binding, execution counters, projection, and result materialization. - CSV values report the median, input/output row counts, actual access counters, Python/platform versions, and a separate one-shot index-construction time. Data generation, table insertion, persistent index building, result comparison, printing, and CSV writing are excluded from query timing. Each hash-join timing includes rebuilding its temporary hash table.
- The script checks identical results for both searches, multiset equality for both joins, expected result sizes, and actual scan/index access paths.
These are small local benchmarks, not throughput or production performance claims. Short timings are noisy, order/cache effects remain, and results depend on hardware, Python, and system activity. The script does not benchmark memory or worst-case hash collisions. See the recorded run for actual measurements rather than universal speedup claims.
- All storage is in memory and lost when the process exits; CSVs are examples, not persistent database storage.
- No updates, deletes, table/index dropping, schema alteration, or constraints.
- No SQL
NULLinputs, aliases, self-joins, outer/cross joins,DISTINCT,HAVING,LIMIT,LIKE,IN,NOT, expressions, subqueries, or complete SQL grammar. - No transactions, ACID guarantees, concurrent access, WAL, crash recovery, permissions, authentication, network server, frontend, web API, or distribution.
- No B-tree, range index, advanced optimizer, predicate pushdown across joins, persistent-index joins, disk spill, or streaming results.
- Float arithmetic uses ordinary binary floats; rounding is expected. Nonfinite
input and overflowing aggregate sums are rejected.
AVGalso rejects an integer result that cannot be represented as a float; an overflowing float intermediate sum is rejected even if its mathematical average would be finite. - Deeply nested expressions are subject to Python's recursion limit. There is no untrusted-input resource budget or production hardening.
The implemented and tested scope supports this portfolio statement:
Built a relational database engine from scratch with SQL-style query parsing, filtering, grouping, hash indexing, and nested-loop and hash-join execution.