From 10a8cfa72b4c231810f5acb8be56c0c6cc5c4fa8 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 25 Jan 2026 18:30:51 +0100 Subject: [PATCH] Step 1 - Foundation & Infrastruktur (SqlDialectProvider Interface, Maven Dependencies, Konfiguration) --- CLAUDE.md | 98 +++++ pom.xml | 30 ++ .../dialect/MySQLDialectProvider.java | 184 +++++++++ .../database/dialect/SqlDialectProvider.java | 357 ++++++++++++++++++ .../resources/application-mssql.properties | 50 +++ .../resources/application-mysql.properties | 50 +++ .../dialect/MySQLDialectProviderTest.java | 279 ++++++++++++++ 7 files changed, 1048 insertions(+) create mode 100644 CLAUDE.md create mode 100644 src/main/java/de/avatic/lcc/database/dialect/MySQLDialectProvider.java create mode 100644 src/main/java/de/avatic/lcc/database/dialect/SqlDialectProvider.java create mode 100644 src/main/resources/application-mssql.properties create mode 100644 src/main/resources/application-mysql.properties create mode 100644 src/test/java/de/avatic/lcc/database/dialect/MySQLDialectProviderTest.java diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0e36732 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +LCC (Logistic Cost Calculator) is a Spring Boot 3.5.9 backend API for calculating complex logistics costs across supply chain networks. It handles materials, packaging, transportation rates, route planning, and multi-component cost calculations including customs duties, handling, inventory, and risk assessment. + +## Build & Run Commands + +```bash +# Build the project +mvn clean install + +# Run the application +mvn spring-boot:run + +# Run all tests +mvn test + +# Run a specific test class +mvn test -Dtest=NodeControllerIntegrationTest + +# Run a specific test method +mvn test -Dtest=NodeControllerIntegrationTest#shouldReturnListOfNodesWithDefaultPagination + +# Skip tests during build +mvn clean install -DskipTests + +# Generate JAXB classes from WSDL (EU taxation service) +mvn jaxb:generate +``` + +## Architecture + +### Layered Architecture +``` +Controllers → DTOs → Services → Transformers → Repositories → MySQL +``` + +### Package Structure (`de.avatic.lcc`) +- **controller/** - REST endpoints organized by domain (calculation, configuration, bulk, users, report) +- **service/access/** - Business logic for domain entities (PremisesService, MaterialService, NodeService, etc.) +- **service/calculation/** - Logistics cost calculation orchestration and step services +- **service/calculation/execution/steps/** - Individual calculation components (airfreight, handling, inventory, customs, etc.) +- **service/bulk/** - Excel-based bulk import/export operations +- **service/api/** - External API integrations (Azure Maps geocoding, EU taxation) +- **service/transformer/** - Entity-to-DTO mapping +- **repositories/** - JDBC-based data access (not JPA) with custom RowMappers +- **model/db/** - Database entity classes +- **dto/** - Data transfer objects for API contracts + +### Key Design Decisions +- **JDBC over JPA**: Uses `JdbcTemplate` and `NamedParameterJdbcTemplate` for complex queries +- **Transformer layer**: Explicit DTO mapping keeps entities separate from API contracts +- **Calculation chain**: Cost calculations broken into fine-grained services in `execution/steps/` + +### Core Calculation Flow +``` +CalculationExecutionService.launchJobCalculation() + → ContainerCalculationService (container type selection: FEU/TEU/HC/TRUCK) + → RouteSectionCostCalculationService (per-section costs) + → AirfreightCalculationService + → HandlingCostCalculationService + → InventoryCostCalculationService + → CustomCostCalculationService (tariff/duties) +``` + +### Authorization Model +Role-based access control via `@PreAuthorize` annotations: +- SUPER, CALCULATION, MATERIAL, FREIGHT, PACKAGING, BASIC + +## Testing + +Integration tests use: +- `@SpringBootTest` + `@AutoConfigureMockMvc` +- `@Transactional` for test isolation +- `@Sql` annotations for test data setup/cleanup from `src/test/resources/master_data/` +- MySQL database (requires env.properties with DB_DATABASE, DB_USER, DB_PASSWORD) + +## Database + +- **MySQL** with Flyway migrations in `src/main/resources/db/migration/` +- Migration naming: `V{N}__{Description}.sql` +- Key tables: `premiss`, `premiss_sink`, `premiss_route`, `calculation_job`, `node`, `material`, `packaging`, `container_rate`, `country_matrix_rate` + +## External Integrations + +- **Azure AD**: OAuth2/OIDC authentication +- **Azure Maps**: Geocoding and route distance calculations (GeoApiService, DistanceApiService) +- **EU Taxation API**: TARIC nomenclature lookup for customs duties (EUTaxationApiService) + +## Configuration + +Key properties in `application.properties`: +- `lcc.auth.identify.by` - User identification method (workday) +- `calculation.job.processor.*` - Async calculation job settings +- Flyway enabled by default; migrations run on startup \ No newline at end of file diff --git a/pom.xml b/pom.xml index 4db4de8..19437b2 100644 --- a/pom.xml +++ b/pom.xml @@ -90,6 +90,12 @@ mysql-connector-j runtime + + com.microsoft.sqlserver + mssql-jdbc + 12.6.1.jre11 + runtime + org.springframework.boot spring-boot-starter-test @@ -178,6 +184,10 @@ org.flywaydb flyway-mysql + + org.flywaydb + flyway-sqlserver + org.glassfish.jaxb @@ -195,6 +205,26 @@ 3.2.3 + + + org.testcontainers + testcontainers + 1.19.7 + test + + + org.testcontainers + mysql + 1.19.7 + test + + + org.testcontainers + mssqlserver + 1.19.7 + test + + diff --git a/src/main/java/de/avatic/lcc/database/dialect/MySQLDialectProvider.java b/src/main/java/de/avatic/lcc/database/dialect/MySQLDialectProvider.java new file mode 100644 index 0000000..fabfd5b --- /dev/null +++ b/src/main/java/de/avatic/lcc/database/dialect/MySQLDialectProvider.java @@ -0,0 +1,184 @@ +package de.avatic.lcc.database.dialect; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * MySQL-specific implementation of {@link SqlDialectProvider}. + * + *

This provider generates SQL syntax compatible with MySQL 8.0+. + * It is automatically activated when the "mysql" Spring profile is active.

+ * + * @author LCC Team + * @since 1.0 + */ +@Component +@Profile("mysql") +public class MySQLDialectProvider implements SqlDialectProvider { + + @Override + public String getDialectName() { + return "MySQL"; + } + + @Override + public String getDriverClassName() { + return "com.mysql.cj.jdbc.Driver"; + } + + // ========== Pagination ========== + + @Override + public String buildPaginationClause(int limit, int offset) { + return "LIMIT ? OFFSET ?"; + } + + @Override + public Object[] getPaginationParameters(int limit, int offset) { + return new Object[]{limit, offset}; + } + + // ========== Upsert Operations ========== + + @Override + public String buildUpsertStatement( + String tableName, + List uniqueColumns, + List insertColumns, + List updateColumns + ) { + // INSERT INTO table (col1, col2, ...) VALUES (?, ?, ...) + String insertPart = String.format( + "INSERT INTO %s (%s) VALUES (%s)", + tableName, + String.join(", ", insertColumns), + insertColumns.stream().map(c -> "?").collect(Collectors.joining(", ")) + ); + + // ON DUPLICATE KEY UPDATE col1 = VALUES(col1), col2 = VALUES(col2), ... + String updatePart = updateColumns.stream() + .map(col -> col + " = VALUES(" + col + ")") + .collect(Collectors.joining(", ")); + + return insertPart + " ON DUPLICATE KEY UPDATE " + updatePart; + } + + @Override + public String buildInsertIgnoreStatement( + String tableName, + List columns, + List uniqueColumns + ) { + return String.format( + "INSERT IGNORE INTO %s (%s) VALUES (%s)", + tableName, + String.join(", ", columns), + columns.stream().map(c -> "?").collect(Collectors.joining(", ")) + ); + } + + // ========== Locking Strategies ========== + + @Override + public String buildSelectForUpdateSkipLocked(String selectStatement) { + return selectStatement + " FOR UPDATE SKIP LOCKED"; + } + + @Override + public String buildSelectForUpdate(String selectStatement) { + return selectStatement + " FOR UPDATE"; + } + + // ========== Date/Time Functions ========== + + @Override + public String getCurrentTimestamp() { + return "NOW()"; + } + + @Override + public String buildDateSubtraction(String baseDate, String value, DateUnit unit) { + String base = baseDate != null ? baseDate : "NOW()"; + return String.format("DATE_SUB(%s, INTERVAL %s %s)", base, value, unit.name()); + } + + @Override + public String buildDateAddition(String baseDate, String value, DateUnit unit) { + String base = baseDate != null ? baseDate : "NOW()"; + return String.format("DATE_ADD(%s, INTERVAL %s %s)", base, value, unit.name()); + } + + @Override + public String extractDate(String columnOrExpression) { + return "DATE(" + columnOrExpression + ")"; + } + + // ========== Auto-increment Reset ========== + + @Override + public String buildAutoIncrementReset(String tableName) { + return String.format("ALTER TABLE %s AUTO_INCREMENT = 1", tableName); + } + + // ========== Geospatial Distance Calculation ========== + + @Override + public String buildHaversineDistance(String lat1, String lng1, String lat2, String lng2) { + // Haversine formula: 6371000 meters (Earth radius) * acos(...) + // Formula: d = 2R * arcsin(sqrt(sin²((lat2-lat1)/2) + cos(lat1)*cos(lat2)*sin²((lon2-lon1)/2))) + // Simplified: R * acos(cos(lat1)*cos(lat2)*cos(lng2-lng1) + sin(lat1)*sin(lat2)) + return String.format( + "6371000 * ACOS(COS(RADIANS(%s)) * COS(RADIANS(%s)) * " + + "COS(RADIANS(%s) - RADIANS(%s)) + SIN(RADIANS(%s)) * SIN(RADIANS(%s)))", + lat1, lat2, lng2, lng1, lat1, lat2 + ); + } + + // ========== String/Type Functions ========== + + @Override + public String buildConcat(String... expressions) { + return "CONCAT(" + String.join(", ", expressions) + ")"; + } + + @Override + public String castToString(String expression) { + return "CAST(" + expression + " AS CHAR)"; + } + + // ========== Bulk Operations ========== + + @Override + public String getMaxLimitValue() { + // MySQL BIGINT UNSIGNED max value + return "18446744073709551615"; + } + + @Override + public boolean supportsReturningClause() { + return false; + } + + @Override + public String buildReturningClause(String... columns) { + throw new UnsupportedOperationException( + "MySQL does not support RETURNING clause. Use LAST_INSERT_ID() or GeneratedKeyHolder instead." + ); + } + + // ========== Schema/DDL ========== + + @Override + public String getAutoIncrementDefinition() { + return "INT NOT NULL AUTO_INCREMENT"; + } + + @Override + public String getTimestampDefinition() { + return "TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"; + } +} \ No newline at end of file diff --git a/src/main/java/de/avatic/lcc/database/dialect/SqlDialectProvider.java b/src/main/java/de/avatic/lcc/database/dialect/SqlDialectProvider.java new file mode 100644 index 0000000..557a8ed --- /dev/null +++ b/src/main/java/de/avatic/lcc/database/dialect/SqlDialectProvider.java @@ -0,0 +1,357 @@ +package de.avatic.lcc.database.dialect; + +import java.util.List; + +/** + * Provides database-specific SQL syntax for different RDBMS implementations. + * Supports MySQL and MSSQL Server with identical semantic behavior. + * + *

This interface abstracts database-specific SQL patterns to enable multi-database support + * in the LCC application. Each dialect provider implements the SQL syntax specific to + * its target database while maintaining consistent semantics across all implementations.

+ * + * @author LCC Team + * @since 1.0 + */ +public interface SqlDialectProvider { + + // ========== Metadata ========== + + /** + * Returns the dialect name (e.g., "MySQL", "MSSQL"). + * + * @return the name of the database dialect + */ + String getDialectName(); + + /** + * Returns the JDBC driver class name for this dialect. + * + * @return the fully qualified JDBC driver class name + */ + String getDriverClassName(); + + // ========== Pagination ========== + + /** + * Generates the pagination clause for limiting and offsetting query results. + * + *

Examples:

+ *
    + *
  • MySQL: {@code LIMIT ? OFFSET ?}
  • + *
  • MSSQL: {@code OFFSET ? ROWS FETCH NEXT ? ROWS ONLY}
  • + *
+ * + *

Note: MSSQL requires an ORDER BY clause before OFFSET/FETCH.

+ * + * @param limit maximum number of rows to return + * @param offset number of rows to skip + * @return SQL clause for pagination (without parameter values) + */ + String buildPaginationClause(int limit, int offset); + + /** + * Returns parameter values in the correct order for the pagination clause. + * + *

Parameter order varies by database:

+ *
    + *
  • MySQL: {@code [limit, offset]}
  • + *
  • MSSQL: {@code [offset, limit]}
  • + *
+ * + * @param limit maximum number of rows to return + * @param offset number of rows to skip + * @return array of parameters in database-specific order + */ + Object[] getPaginationParameters(int limit, int offset); + + // ========== Upsert Operations ========== + + /** + * Builds an UPSERT (INSERT or UPDATE) statement. + * + *

Database-specific implementations:

+ *
    + *
  • MySQL: {@code INSERT ... ON DUPLICATE KEY UPDATE ...}
  • + *
  • MSSQL: {@code MERGE ... WHEN MATCHED THEN UPDATE WHEN NOT MATCHED THEN INSERT ...}
  • + *
+ * + * @param tableName target table name + * @param uniqueColumns columns that define uniqueness (for matching existing rows) + * @param insertColumns all columns to insert in a new row + * @param updateColumns columns to update if row exists + * @return complete UPSERT SQL statement with placeholders + */ + String buildUpsertStatement( + String tableName, + List uniqueColumns, + List insertColumns, + List updateColumns + ); + + /** + * Builds an INSERT IGNORE statement that inserts only if the row does not exist. + * + *

Database-specific implementations:

+ *
    + *
  • MySQL: {@code INSERT IGNORE INTO ...}
  • + *
  • MSSQL: {@code IF NOT EXISTS (...) INSERT INTO ...}
  • + *
+ * + * @param tableName target table name + * @param columns columns to insert + * @param uniqueColumns columns that define uniqueness (for existence check) + * @return INSERT IGNORE statement with placeholders + */ + String buildInsertIgnoreStatement( + String tableName, + List columns, + List uniqueColumns + ); + + // ========== Locking Strategies ========== + + /** + * Builds SELECT FOR UPDATE with skip locked capability for pessimistic locking. + * + *

This is critical for {@code CalculationJobRepository} concurrent job processing.

+ * + *

Database-specific implementations:

+ *
    + *
  • MySQL: {@code SELECT ... FOR UPDATE SKIP LOCKED}
  • + *
  • MSSQL: {@code SELECT ... WITH (UPDLOCK, READPAST)}
  • + *
+ * + * @param selectStatement base SELECT statement (without locking clause) + * @return complete statement with pessimistic locking that skips locked rows + */ + String buildSelectForUpdateSkipLocked(String selectStatement); + + /** + * Builds standard SELECT FOR UPDATE for pessimistic locking (waits for locks). + * + *

Database-specific implementations:

+ *
    + *
  • MySQL: {@code SELECT ... FOR UPDATE}
  • + *
  • MSSQL: {@code SELECT ... WITH (UPDLOCK, ROWLOCK)}
  • + *
+ * + * @param selectStatement base SELECT statement (without locking clause) + * @return complete statement with pessimistic locking + */ + String buildSelectForUpdate(String selectStatement); + + // ========== Date/Time Functions ========== + + /** + * Returns the SQL function for getting the current timestamp. + * + *

Database-specific implementations:

+ *
    + *
  • MySQL: {@code NOW()}
  • + *
  • MSSQL: {@code GETDATE()}
  • + *
+ * + * @return SQL function for current timestamp + */ + String getCurrentTimestamp(); + + /** + * Builds a date subtraction expression. + * + *

Database-specific implementations:

+ *
    + *
  • MySQL: {@code DATE_SUB(NOW(), INTERVAL ? DAY)}
  • + *
  • MSSQL: {@code DATEADD(DAY, -?, GETDATE())}
  • + *
+ * + * @param baseDate base date expression (or null to use current timestamp) + * @param value placeholder for number of time units to subtract (e.g., "?") + * @param unit time unit (DAY, HOUR, MINUTE, etc.) + * @return date subtraction expression + */ + String buildDateSubtraction(String baseDate, String value, DateUnit unit); + + /** + * Builds a date addition expression. + * + *

Database-specific implementations:

+ *
    + *
  • MySQL: {@code DATE_ADD(NOW(), INTERVAL ? DAY)}
  • + *
  • MSSQL: {@code DATEADD(DAY, ?, GETDATE())}
  • + *
+ * + * @param baseDate base date expression (or null to use current timestamp) + * @param value placeholder for number of time units to add (e.g., "?") + * @param unit time unit (DAY, HOUR, MINUTE, etc.) + * @return date addition expression + */ + String buildDateAddition(String baseDate, String value, DateUnit unit); + + /** + * Extracts the date part from a datetime expression (ignoring time component). + * + *

Database-specific implementations:

+ *
    + *
  • MySQL: {@code DATE(column)}
  • + *
  • MSSQL: {@code CAST(column AS DATE)}
  • + *
+ * + * @param columnOrExpression column name or expression to extract date from + * @return expression that extracts date component + */ + String extractDate(String columnOrExpression); + + // ========== Auto-increment Reset ========== + + /** + * Resets the auto-increment counter for a table (primarily used in tests). + * + *

Database-specific implementations:

+ *
    + *
  • MySQL: {@code ALTER TABLE table AUTO_INCREMENT = 1}
  • + *
  • MSSQL: {@code DBCC CHECKIDENT ('table', RESEED, 0)}
  • + *
+ * + * @param tableName table to reset auto-increment counter + * @return SQL statement to reset auto-increment + */ + String buildAutoIncrementReset(String tableName); + + // ========== Geospatial Distance Calculation ========== + + /** + * Builds a Haversine distance calculation expression. + * + *

Used in {@code NodeRepository} for finding nearby nodes based on geographic coordinates. + * Calculates the great-circle distance between two points on Earth's surface.

+ * + *

Both MySQL and MSSQL support trigonometric functions (SIN, COS, ACOS, RADIANS), + * so the implementation is similar across databases.

+ * + * @param lat1 first latitude column or expression + * @param lng1 first longitude column or expression + * @param lat2 second latitude column or expression + * @param lng2 second longitude column or expression + * @return expression calculating distance in meters + */ + String buildHaversineDistance(String lat1, String lng1, String lat2, String lng2); + + // ========== String/Type Functions ========== + + /** + * Builds a string concatenation expression. + * + *

Database-specific implementations:

+ *
    + *
  • MySQL: {@code CONCAT(a, b, c)}
  • + *
  • MSSQL: {@code CONCAT(a, b, c)} (SQL Server 2012+) or {@code a + b + c}
  • + *
+ * + * @param expressions expressions to concatenate + * @return concatenation expression + */ + String buildConcat(String... expressions); + + /** + * Converts an expression to string type. + * + *

Database-specific implementations:

+ *
    + *
  • MySQL: {@code CAST(x AS CHAR)}
  • + *
  • MSSQL: {@code CAST(x AS VARCHAR(MAX))}
  • + *
+ * + * @param expression expression to convert to string + * @return cast-to-string expression + */ + String castToString(String expression); + + // ========== Bulk Operations ========== + + /** + * Returns the maximum safe value for LIMIT clause. + * + *

Used for workarounds in queries that need to skip LIMIT but still use OFFSET.

+ *
    + *
  • MySQL: {@code 18446744073709551615} (BIGINT UNSIGNED max)
  • + *
  • MSSQL: {@code 2147483647} (INT max)
  • + *
+ * + * @return maximum limit value as string + */ + String getMaxLimitValue(); + + /** + * Checks if the dialect supports RETURNING clause for INSERT statements. + * + *
    + *
  • MySQL: {@code false} (use LAST_INSERT_ID())
  • + *
  • MSSQL: {@code true} (supports OUTPUT INSERTED.id)
  • + *
+ * + * @return true if RETURNING clause is supported + */ + boolean supportsReturningClause(); + + /** + * Builds a RETURNING clause for INSERT statement. + * + *

MSSQL example: {@code OUTPUT INSERTED.id}

+ * + * @param columns columns to return + * @return RETURNING clause + * @throws UnsupportedOperationException if dialect does not support RETURNING + */ + String buildReturningClause(String... columns); + + // ========== Schema/DDL ========== + + /** + * Returns the auto-increment column definition for schema creation. + * + *

Database-specific implementations:

+ *
    + *
  • MySQL: {@code INT NOT NULL AUTO_INCREMENT}
  • + *
  • MSSQL: {@code INT NOT NULL IDENTITY(1,1)}
  • + *
+ * + * @return auto-increment column definition + */ + String getAutoIncrementDefinition(); + + /** + * Returns the timestamp column definition with automatic update capability. + * + *

Database-specific implementations:

+ *
    + *
  • MySQL: {@code TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP}
  • + *
  • MSSQL: {@code DATETIME2 NOT NULL DEFAULT GETDATE()} (requires trigger for ON UPDATE)
  • + *
+ * + *

Note: For MSSQL, triggers must be created separately to handle ON UPDATE behavior.

+ * + * @return timestamp column definition + */ + String getTimestampDefinition(); + + // ========== Helper Enums ========== + + /** + * Time units for date arithmetic operations. + */ + enum DateUnit { + /** Year unit */ + YEAR, + /** Month unit */ + MONTH, + /** Day unit */ + DAY, + /** Hour unit */ + HOUR, + /** Minute unit */ + MINUTE, + /** Second unit */ + SECOND + } +} \ No newline at end of file diff --git a/src/main/resources/application-mssql.properties b/src/main/resources/application-mssql.properties new file mode 100644 index 0000000..2106316 --- /dev/null +++ b/src/main/resources/application-mssql.properties @@ -0,0 +1,50 @@ +# MSSQL Profile Configuration +# Activate with: -Dspring.profiles.active=mssql or SPRING_PROFILES_ACTIVE=mssql + +# Application Name +spring.application.name=lcc + +# Database Configuration - MSSQL +spring.datasource.driver-class-name=com.microsoft.sqlserver.jdbc.SQLServerDriver +spring.datasource.url=jdbc:sqlserver://${DB_HOST:localhost}:1433;databaseName=${DB_DATABASE:lcc};encrypt=true;trustServerCertificate=true +spring.datasource.username=${DB_USER:sa} +spring.datasource.password=${DB_PASSWORD} + +# File Upload Limits +spring.servlet.multipart.max-file-size=30MB +spring.servlet.multipart.max-request-size=50MB + +# Azure AD Configuration +spring.cloud.azure.active-directory.enabled=true +spring.cloud.azure.active-directory.authorization-clients.graph.scopes=openid,profile,email,https://graph.microsoft.com/User.Read + +# Management Endpoints +management.endpoints.web.exposure.include=health,info,metrics +management.endpoint.health.show-details=when-authorized + +# Flyway Migration - MSSQL +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration/mssql +spring.flyway.baseline-on-migrate=true +spring.sql.init.mode=never + +# LCC Configuration +lcc.allowed_cors= +lcc.allowed_oauth_token_cors=* + +lcc.auth.identify.by=workday +lcc.auth.claim.workday=employeeid +lcc.auth.claim.email=preferred_username +lcc.auth.claim.firstname=given_name +lcc.auth.claim.lastname=family_name + +lcc.auth.claim.ignore.workday=false + +# Bulk Import +lcc.bulk.sheet_password=secretSheet?! + +# Calculation Job Processor Configuration +calculation.job.processor.enabled=true +calculation.job.processor.pool-size=1 +calculation.job.processor.delay=5000 +calculation.job.processor.thread-name-prefix=calc-job- \ No newline at end of file diff --git a/src/main/resources/application-mysql.properties b/src/main/resources/application-mysql.properties new file mode 100644 index 0000000..f33a554 --- /dev/null +++ b/src/main/resources/application-mysql.properties @@ -0,0 +1,50 @@ +# MySQL Profile Configuration +# Activate with: -Dspring.profiles.active=mysql or SPRING_PROFILES_ACTIVE=mysql + +# Application Name +spring.application.name=lcc + +# Database Configuration - MySQL +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +spring.datasource.url=jdbc:mysql://${DB_HOST:localhost}:3306/${DB_DATABASE:lcc} +spring.datasource.username=${DB_USER:root} +spring.datasource.password=${DB_PASSWORD} + +# File Upload Limits +spring.servlet.multipart.max-file-size=30MB +spring.servlet.multipart.max-request-size=50MB + +# Azure AD Configuration +spring.cloud.azure.active-directory.enabled=true +spring.cloud.azure.active-directory.authorization-clients.graph.scopes=openid,profile,email,https://graph.microsoft.com/User.Read + +# Management Endpoints +management.endpoints.web.exposure.include=health,info,metrics +management.endpoint.health.show-details=when-authorized + +# Flyway Migration - MySQL +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration/mysql +spring.flyway.baseline-on-migrate=true +spring.sql.init.mode=never + +# LCC Configuration +lcc.allowed_cors= +lcc.allowed_oauth_token_cors=* + +lcc.auth.identify.by=workday +lcc.auth.claim.workday=employeeid +lcc.auth.claim.email=preferred_username +lcc.auth.claim.firstname=given_name +lcc.auth.claim.lastname=family_name + +lcc.auth.claim.ignore.workday=false + +# Bulk Import +lcc.bulk.sheet_password=secretSheet?! + +# Calculation Job Processor Configuration +calculation.job.processor.enabled=true +calculation.job.processor.pool-size=1 +calculation.job.processor.delay=5000 +calculation.job.processor.thread-name-prefix=calc-job- \ No newline at end of file diff --git a/src/test/java/de/avatic/lcc/database/dialect/MySQLDialectProviderTest.java b/src/test/java/de/avatic/lcc/database/dialect/MySQLDialectProviderTest.java new file mode 100644 index 0000000..9830609 --- /dev/null +++ b/src/test/java/de/avatic/lcc/database/dialect/MySQLDialectProviderTest.java @@ -0,0 +1,279 @@ +package de.avatic.lcc.database.dialect; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link MySQLDialectProvider}. + */ +@DisplayName("MySQLDialectProvider Tests") +class MySQLDialectProviderTest { + + private MySQLDialectProvider provider; + + @BeforeEach + void setUp() { + provider = new MySQLDialectProvider(); + } + + @Nested + @DisplayName("Metadata Tests") + class MetadataTests { + + @Test + @DisplayName("Should return correct dialect name") + void shouldReturnCorrectDialectName() { + assertEquals("MySQL", provider.getDialectName()); + } + + @Test + @DisplayName("Should return correct driver class name") + void shouldReturnCorrectDriverClassName() { + assertEquals("com.mysql.cj.jdbc.Driver", provider.getDriverClassName()); + } + } + + @Nested + @DisplayName("Pagination Tests") + class PaginationTests { + + @Test + @DisplayName("Should build correct pagination clause") + void shouldBuildCorrectPaginationClause() { + String result = provider.buildPaginationClause(10, 20); + assertEquals("LIMIT ? OFFSET ?", result); + } + + @Test + @DisplayName("Should return pagination parameters in correct order") + void shouldReturnPaginationParametersInCorrectOrder() { + Object[] params = provider.getPaginationParameters(10, 20); + assertArrayEquals(new Object[]{10, 20}, params); + } + } + + @Nested + @DisplayName("Upsert Operation Tests") + class UpsertOperationTests { + + @Test + @DisplayName("Should build correct upsert statement") + void shouldBuildCorrectUpsertStatement() { + List uniqueCols = Arrays.asList("id", "user_id"); + List insertCols = Arrays.asList("id", "user_id", "name", "value"); + List updateCols = Arrays.asList("name", "value"); + + String result = provider.buildUpsertStatement("test_table", uniqueCols, insertCols, updateCols); + + assertTrue(result.contains("INSERT INTO test_table")); + assertTrue(result.contains("(id, user_id, name, value)")); + assertTrue(result.contains("VALUES (?, ?, ?, ?)")); + assertTrue(result.contains("ON DUPLICATE KEY UPDATE")); + assertTrue(result.contains("name = VALUES(name)")); + assertTrue(result.contains("value = VALUES(value)")); + } + + @Test + @DisplayName("Should build correct insert ignore statement") + void shouldBuildCorrectInsertIgnoreStatement() { + List columns = Arrays.asList("user_id", "group_id"); + List uniqueCols = Arrays.asList("user_id", "group_id"); + + String result = provider.buildInsertIgnoreStatement("mapping_table", columns, uniqueCols); + + assertEquals("INSERT IGNORE INTO mapping_table (user_id, group_id) VALUES (?, ?)", result); + } + } + + @Nested + @DisplayName("Locking Strategy Tests") + class LockingStrategyTests { + + @Test + @DisplayName("Should build SELECT FOR UPDATE SKIP LOCKED") + void shouldBuildSelectForUpdateSkipLocked() { + String baseQuery = "SELECT * FROM calculation_job WHERE state = 'CREATED'"; + String result = provider.buildSelectForUpdateSkipLocked(baseQuery); + + assertTrue(result.endsWith("FOR UPDATE SKIP LOCKED")); + assertTrue(result.startsWith("SELECT * FROM calculation_job")); + } + + @Test + @DisplayName("Should build SELECT FOR UPDATE") + void shouldBuildSelectForUpdate() { + String baseQuery = "SELECT * FROM calculation_job WHERE id = ?"; + String result = provider.buildSelectForUpdate(baseQuery); + + assertTrue(result.endsWith("FOR UPDATE")); + assertFalse(result.contains("SKIP LOCKED")); + } + } + + @Nested + @DisplayName("Date/Time Function Tests") + class DateTimeFunctionTests { + + @Test + @DisplayName("Should return NOW() for current timestamp") + void shouldReturnNowForCurrentTimestamp() { + assertEquals("NOW()", provider.getCurrentTimestamp()); + } + + @Test + @DisplayName("Should build date subtraction with NOW()") + void shouldBuildDateSubtractionWithNow() { + String result = provider.buildDateSubtraction(null, "3", SqlDialectProvider.DateUnit.DAY); + assertEquals("DATE_SUB(NOW(), INTERVAL 3 DAY)", result); + } + + @Test + @DisplayName("Should build date subtraction with custom base date") + void shouldBuildDateSubtractionWithCustomBaseDate() { + String result = provider.buildDateSubtraction("calculation_date", "60", SqlDialectProvider.DateUnit.MINUTE); + assertEquals("DATE_SUB(calculation_date, INTERVAL 60 MINUTE)", result); + } + + @Test + @DisplayName("Should build date addition with NOW()") + void shouldBuildDateAdditionWithNow() { + String result = provider.buildDateAddition(null, "7", SqlDialectProvider.DateUnit.DAY); + assertEquals("DATE_ADD(NOW(), INTERVAL 7 DAY)", result); + } + + @Test + @DisplayName("Should build date addition with custom base date") + void shouldBuildDateAdditionWithCustomBaseDate() { + String result = provider.buildDateAddition("start_date", "1", SqlDialectProvider.DateUnit.MONTH); + assertEquals("DATE_ADD(start_date, INTERVAL 1 MONTH)", result); + } + + @Test + @DisplayName("Should extract date from column") + void shouldExtractDateFromColumn() { + String result = provider.extractDate("created_at"); + assertEquals("DATE(created_at)", result); + } + + @Test + @DisplayName("Should extract date from expression") + void shouldExtractDateFromExpression() { + String result = provider.extractDate("NOW()"); + assertEquals("DATE(NOW())", result); + } + } + + @Nested + @DisplayName("Auto-increment Reset Tests") + class AutoIncrementResetTests { + + @Test + @DisplayName("Should build auto-increment reset statement") + void shouldBuildAutoIncrementResetStatement() { + String result = provider.buildAutoIncrementReset("test_table"); + assertEquals("ALTER TABLE test_table AUTO_INCREMENT = 1", result); + } + } + + @Nested + @DisplayName("Geospatial Distance Tests") + class GeospatialDistanceTests { + + @Test + @DisplayName("Should build Haversine distance calculation") + void shouldBuildHaversineDistanceCalculation() { + String result = provider.buildHaversineDistance("50.1", "8.6", "node.geo_lat", "node.geo_lng"); + + assertTrue(result.contains("6371000")); + assertTrue(result.contains("ACOS")); + assertTrue(result.contains("COS")); + assertTrue(result.contains("SIN")); + assertTrue(result.contains("RADIANS")); + assertTrue(result.contains("50.1")); + assertTrue(result.contains("8.6")); + assertTrue(result.contains("node.geo_lat")); + assertTrue(result.contains("node.geo_lng")); + } + } + + @Nested + @DisplayName("String/Type Function Tests") + class StringTypeFunctionTests { + + @Test + @DisplayName("Should build CONCAT with multiple expressions") + void shouldBuildConcatWithMultipleExpressions() { + String result = provider.buildConcat("first_name", "' '", "last_name"); + assertEquals("CONCAT(first_name, ' ', last_name)", result); + } + + @Test + @DisplayName("Should build CONCAT with single expression") + void shouldBuildConcatWithSingleExpression() { + String result = provider.buildConcat("column_name"); + assertEquals("CONCAT(column_name)", result); + } + + @Test + @DisplayName("Should cast to string") + void shouldCastToString() { + String result = provider.castToString("user_id"); + assertEquals("CAST(user_id AS CHAR)", result); + } + } + + @Nested + @DisplayName("Bulk Operation Tests") + class BulkOperationTests { + + @Test + @DisplayName("Should return MySQL BIGINT UNSIGNED max value") + void shouldReturnMySQLBigIntUnsignedMaxValue() { + assertEquals("18446744073709551615", provider.getMaxLimitValue()); + } + + @Test + @DisplayName("Should not support RETURNING clause") + void shouldNotSupportReturningClause() { + assertFalse(provider.supportsReturningClause()); + } + + @Test + @DisplayName("Should throw exception when building RETURNING clause") + void shouldThrowExceptionWhenBuildingReturningClause() { + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> provider.buildReturningClause("id", "name") + ); + + assertTrue(exception.getMessage().contains("MySQL does not support RETURNING")); + assertTrue(exception.getMessage().contains("LAST_INSERT_ID")); + } + } + + @Nested + @DisplayName("Schema/DDL Tests") + class SchemaDDLTests { + + @Test + @DisplayName("Should return AUTO_INCREMENT definition") + void shouldReturnAutoIncrementDefinition() { + String result = provider.getAutoIncrementDefinition(); + assertEquals("INT NOT NULL AUTO_INCREMENT", result); + } + + @Test + @DisplayName("Should return TIMESTAMP with ON UPDATE definition") + void shouldReturnTimestampWithOnUpdateDefinition() { + String result = provider.getTimestampDefinition(); + assertEquals("TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", result); + } + } +} \ No newline at end of file