diff --git a/.github/workflows/maven.yml b/.github/workflows/main.yml similarity index 54% rename from .github/workflows/maven.yml rename to .github/workflows/main.yml index 4bf9282..be4a291 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/main.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/superpowers/specs/2026-04-11-orm-annotations-exceptions-auto-ddl-design.md b/docs/superpowers/specs/2026-04-11-orm-annotations-exceptions-auto-ddl-design.md new file mode 100644 index 0000000..8aba307 --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-orm-annotations-exceptions-auto-ddl-design.md @@ -0,0 +1,359 @@ +# ORM: Annotations, Exception Hierarchy, and Auto-DDL + +**Date:** 2026-04-11 +**Project:** javaorm — a learning-oriented Java ORM built on JDBC and reflection +**Status:** Approved (reviewed) + +--- + +## Background + +The current ORM derives table and column names entirely from Java class and field names via reflection. There are no custom annotations, exception types are either raw `RuntimeException` or propagated `SQLException`, and schema creation (`CreateTable`) is manual. + +This spec covers three interconnected features: +1. JPA-style annotations (`@Entity`, `@Table`, `@Id`, `@Column`, `@Transient`) +2. A typed exception hierarchy with contextual information +3. Auto-DDL triggered lazily per entity, configurable via `orm.properties` + +--- + +## Approach + +**Metadata layer (Approach A):** A new `EntityMetadata` class extracts annotation data from an entity class once and is cached per session. The existing parsers are updated to consume `EntityMetadata` instead of calling `ReflectionUtils` directly. Exception translation and auto-DDL logic live in `DB`. This mirrors how Hibernate separates `ClassMetadata` extraction from SQL generation. + +--- + +## 1. Annotations + +**Package:** `org.example.database.annotation` + +### `@Entity` +- Target: `ElementType.TYPE` +- Retention: `RetentionPolicy.RUNTIME` +- Marks a class as an ORM-managed entity. Required on every class passed to `DB` operations. + +### `@Table` +- Target: `ElementType.TYPE` +- Retention: `RetentionPolicy.RUNTIME` +- Attribute: `String name()` — defaults to `""` (ORM falls back to class simple name) +- Overrides the SQL table name. + +### `@Id` +- Target: `ElementType.FIELD` +- Retention: `RetentionPolicy.RUNTIME` +- Marks the primary key field. Exactly one field per entity must carry this annotation. + +### `@Column` +- Target: `ElementType.FIELD` +- Retention: `RetentionPolicy.RUNTIME` +- Attributes: + - `String name()` — defaults to `""` (ORM falls back to field name) + - `boolean nullable()` — defaults to `true` + - `int length()` — defaults to `255` (applies to `varchar`) + - `boolean unique()` — defaults to `false` + +### `@Transient` +- Target: `ElementType.FIELD` +- Retention: `RetentionPolicy.RUNTIME` +- Excludes the field from all SQL operations (INSERT, SELECT, CREATE TABLE). + +### Example entity + +```java +@Entity +@Table(name = "users") +public class UserTest { + @Id + private String id; + + @Column(name = "user_name", nullable = false, length = 100) + private String name; + + @Column + private int age; + + @Transient + private String internalNote; +} +``` + +--- + +## 2. Entity Validation + +**Class:** `EntityValidator` in `org.example.database.reflection` + +Validation runs at the beginning of every `DB` operation, **before** `EntityMetadata.fromClass()` is called. `EntityMetadata.fromClass()` assumes a pre-validated entity class and does not re-run these checks. + +Method signature: `public static void validate(Class entityClass)` — accepts the entity class (not the instance). `DB` calls it as `EntityValidator.validate(entity.getClass())`. + +Rules (in order): +1. The class must have `@Entity`. Violation → `EntityValidationException("Class X is not annotated with @Entity", entityClass)` +2. Exactly one field must have `@Id`. Zero or more than one → `EntityValidationException("Entity X must have exactly one @Id field", entityClass)` +3. The `@Id` field must not also have `@Transient`. Violation → `EntityValidationException("@Id field cannot be @Transient in entity X", entityClass)` + +--- + +## 3. Exception Hierarchy + +**Package:** `org.example.database.exception` + +All exceptions are unchecked (extend `RuntimeException`) so callers are not forced to handle them, matching the pattern used by Spring's `DataAccessException`. + +``` +RuntimeException +└── OrmException + ├── EntityValidationException + ├── EntityNotFoundException + ├── DuplicateKeyException + └── DatabaseException +``` + +### `OrmException` +Base class. Constructor: `OrmException(String message)` and `OrmException(String message, Throwable cause)`. + +### `EntityValidationException` +Thrown when entity structure is invalid (missing `@Entity`, `@Id` issues). +Extra context: `Class entityClass` +Message example: `"Entity validation failed for 'UserTest': No @Id field found"` + +### `EntityNotFoundException` +Thrown when `Find()` returns zero rows. +Extra context: `Class entityClass`, `String sqlQuery` +Message example: `"No results found for entity 'UserTest' with query: SELECT id, name FROM users"` + +**`Find` result-set fix:** The current `DB.Find` calls `resultSet.next()` which advances the cursor and consumes the first row, so returning the `ResultSet` to the caller leaves them on row 1 — skipping it. The fix uses a single consistent strategy: call `resultSet.next()` once after executing the query. If it returns `false`, throw `EntityNotFoundException`. If it returns `true`, return the `ResultSet` to the caller — the cursor is already on the first row. Callers must **not** call `next()` again before reading the first row. The `isBeforeFirst()` approach is not used. + +### `DuplicateKeyException` +Thrown when INSERT violates a UNIQUE or PRIMARY KEY constraint (PostgreSQL SQLState `23505`). +Extra context: `Class entityClass`, `String sqlQuery`, `String constraintName` +Message example: `"Duplicate key violation on entity 'UserTest': unique constraint 'users_pkey'"` + +### `DatabaseException` +Wraps any other unexpected `SQLException`. +Extra context: `String sqlQuery`, `SQLException cause` +Message example: `"Database error executing query 'INSERT INTO users...': "` + +### Exception Translation + +A new `ExceptionTranslator` class (in `org.example.database.exception`) handles translation: + +```java +public class ExceptionTranslator { + public static OrmException translate(SQLException e, Class entityClass, String sql); +} +``` + +Translation rules (checked in order; first match wins): +1. SQLState equals `23505` → `DuplicateKeyException` +2. SQLState starts with `23` → `DatabaseException` (other integrity violations) +3. Any other `SQLException` → `DatabaseException` + +`DB.Insert`, `DB.Find`, and `DB.CreateTable` no longer declare `throws SQLException`. They catch `SQLException` internally and call `ExceptionTranslator.translate()`. For `DB.CreateTable`, the entity class is available from the entity argument (call `entity.getClass()`) and is passed to the translator as `entityClass`. + +--- + +## 4. EntityMetadata + +**Package:** `org.example.database.reflection` + +### `ColumnMetadata` + +```java +class ColumnMetadata { + public String columnName; // resolved name (from @Column(name) or field name) + public String sqlType; // SQL type string (e.g. "varchar(100)", "int", "float8") + public boolean nullable; // false forces NOT NULL; @Id always forces nullable=false + public boolean unique; + public boolean isPrimaryKey; // true if @Id present + public Object value; // current field value (only populated in withValues copies) +} +``` + +### `EntityMetadata` + +```java +class EntityMetadata { + public String tableName; // from @Table(name) or class simple name + public List columns; // non-@Transient fields, in declaration order + public ColumnMetadata idColumn; // the @Id field +} +``` + +**Factory methods:** +- `public static EntityMetadata fromClass(Class entityClass)` — builds **structural** metadata (table name, column names, SQL types, constraints) from the class definition. This is cached per class. +- `public EntityMetadata withValues(Object entity)` — instance method. Takes the cached structural `EntityMetadata` and produces a new instance with field values populated from the entity instance. This is called on every operation, not cached. + +`ColumnMetadata` holds `value` only in the per-call copy returned by `withValues`. The cached structural metadata never holds values. + +`ReflectionUtils` is updated internally to populate `EntityMetadata` instead of returning raw `Map`. The existing public API of `ReflectionUtils` may be removed or made package-private, as `EntityMetadata` factory methods become the public entry point. + +### Caching + +`DB` holds a `Map, EntityMetadata> schemaCache` where each entry is structural-only metadata (no values). On each operation, `DB` calls `cachedMetadata.withValues(entity)` to get a fresh per-call copy with current field values. The cache is populated on first access per entity class. + +--- + +## 5. Auto-DDL + +### Configuration + +**File:** `src/main/resources/orm.properties` + +```properties +# Options: create-if-not-exists, none (default: none) +orm.ddl-auto=create-if-not-exists +``` + +### `OrmConfig` + +**Class:** `OrmConfig` in `org.example.database` + +```java +class OrmConfig { + DdlAuto ddlAuto; + static OrmConfig load(); // reads classpath:orm.properties; returns NONE if file absent +} + +enum DdlAuto { + CREATE_IF_NOT_EXISTS, + NONE +} +``` + +`DB.Open()` calls `OrmConfig.load()` and stores the result on the `DB` instance. + +### Lazy trigger + +`DB` holds a `Set> initializedEntities`. + +Before any `Insert` operation: +1. `EntityValidator.validate(entity.getClass())` is called. +2. `DB` retrieves or populates the structural `EntityMetadata` from `schemaCache` via `EntityMetadata.fromClass(entityClass)`. +3. If `ddlAuto == CREATE_IF_NOT_EXISTS` and `entityClass` is not in `initializedEntities`: + - Execute `CREATE TABLE IF NOT EXISTS` using `CreateTableStatementParser` with the **structural** `EntityMetadata` (from `schemaCache`, no field values needed for DDL) + - Add `entityClass` to `initializedEntities` +4. If `ddlAuto == NONE` — skip auto-DDL entirely. +5. Call `structuralMetadata.withValues(entity)` to get a per-call copy with field values. +6. Pass the value-bearing `EntityMetadata` to `CreateStatementParser`. + +Before any `Find` operation: +1. `EntityValidator.validate(entity.getClass())` is called. +2. `DB` retrieves or populates the structural `EntityMetadata` from `schemaCache`. +3. Auto-DDL does **not** fire for `Find`. Automatically creating an empty table before a SELECT would result in an immediate `EntityNotFoundException` on every fresh session, which is misleading. Schema creation should only be triggered by write operations (`Insert`). +4. Call `structuralMetadata.withValues(entity)` to get a per-call copy with field values. +5. Pass the value-bearing `EntityMetadata` to `FindStatementParser`. + +`db.CreateTable(entity)` remains public. Users can call it manually at any time regardless of the `ddl-auto` setting. + +--- + +## 6. Updated Parser Contract + +Parsers are updated to accept `EntityMetadata` instead of a raw `Object`: + +```java +interface StatementParser { + String parse(EntityMetadata metadata); +} +``` + +`Parser.java` is updated with a new signature: `public static String parseStatement(StatementParser statement, EntityMetadata metadata)`. It simply calls `statement.parse(metadata)` and returns the result. + +Each parser reads `metadata.tableName`, `metadata.columns`, and `metadata.idColumn` to build SQL. SQL type handling (currently in `ReflectionUtils.getSqlType`) moves to `ColumnMetadata` construction inside `EntityMetadata.fromClass()`. + +**Call chain for `Insert` and `Find`:** +See §5 Lazy trigger for the full step-by-step. In summary: `DB` validates → retrieves/caches structural `EntityMetadata` → calls `withValues(entity)` for a per-call copy → passes that copy to the appropriate parser via `Parser.parseStatement(parser, metadata)`. For auto-DDL in `Insert`, the structural (no-values) `EntityMetadata` is passed to `CreateTableStatementParser`. + +**`CreateTableStatementParser` — DDL constraint assembly:** +`CreateTableStatementParser` now emits `CREATE TABLE IF NOT EXISTS` (updated from the current `CREATE TABLE`). `ColumnMetadata` retains `nullable`, `unique`, and `isPrimaryKey` as booleans. During `EntityMetadata.fromClass()` construction, an `@Id` field always has `nullable` forced to `false` regardless of any `@Column(nullable=...)` setting. `CreateTableStatementParser` assembles DDL constraint fragments when iterating columns: +- `nullable = false` → append `NOT NULL` +- `unique = true` → append `UNIQUE` +- `isPrimaryKey = true` → append `PRIMARY KEY` +- Constraints are appended in this order: type, then `PRIMARY KEY`, then `NOT NULL`, then `UNIQUE` +- Example output for `@Id String id`: `id varchar(255) PRIMARY KEY NOT NULL` +- Note: `PRIMARY KEY` implies `NOT NULL` in PostgreSQL — appending both is intentional for explicitness in this learning project. + +The `length` attribute from `@Column` is folded into `sqlType` during `ColumnMetadata` construction: a `String` field with `length=100` produces `sqlType = "varchar(100)"`. + +**SQL type mapping** (used inside `EntityMetadata.fromClass()` when building `ColumnMetadata.sqlType`): + +| Java type | SQL type | +|---|---| +| `String` | `varchar(N)` where N = `@Column(length)` default 255 | +| `int` / `Integer` | `int` | +| `float` / `Float` | `real` | +| `double` / `Double` | `float8` | +| `long` / `Long` | `bigint` | +| `boolean` / `Boolean` | `boolean` | +| anything else | raw Java simple type name (logged as a warning) | + +**Fix: type-aware value quoting** — `CreateStatementParser` currently wraps all values in single quotes. With `ColumnMetadata` carrying the Java field type, numeric types (`int`, `float`, `double`) are no longer quoted in INSERT values. Only `String` and other non-numeric types get single-quote wrapping. The existing `CreateStatementTest` assertion for `age` will change from `'20'` to `20` once `age` is typed as `int`. + +--- + +## 7. Package Structure + +``` +org.example.database/ +├── DB.java (updated) +├── Driver.java (unchanged) +├── OrmConfig.java (new) +├── annotation/ +│ ├── Entity.java (new) +│ ├── Table.java (new) +│ ├── Id.java (new) +│ ├── Column.java (new) +│ └── Transient.java (new) +├── exception/ +│ ├── OrmException.java (new) +│ ├── EntityValidationException.java (new) +│ ├── EntityNotFoundException.java (new) +│ ├── DuplicateKeyException.java (new) +│ ├── DatabaseException.java (new) +│ └── ExceptionTranslator.java (new) +├── reflection/ +│ ├── ReflectionUtils.java (updated: internal use only) +│ ├── EntityMetadata.java (new) +│ ├── ColumnMetadata.java (new) +│ └── EntityValidator.java (new) +└── parser/ + ├── Parser.java (minor update: passes EntityMetadata) + ├── StatementParser.java (updated: parse(EntityMetadata)) + ├── ParserConstants.java (unchanged) + ├── CreateStatementParser.java (updated: consumes EntityMetadata) + ├── CreateTableStatementParser.java (updated: consumes EntityMetadata) + └── FindStatementParser.java (updated: consumes EntityMetadata) +``` + +--- + +## 8. Testing + +Existing tests (`CreateStatementTest`, `CreateTableStatementTest`, `FindStatementTest`) need updating because `StatementParser.parse()` signature changes from `Object` to `EntityMetadata`. `UserTest` in test sources needs `@Entity` and `@Id` annotations added, and `age` changes from `String` to `int`. Assertion changes: +- `CreateStatementTest`: `age` value changes from `'20'` to `20` (no quotes for numeric type) +- `CreateTableStatementTest`: `age` column type changes from `age varchar(255)` to `age int`; the `id` column gains `PRIMARY KEY NOT NULL` constraints +- `FindStatementTest`: no assertion changes (column list is unaffected by type) + +New unit tests to add: +- `EntityValidatorTest` — covers all three validation failure cases +- `ExceptionTranslatorTest` — covers SQLState mapping (in order: `23505`, `23xxx`, other) +- `OrmConfigTest` — covers: file present with valid value, file absent (defaults to `NONE`), unknown value (throws `OrmException` with a clear message — invalid `orm.ddl-auto` value is not silently ignored) +- `EntityMetadataTest` — covers annotation reading, `@Transient` exclusion, name overrides, structural cache vs. value copy + +The integration test (`CreateUserTest`) is updated: +- `UserTest` is annotated with `@Entity` and `@Id`; `age` changes to `int` +- A test-scoped `orm.properties` (in `src/test/resources/`) sets `orm.ddl-auto=create-if-not-exists` +- The manual `db.CreateTable()` call is removed — auto-DDL handles it via `Insert` +- The `db.Find()` result assertions (`resultSet.getString(1)` etc.) remain unchanged: the cursor is already on the first row, which is consistent with the existing test code — no assertion change needed there. The `age` read-back changes from `resultSet.getString(3)` to `resultSet.getInt(3)` (or remain as `getString` since PostgreSQL will coerce). + +--- + +## Out of Scope + +- `UPDATE` and `VALIDATE` DDL modes +- `DELETE` operation +- WHERE clause support in `Find` +- Multi-database support (only PostgreSQL) +- Connection pooling +- Transaction management 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 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; } } 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()); } }