Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Implementation Plan: ORM annotations, exceptions, metadata, and auto-DDL

**Date:** 2026-04-12
**Status:** planned
**Source spec:** `docs/superpowers/specs/2026-04-11-orm-annotations-exceptions-auto-ddl-design.md`

## Goal

Re-implement the approved ORM feature set in a controlled way after reverting the previous implementation commit.

## Why this plan exists

The previous implementation was reverted so the feature can be rebuilt with clearer checkpoints, documented decisions, and review-friendly commits.

## Constraints

- Work must happen on a dedicated feature branch, not directly on `main`
- The approved spec is the source of truth unless a new documented decision updates it
- Significant implementation decisions should be written down here as the work progresses

## Delivery phases

### Phase 1: annotations and validation
- Add `@Entity`, `@Table`, `@Id`, `@Column`, `@Transient`
- Add `EntityValidator`
- Add tests for validation rules

### Phase 2: metadata model
- Add `ColumnMetadata`
- Add `EntityMetadata`
- Implement structural metadata extraction and per-instance value copying
- Add tests for annotation mapping and transient exclusion

### Phase 3: parser refactor
- Change parser contract to consume `EntityMetadata`
- Refactor create, create-table, and find parsers
- Implement type-aware quoting and DDL constraint assembly
- Update parser tests

### Phase 4: exceptions and translation
- Add ORM exception hierarchy
- Add `ExceptionTranslator`
- Add tests for SQLState translation

### Phase 5: DB integration and config
- Add `OrmConfig` and `DdlAuto`
- Add metadata cache and initialized-entity tracking in `DB`
- Integrate validation, exception translation, lazy auto-DDL, and the `Find` cursor fix
- Update integration tests

### Phase 6: polish and review
- Verify package structure matches the spec
- Run tests
- Review for spec drift
- Prepare PR notes from this plan

## Initial decisions

1. Reverted commit `b7ebaad` before re-implementation to restore a clean baseline.
2. The next implementation should be split into small reviewable commits by phase.
3. This document is the running log for implementation decisions and review notes.
4. Entity validation remains runtime-based for now, executed at the start of ORM operations in `DB`, because that keeps the public API simple and matches the current reflection-driven architecture.
5. We will keep custom annotations with JPA-style names and semantics (`@Entity`, `@Id`, etc.) instead of depending on JPA itself. That gives us familiar rules, including the strong expectation that an entity must have exactly one id, without adding an external persistence dependency.

## Review checklist

- [ ] validation rules match the spec exactly
- [ ] `@Transient` fields are excluded from all SQL operations
- [ ] parser API is metadata-based end-to-end
- [ ] numeric values are not quoted in INSERT SQL
- [ ] `CreateTable` uses `IF NOT EXISTS`
- [ ] auto-DDL runs only on `Insert`
- [ ] `Find` does not skip the first row
- [ ] tests cover config, metadata, validator, translator, and integration flow

## Follow-up notes

### 2026-04-12
- Future exploration: evaluate whether some validation can move earlier than operation-time, for example through explicit metadata bootstrap or startup-time schema registration.
- Current choice: keep validation at runtime in `DB` and enforce the JPA-like invariant that every ORM-managed entity must declare exactly one `@Id`.
- Integration tests are required, not optional. If Docker is unavailable locally, that is an environment problem to fix, not a reason to silently skip coverage.
- CI must run `mvn test` on pull requests with Docker available so Testcontainers-backed coverage remains enforced.

Add dated notes below when implementation decisions change or additional tradeoffs appear.
24 changes: 24 additions & 0 deletions docs/write-plans/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Write Plans

This folder records implementation plans, technical decisions, and delivery notes for feature work.

## Purpose

Before implementing a feature, create a dated plan document here that captures:

- the problem being solved
- the approved spec or source of truth
- implementation phases
- design decisions and tradeoffs
- testing approach
- follow-up items

## Naming

Use descriptive dated filenames, for example:

- `2026-04-12-orm-annotations-exceptions-auto-ddl-implementation-plan.md`

## Working rule

Feature work should start from a dedicated feature branch, and the related write plan should be created or updated before major code changes.
138 changes: 77 additions & 61 deletions src/main/java/org/example/database/DB.java
Original file line number Diff line number Diff line change
@@ -1,92 +1,108 @@
package org.example.database;

