Step 1 - Foundation & Infrastruktur (SqlDialectProvider Interface, Maven Dependencies, Konfiguration)
This commit is contained in:
parent
417221eca8
commit
10a8cfa72b
7 changed files with 1048 additions and 0 deletions
98
CLAUDE.md
Normal file
98
CLAUDE.md
Normal file
|
|
@ -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
|
||||
30
pom.xml
30
pom.xml
|
|
@ -90,6 +90,12 @@
|
|||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.microsoft.sqlserver</groupId>
|
||||
<artifactId>mssql-jdbc</artifactId>
|
||||
<version>12.6.1.jre11</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
|
@ -178,6 +184,10 @@
|
|||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-mysql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-sqlserver</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.glassfish.jaxb</groupId>
|
||||
|
|
@ -195,6 +205,26 @@
|
|||
<version>3.2.3</version>
|
||||
</dependency>
|
||||
|
||||
<!-- TestContainers for multi-database integration testing -->
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>1.19.7</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>mysql</artifactId>
|
||||
<version>1.19.7</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>mssqlserver</artifactId>
|
||||
<version>1.19.7</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
|
|
|||
|
|
@ -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}.
|
||||
*
|
||||
* <p>This provider generates SQL syntax compatible with MySQL 8.0+.
|
||||
* It is automatically activated when the "mysql" Spring profile is active.</p>
|
||||
*
|
||||
* @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<String> uniqueColumns,
|
||||
List<String> insertColumns,
|
||||
List<String> 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<String> columns,
|
||||
List<String> 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Examples:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code LIMIT ? OFFSET ?}</li>
|
||||
* <li>MSSQL: {@code OFFSET ? ROWS FETCH NEXT ? ROWS ONLY}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Note:</b> MSSQL requires an ORDER BY clause before OFFSET/FETCH.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Parameter order varies by database:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code [limit, offset]}</li>
|
||||
* <li>MSSQL: {@code [offset, limit]}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Database-specific implementations:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code INSERT ... ON DUPLICATE KEY UPDATE ...}</li>
|
||||
* <li>MSSQL: {@code MERGE ... WHEN MATCHED THEN UPDATE WHEN NOT MATCHED THEN INSERT ...}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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<String> uniqueColumns,
|
||||
List<String> insertColumns,
|
||||
List<String> updateColumns
|
||||
);
|
||||
|
||||
/**
|
||||
* Builds an INSERT IGNORE statement that inserts only if the row does not exist.
|
||||
*
|
||||
* <p>Database-specific implementations:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code INSERT IGNORE INTO ...}</li>
|
||||
* <li>MSSQL: {@code IF NOT EXISTS (...) INSERT INTO ...}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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<String> columns,
|
||||
List<String> uniqueColumns
|
||||
);
|
||||
|
||||
// ========== Locking Strategies ==========
|
||||
|
||||
/**
|
||||
* Builds SELECT FOR UPDATE with skip locked capability for pessimistic locking.
|
||||
*
|
||||
* <p>This is critical for {@code CalculationJobRepository} concurrent job processing.</p>
|
||||
*
|
||||
* <p>Database-specific implementations:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code SELECT ... FOR UPDATE SKIP LOCKED}</li>
|
||||
* <li>MSSQL: {@code SELECT ... WITH (UPDLOCK, READPAST)}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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).
|
||||
*
|
||||
* <p>Database-specific implementations:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code SELECT ... FOR UPDATE}</li>
|
||||
* <li>MSSQL: {@code SELECT ... WITH (UPDLOCK, ROWLOCK)}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Database-specific implementations:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code NOW()}</li>
|
||||
* <li>MSSQL: {@code GETDATE()}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @return SQL function for current timestamp
|
||||
*/
|
||||
String getCurrentTimestamp();
|
||||
|
||||
/**
|
||||
* Builds a date subtraction expression.
|
||||
*
|
||||
* <p>Database-specific implementations:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code DATE_SUB(NOW(), INTERVAL ? DAY)}</li>
|
||||
* <li>MSSQL: {@code DATEADD(DAY, -?, GETDATE())}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Database-specific implementations:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code DATE_ADD(NOW(), INTERVAL ? DAY)}</li>
|
||||
* <li>MSSQL: {@code DATEADD(DAY, ?, GETDATE())}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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).
|
||||
*
|
||||
* <p>Database-specific implementations:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code DATE(column)}</li>
|
||||
* <li>MSSQL: {@code CAST(column AS DATE)}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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).
|
||||
*
|
||||
* <p>Database-specific implementations:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code ALTER TABLE table AUTO_INCREMENT = 1}</li>
|
||||
* <li>MSSQL: {@code DBCC CHECKIDENT ('table', RESEED, 0)}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Used in {@code NodeRepository} for finding nearby nodes based on geographic coordinates.
|
||||
* Calculates the great-circle distance between two points on Earth's surface.</p>
|
||||
*
|
||||
* <p>Both MySQL and MSSQL support trigonometric functions (SIN, COS, ACOS, RADIANS),
|
||||
* so the implementation is similar across databases.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Database-specific implementations:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code CONCAT(a, b, c)}</li>
|
||||
* <li>MSSQL: {@code CONCAT(a, b, c)} (SQL Server 2012+) or {@code a + b + c}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param expressions expressions to concatenate
|
||||
* @return concatenation expression
|
||||
*/
|
||||
String buildConcat(String... expressions);
|
||||
|
||||
/**
|
||||
* Converts an expression to string type.
|
||||
*
|
||||
* <p>Database-specific implementations:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code CAST(x AS CHAR)}</li>
|
||||
* <li>MSSQL: {@code CAST(x AS VARCHAR(MAX))}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Used for workarounds in queries that need to skip LIMIT but still use OFFSET.</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code 18446744073709551615} (BIGINT UNSIGNED max)</li>
|
||||
* <li>MSSQL: {@code 2147483647} (INT max)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @return maximum limit value as string
|
||||
*/
|
||||
String getMaxLimitValue();
|
||||
|
||||
/**
|
||||
* Checks if the dialect supports RETURNING clause for INSERT statements.
|
||||
*
|
||||
* <ul>
|
||||
* <li>MySQL: {@code false} (use LAST_INSERT_ID())</li>
|
||||
* <li>MSSQL: {@code true} (supports OUTPUT INSERTED.id)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @return true if RETURNING clause is supported
|
||||
*/
|
||||
boolean supportsReturningClause();
|
||||
|
||||
/**
|
||||
* Builds a RETURNING clause for INSERT statement.
|
||||
*
|
||||
* <p>MSSQL example: {@code OUTPUT INSERTED.id}</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>Database-specific implementations:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code INT NOT NULL AUTO_INCREMENT}</li>
|
||||
* <li>MSSQL: {@code INT NOT NULL IDENTITY(1,1)}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @return auto-increment column definition
|
||||
*/
|
||||
String getAutoIncrementDefinition();
|
||||
|
||||
/**
|
||||
* Returns the timestamp column definition with automatic update capability.
|
||||
*
|
||||
* <p>Database-specific implementations:</p>
|
||||
* <ul>
|
||||
* <li>MySQL: {@code TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP}</li>
|
||||
* <li>MSSQL: {@code DATETIME2 NOT NULL DEFAULT GETDATE()} (requires trigger for ON UPDATE)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Note:</b> For MSSQL, triggers must be created separately to handle ON UPDATE behavior.</p>
|
||||
*
|
||||
* @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
|
||||
}
|
||||
}
|
||||
50
src/main/resources/application-mssql.properties
Normal file
50
src/main/resources/application-mssql.properties
Normal file
|
|
@ -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-
|
||||
50
src/main/resources/application-mysql.properties
Normal file
50
src/main/resources/application-mysql.properties
Normal file
|
|
@ -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-
|
||||
|
|
@ -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<String> uniqueCols = Arrays.asList("id", "user_id");
|
||||
List<String> insertCols = Arrays.asList("id", "user_id", "name", "value");
|
||||
List<String> 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<String> columns = Arrays.asList("user_id", "group_id");
|
||||
List<String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue