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
24 changes: 14 additions & 10 deletions .github/workflows/maven.yml → .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,20 @@ on:

jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: '11'
distribution: 'temurin'
cache: maven
- name: Build with Maven
run: mvn -B package --file pom.xml
- uses: actions/checkout@v4

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven

- name: Verify Docker is available
run: docker version

- name: Run test suite
run: mvn -B test --file pom.xml

Large diffs are not rendered by default.

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.
99 changes: 79 additions & 20 deletions src/main/java/org/example/database/DB.java
Original file line number Diff line number Diff line change
@@ -1,49 +1,108 @@
package org.example.database;

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.*;
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 final Statement statement;
private Connection connection;
private final Connection connection;
private final OrmConfig ormConfig;
private final Map<Class<?>, EntityMetadata> schemaCache = new HashMap<>();
private final Set<Class<?>> initializedEntities = new HashSet<>();

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

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());
Connection connection = DriverManager.getConnection(url, user, password);
return new DB(connection, connection.createStatement(), OrmConfig.load());
}

public void Insert(Object object) throws SQLException {
String statement = Parser.parseStatement(new CreateStatementParser(), object);
System.out.println(statement);
this.statement.execute(statement);
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 {
this.statement.execute(sql);
} catch (SQLException e) {
throw ExceptionTranslator.translate(e, entityClass, sql);
}
}

public void CreateTable(Object object) {
Class<?> entityClass = object.getClass();
EntityValidator.validate(entityClass);
EntityMetadata metadata = getOrCreateMetadata(entityClass);
String sql = Parser.parseStatement(new CreateTableStatementParser(), metadata);
try {
this.statement.execute(sql);
} catch (SQLException e) {
throw ExceptionTranslator.translate(e, entityClass, sql);
}
}

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);

public void CreateTable(Object object) throws SQLException {
String statement = Parser.parseStatement(new CreateTableStatementParser(), object);
boolean result = this.statement.execute(statement);
System.out.println(result);
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 resultSet;
} catch (SQLException e) {
throw ExceptionTranslator.translate(e, entityClass, sql);
}
}

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

public ResultSet Find(Object object) throws SQLException {
String statement = Parser.parseStatement(new FindStatementParser(), object);
private void initializeEntityIfNeeded(Class<?> entityClass, EntityMetadata structuralMetadata) {
if (ormConfig.getDdlAuto() != OrmConfig.DdlAuto.CREATE_IF_NOT_EXISTS || initializedEntities.contains(entityClass)) {
return;
}

ResultSet resultSet = this.statement.executeQuery(statement);
if (!resultSet.next()) {
throw new RuntimeException("No results from query");
String sql = Parser.parseStatement(new CreateTableStatementParser(), structuralMetadata);
try {
this.statement.execute(sql);
initializedEntities.add(entityClass);
} catch (SQLException e) {
throw ExceptionTranslator.translate(e, entityClass, sql);
}
return resultSet;
}
}
53 changes: 53 additions & 0 deletions src/main/java/org/example/database/OrmConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +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 {
private final DdlAuto ddlAuto;

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

public DdlAuto getDdlAuto() {
return ddlAuto;
}

public static OrmConfig load() {
Properties properties = new Properties();
try (InputStream inputStream = OrmConfig.class.getClassLoader().getResourceAsStream("orm.properties")) {
if (inputStream == null) {
return new OrmConfig(DdlAuto.NONE);
}
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);
}
}

public enum DdlAuto {
CREATE_IF_NOT_EXISTS("create-if-not-exists"),
NONE("none");

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);
}
}
}
15 changes: 15 additions & 0 deletions src/main/java/org/example/database/annotation/Column.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package org.example.database.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Column {
String name() default "";
boolean nullable() default true;
int length() default 255;
boolean unique() default false;
}
11 changes: 11 additions & 0 deletions src/main/java/org/example/database/annotation/Entity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.example.database.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Entity {
}
11 changes: 11 additions & 0 deletions src/main/java/org/example/database/annotation/Id.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.example.database.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Id {
}
12 changes: 12 additions & 0 deletions src/main/java/org/example/database/annotation/Table.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.example.database.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Table {
String name() default "";
}
11 changes: 11 additions & 0 deletions src/main/java/org/example/database/annotation/Transient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.example.database.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Transient {
}
Loading