From ef1aaf3b8a95c7eb46b0598fbcecea85af1ad42c Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 12 Apr 2026 18:31:19 +0000 Subject: [PATCH 1/5] Revert "Implement ORM features: annotations, exception hierarchy, metadata, and auto-DDL" This reverts commit b7ebaad63298885acff4cf39a76221b9dbd43f94. --- src/main/java/org/example/database/DB.java | 99 ++++++------------- .../java/org/example/database/OrmConfig.java | 37 ------- .../example/database/annotation/Column.java | 15 --- .../example/database/annotation/Entity.java | 11 --- .../org/example/database/annotation/Id.java | 11 --- .../example/database/annotation/Table.java | 12 --- .../database/annotation/Transient.java | 11 --- .../database/exception/DatabaseException.java | 9 -- .../exception/DuplicateKeyException.java | 9 -- .../exception/EntityNotFoundException.java | 9 -- .../exception/EntityValidationException.java | 9 -- .../exception/ExceptionTranslator.java | 17 ---- .../database/exception/OrmException.java | 11 --- .../parser/CreateStatementParser.java | 43 +++++++- .../parser/CreateTableStatementParser.java | 36 ++++++- .../database/parser/FindStatementParser.java | 32 +++++- .../org/example/database/parser/Parser.java | 5 +- .../database/parser/StatementParser.java | 5 +- .../database/reflection/ColumnMetadata.java | 21 ---- .../database/reflection/EntityMetadata.java | 29 ------ .../database/reflection/EntityValidator.java | 22 ----- src/main/resources/orm.properties | 2 - 22 files changed, 131 insertions(+), 324 deletions(-) delete mode 100644 src/main/java/org/example/database/OrmConfig.java delete mode 100644 src/main/java/org/example/database/annotation/Column.java delete mode 100644 src/main/java/org/example/database/annotation/Entity.java delete mode 100644 src/main/java/org/example/database/annotation/Id.java delete mode 100644 src/main/java/org/example/database/annotation/Table.java delete mode 100644 src/main/java/org/example/database/annotation/Transient.java delete mode 100644 src/main/java/org/example/database/exception/DatabaseException.java delete mode 100644 src/main/java/org/example/database/exception/DuplicateKeyException.java delete mode 100644 src/main/java/org/example/database/exception/EntityNotFoundException.java delete mode 100644 src/main/java/org/example/database/exception/EntityValidationException.java delete mode 100644 src/main/java/org/example/database/exception/ExceptionTranslator.java delete mode 100644 src/main/java/org/example/database/exception/OrmException.java delete mode 100644 src/main/java/org/example/database/reflection/ColumnMetadata.java delete mode 100644 src/main/java/org/example/database/reflection/EntityMetadata.java delete mode 100644 src/main/java/org/example/database/reflection/EntityValidator.java delete mode 100644 src/main/resources/orm.properties diff --git a/src/main/java/org/example/database/DB.java b/src/main/java/org/example/database/DB.java index 097cda9..f25a3b3 100644 --- a/src/main/java/org/example/database/DB.java +++ b/src/main/java/org/example/database/DB.java @@ -1,92 +1,49 @@ 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 org.example.database.parser.CreateStatementParser; +import org.example.database.parser.CreateTableStatementParser; +import org.example.database.parser.FindStatementParser; +import org.example.database.parser.Parser; + import java.sql.*; -import java.util.*; public class DB { + private final Statement statement; private Connection connection; - private OrmConfig ormConfig; - private Set> initializedEntities = new HashSet<>(); - private Map, EntityMetadata> schemaCache = new HashMap<>(); - private DB(Connection connection) { + public DB(Connection connection, Statement statement) { this.connection = connection; - this.ormConfig = OrmConfig.load(); + this.statement = statement; } - 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()); - public void CreateTable(Object entity) { - 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); - } - } catch (SQLException e) { - throw ExceptionTranslator.translate(e, entity.getClass(), createTableSQL); - } + Connection connection = DriverManager.getConnection( + url, user, password); + return new DB(connection, connection.createStatement()); } - public void Insert(Object entity) { - 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); - } - } catch (SQLException e) { - throw ExceptionTranslator.translate(e, entity.getClass(), "INSERT"); - } + public void Insert(Object object) throws SQLException { + String statement = Parser.parseStatement(new CreateStatementParser(), object); + System.out.println(statement); + this.statement.execute(statement); } - 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); - - if (!rs.next()) { - throw new EntityNotFoundException(entityClass, selectSQL); - } - return rs; - } catch (SQLException e) { - throw ExceptionTranslator.translate(e, entityClass, query); - } + public void CreateTable(Object object) throws SQLException { + String statement = Parser.parseStatement(new CreateTableStatementParser(), object); + boolean result = this.statement.execute(statement); + System.out.println(result); } - public void Close() { - try { - if (connection != null && !connection.isClosed()) { - connection.close(); - } - } catch (SQLException e) { - e.printStackTrace(); + public ResultSet Find(Object object) throws SQLException { + String statement = Parser.parseStatement(new FindStatementParser(), object); + + ResultSet resultSet = this.statement.executeQuery(statement); + if (!resultSet.next()) { + throw new RuntimeException("No results from query"); } + return resultSet; } } diff --git a/src/main/java/org/example/database/OrmConfig.java b/src/main/java/org/example/database/OrmConfig.java deleted file mode 100644 index aa4dbea..0000000 --- a/src/main/java/org/example/database/OrmConfig.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.example.database; - -import java.io.InputStream; -import java.util.Properties; - -public class OrmConfig { - public enum DdlAuto { - CREATE_IF_NOT_EXISTS, - NONE - } - - private DdlAuto ddlAuto = DdlAuto.NONE; - - 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); - } - } - } catch (Exception e) { - // Default to NONE if any error occurs - } - return new OrmConfig(DdlAuto.NONE); - } - - private OrmConfig(DdlAuto ddlAuto) { - this.ddlAuto = ddlAuto; - } - - public DdlAuto getDdlAuto() { - return ddlAuto; - } -} diff --git a/src/main/java/org/example/database/annotation/Column.java b/src/main/java/org/example/database/annotation/Column.java deleted file mode 100644 index e6c27ba..0000000 --- a/src/main/java/org/example/database/annotation/Column.java +++ /dev/null @@ -1,15 +0,0 @@ -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; - -@Target(ElementType.FIELD) -@Retention(RetentionPolicy.RUNTIME) -public @interface Column { - String name() default ""; - boolean nullable() default true; - int length() default 255; - boolean unique() default false; -} diff --git a/src/main/java/org/example/database/annotation/Entity.java b/src/main/java/org/example/database/annotation/Entity.java deleted file mode 100644 index 0b3df18..0000000 --- a/src/main/java/org/example/database/annotation/Entity.java +++ /dev/null @@ -1,11 +0,0 @@ -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; - -@Target(ElementType.TYPE) -@Retention(RetentionPolicy.RUNTIME) -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 deleted file mode 100644 index 39ed260..0000000 --- a/src/main/java/org/example/database/annotation/Id.java +++ /dev/null @@ -1,11 +0,0 @@ -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; - -@Target(ElementType.FIELD) -@Retention(RetentionPolicy.RUNTIME) -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 deleted file mode 100644 index 55d1ff1..0000000 --- a/src/main/java/org/example/database/annotation/Table.java +++ /dev/null @@ -1,12 +0,0 @@ -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; - -@Target(ElementType.TYPE) -@Retention(RetentionPolicy.RUNTIME) -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 deleted file mode 100644 index e2d1c93..0000000 --- a/src/main/java/org/example/database/annotation/Transient.java +++ /dev/null @@ -1,11 +0,0 @@ -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; - -@Target(ElementType.FIELD) -@Retention(RetentionPolicy.RUNTIME) -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 deleted file mode 100644 index a0add99..0000000 --- a/src/main/java/org/example/database/exception/DatabaseException.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.example.database.exception; - -import java.sql.SQLException; - -public class DatabaseException extends OrmException { - public DatabaseException(String message, SQLException cause) { - super(message, cause); - } -} diff --git a/src/main/java/org/example/database/exception/DuplicateKeyException.java b/src/main/java/org/example/database/exception/DuplicateKeyException.java deleted file mode 100644 index ef0d6cc..0000000 --- a/src/main/java/org/example/database/exception/DuplicateKeyException.java +++ /dev/null @@ -1,9 +0,0 @@ -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 + "'"); - } -} diff --git a/src/main/java/org/example/database/exception/EntityNotFoundException.java b/src/main/java/org/example/database/exception/EntityNotFoundException.java deleted file mode 100644 index 84ec734..0000000 --- a/src/main/java/org/example/database/exception/EntityNotFoundException.java +++ /dev/null @@ -1,9 +0,0 @@ -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); - } -} diff --git a/src/main/java/org/example/database/exception/EntityValidationException.java b/src/main/java/org/example/database/exception/EntityValidationException.java deleted file mode 100644 index 7c6a27e..0000000 --- a/src/main/java/org/example/database/exception/EntityValidationException.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.example.database.exception; - -import java.lang.reflect.Class; - -public class EntityValidationException extends OrmException { - public EntityValidationException(String message, Class entityClass) { - super("Entity validation failed for '" + entityClass.getSimpleName() + "': " + message); - } -} diff --git a/src/main/java/org/example/database/exception/ExceptionTranslator.java b/src/main/java/org/example/database/exception/ExceptionTranslator.java deleted file mode 100644 index 52bc2a0..0000000 --- a/src/main/java/org/example/database/exception/ExceptionTranslator.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.example.database.exception; - -import java.lang.reflect.Class; -import java.sql.Connection; -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); - } - } -} diff --git a/src/main/java/org/example/database/exception/OrmException.java b/src/main/java/org/example/database/exception/OrmException.java deleted file mode 100644 index 3966037..0000000 --- a/src/main/java/org/example/database/exception/OrmException.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.example.database.exception; - -public class OrmException extends RuntimeException { - public OrmException(String message) { - super(message); - } - - public OrmException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/src/main/java/org/example/database/parser/CreateStatementParser.java b/src/main/java/org/example/database/parser/CreateStatementParser.java index 4a3db99..79c68a1 100644 --- a/src/main/java/org/example/database/parser/CreateStatementParser.java +++ b/src/main/java/org/example/database/parser/CreateStatementParser.java @@ -1,11 +1,46 @@ package org.example.database.parser; -import org.example.database.reflection.EntityMetadata; +import org.example.database.reflection.ReflectionUtils; + +import java.lang.reflect.Field; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; public class CreateStatementParser implements StatementParser { + private String[] keys = {"INSERT", "INTO", "VALUES"}; + @Override - public String parse(EntityMetadata metadata) { - // Implementation to generate INSERT SQL using metadata - return ""; + public String parse(Object object) { + Map keyValueFields = ReflectionUtils.getKeyValuesMap(object); + return createStatement(keyValueFields, object.getClass().getSimpleName()); + } + + private String createStatement(Map keyValueFields, String name) { + StringBuilder statement = new StringBuilder(); + + for(String key : keys ) { + statement.append(key); + statement.append(ParserConstants.SPACE_DELIMITER); + if (key.equals("INTO")) { + statement.append(name); + statement.append(getKeysStatement(keyValueFields)); + statement.append(ParserConstants.SPACE_DELIMITER); + } + if (key.equals("VALUES")) { + statement.append(getValuesStatement(keyValueFields)); + } + } + return statement.toString(); } + + private String getValuesStatement(Map keyValueFields) { + return keyValueFields.entrySet().stream().map(it-> String.format("'%s'",it.getValue())).collect(Collectors.joining(", ","(", ")")); + } + + private String getKeysStatement(Map keys) { + return keys.entrySet().stream().map(Map.Entry::getKey).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..ebbf000 100644 --- a/src/main/java/org/example/database/parser/CreateTableStatementParser.java +++ b/src/main/java/org/example/database/parser/CreateTableStatementParser.java @@ -1,11 +1,39 @@ package org.example.database.parser; -import org.example.database.reflection.EntityMetadata; +import org.example.database.reflection.ReflectionUtils; + +import java.util.Map; +import java.util.stream.Collectors; public class CreateTableStatementParser implements StatementParser { + private String[] keys = {"CREATE", "TABLE"}; + + + @Override - public String parse(EntityMetadata metadata) { - // Implementation to generate CREATE TABLE SQL using metadata - return ""; + public String parse(Object object) { + Map keyTypeFields = ReflectionUtils.getKeyTypeMap(object); + return createStatement(keyTypeFields, object.getClass().getSimpleName()); } + + private String createStatement(Map keyValueFields, String name) { + StringBuilder statement = new StringBuilder(); + + for(String key : keys ) { + statement.append(key); + statement.append(ParserConstants.SPACE_DELIMITER); + if (key.equals("TABLE")) { + statement.append(name); + statement.append(ParserConstants.SPACE_DELIMITER); + statement.append(getTableKeyAndTypeStatement(keyValueFields)); + } + } + return statement.toString(); + } + + private String getTableKeyAndTypeStatement(Map keyValueFields) { + return keyValueFields.entrySet().stream().map(it -> String.format("%s%s%s",it.getKey(), ParserConstants.SPACE_DELIMITER, it.getValue())).collect(Collectors.joining(",\n","(\n","\n)")); + } + + } diff --git a/src/main/java/org/example/database/parser/FindStatementParser.java b/src/main/java/org/example/database/parser/FindStatementParser.java index 81213db..f7ed18a 100644 --- a/src/main/java/org/example/database/parser/FindStatementParser.java +++ b/src/main/java/org/example/database/parser/FindStatementParser.java @@ -1,11 +1,35 @@ package org.example.database.parser; -import org.example.database.reflection.EntityMetadata; +import org.example.database.reflection.ReflectionUtils; + +import java.util.Map; +import java.util.stream.Collectors; public class FindStatementParser implements StatementParser { + String[] keys = { "SELECT", "FROM"}; @Override - public String parse(EntityMetadata metadata) { - // Implementation to generate SELECT SQL using metadata - return ""; + public String parse(Object object) { + var mapKeyValue = ReflectionUtils.getKeyValuesMap(object); + return createStatement(mapKeyValue, object.getClass().getSimpleName()); + } + + private String createStatement(Map mapKeyValue, String tableName) { + var statement = new StringBuilder(); + for(var key: keys) { + statement.append(key); + statement.append(ParserConstants.SPACE_DELIMITER); + if (key.equals("SELECT")) { + statement.append(getColumns(mapKeyValue)); + statement.append(ParserConstants.SPACE_DELIMITER); + } + if (key.equals("FROM")) { + statement.append(tableName); + } + } + return statement.toString(); + } + + private String getColumns(Map mapKeyValue) { + return mapKeyValue.entrySet().stream().map(it -> it.getKey()).collect(Collectors.joining(", ","","")); } } diff --git a/src/main/java/org/example/database/parser/Parser.java b/src/main/java/org/example/database/parser/Parser.java index 1a07f38..77a3779 100644 --- a/src/main/java/org/example/database/parser/Parser.java +++ b/src/main/java/org/example/database/parser/Parser.java @@ -1,9 +1,8 @@ package org.example.database.parser; -import org.example.database.reflection.EntityMetadata; public class Parser { - public static String parseStatement(StatementParser statement, EntityMetadata metadata) { - return statement.parse(metadata); + public static String parseStatement(StatementParser statement, Object object) { + return statement.parse(object); } } diff --git a/src/main/java/org/example/database/parser/StatementParser.java b/src/main/java/org/example/database/parser/StatementParser.java index 4d4a70d..0d851f1 100644 --- a/src/main/java/org/example/database/parser/StatementParser.java +++ b/src/main/java/org/example/database/parser/StatementParser.java @@ -1,7 +1,6 @@ package org.example.database.parser; -import org.example.database.reflection.EntityMetadata; - public interface StatementParser { - String parse(EntityMetadata metadata); + + String parse(Object object); } diff --git a/src/main/java/org/example/database/reflection/ColumnMetadata.java b/src/main/java/org/example/database/reflection/ColumnMetadata.java deleted file mode 100644 index a314eb8..0000000 --- a/src/main/java/org/example/database/reflection/ColumnMetadata.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.example.database.reflection; - -import java.lang.reflect.Field; - -public class ColumnMetadata { - public String columnName; - public String sqlType; - public boolean nullable; - public boolean unique; - public boolean isPrimaryKey; - public Object value; - - public static ColumnMetadata fromField(Field field) { - ColumnMetadata metadata = new ColumnMetadata(); - metadata.columnName = field.getName(); - metadata.nullable = true; - metadata.unique = false; - metadata.isPrimaryKey = false; - return metadata; - } -} diff --git a/src/main/java/org/example/database/reflection/EntityMetadata.java b/src/main/java/org/example/database/reflection/EntityMetadata.java deleted file mode 100644 index 2638512..0000000 --- a/src/main/java/org/example/database/reflection/EntityMetadata.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.example.database.reflection; - -import java.lang.reflect.Field; -import java.util.ArrayList; -import java.util.List; - -public class EntityMetadata { - public String tableName; - public List columns; - public ColumnMetadata idColumn; - - public static EntityMetadata fromClass(Class entityClass) { - EntityMetadata metadata = new EntityMetadata(); - // Logic to derive table name and column metadata from annotations - 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; - } -} diff --git a/src/main/java/org/example/database/reflection/EntityValidator.java b/src/main/java/org/example/database/reflection/EntityValidator.java deleted file mode 100644 index 3eee991..0000000 --- a/src/main/java/org/example/database/reflection/EntityValidator.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.example.database.reflection; - -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); - } - - int idCount = 0; - for (Field field : entityClass.getDeclaredFields()) { - if (field.isAnnotationPresent(Id.class)) { - idCount++; - } - } - - if (idCount != 1) { - throw new EntityValidationException("Entity must have exactly one @Id field", entityClass); - } - } -} diff --git a/src/main/resources/orm.properties b/src/main/resources/orm.properties deleted file mode 100644 index 48245e2..0000000 --- a/src/main/resources/orm.properties +++ /dev/null @@ -1,2 +0,0 @@ -# Options: create-if-not-exists, none (default: none) -orm.ddl-auto=create-if-not-exists \ No newline at end of file From 16a6b7cade4463eee26b4255c69be1d570e756b8 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 12 Apr 2026 18:34:15 +0000 Subject: [PATCH 2/5] docs: add write plan for ORM reimplementation --- ...exceptions-auto-ddl-implementation-plan.md | 76 +++++++++++++++++++ docs/write-plans/README.md | 24 ++++++ 2 files changed, 100 insertions(+) create mode 100644 docs/write-plans/2026-04-12-orm-annotations-exceptions-auto-ddl-implementation-plan.md create mode 100644 docs/write-plans/README.md 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..440f6cd --- /dev/null +++ b/docs/write-plans/2026-04-12-orm-annotations-exceptions-auto-ddl-implementation-plan.md @@ -0,0 +1,76 @@ +# 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. + +## 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 + +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. From ff6dc8276b2c866e54a28e372339c54f59afbf41 Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 12 Apr 2026 18:44:46 +0000 Subject: [PATCH 3/5] feat: implement ORM metadata annotations and auto-ddl --- ...exceptions-auto-ddl-implementation-plan.md | 6 ++ src/main/java/org/example/database/DB.java | 99 +++++++++++++++---- .../java/org/example/database/OrmConfig.java | 53 ++++++++++ .../example/database/annotation/Column.java | 15 +++ .../example/database/annotation/Entity.java | 11 +++ .../org/example/database/annotation/Id.java | 11 +++ .../example/database/annotation/Table.java | 12 +++ .../database/annotation/Transient.java | 11 +++ .../database/exception/DatabaseException.java | 20 ++++ .../exception/DuplicateKeyException.java | 26 +++++ .../exception/EntityNotFoundException.java | 20 ++++ .../exception/EntityValidationException.java | 14 +++ .../exception/ExceptionTranslator.java | 43 ++++++++ .../database/exception/OrmException.java | 11 +++ .../parser/CreateStatementParser.java | 53 ++++++---- .../parser/CreateTableStatementParser.java | 45 ++++++--- .../database/parser/FindStatementParser.java | 25 +++-- .../org/example/database/parser/Parser.java | 5 +- .../database/parser/StatementParser.java | 4 +- .../database/reflection/ColumnMetadata.java | 27 +++++ .../database/reflection/EntityMetadata.java | 94 ++++++++++++++++++ .../database/reflection/EntityValidator.java | 40 ++++++++ .../database/reflection/ReflectionUtils.java | 50 +--------- src/main/resources/orm.properties | 2 + .../java/org/example/CreateStatementTest.java | 8 +- .../org/example/CreateTableStatementTest.java | 13 +-- .../java/org/example/EntityMetadataTest.java | 49 +++++++++ .../java/org/example/EntityValidatorTest.java | 56 +++++++++++ .../org/example/ExceptionTranslatorTest.java | 31 ++++++ .../java/org/example/FindStatementTest.java | 6 +- src/test/java/org/example/OrmConfigTest.java | 26 +++++ src/test/java/org/example/UserTest.java | 15 ++- 32 files changed, 770 insertions(+), 131 deletions(-) create mode 100644 src/main/java/org/example/database/OrmConfig.java create mode 100644 src/main/java/org/example/database/annotation/Column.java create mode 100644 src/main/java/org/example/database/annotation/Entity.java create mode 100644 src/main/java/org/example/database/annotation/Id.java create mode 100644 src/main/java/org/example/database/annotation/Table.java create mode 100644 src/main/java/org/example/database/annotation/Transient.java create mode 100644 src/main/java/org/example/database/exception/DatabaseException.java create mode 100644 src/main/java/org/example/database/exception/DuplicateKeyException.java create mode 100644 src/main/java/org/example/database/exception/EntityNotFoundException.java create mode 100644 src/main/java/org/example/database/exception/EntityValidationException.java create mode 100644 src/main/java/org/example/database/exception/ExceptionTranslator.java create mode 100644 src/main/java/org/example/database/exception/OrmException.java create mode 100644 src/main/java/org/example/database/reflection/ColumnMetadata.java create mode 100644 src/main/java/org/example/database/reflection/EntityMetadata.java create mode 100644 src/main/java/org/example/database/reflection/EntityValidator.java create mode 100644 src/main/resources/orm.properties create mode 100644 src/test/java/org/example/EntityMetadataTest.java create mode 100644 src/test/java/org/example/EntityValidatorTest.java create mode 100644 src/test/java/org/example/ExceptionTranslatorTest.java create mode 100644 src/test/java/org/example/OrmConfigTest.java 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 index 440f6cd..dd9d6f0 100644 --- 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 @@ -59,6 +59,8 @@ The previous implementation was reverted so the feature can be rebuilt with clea 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 @@ -73,4 +75,8 @@ The previous implementation was reverted so the feature can be rebuilt with clea ## 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`. + Add dated notes below when implementation decisions change or additional tradeoffs appear. diff --git a/src/main/java/org/example/database/DB.java b/src/main/java/org/example/database/DB.java index f25a3b3..2787f03 100644 --- a/src/main/java/org/example/database/DB.java +++ b/src/main/java/org/example/database/DB.java @@ -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, EntityMetadata> schemaCache = new HashMap<>(); + private final Set> 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; } } diff --git a/src/main/java/org/example/database/OrmConfig.java b/src/main/java/org/example/database/OrmConfig.java new file mode 100644 index 0000000..318cbc8 --- /dev/null +++ b/src/main/java/org/example/database/OrmConfig.java @@ -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); + } + } +} diff --git a/src/main/java/org/example/database/annotation/Column.java b/src/main/java/org/example/database/annotation/Column.java new file mode 100644 index 0000000..7b2f4d5 --- /dev/null +++ b/src/main/java/org/example/database/annotation/Column.java @@ -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; +} diff --git a/src/main/java/org/example/database/annotation/Entity.java b/src/main/java/org/example/database/annotation/Entity.java new file mode 100644 index 0000000..4d22f54 --- /dev/null +++ b/src/main/java/org/example/database/annotation/Entity.java @@ -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 { +} diff --git a/src/main/java/org/example/database/annotation/Id.java b/src/main/java/org/example/database/annotation/Id.java new file mode 100644 index 0000000..1faa6bc --- /dev/null +++ b/src/main/java/org/example/database/annotation/Id.java @@ -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 { +} diff --git a/src/main/java/org/example/database/annotation/Table.java b/src/main/java/org/example/database/annotation/Table.java new file mode 100644 index 0000000..d31b5ea --- /dev/null +++ b/src/main/java/org/example/database/annotation/Table.java @@ -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 ""; +} diff --git a/src/main/java/org/example/database/annotation/Transient.java b/src/main/java/org/example/database/annotation/Transient.java new file mode 100644 index 0000000..2a52570 --- /dev/null +++ b/src/main/java/org/example/database/annotation/Transient.java @@ -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 { +} diff --git a/src/main/java/org/example/database/exception/DatabaseException.java b/src/main/java/org/example/database/exception/DatabaseException.java new file mode 100644 index 0000000..f333b9b --- /dev/null +++ b/src/main/java/org/example/database/exception/DatabaseException.java @@ -0,0 +1,20 @@ +package org.example.database.exception; + +public class DatabaseException extends OrmException { + 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 new file mode 100644 index 0000000..03599fd --- /dev/null +++ b/src/main/java/org/example/database/exception/DuplicateKeyException.java @@ -0,0 +1,26 @@ +package org.example.database.exception; + +public class DuplicateKeyException extends OrmException { + 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 new file mode 100644 index 0000000..5310335 --- /dev/null +++ b/src/main/java/org/example/database/exception/EntityNotFoundException.java @@ -0,0 +1,20 @@ +package org.example.database.exception; + +public class EntityNotFoundException extends OrmException { + 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 new file mode 100644 index 0000000..cc8f650 --- /dev/null +++ b/src/main/java/org/example/database/exception/EntityValidationException.java @@ -0,0 +1,14 @@ +package org.example.database.exception; + +public class EntityValidationException extends OrmException { + private final Class entityClass; + + public EntityValidationException(String message, Class entityClass) { + 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 new file mode 100644 index 0000000..fc72839 --- /dev/null +++ b/src/main/java/org/example/database/exception/ExceptionTranslator.java @@ -0,0 +1,43 @@ +package org.example.database.exception; + +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) { + 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/exception/OrmException.java b/src/main/java/org/example/database/exception/OrmException.java new file mode 100644 index 0000000..3966037 --- /dev/null +++ b/src/main/java/org/example/database/exception/OrmException.java @@ -0,0 +1,11 @@ +package org.example.database.exception; + +public class OrmException extends RuntimeException { + public OrmException(String message) { + super(message); + } + + public OrmException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/example/database/parser/CreateStatementParser.java b/src/main/java/org/example/database/parser/CreateStatementParser.java index 79c68a1..f63d658 100644 --- a/src/main/java/org/example/database/parser/CreateStatementParser.java +++ b/src/main/java/org/example/database/parser/CreateStatementParser.java @@ -1,46 +1,65 @@ package org.example.database.parser; -import org.example.database.reflection.ReflectionUtils; +import org.example.database.reflection.ColumnMetadata; +import org.example.database.reflection.EntityMetadata; -import java.lang.reflect.Field; -import java.util.LinkedHashMap; -import java.util.Map; import java.util.stream.Collectors; public class CreateStatementParser implements StatementParser { - private String[] keys = {"INSERT", "INTO", "VALUES"}; + private final String[] keys = {"INSERT", "INTO", "VALUES"}; @Override - public String parse(Object object) { - Map keyValueFields = ReflectionUtils.getKeyValuesMap(object); - return createStatement(keyValueFields, object.getClass().getSimpleName()); + public String parse(EntityMetadata metadata) { + return createStatement(metadata); } - private String createStatement(Map keyValueFields, String name) { + private String createStatement(EntityMetadata metadata) { StringBuilder statement = new StringBuilder(); - for(String key : keys ) { + for (String key : keys) { statement.append(key); statement.append(ParserConstants.SPACE_DELIMITER); if (key.equals("INTO")) { - statement.append(name); - statement.append(getKeysStatement(keyValueFields)); + statement.append(metadata.tableName); + statement.append(getKeysStatement(metadata)); statement.append(ParserConstants.SPACE_DELIMITER); } if (key.equals("VALUES")) { - statement.append(getValuesStatement(keyValueFields)); + statement.append(getValuesStatement(metadata)); } } return statement.toString(); } - private String getValuesStatement(Map keyValueFields) { - return keyValueFields.entrySet().stream().map(it-> String.format("'%s'",it.getValue())).collect(Collectors.joining(", ","(", ")")); + private String getValuesStatement(EntityMetadata metadata) { + return metadata.columns.stream() + .map(this::formatValue) + .collect(Collectors.joining(", ", "(", ")")); } - private String getKeysStatement(Map keys) { - return keys.entrySet().stream().map(Map.Entry::getKey).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 ebbf000..b3179e7 100644 --- a/src/main/java/org/example/database/parser/CreateTableStatementParser.java +++ b/src/main/java/org/example/database/parser/CreateTableStatementParser.java @@ -1,39 +1,56 @@ package org.example.database.parser; -import org.example.database.reflection.ReflectionUtils; +import org.example.database.reflection.ColumnMetadata; +import org.example.database.reflection.EntityMetadata; -import java.util.Map; import java.util.stream.Collectors; public class CreateTableStatementParser implements StatementParser { - private String[] keys = {"CREATE", "TABLE"}; - - + private final String[] keys = {"CREATE", "TABLE"}; @Override - public String parse(Object object) { - Map keyTypeFields = ReflectionUtils.getKeyTypeMap(object); - return createStatement(keyTypeFields, object.getClass().getSimpleName()); + public String parse(EntityMetadata metadata) { + return createStatement(metadata); } - private String createStatement(Map keyValueFields, String name) { + private String createStatement(EntityMetadata metadata) { StringBuilder statement = new StringBuilder(); - for(String key : keys ) { + for (String key : keys) { statement.append(key); statement.append(ParserConstants.SPACE_DELIMITER); if (key.equals("TABLE")) { - statement.append(name); + statement.append("IF NOT EXISTS"); statement.append(ParserConstants.SPACE_DELIMITER); - statement.append(getTableKeyAndTypeStatement(keyValueFields)); + statement.append(metadata.tableName); + statement.append(ParserConstants.SPACE_DELIMITER); + statement.append(getTableKeyAndTypeStatement(metadata)); } } return statement.toString(); } - private String getTableKeyAndTypeStatement(Map keyValueFields) { - return keyValueFields.entrySet().stream().map(it -> String.format("%s%s%s",it.getKey(), ParserConstants.SPACE_DELIMITER, it.getValue())).collect(Collectors.joining(",\n","(\n","\n)")); + 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 f7ed18a..346bf41 100644 --- a/src/main/java/org/example/database/parser/FindStatementParser.java +++ b/src/main/java/org/example/database/parser/FindStatementParser.java @@ -1,35 +1,34 @@ package org.example.database.parser; -import org.example.database.reflection.ReflectionUtils; +import org.example.database.reflection.EntityMetadata; -import java.util.Map; import java.util.stream.Collectors; public class FindStatementParser implements StatementParser { - String[] keys = { "SELECT", "FROM"}; + private final String[] keys = {"SELECT", "FROM"}; + @Override - public String parse(Object object) { - var mapKeyValue = ReflectionUtils.getKeyValuesMap(object); - return createStatement(mapKeyValue, object.getClass().getSimpleName()); + public String parse(EntityMetadata metadata) { + return createStatement(metadata); } - private String createStatement(Map mapKeyValue, String tableName) { - var statement = new StringBuilder(); - for(var key: keys) { + 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(mapKeyValue)); + statement.append(getColumns(metadata)); statement.append(ParserConstants.SPACE_DELIMITER); } if (key.equals("FROM")) { - statement.append(tableName); + statement.append(metadata.tableName); } } return statement.toString(); } - private String getColumns(Map mapKeyValue) { - return mapKeyValue.entrySet().stream().map(it -> it.getKey()).collect(Collectors.joining(", ","","")); + 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/Parser.java b/src/main/java/org/example/database/parser/Parser.java index 77a3779..1a07f38 100644 --- a/src/main/java/org/example/database/parser/Parser.java +++ b/src/main/java/org/example/database/parser/Parser.java @@ -1,8 +1,9 @@ package org.example.database.parser; +import org.example.database.reflection.EntityMetadata; public class Parser { - public static String parseStatement(StatementParser statement, Object object) { - return statement.parse(object); + public static String parseStatement(StatementParser statement, EntityMetadata metadata) { + return statement.parse(metadata); } } diff --git a/src/main/java/org/example/database/parser/StatementParser.java b/src/main/java/org/example/database/parser/StatementParser.java index 0d851f1..d159245 100644 --- a/src/main/java/org/example/database/parser/StatementParser.java +++ b/src/main/java/org/example/database/parser/StatementParser.java @@ -1,6 +1,8 @@ package org.example.database.parser; +import org.example.database.reflection.EntityMetadata; + public interface StatementParser { - String parse(Object object); + 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 new file mode 100644 index 0000000..019345d --- /dev/null +++ b/src/main/java/org/example/database/reflection/ColumnMetadata.java @@ -0,0 +1,27 @@ +package org.example.database.reflection; + +import java.lang.reflect.Field; + +public class ColumnMetadata { + public String columnName; + public String sqlType; + public boolean nullable; + public boolean unique; + public boolean isPrimaryKey; + public Object value; + public Class javaType; + public Field field; + + public ColumnMetadata copy() { + ColumnMetadata metadata = new ColumnMetadata(); + 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 new file mode 100644 index 0000000..f3231cc --- /dev/null +++ b/src/main/java/org/example/database/reflection/EntityMetadata.java @@ -0,0 +1,94 @@ +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; + +public class EntityMetadata { + public String tableName; + public List columns; + public ColumnMetadata idColumn; + + public static EntityMetadata fromClass(Class entityClass) { + EntityMetadata metadata = new EntityMetadata(); + 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 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 new file mode 100644 index 0000000..a88e100 --- /dev/null +++ b/src/main/java/org/example/database/reflection/EntityValidator.java @@ -0,0 +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( + 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( + 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 new file mode 100644 index 0000000..9ed47ac --- /dev/null +++ b/src/main/resources/orm.properties @@ -0,0 +1,2 @@ +# Options: create-if-not-exists, none (default: none) +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; } } From 04c9c46c3c75e497da527cd0abf1b88c67dd8c4e Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 12 Apr 2026 18:54:12 +0000 Subject: [PATCH 4/5] test: align integration test with new entity model --- .../java/org/example/integration/CreateUserTest.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/test/java/org/example/integration/CreateUserTest.java b/src/test/java/org/example/integration/CreateUserTest.java index 58e5dd6..89e69af 100644 --- a/src/test/java/org/example/integration/CreateUserTest.java +++ b/src/test/java/org/example/integration/CreateUserTest.java @@ -5,9 +5,9 @@ import org.example.database.Driver; import org.junit.Assert; import org.junit.Test; +import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.PostgreSQLContainer; -import javax.xml.transform.Result; import java.sql.ResultSet; import java.sql.SQLException; @@ -15,10 +15,14 @@ public class CreateUserTest { @Test public void shouldCreateUserTest() throws SQLException, ClassNotFoundException { + if (!DockerClientFactory.instance().isDockerAvailable()) { + return; + } + 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 +32,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()); } } From 7a43719bd7fb1345f43627d5b0b98a3be75d5e5c Mon Sep 17 00:00:00 2001 From: hermes Date: Sun, 12 Apr 2026 18:55:59 +0000 Subject: [PATCH 5/5] ci: require docker-backed test execution --- .github/workflows/maven.yml | 24 +++++++++++-------- ...exceptions-auto-ddl-implementation-plan.md | 2 ++ .../example/integration/CreateUserTest.java | 5 ---- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 4bf9282..be4a291 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -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 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 index dd9d6f0..7a90e95 100644 --- 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 @@ -78,5 +78,7 @@ The previous implementation was reverted so the feature can be rebuilt with clea ### 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/src/test/java/org/example/integration/CreateUserTest.java b/src/test/java/org/example/integration/CreateUserTest.java index 89e69af..85bbd37 100644 --- a/src/test/java/org/example/integration/CreateUserTest.java +++ b/src/test/java/org/example/integration/CreateUserTest.java @@ -5,7 +5,6 @@ import org.example.database.Driver; import org.junit.Assert; import org.junit.Test; -import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.PostgreSQLContainer; import java.sql.ResultSet; @@ -15,10 +14,6 @@ public class CreateUserTest { @Test public void shouldCreateUserTest() throws SQLException, ClassNotFoundException { - if (!DockerClientFactory.instance().isDockerAvailable()) { - return; - } - UserTest userTest = new UserTest(); userTest.setId("1"); userTest.setName("thalles");