import org.example.database.annotation.Entity;
import org.example.database.annotation.Table;
import org.example.database.exception.*;
import org.example.database.parser.*;
import org.example.database.reflection.*;
import java.lang.reflect.Field;
import java.sql.*;
import java.util.*;
import org.example.database.exception.EntityNotFoundException;
import org.example.database.exception.ExceptionTranslator;
import org.example.database.parser.CreateStatementParser;
import org.example.database.parser.CreateTableStatementParser;
import org.example.database.parser.FindStatementParser;
import org.example.database.parser.Parser;
import org.example.database.reflection.EntityMetadata;
import org.example.database.reflection.EntityValidator;
import org.example.database.reflection.ReflectionUtils;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

public class DB {
private Connection connection;
private OrmConfig ormConfig;
private Set<Class<?>> initializedEntities = new HashSet<>();
private Map<Class<?>, EntityMetadata> schemaCache = new HashMap<>();
private final Statement statement;
private final Connection connection;
private final OrmConfig ormConfig;
private final Map<Class<?>, EntityMetadata> schemaCache = new HashMap<>();
private final Set<Class<?>> initializedEntities = new HashSet<>();

private DB(Connection connection) {
public DB(Connection connection, Statement statement, OrmConfig ormConfig) {
this.connection = connection;
this.ormConfig = OrmConfig.load();
this.statement = statement;
this.ormConfig = ormConfig;
}

public static DB Open(String url, Driver driver, String username, String password) throws SQLException {
Connection connection = DriverManager.getConnection(url, username, password);
return new DB(connection);
public static DB Open(String url, Driver driver, String user, String password) throws SQLException, ClassNotFoundException {
Class.forName(driver.getDriverName());

Connection connection = DriverManager.getConnection(url, user, password);
return new DB(connection, connection.createStatement(), OrmConfig.load());
}

public void CreateTable(Object entity) {
public void Insert(Object object) {
Class<?> entityClass = object.getClass();
EntityValidator.validate(entityClass);
EntityMetadata structuralMetadata = getOrCreateMetadata(entityClass);
initializeEntityIfNeeded(entityClass, structuralMetadata);
EntityMetadata metadata = structuralMetadata.withValues(object);
String sql = Parser.parseStatement(new CreateStatementParser(), metadata);
try {
EntityValidator.validate(entity.getClass());
EntityMetadata metadata = EntityMetadata.fromClass(entity.getClass());
String createTableSQL = Parser.parseStatement(new CreateTableStatementParser(), metadata);
try (Statement stmt = connection.createStatement()) {
stmt.execute(createTableSQL);
}
this.statement.execute(sql);
} catch (SQLException e) {
throw ExceptionTranslator.translate(e, entity.getClass(), createTableSQL);
throw ExceptionTranslator.translate(e, entityClass, sql);
}
}

public void Insert(Object entity) {
public void CreateTable(Object object) {
Class<?> entityClass = object.getClass();
EntityValidator.validate(entityClass);
EntityMetadata metadata = getOrCreateMetadata(entityClass);
String sql = Parser.parseStatement(new CreateTableStatementParser(), metadata);
try {
EntityValidator.validate(entity.getClass());
Class<?> entityClass = entity.getClass();
EntityMetadata metadata = schemaCache.computeIfAbsent(entityClass, EntityMetadata::fromClass);
EntityMetadata metadataWithValues = metadata.withValues(entity);

if (ormConfig.getDdlAuto() == OrmConfig.DdlAuto.CREATE_IF_NOT_EXISTS && !initializedEntities.contains(entityClass)) {
String createTableSQL = Parser.parseStatement(new CreateTableStatementParser(), metadata);
try (Statement stmt = connection.createStatement()) {
stmt.execute(createTableSQL);
}
initializedEntities.add(entityClass);
}

String insertSQL = Parser.parseStatement(new CreateStatementParser(), metadataWithValues);
try (Statement stmt = connection.createStatement()) {
stmt.execute(insertSQL);
}
this.statement.execute(sql);
} catch (SQLException e) {
throw ExceptionTranslator.translate(e, entity.getClass(), "INSERT");
throw ExceptionTranslator.translate(e, entityClass, sql);
}
}

public ResultSet Find(Class<?> entityClass, String query) {
try {
EntityValidator.validate(entityClass);
EntityMetadata metadata = schemaCache.computeIfAbsent(entityClass, EntityMetadata::fromClass);
String selectSQL = Parser.parseStatement(new FindStatementParser(), metadata);
Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(selectSQL);
public ResultSet Find(Object object) {
Class<?> entityClass = object.getClass();
EntityValidator.validate(entityClass);
EntityMetadata structuralMetadata = getOrCreateMetadata(entityClass);
EntityMetadata metadata = structuralMetadata.withValues(object);
String sql = Parser.parseStatement(new FindStatementParser(), metadata);

if (!rs.next()) {
throw new EntityNotFoundException(entityClass, selectSQL);
try {
ResultSet resultSet = this.statement.executeQuery(sql);
if (!resultSet.next()) {
throw new EntityNotFoundException(
String.format("No results found for entity '%s' with query: %s", entityClass.getSimpleName(), sql),
entityClass,
sql
);
}

return rs;
return resultSet;
} catch (SQLException e) {
throw ExceptionTranslator.translate(e, entityClass, query);
throw ExceptionTranslator.translate(e, entityClass, sql);
}
}

public void Close() {
private EntityMetadata getOrCreateMetadata(Class<?> entityClass) {
return schemaCache.computeIfAbsent(entityClass, ReflectionUtils::getEntityMetadata);
}

private void initializeEntityIfNeeded(Class<?> entityClass, EntityMetadata structuralMetadata) {
if (ormConfig.getDdlAuto() != OrmConfig.DdlAuto.CREATE_IF_NOT_EXISTS || initializedEntities.contains(entityClass)) {
return;
}

String sql = Parser.parseStatement(new CreateTableStatementParser(), structuralMetadata);
try {
if (connection != null && !connection.isClosed()) {
connection.close();
}
this.statement.execute(sql);
initializedEntities.add(entityClass);
} catch (SQLException e) {
e.printStackTrace();
throw ExceptionTranslator.translate(e, entityClass, sql);
}
}
}
56 changes: 36 additions & 20 deletions src/main/java/org/example/database/OrmConfig.java
Original file line number Diff line number Diff line change
@@ -1,37 +1,53 @@
package org.example.database;

import org.example.database.exception.OrmException;

import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;

public class OrmConfig {
public enum DdlAuto {
CREATE_IF_NOT_EXISTS,
NONE
private final DdlAuto ddlAuto;

public OrmConfig(DdlAuto ddlAuto) {
this.ddlAuto = ddlAuto;
}

private DdlAuto ddlAuto = DdlAuto.NONE;
public DdlAuto getDdlAuto() {
return ddlAuto;
}

public static OrmConfig load() {
Properties props = new Properties();
try (InputStream input = OrmConfig.class.getClassLoader().getResourceAsStream("orm.properties")) {
if (input != null) {
props.load(input);
String ddlMode = props.getProperty("orm.ddl-auto");
if ("create-if-not-exists".equalsIgnoreCase(ddlMode)) {
return new OrmConfig(DdlAuto.CREATE_IF_NOT_EXISTS);
}
Properties properties = new Properties();
try (InputStream inputStream = OrmConfig.class.getClassLoader().getResourceAsStream("orm.properties")) {
if (inputStream == null) {
return new OrmConfig(DdlAuto.NONE);
}
} catch (Exception e) {
// Default to NONE if any error occurs
properties.load(inputStream);
String value = properties.getProperty("orm.ddl-auto", "none");
return new OrmConfig(DdlAuto.fromValue(value));
} catch (IOException e) {
throw new OrmException("Failed to load orm.properties", e);
}
return new OrmConfig(DdlAuto.NONE);
}

private OrmConfig(DdlAuto ddlAuto) {
this.ddlAuto = ddlAuto;
}
public enum DdlAuto {
CREATE_IF_NOT_EXISTS("create-if-not-exists"),
NONE("none");

public DdlAuto getDdlAuto() {
return ddlAuto;
private final String value;

DdlAuto(String value) {
this.value = value;
}

public static DdlAuto fromValue(String value) {
for (DdlAuto ddlAuto : values()) {
if (ddlAuto.value.equalsIgnoreCase(value)) {
return ddlAuto;
}
}
throw new OrmException("Invalid orm.ddl-auto value: " + value);
}
}
}
2 changes: 1 addition & 1 deletion src/main/java/org/example/database/annotation/Column.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Column {
String name() default "";
boolean nullable() default true;
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/org/example/database/annotation/Entity.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Entity {
}
2 changes: 1 addition & 1 deletion src/main/java/org/example/database/annotation/Id.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Id {
}
2 changes: 1 addition & 1 deletion src/main/java/org/example/database/annotation/Table.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Table {
String name() default "";
}
Loading
Loading