From 4c2834b92cf28de914eafabeb0332c84748097da Mon Sep 17 00:00:00 2001 From: Maxence Simon <32517160+Maxlego08@users.noreply.github.com> Date: Tue, 7 Oct 2025 13:58:54 +0200 Subject: [PATCH 1/8] Allow configuring Hikari pool sizing --- .../sarah/DatabaseConfiguration.java | 33 +++++++++++++++++-- .../sarah/HikariDatabaseConnection.java | 16 +++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/main/java/fr/maxlego08/sarah/DatabaseConfiguration.java b/src/main/java/fr/maxlego08/sarah/DatabaseConfiguration.java index cecbe47..1b2a016 100644 --- a/src/main/java/fr/maxlego08/sarah/DatabaseConfiguration.java +++ b/src/main/java/fr/maxlego08/sarah/DatabaseConfiguration.java @@ -18,9 +18,17 @@ public class DatabaseConfiguration { private final String database; private final boolean debug; private final DatabaseType databaseType; + private final Integer maximumPoolSize; + private final Integer minimumIdle; public DatabaseConfiguration(String tablePrefix, String user, String password, int port, String host, String database, boolean debug, DatabaseType databaseType) { + this(tablePrefix, user, password, port, host, database, debug, databaseType, null, null); + } + + public DatabaseConfiguration(String tablePrefix, String user, String password, int port, String host, + String database, boolean debug, DatabaseType databaseType, + Integer maximumPoolSize, Integer minimumIdle) { this.tablePrefix = tablePrefix; this.user = user; this.password = password; @@ -29,6 +37,8 @@ public DatabaseConfiguration(String tablePrefix, String user, String password, i this.database = database; this.debug = debug; this.databaseType = databaseType; + this.maximumPoolSize = maximumPoolSize; + this.minimumIdle = minimumIdle; } public static DatabaseConfiguration create(String user, String password, int port, String host, String database, DatabaseType databaseType) { @@ -103,6 +113,19 @@ public DatabaseType getDatabaseType() { return databaseType; } + public Integer getMaximumPoolSize() { + return maximumPoolSize; + } + + public Integer getMinimumIdle() { + return minimumIdle; + } + + public DatabaseConfiguration withPoolSettings(Integer maximumPoolSize, Integer minimumIdle) { + return new DatabaseConfiguration(this.tablePrefix, this.user, this.password, this.port, this.host, + this.database, this.debug, this.databaseType, maximumPoolSize, minimumIdle); + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -115,12 +138,14 @@ public boolean equals(Object o) { Objects.equals(password, that.password) && Objects.equals(host, that.host) && Objects.equals(database, that.database) && - databaseType == that.databaseType; + databaseType == that.databaseType && + Objects.equals(maximumPoolSize, that.maximumPoolSize) && + Objects.equals(minimumIdle, that.minimumIdle); } @Override public int hashCode() { - return Objects.hash(tablePrefix, user, password, port, host, database, debug, databaseType); + return Objects.hash(tablePrefix, user, password, port, host, database, debug, databaseType, maximumPoolSize, minimumIdle); } @Override @@ -134,6 +159,8 @@ public String toString() { ", database='" + database + '\'' + ", debug=" + debug + ", databaseType=" + databaseType + + ", maximumPoolSize=" + maximumPoolSize + + ", minimumIdle=" + minimumIdle + '}'; } -} \ No newline at end of file +} diff --git a/src/main/java/fr/maxlego08/sarah/HikariDatabaseConnection.java b/src/main/java/fr/maxlego08/sarah/HikariDatabaseConnection.java index 6c85cd6..3ea1be1 100644 --- a/src/main/java/fr/maxlego08/sarah/HikariDatabaseConnection.java +++ b/src/main/java/fr/maxlego08/sarah/HikariDatabaseConnection.java @@ -53,8 +53,20 @@ private void initializeDataSource() { config.setPassword(databaseConfiguration.getPassword()); // Pooling - config.setMaximumPoolSize(MAXIMUM_POOL_SIZE); - config.setMinimumIdle(MINIMUM_IDLE); + int configuredMaxPoolSize = MAXIMUM_POOL_SIZE; + Integer maxPoolSize = databaseConfiguration.getMaximumPoolSize(); + if (maxPoolSize != null && maxPoolSize > 0) { + configuredMaxPoolSize = maxPoolSize; + } + + int configuredMinimumIdle = Math.min(configuredMaxPoolSize, MINIMUM_IDLE); + Integer minIdle = databaseConfiguration.getMinimumIdle(); + if (minIdle != null && minIdle >= 0) { + configuredMinimumIdle = Math.min(configuredMaxPoolSize, minIdle); + } + + config.setMaximumPoolSize(configuredMaxPoolSize); + config.setMinimumIdle(configuredMinimumIdle); config.setMaxLifetime(MAX_LIFETIME); config.setConnectionTimeout(CONNECTION_TIMEOUT); config.setLeakDetectionThreshold(LEAK_DETECTION_THRESHOLD); From b0ec41cc452fea0856448ef63e29b4e2465c7aea Mon Sep 17 00:00:00 2001 From: Traqueur <54551467+Traqueur-dev@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:03:59 +0100 Subject: [PATCH 2/8] fix: permits with dto primary composite (#15) (#16) --- build.gradle.kts | 2 +- src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 030db26..1eba001 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ rootProject.extra.properties["sha"]?.let { sha -> } group = "fr.maxlego08.sarah" -version = "1.20" +version = "1.20.1" extra.set("targetFolder", file("target/")) extra.set("apiFolder", file("target-api/")) diff --git a/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java b/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java index c0de28c..6d82dae 100644 --- a/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java +++ b/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java @@ -74,7 +74,7 @@ public static Consumer createConsumerFromTemplate(Class template, Obj } if (column != null) { - if (column.primary() && !primaryAlready) { + if (column.primary()) { primaryAlready = true; schema.primary(); } From 00a00457be4e20a4721320c42f47f2238dbe00ff Mon Sep 17 00:00:00 2001 From: Traqueur <54551467+Traqueur-dev@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:04:52 +0100 Subject: [PATCH 3/8] Hotfix/autoincrement (#17) * fix: permits with dto primary composite (#15) * fix: autoincrement usage --- build.gradle.kts | 3 ++- src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 1eba001..125df0e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,8 @@ rootProject.extra.properties["sha"]?.let { sha -> } group = "fr.maxlego08.sarah" -version = "1.20.1" +version = "1.20.2" + extra.set("targetFolder", file("target/")) extra.set("apiFolder", file("target-api/")) diff --git a/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java b/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java index 6d82dae..f2d0083 100644 --- a/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java +++ b/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java @@ -74,6 +74,9 @@ public static Consumer createConsumerFromTemplate(Class template, Obj } if (column != null) { + if(column.primary() && column.autoIncrement()) { + throw new IllegalArgumentException("A column cannot be both primary and auto increment"); + } if (column.primary()) { primaryAlready = true; schema.primary(); @@ -82,6 +85,7 @@ public static Consumer createConsumerFromTemplate(Class template, Obj if (!type.getTypeName().equals("long")) { throw new IllegalArgumentException("Auto increment is only available for long type"); } + primaryAlready = true; schema.autoIncrement(column.value()); } if (column.foreignKey()) { From 773466166aabcff1487cf9b72aed4b282440ead5 Mon Sep 17 00:00:00 2001 From: Traqueur_ Date: Tue, 18 Nov 2025 10:23:16 +0100 Subject: [PATCH 4/8] fix: the way to use autoincrement --- .../maxlego08/sarah/ConsumerConstructor.java | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java b/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java index f2d0083..dd8da7a 100644 --- a/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java +++ b/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java @@ -67,10 +67,22 @@ public static Consumer createConsumerFromTemplate(Class template, Obj name = column.value(); } - try { - schemaFromType(schema, typeName, name, data == null ? null : field.get(data)); - } catch (IllegalAccessException e) { - throw new RuntimeException(e); + if (column != null && column.autoIncrement()) { + if (type.equals(long.class) || type.equals(Long.class)) { + schema.autoIncrementBigInt(column.value()); + primaryAlready = true; + } else if (type.equals(int.class) || type.equals(Integer.class)) { + schema.autoIncrement(column.value()); + primaryAlready = true; + } else { + throw new IllegalArgumentException("Auto increment is only supported for long and int types"); + } + } else { + try { + schemaFromType(schema, typeName, name, data == null ? null : field.get(data)); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } } if (column != null) { @@ -81,13 +93,6 @@ public static Consumer createConsumerFromTemplate(Class template, Obj primaryAlready = true; schema.primary(); } - if (column.autoIncrement()) { - if (!type.getTypeName().equals("long")) { - throw new IllegalArgumentException("Auto increment is only available for long type"); - } - primaryAlready = true; - schema.autoIncrement(column.value()); - } if (column.foreignKey()) { if (column.foreignKeyReference().isEmpty()) { throw new IllegalArgumentException("Foreign key reference is empty"); From aeb6962000fbf3b70154a43b530402c6437e56db Mon Sep 17 00:00:00 2001 From: Traqueur_ Date: Tue, 18 Nov 2025 10:53:47 +0100 Subject: [PATCH 5/8] fix: autoincrement in sql queries --- .../sarah/conditions/ColumnDefinition.java | 4 +++ .../sarah/requests/InsertAllRequest.java | 15 +++++---- .../sarah/requests/InsertBatchRequest.java | 12 +++++-- .../sarah/requests/InsertRequest.java | 13 +++++--- .../sarah/requests/UpsertBatchRequest.java | 32 ++++++++++++------- .../sarah/requests/UpsertRequest.java | 28 ++++++++++------ 6 files changed, 71 insertions(+), 33 deletions(-) diff --git a/src/main/java/fr/maxlego08/sarah/conditions/ColumnDefinition.java b/src/main/java/fr/maxlego08/sarah/conditions/ColumnDefinition.java index 195406e..2ce3fb6 100644 --- a/src/main/java/fr/maxlego08/sarah/conditions/ColumnDefinition.java +++ b/src/main/java/fr/maxlego08/sarah/conditions/ColumnDefinition.java @@ -156,6 +156,10 @@ public ColumnDefinition setObject(Object object) { return this; } + public boolean isAutoIncrement() { + return isAutoIncrement; + } + public ColumnDefinition setAutoIncrement(boolean isAutoIncrement) { this.isAutoIncrement = isAutoIncrement; return this; diff --git a/src/main/java/fr/maxlego08/sarah/requests/InsertAllRequest.java b/src/main/java/fr/maxlego08/sarah/requests/InsertAllRequest.java index 2c1f300..0043d74 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/InsertAllRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/InsertAllRequest.java @@ -27,15 +27,18 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration StringBuilder insertBuilder = new StringBuilder("INSERT INTO " + this.toTableName + " ("); StringBuilder columns = new StringBuilder(); - int size = this.schema.getColumns().size(); - for (int i = 0; i < size; i++) { - - ColumnDefinition columnDefinition = this.schema.getColumns().get(i); + int columnIndex = 0; + for (ColumnDefinition columnDefinition : this.schema.getColumns()) { + // Skip auto-increment columns + if (columnDefinition.isAutoIncrement()) { + continue; + } - columns.append(columnDefinition.getSafeName()); - if (i < (size - 1)) { + if (columnIndex > 0) { columns.append(","); } + columns.append(columnDefinition.getSafeName()); + columnIndex++; } insertBuilder.append(columns).append(") "); diff --git a/src/main/java/fr/maxlego08/sarah/requests/InsertBatchRequest.java b/src/main/java/fr/maxlego08/sarah/requests/InsertBatchRequest.java index c02de2f..572d34b 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/InsertBatchRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/InsertBatchRequest.java @@ -37,8 +37,11 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration List placeholders = new ArrayList<>(); List columnNames = new ArrayList<>(); + // Skip auto-increment columns for (ColumnDefinition column : firstSchema.getColumns()) { - columnNames.add(column.getSafeName()); + if (!column.isAutoIncrement()) { + columnNames.add(column.getSafeName()); + } } insertQuery.append(String.join(", ", columnNames)).append(") "); @@ -46,8 +49,11 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration for (Schema schema : schemas) { List rowPlaceholders = new ArrayList<>(); for (ColumnDefinition column : schema.getColumns()) { - rowPlaceholders.add("?"); - values.add(column.getObject()); + // Skip auto-increment columns + if (!column.isAutoIncrement()) { + rowPlaceholders.add("?"); + values.add(column.getObject()); + } } placeholders.add("(" + String.join(", ", rowPlaceholders) + ")"); } diff --git a/src/main/java/fr/maxlego08/sarah/requests/InsertRequest.java b/src/main/java/fr/maxlego08/sarah/requests/InsertRequest.java index 9a1f3a8..bff9468 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/InsertRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/InsertRequest.java @@ -31,11 +31,16 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration List values = new ArrayList<>(); - for (int i = 0; i < this.schema.getColumns().size(); i++) { - ColumnDefinition columnDefinition = this.schema.getColumns().get(i); - insertQuery.append(i > 0 ? ", " : "").append(columnDefinition.getSafeName()); - valuesQuery.append(i > 0 ? ", " : "").append("?"); + int paramIndex = 0; + for (ColumnDefinition columnDefinition : this.schema.getColumns()) { + // Skip auto-increment columns + if (columnDefinition.isAutoIncrement()) { + continue; + } + insertQuery.append(paramIndex > 0 ? ", " : "").append(columnDefinition.getSafeName()); + valuesQuery.append(paramIndex > 0 ? ", " : "").append("?"); values.add(columnDefinition.getObject()); + paramIndex++; } insertQuery.append(") "); diff --git a/src/main/java/fr/maxlego08/sarah/requests/UpsertBatchRequest.java b/src/main/java/fr/maxlego08/sarah/requests/UpsertBatchRequest.java index 68eb9ce..d998f2a 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/UpsertBatchRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/UpsertBatchRequest.java @@ -36,19 +36,27 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration List values = new ArrayList<>(); List placeholders = new ArrayList<>(); - List columnNames = new ArrayList<>(); + List insertColumnNames = new ArrayList<>(); + List allColumnNames = new ArrayList<>(); + // Build column lists - skip auto-increment for INSERT, include all for UPDATE for (ColumnDefinition column : firstSchema.getColumns()) { - columnNames.add(column.getSafeName()); + allColumnNames.add(column.getSafeName()); + if (!column.isAutoIncrement()) { + insertColumnNames.add(column.getSafeName()); + } } - insertQuery.append(String.join(", ", columnNames)).append(") "); + insertQuery.append(String.join(", ", insertColumnNames)).append(") "); for (Schema schema : schemas) { List rowPlaceholders = new ArrayList<>(); for (ColumnDefinition column : schema.getColumns()) { - rowPlaceholders.add("?"); - values.add(column.getObject()); + // Skip auto-increment columns + if (!column.isAutoIncrement()) { + rowPlaceholders.add("?"); + values.add(column.getObject()); + } } placeholders.add("(" + String.join(", ", rowPlaceholders) + ")"); } @@ -60,19 +68,21 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration List primaryKeys = firstSchema.getPrimaryKeys(); onConflictQuery.append(String.join(", ", primaryKeys)).append(") DO UPDATE SET "); - for (int i = 0; i < columnNames.size(); i++) { + // Use all columns for UPDATE (including auto-increment) + for (int i = 0; i < allColumnNames.size(); i++) { if (i > 0) onUpdateQuery.append(", "); - onUpdateQuery.append(columnNames.get(i)).append(" = excluded.").append(columnNames.get(i)); + onUpdateQuery.append(allColumnNames.get(i)).append(" = excluded.").append(allColumnNames.get(i)); } - + insertQuery.append(valuesQuery).append(onConflictQuery).append(onUpdateQuery); } else { onUpdateQuery.append(" ON DUPLICATE KEY UPDATE "); - for (int i = 0; i < columnNames.size(); i++) { + // Use all columns for UPDATE (including auto-increment) + for (int i = 0; i < allColumnNames.size(); i++) { if (i > 0) onUpdateQuery.append(", "); - onUpdateQuery.append(columnNames.get(i)).append(" = VALUES(").append(columnNames.get(i)).append(")"); + onUpdateQuery.append(allColumnNames.get(i)).append(" = VALUES(").append(allColumnNames.get(i)).append(")"); } - + insertQuery.append(valuesQuery).append(onUpdateQuery); } diff --git a/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java b/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java index 2404696..dc1822c 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java @@ -29,21 +29,31 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration StringBuilder valuesQuery = new StringBuilder("VALUES ("); StringBuilder onUpdateQuery = new StringBuilder(); - List values = new ArrayList<>(); + List insertValues = new ArrayList<>(); + List updateValues = new ArrayList<>(); + + int insertIndex = 0; + int updateIndex = 0; + for (ColumnDefinition columnDefinition : this.schema.getColumns()) { + // Skip auto-increment columns in INSERT part + if (!columnDefinition.isAutoIncrement()) { + insertQuery.append(insertIndex > 0 ? ", " : "").append(columnDefinition.getSafeName()); + valuesQuery.append(insertIndex > 0 ? ", " : "").append("?"); + insertValues.add(columnDefinition.getObject()); + insertIndex++; + } - for (int i = 0; i < this.schema.getColumns().size(); i++) { - ColumnDefinition columnDefinition = this.schema.getColumns().get(i); - insertQuery.append(i > 0 ? ", " : "").append(columnDefinition.getSafeName()); - valuesQuery.append(i > 0 ? ", " : "").append("?"); - if (i > 0) { + // Include all columns in UPDATE part + if (updateIndex > 0) { onUpdateQuery.append(", "); } if (databaseType == DatabaseType.SQLITE) { onUpdateQuery.append(columnDefinition.getSafeName()).append(" = excluded.").append(columnDefinition.getSafeName()); } else { onUpdateQuery.append(columnDefinition.getSafeName()).append(" = ?"); + updateValues.add(columnDefinition.getObject()); } - values.add(columnDefinition.getObject()); + updateIndex++; } insertQuery.append(") "); @@ -75,13 +85,13 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration int index = 1; // Setting values for INSERT part - for (Object value : values) { + for (Object value : insertValues) { preparedStatement.setObject(index++, value); } // Setting values for UPDATE part (only if not SQLite, since SQLite uses "excluded" keyword) if (databaseType != DatabaseType.SQLITE) { - for (Object value : values) { + for (Object value : updateValues) { preparedStatement.setObject(index++, value); } } From a7c61fbe5f77167322dc9798a826cdaebbdba668 Mon Sep 17 00:00:00 2001 From: Traqueur_ Date: Tue, 18 Nov 2025 11:19:15 +0100 Subject: [PATCH 6/8] fix: upsert --- .../sarah/requests/UpsertBatchRequest.java | 16 ++++++-------- .../sarah/requests/UpsertRequest.java | 22 ++++++++++--------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/main/java/fr/maxlego08/sarah/requests/UpsertBatchRequest.java b/src/main/java/fr/maxlego08/sarah/requests/UpsertBatchRequest.java index d998f2a..2607dd2 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/UpsertBatchRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/UpsertBatchRequest.java @@ -37,11 +37,9 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration List values = new ArrayList<>(); List placeholders = new ArrayList<>(); List insertColumnNames = new ArrayList<>(); - List allColumnNames = new ArrayList<>(); - // Build column lists - skip auto-increment for INSERT, include all for UPDATE + // Build column list - skip auto-increment columns for (ColumnDefinition column : firstSchema.getColumns()) { - allColumnNames.add(column.getSafeName()); if (!column.isAutoIncrement()) { insertColumnNames.add(column.getSafeName()); } @@ -68,19 +66,19 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration List primaryKeys = firstSchema.getPrimaryKeys(); onConflictQuery.append(String.join(", ", primaryKeys)).append(") DO UPDATE SET "); - // Use all columns for UPDATE (including auto-increment) - for (int i = 0; i < allColumnNames.size(); i++) { + // Skip auto-increment columns in UPDATE as well + for (int i = 0; i < insertColumnNames.size(); i++) { if (i > 0) onUpdateQuery.append(", "); - onUpdateQuery.append(allColumnNames.get(i)).append(" = excluded.").append(allColumnNames.get(i)); + onUpdateQuery.append(insertColumnNames.get(i)).append(" = excluded.").append(insertColumnNames.get(i)); } insertQuery.append(valuesQuery).append(onConflictQuery).append(onUpdateQuery); } else { onUpdateQuery.append(" ON DUPLICATE KEY UPDATE "); - // Use all columns for UPDATE (including auto-increment) - for (int i = 0; i < allColumnNames.size(); i++) { + // Skip auto-increment columns in UPDATE as well + for (int i = 0; i < insertColumnNames.size(); i++) { if (i > 0) onUpdateQuery.append(", "); - onUpdateQuery.append(allColumnNames.get(i)).append(" = VALUES(").append(allColumnNames.get(i)).append(")"); + onUpdateQuery.append(insertColumnNames.get(i)).append(" = VALUES(").append(insertColumnNames.get(i)).append(")"); } insertQuery.append(valuesQuery).append(onUpdateQuery); diff --git a/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java b/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java index dc1822c..687020c 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java @@ -43,17 +43,19 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration insertIndex++; } - // Include all columns in UPDATE part - if (updateIndex > 0) { - onUpdateQuery.append(", "); - } - if (databaseType == DatabaseType.SQLITE) { - onUpdateQuery.append(columnDefinition.getSafeName()).append(" = excluded.").append(columnDefinition.getSafeName()); - } else { - onUpdateQuery.append(columnDefinition.getSafeName()).append(" = ?"); - updateValues.add(columnDefinition.getObject()); + // Skip auto-increment columns in UPDATE part as well + if (!columnDefinition.isAutoIncrement()) { + if (updateIndex > 0) { + onUpdateQuery.append(", "); + } + if (databaseType == DatabaseType.SQLITE) { + onUpdateQuery.append(columnDefinition.getSafeName()).append(" = excluded.").append(columnDefinition.getSafeName()); + } else { + onUpdateQuery.append(columnDefinition.getSafeName()).append(" = ?"); + updateValues.add(columnDefinition.getObject()); + } + updateIndex++; } - updateIndex++; } insertQuery.append(") "); From d8f797243af0d4057583cf50aa060e67ebaec667 Mon Sep 17 00:00:00 2001 From: Traqueur_ Date: Tue, 18 Nov 2025 11:51:37 +0100 Subject: [PATCH 7/8] fix: upsert primary key check --- .../sarah/requests/UpsertRequest.java | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java b/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java index 687020c..5b799d2 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java @@ -65,9 +65,10 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration if (databaseType == DatabaseType.SQLITE) { StringBuilder onConflictQuery = new StringBuilder(" ON CONFLICT ("); - List primaryKeys = schema.getPrimaryKeys(); - for (int i = 0; i < primaryKeys.size(); i++) { - onConflictQuery.append(i > 0 ? ", " : "").append(primaryKeys.get(i)); + List nonAutoIncrementPrimaryKeys = getNonAutoIncrementPrimaryKeys(); + + for (int i = 0; i < nonAutoIncrementPrimaryKeys.size(); i++) { + onConflictQuery.append(i > 0 ? ", " : "").append(nonAutoIncrementPrimaryKeys.get(i)); } onConflictQuery.append(") DO UPDATE SET "); upsertQuery = insertQuery + valuesQuery.toString() + onConflictQuery + onUpdateQuery; @@ -106,4 +107,30 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration } } + + private List getNonAutoIncrementPrimaryKeys() { + List primaryKeys = schema.getPrimaryKeys(); + + // Filter out auto-increment columns from primary keys for ON CONFLICT clause + List nonAutoIncrementPrimaryKeys = new ArrayList<>(); + for (String primaryKey : primaryKeys) { + boolean isAutoIncrement = false; + for (ColumnDefinition col : schema.getColumns()) { + if (col.getSafeName().equals(primaryKey) && col.isAutoIncrement()) { + isAutoIncrement = true; + break; + } + } + if (!isAutoIncrement) { + nonAutoIncrementPrimaryKeys.add(primaryKey); + } + } + + // If no non-auto-increment primary keys exist, we cannot use ON CONFLICT + // In this case, fall back to a regular INSERT (which will fail on conflict) + if (nonAutoIncrementPrimaryKeys.isEmpty()) { + throw new IllegalStateException("UPSERT requires at least one non-auto-increment primary key or unique constraint for SQLite"); + } + return nonAutoIncrementPrimaryKeys; + } } From 6368a4c1f26d42f3b4665d745a6a483dfbe30cda Mon Sep 17 00:00:00 2001 From: Traqueur <54551467+Traqueur-dev@users.noreply.github.com> Date: Thu, 20 Nov 2025 10:47:49 +0100 Subject: [PATCH 8/8] Feat/unit tests (#19) * feat: add units tests (wip) * fix: gitignore * fix: tests * fix: remove test db --- .gitignore | 1 + build.gradle.kts | 79 ++--- gradlew | 0 settings.gradle.kts | 12 +- src/main/java/fr/maxlego08/sarah/Column.java | 2 + .../maxlego08/sarah/ConsumerConstructor.java | 18 +- .../maxlego08/sarah/DatabaseConnection.java | 44 ++- .../sarah/HikariDatabaseConnection.java | 10 +- .../fr/maxlego08/sarah/MariaDbConnection.java | 6 +- .../fr/maxlego08/sarah/MigrationManager.java | 12 +- .../fr/maxlego08/sarah/MySqlConnection.java | 6 +- .../fr/maxlego08/sarah/RequestHelper.java | 99 ++++-- .../fr/maxlego08/sarah/SchemaBuilder.java | 47 ++- .../fr/maxlego08/sarah/SqliteConnection.java | 22 +- .../sarah/conditions/ColumnDefinition.java | 27 +- .../fr/maxlego08/sarah/database/Schema.java | 2 - .../sarah/exceptions/DatabaseException.java | 33 ++ .../sarah/exceptions/SarahException.java | 15 + .../sarah/requests/AlterRequest.java | 5 +- .../sarah/requests/CreateIndexRequest.java | 5 +- .../sarah/requests/CreateRequest.java | 15 +- .../sarah/requests/DeleteRequest.java | 5 +- .../sarah/requests/InsertAllRequest.java | 5 +- .../sarah/requests/InsertBatchRequest.java | 9 +- .../sarah/requests/InsertRequest.java | 8 +- .../sarah/requests/ModifyRequest.java | 5 +- .../sarah/requests/RenameExecutor.java | 5 +- .../sarah/requests/UpdateBatchRequest.java | 5 +- .../sarah/requests/UpdateRequest.java | 5 +- .../sarah/requests/UpsertBatchRequest.java | 5 +- .../sarah/requests/UpsertRequest.java | 31 +- .../security/SecureObjectInputStream.java | 154 +++++++++ .../sarah/transaction/Transaction.java | 100 ++++++ .../fr/maxlego08/sarah/AutoIncrementTest.java | 231 +++++++++++++ .../fr/maxlego08/sarah/DTOConsumerTest.java | 307 ++++++++++++++++++ .../fr/maxlego08/sarah/DatabaseTestBase.java | 162 +++++++++ .../fr/maxlego08/sarah/DeleteRequestTest.java | 104 ++++++ .../sarah/InsertBatchRequestTest.java | 156 +++++++++ .../fr/maxlego08/sarah/InsertRequestTest.java | 212 ++++++++++++ .../fr/maxlego08/sarah/JoinConditionTest.java | 198 +++++++++++ .../fr/maxlego08/sarah/MigrationTest.java | 299 +++++++++++++++++ .../fr/maxlego08/sarah/SelectRequestTest.java | 206 ++++++++++++ .../fr/maxlego08/sarah/UpdateRequestTest.java | 120 +++++++ .../fr/maxlego08/sarah/UpsertRequestTest.java | 212 ++++++++++++ .../maxlego08/sarah/WhereConditionTest.java | 208 ++++++++++++ 45 files changed, 3049 insertions(+), 163 deletions(-) mode change 100755 => 100644 gradlew create mode 100644 src/main/java/fr/maxlego08/sarah/exceptions/DatabaseException.java create mode 100644 src/main/java/fr/maxlego08/sarah/exceptions/SarahException.java create mode 100644 src/main/java/fr/maxlego08/sarah/security/SecureObjectInputStream.java create mode 100644 src/main/java/fr/maxlego08/sarah/transaction/Transaction.java create mode 100644 src/test/java/fr/maxlego08/sarah/AutoIncrementTest.java create mode 100644 src/test/java/fr/maxlego08/sarah/DTOConsumerTest.java create mode 100644 src/test/java/fr/maxlego08/sarah/DatabaseTestBase.java create mode 100644 src/test/java/fr/maxlego08/sarah/DeleteRequestTest.java create mode 100644 src/test/java/fr/maxlego08/sarah/InsertBatchRequestTest.java create mode 100644 src/test/java/fr/maxlego08/sarah/InsertRequestTest.java create mode 100644 src/test/java/fr/maxlego08/sarah/JoinConditionTest.java create mode 100644 src/test/java/fr/maxlego08/sarah/MigrationTest.java create mode 100644 src/test/java/fr/maxlego08/sarah/SelectRequestTest.java create mode 100644 src/test/java/fr/maxlego08/sarah/UpdateRequestTest.java create mode 100644 src/test/java/fr/maxlego08/sarah/UpsertRequestTest.java create mode 100644 src/test/java/fr/maxlego08/sarah/WhereConditionTest.java diff --git a/.gitignore b/.gitignore index 634f74e..87a7ceb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +.claude/ ### IntelliJ IDEA ### .idea/modules.xml diff --git a/build.gradle.kts b/build.gradle.kts index 125df0e..da64268 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,23 +2,22 @@ import java.util.Locale plugins { `java-library` - id("com.github.johnrengelman.shadow") version "7.1.2" // Pour remplacer maven-shade-plugin + id("re.alwyn974.groupez.publish") version "1.0.0" + id("com.gradleup.shadow") version "9.0.0-beta11" `maven-publish` } -rootProject.extra.properties["sha"]?.let { sha -> - version = sha -} - -group = "fr.maxlego08.sarah" -version = "1.20.2" - - extra.set("targetFolder", file("target/")) -extra.set("apiFolder", file("target-api/")) extra.set("classifier", System.getProperty("archive.classifier")) extra.set("sha", System.getProperty("github.sha")) +group = "fr.maxlego08.sarah" +version = "1.21" + +rootProject.extra.properties["sha"]?.let { sha -> + version = sha +} + java { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 @@ -33,6 +32,16 @@ repositories { dependencies { implementation("com.zaxxer:HikariCP:4.0.3") + + // Test dependencies + testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.3") + testImplementation("org.junit.jupiter:junit-jupiter-params:5.9.3") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.3") + testImplementation("org.mockito:mockito-core:5.3.1") + testImplementation("org.mockito:mockito-junit-jupiter:5.3.1") + testImplementation("org.xerial:sqlite-jdbc:3.42.0.0") + testImplementation("org.mariadb.jdbc:mariadb-java-client:3.1.4") + testImplementation("com.mysql:mysql-connector-j:8.2.0") } tasks.withType { @@ -52,44 +61,16 @@ tasks.build { dependsOn(tasks.shadowJar) } -publishing { - - var repository = System.getProperty("repository.name", "snapshots").replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - - repositories { - maven { - name = "groupez${repository}" - url = uri("https://repo.groupez.dev/${repository.lowercase()}") - credentials { - username = findProperty("${name}Username") as String? ?: System.getenv("MAVEN_USERNAME") - password = findProperty("${name}Password") as String? ?: System.getenv("MAVEN_PASSWORD") - } - authentication { - create("basic") - } - } - } +tasks.shadowJar { + archiveClassifier.set("") + destinationDirectory.set(rootProject.extra["targetFolder"] as File) +} - publications { - register("groupez${repository}") { - pom { - groupId = project.group as String? - artifactId = rootProject.name.lowercase() - version = if (repository.lowercase() == "snapshots") { - System.getProperty("github.sha") - } else { - project.version as String? - } - - scm { - connection = "scm:git:git://github.com/GroupeZ-dev/${rootProject.name}.git" - developerConnection = "scm:git:ssh://github.com/GroupeZ-dev/${rootProject.name}.git" - url = "https://github.com/GroupeZ-dev/${rootProject.name}/" - } - } - artifact(tasks.shadowJar) - // artifact(tasks.javadocJar) - // artifact(tasks.sourcesJar) - } - } +tasks.test { + useJUnitPlatform() +} + +publishConfig { + githubOwner = "GroupeZ-dev" + useRootProjectName = true } \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100755 new mode 100644 diff --git a/settings.gradle.kts b/settings.gradle.kts index 849a1c0..e4bec9b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1,11 @@ -rootProject.name = "Sarah" \ No newline at end of file +rootProject.name = "Sarah" + +pluginManagement { + repositories { + maven { + name = "groupezReleases" + url = uri("https://repo.groupez.dev/releases") + } + gradlePluginPortal() + } +} \ No newline at end of file diff --git a/src/main/java/fr/maxlego08/sarah/Column.java b/src/main/java/fr/maxlego08/sarah/Column.java index 8eeca91..7755b56 100644 --- a/src/main/java/fr/maxlego08/sarah/Column.java +++ b/src/main/java/fr/maxlego08/sarah/Column.java @@ -18,4 +18,6 @@ String type() default ""; boolean nullable() default false; + + boolean unique() default false; } diff --git a/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java b/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java index dd8da7a..0fe20ae 100644 --- a/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java +++ b/src/main/java/fr/maxlego08/sarah/ConsumerConstructor.java @@ -5,6 +5,7 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Type; +import java.util.Arrays; import java.util.Date; import java.util.UUID; import java.util.function.Consumer; @@ -39,8 +40,18 @@ public static Consumer createConsumerFromTemplate(Class template, Obj Constructor firstConstructor = constructors[0]; firstConstructor.setAccessible(true); - Field[] fields = template.getDeclaredFields(); - if (fields.length != firstConstructor.getParameterCount()) { + Field[] allFields = template.getDeclaredFields(); + // Filter out synthetic fields (added by compiler for local/anonymous classes) + Field[] fields = Arrays.stream(allFields) + .filter(f -> !f.isSynthetic()) + .toArray(Field[]::new); + + // For local/anonymous classes, count only non-synthetic constructor parameters + long nonSyntheticParamCount = Arrays.stream(firstConstructor.getParameters()) + .filter(p -> !p.isSynthetic()) + .count(); + + if (fields.length != nonSyntheticParamCount) { throw new IllegalArgumentException("Fields count does not match constructor parameters count"); } @@ -102,6 +113,9 @@ public static Consumer createConsumerFromTemplate(Class template, Obj if (column.nullable()) { schema.nullable(); } + if (column.unique()) { + schema.unique(); + } } if (i == 0 && !primaryAlready) { diff --git a/src/main/java/fr/maxlego08/sarah/DatabaseConnection.java b/src/main/java/fr/maxlego08/sarah/DatabaseConnection.java index d4e9b2b..af94a7e 100644 --- a/src/main/java/fr/maxlego08/sarah/DatabaseConnection.java +++ b/src/main/java/fr/maxlego08/sarah/DatabaseConnection.java @@ -1,6 +1,9 @@ package fr.maxlego08.sarah; import fr.maxlego08.sarah.database.DatabaseType; +import fr.maxlego08.sarah.exceptions.DatabaseException; +import fr.maxlego08.sarah.logger.Logger; +import fr.maxlego08.sarah.transaction.Transaction; import java.sql.Connection; import java.sql.SQLException; @@ -12,10 +15,12 @@ public abstract class DatabaseConnection { protected final DatabaseConfiguration databaseConfiguration; + protected final Logger logger; protected Connection connection; - public DatabaseConnection(DatabaseConfiguration databaseConfiguration) { + public DatabaseConnection(DatabaseConfiguration databaseConfiguration, Logger logger) { this.databaseConfiguration = databaseConfiguration; + this.logger = logger; } /** @@ -46,14 +51,10 @@ public boolean isValid() { } if (!isConnected(connection)) { - try { - Connection temp_connection = this.connectToDatabase(); - - if (isConnected(temp_connection)) { - temp_connection.close(); - } + try (Connection tempConnection = this.connectToDatabase()) { + return isConnected(tempConnection); } catch (Exception exception) { - exception.printStackTrace(); + this.logger.info("Failed to validate database connection: " + exception.getMessage()); return false; } } @@ -87,7 +88,7 @@ public void disconnect() { try { connection.close(); } catch (SQLException exception) { - exception.printStackTrace(); + this.logger.info("Failed to disconnect from database: " + exception.getMessage()); } } } @@ -100,7 +101,8 @@ public void connect() { try { connection = this.connectToDatabase(); } catch (Exception exception) { - exception.printStackTrace(); + this.logger.info("Failed to connect to database: " + exception.getMessage()); + throw new DatabaseException("connect", exception); } } } @@ -117,4 +119,26 @@ public Connection getConnection() { connect(); return connection; } + + /** + * Begins a new database transaction. + * Use try-with-resources to ensure proper cleanup: + *
+     * try (Transaction tx = connection.beginTransaction()) {
+     *     // Execute operations
+     *     tx.commit();
+     * } // Automatically rolls back if not committed
+     * 
+ * + * @return a new Transaction instance + * @throws DatabaseException if the transaction cannot be started + */ + public Transaction beginTransaction() { + try { + return new Transaction(getConnection()); + } catch (SQLException exception) { + this.logger.info("Failed to begin transaction: " + exception.getMessage()); + throw new DatabaseException("begin-transaction", exception); + } + } } diff --git a/src/main/java/fr/maxlego08/sarah/HikariDatabaseConnection.java b/src/main/java/fr/maxlego08/sarah/HikariDatabaseConnection.java index 3ea1be1..34d6ed4 100644 --- a/src/main/java/fr/maxlego08/sarah/HikariDatabaseConnection.java +++ b/src/main/java/fr/maxlego08/sarah/HikariDatabaseConnection.java @@ -3,6 +3,8 @@ import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import fr.maxlego08.sarah.database.DatabaseType; +import fr.maxlego08.sarah.exceptions.DatabaseException; +import fr.maxlego08.sarah.logger.Logger; import javax.sql.DataSource; import java.sql.Connection; @@ -26,8 +28,8 @@ public class HikariDatabaseConnection extends DatabaseConnection { private HikariDataSource dataSource; - public HikariDatabaseConnection(DatabaseConfiguration databaseConfiguration) { - super(databaseConfiguration); + public HikariDatabaseConnection(DatabaseConfiguration databaseConfiguration, Logger logger) { + super(databaseConfiguration, logger); this.initializeDataSource(); } @@ -126,8 +128,8 @@ public Connection getConnection() { try { return dataSource.getConnection(); } catch (SQLException exception) { - exception.printStackTrace(); - return null; + this.logger.info("Failed to get connection from Hikari pool: " + exception.getMessage()); + throw new DatabaseException("getConnection", exception); } } diff --git a/src/main/java/fr/maxlego08/sarah/MariaDbConnection.java b/src/main/java/fr/maxlego08/sarah/MariaDbConnection.java index f7c8f45..0205b30 100644 --- a/src/main/java/fr/maxlego08/sarah/MariaDbConnection.java +++ b/src/main/java/fr/maxlego08/sarah/MariaDbConnection.java @@ -1,5 +1,7 @@ package fr.maxlego08.sarah; +import fr.maxlego08.sarah.logger.Logger; + import java.sql.Connection; import java.sql.DriverManager; import java.util.Properties; @@ -9,8 +11,8 @@ */ public class MariaDbConnection extends DatabaseConnection { - public MariaDbConnection(DatabaseConfiguration databaseConfiguration) { - super(databaseConfiguration); + public MariaDbConnection(DatabaseConfiguration databaseConfiguration, Logger logger) { + super(databaseConfiguration, logger); } @Override diff --git a/src/main/java/fr/maxlego08/sarah/MigrationManager.java b/src/main/java/fr/maxlego08/sarah/MigrationManager.java index a6b6359..16f4746 100644 --- a/src/main/java/fr/maxlego08/sarah/MigrationManager.java +++ b/src/main/java/fr/maxlego08/sarah/MigrationManager.java @@ -4,6 +4,7 @@ import fr.maxlego08.sarah.database.DatabaseType; import fr.maxlego08.sarah.database.Migration; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.Connection; @@ -120,7 +121,8 @@ public static void execute(DatabaseConnection databaseConnection, Logger logger) } mustBeAdd.addAll(columnDefinitions); } catch (SQLException exception) { - exception.printStackTrace(); + logger.info("Failed to get table info for migration: " + exception.getMessage()); + throw new DatabaseException("migration-table-info", tableName, exception); } } else { for (ColumnDefinition column : schema.getColumns()) { @@ -187,7 +189,8 @@ private static void createMigrationTable(DatabaseConnection databaseConnection, try { schema.execute(databaseConnection, logger); } catch (SQLException exception) { - exception.printStackTrace(); + logger.info("Failed to create migration table: " + exception.getMessage()); + throw new DatabaseException("create-migration-table", migrationTableName, exception); } } @@ -203,7 +206,7 @@ private static List getMigrations(DatabaseConnection databaseConnection, try { return schema.executeSelect(MigrationTable.class, databaseConnection, logger).stream().map(MigrationTable::getMigration).collect(Collectors.toList()); } catch (Exception exception) { - exception.printStackTrace(); + logger.info("Failed to get migrations list: " + exception.getMessage()); } return new ArrayList<>(); } @@ -221,7 +224,8 @@ private static void insertMigration(DatabaseConnection databaseConnection, Logge try { SchemaBuilder.insert(migrationTableName, schema -> schema.string("migration", migration.getClass().getSimpleName())).execute(databaseConnection, logger); } catch (SQLException exception) { - exception.printStackTrace(); + logger.info("Failed to insert migration record: " + exception.getMessage()); + throw new DatabaseException("insert-migration", migrationTableName, exception); } } diff --git a/src/main/java/fr/maxlego08/sarah/MySqlConnection.java b/src/main/java/fr/maxlego08/sarah/MySqlConnection.java index b4b49d3..049b2b7 100644 --- a/src/main/java/fr/maxlego08/sarah/MySqlConnection.java +++ b/src/main/java/fr/maxlego08/sarah/MySqlConnection.java @@ -1,13 +1,15 @@ package fr.maxlego08.sarah; +import fr.maxlego08.sarah.logger.Logger; + import java.sql.Connection; import java.sql.DriverManager; import java.util.Properties; public class MySqlConnection extends DatabaseConnection { - public MySqlConnection(DatabaseConfiguration databaseConfiguration) { - super(databaseConfiguration); + public MySqlConnection(DatabaseConfiguration databaseConfiguration, Logger logger) { + super(databaseConfiguration, logger); } @Override diff --git a/src/main/java/fr/maxlego08/sarah/RequestHelper.java b/src/main/java/fr/maxlego08/sarah/RequestHelper.java index e4ef7f2..31ae903 100644 --- a/src/main/java/fr/maxlego08/sarah/RequestHelper.java +++ b/src/main/java/fr/maxlego08/sarah/RequestHelper.java @@ -2,6 +2,7 @@ import fr.maxlego08.sarah.database.DatabaseType; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import fr.maxlego08.sarah.requests.InsertBatchRequest; import fr.maxlego08.sarah.requests.UpdateBatchRequest; @@ -11,6 +12,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.BiFunction; import java.util.function.Consumer; public class RequestHelper { @@ -48,24 +50,11 @@ public void upsert(String tableName, Consumer consumer) { try { SchemaBuilder.upsert(tableName, consumer).execute(this.connection, this.logger); } catch (SQLException exception) { - exception.printStackTrace(); + this.logger.info("Upsert operation failed on table: " + tableName + " - " + exception.getMessage()); + throw new DatabaseException("upsert", tableName, exception); } } - /** - * Updates a table in the database using the given class template and data. - * The table name is inferred from the class name. - * The fields of the class are used to define the columns of the table. - * The data is used to provide values for the columns. - * - * @param tableName the name of the table - * @param clazz the class template - * @param data the data to be updated - */ - public void update(String tableName, Class clazz, T data) { - this.update(tableName, ConsumerConstructor.createConsumerFromTemplate(clazz, data)); - } - /** * Updates a table in the database using the given schema. * The schema builder should have a consumer that defines the columns and values to be updated. @@ -77,7 +66,8 @@ public void update(String tableName, Consumer consumer) { try { SchemaBuilder.update(tableName, consumer).execute(this.connection, this.logger); } catch (SQLException exception) { - exception.printStackTrace(); + this.logger.info("Update operation failed on table: " + tableName + " - " + exception.getMessage()); + throw new DatabaseException("update", tableName, exception); } } @@ -136,8 +126,9 @@ public void insert(String tableName, Consumer consumer, Consumer consumer) { try { return schema.executeSelectCount(this.connection, this.logger); } catch (SQLException exception) { - exception.printStackTrace(); + this.logger.info("Count operation failed on table: " + tableName + " - " + exception.getMessage()); } return 0L; } @@ -177,7 +168,7 @@ public List select(String tableName, Class clazz, Consumer con try { return schema.executeSelect(clazz, this.connection, this.logger); } catch (Exception exception) { - exception.printStackTrace(); + this.logger.info("Select operation failed on table: " + tableName + " - " + exception.getMessage()); } return new ArrayList<>(); } @@ -198,7 +189,7 @@ public List> select(String tableName, Consumer consu try { return schema.executeSelect(this.connection, this.logger); } catch (Exception exception) { - exception.printStackTrace(); + this.logger.info("Select operation failed on table: " + tableName + " - " + exception.getMessage()); } return new ArrayList<>(); } @@ -216,7 +207,7 @@ public List selectAll(String tableName, Class clazz) { try { return schema.executeSelect(clazz, this.connection, this.logger); } catch (Exception exception) { - exception.printStackTrace(); + this.logger.info("SelectAll operation failed on table: " + tableName + " - " + exception.getMessage()); } return new ArrayList<>(); } @@ -235,7 +226,8 @@ public void delete(String tableName, Consumer consumer) { try { schema.execute(this.connection, this.logger); } catch (SQLException exception) { - exception.printStackTrace(); + this.logger.info("Delete operation failed on table: " + tableName + " - " + exception.getMessage()); + throw new DatabaseException("delete", tableName, exception); } } @@ -253,6 +245,25 @@ public void upsertMultiple(List schemas) { request.execute(this.connection, this.connection.getDatabaseConfiguration(), this.logger); } + /** + * Executes an upsert operation on a batch of DTOs. + * This method converts each DTO to a Schema and then performs batch upsert operation, + * allowing for the insertion of new rows or updating existing rows based on primary key constraints. + * + * @param tableName the name of the table + * @param clazz the class type of the DTOs + * @param dataList a list of DTO objects to be upserted + * @param the type of the DTO + */ + public void upsertMultiple(String tableName, Class clazz, List dataList) { + List schemas = new ArrayList<>(); + for (T data : dataList) { + Schema schema = SchemaBuilder.upsert(tableName, ConsumerConstructor.createConsumerFromTemplate(clazz, data)); + schemas.add(schema); + } + this.upsertMultiple(schemas); + } + /** * Executes an insert operation on a batch of schemas. * This method utilizes an InsertBatchRequest to perform the insert operation @@ -267,6 +278,25 @@ public void insertMultiple(List schemas) { request.execute(this.connection, this.connection.getDatabaseConfiguration(), this.logger); } + /** + * Executes an insert operation on a batch of DTOs. + * This method converts each DTO to a Schema and then performs batch insert operation, + * allowing for the insertion of multiple rows at once. + * + * @param tableName the name of the table + * @param clazz the class type of the DTOs + * @param dataList a list of DTO objects to be inserted + * @param the type of the DTO + */ + public void insertMultiple(String tableName, Class clazz, List dataList) { + List schemas = new ArrayList<>(); + for (T data : dataList) { + Schema schema = SchemaBuilder.insert(tableName, ConsumerConstructor.createConsumerFromTemplate(clazz, data)); + schemas.add(schema); + } + this.insertMultiple(schemas); + } + /** * Executes an update operation on a batch of schemas. * This method utilizes an UpdateBatchRequest to perform the update operation @@ -284,7 +314,8 @@ public void updateMultiple(List schemas) { try { schema.execute(this.connection, this.logger); } catch (SQLException exception) { - exception.printStackTrace(); + this.logger.info("UpdateMultiple operation failed for schema: " + schema.getTableName() + " - " + exception.getMessage()); + throw new DatabaseException("updateMultiple", schema.getTableName(), exception); } } return; @@ -294,6 +325,28 @@ public void updateMultiple(List schemas) { request.execute(this.connection, this.connection.getDatabaseConfiguration(), this.logger); } + /** + * Executes an update operation on a batch of DTOs. + * This method converts each DTO to a Schema and then performs batch update operation, + * allowing for modifications to existing rows based on the DTO data. + * + *

Note: For SQLite databases, updates are executed sequentially rather than in a single batch + * due to driver limitations.

+ * + * @param tableName the name of the table + * @param clazz the class type of the DTOs + * @param dataList a list of DTO objects to be updated + * @param the type of the DTO + */ + public void updateMultiple(String tableName, Class clazz, List dataList) { + List schemas = new ArrayList<>(); + for (T data : dataList) { + Schema schema = SchemaBuilder.update(tableName, ConsumerConstructor.createConsumerFromTemplate(clazz, data)); + schemas.add(schema); + } + this.updateMultiple(schemas); + } + /** * Retrieves the current database connection. * diff --git a/src/main/java/fr/maxlego08/sarah/SchemaBuilder.java b/src/main/java/fr/maxlego08/sarah/SchemaBuilder.java index 8947bbe..fc831c5 100644 --- a/src/main/java/fr/maxlego08/sarah/SchemaBuilder.java +++ b/src/main/java/fr/maxlego08/sarah/SchemaBuilder.java @@ -9,6 +9,7 @@ import fr.maxlego08.sarah.database.Migration; import fr.maxlego08.sarah.database.Schema; import fr.maxlego08.sarah.database.SchemaType; +import fr.maxlego08.sarah.exceptions.SarahException; import fr.maxlego08.sarah.logger.Logger; import fr.maxlego08.sarah.requests.AlterRequest; import fr.maxlego08.sarah.requests.CreateIndexRequest; @@ -20,6 +21,7 @@ import fr.maxlego08.sarah.requests.RenameExecutor; import fr.maxlego08.sarah.requests.UpdateRequest; import fr.maxlego08.sarah.requests.UpsertRequest; +import fr.maxlego08.sarah.security.SecureObjectInputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -220,20 +222,19 @@ public Schema whereNull(String columnName) { @Override public Schema whereIn(String columnName, Object... objects) { - return whereIn(null, columnName, objects); + // Convert varargs to List to avoid ambiguity + List valuesList = Arrays.stream(objects).map(String::valueOf).collect(Collectors.toList()); + this.whereConditions.add(new WhereCondition(null, columnName, valuesList)); + return this; } @Override public Schema whereIn(String columnName, List strings) { - return whereIn(null, columnName, strings); - } - - @Override - public Schema whereIn(String tablePrefix, String columnName, Object... objects) { - this.whereConditions.add(new WhereCondition(tablePrefix, columnName, Arrays.stream(objects).map(String::valueOf).collect(Collectors.toList()))); + this.whereConditions.add(new WhereCondition(null, columnName, strings)); return this; } + // Method with table prefix - use List to avoid ambiguity with varargs version @Override public Schema whereIn(String tablePrefix, String columnName, List strings) { this.whereConditions.add(new WhereCondition(tablePrefix, columnName, strings)); @@ -493,7 +494,7 @@ public long executeSelectCount(DatabaseConnection databaseConnection, Logger log } } } catch (SQLException exception) { - exception.printStackTrace(); + logger.info("Failed to execute schema select count: " + exception.getMessage()); throw new SQLException("Failed to execute schema select count: " + exception.getMessage(), exception); } return 0; @@ -630,8 +631,7 @@ protected Object convertToRequiredType(Object value, Class type) { try { return formatter.parse((String) value); } catch (ParseException exception) { - exception.printStackTrace(); - return null; + throw new SarahException("Failed to parse date: " + value, exception); } } if (value instanceof Number) { @@ -654,11 +654,34 @@ protected byte[] serializeObject(Object object) throws IOException { } } + /** + * Securely deserializes an object from a byte array using a whitelist approach. + * This method protects against deserialization attacks (CVE-2015-7501, CVE-2017-7525, etc.) + * by only allowing specific classes to be deserialized. + * + *

The package of the requested type is automatically whitelisted, allowing users + * of this library to deserialize their own model classes without additional configuration. + * This provides security by default while maintaining ease of use.

+ * + * @param data the serialized object data + * @param type the expected type of the deserialized object + * @return the deserialized object + * @throws SarahException if deserialization fails or an unauthorized class is detected + */ protected T deserializeObject(byte[] data, Class type) { - try (ByteArrayInputStream bais = new ByteArrayInputStream(data); ObjectInputStream ois = new ObjectInputStream(bais)) { + try (ByteArrayInputStream bais = new ByteArrayInputStream(data); + SecureObjectInputStream ois = new SecureObjectInputStream(bais, type)) { + + // Automatically allow the package of the requested type + // This allows library users to deserialize their own model classes + if (type.getPackage() != null) { + String packageName = type.getPackage().getName(); + ois.allowPackagePrefix(packageName); + } + return type.cast(ois.readObject()); } catch (IOException | ClassNotFoundException exception) { - throw new Error("An exception occurred during deserialization of a BLOB ", exception); + throw new SarahException("Failed to deserialize BLOB: " + exception.getMessage(), exception); } } diff --git a/src/main/java/fr/maxlego08/sarah/SqliteConnection.java b/src/main/java/fr/maxlego08/sarah/SqliteConnection.java index 01d5b9e..ef90591 100644 --- a/src/main/java/fr/maxlego08/sarah/SqliteConnection.java +++ b/src/main/java/fr/maxlego08/sarah/SqliteConnection.java @@ -1,6 +1,10 @@ package fr.maxlego08.sarah; +import fr.maxlego08.sarah.logger.Logger; + import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; import java.sql.Connection; import java.sql.DriverManager; @@ -9,28 +13,26 @@ public class SqliteConnection extends DatabaseConnection { private final File folder; private String fileName = "database.db"; - public SqliteConnection(DatabaseConfiguration databaseConfiguration, File folder) { - super(databaseConfiguration); + public SqliteConnection(DatabaseConfiguration databaseConfiguration, File folder, Logger logger) { + super(databaseConfiguration, logger); this.folder = folder; } @Override public Connection connectToDatabase() throws Exception { - if (!this.folder.exists()) { - this.folder.mkdirs(); - } + // Thread-safe directory creation using Files API + Files.createDirectories(folder.toPath()); - File databaseFile = new File(this.folder, this.fileName); - if (!databaseFile.exists()) { - databaseFile.createNewFile(); - } + // SQLite automatically creates the file if it doesn't exist + Path dbPath = folder.toPath().resolve(fileName); + String url = "jdbc:sqlite:" + dbPath.toAbsolutePath(); try { Class.forName("org.sqlite.JDBC"); } catch (ClassNotFoundException ignored) { } - return DriverManager.getConnection("jdbc:sqlite:" + databaseFile.getAbsolutePath()); + return DriverManager.getConnection(url); } public File getFolder() { diff --git a/src/main/java/fr/maxlego08/sarah/conditions/ColumnDefinition.java b/src/main/java/fr/maxlego08/sarah/conditions/ColumnDefinition.java index 2ce3fb6..8213aaf 100644 --- a/src/main/java/fr/maxlego08/sarah/conditions/ColumnDefinition.java +++ b/src/main/java/fr/maxlego08/sarah/conditions/ColumnDefinition.java @@ -33,7 +33,15 @@ public ColumnDefinition(String name) { * @return The SQL string representation of the column */ public String build(DatabaseConfiguration databaseConfiguration) { - StringBuilder columnSQL = new StringBuilder("`" + name + "` " + type); + // For SQLite autoincrement, use INTEGER instead of BIGINT/INT + String columnType = type; + if (isAutoIncrement && databaseConfiguration.getDatabaseType() == DatabaseType.SQLITE) { + if (type.equalsIgnoreCase("BIGINT") || type.equalsIgnoreCase("INT") || type.equalsIgnoreCase("INTEGER")) { + columnType = "INTEGER"; + } + } + + StringBuilder columnSQL = new StringBuilder("`" + name + "` " + columnType); if (length != 0 && decimal != 0) { columnSQL.append("(").append(length).append(",").append(decimal).append(")"); @@ -41,8 +49,17 @@ public String build(DatabaseConfiguration databaseConfiguration) { columnSQL.append("(").append(length).append(")"); } - if (isAutoIncrement) { - if (databaseConfiguration.getDatabaseType() != DatabaseType.SQLITE) { + // For autoincrement columns with primary key + if (isAutoIncrement && isPrimaryKey) { + if (databaseConfiguration.getDatabaseType() == DatabaseType.SQLITE) { + // SQLite: INTEGER PRIMARY KEY AUTOINCREMENT (inline, no NOT NULL needed) + columnSQL.append(" PRIMARY KEY AUTOINCREMENT"); + if (unique) { + columnSQL.append(" UNIQUE"); + } + return columnSQL.toString(); + } else { + // MySQL/MariaDB: column will have AUTO_INCREMENT columnSQL.append(" AUTO_INCREMENT"); } } @@ -147,6 +164,10 @@ public void setUnique(boolean unique) { this.unique = unique; } + public boolean isUnique() { + return unique; + } + public Object getObject() { return object; } diff --git a/src/main/java/fr/maxlego08/sarah/database/Schema.java b/src/main/java/fr/maxlego08/sarah/database/Schema.java index f59a98e..237be9b 100644 --- a/src/main/java/fr/maxlego08/sarah/database/Schema.java +++ b/src/main/java/fr/maxlego08/sarah/database/Schema.java @@ -389,8 +389,6 @@ public interface Schema { Schema whereIn(String columnName, List strings); - Schema whereIn(String tablePrefix, String columnName, Object... objects); - Schema whereIn(String tablePrefix, String columnName, List strings); /** diff --git a/src/main/java/fr/maxlego08/sarah/exceptions/DatabaseException.java b/src/main/java/fr/maxlego08/sarah/exceptions/DatabaseException.java new file mode 100644 index 0000000..e4fbce3 --- /dev/null +++ b/src/main/java/fr/maxlego08/sarah/exceptions/DatabaseException.java @@ -0,0 +1,33 @@ +package fr.maxlego08.sarah.exceptions; + +/** + * Exception thrown when a database operation fails. + * Contains information about the operation and table involved. + * + * @since 1.0.0 + */ +public class DatabaseException extends SarahException { + + private final String tableName; + private final String operation; + + public DatabaseException(String operation, String tableName, Throwable cause) { + super(String.format("Database operation '%s' failed on table '%s'", operation, tableName), cause); + this.operation = operation; + this.tableName = tableName; + } + + public DatabaseException(String operation, Throwable cause) { + super(String.format("Database operation '%s' failed", operation), cause); + this.operation = operation; + this.tableName = null; + } + + public String getTableName() { + return tableName; + } + + public String getOperation() { + return operation; + } +} \ No newline at end of file diff --git a/src/main/java/fr/maxlego08/sarah/exceptions/SarahException.java b/src/main/java/fr/maxlego08/sarah/exceptions/SarahException.java new file mode 100644 index 0000000..738261b --- /dev/null +++ b/src/main/java/fr/maxlego08/sarah/exceptions/SarahException.java @@ -0,0 +1,15 @@ +package fr.maxlego08.sarah.exceptions; + +/** + * Base exception for all Sarah library exceptions. + */ +public class SarahException extends RuntimeException { + + public SarahException(String message) { + super(message); + } + + public SarahException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/fr/maxlego08/sarah/requests/AlterRequest.java b/src/main/java/fr/maxlego08/sarah/requests/AlterRequest.java index 0006216..a25525d 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/AlterRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/AlterRequest.java @@ -5,6 +5,7 @@ import fr.maxlego08.sarah.conditions.ColumnDefinition; import fr.maxlego08.sarah.database.Executor; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.Connection; @@ -51,8 +52,8 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration preparedStatement.execute(); return preparedStatement.getUpdateCount(); } catch (SQLException exception) { - exception.printStackTrace(); - return -1; + logger.info("Alter table operation failed on table: " + this.schema.getTableName() + " - " + exception.getMessage()); + throw new DatabaseException("alter", this.schema.getTableName(), exception); } } } diff --git a/src/main/java/fr/maxlego08/sarah/requests/CreateIndexRequest.java b/src/main/java/fr/maxlego08/sarah/requests/CreateIndexRequest.java index 5e934e3..68557bf 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/CreateIndexRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/CreateIndexRequest.java @@ -5,6 +5,7 @@ import fr.maxlego08.sarah.conditions.ColumnDefinition; import fr.maxlego08.sarah.database.Executor; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.Connection; @@ -43,8 +44,8 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration preparedStatement.execute(); return preparedStatement.getUpdateCount(); } catch (SQLException exception) { - exception.printStackTrace(); - return -1; + logger.info("Create index operation failed on table: " + tableName + " - " + exception.getMessage()); + throw new DatabaseException("createIndex", tableName, exception); } } diff --git a/src/main/java/fr/maxlego08/sarah/requests/CreateRequest.java b/src/main/java/fr/maxlego08/sarah/requests/CreateRequest.java index 996a52c..8e4b24f 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/CreateRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/CreateRequest.java @@ -5,6 +5,7 @@ import fr.maxlego08.sarah.conditions.ColumnDefinition; import fr.maxlego08.sarah.database.Executor; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.Connection; @@ -28,12 +29,20 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration createTableSQL.append(this.schema.getTableName()).append(" ("); List columnSQLs = new ArrayList<>(); + boolean hasInlinePrimaryKey = false; + for (ColumnDefinition column : this.schema.getColumns()) { columnSQLs.add(column.build(databaseConfiguration)); + // Check if this column has inline PRIMARY KEY (SQLite autoincrement) + if (column.isAutoIncrement() && column.isPrimaryKey() && + databaseConfiguration.getDatabaseType() == fr.maxlego08.sarah.database.DatabaseType.SQLITE) { + hasInlinePrimaryKey = true; + } } createTableSQL.append(String.join(", ", columnSQLs)); - if (!this.schema.getPrimaryKeys().isEmpty()) { + // Only add separate PRIMARY KEY clause if there's no inline PRIMARY KEY + if (!this.schema.getPrimaryKeys().isEmpty() && !hasInlinePrimaryKey) { createTableSQL.append(", PRIMARY KEY (").append(String.join(", ", this.schema.getPrimaryKeys())).append(")"); } @@ -52,8 +61,8 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration preparedStatement.execute(); return preparedStatement.getUpdateCount(); } catch (SQLException exception) { - exception.printStackTrace(); - return -1; + logger.info("Create table operation failed on table: " + this.schema.getTableName() + " - " + exception.getMessage()); + throw new DatabaseException("create", this.schema.getTableName(), exception); } } } diff --git a/src/main/java/fr/maxlego08/sarah/requests/DeleteRequest.java b/src/main/java/fr/maxlego08/sarah/requests/DeleteRequest.java index 4588694..1b9639c 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/DeleteRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/DeleteRequest.java @@ -4,6 +4,7 @@ import fr.maxlego08.sarah.DatabaseConnection; import fr.maxlego08.sarah.database.Executor; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.Connection; @@ -34,8 +35,8 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration int result = preparedStatement.executeUpdate(); return result; } catch (SQLException exception) { - exception.printStackTrace(); - return -1; + logger.info("Delete operation failed on table: " + schemaBuilder.getTableName() + " - " + exception.getMessage()); + throw new DatabaseException("delete", schemaBuilder.getTableName(), exception); } } } diff --git a/src/main/java/fr/maxlego08/sarah/requests/InsertAllRequest.java b/src/main/java/fr/maxlego08/sarah/requests/InsertAllRequest.java index 0043d74..0f82ea4 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/InsertAllRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/InsertAllRequest.java @@ -5,6 +5,7 @@ import fr.maxlego08.sarah.conditions.ColumnDefinition; import fr.maxlego08.sarah.database.Executor; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.Connection; @@ -56,8 +57,8 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration try (Connection connection = databaseConnection.getConnection(); PreparedStatement preparedStatement = connection.prepareStatement(insertQuery)) { preparedStatement.executeUpdate(); } catch (SQLException exception) { - exception.printStackTrace(); - return -1; + logger.info("Insert all operation failed from table: " + this.schema.getTableName() + " to table: " + this.toTableName + " - " + exception.getMessage()); + throw new DatabaseException("insertAll", this.toTableName, exception); } return 0; diff --git a/src/main/java/fr/maxlego08/sarah/requests/InsertBatchRequest.java b/src/main/java/fr/maxlego08/sarah/requests/InsertBatchRequest.java index 572d34b..87f31d9 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/InsertBatchRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/InsertBatchRequest.java @@ -5,6 +5,7 @@ import fr.maxlego08.sarah.conditions.ColumnDefinition; import fr.maxlego08.sarah.database.Executor; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.Connection; @@ -73,9 +74,9 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration for (Object value : values) { preparedStatement.setObject(index++, value); } - + int updatedRows = preparedStatement.executeUpdate(); - + try (ResultSet generatedKeys = preparedStatement.getGeneratedKeys()) { if (generatedKeys.next()) { return generatedKeys.getInt(1); @@ -83,8 +84,8 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration } return updatedRows; } catch (SQLException exception) { - exception.printStackTrace(); - return -1; + logger.info("Insert batch operation failed on table: " + firstSchema.getTableName() + " - " + exception.getMessage()); + throw new DatabaseException("insertBatch", firstSchema.getTableName(), exception); } } } diff --git a/src/main/java/fr/maxlego08/sarah/requests/InsertRequest.java b/src/main/java/fr/maxlego08/sarah/requests/InsertRequest.java index bff9468..b22b015 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/InsertRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/InsertRequest.java @@ -5,6 +5,7 @@ import fr.maxlego08.sarah.conditions.ColumnDefinition; import fr.maxlego08.sarah.database.Executor; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.Connection; @@ -65,11 +66,12 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration return 0; } } catch (Exception exception) { - return -1; + logger.info("Insert operation failed on table: " + this.schema.getTableName() + " - Failed to retrieve generated keys - " + exception.getMessage()); + throw new DatabaseException("insert", this.schema.getTableName(), exception); } } catch (SQLException exception) { - exception.printStackTrace(); - return -1; + logger.info("Insert operation failed on table: " + this.schema.getTableName() + " - " + exception.getMessage()); + throw new DatabaseException("insert", this.schema.getTableName(), exception); } } } diff --git a/src/main/java/fr/maxlego08/sarah/requests/ModifyRequest.java b/src/main/java/fr/maxlego08/sarah/requests/ModifyRequest.java index 1ada0ea..c146e39 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/ModifyRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/ModifyRequest.java @@ -6,6 +6,7 @@ import fr.maxlego08.sarah.database.Executor; import fr.maxlego08.sarah.database.Schema; import fr.maxlego08.sarah.database.SchemaType; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.SQLException; @@ -27,8 +28,8 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration try { tmpSchema.execute(databaseConnection, logger); } catch (SQLException exception) { - exception.printStackTrace(); - return -1; + logger.info("Modify table operation failed on table: " + schema.getTableName() + " - Failed to create temporary table - " + exception.getMessage()); + throw new DatabaseException("modify", schema.getTableName(), exception); } Executor executor = new InsertAllRequest(schema, tmpTableName); diff --git a/src/main/java/fr/maxlego08/sarah/requests/RenameExecutor.java b/src/main/java/fr/maxlego08/sarah/requests/RenameExecutor.java index 195cb3b..48585bc 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/RenameExecutor.java +++ b/src/main/java/fr/maxlego08/sarah/requests/RenameExecutor.java @@ -4,6 +4,7 @@ import fr.maxlego08.sarah.DatabaseConnection; import fr.maxlego08.sarah.database.Executor; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.Connection; @@ -36,8 +37,8 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration preparedStatement.execute(); return preparedStatement.getUpdateCount(); } catch (SQLException exception) { - exception.printStackTrace(); - return -1; + logger.info("Rename table operation failed: " + this.schema.getTableName() + " to " + this.schema.getNewTableName() + " - " + exception.getMessage()); + throw new DatabaseException("rename", this.schema.getTableName(), exception); } } } diff --git a/src/main/java/fr/maxlego08/sarah/requests/UpdateBatchRequest.java b/src/main/java/fr/maxlego08/sarah/requests/UpdateBatchRequest.java index cf7d429..0e146f3 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/UpdateBatchRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/UpdateBatchRequest.java @@ -6,6 +6,7 @@ import fr.maxlego08.sarah.conditions.JoinCondition; import fr.maxlego08.sarah.database.Executor; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.Connection; @@ -69,8 +70,8 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration return total; } catch (SQLException exception) { - exception.printStackTrace(); - return -1; + logger.info("Update batch operation failed on table: " + firstSchema.getTableName() + " - " + exception.getMessage()); + throw new DatabaseException("updateBatch", firstSchema.getTableName(), exception); } } } diff --git a/src/main/java/fr/maxlego08/sarah/requests/UpdateRequest.java b/src/main/java/fr/maxlego08/sarah/requests/UpdateRequest.java index 9669cf2..e74b244 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/UpdateRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/UpdateRequest.java @@ -6,6 +6,7 @@ import fr.maxlego08.sarah.conditions.JoinCondition; import fr.maxlego08.sarah.database.Executor; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.Connection; @@ -59,8 +60,8 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration preparedStatement.executeUpdate(); return preparedStatement.getUpdateCount(); } catch (SQLException exception) { - exception.printStackTrace(); - return -1; + logger.info("Update operation failed on table: " + this.schema.getTableName() + " - " + exception.getMessage()); + throw new DatabaseException("update", this.schema.getTableName(), exception); } } } diff --git a/src/main/java/fr/maxlego08/sarah/requests/UpsertBatchRequest.java b/src/main/java/fr/maxlego08/sarah/requests/UpsertBatchRequest.java index 2607dd2..de65f72 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/UpsertBatchRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/UpsertBatchRequest.java @@ -6,6 +6,7 @@ import fr.maxlego08.sarah.database.DatabaseType; import fr.maxlego08.sarah.database.Executor; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.Connection; @@ -99,8 +100,8 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration return preparedStatement.executeUpdate(); } catch (SQLException exception) { - exception.printStackTrace(); - return -1; + logger.info("Upsert batch operation failed on table: " + firstSchema.getTableName() + " - " + exception.getMessage()); + throw new DatabaseException("upsertBatch", firstSchema.getTableName(), exception); } } } diff --git a/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java b/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java index 5b799d2..a571f8d 100644 --- a/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java +++ b/src/main/java/fr/maxlego08/sarah/requests/UpsertRequest.java @@ -6,6 +6,7 @@ import fr.maxlego08.sarah.database.DatabaseType; import fr.maxlego08.sarah.database.Executor; import fr.maxlego08.sarah.database.Schema; +import fr.maxlego08.sarah.exceptions.DatabaseException; import fr.maxlego08.sarah.logger.Logger; import java.sql.Connection; @@ -101,18 +102,17 @@ public int execute(DatabaseConnection databaseConnection, DatabaseConfiguration preparedStatement.executeUpdate(); return preparedStatement.getUpdateCount(); } catch (SQLException exception) { - exception.printStackTrace(); - //throw new SQLException("Failed to execute upsert: " + exception.getMessage(), exception); - return -1; + logger.info("Upsert operation failed on table: " + this.schema.getTableName() + " - " + exception.getMessage()); + throw new DatabaseException("upsert", this.schema.getTableName(), exception); } } private List getNonAutoIncrementPrimaryKeys() { - List primaryKeys = schema.getPrimaryKeys(); + List conflictColumns = new ArrayList<>(); - // Filter out auto-increment columns from primary keys for ON CONFLICT clause - List nonAutoIncrementPrimaryKeys = new ArrayList<>(); + // First, try to find primary keys that are not auto-increment + List primaryKeys = schema.getPrimaryKeys(); for (String primaryKey : primaryKeys) { boolean isAutoIncrement = false; for (ColumnDefinition col : schema.getColumns()) { @@ -122,15 +122,24 @@ private List getNonAutoIncrementPrimaryKeys() { } } if (!isAutoIncrement) { - nonAutoIncrementPrimaryKeys.add(primaryKey); + conflictColumns.add(primaryKey); + } + } + + // If no non-auto-increment primary keys exist, look for UNIQUE columns + if (conflictColumns.isEmpty()) { + for (ColumnDefinition col : schema.getColumns()) { + // Check if column is unique and not auto-increment + if (col.isUnique() && !col.isAutoIncrement()) { + conflictColumns.add(col.getSafeName()); + } } } - // If no non-auto-increment primary keys exist, we cannot use ON CONFLICT - // In this case, fall back to a regular INSERT (which will fail on conflict) - if (nonAutoIncrementPrimaryKeys.isEmpty()) { + // If still no conflict columns found, throw error + if (conflictColumns.isEmpty()) { throw new IllegalStateException("UPSERT requires at least one non-auto-increment primary key or unique constraint for SQLite"); } - return nonAutoIncrementPrimaryKeys; + return conflictColumns; } } diff --git a/src/main/java/fr/maxlego08/sarah/security/SecureObjectInputStream.java b/src/main/java/fr/maxlego08/sarah/security/SecureObjectInputStream.java new file mode 100644 index 0000000..36b3c61 --- /dev/null +++ b/src/main/java/fr/maxlego08/sarah/security/SecureObjectInputStream.java @@ -0,0 +1,154 @@ +package fr.maxlego08.sarah.security; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InvalidClassException; +import java.io.ObjectInputStream; +import java.io.ObjectStreamClass; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Secure ObjectInputStream that prevents deserialization attacks by using a whitelist of allowed classes. + * This prevents arbitrary code execution vulnerabilities (CVE-2015-7501, CVE-2017-7525, etc.) + * + * @since 1.0.0 + */ +public class SecureObjectInputStream extends ObjectInputStream { + + /** + * Whitelist of allowed classes for deserialization. + * Only classes in this set can be deserialized. + */ + private final Set allowedClasses; + + /** + * Whitelist of allowed package prefixes. + * Classes from these packages can be deserialized. + */ + private final Set allowedPackagePrefixes; + + /** + * Creates a SecureObjectInputStream with the specified allowed classes. + * + * @param in the input stream to read from + * @param allowedClasses classes that are allowed to be deserialized + * @throws IOException if an I/O error occurs + */ + public SecureObjectInputStream(InputStream in, Class... allowedClasses) throws IOException { + super(in); + this.allowedClasses = new HashSet<>(); + this.allowedPackagePrefixes = new HashSet<>(); + + // Add safe primitive wrapper classes by default + addSafeDefaults(); + + // Add user-specified classes + for (Class clazz : allowedClasses) { + this.allowedClasses.add(clazz.getName()); + } + } + + /** + * Creates a SecureObjectInputStream with allowed classes and package prefixes. + * + * @param in the input stream to read from + * @param allowedClasses specific classes that are allowed + * @param allowedPackagePrefixes package prefixes that are allowed (e.g., "fr.maxlego08.sarah.models") + * @throws IOException if an I/O error occurs + */ + public SecureObjectInputStream(InputStream in, Set allowedClasses, Set allowedPackagePrefixes) throws IOException { + super(in); + this.allowedClasses = new HashSet<>(allowedClasses); + this.allowedPackagePrefixes = new HashSet<>(allowedPackagePrefixes); + addSafeDefaults(); + } + + /** + * Adds safe default classes that are unlikely to be exploited. + */ + private void addSafeDefaults() { + // Java primitive wrappers and basic types + allowedClasses.add("java.lang.String"); + allowedClasses.add("java.lang.Integer"); + allowedClasses.add("java.lang.Long"); + allowedClasses.add("java.lang.Double"); + allowedClasses.add("java.lang.Float"); + allowedClasses.add("java.lang.Boolean"); + allowedClasses.add("java.lang.Byte"); + allowedClasses.add("java.lang.Short"); + allowedClasses.add("java.lang.Character"); + allowedClasses.add("java.lang.Number"); + + // Date and time + allowedClasses.add("java.util.Date"); + allowedClasses.add("java.sql.Date"); + allowedClasses.add("java.sql.Time"); + allowedClasses.add("java.sql.Timestamp"); + + // Arrays of primitives + allowedClasses.add("[B"); // byte[] + allowedClasses.add("[C"); // char[] + allowedClasses.add("[I"); // int[] + allowedClasses.add("[J"); // long[] + allowedClasses.add("[F"); // float[] + allowedClasses.add("[D"); // double[] + allowedClasses.add("[Z"); // boolean[] + allowedClasses.add("[S"); // short[] + + // Common safe collections (empty or with safe elements only) + allowedClasses.add("java.util.ArrayList"); + allowedClasses.add("java.util.HashMap"); + allowedClasses.add("java.util.HashSet"); + allowedClasses.add("java.util.LinkedList"); + allowedClasses.add("java.util.TreeMap"); + allowedClasses.add("java.util.TreeSet"); + + // UUID + allowedClasses.add("java.util.UUID"); + } + + @Override + protected Class resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { + String className = desc.getName(); + + // Check if class is explicitly allowed + if (allowedClasses.contains(className)) { + return super.resolveClass(desc); + } + + // Check if class package prefix is allowed + for (String prefix : allowedPackagePrefixes) { + if (className.startsWith(prefix)) { + return super.resolveClass(desc); + } + } + + // Reject all other classes to prevent deserialization attacks + throw new InvalidClassException( + "Unauthorized deserialization attempt", + "Class " + className + " is not in the whitelist. " + + "This is a security measure to prevent deserialization attacks." + ); + } + + /** + * Adds a class to the whitelist of allowed classes. + * + * @param clazz the class to allow + */ + public void allowClass(Class clazz) { + this.allowedClasses.add(clazz.getName()); + } + + /** + * Adds a package prefix to the whitelist. + * All classes from packages starting with this prefix will be allowed. + * + * @param packagePrefix the package prefix (e.g., "fr.maxlego08.sarah.models") + */ + public void allowPackagePrefix(String packagePrefix) { + this.allowedPackagePrefixes.add(packagePrefix); + } +} \ No newline at end of file diff --git a/src/main/java/fr/maxlego08/sarah/transaction/Transaction.java b/src/main/java/fr/maxlego08/sarah/transaction/Transaction.java new file mode 100644 index 0000000..b566dc0 --- /dev/null +++ b/src/main/java/fr/maxlego08/sarah/transaction/Transaction.java @@ -0,0 +1,100 @@ +package fr.maxlego08.sarah.transaction; + +import fr.maxlego08.sarah.exceptions.DatabaseException; + +import java.sql.Connection; +import java.sql.SQLException; + +/** + * Represents a database transaction that groups multiple operations. + * Provides commit and rollback capabilities. + * + */ +public class Transaction implements AutoCloseable { + + private final Connection connection; + private boolean committed = false; + private boolean rolledBack = false; + + public Transaction(Connection connection) throws SQLException { + this.connection = connection; + this.connection.setAutoCommit(false); + } + + /** + * Gets the underlying database connection. + * + * @return the connection + */ + public Connection getConnection() { + return connection; + } + + /** + * Commits the transaction, making all changes permanent. + * + * @throws DatabaseException if the commit fails + */ + public void commit() { + if (committed || rolledBack) { + throw new IllegalStateException("Transaction already " + (committed ? "committed" : "rolled back")); + } + try { + connection.commit(); + committed = true; + } catch (SQLException exception) { + throw new DatabaseException("commit", exception); + } + } + + /** + * Rolls back the transaction, undoing all changes. + * + * @throws DatabaseException if the rollback fails + */ + public void rollback() { + if (committed || rolledBack) { + throw new IllegalStateException("Transaction already " + (committed ? "committed" : "rolled back")); + } + try { + connection.rollback(); + rolledBack = true; + } catch (SQLException exception) { + throw new DatabaseException("rollback", exception); + } + } + + /** + * Automatically rolls back the transaction if it hasn't been committed. + * This is called when using try-with-resources. + */ + @Override + public void close() { + try { + if (!committed && !rolledBack) { + connection.rollback(); + } + connection.setAutoCommit(true); + } catch (SQLException exception) { + throw new DatabaseException("close-transaction", exception); + } + } + + /** + * Checks if the transaction has been committed. + * + * @return true if committed, false otherwise + */ + public boolean isCommitted() { + return committed; + } + + /** + * Checks if the transaction has been rolled back. + * + * @return true if rolled back, false otherwise + */ + public boolean isRolledBack() { + return rolledBack; + } +} \ No newline at end of file diff --git a/src/test/java/fr/maxlego08/sarah/AutoIncrementTest.java b/src/test/java/fr/maxlego08/sarah/AutoIncrementTest.java new file mode 100644 index 0000000..1e27794 --- /dev/null +++ b/src/test/java/fr/maxlego08/sarah/AutoIncrementTest.java @@ -0,0 +1,231 @@ +package fr.maxlego08.sarah; + +import fr.maxlego08.sarah.database.Schema; +import org.junit.jupiter.api.Test; + +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite specifically for autoincrement functionality and related bug fixes + * Tests fixes from commits: d8f7972, a7c61fb, aeb6962 + */ +public class AutoIncrementTest extends DatabaseTestBase { + + @Test + public void testAutoIncrementInInsert() throws Exception { + // Create table with autoincrement + SchemaBuilder.create(null, "test_autoincrement", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("name", 50); + }).execute(connection, testLogger); + + // Insert without specifying ID + AtomicInteger id1 = new AtomicInteger(); + requestHelper.insert("test_autoincrement", schema -> { + schema.string("name", "first"); + }, id1::set); + + AtomicInteger id2 = new AtomicInteger(); + requestHelper.insert("test_autoincrement", schema -> { + schema.string("name", "second"); + }, id2::set); + + // Verify IDs are auto-generated and sequential + assertTrue(id1.get() > 0); + assertTrue(id2.get() > id1.get()); + } + + @Test + public void testAutoIncrementNotInUpsert() throws Exception { + // Test fix from commit aeb6962 and a7c61fb + // Autoincrement columns should be skipped in UPSERT operations + + SchemaBuilder.create(null, "test_autoincrement", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("code", 10).unique(); + schema.string("value", 50); + }).execute(connection, testLogger); + + // Insert initial record + requestHelper.insert("test_autoincrement", schema -> { + schema.string("code", "ABC"); + schema.string("value", "initial"); + }); + + // Get the generated ID + int originalId; + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT id FROM test_autoincrement WHERE code = 'ABC'"); + assertTrue(rs.next()); + originalId = rs.getInt("id"); + } + + // Upsert with same code + requestHelper.upsert("test_autoincrement", schema -> { + schema.string("code", "ABC").unique(); // Mark as unique for conflict detection + schema.string("value", "updated"); + }); + + // Verify ID hasn't changed + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT id, value FROM test_autoincrement WHERE code = 'ABC'"); + assertTrue(rs.next()); + assertEquals(originalId, rs.getInt("id"), "Autoincrement ID should not change on upsert"); + assertEquals("updated", rs.getString("value")); + } + } + + @Test + public void testUpsertWithOnlyAutoIncrementPrimaryKey() throws Exception { + // Test fix from commit d8f7972 + // This should throw an exception for SQLite as there's no non-autoincrement key for ON CONFLICT + + SchemaBuilder.create(null, "test_autoincrement", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("name", 50); + }).execute(connection, testLogger); + + if (configuration.getDatabaseType() == fr.maxlego08.sarah.database.DatabaseType.SQLITE) { + // For SQLite, UPSERT with only autoincrement primary key should fail or fallback + try { + requestHelper.upsert("test_autoincrement", schema -> { + schema.string("name", "test"); + }); + // If it doesn't throw, it should have fallen back to regular insert + assertEquals(1, countRows("test_autoincrement")); + } catch (Exception e) { + // Expected for SQLite - this is OK + assertTrue(e.getMessage().contains("UPSERT") || e.getMessage().contains("autoincrement")); + } + } else { + // For MySQL/MariaDB, this should work with ON DUPLICATE KEY UPDATE + requestHelper.upsert("test_autoincrement", schema -> { + schema.string("name", "test"); + }); + assertTrue(countRows("test_autoincrement") > 0); + } + } + + @Test + public void testCompositeKeyWithAutoIncrement() throws Exception { + // Create table with composite key including an autoincrement column + executeRawSQL("DROP TABLE IF EXISTS test_composite"); + + if (configuration.getDatabaseType() == fr.maxlego08.sarah.database.DatabaseType.SQLITE) { + executeRawSQL("CREATE TABLE test_composite (id INTEGER PRIMARY KEY AUTOINCREMENT, category VARCHAR(50), name VARCHAR(50), UNIQUE(category, name))"); + } else { + executeRawSQL("CREATE TABLE test_composite (id BIGINT AUTO_INCREMENT, category VARCHAR(50), name VARCHAR(50), PRIMARY KEY (id), UNIQUE(category, name))"); + } + + // Insert initial record + requestHelper.insert("test_composite", schema -> { + schema.string("category", "food"); + schema.string("name", "apple"); + }); + + // Upsert with same category+name + requestHelper.upsert("test_composite", schema -> { + schema.string("category", "food").unique(); // Mark as part of unique constraint + schema.string("name", "apple").unique(); // Mark as part of unique constraint + }); + + // Should still have only 1 record + assertEquals(1, countRows("test_composite")); + } + + @Test + public void testBatchInsertWithAutoIncrement() throws Exception { + SchemaBuilder.create(null, "test_autoincrement", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("name", 50); + }).execute(connection, testLogger); + + List schemas = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + final int index = i; + schemas.add(SchemaBuilder.insert("test_autoincrement", schema -> { + schema.string("name", "batch" + index); + })); + } + + requestHelper.insertMultiple(schemas); + + assertEquals(5, countRows("test_autoincrement")); + + // Verify IDs are sequential + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT id FROM test_autoincrement ORDER BY id"); + int previousId = 0; + while (rs.next()) { + int currentId = rs.getInt("id"); + assertTrue(currentId > previousId); + previousId = currentId; + } + } + } + + @Test + public void testAutoIncrementBigIntVsInteger() throws Exception { + // Clean up any existing tables + executeRawSQL("DROP TABLE IF EXISTS test_bigint"); + executeRawSQL("DROP TABLE IF EXISTS test_integer"); + + // Test autoIncrementBigInt vs autoIncrement + SchemaBuilder.create(null, "test_bigint", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("name", 50); + }).execute(connection, testLogger); + + SchemaBuilder.create(null, "test_integer", schema -> { + schema.autoIncrement("id"); + schema.string("name", 50); + }).execute(connection, testLogger); + + // Both should work + requestHelper.insert("test_bigint", schema -> { + schema.string("name", "bigint test"); + }); + + requestHelper.insert("test_integer", schema -> { + schema.string("name", "integer test"); + }); + + assertEquals(1, countRows("test_bigint")); + assertEquals(1, countRows("test_integer")); + } + + @Test + public void testUpdateDoesNotAffectAutoIncrement() throws Exception { + SchemaBuilder.create(null, "test_autoincrement", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("name", 50); + }).execute(connection, testLogger); + + AtomicInteger generatedId = new AtomicInteger(); + requestHelper.insert("test_autoincrement", schema -> { + schema.string("name", "original"); + }, generatedId::set); + + int originalId = generatedId.get(); + + // Update the record + requestHelper.update("test_autoincrement", schema -> { + schema.string("name", "updated"); + schema.where("id", originalId); + }); + + // Verify ID hasn't changed + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT id, name FROM test_autoincrement"); + assertTrue(rs.next()); + assertEquals(originalId, rs.getInt("id")); + assertEquals("updated", rs.getString("name")); + } + } +} \ No newline at end of file diff --git a/src/test/java/fr/maxlego08/sarah/DTOConsumerTest.java b/src/test/java/fr/maxlego08/sarah/DTOConsumerTest.java new file mode 100644 index 0000000..4330e23 --- /dev/null +++ b/src/test/java/fr/maxlego08/sarah/DTOConsumerTest.java @@ -0,0 +1,307 @@ +package fr.maxlego08.sarah; + +import fr.maxlego08.sarah.database.Schema; +import org.junit.jupiter.api.Test; + +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite for using DTO classes with ConsumerConstructor + * Tests the automatic schema generation and mapping from class templates + */ +public class DTOConsumerTest extends DatabaseTestBase { + + // Simple DTO using constructor only + public static class ProductDTO { + @Column(value = "id", autoIncrement = true) + private final Long id; + @Column(value = "name", unique = true) + private final String name; + @Column(value = "description", nullable = true) + private final String description; + private final Double price; + @Column(value = "stock", nullable = true) + private final Integer stock; + private final Boolean available; + + public ProductDTO(Long id, String name, String description, Double price, Integer stock, Boolean available) { + this.id = id; + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + this.available = available; + } + + public Long getId() { return id; } + public String getName() { return name; } + public String getDescription() { return description; } + public Double getPrice() { return price; } + public Integer getStock() { return stock; } + public Boolean getAvailable() { return available; } + } + + // DTO with @Column annotations + public static class UserDTO { + @Column(value = "user_id", autoIncrement = true) + private final Long id; + + @Column("user_name") + private final String username; + + @Column("user_email") + private final String email; + + private final Integer age; + + public UserDTO(Long id, String username, String email, Integer age) { + this.id = id; + this.username = username; + this.email = email; + this.age = age; + } + + public Long getId() { return id; } + public String getUsername() { return username; } + public String getEmail() { return email; } + public Integer getAge() { return age; } + } + + // DTO with various data types + public static class ComplexDTO { + @Column(value = "id", autoIncrement = true) + private final Long id; + private final String textField; + private final Integer intField; + private final Double doubleField; + private final Boolean boolField; + + public ComplexDTO(Long id, String textField, Integer intField, Double doubleField, Boolean boolField) { + this.id = id; + this.textField = textField; + this.intField = intField; + this.doubleField = doubleField; + this.boolField = boolField; + } + + public Long getId() { return id; } + public String getTextField() { return textField; } + public Integer getIntField() { return intField; } + public Double getDoubleField() { return doubleField; } + public Boolean getBoolField() { return boolField; } + } + + @Test + public void testCreateTableFromDTOClass() throws Exception { + // Create table using DTO class template + SchemaBuilder.create(null, "test_products", ProductDTO.class).execute(connection, testLogger); + + // Verify table was created by inserting data + requestHelper.insert("test_products", schema -> { + schema.string("name", "Laptop"); + schema.string("description", "High-performance laptop"); + schema.decimal("price", 999.99); + schema.bigInt("stock", 50); + schema.bool("available", true); + }); + + assertEquals(1, countRows("test_products")); + } + + @Test + public void testInsertWithDTOInstance() throws Exception { + // Create table + SchemaBuilder.create(null, "test_products", ProductDTO.class).execute(connection, testLogger); + + // Create a product instance + ProductDTO product = new ProductDTO(null, "Mouse", "Wireless mouse", 29.99, 100, true); + + // Insert using DTO + requestHelper.insert("test_products", ProductDTO.class, product); + + // Verify insertion + assertEquals(1, countRows("test_products")); + + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT * FROM test_products"); + assertTrue(rs.next()); + assertEquals("Mouse", rs.getString("name")); + assertEquals("Wireless mouse", rs.getString("description")); + assertEquals(29.99, rs.getDouble("price"), 0.01); + assertEquals(100, rs.getInt("stock")); + assertTrue(rs.getBoolean("available")); + } + } + + @Test + public void testUpdateWithDTO() throws Exception { + // Create and insert + SchemaBuilder.create(null, "test_products", ProductDTO.class).execute(connection, testLogger); + + ProductDTO product = new ProductDTO(null, "Keyboard", "Mechanical keyboard", 149.99, 30, true); + requestHelper.insert("test_products", ProductDTO.class, product); + + // Update using manual schema with WHERE clause + requestHelper.update("test_products", schema -> { + schema.string("name", "Keyboard RGB"); + schema.string("description", "RGB Mechanical keyboard"); + schema.decimal("price", 199.99); + schema.bigInt("stock", 25); + schema.bool("available", true); + schema.where("name", "Keyboard"); + }); + + // Verify update + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT * FROM test_products"); + assertTrue(rs.next()); + assertEquals("Keyboard RGB", rs.getString("name")); + assertEquals(199.99, rs.getDouble("price"), 0.01); + } + } + + @Test + public void testUpsertWithDTO() throws Exception { + SchemaBuilder.create(null, "test_products", ProductDTO.class).execute(connection, testLogger); + + // First upsert (insert) + ProductDTO product1 = new ProductDTO(null, "Monitor", "4K Monitor", 499.99, 20, true); + requestHelper.upsert("test_products", ProductDTO.class, product1); + assertEquals(1, countRows("test_products")); + + // Second upsert + ProductDTO product2 = new ProductDTO(null, "Monitor", "8K Monitor", 1499.99, 15, true); + requestHelper.upsert("test_products", ProductDTO.class, product2); + + assertTrue(countRows("test_products") >= 1); + } + + @Test + public void testDTOWithColumnAnnotations() throws Exception { + // Create table using DTO with @Column annotations + SchemaBuilder.create(null, "test_users_dto", UserDTO.class).execute(connection, testLogger); + + UserDTO user = new UserDTO(null, "john_doe", "john@example.com", 30); + requestHelper.insert("test_users_dto", UserDTO.class, user); + + // Verify column names from @Column annotations + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT user_id, user_name, user_email, age FROM test_users_dto"); + assertTrue(rs.next()); + assertNotNull(rs.getObject("user_id")); + assertEquals("john_doe", rs.getString("user_name")); + assertEquals("john@example.com", rs.getString("user_email")); + assertEquals(30, rs.getInt("age")); + } + } + + @Test + public void testSelectWithDTO() throws Exception { + // Create table and insert data + SchemaBuilder.create(null, "test_products", ProductDTO.class).execute(connection, testLogger); + + requestHelper.insert("test_products", schema -> { + schema.string("name", "Headphones"); + schema.string("description", "Noise-canceling headphones"); + schema.decimal("price", 299.99); + schema.bigInt("stock", 40); + schema.bool("available", true); + }); + + // Select using DTO class + List results = requestHelper.selectAll("test_products", ProductDTO.class); + + assertEquals(1, results.size()); + ProductDTO retrieved = results.get(0); + assertNotNull(retrieved.getId()); + assertEquals("Headphones", retrieved.getName()); + assertEquals("Noise-canceling headphones", retrieved.getDescription()); + assertEquals(299.99, retrieved.getPrice(), 0.01); + assertEquals(40, retrieved.getStock()); + assertTrue(retrieved.getAvailable()); + } + + @Test + public void testDTOWithNullValues() throws Exception { + SchemaBuilder.create(null, "test_products", ProductDTO.class).execute(connection, testLogger); + + ProductDTO product = new ProductDTO(null, "Test Product", null, 10.0, null, false); + requestHelper.insert("test_products", ProductDTO.class, product); + + List results = requestHelper.selectAll("test_products", ProductDTO.class); + assertEquals(1, results.size()); + + ProductDTO retrieved = results.get(0); + assertEquals("Test Product", retrieved.getName()); + assertNull(retrieved.getDescription()); + assertNull(retrieved.getStock()); + assertFalse(retrieved.getAvailable()); + } + + @Test + public void testMultipleInsertsWithDTO() throws Exception { + SchemaBuilder.create(null, "test_products", ProductDTO.class).execute(connection, testLogger); + + // Insert multiple products using DTO + for (int i = 1; i <= 5; i++) { + ProductDTO product = new ProductDTO( + null, + "Product " + i, + "Description " + i, + 10.0 * i, + i * 10, + i % 2 == 0 + ); + requestHelper.insert("test_products", ProductDTO.class, product); + } + + assertEquals(5, countRows("test_products")); + + // Select all and verify + List results = requestHelper.selectAll("test_products", ProductDTO.class); + assertEquals(5, results.size()); + } + + @Test + public void testSelectWithDTOAndWhereCondition() throws Exception { + SchemaBuilder.create(null, "test_products", ProductDTO.class).execute(connection, testLogger); + + // Insert multiple products + requestHelper.insert("test_products", ProductDTO.class, + new ProductDTO(null, "Cheap Product", "Budget item", 5.0, 100, true)); + requestHelper.insert("test_products", ProductDTO.class, + new ProductDTO(null, "Expensive Product", "Premium item", 500.0, 10, true)); + requestHelper.insert("test_products", ProductDTO.class, + new ProductDTO(null, "Mid Product", "Standard item", 50.0, 50, false)); + + // Select with WHERE condition + List results = requestHelper.select("test_products", ProductDTO.class, schema -> { + schema.where("price", ">", 10); + schema.where("available", true); + }); + + assertEquals(1, results.size()); + assertEquals("Expensive Product", results.get(0).getName()); + } + + @Test + public void testDTOWithDifferentTypes() throws Exception { + SchemaBuilder.create(null, "test_complex", ComplexDTO.class).execute(connection, testLogger); + + ComplexDTO complex = new ComplexDTO(null, "test", 42, 3.14, true); + requestHelper.insert("test_complex", ComplexDTO.class, complex); + + List results = requestHelper.selectAll("test_complex", ComplexDTO.class); + assertEquals(1, results.size()); + + ComplexDTO retrieved = results.get(0); + assertEquals("test", retrieved.getTextField()); + assertEquals(42, retrieved.getIntField()); + assertEquals(3.14, retrieved.getDoubleField(), 0.001); + assertTrue(retrieved.getBoolField()); + } +} \ No newline at end of file diff --git a/src/test/java/fr/maxlego08/sarah/DatabaseTestBase.java b/src/test/java/fr/maxlego08/sarah/DatabaseTestBase.java new file mode 100644 index 0000000..2c81fc0 --- /dev/null +++ b/src/test/java/fr/maxlego08/sarah/DatabaseTestBase.java @@ -0,0 +1,162 @@ +package fr.maxlego08.sarah; + +import fr.maxlego08.sarah.database.DatabaseType; +import fr.maxlego08.sarah.logger.JULogger; +import fr.maxlego08.sarah.logger.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import java.io.File; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +/** + * Base class for database tests providing common setup and teardown functionality + */ +public abstract class DatabaseTestBase { + + protected DatabaseConnection connection; + protected RequestHelper requestHelper; + protected DatabaseConfiguration configuration; + protected Logger testLogger; + protected File sqliteFile; + + @BeforeEach + public void setUp() throws Exception { + testLogger = new TestLogger(); + configuration = createConfiguration(); + + // CRITICAL: Set database configuration in MigrationManager + // This is required for timestamps() and other schema operations + MigrationManager.setDatabaseConfiguration(configuration); + + connection = createConnection(); + connection.connect(); + requestHelper = new RequestHelper(connection, testLogger); + + // Additional setup hook for subclasses + afterConnectionSetup(); + } + + @AfterEach + public void tearDown() throws Exception { + if (connection != null && connection.isValid()) { + cleanupDatabase(); + connection.disconnect(); + } + + // Clean up SQLite file if exists + if (configuration.getDatabaseType() == DatabaseType.SQLITE) { + this.sqliteFile.delete(); + } + } + + /** + * Create database configuration for tests + */ + protected DatabaseConfiguration createConfiguration() { + // Default to SQLite for easier testing + return DatabaseConfiguration.sqlite(true); + } + + /** + * Create database connection based on configuration + */ + protected DatabaseConnection createConnection() { + Logger logger = JULogger.from(java.util.logging.Logger.getLogger("TEST")); + DatabaseType type = configuration.getDatabaseType(); + switch (type) { + case SQLITE: + // SqliteConnection expects a folder, not a file + // It will create a file named "database.db" inside that folder + // So we pass the current directory and set the filename + SqliteConnection sqliteConnection = new SqliteConnection(configuration, new File("."), logger); + sqliteConnection.setFileName(getSqlitePath()); + this.sqliteFile = sqliteConnection.getFolder().toPath().resolve(this.getSqlitePath()).toFile(); + return sqliteConnection; + case MYSQL: + return new MySqlConnection(configuration, logger); + case MARIADB: + return new MariaDbConnection(configuration, logger); + default: + throw new IllegalArgumentException("Unsupported database type: " + type); + } + } + + /** + * Hook for additional setup after connection is established + */ + protected void afterConnectionSetup() throws Exception { + // Override in subclasses if needed + } + + /** + * Clean up all test tables + */ + protected void cleanupDatabase() throws SQLException { + Connection conn = connection.getConnection(); + try (Statement stmt = conn.createStatement()) { + // Drop common test tables + String[] tables = {"test_users", "test_products", "test_orders", "test_composite", + "test_autoincrement", "migrations", "test_join_a", "test_join_b", + "test_create", "test_drop", "test_rename", "test_renamed", "test_index", + "test_unique", "test_foreign", "test_referenced", "test_users_dto", + "test_complex", "test_profiles", "test_old_name", "test_new_name", + "test_parent", "test_child", "test_migration", "test_idempotent", + "test_alter", "test_all_types", "test_nullable"}; + + for (String table : tables) { + try { + if (configuration.getDatabaseType() == DatabaseType.SQLITE) { + stmt.execute("DROP TABLE IF EXISTS " + table); + } else { + stmt.execute("DROP TABLE IF EXISTS " + table); + } + } catch (SQLException e) { + // Ignore if table doesn't exist + } + } + } + } + + /** + * Get SQLite database file path for tests + */ + protected String getSqlitePath() { + return "test_database.db"; + } + + /** + * Simple test logger implementation + */ + protected static class TestLogger implements Logger { + @Override + public void info(String message) { + System.out.println("[INFO] " + message); + } + } + + /** + * Helper method to execute raw SQL (useful for setup/verification) + */ + protected void executeRawSQL(String sql) throws SQLException { + try (Statement stmt = connection.getConnection().createStatement()) { + stmt.execute(sql); + } + } + + /** + * Helper method to count rows in a table + */ + protected int countRows(String tableName) throws SQLException { + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM " + tableName); + if (rs.next()) { + return rs.getInt(1); + } + return 0; + } + } +} \ No newline at end of file diff --git a/src/test/java/fr/maxlego08/sarah/DeleteRequestTest.java b/src/test/java/fr/maxlego08/sarah/DeleteRequestTest.java new file mode 100644 index 0000000..ed110ce --- /dev/null +++ b/src/test/java/fr/maxlego08/sarah/DeleteRequestTest.java @@ -0,0 +1,104 @@ +package fr.maxlego08.sarah; + +import fr.maxlego08.sarah.database.Schema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.sql.ResultSet; +import java.sql.Statement; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite for DELETE operations + */ +public class DeleteRequestTest extends DatabaseTestBase { + + @Override + protected void afterConnectionSetup() throws Exception { + SchemaBuilder.create(null, "test_users", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("username", 50); + schema.string("email", 100); + schema.integer("age"); + }).execute(connection, testLogger); + } + + @BeforeEach + public void insertTestData() { + requestHelper.insert("test_users", schema -> { + schema.string("username", "user1"); + schema.string("email", "user1@example.com"); + schema.bigInt("age", 25); + }); + + requestHelper.insert("test_users", schema -> { + schema.string("username", "user2"); + schema.string("email", "user2@example.com"); + schema.bigInt("age", 30); + }); + + requestHelper.insert("test_users", schema -> { + schema.string("username", "user3"); + schema.string("email", "user3@example.com"); + schema.bigInt("age", 35); + }); + } + + @Test + public void testDeleteSingleRow() throws Exception { + requestHelper.delete("test_users", schema -> { + schema.where("username", "user1"); + }); + + assertEquals(2, countRows("test_users")); + + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT * FROM test_users WHERE username = 'user1'"); + assertFalse(rs.next()); + } + } + + @Test + public void testDeleteMultipleRows() throws Exception { + requestHelper.delete("test_users", schema -> { + schema.where("age", ">=", 30); + }); + + assertEquals(1, countRows("test_users")); + + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT username FROM test_users"); + assertTrue(rs.next()); + assertEquals("user1", rs.getString("username")); + assertFalse(rs.next()); + } + } + + @Test + public void testDeleteWithMultipleConditions() throws Exception { + requestHelper.delete("test_users", schema -> { + schema.where("age", ">", 25); + schema.where("age", "<", 35); + }); + + assertEquals(2, countRows("test_users")); + } + + @Test + public void testDeleteNoMatches() throws Exception { + requestHelper.delete("test_users", schema -> { + schema.where("username", "nonexistent"); + }); + + assertEquals(3, countRows("test_users")); + } + + @Test + public void testDeleteAll() throws Exception { + // Be careful with this in production! + SchemaBuilder.delete("test_users").execute(connection, testLogger); + + assertEquals(0, countRows("test_users")); + } +} \ No newline at end of file diff --git a/src/test/java/fr/maxlego08/sarah/InsertBatchRequestTest.java b/src/test/java/fr/maxlego08/sarah/InsertBatchRequestTest.java new file mode 100644 index 0000000..e106540 --- /dev/null +++ b/src/test/java/fr/maxlego08/sarah/InsertBatchRequestTest.java @@ -0,0 +1,156 @@ +package fr.maxlego08.sarah; + +import fr.maxlego08.sarah.database.Schema; +import org.junit.jupiter.api.Test; + +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite for INSERT BATCH operations + */ +public class InsertBatchRequestTest extends DatabaseTestBase { + + @Override + protected void afterConnectionSetup() throws Exception { + // Create test table + SchemaBuilder.create(null, "test_users", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("username", 50); + schema.string("email", 100).nullable(); + schema.integer("age"); + schema.timestamps(); + }).execute(connection, testLogger); + } + + @Test + public void testBatchInsertMultipleRows() throws Exception { + List schemas = new ArrayList<>(); + + // Create multiple schemas for batch insert + schemas.add(SchemaBuilder.insert("test_users", schema -> { + schema.string("username", "user1"); + schema.string("email", "user1@example.com"); + schema.bigInt("age", 20); + })); + + schemas.add(SchemaBuilder.insert("test_users", schema -> { + schema.string("username", "user2"); + schema.string("email", "user2@example.com"); + schema.bigInt("age", 30); + })); + + schemas.add(SchemaBuilder.insert("test_users", schema -> { + schema.string("username", "user3"); + schema.string("email", "user3@example.com"); + schema.bigInt("age", 40); + })); + + // Execute batch insert + requestHelper.insertMultiple(schemas); + + // Verify all rows were inserted + assertEquals(3, countRows("test_users")); + + // Verify data integrity + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT * FROM test_users ORDER BY id"); + + assertTrue(rs.next()); + assertEquals("user1", rs.getString("username")); + assertEquals(20, rs.getInt("age")); + + assertTrue(rs.next()); + assertEquals("user2", rs.getString("username")); + assertEquals(30, rs.getInt("age")); + + assertTrue(rs.next()); + assertEquals("user3", rs.getString("username")); + assertEquals(40, rs.getInt("age")); + + assertFalse(rs.next()); + } + } + + @Test + public void testBatchInsertWithNullValues() throws Exception { + List schemas = new ArrayList<>(); + + schemas.add(SchemaBuilder.insert("test_users", schema -> { + schema.string("username", "user1"); + schema.string("email", null); + schema.bigInt("age", 25); + })); + + schemas.add(SchemaBuilder.insert("test_users", schema -> { + schema.string("username", "user2"); + schema.string("email", "user2@example.com"); + schema.bigInt("age", 35); + })); + + requestHelper.insertMultiple(schemas); + + assertEquals(2, countRows("test_users")); + + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT * FROM test_users WHERE username = 'user1'"); + assertTrue(rs.next()); + assertNull(rs.getString("email")); + } + } + + @Test + public void testBatchInsertLargeDataset() throws Exception { + List schemas = new ArrayList<>(); + + // Create 100 users + for (int i = 0; i < 100; i++) { + final int index = i; + schemas.add(SchemaBuilder.insert("test_users", schema -> { + schema.string("username", "user" + index); + schema.string("email", "user" + index + "@example.com"); + schema.bigInt("age", 20 + (index % 50)); + })); + } + + requestHelper.insertMultiple(schemas); + + assertEquals(100, countRows("test_users")); + } + + @Test + public void testBatchInsertEmptyList() throws Exception { + List schemas = new ArrayList<>(); + + // Should not throw an exception + requestHelper.insertMultiple(schemas); + + assertEquals(0, countRows("test_users")); + } + + @Test + public void testBatchInsertSingleRow() throws Exception { + List schemas = new ArrayList<>(); + + schemas.add(SchemaBuilder.insert("test_users", schema -> { + schema.string("username", "single_user"); + schema.string("email", "single@example.com"); + schema.bigInt("age", 42); + })); + + requestHelper.insertMultiple(schemas); + + assertEquals(1, countRows("test_users")); + + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT * FROM test_users"); + assertTrue(rs.next()); + assertEquals("single_user", rs.getString("username")); + assertEquals(42, rs.getInt("age")); + } + } +} \ No newline at end of file diff --git a/src/test/java/fr/maxlego08/sarah/InsertRequestTest.java b/src/test/java/fr/maxlego08/sarah/InsertRequestTest.java new file mode 100644 index 0000000..643e06f --- /dev/null +++ b/src/test/java/fr/maxlego08/sarah/InsertRequestTest.java @@ -0,0 +1,212 @@ +package fr.maxlego08.sarah; + +import fr.maxlego08.sarah.database.Schema; +import org.junit.jupiter.api.Test; + +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite for INSERT operations + */ +public class InsertRequestTest extends DatabaseTestBase { + + @Override + protected void afterConnectionSetup() throws Exception { + // Create test table using Schema.create with consumer + SchemaBuilder.create(null, "test_users", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("username", 50); + schema.string("email", 100).nullable(); + schema.integer("age").nullable(); // Allow null for testing + schema.bool("active").defaultValue(true); + schema.timestamps(); + }).execute(connection, testLogger); + } + + @Test + public void testSimpleInsert() throws Exception { + // Use RequestHelper.insert with consumer + AtomicInteger generatedId = new AtomicInteger(); + requestHelper.insert("test_users", schema -> { + schema.string("username", "john_doe"); + schema.string("email", "john@example.com"); + schema.bigInt("age", 25); + }, generatedId::set); + + // Verify + assertTrue(generatedId.get() > 0, "Generated ID should be positive"); + assertEquals(1, countRows("test_users")); + + // Verify inserted data + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT * FROM test_users WHERE id = " + generatedId.get()); + assertTrue(rs.next()); + assertEquals("john_doe", rs.getString("username")); + assertEquals("john@example.com", rs.getString("email")); + assertEquals(25, rs.getInt("age")); + } + } + + @Test + public void testInsertWithNullValues() throws Exception { + AtomicInteger generatedId = new AtomicInteger(); + requestHelper.insert("test_users", schema -> { + schema.string("username", "jane_doe"); + schema.string("email", null); + schema.object("age", null); + }, generatedId::set); + + assertTrue(generatedId.get() > 0); + + // Verify null values + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT * FROM test_users WHERE id = " + generatedId.get()); + assertTrue(rs.next()); + assertEquals("jane_doe", rs.getString("username")); + assertNull(rs.getString("email")); + } + } + + @Test + public void testInsertWithTimestamps() throws Exception { + AtomicInteger generatedId = new AtomicInteger(); + requestHelper.insert("test_users", schema -> { + schema.string("username", "time_user"); + schema.string("email", "time@example.com"); + schema.bigInt("age", 30); + }, generatedId::set); + + // Verify timestamps are auto-generated + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT created_at, updated_at FROM test_users WHERE id = " + generatedId.get()); + assertTrue(rs.next()); + assertNotNull(rs.getTimestamp("created_at")); + assertNotNull(rs.getTimestamp("updated_at")); + } + } + + @Test + public void testInsertMultipleRows() throws Exception { + // Insert first row + AtomicInteger id1 = new AtomicInteger(); + requestHelper.insert("test_users", schema -> { + schema.string("username", "user1"); + schema.string("email", "user1@example.com"); + schema.bigInt("age", 20); + }, id1::set); + + // Insert second row + AtomicInteger id2 = new AtomicInteger(); + requestHelper.insert("test_users", schema -> { + schema.string("username", "user2"); + schema.string("email", "user2@example.com"); + schema.bigInt("age", 30); + }, id2::set); + + // Insert third row + AtomicInteger id3 = new AtomicInteger(); + requestHelper.insert("test_users", schema -> { + schema.string("username", "user3"); + schema.string("email", "user3@example.com"); + schema.bigInt("age", 40); + }, id3::set); + + // Verify all inserted + assertEquals(3, countRows("test_users")); + assertTrue(id1.get() > 0 && id2.get() > 0 && id3.get() > 0); + assertTrue(id2.get() > id1.get() && id3.get() > id2.get(), "IDs should be sequential"); + } + + @Test + public void testInsertWithBoolean() throws Exception { + // Insert active user + AtomicInteger id1 = new AtomicInteger(); + requestHelper.insert("test_users", schema -> { + schema.string("username", "active_user"); + schema.string("email", "active@example.com"); + schema.bigInt("age", 25); + schema.bool("active", true); + }, id1::set); + + // Insert inactive user + AtomicInteger id2 = new AtomicInteger(); + requestHelper.insert("test_users", schema -> { + schema.string("username", "inactive_user"); + schema.string("email", "inactive@example.com"); + schema.bigInt("age", 25); + schema.bool("active", false); + }, id2::set); + + // Verify boolean values + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT active FROM test_users WHERE id = " + id1.get()); + assertTrue(rs.next()); + assertTrue(rs.getBoolean("active")); + + rs = stmt.executeQuery("SELECT active FROM test_users WHERE id = " + id2.get()); + assertTrue(rs.next()); + assertFalse(rs.getBoolean("active")); + } + } + + @Test + public void testInsertAutoIncrementSkipped() throws Exception { + // The autoincrement column should be skipped in INSERT + AtomicInteger generatedId = new AtomicInteger(); + requestHelper.insert("test_users", schema -> { + // Note: We don't set the 'id' column + schema.string("username", "auto_user"); + schema.string("email", "auto@example.com"); + schema.bigInt("age", 35); + }, generatedId::set); + + // Verify ID was auto-generated + assertTrue(generatedId.get() > 0); + + // Verify the record exists with the auto-generated ID + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT id FROM test_users WHERE username = 'auto_user'"); + assertTrue(rs.next()); + assertEquals(generatedId.get(), rs.getInt("id")); + } + } + + @Test + public void testInsertWithDifferentDataTypes() throws Exception { + // Create a table with various data types + SchemaBuilder.create(null, "test_products", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("name", 100); + schema.decimal("price", 10, 2); + schema.bigInt("stock"); + schema.text("description"); + schema.bool("available"); + }).execute(connection, testLogger); + + AtomicInteger generatedId = new AtomicInteger(); + requestHelper.insert("test_products", schema -> { + schema.string("name", "Test Product"); + schema.decimal("price", 19.99); + schema.bigInt("stock", 100); + schema.string("description", "This is a test product with a longer description"); + schema.bool("available", true); + }, generatedId::set); + + assertTrue(generatedId.get() > 0); + + // Verify inserted data + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT * FROM test_products WHERE id = " + generatedId.get()); + assertTrue(rs.next()); + assertEquals("Test Product", rs.getString("name")); + assertEquals(19.99, rs.getDouble("price"), 0.01); + assertEquals(100, rs.getLong("stock")); + assertEquals("This is a test product with a longer description", rs.getString("description")); + assertTrue(rs.getBoolean("available")); + } + } +} \ No newline at end of file diff --git a/src/test/java/fr/maxlego08/sarah/JoinConditionTest.java b/src/test/java/fr/maxlego08/sarah/JoinConditionTest.java new file mode 100644 index 0000000..38cf40c --- /dev/null +++ b/src/test/java/fr/maxlego08/sarah/JoinConditionTest.java @@ -0,0 +1,198 @@ +package fr.maxlego08.sarah; + +import fr.maxlego08.sarah.database.Schema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite for JOIN operations + */ +public class JoinConditionTest extends DatabaseTestBase { + + @Override + protected void afterConnectionSetup() throws Exception { + // Create users table + SchemaBuilder.create(null, "test_users", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("username", 50); + schema.string("email", 100); + }).execute(connection, testLogger); + + // Create orders table + SchemaBuilder.create(null, "test_orders", schema -> { + schema.autoIncrementBigInt("id"); + schema.bigInt("user_id"); + schema.string("product", 100); + schema.decimal("amount", 10, 2); + }).execute(connection, testLogger); + } + + @BeforeEach + public void insertTestData() { + // Insert users + requestHelper.insert("test_users", schema -> { + schema.string("username", "alice"); + schema.string("email", "alice@example.com"); + }); + + requestHelper.insert("test_users", schema -> { + schema.string("username", "bob"); + schema.string("email", "bob@example.com"); + }); + + requestHelper.insert("test_users", schema -> { + schema.string("username", "charlie"); + schema.string("email", "charlie@example.com"); + }); + + // Insert orders + requestHelper.insert("test_orders", schema -> { + schema.bigInt("user_id", 1); + schema.string("product", "Laptop"); + schema.decimal("amount", 999.99); + }); + + requestHelper.insert("test_orders", schema -> { + schema.bigInt("user_id", 1); + schema.string("product", "Mouse"); + schema.decimal("amount", 29.99); + }); + + requestHelper.insert("test_orders", schema -> { + schema.bigInt("user_id", 2); + schema.string("product", "Keyboard"); + schema.decimal("amount", 79.99); + }); + } + + @Test + public void testLeftJoin() { + Schema schema = SchemaBuilder.select("test_users"); + schema.addSelect("test_users", "username"); + schema.addSelect("o", "product"); + schema.leftJoin("test_orders", "o", "user_id", "test_users", "id"); + + try { + List> results = schema.executeSelect(connection, testLogger); + // Should return all users, even charlie who has no orders + assertTrue(results.size() >= 3); + + // Verify alice has orders + boolean foundAlice = false; + for (Map row : results) { + if ("alice".equals(row.get("username"))) { + foundAlice = true; + assertNotNull(row.get("product")); + } + } + assertTrue(foundAlice); + } catch (Exception e) { + fail("Exception thrown: " + e.getMessage()); + } + } + + @Test + public void testInnerJoin() { + Schema schema = SchemaBuilder.select("test_users"); + schema.addSelect("test_users", "username"); + schema.addSelect("o", "product"); + schema.innerJoin("test_orders", "o", "user_id", "test_users", "id"); + + try { + List> results = schema.executeSelect(connection, testLogger); + // Should return only users with orders (alice and bob) + assertEquals(3, results.size()); // 2 orders for alice, 1 for bob + + // Verify charlie (no orders) is not included + for (Map row : results) { + assertNotEquals("charlie", row.get("username")); + } + } catch (Exception e) { + fail("Exception thrown: " + e.getMessage()); + } + } + + @Test + public void testJoinWithWhereCondition() { + Schema schema = SchemaBuilder.select("test_users"); + schema.addSelect("test_users", "username"); + schema.addSelect("o", "product"); + schema.addSelect("o", "amount"); + schema.innerJoin("test_orders", "o", "user_id", "test_users", "id"); + schema.where("o", "amount", ">", 50); + + try { + List> results = schema.executeSelect(connection, testLogger); + // Should return only orders with amount > 50 (Laptop and Keyboard) + assertEquals(2, results.size()); + + for (Map row : results) { + String product = (String) row.get("product"); + assertTrue(product.equals("Laptop") || product.equals("Keyboard")); + } + } catch (Exception e) { + fail("Exception thrown: " + e.getMessage()); + } + } + + @Test + public void testJoinWithOrderBy() { + Schema schema = SchemaBuilder.select("test_users"); + schema.addSelect("test_users", "username"); + schema.addSelect("o", "product"); + schema.addSelect("o", "amount"); + schema.innerJoin("test_orders", "o", "user_id", "test_users", "id"); + schema.orderByDesc("amount"); + + try { + List> results = schema.executeSelect(connection, testLogger); + assertEquals(3, results.size()); + + // First result should be the most expensive order (Laptop) + assertEquals("Laptop", results.get(0).get("product")); + } catch (Exception e) { + fail("Exception thrown: " + e.getMessage()); + } + } + + @Test + public void testMultipleJoins() throws SQLException { + // Create a third table + SchemaBuilder.create(null, "test_profiles", schema -> { + schema.autoIncrementBigInt("id"); + schema.bigInt("user_id"); + schema.string("bio", 255); + }).execute(connection, testLogger); + + requestHelper.insert("test_profiles", schema -> { + schema.bigInt("user_id", 1); + schema.string("bio", "Alice's bio"); + }); + + Schema selectSchema = SchemaBuilder.select("test_users"); + selectSchema.addSelect("test_users", "username"); + selectSchema.addSelect("o", "product"); + selectSchema.addSelect("p", "bio"); + selectSchema.innerJoin("test_orders", "o", "user_id", "test_users", "id"); + selectSchema.innerJoin("test_profiles", "p", "user_id", "test_users", "id"); + + try { + List> results = selectSchema.executeSelect(connection, testLogger); + // Should return only alice's orders (she has both orders and profile) + assertTrue(results.size() >= 2); + + for (Map row : results) { + assertEquals("alice", row.get("username")); + assertEquals("Alice's bio", row.get("bio")); + } + } catch (Exception e) { + fail("Exception thrown: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/test/java/fr/maxlego08/sarah/MigrationTest.java b/src/test/java/fr/maxlego08/sarah/MigrationTest.java new file mode 100644 index 0000000..5674207 --- /dev/null +++ b/src/test/java/fr/maxlego08/sarah/MigrationTest.java @@ -0,0 +1,299 @@ +package fr.maxlego08.sarah; + +import fr.maxlego08.sarah.database.Migration; +import fr.maxlego08.sarah.database.Schema; +import org.junit.jupiter.api.Test; + +import java.sql.ResultSet; +import java.sql.Statement; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite for Schema migrations + */ +public class MigrationTest extends DatabaseTestBase { + + @Test + public void testCreateTable() throws Exception { + Schema schema = SchemaBuilder.create(null, "test_create", s -> { + s.autoIncrementBigInt("id"); + s.string("name", 100); + s.integer("age"); + s.bool("active").defaultValue(true); + s.timestamps(); + }); + + schema.execute(connection, testLogger); + + // Verify table exists by inserting data + requestHelper.insert("test_create", s -> { + s.string("name", "test"); + s.bigInt("age", 25); + }); + + assertEquals(1, countRows("test_create")); + } + + @Test + public void testDropTable() throws Exception { + // Create table + SchemaBuilder.create(null, "test_drop", s -> { + s.autoIncrementBigInt("id"); + s.string("name", 50); + }).execute(connection, testLogger); + + // Verify table exists + requestHelper.insert("test_drop", s -> s.string("name", "test")); + assertEquals(1, countRows("test_drop")); + + // Drop table + SchemaBuilder.drop(null, "test_drop").execute(connection, testLogger); + + // Verify table no longer exists + try { + countRows("test_drop"); + fail("Table should have been dropped"); + } catch (Exception e) { + // Expected - table doesn't exist + assertTrue(e.getMessage().contains("test_drop") || e.getMessage().contains("no such table")); + } + } + + @Test + public void testAlterTableAddColumn() throws Exception { + // Create initial table + SchemaBuilder.create(null, "test_alter", s -> { + s.autoIncrementBigInt("id"); + s.string("name", 50); + }).execute(connection, testLogger); + + // Insert data + requestHelper.insert("test_alter", s -> s.string("name", "test")); + + // Alter table to add new column + SchemaBuilder.alter(null, "test_alter", s -> { + s.string("email", 100).nullable(); + }).execute(connection, testLogger); + + // Verify new column exists + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT id, name, email FROM test_alter"); + assertTrue(rs.next()); + assertEquals("test", rs.getString("name")); + assertNull(rs.getString("email")); + } + } + + @Test + public void testMigrationWithMigrationManager() throws Exception { + // Initialize migration manager + MigrationManager.setDatabaseConfiguration(configuration); + + // Create a test migration + Migration testMigration = new Migration() { + @Override + public void up() { + this.create("test_migration", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("username", 50); + schema.string("email", 100); + }); + } + }; + + // Register and execute migration + MigrationManager.registerMigration(testMigration); + MigrationManager.execute(connection, testLogger); + + // Verify table was created + requestHelper.insert("test_migration", s -> { + s.string("username", "migrated_user"); + s.string("email", "migrated@example.com"); + }); + + assertEquals(1, countRows("test_migration")); + + // Verify migrations table exists and has our migration + long migrationCount = requestHelper.count("migrations", s -> {}); + assertTrue(migrationCount > 0, "Migrations table should have at least one entry"); + } + + @Test + public void testMigrationIdempotency() throws Exception { + MigrationManager.setDatabaseConfiguration(configuration); + + Migration testMigration = new Migration() { + @Override + public void up() { + this.create("test_idempotent", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("name", 50); + }); + } + }; + + // Execute migration twice + MigrationManager.registerMigration(testMigration); + MigrationManager.execute(connection, testLogger); + MigrationManager.execute(connection, testLogger); + + // Should still have only one table (no error on second execution) + requestHelper.insert("test_idempotent", s -> s.string("name", "test")); + assertEquals(1, countRows("test_idempotent")); + } + + @Test + public void testCreateIndex() throws Exception { + // Create table + SchemaBuilder.create(null, "test_index", s -> { + s.autoIncrementBigInt("id"); + s.string("email", 100); + s.string("username", 50); + }).execute(connection, testLogger); + + // Create index on email + SchemaBuilder.createIndex(null, "test_index", "email").execute(connection, testLogger); + + // Verify table still works (index creation doesn't break anything) + requestHelper.insert("test_index", s -> { + s.string("email", "test@example.com"); + s.string("username", "testuser"); + }); + + assertEquals(1, countRows("test_index")); + } + + @Test + public void testRenameTable() throws Exception { + // Create table + SchemaBuilder.create(null, "test_old_name", s -> { + s.autoIncrementBigInt("id"); + s.string("name", 50); + }).execute(connection, testLogger); + + // Insert data + requestHelper.insert("test_old_name", s -> s.string("name", "test")); + + // Rename table + SchemaBuilder.rename(null, "test_old_name", "test_new_name").execute(connection, testLogger); + + // Verify new name works + assertEquals(1, countRows("test_new_name")); + + // Verify old name doesn't exist + try { + countRows("test_old_name"); + fail("Old table name should no longer exist"); + } catch (Exception e) { + // Expected + assertTrue(e.getMessage().contains("test_old_name") || e.getMessage().contains("no such table")); + } + } + + @Test + public void testCreateTableWithForeignKey() throws Exception { + // Create parent table + SchemaBuilder.create(null, "test_parent", s -> { + s.autoIncrementBigInt("id"); + s.string("name", 50); + }).execute(connection, testLogger); + + // Create child table with foreign key + SchemaBuilder.create(null, "test_child", s -> { + s.autoIncrementBigInt("id"); + s.bigInt("parent_id").foreignKey("test_parent", "id", true); + s.string("description", 100); + }).execute(connection, testLogger); + + // Insert parent + requestHelper.insert("test_parent", s -> s.string("name", "parent1")); + + // Insert child + requestHelper.insert("test_child", s -> { + s.bigInt("parent_id", 1); + s.string("description", "child1"); + }); + + assertEquals(1, countRows("test_parent")); + assertEquals(1, countRows("test_child")); + } + + @Test + public void testCreateTableWithAllDataTypes() throws Exception { + SchemaBuilder.create(null, "test_all_types", s -> { + s.autoIncrementBigInt("id"); + s.string("str_col", 50); + s.text("text_col"); + s.longText("longtext_col"); + s.integer("int_col"); + s.bigInt("bigint_col"); + s.decimal("decimal_col", 10, 2); + s.bool("bool_col"); + s.timestamp("timestamp_col"); + s.blob("blob_col"); + s.uuid("uuid_col"); + s.timestamps(); + }).execute(connection, testLogger); + + // Verify table was created + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT * FROM test_all_types LIMIT 0"); + // Just checking that the query doesn't throw + assertNotNull(rs); + } + } + + @Test + public void testCreateTableWithUniqueConstraint() throws Exception { + SchemaBuilder.create(null, "test_unique", s -> { + s.autoIncrementBigInt("id"); + s.string("email", 100).unique(); + s.string("username", 50); + }).execute(connection, testLogger); + + // Insert first user + requestHelper.insert("test_unique", s -> { + s.string("email", "unique@example.com"); + s.string("username", "user1"); + }); + + // Try to insert duplicate email - should either throw exception or fail silently + try { + requestHelper.insert("test_unique", s -> { + s.string("email", "unique@example.com"); + s.string("username", "user2"); + }); + } catch (Exception e) { + // Expected - unique constraint violation + assertTrue(e.getMessage().toLowerCase().contains("unique") || + e.getMessage().toLowerCase().contains("constraint") || + e.getMessage().toLowerCase().contains("duplicate")); + } + + // Verify that duplicate was NOT inserted (either exception or silent failure) + assertEquals(1, countRows("test_unique"), "Table should still have only 1 row - duplicate should not have been inserted"); + } + + @Test + public void testCreateTableWithNullableColumn() throws Exception { + SchemaBuilder.create(null, "test_nullable", s -> { + s.autoIncrementBigInt("id"); + s.string("required_col", 50); // NOT NULL by default + s.string("optional_col", 50).nullable(); + }).execute(connection, testLogger); + + // Insert with null optional column + requestHelper.insert("test_nullable", s -> { + s.string("required_col", "required"); + s.string("optional_col", null); + }); + + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT * FROM test_nullable"); + assertTrue(rs.next()); + assertEquals("required", rs.getString("required_col")); + assertNull(rs.getString("optional_col")); + } + } +} \ No newline at end of file diff --git a/src/test/java/fr/maxlego08/sarah/SelectRequestTest.java b/src/test/java/fr/maxlego08/sarah/SelectRequestTest.java new file mode 100644 index 0000000..e7470a6 --- /dev/null +++ b/src/test/java/fr/maxlego08/sarah/SelectRequestTest.java @@ -0,0 +1,206 @@ +package fr.maxlego08.sarah; + +import fr.maxlego08.sarah.database.Schema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite for SELECT operations + */ +public class SelectRequestTest extends DatabaseTestBase { + + // Simple DTO for testing + public static class UserDTO { + private final long id; + private final String username; + private final String email; + private final int age; + + public UserDTO(long id, String username, String email, int age) { + this.id = id; + this.username = username; + this.email = email; + this.age = age; + } + + public long getId() { return id; } + public String getUsername() { return username; } + public String getEmail() { return email; } + public int getAge() { return age; } + } + + @Override + protected void afterConnectionSetup() throws Exception { + SchemaBuilder.create(null, "test_users", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("username", 50); + schema.string("email", 100); + schema.integer("age"); + }).execute(connection, testLogger); + } + + @BeforeEach + public void insertTestData() { + requestHelper.insert("test_users", schema -> { + schema.string("username", "alice"); + schema.string("email", "alice@example.com"); + schema.bigInt("age", 25); + }); + + requestHelper.insert("test_users", schema -> { + schema.string("username", "bob"); + schema.string("email", "bob@example.com"); + schema.bigInt("age", 30); + }); + + requestHelper.insert("test_users", schema -> { + schema.string("username", "charlie"); + schema.string("email", "charlie@example.com"); + schema.bigInt("age", 35); + }); + } + + @Test + public void testSelectAll() { + List> results = requestHelper.select("test_users", schema -> {}); + + assertEquals(3, results.size()); + } + + @Test + public void testSelectWithWhere() { + List> results = requestHelper.select("test_users", schema -> { + schema.where("username", "alice"); + }); + + assertEquals(1, results.size()); + assertEquals("alice", results.get(0).get("username")); + assertEquals("alice@example.com", results.get(0).get("email")); + } + + @Test + public void testSelectWithMultipleWhereConditions() { + List> results = requestHelper.select("test_users", schema -> { + schema.where("age", ">=", 30); + schema.where("age", "<=", 35); + }); + + assertEquals(2, results.size()); + } + + @Test + public void testSelectWithWhereIn() { + List> results = requestHelper.select("test_users", schema -> { + schema.whereIn("username", "alice", "charlie"); + }); + + assertEquals(2, results.size()); + } + + @Test + public void testSelectWithTypedResult() { + List results = requestHelper.select("test_users", UserDTO.class, schema -> { + schema.where("username", "bob"); + }); + + assertEquals(1, results.size()); + assertEquals("bob", results.get(0).getUsername()); + assertEquals("bob@example.com", results.get(0).getEmail()); + assertEquals(30, results.get(0).getAge()); + } + + @Test + public void testSelectAllTyped() { + List results = requestHelper.selectAll("test_users", UserDTO.class); + + assertEquals(3, results.size()); + } + + @Test + public void testSelectWithOrderBy() { + Schema schema = SchemaBuilder.select("test_users"); + schema.orderByDesc("age"); + + try { + List> results = schema.executeSelect(connection, testLogger); + assertEquals(3, results.size()); + assertEquals("charlie", results.get(0).get("username")); // age 35 + assertEquals("bob", results.get(1).get("username")); // age 30 + assertEquals("alice", results.get(2).get("username")); // age 25 + } catch (Exception e) { + fail("Exception thrown: " + e.getMessage()); + } + } + + @Test + public void testSelectCount() { + long count = requestHelper.count("test_users", schema -> {}); + + assertEquals(3, count); + } + + @Test + public void testSelectCountWithWhere() { + long count = requestHelper.count("test_users", schema -> { + schema.where("age", ">", 25); + }); + + assertEquals(2, count); + } + + @Test + public void testSelectSpecificColumns() { + Schema schema = SchemaBuilder.select("test_users"); + schema.addSelect("username"); + schema.addSelect("email"); + + try { + List> results = schema.executeSelect(connection, testLogger); + assertEquals(3, results.size()); + + for (Map row : results) { + assertTrue(row.containsKey("username")); + assertTrue(row.containsKey("email")); + // Note: depending on the implementation, "id" and "age" might or might not be present + } + } catch (Exception e) { + fail("Exception thrown: " + e.getMessage()); + } + } + + @Test + public void testSelectDistinct() { + // Insert duplicate age + requestHelper.insert("test_users", schema -> { + schema.string("username", "david"); + schema.string("email", "david@example.com"); + schema.bigInt("age", 25); // Same age as alice + }); + + Schema schema = SchemaBuilder.select("test_users"); + schema.addSelect("age"); + schema.distinct(); + + try { + List> results = schema.executeSelect(connection, testLogger); + // Should have 4 distinct ages: 25, 30, 35 (even though 25 appears twice) + assertTrue(results.size() <= 4); + } catch (Exception e) { + fail("Exception thrown: " + e.getMessage()); + } + } + + @Test + public void testSelectNoMatches() { + List> results = requestHelper.select("test_users", schema -> { + schema.where("username", "nonexistent"); + }); + + assertEquals(0, results.size()); + } +} \ No newline at end of file diff --git a/src/test/java/fr/maxlego08/sarah/UpdateRequestTest.java b/src/test/java/fr/maxlego08/sarah/UpdateRequestTest.java new file mode 100644 index 0000000..08d6bf5 --- /dev/null +++ b/src/test/java/fr/maxlego08/sarah/UpdateRequestTest.java @@ -0,0 +1,120 @@ +package fr.maxlego08.sarah; + +import fr.maxlego08.sarah.database.Schema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.sql.ResultSet; +import java.sql.Statement; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite for UPDATE operations + */ +public class UpdateRequestTest extends DatabaseTestBase { + + @Override + protected void afterConnectionSetup() throws Exception { + SchemaBuilder.create(null, "test_users", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("username", 50); + schema.string("email", 100).nullable(); // Allow null for update tests + schema.integer("age"); + schema.bool("active").defaultValue(true); + }).execute(connection, testLogger); + } + + @BeforeEach + public void insertTestData() { + // Insert test data before each test + requestHelper.insert("test_users", schema -> { + schema.string("username", "user1"); + schema.string("email", "user1@example.com"); + schema.bigInt("age", 25); + schema.bool("active", true); + }); + + requestHelper.insert("test_users", schema -> { + schema.string("username", "user2"); + schema.string("email", "user2@example.com"); + schema.bigInt("age", 30); + schema.bool("active", true); + }); + } + + @Test + public void testSimpleUpdate() throws Exception { + requestHelper.update("test_users", schema -> { + schema.string("email", "user1.updated@example.com"); + schema.bigInt("age", 26); + schema.where("username", "user1"); + }); + + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT email, age FROM test_users WHERE username = 'user1'"); + assertTrue(rs.next()); + assertEquals("user1.updated@example.com", rs.getString("email")); + assertEquals(26, rs.getInt("age")); + } + } + + @Test + public void testUpdateWithMultipleWhereConditions() throws Exception { + requestHelper.update("test_users", schema -> { + schema.bool("active", false); + schema.where("username", "user1"); + schema.where("age", ">", 20); + }); + + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT active FROM test_users WHERE username = 'user1'"); + assertTrue(rs.next()); + assertFalse(rs.getBoolean("active")); + } + } + + @Test + public void testUpdateMultipleRows() throws Exception { + requestHelper.update("test_users", schema -> { + schema.bool("active", false); + schema.where("age", ">=", 25); + }); + + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM test_users WHERE active = false"); + assertTrue(rs.next()); + assertEquals(2, rs.getInt(1)); + } + } + + @Test + public void testUpdateWithNullValue() throws Exception { + requestHelper.update("test_users", schema -> { + schema.string("email", null); + schema.where("username", "user2"); + }); + + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT email FROM test_users WHERE username = 'user2'"); + assertTrue(rs.next()); + assertNull(rs.getString("email")); + } + } + + @Test + public void testUpdateNoMatches() throws Exception { + // Update with WHERE condition that matches no rows + requestHelper.update("test_users", schema -> { + schema.string("email", "nope@example.com"); + schema.where("username", "nonexistent"); + }); + + // Verify original data unchanged + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT email FROM test_users WHERE username = 'user1'"); + assertTrue(rs.next()); + assertEquals("user1@example.com", rs.getString("email")); + } + } +} \ No newline at end of file diff --git a/src/test/java/fr/maxlego08/sarah/UpsertRequestTest.java b/src/test/java/fr/maxlego08/sarah/UpsertRequestTest.java new file mode 100644 index 0000000..4760603 --- /dev/null +++ b/src/test/java/fr/maxlego08/sarah/UpsertRequestTest.java @@ -0,0 +1,212 @@ +package fr.maxlego08.sarah; + +import fr.maxlego08.sarah.database.Schema; +import org.junit.jupiter.api.Test; + +import java.sql.ResultSet; +import java.sql.Statement; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite for UPSERT operations - tests the recent autoincrement fixes + */ +public class UpsertRequestTest extends DatabaseTestBase { + + @Override + protected void afterConnectionSetup() throws Exception { + // Create test table with autoincrement + SchemaBuilder.create(null, "test_users", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("username", 50).unique(); + schema.string("email", 100).nullable(); // Email can be null for upsert tests + schema.integer("age"); + schema.timestamps(); + }).execute(connection, testLogger); + } + + @Test + public void testUpsertInsertNew() throws Exception { + // Insert new record + requestHelper.upsert("test_users", schema -> { + schema.string("username", "john_doe").unique(); // Mark as unique to match table constraint + schema.string("email", "john@example.com"); + schema.bigInt("age", 25); + }); + + assertEquals(1, countRows("test_users")); + + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT * FROM test_users WHERE username = 'john_doe'"); + assertTrue(rs.next()); + assertEquals("john@example.com", rs.getString("email")); + assertEquals(25, rs.getInt("age")); + } + } + + @Test + public void testUpsertUpdateExisting() throws Exception { + // Insert initial record + requestHelper.insert("test_users", schema -> { + schema.string("username", "jane_doe").unique(); + schema.string("email", "jane@example.com"); + schema.bigInt("age", 30); + }); + + // Upsert with same username (should update) + requestHelper.upsert("test_users", schema -> { + schema.string("username", "jane_doe").unique(); + schema.string("email", "jane.updated@example.com"); + schema.bigInt("age", 31); + }); + + // Should still have only 1 row + assertEquals(1, countRows("test_users")); + + // Verify data was updated + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT * FROM test_users WHERE username = 'jane_doe'"); + assertTrue(rs.next()); + assertEquals("jane.updated@example.com", rs.getString("email")); + assertEquals(31, rs.getInt("age")); + } + } + + @Test + public void testUpsertWithAutoIncrementSkipped() throws Exception { + // This tests the fix from commit d8f7972 and a7c61fb + // Autoincrement columns should be skipped in both INSERT and UPDATE parts + + // Insert a record + requestHelper.insert("test_users", schema -> { + schema.string("username", "auto_test").unique(); + schema.string("email", "auto@example.com"); + schema.bigInt("age", 25); + }); + + // Get the auto-generated ID + int originalId; + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT id FROM test_users WHERE username = 'auto_test'"); + assertTrue(rs.next()); + originalId = rs.getInt("id"); + } + + // Upsert with same username + requestHelper.upsert("test_users", schema -> { + schema.string("username", "auto_test").unique(); + schema.string("email", "auto.updated@example.com"); + schema.bigInt("age", 26); + }); + + // Verify the ID hasn't changed (autoincrement wasn't updated) + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT id, email, age FROM test_users WHERE username = 'auto_test'"); + assertTrue(rs.next()); + assertEquals(originalId, rs.getInt("id"), "ID should not change on upsert"); + assertEquals("auto.updated@example.com", rs.getString("email")); + assertEquals(26, rs.getInt("age")); + } + } + + @Test + public void testUpsertMultipleOperations() throws Exception { + // Mix of inserts and updates + requestHelper.upsert("test_users", schema -> { + schema.string("username", "user1").unique(); + schema.string("email", "user1@example.com"); + schema.bigInt("age", 20); + }); + + requestHelper.upsert("test_users", schema -> { + schema.string("username", "user2").unique(); + schema.string("email", "user2@example.com"); + schema.bigInt("age", 30); + }); + + // Update user1 + requestHelper.upsert("test_users", schema -> { + schema.string("username", "user1").unique(); + schema.string("email", "user1.updated@example.com"); + schema.bigInt("age", 21); + }); + + // Should have 2 rows + assertEquals(2, countRows("test_users")); + + // Verify user1 was updated + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT email, age FROM test_users WHERE username = 'user1'"); + assertTrue(rs.next()); + assertEquals("user1.updated@example.com", rs.getString("email")); + assertEquals(21, rs.getInt("age")); + } + } + + @Test + public void testUpsertWithNullValues() throws Exception { + requestHelper.insert("test_users", schema -> { + schema.string("username", "null_test").unique(); + schema.string("email", "null@example.com"); + schema.bigInt("age", 25); + }); + + // Upsert with null email + requestHelper.upsert("test_users", schema -> { + schema.string("username", "null_test").unique(); + schema.string("email", null); + schema.bigInt("age", 26); + }); + + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT email, age FROM test_users WHERE username = 'null_test'"); + assertTrue(rs.next()); + assertNull(rs.getString("email")); + assertEquals(26, rs.getInt("age")); + } + } + + @Test + public void testUpsertCompositePrimaryKeyTable() throws Exception { + // Create table with composite primary key (no autoincrement) + SchemaBuilder.create(null, "test_composite", schema -> { + schema.string("key1", 50); + schema.string("key2", 50); + schema.string("value", 100); + schema.integer("count"); + }).execute(connection, testLogger); + + // Add composite primary key manually + executeRawSQL("DROP TABLE IF EXISTS test_composite"); + if (configuration.getDatabaseType() == fr.maxlego08.sarah.database.DatabaseType.SQLITE) { + executeRawSQL("CREATE TABLE test_composite (key1 VARCHAR(50), key2 VARCHAR(50), value VARCHAR(100), count INT, PRIMARY KEY (key1, key2))"); + } else { + executeRawSQL("CREATE TABLE test_composite (key1 VARCHAR(50), key2 VARCHAR(50), value VARCHAR(100), count INT, PRIMARY KEY (key1, key2))"); + } + + // Insert initial record + requestHelper.insert("test_composite", schema -> { + schema.string("key1", "A"); + schema.string("key2", "B"); + schema.string("value", "initial"); + schema.bigInt("count", 1); + }); + + // Upsert with same composite key + requestHelper.upsert("test_composite", schema -> { + schema.string("key1", "A").primary(); // Mark as primary key + schema.string("key2", "B").primary(); // Mark as primary key + schema.string("value", "updated"); + schema.bigInt("count", 2); + }); + + assertEquals(1, countRows("test_composite")); + + try (Statement stmt = connection.getConnection().createStatement()) { + ResultSet rs = stmt.executeQuery("SELECT value, count FROM test_composite WHERE key1 = 'A' AND key2 = 'B'"); + assertTrue(rs.next()); + assertEquals("updated", rs.getString("value")); + assertEquals(2, rs.getInt("count")); + } + } +} \ No newline at end of file diff --git a/src/test/java/fr/maxlego08/sarah/WhereConditionTest.java b/src/test/java/fr/maxlego08/sarah/WhereConditionTest.java new file mode 100644 index 0000000..9a4ffa2 --- /dev/null +++ b/src/test/java/fr/maxlego08/sarah/WhereConditionTest.java @@ -0,0 +1,208 @@ +package fr.maxlego08.sarah; + +import fr.maxlego08.sarah.database.Schema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test suite for WHERE conditions + */ +public class WhereConditionTest extends DatabaseTestBase { + + @Override + protected void afterConnectionSetup() throws Exception { + SchemaBuilder.create(null, "test_users", schema -> { + schema.autoIncrementBigInt("id"); + schema.string("username", 50); + schema.string("email", 100).nullable(); + schema.integer("age"); + schema.bool("active"); + }).execute(connection, testLogger); + } + + @BeforeEach + public void insertTestData() { + requestHelper.insert("test_users", schema -> { + schema.string("username", "alice"); + schema.string("email", "alice@example.com"); + schema.bigInt("age", 25); + schema.bool("active", true); + }); + + requestHelper.insert("test_users", schema -> { + schema.string("username", "bob"); + schema.string("email", null); + schema.bigInt("age", 30); + schema.bool("active", true); + }); + + requestHelper.insert("test_users", schema -> { + schema.string("username", "charlie"); + schema.string("email", "charlie@example.com"); + schema.bigInt("age", 35); + schema.bool("active", false); + }); + + requestHelper.insert("test_users", schema -> { + schema.string("username", "david"); + schema.string("email", "david@example.com"); + schema.bigInt("age", 30); + schema.bool("active", true); + }); + } + + @Test + public void testWhereEquals() { + List> results = requestHelper.select("test_users", schema -> { + schema.where("username", "alice"); + }); + + assertEquals(1, results.size()); + assertEquals("alice", results.get(0).get("username")); + } + + @Test + public void testWhereGreaterThan() { + List> results = requestHelper.select("test_users", schema -> { + schema.where("age", ">", 25); + }); + + assertEquals(3, results.size()); // bob, charlie, david have age > 25 + } + + @Test + public void testWhereLessThan() { + List> results = requestHelper.select("test_users", schema -> { + schema.where("age", "<", 35); + }); + + assertEquals(3, results.size()); // alice, bob, david have age < 35 + } + + @Test + public void testWhereGreaterThanOrEqual() { + List> results = requestHelper.select("test_users", schema -> { + schema.where("age", ">=", 30); + }); + + assertEquals(3, results.size()); // bob, charlie, david have age >= 30 + } + + @Test + public void testWhereLessThanOrEqual() { + List> results = requestHelper.select("test_users", schema -> { + schema.where("age", "<=", 30); + }); + + assertEquals(3, results.size()); // alice, bob, david have age <= 30 + } + + @Test + public void testWhereNotEqual() { + List> results = requestHelper.select("test_users", schema -> { + schema.where("username", "!=", "alice"); + }); + + assertEquals(3, results.size()); // bob, charlie, david + } + + @Test + public void testWhereNull() { + List> results = requestHelper.select("test_users", schema -> { + schema.whereNull("email"); + }); + + assertEquals(1, results.size()); + assertEquals("bob", results.get(0).get("username")); + } + + @Test + public void testWhereNotNull() { + List> results = requestHelper.select("test_users", schema -> { + schema.whereNotNull("email"); + }); + + assertEquals(3, results.size()); // alice, charlie, david have non-null email + } + + @Test + public void testWhereIn() { + List> results = requestHelper.select("test_users", schema -> { + schema.whereIn("username", "alice", "charlie"); + }); + + assertEquals(2, results.size()); + } + + @Test + public void testWhereInWithList() { + List usernames = Arrays.asList("alice", "bob"); + + List> results = requestHelper.select("test_users", schema -> { + schema.whereIn("username", usernames); + }); + + assertEquals(2, results.size()); + } + + @Test + public void testMultipleWhereConditions() { + List> results = requestHelper.select("test_users", schema -> { + schema.where("age", ">=", 25); + schema.where("age", "<=", 30); + schema.where("active", true); + }); + + assertEquals(3, results.size()); // alice, bob, david match all conditions + } + + @Test + public void testWhereLike() { + // Note: LIKE operator support may depend on implementation + List> results = requestHelper.select("test_users", schema -> { + schema.where("username", "LIKE", "a%"); + }); + + assertEquals(1, results.size()); + assertEquals("alice", results.get(0).get("username")); + } + + @Test + public void testWhereWithBooleanValue() { + List> results = requestHelper.select("test_users", schema -> { + schema.where("active", false); + }); + + assertEquals(1, results.size()); + assertEquals("charlie", results.get(0).get("username")); + } + + @Test + public void testComplexWhereConditions() { + // Active users older than 25 with email not null + List> results = requestHelper.select("test_users", schema -> { + schema.where("active", true); + schema.where("age", ">", 25); + schema.whereNotNull("email"); + }); + + assertEquals(1, results.size()); + // Should not include bob (email is null) or charlie (not active) + } + + @Test + public void testWhereInEmptyList() { + List> results = requestHelper.select("test_users", schema -> { + schema.whereIn("username"); + }); + + // Behavior may vary, but typically should return no results or handle gracefully + assertTrue(results.size() >= 0); + } +} \ No newline at end of file