diff --git a/docs/write-plans/2026-04-12-orm-annotations-exceptions-auto-ddl-implementation-plan.md b/docs/write-plans/2026-04-12-orm-annotations-exceptions-auto-ddl-implementation-plan.md new file mode 100644 index 0000000..7a90e95 --- /dev/null +++ b/docs/write-plans/2026-04-12-orm-annotations-exceptions-auto-ddl-implementation-plan.md @@ -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. diff --git a/docs/write-plans/README.md b/docs/write-plans/README.md new file mode 100644 index 0000000..3549c11 --- /dev/null +++ b/docs/write-plans/README.md @@ -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. diff --git a/src/main/java/org/example/database/DB.java b/src/main/java/org/example/database/DB.java index 097cda9..2787f03 100644 --- a/src/main/java/org/example/database/DB.java +++ b/src/main/java/org/example/database/DB.java @@ -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> initializedEntities = new HashSet<>(); - private Map, EntityMetadata> schemaCache = new HashMap<>(); + private final Statement statement; + private final Connection connection; + private final OrmConfig ormConfig; + private final Map, EntityMetadata> schemaCache = new HashMap<>(); + private final Set> 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); } } } diff --git a/src/main/java/org/example/database/OrmConfig.java b/src/main/java/org/example/database/OrmConfig.java index aa4dbea..318cbc8 100644 --- a/src/main/java/org/example/database/OrmConfig.java +++ b/src/main/java/org/example/database/OrmConfig.java @@ -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); + } } } diff --git a/src/main/java/org/example/database/annotation/Column.java b/src/main/java/org/example/database/annotation/Column.java index e6c27ba..7b2f4d5 100644 --- a/src/main/java/org/example/database/annotation/Column.java +++ b/src/main/java/org/example/database/annotation/Column.java @@ -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; diff --git a/src/main/java/org/example/database/annotation/Entity.java b/src/main/java/org/example/database/annotation/Entity.java index 0b3df18..4d22f54 100644 --- a/src/main/java/org/example/database/annotation/Entity.java +++ b/src/main/java/org/example/database/annotation/Entity.java @@ -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 { } diff --git a/src/main/java/org/example/database/annotation/Id.java b/src/main/java/org/example/database/annotation/Id.java index 39ed260..1faa6bc 100644 --- a/src/main/java/org/example/database/annotation/Id.java +++ b/src/main/java/org/example/database/annotation/Id.java @@ -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 { } diff --git a/src/main/java/org/example/database/annotation/Table.java b/src/main/java/org/example/database/annotation/Table.java index 55d1ff1..d31b5ea 100644 --- a/src/main/java/org/example/database/annotation/Table.java +++ b/src/main/java/org/example/database/annotation/Table.java @@ -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 ""; } diff --git a/src/main/java/org/example/database/annotation/Transient.java b/src/main/java/org/example/database/annotation/Transient.java index e2d1c93..2a52570 100644 --- a/src/main/java/org/example/database/annotation/Transient.java +++ b/src/main/java/org/example/database/annotation/Transient.java @@ -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 Transient { } diff --git a/src/main/java/org/example/database/exception/DatabaseException.java b/src/main/java/org/example/database/exception/DatabaseException.java index a0add99..f333b9b 100644 --- a/src/main/java/org/example/database/exception/DatabaseException.java +++ b/src/main/java/org/example/database/exception/DatabaseException.java @@ -1,9 +1,20 @@ package org.example.database.exception; -import java.sql.SQLException; - public class DatabaseException extends OrmException { - public DatabaseException(String message, SQLException cause) { + private final Class entityClass; + private final String sqlQuery; + + public DatabaseException(String message, Class entityClass, String sqlQuery, Throwable cause) { super(message, cause); + this.entityClass = entityClass; + this.sqlQuery = sqlQuery; + } + + public Class getEntityClass() { + return entityClass; + } + + public String getSqlQuery() { + return sqlQuery; } } diff --git a/src/main/java/org/example/database/exception/DuplicateKeyException.java b/src/main/java/org/example/database/exception/DuplicateKeyException.java index ef0d6cc..03599fd 100644 --- a/src/main/java/org/example/database/exception/DuplicateKeyException.java +++ b/src/main/java/org/example/database/exception/DuplicateKeyException.java @@ -1,9 +1,26 @@ package org.example.database.exception; -import java.lang.reflect.Class; - public class DuplicateKeyException extends OrmException { - public DuplicateKeyException(Class entityClass, String sqlQuery, String constraintName) { - super("Duplicate key violation on entity '" + entityClass.getSimpleName() + "': unique constraint '" + constraintName + "'"); + private final Class entityClass; + private final String sqlQuery; + private final String constraintName; + + public DuplicateKeyException(String message, Class entityClass, String sqlQuery, String constraintName, Throwable cause) { + super(message, cause); + this.entityClass = entityClass; + this.sqlQuery = sqlQuery; + this.constraintName = constraintName; + } + + public Class getEntityClass() { + return entityClass; + } + + public String getSqlQuery() { + return sqlQuery; + } + + public String getConstraintName() { + return constraintName; } } diff --git a/src/main/java/org/example/database/exception/EntityNotFoundException.java b/src/main/java/org/example/database/exception/EntityNotFoundException.java index 84ec734..5310335 100644 --- a/src/main/java/org/example/database/exception/EntityNotFoundException.java +++ b/src/main/java/org/example/database/exception/EntityNotFoundException.java @@ -1,9 +1,20 @@ package org.example.database.exception; -import java.lang.reflect.Class; - public class EntityNotFoundException extends OrmException { - public EntityNotFoundException(Class entityClass, String sqlQuery) { - super("No results found for entity '" + entityClass.getSimpleName() + "' with query: " + sqlQuery); + private final Class entityClass; + private final String sqlQuery; + + public EntityNotFoundException(String message, Class entityClass, String sqlQuery) { + super(message); + this.entityClass = entityClass; + this.sqlQuery = sqlQuery; + } + + public Class getEntityClass() { + return entityClass; + } + + public String getSqlQuery() { + return sqlQuery; } } diff --git a/src/main/java/org/example/database/exception/EntityValidationException.java b/src/main/java/org/example/database/exception/EntityValidationException.java index 7c6a27e..cc8f650 100644 --- a/src/main/java/org/example/database/exception/EntityValidationException.java +++ b/src/main/java/org/example/database/exception/EntityValidationException.java @@ -1,9 +1,14 @@ package org.example.database.exception; -import java.lang.reflect.Class; - public class EntityValidationException extends OrmException { + private final Class entityClass; + public EntityValidationException(String message, Class entityClass) { - super("Entity validation failed for '" + entityClass.getSimpleName() + "': " + message); + super(message); + this.entityClass = entityClass; + } + + public Class getEntityClass() { + return entityClass; } } diff --git a/src/main/java/org/example/database/exception/ExceptionTranslator.java b/src/main/java/org/example/database/exception/ExceptionTranslator.java index 52bc2a0..fc72839 100644 --- a/src/main/java/org/example/database/exception/ExceptionTranslator.java +++ b/src/main/java/org/example/database/exception/ExceptionTranslator.java @@ -1,17 +1,43 @@ package org.example.database.exception; -import java.lang.reflect.Class; -import java.sql.Connection; +import org.postgresql.util.PSQLException; +import org.postgresql.util.ServerErrorMessage; + import java.sql.SQLException; public class ExceptionTranslator { + public static OrmException translate(SQLException e, Class entityClass, String sql) { - if (e.getSQLState().equals("23505")) { - return new DuplicateKeyException(entityClass, sql, e.getMessage()); - } else if (e.getSQLState().startsWith("23")) { - return new DatabaseException("Integrity error executing query '" + sql + "': " + e.getMessage(), e); - } else { - return new DatabaseException("Database error executing query '" + sql + "': " + e.getMessage(), e); + String sqlState = e.getSQLState(); + if ("23505".equals(sqlState)) { + String constraintName = null; + if (e instanceof PSQLException) { + ServerErrorMessage serverErrorMessage = ((PSQLException) e).getServerErrorMessage(); + if (serverErrorMessage != null) { + constraintName = serverErrorMessage.getConstraint(); + } + } + return new DuplicateKeyException( + String.format("Duplicate key violation on entity '%s': unique constraint '%s'", entityClass.getSimpleName(), constraintName), + entityClass, + sql, + constraintName, + e + ); + } + if (sqlState != null && sqlState.startsWith("23")) { + return new DatabaseException( + String.format("Database error executing query '%s': %s", sql, e.getMessage()), + entityClass, + sql, + e + ); } + return new DatabaseException( + String.format("Database error executing query '%s': %s", sql, e.getMessage()), + entityClass, + sql, + e + ); } } diff --git a/src/main/java/org/example/database/parser/CreateStatementParser.java b/src/main/java/org/example/database/parser/CreateStatementParser.java index 4a3db99..f63d658 100644 --- a/src/main/java/org/example/database/parser/CreateStatementParser.java +++ b/src/main/java/org/example/database/parser/CreateStatementParser.java @@ -1,11 +1,65 @@ package org.example.database.parser; +import org.example.database.reflection.ColumnMetadata; import org.example.database.reflection.EntityMetadata; +import java.util.stream.Collectors; + public class CreateStatementParser implements StatementParser { + private final String[] keys = {"INSERT", "INTO", "VALUES"}; + @Override public String parse(EntityMetadata metadata) { - // Implementation to generate INSERT SQL using metadata - return ""; + return createStatement(metadata); + } + + private String createStatement(EntityMetadata metadata) { + StringBuilder statement = new StringBuilder(); + + for (String key : keys) { + statement.append(key); + statement.append(ParserConstants.SPACE_DELIMITER); + if (key.equals("INTO")) { + statement.append(metadata.tableName); + statement.append(getKeysStatement(metadata)); + statement.append(ParserConstants.SPACE_DELIMITER); + } + if (key.equals("VALUES")) { + statement.append(getValuesStatement(metadata)); + } + } + return statement.toString(); + } + + private String getValuesStatement(EntityMetadata metadata) { + return metadata.columns.stream() + .map(this::formatValue) + .collect(Collectors.joining(", ", "(", ")")); + } + + private String formatValue(ColumnMetadata columnMetadata) { + Object value = columnMetadata.value; + if (value == null) { + return "null"; + } + if (isNumeric(columnMetadata.javaType) || isBoolean(columnMetadata.javaType)) { + return String.valueOf(value); + } + return String.format("'%s'", String.valueOf(value).replace("'", "''")); + } + + private boolean isNumeric(Class type) { + return Integer.class.equals(type) || int.class.equals(type) + || Float.class.equals(type) || float.class.equals(type) + || Double.class.equals(type) || double.class.equals(type) + || Long.class.equals(type) || long.class.equals(type); + } + + private boolean isBoolean(Class type) { + return Boolean.class.equals(type) || boolean.class.equals(type); + } + + private String getKeysStatement(EntityMetadata metadata) { + return metadata.columns.stream().map(it -> it.columnName).collect(Collectors.joining(", ", "(", ")")); } } diff --git a/src/main/java/org/example/database/parser/CreateTableStatementParser.java b/src/main/java/org/example/database/parser/CreateTableStatementParser.java index 4d99951..b3179e7 100644 --- a/src/main/java/org/example/database/parser/CreateTableStatementParser.java +++ b/src/main/java/org/example/database/parser/CreateTableStatementParser.java @@ -1,11 +1,56 @@ package org.example.database.parser; +import org.example.database.reflection.ColumnMetadata; import org.example.database.reflection.EntityMetadata; +import java.util.stream.Collectors; + public class CreateTableStatementParser implements StatementParser { + private final String[] keys = {"CREATE", "TABLE"}; + @Override public String parse(EntityMetadata metadata) { - // Implementation to generate CREATE TABLE SQL using metadata - return ""; + return createStatement(metadata); + } + + private String createStatement(EntityMetadata metadata) { + StringBuilder statement = new StringBuilder(); + + for (String key : keys) { + statement.append(key); + statement.append(ParserConstants.SPACE_DELIMITER); + if (key.equals("TABLE")) { + statement.append("IF NOT EXISTS"); + statement.append(ParserConstants.SPACE_DELIMITER); + statement.append(metadata.tableName); + statement.append(ParserConstants.SPACE_DELIMITER); + statement.append(getTableKeyAndTypeStatement(metadata)); + } + } + return statement.toString(); + } + + private String getTableKeyAndTypeStatement(EntityMetadata metadata) { + return metadata.columns.stream() + .map(this::formatColumn) + .collect(Collectors.joining(",\n", "(\n", "\n)")); + } + + private String formatColumn(ColumnMetadata columnMetadata) { + StringBuilder builder = new StringBuilder(); + builder.append(columnMetadata.columnName) + .append(ParserConstants.SPACE_DELIMITER) + .append(columnMetadata.sqlType); + + if (columnMetadata.isPrimaryKey) { + builder.append(ParserConstants.SPACE_DELIMITER).append("PRIMARY KEY"); + } + if (!columnMetadata.nullable) { + builder.append(ParserConstants.SPACE_DELIMITER).append("NOT NULL"); + } + if (columnMetadata.unique) { + builder.append(ParserConstants.SPACE_DELIMITER).append("UNIQUE"); + } + return builder.toString(); } } diff --git a/src/main/java/org/example/database/parser/FindStatementParser.java b/src/main/java/org/example/database/parser/FindStatementParser.java index 81213db..346bf41 100644 --- a/src/main/java/org/example/database/parser/FindStatementParser.java +++ b/src/main/java/org/example/database/parser/FindStatementParser.java @@ -2,10 +2,33 @@ import org.example.database.reflection.EntityMetadata; +import java.util.stream.Collectors; + public class FindStatementParser implements StatementParser { + private final String[] keys = {"SELECT", "FROM"}; + @Override public String parse(EntityMetadata metadata) { - // Implementation to generate SELECT SQL using metadata - return ""; + return createStatement(metadata); + } + + private String createStatement(EntityMetadata metadata) { + StringBuilder statement = new StringBuilder(); + for (String key : keys) { + statement.append(key); + statement.append(ParserConstants.SPACE_DELIMITER); + if (key.equals("SELECT")) { + statement.append(getColumns(metadata)); + statement.append(ParserConstants.SPACE_DELIMITER); + } + if (key.equals("FROM")) { + statement.append(metadata.tableName); + } + } + return statement.toString(); + } + + private String getColumns(EntityMetadata metadata) { + return metadata.columns.stream().map(it -> it.columnName).collect(Collectors.joining(", ")); } } diff --git a/src/main/java/org/example/database/parser/StatementParser.java b/src/main/java/org/example/database/parser/StatementParser.java index 4d4a70d..d159245 100644 --- a/src/main/java/org/example/database/parser/StatementParser.java +++ b/src/main/java/org/example/database/parser/StatementParser.java @@ -3,5 +3,6 @@ import org.example.database.reflection.EntityMetadata; public interface StatementParser { + String parse(EntityMetadata metadata); } diff --git a/src/main/java/org/example/database/reflection/ColumnMetadata.java b/src/main/java/org/example/database/reflection/ColumnMetadata.java index a314eb8..019345d 100644 --- a/src/main/java/org/example/database/reflection/ColumnMetadata.java +++ b/src/main/java/org/example/database/reflection/ColumnMetadata.java @@ -9,13 +9,19 @@ public class ColumnMetadata { public boolean unique; public boolean isPrimaryKey; public Object value; + public Class javaType; + public Field field; - public static ColumnMetadata fromField(Field field) { + public ColumnMetadata copy() { ColumnMetadata metadata = new ColumnMetadata(); - metadata.columnName = field.getName(); - metadata.nullable = true; - metadata.unique = false; - metadata.isPrimaryKey = false; + metadata.columnName = this.columnName; + metadata.sqlType = this.sqlType; + metadata.nullable = this.nullable; + metadata.unique = this.unique; + metadata.isPrimaryKey = this.isPrimaryKey; + metadata.value = this.value; + metadata.javaType = this.javaType; + metadata.field = this.field; return metadata; } } diff --git a/src/main/java/org/example/database/reflection/EntityMetadata.java b/src/main/java/org/example/database/reflection/EntityMetadata.java index 2638512..f3231cc 100644 --- a/src/main/java/org/example/database/reflection/EntityMetadata.java +++ b/src/main/java/org/example/database/reflection/EntityMetadata.java @@ -1,5 +1,10 @@ package org.example.database.reflection; +import org.example.database.annotation.Column; +import org.example.database.annotation.Id; +import org.example.database.annotation.Table; +import org.example.database.annotation.Transient; + import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; @@ -11,19 +16,79 @@ public class EntityMetadata { public static EntityMetadata fromClass(Class entityClass) { EntityMetadata metadata = new EntityMetadata(); - // Logic to derive table name and column metadata from annotations + Table table = entityClass.getAnnotation(Table.class); + metadata.tableName = table != null && !table.name().isEmpty() ? table.name() : entityClass.getSimpleName(); + metadata.columns = new ArrayList<>(); + + for (Field field : entityClass.getDeclaredFields()) { + if (field.isAnnotationPresent(Transient.class)) { + continue; + } + + field.setAccessible(true); + ColumnMetadata columnMetadata = new ColumnMetadata(); + Column column = field.getAnnotation(Column.class); + columnMetadata.columnName = column != null && !column.name().isEmpty() ? column.name() : field.getName(); + columnMetadata.nullable = column == null || column.nullable(); + columnMetadata.unique = column != null && column.unique(); + columnMetadata.isPrimaryKey = field.isAnnotationPresent(Id.class); + if (columnMetadata.isPrimaryKey) { + columnMetadata.nullable = false; + } + columnMetadata.javaType = field.getType(); + columnMetadata.sqlType = resolveSqlType(field.getType(), column); + columnMetadata.field = field; + + metadata.columns.add(columnMetadata); + if (columnMetadata.isPrimaryKey) { + metadata.idColumn = columnMetadata; + } + } + return metadata; } public EntityMetadata withValues(Object entity) { - EntityMetadata copy = new EntityMetadata(); - copy.tableName = this.tableName; - copy.columns = new ArrayList<>(); - for (ColumnMetadata column : this.columns) { - ColumnMetadata newColumn = new ColumnMetadata(); - // Populate values - copy.columns.add(newColumn); - } - return copy; + EntityMetadata metadata = new EntityMetadata(); + metadata.tableName = this.tableName; + metadata.columns = new ArrayList<>(); + + for (ColumnMetadata structuralColumn : this.columns) { + ColumnMetadata copy = structuralColumn.copy(); + try { + copy.value = copy.field.get(entity); + } catch (IllegalAccessException e) { + throw new RuntimeException("Failed to read field value: " + copy.field.getName(), e); + } + metadata.columns.add(copy); + if (copy.isPrimaryKey) { + metadata.idColumn = copy; + } + } + + return metadata; + } + + private static String resolveSqlType(Class type, Column column) { + if (String.class.equals(type)) { + int length = column != null ? column.length() : 255; + return "varchar(" + length + ")"; + } + if (Integer.class.equals(type) || int.class.equals(type)) { + return "int"; + } + if (Float.class.equals(type) || float.class.equals(type)) { + return "real"; + } + if (Double.class.equals(type) || double.class.equals(type)) { + return "float8"; + } + if (Long.class.equals(type) || long.class.equals(type)) { + return "bigint"; + } + if (Boolean.class.equals(type) || boolean.class.equals(type)) { + return "boolean"; + } + return type.getSimpleName(); } } diff --git a/src/main/java/org/example/database/reflection/EntityValidator.java b/src/main/java/org/example/database/reflection/EntityValidator.java index 3eee991..a88e100 100644 --- a/src/main/java/org/example/database/reflection/EntityValidator.java +++ b/src/main/java/org/example/database/reflection/EntityValidator.java @@ -1,22 +1,40 @@ package org.example.database.reflection; +import org.example.database.annotation.Entity; +import org.example.database.annotation.Id; +import org.example.database.annotation.Transient; +import org.example.database.exception.EntityValidationException; + import java.lang.reflect.Field; public class EntityValidator { + public static void validate(Class entityClass) { if (!entityClass.isAnnotationPresent(Entity.class)) { - throw new EntityValidationException("Class is not annotated with @Entity", entityClass); + throw new EntityValidationException( + String.format("Class %s is not annotated with @Entity", entityClass.getSimpleName()), + entityClass + ); } int idCount = 0; for (Field field : entityClass.getDeclaredFields()) { if (field.isAnnotationPresent(Id.class)) { idCount++; + if (field.isAnnotationPresent(Transient.class)) { + throw new EntityValidationException( + String.format("@Id field cannot be @Transient in entity %s", entityClass.getSimpleName()), + entityClass + ); + } } } if (idCount != 1) { - throw new EntityValidationException("Entity must have exactly one @Id field", entityClass); + throw new EntityValidationException( + String.format("Entity %s must have exactly one @Id field", entityClass.getSimpleName()), + entityClass + ); } } } diff --git a/src/main/java/org/example/database/reflection/ReflectionUtils.java b/src/main/java/org/example/database/reflection/ReflectionUtils.java index eeba70a..a9264bc 100644 --- a/src/main/java/org/example/database/reflection/ReflectionUtils.java +++ b/src/main/java/org/example/database/reflection/ReflectionUtils.java @@ -1,55 +1,11 @@ package org.example.database.reflection; -import java.lang.reflect.Field; -import java.util.LinkedHashMap; -import java.util.Map; - public class ReflectionUtils { - public static Map getKeyValuesMap(Object object) { - try { - Class objectClass = Class.forName(object.getClass().getName()); - Field[] fields = objectClass.getDeclaredFields(); - Map keyValueFields = new LinkedHashMap<>(); - for (Field field : fields) { - field.setAccessible(true); - Object value = field.get(object); - keyValueFields.put(field.getName(),value); - } - return keyValueFields; - }catch (Exception e ) { - throw new RuntimeException("Failed to parse object: "+ e.getMessage()); - } - } - - public static Map getKeyTypeMap(Object object) { - try { - Class objectClass = Class.forName(object.getClass().getName()); - Field[] fields = objectClass.getDeclaredFields(); - Map keyValueFields = new LinkedHashMap<>(); - for (Field field : fields) { - var type = field.getType().getSimpleName(); - keyValueFields.put(field.getName(), getSqlType(type)); - } - return keyValueFields; - }catch (Exception e ) { - throw new RuntimeException("Failed to parse object: "+ e.getMessage()); - } + private ReflectionUtils() { } - private static String getSqlType(String value) { - switch (value) { - case "String": - return "varchar(255)"; - case "Integer": - case "int": - return "int"; - case "float": - return "real"; - case "double": - return "float8"; - default: - return value; - } + public static EntityMetadata getEntityMetadata(Class entityClass) { + return EntityMetadata.fromClass(entityClass); } } diff --git a/src/main/resources/orm.properties b/src/main/resources/orm.properties index 48245e2..9ed47ac 100644 --- a/src/main/resources/orm.properties +++ b/src/main/resources/orm.properties @@ -1,2 +1,2 @@ # Options: create-if-not-exists, none (default: none) -orm.ddl-auto=create-if-not-exists \ No newline at end of file +orm.ddl-auto=none diff --git a/src/test/java/org/example/CreateStatementTest.java b/src/test/java/org/example/CreateStatementTest.java index a1b6bab..44719d7 100644 --- a/src/test/java/org/example/CreateStatementTest.java +++ b/src/test/java/org/example/CreateStatementTest.java @@ -2,6 +2,7 @@ import org.example.database.parser.CreateStatementParser; import org.example.database.parser.Parser; +import org.example.database.reflection.EntityMetadata; import org.junit.Assert; import org.junit.Test; @@ -10,10 +11,11 @@ public class CreateStatementTest { @Test public void CreateObjectStatementTest() { UserTest user = new UserTest(); - user.setAge("20"); + user.setAge(20); user.setName("thalles"); user.setId("1"); - String statement = Parser.parseStatement(new CreateStatementParser(), user); - Assert.assertEquals("INSERT INTO UserTest(id, name, age) VALUES ('1', 'thalles', '20')", statement); + EntityMetadata metadata = EntityMetadata.fromClass(UserTest.class).withValues(user); + String statement = Parser.parseStatement(new CreateStatementParser(), metadata); + Assert.assertEquals("INSERT INTO UserTest(id, name, age) VALUES ('1', 'thalles', 20)", statement); } } diff --git a/src/test/java/org/example/CreateTableStatementTest.java b/src/test/java/org/example/CreateTableStatementTest.java index cb5652b..6a6e7af 100644 --- a/src/test/java/org/example/CreateTableStatementTest.java +++ b/src/test/java/org/example/CreateTableStatementTest.java @@ -2,6 +2,7 @@ import org.example.database.parser.CreateTableStatementParser; import org.example.database.parser.Parser; +import org.example.database.reflection.EntityMetadata; import org.junit.Assert; import org.junit.Test; @@ -9,15 +10,11 @@ public class CreateTableStatementTest { @Test public void CreateObjectStatementTest() { - UserTest user = new UserTest(); - user.setAge("20"); - user.setName("thalles"); - user.setId("1"); - String statement = Parser.parseStatement(new CreateTableStatementParser(), user); - Assert.assertEquals("CREATE TABLE UserTest (\n" + - "id varchar(255),\n" + + String statement = Parser.parseStatement(new CreateTableStatementParser(), EntityMetadata.fromClass(UserTest.class)); + Assert.assertEquals("CREATE TABLE IF NOT EXISTS UserTest (\n" + + "id varchar(255) PRIMARY KEY NOT NULL,\n" + "name varchar(255),\n" + - "age varchar(255)\n" + + "age int\n" + ")", statement); } } diff --git a/src/test/java/org/example/EntityMetadataTest.java b/src/test/java/org/example/EntityMetadataTest.java new file mode 100644 index 0000000..57a5063 --- /dev/null +++ b/src/test/java/org/example/EntityMetadataTest.java @@ -0,0 +1,49 @@ +package org.example; + +import org.example.database.annotation.Column; +import org.example.database.annotation.Entity; +import org.example.database.annotation.Id; +import org.example.database.annotation.Table; +import org.example.database.annotation.Transient; +import org.example.database.reflection.EntityMetadata; +import org.junit.Assert; +import org.junit.Test; + +public class EntityMetadataTest { + + @Test + public void shouldReadAnnotationsAndValues() { + MetadataEntity entity = new MetadataEntity(); + entity.id = "7"; + entity.name = "neo"; + entity.age = 42; + entity.internalNote = "ignore"; + + EntityMetadata structuralMetadata = EntityMetadata.fromClass(MetadataEntity.class); + EntityMetadata valueMetadata = structuralMetadata.withValues(entity); + + Assert.assertEquals("users", structuralMetadata.tableName); + Assert.assertEquals(3, structuralMetadata.columns.size()); + Assert.assertEquals("id", structuralMetadata.idColumn.columnName); + Assert.assertEquals("user_name", structuralMetadata.columns.get(1).columnName); + Assert.assertEquals("varchar(100)", structuralMetadata.columns.get(1).sqlType); + Assert.assertEquals("7", valueMetadata.idColumn.value); + Assert.assertEquals("neo", valueMetadata.columns.get(1).value); + Assert.assertEquals(42, valueMetadata.columns.get(2).value); + } + + @Entity + @Table(name = "users") + private static class MetadataEntity { + @Id + String id; + + @Column(name = "user_name", length = 100, nullable = false) + String name; + + int age; + + @Transient + String internalNote; + } +} diff --git a/src/test/java/org/example/EntityValidatorTest.java b/src/test/java/org/example/EntityValidatorTest.java new file mode 100644 index 0000000..b74be07 --- /dev/null +++ b/src/test/java/org/example/EntityValidatorTest.java @@ -0,0 +1,56 @@ +package org.example; + +import org.example.database.annotation.Entity; +import org.example.database.annotation.Id; +import org.example.database.annotation.Transient; +import org.example.database.exception.EntityValidationException; +import org.example.database.reflection.EntityValidator; +import org.junit.Test; + +public class EntityValidatorTest { + + @Test(expected = EntityValidationException.class) + public void shouldFailWhenEntityAnnotationIsMissing() { + EntityValidator.validate(NoEntityAnnotation.class); + } + + @Test(expected = EntityValidationException.class) + public void shouldFailWhenIdIsMissing() { + EntityValidator.validate(NoIdEntity.class); + } + + @Test(expected = EntityValidationException.class) + public void shouldFailWhenMultipleIdsExist() { + EntityValidator.validate(MultipleIdsEntity.class); + } + + @Test(expected = EntityValidationException.class) + public void shouldFailWhenIdIsTransient() { + EntityValidator.validate(TransientIdEntity.class); + } + + private static class NoEntityAnnotation { + @Id + private String id; + } + + @Entity + private static class NoIdEntity { + private String name; + } + + @Entity + private static class MultipleIdsEntity { + @Id + private String id; + @Id + private String otherId; + } + + @Entity + private static class TransientIdEntity { + @Id + @Transient + private String id; + } +} diff --git a/src/test/java/org/example/ExceptionTranslatorTest.java b/src/test/java/org/example/ExceptionTranslatorTest.java new file mode 100644 index 0000000..95171bc --- /dev/null +++ b/src/test/java/org/example/ExceptionTranslatorTest.java @@ -0,0 +1,31 @@ +package org.example; + +import org.example.database.exception.DatabaseException; +import org.example.database.exception.DuplicateKeyException; +import org.example.database.exception.ExceptionTranslator; +import org.example.database.exception.OrmException; +import org.junit.Assert; +import org.junit.Test; + +import java.sql.SQLException; + +public class ExceptionTranslatorTest { + + @Test + public void shouldTranslateDuplicateKey() { + OrmException exception = ExceptionTranslator.translate(new SQLException("duplicate", "23505"), UserTest.class, "INSERT SQL"); + Assert.assertTrue(exception instanceof DuplicateKeyException); + } + + @Test + public void shouldTranslateIntegrityViolation() { + OrmException exception = ExceptionTranslator.translate(new SQLException("integrity", "23514"), UserTest.class, "INSERT SQL"); + Assert.assertTrue(exception instanceof DatabaseException); + } + + @Test + public void shouldTranslateGenericDatabaseError() { + OrmException exception = ExceptionTranslator.translate(new SQLException("generic", "42000"), UserTest.class, "INSERT SQL"); + Assert.assertTrue(exception instanceof DatabaseException); + } +} diff --git a/src/test/java/org/example/FindStatementTest.java b/src/test/java/org/example/FindStatementTest.java index ba490a6..6ef3c70 100644 --- a/src/test/java/org/example/FindStatementTest.java +++ b/src/test/java/org/example/FindStatementTest.java @@ -1,8 +1,8 @@ package org.example; -import org.example.database.parser.CreateStatementParser; import org.example.database.parser.FindStatementParser; import org.example.database.parser.Parser; +import org.example.database.reflection.EntityMetadata; import org.junit.Assert; import org.junit.Test; @@ -11,10 +11,10 @@ public class FindStatementTest { @Test public void CreateObjectStatementTest() { UserTest user = new UserTest(); - user.setAge("20"); + user.setAge(20); user.setName("thalles"); user.setId("1"); - String statement = Parser.parseStatement(new FindStatementParser(), user); + String statement = Parser.parseStatement(new FindStatementParser(), EntityMetadata.fromClass(UserTest.class).withValues(user)); Assert.assertEquals("SELECT id, name, age FROM UserTest", statement); } } diff --git a/src/test/java/org/example/OrmConfigTest.java b/src/test/java/org/example/OrmConfigTest.java new file mode 100644 index 0000000..9cd7f02 --- /dev/null +++ b/src/test/java/org/example/OrmConfigTest.java @@ -0,0 +1,26 @@ +package org.example; + +import org.example.database.OrmConfig; +import org.example.database.exception.OrmException; +import org.junit.Assert; +import org.junit.Test; + +public class OrmConfigTest { + + @Test + public void shouldLoadDefaultWhenFileIsPresentWithNone() { + OrmConfig config = OrmConfig.load(); + Assert.assertEquals(OrmConfig.DdlAuto.NONE, config.getDdlAuto()); + } + + @Test + public void shouldParseValidValue() { + OrmConfig config = new OrmConfig(OrmConfig.DdlAuto.CREATE_IF_NOT_EXISTS); + Assert.assertEquals(OrmConfig.DdlAuto.CREATE_IF_NOT_EXISTS, config.getDdlAuto()); + } + + @Test(expected = OrmException.class) + public void shouldFailOnInvalidValue() { + OrmConfig.DdlAuto.fromValue("wat"); + } +} diff --git a/src/test/java/org/example/UserTest.java b/src/test/java/org/example/UserTest.java index 115bdeb..40b347c 100644 --- a/src/test/java/org/example/UserTest.java +++ b/src/test/java/org/example/UserTest.java @@ -1,9 +1,18 @@ package org.example; +import org.example.database.annotation.Column; +import org.example.database.annotation.Entity; +import org.example.database.annotation.Id; + +@Entity public class UserTest { + @Id private String id; + private String name; - private String age; + + @Column + private int age; public String getId() { return id; @@ -21,11 +30,11 @@ public void setName(String name) { this.name = name; } - public String getAge() { + public int getAge() { return age; } - public void setAge(String age) { + public void setAge(int age) { this.age = age; } } diff --git a/src/test/java/org/example/integration/CreateUserTest.java b/src/test/java/org/example/integration/CreateUserTest.java index 58e5dd6..85bbd37 100644 --- a/src/test/java/org/example/integration/CreateUserTest.java +++ b/src/test/java/org/example/integration/CreateUserTest.java @@ -7,7 +7,6 @@ import org.junit.Test; import org.testcontainers.containers.PostgreSQLContainer; -import javax.xml.transform.Result; import java.sql.ResultSet; import java.sql.SQLException; @@ -18,7 +17,7 @@ public void shouldCreateUserTest() throws SQLException, ClassNotFoundException { UserTest userTest = new UserTest(); userTest.setId("1"); userTest.setName("thalles"); - userTest.setAge("20"); + userTest.setAge(20); PostgreSQLContainer postgresContainer = new PostgreSQLContainer(); postgresContainer.start(); String url = postgresContainer.getJdbcUrl(); @@ -28,6 +27,6 @@ public void shouldCreateUserTest() throws SQLException, ClassNotFoundException { ResultSet resultSet = db.Find(userTest); Assert.assertEquals(resultSet.getString(1), userTest.getId()); Assert.assertEquals(resultSet.getString(2), userTest.getName()); - Assert.assertEquals(resultSet.getString(3), userTest.getAge()); + Assert.assertEquals(resultSet.getInt(3), userTest.getAge()); } }