diff --git a/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/AccountExportIgnore.java b/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/AccountExportIgnore.java new file mode 100644 index 00000000..d2988a03 --- /dev/null +++ b/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/AccountExportIgnore.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tools.dynamia.modules.saas.api; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a JPA {@code @Entity} class to be completely excluded from the Tenant Mobility + * export/import/clone pipeline. + * + *

Apply this annotation to entities that contain ephemeral, audit, cache or metric data + * that should never travel with a tenant: + * + *

{@code
+ * @Entity
+ * @AccountExportIgnore
+ * public class LoginAuditLog extends SimpleEntitySaaS {
+ *     ...
+ * }
+ * }
+ * + *

Entities annotated with {@code @AccountExportIgnore} are silently skipped during + * discovery, so they will never appear in an export file and will never be processed + * on import. + * + * @author Mario Serrano Leones + * @see ExportIgnore + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface AccountExportIgnore { +} + diff --git a/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/ExportIgnore.java b/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/ExportIgnore.java new file mode 100644 index 00000000..a143dc1d --- /dev/null +++ b/extensions/saas/sources/api/src/main/java/tools/dynamia/modules/saas/api/ExportIgnore.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package tools.dynamia.modules.saas.api; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a specific field in a JPA entity to be excluded from Tenant Mobility + * serialization during export. + * + *

Use this for fields that are computed, cached, or otherwise should not be + * persisted to a different environment: + * + *

{@code
+ * @Entity
+ * public class Customer extends SimpleEntitySaaS {
+ *
+ *     private String name;
+ *
+ *     @ExportIgnore
+ *     private transient String cachedFullName;   // computed
+ *
+ *     @ExportIgnore
+ *     private String internalToken;              // environment-specific secret
+ * }
+ * }
+ * + *

Fields with this annotation are silently skipped during entity serialization. + * On import, those fields will retain their default (null / primitive default) values. + * + * @author Mario Serrano Leones + * @see AccountExportIgnore + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface ExportIgnore { +} + diff --git a/extensions/saas/sources/migration/ARCHITECTURE.md b/extensions/saas/sources/migration/ARCHITECTURE.md new file mode 100644 index 00000000..6ecb2e6a --- /dev/null +++ b/extensions/saas/sources/migration/ARCHITECTURE.md @@ -0,0 +1,338 @@ +# Account Migration Module — Architecture + +## 1. Overview + +The Account Migration Module enables full lifecycle management of tenant (Account) data: export, import, clone, backup, and restore. It is designed for: + +- **Millions of rows** — streaming, never loads all data into memory. +- **Database independence** — uses JPA/Hibernate metamodel exclusively. +- **Extensibility** — SPI interfaces for identity mapping, progress tracking, and cancellation. +- **Non-blocking** — every operation runs as an async background job (Java virtual threads). + +--- + +## 2. Component Map + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ AccountMigrationController (REST) │ +│ POST /export POST /import POST /clone GET /jobs/{jobId} GET /download │ +└─────────────────────────────┬────────────────────────────────────────┘ + │ delegates to +┌─────────────────────────────▼────────────────────────────────────────┐ +│ AccountMigrationJobService │ +│ createJob() · cancelJob() · getJob() · listJobs() │ +│ Persists AccountMigrationJob entity in DB │ +└──────┬───────────────────────────────────────────┬───────────────────┘ + │ launches via │ saves progress via + │ SchedulerUtil.runWithResult(worker) │ CrudService.update() + ▼ │ +┌─────────────────────────┐ │ +│ Workers (VirtualThread) │ │ +│ ┌──────────────────────┴─┐ │ +│ │ ExportWorker │ │ +│ │ ImportWorker │◄────────────────────┘ +│ │ CloneWorker │ +│ └────────────┬───────────┘ +│ │ calls +│ ┌────────────▼───────────┐ +│ │ AccountMigrationService │ +│ │ (impl: coordinates │ +│ │ pipelines) │ +│ └────────────┬───────────┘ +└───────────────┼─────────────────────────────────────────────────────── + │ + ┌───────────┴────────────┐ + │ │ + ▼ ▼ +┌──────────────┐ ┌────────────────┐ +│ ExportPipeline│ │ ImportPipeline │ +│ (streaming │ │ (streaming │ +│ JSON write)│ │ JSON read) │ +└──────┬───────┘ └───────┬────────┘ + │ │ + │ uses │ uses + ┌──┴──────────────────────┴──┐ + │ AccountEntityDiscovery │ discovers all @Entity + AccountAware + │ EntityDependencyGraph │ topological sort via JPA metamodel + │ IdentityMapper SPI │ KEEP_IDS / REGENERATE_IDS + └────────────────────────────┘ +``` + +--- + +## 3. Entity Discovery + +### Algorithm + +``` +1. Get all managed entity types: EntityManagerFactory.getMetamodel().getEntities() +2. For each EntityType: + a. If AccountAware.class.isAssignableFrom(T.getJavaType()) → candidate + b. If T.getJavaType().isAnnotationPresent(@AccountExportIgnore) → exclude +3. Always include Account.class (the tenant root) +4. Return final exportable set +``` + +### Dependency Graph (Topological Sort) + +Used to determine import order: parents before children. + +``` +For each candidate entity E: + For each SingularAttribute A in E's metamodel: + If A.persistentAttributeType == MANY_TO_ONE or ONE_TO_ONE: + If A.javaType is also in the candidate set: + Add edge: A.javaType → E (A.javaType must be imported before E) + +Run Kahn's BFS algorithm to get topological order. +``` + +Example result: +``` +Account → Customer → Order → OrderItem + ↓ + Product +``` + +--- + +## 4. Export Pipeline + +### Streaming Strategy + +``` +OutputStream (raw or GZIPOutputStream) + └── JsonGenerator (Jackson streaming) + ├── Write header: {version, exportedAt, sourceAccountId, identityStrategy} + ├── Write "account": AccountDTO object + └── Write "entities": { + For each entityClass in topological order: + Write entityClass.getName(): [ + LOOP (pagination by chunks): + count = CrudService.count(entityClass, {accountId}) + for page 1..N: + chunk = CrudService.find(entityClass, {accountId, paginator}) + for each record: + write as JSON object (flat map, refs as _ref_id) + ] + } +``` + +### Serialization of a Single Entity + +``` +For each SingularAttribute in EntityType: + BASIC / EMBEDDED → write field value directly + MANY_TO_ONE / ONE_TO_ONE → write {fieldName}_ref_id: + ONE_TO_MANY / MANY_TO_MANY → SKIP (reconstructed during import via child entities) +``` + +Fields annotated with `@ExportIgnore` are skipped. + +--- + +## 5. Import Pipeline + +### Streaming Strategy + +``` +InputStream (auto-detected: raw or GZIPInputStream) + └── JsonParser (Jackson streaming) + ├── Read header → validate version, note sourceAccountId + ├── Read "account" → AccountDTO (optionally create new Account) + └── Read "entities" → { + For each entityClassName: + resolve class → Class.forName(entityClassName) + For each JSON record in array (chunked): + deserialize → entity instance + resolve _ref_id references via idMappings + set accountId = targetAccountId + persist entity + record originalId → newId in idMappings + } +``` + +### ID Resolution (Identity Mapper) + +``` +KEEP_IDS: + newId = originalId + ref resolution: use originalRefId as-is + +REGENERATE_IDS (default for clone): + newId = null → JPA auto-generates + after persist: record {originalId → generatedId} in idMappings + ref resolution: idMappings[refClass][originalRefId] → resolvedId +``` + +--- + +## 6. Worker Lifecycle + +``` +POST /export/{accountId} + │ + ▼ +AccountMigrationJobService.createExportJob(accountId, options) + │ + ├── 1. Persist AccountMigrationJob{status=PENDING} + ├── 2. SchedulerUtil.runWithResult(new ExportWorker(jobId, accountId, options)) + │ └── Virtual Thread starts + │ ├── Update job status → RUNNING + │ ├── Call ExportPipeline.export(...) + │ │ └── MigrationProgressListener updates job.progress periodically + │ ├── On success: update job status → COMPLETED, set resultPath + │ └── On failure: update job status → FAILED, set errorMessage + └── 3. Return jobId to caller (non-blocking) + +Cancellation: + POST /jobs/{jobId}/cancel + ├── Load job from DB + ├── Get CancellationToken from in-memory registry + ├── token.cancel() + └── Worker's main loop checks token.isCancelled() between chunks → exits gracefully +``` + +--- + +## 7. Export File Format + +``` +saas_export_42_20260614T100500.json[.gz] +``` + +```json +{ + "version": "1", + "exportedAt": "2026-06-14T10:05:00", + "sourceAccountId": 42, + "identityStrategy": "KEEP_IDS", + "account": { + "id": 42, + "name": "Acme Corp", + "subdomain": "acme", + "email": "admin@acme.com", + ... + }, + "entities": { + "tools.dynamia.modules.saas.jpa.AccountParameter": [ + { "id": 1, "accountId": 42, "name": "theme", "value": "dark" } + ], + "com.example.Customer": [ + { "id": 10, "accountId": 42, "name": "John", "category_ref_id": 3 } + ], + "com.example.Order": [ + { "id": 100, "accountId": 42, "customer_ref_id": 10, "total": 99.99 } + ] + } +} +``` + +**Key conventions:** +- `{fieldName}_ref_id` encodes a `@ManyToOne` / `@OneToOne` reference by its primary key. +- The `account` section makes the package self-describing. +- Entities appear in topological order (parents before children). + +--- + +## 8. Identity Mapping SPI + +```java +public interface IdentityMapper { + // Map an original ID to the ID to use when persisting + // Return null → let JPA auto-generate + Object mapId(Object originalId, Class entityClass); + + // Resolve a reference ID from the exported file to the actual ID in the target DB + Object resolveReferenceId(Object originalRefId, Class refClass, + Map> idMappings); + + IdentityStrategy getStrategy(); +} +``` + +Register a custom implementation as a Spring bean (`@Component` / `@Service`) to override the default behaviour for a given strategy. `ImportPipeline` auto-discovers all `IdentityMapper` beans and selects by `getStrategy()` before falling back to the built-in defaults. + +### Built-in Implementations + +| Class | Strategy | Use Case | +|-------|----------|---------- | +| `KeepIdsIdentityMapper` | `KEEP_IDS` | Cross-env restore to empty DB | +| `RegenerateIdsIdentityMapper` | `REGENERATE_IDS` | Clone within same DB | + +> **Note:** `UUID7` is declared in `IdentityStrategy` but not yet implemented. Selecting it throws `MigrationException` (planned for v3). + +--- + +## 9. Annotaions + +### `@AccountExportIgnore` + +```java +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface AccountExportIgnore {} +``` + +Apply to `@Entity` classes that should never be exported (audit logs, metrics, caches). + +### `@ExportIgnore` + +```java +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface ExportIgnore {} +``` + +Apply to entity fields that should be skipped during serialization (computed values, sensitive tokens, caches). + +--- + +## 10. Database Schema + +The module adds one table: + +```sql +CREATE TABLE saas_migration_jobs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + uuid VARCHAR(50) NOT NULL UNIQUE, + account_id BIGINT, + target_account_id BIGINT, + job_type VARCHAR(30), + status VARCHAR(30), + progress INT DEFAULT 0, + progress_message VARCHAR(2000), + created_at DATETIME, + started_at DATETIME, + finished_at DATETIME, + error_message TEXT, + result_path VARCHAR(1000), + options_json VARCHAR(2000) +); +``` + +--- + +## 11. Scalability Notes + +| Concern | Mitigation | +|---------|------------| +| Large tables | Chunked pagination (configurable, default 500 rows/page) | +| Memory | Jackson streaming API: never load all rows | +| Network / disk | Optional GZIP compression | +| Long-running jobs | Virtual threads, cooperative cancellation via `CancellationToken` | +| DB load | Read-only paginated queries; imports batched per chunk | +| Concurrent jobs | In-memory job registry + DB-backed state; configurable max concurrent | + +--- + +## 12. Implementation Roadmap + +| Phase | Scope | +|-------|-------| +| **v1 (current)** | EXPORT, IMPORT, CLONE, BACKUP, RESTORE. `KEEP_IDS` + `REGENERATE_IDS`. REST API. Progress tracking. Cancellation. | +| **v2** | Cross-environment MIGRATE (HTTP push to remote endpoint). Resume after failure (checkpoint in DB). | +| **v3** | `UUID7` identity strategy. Partial export (subset of entities). Schema validation on import. Diff/merge strategy. | +| **v4** | Multi-region database migration. S3/GCS file storage backend. Event-driven progress via SSE/WebSocket. | + diff --git a/extensions/saas/sources/migration/README.md b/extensions/saas/sources/migration/README.md new file mode 100644 index 00000000..ceffd010 --- /dev/null +++ b/extensions/saas/sources/migration/README.md @@ -0,0 +1,202 @@ +# DynamiaTools SaaS — Tenant Mobility Module + +[![Maven Central](https://img.shields.io/maven-central/v/tools.dynamia.modules/tools.dynamia.modules.saas.migration)](https://search.maven.org/search?q=tools.dynamia.modules.saas.migration) +![Java Version Required](https://img.shields.io/badge/java-25-blue) + +The **Tenant Mobility Module** is a sub-module of the SaaS extension that provides full lifecycle management for tenant data: export, import, clone, backup, restore, and cross-environment migration. + +All operations run as **async background jobs** via virtual threads, so long-running processes (millions of rows) never block the application. + +--- + +## Features + +| Operation | Description | +|-----------|-------------| +| `EXPORT` | Serialize all tenant data to a versioned JSON/GZIP file | +| `IMPORT` | Restore tenant data from a previously exported file | +| `CLONE` | Duplicate a tenant in the same system (different accountId) | +| `BACKUP` | Alias for EXPORT tagged as backup | +| `RESTORE` | Alias for IMPORT that replaces existing data | +| `MIGRATE` | Cross-environment export + remote import (planned v2) | + +--- + +## Installation + +```xml + + tools.dynamia.modules + tools.dynamia.modules.saas.migration + 26.6.0 + +``` + +Make sure your JPA entity scan includes `tools.dynamia.modules.saas.migration` (or let Spring Boot's auto-scan pick it up from the classpath). + +--- + +## Quick Start + +### 1. Mark entities to ignore (optional) + +```java +// Suppress entire entity from export/import +@Entity +@AccountExportIgnore +public class LoginAuditLog extends SimpleEntitySaaS { ... } + +// Suppress specific fields +@Entity +public class Customer extends SimpleEntitySaaS { + + @ExportIgnore + private String cachedScore; // computed, do not export +} +``` + +### 2. Launch an async export job via REST + +```http +POST /api/saas/migration/jobs/export/42 +Content-Type: application/json + +{ + "chunkSize": 500, + "compressionEnabled": true, + "identityStrategy": "KEEP_IDS" +} +``` + +Response: +```json +{ + "jobId": "abc-123", + "jobType": "EXPORT", + "status": "PENDING", + "createdAt": "2026-06-14T10:00:00" +} +``` + +### 3. Poll job status + +```http +GET /api/saas/migration/jobs/abc-123 +``` + +### 4. Download result when COMPLETED + +```http +GET /api/saas/migration/jobs/abc-123/download +``` + +### 5. Import a file + +```http +POST /api/saas/migration/jobs/import +Content-Type: multipart/form-data + +file=@tenant_backup.json.gz +targetAccountId=99 +identityStrategy=REGENERATE_IDS +``` + +### 6. Clone a tenant + +```http +POST /api/saas/migration/jobs/clone +Content-Type: application/json + +{ + "sourceAccountId": 42, + "targetAccountId": 99, + "identityStrategy": "REGENERATE_IDS" +} +``` + +--- + +## REST API Reference + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/saas/migration/jobs/export/{accountId}` | Start export job | +| `POST` | `/api/saas/migration/jobs/import` | Start import job (multipart) | +| `POST` | `/api/saas/migration/jobs/clone` | Start clone job | +| `POST` | `/api/saas/migration/jobs/backup/{accountId}` | Start backup job | +| `POST` | `/api/saas/migration/jobs/restore/{accountId}` | Start restore job (multipart) | +| `GET` | `/api/saas/migration/jobs` | List all jobs | +| `GET` | `/api/saas/migration/jobs/{jobId}` | Get job status & progress | +| `POST` | `/api/saas/migration/jobs/{jobId}/cancel` | Cancel a running job | +| `GET` | `/api/saas/migration/jobs/{jobId}/download` | Download result file | + +--- + +## Configuration + +```properties +# saas-migration +dynamia.saas.migration.chunk-size=500 +dynamia.saas.migration.output-directory=${java.io.tmpdir}/saas-migration +dynamia.saas.migration.compression-enabled=false +dynamia.saas.migration.max-concurrent-jobs=5 +dynamia.saas.migration.fail-on-entity-error=false +``` + +--- + +## Identity Strategies + +| Strategy | Description | +|----------|-------------| +| `KEEP_IDS` | Preserve original database IDs. Suitable for cross-environment restore to an empty target DB. | +| `REGENERATE_IDS` | Assign new auto-generated IDs. Safe for cloning within the same DB. | +| `UUID7` | (Planned v3) Use UUIDv7 for all IDs. | + +--- + +## Export Format + +```json +{ + "version": "1", + "exportedAt": "2026-06-14T10:05:00", + "sourceAccountId": 42, + "identityStrategy": "KEEP_IDS", + "account": { "id": 42, "name": "Acme Corp", ... }, + "entities": { + "com.example.Customer": [ + { "id": 1, "accountId": 42, "name": "John", "type_ref_id": 5 }, + ... + ], + "com.example.Order": [ + { "id": 10, "accountId": 42, "customer_ref_id": 1, ... } + ] + } +} +``` + +Relationship fields (`@ManyToOne`, `@OneToOne`) are serialized as `{fieldName}_ref_id` to decouple the export from JPA serialization complexity. Collections (`@OneToMany`, `@ManyToMany`) are reconstructed naturally when child entities reference their parents. + +--- + +## Extension Points (SPI) + +| Interface | Description | +|-----------|-------------| +| `IdentityMapper` | Custom ID mapping strategy | +| `MigrationProgressListener` | Hook for progress events | +| `CancellationToken` | Cooperative cancellation signal | + +--- + +## Architecture + +See [ARCHITECTURE.md](ARCHITECTURE.md) for detailed design decisions, component diagrams, and implementation roadmap. + +--- + +## License + +Apache License 2.0 — Copyright © 2026 Dynamia Soluciones IT S.A.S + diff --git a/extensions/saas/sources/migration/pom.xml b/extensions/saas/sources/migration/pom.xml new file mode 100644 index 00000000..58eb7627 --- /dev/null +++ b/extensions/saas/sources/migration/pom.xml @@ -0,0 +1,139 @@ + + + + + 4.0.0 + + + tools.dynamia.modules + tools.dynamia.modules.saas.parent + 26.6.0 + + + DynamiaModules - SaaS Tenant Mobility (Migration) + tools.dynamia.modules.saas.migration + https://www.dynamia.tools/modules/saas/migration + + Tenant Mobility module for DynamiaTools SaaS. Provides async export, import, clone, + backup and restore of tenant data via streaming JSON with JPA-based entity discovery. + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler} + + ${java.version} + ${java.version} + ${source.encoding} + true + + + + + + + + + + tools.dynamia.modules + tools.dynamia.modules.saas + 26.6.0 + + + + + tools.dynamia.modules + tools.dynamia.modules.saas.api + 26.6.0 + + + + + tools.dynamia.modules + tools.dynamia.modules.saas.jpa + 26.6.0 + + + + + tools.dynamia + tools.dynamia.integration + 26.6.0 + + + + + tools.dynamia + tools.dynamia.domain.jpa + 26.6.0 + + + + + org.hibernate.orm + hibernate-core + + + + + org.springframework.boot + spring-boot-autoconfigure + + + + + tools.jackson.core + jackson-databind + + + + + jakarta.servlet + jakarta.servlet-api + provided + + + jakarta.annotation + jakarta.annotation-api + provided + + + + + junit + junit + ${junit.version} + test + + + + org.mockito + mockito-core + 5.20.0 + test + + + + + + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountCloneOptions.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountCloneOptions.java new file mode 100644 index 00000000..a057a28b --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountCloneOptions.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.api; + +/** + * Options for a clone operation (source tenant → target tenant, same system). + * + * @author Mario Serrano Leones + */ +public class AccountCloneOptions { + + /** ID of the account to clone data from. Required. */ + private Long sourceAccountId; + + /** ID of the (already existing) target account. Required. */ + private Long targetAccountId; + + /** + * Strategy for handling IDs. + * Defaults to {@link IdentityStrategy#REGENERATE_IDS} because clone typically + * happens within the same database. + */ + private IdentityStrategy identityStrategy = IdentityStrategy.REGENERATE_IDS; + + /** Records per page during export/import. Default: 500. */ + private int chunkSize = 500; + + /** + * When {@code true}, entity errors are fatal. When {@code false}, they are + * logged and the clone continues. Default: {@code false}. + */ + private boolean failOnEntityError = false; + + // ─── Fluent builder ──────────────────────────────────────────────────────── + + public AccountCloneOptions source(Long sourceAccountId) { + this.sourceAccountId = sourceAccountId; + return this; + } + + public AccountCloneOptions target(Long targetAccountId) { + this.targetAccountId = targetAccountId; + return this; + } + + public AccountCloneOptions identityStrategy(IdentityStrategy identityStrategy) { + this.identityStrategy = identityStrategy; + return this; + } + + public AccountCloneOptions chunkSize(int chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + // ─── Accessors ───────────────────────────────────────────────────────────── + + public Long getSourceAccountId() { + return sourceAccountId; + } + + public void setSourceAccountId(Long sourceAccountId) { + this.sourceAccountId = sourceAccountId; + } + + public Long getTargetAccountId() { + return targetAccountId; + } + + public void setTargetAccountId(Long targetAccountId) { + this.targetAccountId = targetAccountId; + } + + public IdentityStrategy getIdentityStrategy() { + return identityStrategy; + } + + public void setIdentityStrategy(IdentityStrategy identityStrategy) { + this.identityStrategy = identityStrategy; + } + + public int getChunkSize() { + return chunkSize; + } + + public void setChunkSize(int chunkSize) { + this.chunkSize = chunkSize; + } + + public boolean isFailOnEntityError() { + return failOnEntityError; + } + + public void setFailOnEntityError(boolean failOnEntityError) { + this.failOnEntityError = failOnEntityError; + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountExportOptions.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountExportOptions.java new file mode 100644 index 00000000..105437f0 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountExportOptions.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.api; + +/** + * Options controlling a tenant export operation. + * + * @author Mario Serrano Leones + */ +public class AccountExportOptions { + + /** Number of records to read from DB per pagination page. Default: 500. */ + private int chunkSize = 500; + + /** When {@code true}, the output stream is wrapped in GZIP compression. */ + private boolean compressionEnabled = false; + + /** Controls how IDs are represented in the exported file. */ + private IdentityStrategy identityStrategy = IdentityStrategy.KEEP_IDS; + + /** Optional display name for this export (used in file names and job labels). */ + private String label; + + // ─── Constructors ────────────────────────────────────────────────────────── + + public AccountExportOptions() { + } + + public AccountExportOptions(IdentityStrategy identityStrategy) { + this.identityStrategy = identityStrategy; + } + + // ─── Fluent builder ──────────────────────────────────────────────────────── + + public AccountExportOptions chunkSize(int chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + public AccountExportOptions compressionEnabled(boolean compressionEnabled) { + this.compressionEnabled = compressionEnabled; + return this; + } + + public AccountExportOptions identityStrategy(IdentityStrategy identityStrategy) { + this.identityStrategy = identityStrategy; + return this; + } + + public AccountExportOptions label(String label) { + this.label = label; + return this; + } + + // ─── Accessors ───────────────────────────────────────────────────────────── + + public int getChunkSize() { + return chunkSize; + } + + public void setChunkSize(int chunkSize) { + this.chunkSize = chunkSize; + } + + public boolean isCompressionEnabled() { + return compressionEnabled; + } + + public void setCompressionEnabled(boolean compressionEnabled) { + this.compressionEnabled = compressionEnabled; + } + + public IdentityStrategy getIdentityStrategy() { + return identityStrategy; + } + + public void setIdentityStrategy(IdentityStrategy identityStrategy) { + this.identityStrategy = identityStrategy; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountImportOptions.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountImportOptions.java new file mode 100644 index 00000000..3a8a1a53 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountImportOptions.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.api; + +/** + * Options controlling a tenant import operation. + * + * @author Mario Serrano Leones + */ +public class AccountImportOptions { + + /** + * Target account ID. + * When {@code null}, the import will attempt to create a new account from + * the {@code account} section of the export file. + */ + private Long targetAccountId; + + /** How to handle primary keys when persisting imported entities. */ + private IdentityStrategy identityStrategy = IdentityStrategy.REGENERATE_IDS; + + /** Number of entities to persist per transaction batch. Default: 500. */ + private int chunkSize = 500; + + /** + * When {@code true}, the import fails immediately if any entity cannot be + * persisted. When {@code false}, errors are logged and the import continues. + */ + private boolean failOnEntityError = false; + + // ─── Fluent builder ──────────────────────────────────────────────────────── + + public AccountImportOptions targetAccountId(Long targetAccountId) { + this.targetAccountId = targetAccountId; + return this; + } + + public AccountImportOptions identityStrategy(IdentityStrategy identityStrategy) { + this.identityStrategy = identityStrategy; + return this; + } + + public AccountImportOptions chunkSize(int chunkSize) { + this.chunkSize = chunkSize; + return this; + } + + public AccountImportOptions failOnEntityError(boolean failOnEntityError) { + this.failOnEntityError = failOnEntityError; + return this; + } + + // ─── Accessors ───────────────────────────────────────────────────────────── + + public Long getTargetAccountId() { + return targetAccountId; + } + + public void setTargetAccountId(Long targetAccountId) { + this.targetAccountId = targetAccountId; + } + + public IdentityStrategy getIdentityStrategy() { + return identityStrategy; + } + + public void setIdentityStrategy(IdentityStrategy identityStrategy) { + this.identityStrategy = identityStrategy; + } + + public int getChunkSize() { + return chunkSize; + } + + public void setChunkSize(int chunkSize) { + this.chunkSize = chunkSize; + } + + public boolean isFailOnEntityError() { + return failOnEntityError; + } + + public void setFailOnEntityError(boolean failOnEntityError) { + this.failOnEntityError = failOnEntityError; + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobDto.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobDto.java new file mode 100644 index 00000000..0a9a2980 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobDto.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.api; + +import tools.dynamia.modules.saas.migration.domain.AccountJobStatus; +import tools.dynamia.modules.saas.migration.domain.AccountJobType; +import tools.dynamia.modules.saas.migration.domain.AccountMigrationJob; + +import java.time.LocalDateTime; + +/** + * Read-only DTO representing the state of a {@link AccountMigrationJob}. + * Returned by REST endpoints. + * + * @author Mario Serrano Leones + */ +public record AccountMigrationJobDto( + Long id, + String uuid, + Long accountId, + Long targetAccountId, + AccountJobType jobType, + AccountJobStatus status, + int progress, + String progressMessage, + String errorMessage, + String downloadUrl, + LocalDateTime createdAt, + LocalDateTime startedAt, + LocalDateTime finishedAt +) { + + /** Convenience: returns {@code true} when the job has reached a terminal state. */ + public boolean isFinished() { + return status == AccountJobStatus.COMPLETED + || status == AccountJobStatus.FAILED + || status == AccountJobStatus.CANCELLED; + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobService.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobService.java new file mode 100644 index 00000000..f75c9bee --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationJobService.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.api; + +import org.springframework.web.multipart.MultipartFile; +import tools.dynamia.modules.saas.migration.domain.AccountMigrationJob; + +import java.util.List; + +/** + * Async job management service for tenant mobility operations. + * + *

Each method creates a {@link AccountMigrationJob} record, launches the operation + * as a background virtual thread via {@code SchedulerUtil.runWithResult()}, and returns + * the job DTO immediately (non-blocking). + * + *

{@code
+ * TenantMobilityJobDto job = jobService.createExportJob(42L, new TenantExportOptions());
+ * // poll status:
+ * TenantMobilityJobDto status = jobService.getJob(job.uuid());
+ * }
+ * + * @author Mario Serrano Leones + */ +public interface AccountMigrationJobService { + + /** + * Starts an async export job for the given account. + * + * @param accountId ID of the account to export + * @param options export configuration + * @return the newly created (PENDING) job + */ + AccountMigrationJobDto createExportJob(Long accountId, AccountExportOptions options); + + /** + * Starts an async import job from an uploaded file. + * + * @param file multipart upload containing the export JSON (or .json.gz) + * @param options import configuration (target account, identity strategy, etc.) + * @return the newly created (PENDING) job + */ + AccountMigrationJobDto createImportJob(MultipartFile file, AccountImportOptions options); + + /** + * Starts an async clone job (source tenant → target tenant, same system). + * + * @param options clone configuration + * @return the newly created (PENDING) job + */ + AccountMigrationJobDto createCloneJob(AccountCloneOptions options); + + /** + * Starts an async backup job (semantically equivalent to export with BACKUP type label). + * + * @param accountId ID of the account to back up + * @return the newly created (PENDING) job + */ + AccountMigrationJobDto createBackupJob(Long accountId); + + /** + * Starts an async restore job from an uploaded file + * (semantically equivalent to import with RESTORE type label). + * + * @param accountId target account to restore into + * @param file multipart upload + * @return the newly created (PENDING) job + */ + AccountMigrationJobDto createRestoreJob(Long accountId, MultipartFile file); + + /** + * Returns the current state of the job identified by {@code jobUuid}. + * + * @param jobUuid UUID of the job + * @return job DTO or {@code null} if not found + */ + AccountMigrationJobDto getJob(String jobUuid); + + /** + * Returns the raw {@link AccountMigrationJob} entity for internal use (e.g. file download). + * + * @param jobUuid UUID of the job + * @return entity or {@code null} + */ + AccountMigrationJob getJobEntity(String jobUuid); + + /** + * Lists all known jobs, optionally filtered by account. + * + * @param accountId filter by account; pass {@code null} to return all jobs + * @return list of jobs ordered by creation date descending + */ + List listJobs(Long accountId); + + /** + * Requests cooperative cancellation of a running job. + *

+ * The pipeline will stop at the next chunk boundary. If the job is already + * finished (COMPLETED/FAILED/CANCELLED), this is a no-op. + * + * @param jobUuid UUID of the job to cancel + */ + void cancelJob(String jobUuid); +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationService.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationService.java new file mode 100644 index 00000000..8384e1e9 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/AccountMigrationService.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.api; + +import java.io.InputStream; +import java.io.OutputStream; + +/** + * High-level service for executing tenant mobility operations synchronously. + * + *

This service is designed to be called from within a worker/job that is already + * running in a background virtual thread. For async job management see + * {@link AccountMigrationJobService}. + * + *

{@code
+ * // Direct usage (synchronous — blocks until complete):
+ * mobilityService.exportTenant(42L, outputStream, new TenantExportOptions(), listener, token);
+ *
+ * // Preferred usage (async via job service):
+ * jobService.createExportJob(42L, new TenantExportOptions());
+ * }
+ * + * @author Mario Serrano Leones + */ +public interface AccountMigrationService { + + /** + * Exports all tenant data for {@code accountId} to the given {@code output} stream. + * + * @param accountId account whose data will be exported + * @param output destination stream (may be wrapped in GZIP by the pipeline if configured) + * @param options export configuration + * @param listener optional progress callback; may be {@code null} + * @param token optional cancellation token; may be {@code null} + */ + void exportTenant(Long accountId, + OutputStream output, + AccountExportOptions options, + MigrationProgressListener listener, + CancellationToken token); + + /** + * Imports tenant data from an exported {@code input} stream. + * + * @param input source stream (GZIP-encoded or plain JSON) + * @param options import configuration, including target account and identity strategy + * @param listener optional progress callback; may be {@code null} + * @param token optional cancellation token; may be {@code null} + */ + void importTenant(InputStream input, + AccountImportOptions options, + MigrationProgressListener listener, + CancellationToken token); + + /** + * Clones a tenant within the same system by exporting to an in-memory buffer + * and immediately importing to the target account. + * + * @param options clone configuration (source/target accounts, identity strategy, etc.) + * @param listener optional progress callback; may be {@code null} + * @param token optional cancellation token; may be {@code null} + */ + void cloneTenant(AccountCloneOptions options, + MigrationProgressListener listener, + CancellationToken token); +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/CancellationToken.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/CancellationToken.java new file mode 100644 index 00000000..9abfb04b --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/CancellationToken.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.api; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Cooperative cancellation signal for long-running migration pipelines. + * + *

The pipeline checks {@link #isCancelled()} between processing chunks. + * When cancellation is requested, the pipeline exits cleanly at the next checkpoint. + * + *

{@code
+ * CancellationToken token = new CancellationToken();
+ * // Pass to pipeline; later, from another thread:
+ * token.cancel();
+ * }
+ * + * @author Mario Serrano Leones + */ +public class CancellationToken { + + private final AtomicBoolean cancelled = new AtomicBoolean(false); + private volatile String reason; + + /** Signals the pipeline to stop at the next checkpoint. */ + public void cancel() { + cancel("Cancelled by user"); + } + + /** + * Signals cancellation with a reason message. + * + * @param reason human-readable reason for cancellation + */ + public void cancel(String reason) { + this.reason = reason; + this.cancelled.set(true); + } + + /** + * Returns {@code true} if cancellation has been requested. + * Pipelines should check this between chunks and exit gracefully. + */ + public boolean isCancelled() { + return cancelled.get(); + } + + /** Returns the cancellation reason, or {@code null} if not cancelled. */ + public String getReason() { + return reason; + } + + /** Factory method for convenience. */ + public static CancellationToken active() { + return new CancellationToken(); + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityMapper.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityMapper.java new file mode 100644 index 00000000..e0a4916c --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityMapper.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.api; + +import java.util.Map; + +/** + * SPI for controlling how entity primary keys are handled during import. + * + *

The default implementation is {@link tools.dynamia.modules.saas.migration.identity.RegenerateIdsIdentityMapper} + * which assigns new JPA-generated IDs and resolves internal references via an + * {@code idMappings} table ({@code entityClass.name → {originalId → newId}}). + * + *

Implement and register this interface as a Spring bean to override the default behaviour. + * + * @author Mario Serrano Leones + */ +public interface IdentityMapper { + + /** + * Determines the ID to use when persisting an imported entity. + * + * @param originalId the ID read from the export file; may be null + * @param entityClass the JPA entity class being imported + * @return the ID to assign before persisting, or {@code null} to let JPA auto-generate + */ + Object mapId(Object originalId, Class entityClass); + + /** + * Resolves a foreign-key reference ID from the export file to the correct ID + * in the target database, using the running ID mapping table. + * + * @param originalRefId the reference ID read from the export ({@code fieldName_ref_id}) + * @param refClass the referenced entity class + * @param idMappings mutable map of {@code className → {originalId → newId}}; updated during import + * @return the actual ID to use when creating the JPA reference proxy + */ + Object resolveReferenceId(Object originalRefId, Class refClass, + Map> idMappings); + + /** Returns the strategy implemented by this mapper. */ + IdentityStrategy getStrategy(); +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityStrategy.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityStrategy.java new file mode 100644 index 00000000..3f8f58c7 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/IdentityStrategy.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.api; + +/** + * Controls how entity primary keys are handled during import. + * + * @author Mario Serrano Leones + */ +public enum IdentityStrategy { + + /** + * Preserve original database IDs. + *

+ * The imported entities will have the same primary keys as the source system. + * This is safe when restoring to an empty target database. + * It may cause constraint violations if the target DB already contains data. + */ + KEEP_IDS, + + /** + * Auto-generate new IDs for all imported entities. + *

+ * JPA auto-generation is used for each entity. Foreign-key references are + * resolved via the internal ID mapping table ({@code originalId → newId}). + * This is the recommended strategy for cloning within the same database. + */ + REGENERATE_IDS, + + /** + * Assign UUIDv7 values as new IDs. + *

+ * Planned for v3. + */ + UUID7 +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationException.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationException.java new file mode 100644 index 00000000..ab26c2de --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationException.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.api; + +/** + * Unchecked exception thrown when a migration pipeline encounters an unrecoverable error. + * + * @author Mario Serrano Leones + */ +public class MigrationException extends RuntimeException { + + public MigrationException(String message) { + super(message); + } + + public MigrationException(String message, Throwable cause) { + super(message, cause); + } + + public MigrationException(Throwable cause) { + super(cause); + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgress.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgress.java new file mode 100644 index 00000000..05a05ce3 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgress.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.api; + +/** + * Carries progress information during a tenant migration operation. + * + * @param processedRecords Total records processed so far. + * @param totalRecords Total records expected (0 if unknown). + * @param message Human-readable description of the current step. + * @author Mario Serrano Leones + */ +public record MigrationProgress(long processedRecords, long totalRecords, String message) { + + /** Returns the progress as a percentage (0–100), or -1 if total is unknown. */ + public int percentage() { + if (totalRecords <= 0) return -1; + return (int) Math.min(100, (processedRecords * 100L) / totalRecords); + } + + @Override + public String toString() { + if (totalRecords > 0) { + return "[%d%%] %s (%d / %d)".formatted(percentage(), message, processedRecords, totalRecords); + } + return "[?] %s (%d processed)".formatted(message, processedRecords); + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgressListener.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgressListener.java new file mode 100644 index 00000000..a8b090ee --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/api/MigrationProgressListener.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.api; + +/** + * SPI callback for receiving progress updates during a migration pipeline execution. + * + *

Implementations are typically provided by the job service to persist progress + * in the {@code TenantMobilityJob} entity. + * + * @author Mario Serrano Leones + */ +@FunctionalInterface +public interface MigrationProgressListener { + + /** + * Called by the pipeline whenever significant progress has been made. + * + * @param progress current progress snapshot + */ + void onProgress(MigrationProgress progress); +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationConfig.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationConfig.java new file mode 100644 index 00000000..ac6c39a5 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationConfig.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Spring Boot auto-configuration for the Tenant Mobility module. + * + *

Registers: + *

+ * + * @author Mario Serrano Leones + */ +@Configuration +@EnableConfigurationProperties(AccountMigrationProperties.class) +public class AccountMigrationConfig { + + private final AccountMigrationProperties properties; + + public AccountMigrationConfig(AccountMigrationProperties properties) { + this.properties = properties; + initOutputDirectory(); + } + + /** + * Dedicated Jackson {@link ObjectMapper} for the migration pipelines. + * + *

Configured to: + *

+ * + *

This bean is named {@code migrationObjectMapper} so it does not conflict + * with any other {@code ObjectMapper} in the application context. + */ + @Bean("migrationObjectMapper") + @ConditionalOnMissingBean(name = "migrationObjectMapper") + public ObjectMapper migrationObjectMapper() { + return JsonMapper.builder() + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .changeDefaultPropertyInclusion( + i -> i.withValueInclusion(JsonInclude.Include.NON_NULL)) + .build(); + } + + private void initOutputDirectory() { + try { + Path dir = Path.of(properties.getOutputDirectory()); + if (!Files.exists(dir)) { + Files.createDirectories(dir); + } + } catch (IOException e) { + throw new RuntimeException( + "Cannot create migration output directory: " + properties.getOutputDirectory(), e); + } + } +} + + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationProperties.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationProperties.java new file mode 100644 index 00000000..43334185 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/config/AccountMigrationProperties.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for the Tenant Mobility module. + * + *

All properties are prefixed with {@code dynamia.saas.migration}. + * + *

Example {@code application.properties}: + *

+ * dynamia.saas.migration.chunk-size=500
+ * dynamia.saas.migration.output-directory=/var/data/saas-migration
+ * dynamia.saas.migration.compression-enabled=false
+ * dynamia.saas.migration.max-concurrent-jobs=5
+ * dynamia.saas.migration.fail-on-entity-error=false
+ * 
+ * + * @author Mario Serrano Leones + */ +@ConfigurationProperties(prefix = "dynamia.saas.migration") +public class AccountMigrationProperties { + + /** Number of records read/written per pagination page. Default: 500. */ + private int chunkSize = 500; + + /** + * Directory where export/backup files are stored. + * Defaults to {@code ${java.io.tmpdir}/saas-migration}. + */ + private String outputDirectory = System.getProperty("java.io.tmpdir") + "/saas-migration"; + + /** Whether to compress output files with GZIP by default. Default: {@code false}. */ + private boolean compressionEnabled = false; + + /** + * Maximum number of jobs that can be in RUNNING state simultaneously. + * Additional jobs remain in PENDING and are started as running jobs finish. + * Default: 5. + */ + private int maxConcurrentJobs = 5; + + /** + * If {@code true}, the import pipeline stops immediately when any entity + * fails to persist. If {@code false}, errors are logged and the import + * continues. Default: {@code false}. + */ + private boolean failOnEntityError = false; + + // ─── Accessors ───────────────────────────────────────────────────────────── + + public int getChunkSize() { + return chunkSize; + } + + public void setChunkSize(int chunkSize) { + this.chunkSize = chunkSize; + } + + public String getOutputDirectory() { + return outputDirectory; + } + + public void setOutputDirectory(String outputDirectory) { + this.outputDirectory = outputDirectory; + } + + public boolean isCompressionEnabled() { + return compressionEnabled; + } + + public void setCompressionEnabled(boolean compressionEnabled) { + this.compressionEnabled = compressionEnabled; + } + + public int getMaxConcurrentJobs() { + return maxConcurrentJobs; + } + + public void setMaxConcurrentJobs(int maxConcurrentJobs) { + this.maxConcurrentJobs = maxConcurrentJobs; + } + + public boolean isFailOnEntityError() { + return failOnEntityError; + } + + public void setFailOnEntityError(boolean failOnEntityError) { + this.failOnEntityError = failOnEntityError; + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/AccountMigrationRestController.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/AccountMigrationRestController.java new file mode 100644 index 00000000..5c957164 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/controllers/AccountMigrationRestController.java @@ -0,0 +1,250 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.controllers; + +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import tools.dynamia.modules.saas.migration.api.AccountCloneOptions; +import tools.dynamia.modules.saas.migration.api.AccountExportOptions; +import tools.dynamia.modules.saas.migration.api.AccountImportOptions; +import tools.dynamia.modules.saas.migration.api.AccountMigrationJobDto; +import tools.dynamia.modules.saas.migration.api.AccountMigrationJobService; +import tools.dynamia.modules.saas.migration.domain.AccountMigrationJob; + +import java.io.File; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; + +/** + * REST API for the Tenant Mobility module. + * + *

Endpoints

+ *
+ * POST  /api/saas/migration/jobs/export/{accountId}         → start export job
+ * POST  /api/saas/migration/jobs/import                      → start import job (multipart)
+ * POST  /api/saas/migration/jobs/clone                       → start clone job
+ * POST  /api/saas/migration/jobs/backup/{accountId}          → start backup job
+ * POST  /api/saas/migration/jobs/restore/{accountId}         → start restore job (multipart)
+ * GET   /api/saas/migration/jobs                             → list all jobs
+ * GET   /api/saas/migration/jobs/{jobId}                     → get job status
+ * POST  /api/saas/migration/jobs/{jobId}/cancel              → cancel a running job
+ * GET   /api/saas/migration/jobs/{jobId}/download            → download result file
+ * 
+ * + *

Note: Authorization is NOT enforced by this controller — the host + * application is responsible for securing these endpoints (e.g., via Spring Security, + * admin role checks, or API key filtering). These endpoints deal with raw tenant data + * and should be restricted to system administrators only. + * + * @author Mario Serrano Leones + */ +@RestController +@RequestMapping(value = "/api/saas/migration", produces = MediaType.APPLICATION_JSON_VALUE) +public class AccountMigrationRestController { + + private final AccountMigrationJobService jobService; + + public AccountMigrationRestController(AccountMigrationJobService jobService) { + this.jobService = jobService; + } + + // ───────────────────────────────────────────────────────────────────────── + // Export + // ───────────────────────────────────────────────────────────────────────── + + /** + * Start an export job for the specified account. + * + *

Request body (optional JSON): + *

{@code
+     * {
+     *   "chunkSize": 500,
+     *   "compressionEnabled": true,
+     *   "identityStrategy": "KEEP_IDS"
+     * }
+     * }
+ */ + @PostMapping("/jobs/export/{accountId}") + public ResponseEntity startExport( + @PathVariable Long accountId, + @RequestBody(required = false) AccountExportOptions options) { + + AccountExportOptions opts = options != null ? options : new AccountExportOptions(); + AccountMigrationJobDto job = jobService.createExportJob(accountId, opts); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(job); + } + + // ───────────────────────────────────────────────────────────────────────── + // Import + // ───────────────────────────────────────────────────────────────────────── + + /** + * Start an import job from an uploaded export file. + * + *

Form fields: + *

+ */ + @PostMapping(value = "/jobs/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity startImport( + @RequestParam("file") MultipartFile file, + @RequestParam(required = false) Long targetAccountId, + @RequestParam(required = false) String identityStrategy, + @RequestParam(required = false, defaultValue = "0") int chunkSize) { + + AccountImportOptions options = new AccountImportOptions() + .targetAccountId(targetAccountId) + .chunkSize(chunkSize > 0 ? chunkSize : 500); + + if (identityStrategy != null) { + try { + options.setIdentityStrategy( + tools.dynamia.modules.saas.migration.api.IdentityStrategy.valueOf(identityStrategy)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } + } + + AccountMigrationJobDto job = jobService.createImportJob(file, options); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(job); + } + + // ───────────────────────────────────────────────────────────────────────── + // Clone + // ───────────────────────────────────────────────────────────────────────── + + /** + * Start a clone job (source tenant → target tenant, same system). + * + *

Request body: + *

{@code
+     * {
+     *   "sourceAccountId": 42,
+     *   "targetAccountId": 99,
+     *   "identityStrategy": "REGENERATE_IDS",
+     *   "chunkSize": 500
+     * }
+     * }
+ */ + @PostMapping("/jobs/clone") + public ResponseEntity startClone( + @RequestBody AccountCloneOptions options) { + + if (options.getSourceAccountId() == null || options.getTargetAccountId() == null) { + return ResponseEntity.badRequest().build(); + } + AccountMigrationJobDto job = jobService.createCloneJob(options); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(job); + } + + // ───────────────────────────────────────────────────────────────────────── + // Backup / Restore + // ───────────────────────────────────────────────────────────────────────── + + /** Start a backup job (export with BACKUP type label and compression). */ + @PostMapping("/jobs/backup/{accountId}") + public ResponseEntity startBackup(@PathVariable Long accountId) { + AccountMigrationJobDto job = jobService.createBackupJob(accountId); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(job); + } + + /** Start a restore job from an uploaded export file. */ + @PostMapping(value = "/jobs/restore/{accountId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity startRestore( + @PathVariable Long accountId, + @RequestParam("file") MultipartFile file) { + + AccountMigrationJobDto job = jobService.createRestoreJob(accountId, file); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(job); + } + + // ───────────────────────────────────────────────────────────────────────── + // Job management + // ───────────────────────────────────────────────────────────────────────── + + /** + * List all jobs. Pass {@code ?accountId=X} to filter by account. + */ + @GetMapping("/jobs") + public ResponseEntity> listJobs( + @RequestParam(required = false) Long accountId) { + return ResponseEntity.ok(jobService.listJobs(accountId)); + } + + /** Get the current status of a job by its UUID. */ + @GetMapping("/jobs/{jobId}") + public ResponseEntity getJob(@PathVariable String jobId) { + AccountMigrationJobDto job = jobService.getJob(jobId); + if (job == null) return ResponseEntity.notFound().build(); + return ResponseEntity.ok(job); + } + + /** Request cancellation of a running job. Idempotent. */ + @PostMapping("/jobs/{jobId}/cancel") + public ResponseEntity> cancelJob(@PathVariable String jobId) { + AccountMigrationJobDto job = jobService.getJob(jobId); + if (job == null) return ResponseEntity.notFound().build(); + jobService.cancelJob(jobId); + return ResponseEntity.ok(Map.of("message", "Cancellation requested for job " + jobId)); + } + + // ───────────────────────────────────────────────────────────────────────── + // File download + // ───────────────────────────────────────────────────────────────────────── + + /** + * Download the result file of a completed EXPORT or BACKUP job. + * Returns 404 if the job is not found, not completed, or has no result file. + */ + @GetMapping("/jobs/{jobId}/download") + public ResponseEntity downloadResult(@PathVariable String jobId) { + AccountMigrationJob job = jobService.getJobEntity(jobId); + if (job == null || job.getResultPath() == null) { + return ResponseEntity.notFound().build(); + } + + File resultFile = Paths.get(job.getResultPath()).toFile(); + if (!resultFile.exists()) { + return ResponseEntity.notFound().build(); + } + + String contentType = job.getResultPath().endsWith(".gz") + ? "application/gzip" + : "application/json"; + + String filename = resultFile.getName(); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + filename + "\"") + .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resultFile.length())) + .body(new FileSystemResource(resultFile)); + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/discovery/AccountEntityDiscovery.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/discovery/AccountEntityDiscovery.java new file mode 100644 index 00000000..42a36af2 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/discovery/AccountEntityDiscovery.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.discovery; + +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.metamodel.EntityType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tools.dynamia.integration.sterotypes.Service; +import tools.dynamia.modules.saas.api.AccountAware; +import tools.dynamia.modules.saas.api.AccountExportIgnore; +import tools.dynamia.modules.saas.domain.Account; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * Discovers all JPA entity classes that participate in the Tenant Mobility export/import. + * + *

Discovery algorithm

+ *
    + *
  1. Retrieves all managed entity types from {@link EntityManagerFactory#getMetamodel()}.
  2. + *
  3. Filters to classes that implement {@link AccountAware}.
  4. + *
  5. Excludes classes annotated with {@link AccountExportIgnore}.
  6. + *
  7. Always includes {@link Account} (the tenant root), regardless of the above filters.
  8. + *
+ * + * @author Mario Serrano Leones + */ +@Service +public class AccountEntityDiscovery { + + private static final Logger log = LoggerFactory.getLogger(AccountEntityDiscovery.class); + + private final EntityManagerFactory emf; + + public AccountEntityDiscovery(EntityManagerFactory emf) { + this.emf = emf; + } + + /** + * Returns the list of entity classes that should be included in the export. + * The list is not sorted; call + * {@link tools.dynamia.modules.saas.migration.graph.EntityDependencyGraph#topologicalSort(List)} + * to obtain the correct import order. + */ + public List> discoverExportableEntities() { + Set> managedTypes = emf.getMetamodel().getEntities(); + List> exportable = new ArrayList<>(); + + // Always include Account as the tenant root + exportable.add(Account.class); + log.debug("[Migration] Always including: {}", Account.class.getName()); + + for (EntityType entityType : managedTypes) { + Class javaType = entityType.getJavaType(); + + // Skip Account itself (already added above) + if (Account.class.equals(javaType)) { + continue; + } + + // Skip entities not annotated as tenant-aware + if (!AccountAware.class.isAssignableFrom(javaType)) { + continue; + } + + // Skip entities explicitly excluded from export + if (javaType.isAnnotationPresent(AccountExportIgnore.class)) { + log.debug("[Migration] Skipping @AccountExportIgnore entity: {}", javaType.getName()); + continue; + } + + exportable.add(javaType); + log.debug("[Migration] Discovered exportable entity: {}", javaType.getName()); + } + + log.info("[Migration] Discovered {} exportable entity types", exportable.size()); + return exportable; + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobStatus.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobStatus.java new file mode 100644 index 00000000..6688409d --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobStatus.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.domain; + +/** + * Lifecycle status of a {@link AccountMigrationJob}. + * + * @author Mario Serrano Leones + */ +public enum AccountJobStatus { + + /** Job has been created but not started yet. */ + PENDING, + + /** Job is currently executing in a background virtual thread. */ + RUNNING, + + /** Job finished successfully. Result file is available for download. */ + COMPLETED, + + /** Job failed with an error. See {@link AccountMigrationJob#getErrorMessage()}. */ + FAILED, + + /** Job was cancelled by the user before it completed. */ + CANCELLED +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobType.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobType.java new file mode 100644 index 00000000..7cad29af --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountJobType.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.domain; + +/** + * Type of a {@link AccountMigrationJob}. + * + * @author Mario Serrano Leones + */ +public enum AccountJobType { + + /** Export all tenant data to a JSON/GZIP file. */ + EXPORT, + + /** Import tenant data from a previously exported file. */ + IMPORT, + + /** Clone a tenant within the same system (source → target accountId). */ + CLONE, + + /** Export tagged as a backup (semantically identical to EXPORT). */ + BACKUP, + + /** Import that replaces existing tenant data (semantically identical to IMPORT). */ + RESTORE, + + /** + * Cross-environment migration: export locally + push to a remote endpoint. + * Planned for v2. + */ + MIGRATE +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java new file mode 100644 index 00000000..a4add282 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/domain/AccountMigrationJob.java @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import tools.dynamia.commons.StringUtils; +import tools.dynamia.domain.jpa.SimpleEntity; +import tools.dynamia.modules.saas.migration.api.AccountExportOptions; +import tools.dynamia.modules.saas.migration.api.AccountImportOptions; + +import java.time.LocalDateTime; + +/** + * Persisted record of a tenant mobility operation (export, import, clone, backup, restore). + * + *

Each row tracks the full lifecycle: PENDING → RUNNING → COMPLETED / FAILED / CANCELLED. + * + *

This entity is NOT {@code AccountAware} intentionally — it is a system-level record + * and must not be exported alongside tenant data. + * + * @author Mario Serrano Leones + */ +@Entity +@Table(name = "saas_migration_jobs") +public class AccountMigrationJob extends SimpleEntity { + + // ─── Identity ────────────────────────────────────────────────────────────── + + /** Stable external identifier (URL-safe, used in REST paths). */ + @Column(nullable = false, unique = true, length = 64) + private String uuid = StringUtils.randomString(); + + // ─── Tenant references ───────────────────────────────────────────────────── + + /** Source tenant account ID. */ + private Long accountId; + + /** Target tenant account ID (used for clone/restore operations). */ + private Long targetAccountId; + + // ─── Classification ──────────────────────────────────────────────────────── + + @Enumerated(EnumType.STRING) + @Column(length = 30) + private AccountJobType jobType; + + @Enumerated(EnumType.STRING) + @Column(length = 30) + private AccountJobStatus status = AccountJobStatus.PENDING; + + // ─── Progress ────────────────────────────────────────────────────────────── + + /** Completion percentage 0–100. */ + private int progress; + + @Column(length = 2000) + private String progressMessage; + + // ─── Timestamps ──────────────────────────────────────────────────────────── + + private LocalDateTime createdAt = LocalDateTime.now(); + private LocalDateTime startedAt; + private LocalDateTime finishedAt; + + // ─── Results & errors ────────────────────────────────────────────────────── + + @Column(columnDefinition = "TEXT") + private String errorMessage; + + /** Absolute path to the result file on disk (EXPORT / BACKUP jobs). */ + @Column(length = 1000) + private String resultPath; + + /** Serialized {@link AccountExportOptions} + * or {@link AccountImportOptions} as JSON. */ + @Column(length = 4000) + private String optionsJson; + + // ─── Helpers ─────────────────────────────────────────────────────────────── + + /** Mark the job as started. */ + public void markRunning() { + this.status = AccountJobStatus.RUNNING; + this.startedAt = LocalDateTime.now(); + } + + /** Mark the job as successfully completed. */ + public void markCompleted() { + this.status = AccountJobStatus.COMPLETED; + this.finishedAt = LocalDateTime.now(); + this.progress = 100; + } + + /** Mark the job as failed with an error message. */ + public void markFailed(String errorMessage) { + this.status = AccountJobStatus.FAILED; + this.finishedAt = LocalDateTime.now(); + this.errorMessage = errorMessage; + } + + /** Mark the job as cancelled. */ + public void markCancelled(String reason) { + this.status = AccountJobStatus.CANCELLED; + this.finishedAt = LocalDateTime.now(); + this.progressMessage = reason; + } + + /** Update running progress (0-100) and an optional human-readable message. */ + public void updateProgress(int progress, String message) { + this.progress = Math.min(100, Math.max(0, progress)); + this.progressMessage = message; + } + + /** Returns {@code true} if the job is in a terminal state. */ + public boolean isFinished() { + return status == AccountJobStatus.COMPLETED + || status == AccountJobStatus.FAILED + || status == AccountJobStatus.CANCELLED; + } + + // ─── Accessors ───────────────────────────────────────────────────────────── + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + public Long getAccountId() { + return accountId; + } + + public void setAccountId(Long accountId) { + this.accountId = accountId; + } + + public Long getTargetAccountId() { + return targetAccountId; + } + + public void setTargetAccountId(Long targetAccountId) { + this.targetAccountId = targetAccountId; + } + + public AccountJobType getJobType() { + return jobType; + } + + public void setJobType(AccountJobType jobType) { + this.jobType = jobType; + } + + public AccountJobStatus getStatus() { + return status; + } + + public void setStatus(AccountJobStatus status) { + this.status = status; + } + + public int getProgress() { + return progress; + } + + public void setProgress(int progress) { + this.progress = progress; + } + + public String getProgressMessage() { + return progressMessage; + } + + public void setProgressMessage(String progressMessage) { + this.progressMessage = progressMessage; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getStartedAt() { + return startedAt; + } + + public void setStartedAt(LocalDateTime startedAt) { + this.startedAt = startedAt; + } + + public LocalDateTime getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(LocalDateTime finishedAt) { + this.finishedAt = finishedAt; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getResultPath() { + return resultPath; + } + + public void setResultPath(String resultPath) { + this.resultPath = resultPath; + } + + public String getOptionsJson() { + return optionsJson; + } + + public void setOptionsJson(String optionsJson) { + this.optionsJson = optionsJson; + } + + @Override + public String toString() { + return "TenantMobilityJob{uuid=" + uuid + ", type=" + jobType + ", status=" + status + "}"; + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraph.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraph.java new file mode 100644 index 00000000..c149cda9 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraph.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.graph; + +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.metamodel.Attribute.PersistentAttributeType; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.SingularAttribute; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tools.dynamia.integration.sterotypes.Service; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; + +/** + * Builds a directed dependency graph of JPA entity classes and produces a + * topologically sorted import order (parents before children). + * + *

Graph construction

+ * For each entity {@code E} in the input set, every {@code @ManyToOne} / + * {@code @OneToOne} attribute that references another entity {@code P} in the + * same set creates a directed edge {@code P → E} (P must be imported before E). + * + *

Topological sort

+ * Kahn's BFS algorithm is used. If a cycle is detected (unlikely with + * well-formed JPA models), the remaining nodes are appended in their original + * discovery order so the pipeline can still proceed. + * + * @author Mario Serrano Leones + */ +@Service +public class EntityDependencyGraph { + + private static final Logger log = LoggerFactory.getLogger(EntityDependencyGraph.class); + + private final EntityManagerFactory emf; + + public EntityDependencyGraph(EntityManagerFactory emf) { + this.emf = emf; + } + + /** + * Returns the input list sorted so that every entity appears after all + * entities it references (i.e., safe insert order). + * + * @param entityClasses the set of entity classes to sort + * @return a new list in topological order + */ + public List> topologicalSort(List> entityClasses) { + if (entityClasses == null || entityClasses.isEmpty()) { + return Collections.emptyList(); + } + + Set> classSet = new HashSet<>(entityClasses); + + // adjacency: node → set of nodes that depend on it (predecessors of edges) + Map, Set>> dependents = new HashMap<>(); + // in-degree: how many entities this entity depends on (within our set) + Map, Integer> inDegree = new HashMap<>(); + + for (Class clazz : entityClasses) { + dependents.putIfAbsent(clazz, new HashSet<>()); + inDegree.putIfAbsent(clazz, 0); + } + + // Build graph from JPA metamodel + for (Class child : entityClasses) { + try { + EntityType entityType = emf.getMetamodel().entity(child); + for (SingularAttribute attr : entityType.getSingularAttributes()) { + PersistentAttributeType pt = attr.getPersistentAttributeType(); + if (pt == PersistentAttributeType.MANY_TO_ONE + || pt == PersistentAttributeType.ONE_TO_ONE) { + Class parent = attr.getJavaType(); + if (classSet.contains(parent) && !parent.equals(child)) { + // child depends on parent → edge: parent → child + dependents.get(parent).add(child); + inDegree.merge(child, 1, Integer::sum); + } + } + } + } catch (IllegalArgumentException e) { + // Entity not in metamodel; skip + log.warn("[Migration] Entity not found in JPA metamodel, skipping graph analysis: {}", child.getName()); + } + } + + // Kahn's BFS topological sort + Queue> queue = new ArrayDeque<>(); + for (Class clazz : entityClasses) { + if (inDegree.get(clazz) == 0) { + queue.add(clazz); + } + } + + List> sorted = new ArrayList<>(entityClasses.size()); + Set> visited = new HashSet<>(); + + while (!queue.isEmpty()) { + Class current = queue.poll(); + sorted.add(current); + visited.add(current); + + for (Class dependent : dependents.get(current)) { + int newDegree = inDegree.merge(dependent, -1, Integer::sum); + if (newDegree == 0) { + queue.add(dependent); + } + } + } + + // Cycle fallback: append remaining unvisited nodes + if (sorted.size() < entityClasses.size()) { + log.warn("[Migration] Dependency graph has cycles; appending {} unresolved entities in original order", + entityClasses.size() - sorted.size()); + for (Class clazz : entityClasses) { + if (!visited.contains(clazz)) { + sorted.add(clazz); + } + } + } + + log.debug("[Migration] Topological sort result: {}", + sorted.stream().map(Class::getSimpleName).toList()); + return sorted; + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapper.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapper.java new file mode 100644 index 00000000..5c7e7165 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapper.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.identity; + +import tools.dynamia.modules.saas.migration.api.IdentityMapper; +import tools.dynamia.modules.saas.migration.api.IdentityStrategy; + +import java.util.Map; + +/** + * Identity mapper that preserves the original primary keys from the export file. + * + *

Suitable for restoring a backup to a completely empty target database where + * ID conflicts are not expected. If the target database already contains rows + * with the same IDs, constraint violations will occur. + * + * @author Mario Serrano Leones + */ +public class KeepIdsIdentityMapper implements IdentityMapper { + + @Override + public Object mapId(Object originalId, Class entityClass) { + // Return the original ID — tell the pipeline to set it before persisting + return originalId; + } + + @Override + public Object resolveReferenceId(Object originalRefId, Class refClass, + Map> idMappings) { + // IDs are unchanged, so the reference ID from the file is already correct + return originalRefId; + } + + @Override + public IdentityStrategy getStrategy() { + return IdentityStrategy.KEEP_IDS; + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapper.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapper.java new file mode 100644 index 00000000..1c55a25b --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapper.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.identity; + +import tools.dynamia.modules.saas.migration.api.IdentityMapper; +import tools.dynamia.modules.saas.migration.api.IdentityStrategy; + +import java.util.Map; + +/** + * Identity mapper that discards original IDs and lets JPA auto-generate new ones. + * + *

During import, each entity is persisted without a pre-set ID so the JPA + * persistence provider assigns a fresh ID. After each {@code persist}, the mapping + * {@code originalId → newId} is recorded and used to resolve foreign-key references + * in subsequent entities. + * + *

This is the recommended strategy for cloning within the same database, where + * duplicate IDs would cause constraint violations. + * + * @author Mario Serrano Leones + */ +public class RegenerateIdsIdentityMapper implements IdentityMapper { + + @Override + public Object mapId(Object originalId, Class entityClass) { + // Return null → pipeline will clear the ID field and let JPA generate a new one + return null; + } + + @Override + public Object resolveReferenceId(Object originalRefId, Class refClass, + Map> idMappings) { + if (originalRefId == null) { + return null; + } + Map classMap = idMappings.get(refClass.getName()); + if (classMap != null) { + Object mapped = classMap.get(originalRefId); + if (mapped != null) { + return mapped; + } + } + // Fallback: return original (may happen for references to entities not in the export, + // e.g., system-level entities shared across tenants) + return originalRefId; + } + + @Override + public IdentityStrategy getStrategy() { + return IdentityStrategy.REGENERATE_IDS; + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstants.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstants.java new file mode 100644 index 00000000..2b318d19 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstants.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.pipeline; + +/** + * Constants shared between the export and import pipelines. + * + * @author Mario Serrano Leones + */ +public final class ExportConstants { + + private ExportConstants() { + } + + /** Current format version written to every export file. */ + public static final String FORMAT_VERSION = "1"; + + /** + * Suffix appended to field names when serializing {@code @ManyToOne} / + * {@code @OneToOne} references. For example, a {@code category} field is + * exported as {@code category_ref_id} containing only the referenced entity's + * primary key value. + */ + public static final String REF_ID_SUFFIX = "_ref_id"; + + /** Top-level JSON field containing the serialized AccountDTO. */ + public static final String FIELD_ACCOUNT = "account"; + + /** Top-level JSON field containing the entity data map. */ + public static final String FIELD_ENTITIES = "entities"; + + /** Top-level JSON field for the format version string. */ + public static final String FIELD_VERSION = "version"; + + /** Top-level JSON field for the ISO-8601 export timestamp. */ + public static final String FIELD_EXPORTED_AT = "exportedAt"; + + /** Top-level JSON field for the source account ID. */ + public static final String FIELD_SOURCE_ACCOUNT_ID = "sourceAccountId"; + + /** Top-level JSON field for the identity strategy name. */ + public static final String FIELD_IDENTITY_STRATEGY = "identityStrategy"; +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportPipeline.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportPipeline.java new file mode 100644 index 00000000..46a7843b --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ExportPipeline.java @@ -0,0 +1,324 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.pipeline; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.core.JsonGenerator; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.metamodel.Attribute.PersistentAttributeType; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.SingularAttribute; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import tools.dynamia.domain.jpa.JpaUtils; +import tools.dynamia.domain.query.DataPaginator; +import tools.dynamia.domain.query.QueryParameters; +import tools.dynamia.domain.services.CrudService; +import tools.dynamia.integration.sterotypes.Service; +import tools.dynamia.modules.saas.api.ExportIgnore; +import tools.dynamia.modules.saas.domain.Account; +import tools.dynamia.modules.saas.migration.api.CancellationToken; +import tools.dynamia.modules.saas.migration.api.MigrationException; +import tools.dynamia.modules.saas.migration.api.MigrationProgress; +import tools.dynamia.modules.saas.migration.api.MigrationProgressListener; +import tools.dynamia.modules.saas.migration.api.AccountExportOptions; +import tools.dynamia.modules.saas.migration.config.AccountMigrationProperties; +import tools.dynamia.modules.saas.migration.discovery.AccountEntityDiscovery; +import tools.dynamia.modules.saas.migration.graph.EntityDependencyGraph; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.Serializable; +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.GZIPOutputStream; + +/** + * Streaming export pipeline. + * + *

Writes tenant data to an {@link OutputStream} using Jackson's + * {@link JsonGenerator}. Entities are processed in topological order, paginated + * by chunks so that RAM usage is bounded regardless of dataset size. + * + *

Serialization rules per attribute type

+ *
    + *
  • {@code BASIC} / {@code EMBEDDED} — value written directly.
  • + *
  • {@code MANY_TO_ONE} / {@code ONE_TO_ONE} — written as + * {@code {fieldName}_ref_id: }.
  • + *
  • {@code ONE_TO_MANY} / {@code MANY_TO_MANY} — skipped; child entities + * include their own references back to the parent.
  • + *
+ * + *

Fields annotated with {@link ExportIgnore} are silently skipped. + * + * @author Mario Serrano Leones + */ +@Service +public class ExportPipeline { + + private static final Logger log = LoggerFactory.getLogger(ExportPipeline.class); + + private final EntityManagerFactory emf; + private final CrudService crudService; + private final AccountEntityDiscovery discovery; + private final EntityDependencyGraph dependencyGraph; + private final AccountMigrationProperties properties; + private final ObjectMapper objectMapper; + + public ExportPipeline(EntityManagerFactory emf, + CrudService crudService, + AccountEntityDiscovery discovery, + EntityDependencyGraph dependencyGraph, + AccountMigrationProperties properties, + @Qualifier("migrationObjectMapper") ObjectMapper objectMapper) { + this.emf = emf; + this.crudService = crudService; + this.discovery = discovery; + this.dependencyGraph = dependencyGraph; + this.properties = properties; + this.objectMapper = objectMapper; + } + + /** + * Exports all tenant data for {@code accountId} to {@code output}. + * + * @param accountId ID of the account to export + * @param output destination stream; ownership is NOT transferred — the caller must close it + * @param options export configuration + * @param listener optional progress callback + * @param token optional cancellation token + */ + public void export(Long accountId, + OutputStream output, + AccountExportOptions options, + MigrationProgressListener listener, + CancellationToken token) { + + Account account = crudService.find(Account.class, accountId); + if (account == null) { + throw new MigrationException("Account not found: " + accountId); + } + + List> candidates = discovery.discoverExportableEntities(); + List> ordered = dependencyGraph.topologicalSort(candidates); + + // Pre-calculate approximate total for progress reporting + long totalRecords = 0; + for (Class ec : ordered) { + if (!Account.class.equals(ec)) { + try { + totalRecords += crudService.count(ec, QueryParameters.with("accountId", accountId)); + } catch (Exception e) { + log.debug("[Migration/Export] Could not count {}: {}", ec.getSimpleName(), e.getMessage()); + } + } + } + totalRecords += 1; // +1 for the account itself + + OutputStream target; + try { + target = options.isCompressionEnabled() ? new GZIPOutputStream(output) : output; + } catch (IOException e) { + throw new MigrationException("Failed to set up output stream", e); + } + + try (JsonGenerator gen = objectMapper.createGenerator(target)) { + + gen.writeStartObject(); + gen.writeStringProperty(ExportConstants.FIELD_VERSION, ExportConstants.FORMAT_VERSION); + gen.writeStringProperty(ExportConstants.FIELD_EXPORTED_AT, LocalDateTime.now().toString()); + gen.writeNumberProperty(ExportConstants.FIELD_SOURCE_ACCOUNT_ID, accountId); + gen.writeStringProperty(ExportConstants.FIELD_IDENTITY_STRATEGY, + options.getIdentityStrategy().name()); + + // Serialize AccountDTO as the tenant descriptor + gen.writeName(ExportConstants.FIELD_ACCOUNT); + objectMapper.writeValue(gen, account.toDTO()); + + // Entity section + gen.writeName(ExportConstants.FIELD_ENTITIES); + gen.writeStartObject(); + + long processed = 0; + for (Class entityClass : ordered) { + if (token != null && token.isCancelled()) { + log.info("[Migration/Export] Cancelled at entity: {}", entityClass.getSimpleName()); + break; + } + + long count = exportEntityType(gen, entityClass, accountId, options, token); + processed += count; + + if (listener != null) { + listener.onProgress(new MigrationProgress(processed, totalRecords, + "Exported " + entityClass.getSimpleName() + " (" + count + " records)")); + } + } + + gen.writeEndObject(); // entities + gen.writeEndObject(); // root + + if (options.isCompressionEnabled()) { + // Flush the GZIP stream + ((GZIPOutputStream) target).finish(); + } + + } catch (IOException e) { + throw new MigrationException("Export failed while writing JSON stream", e); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Internal helpers + // ───────────────────────────────────────────────────────────────────────── + + private long exportEntityType(JsonGenerator gen, Class entityClass, Long accountId, + AccountExportOptions options, CancellationToken token) + throws IOException { + + long processed = 0; + int chunkSize = resolveChunkSize(options); + + gen.writeName(entityClass.getName()); + gen.writeStartArray(); + + try { + EntityType entityType = emf.getMetamodel().entity(entityClass); + + if (Account.class.equals(entityClass)) { + // Account is already serialized in the header; produce an empty array here + // to keep the format consistent (importers can find it if needed) + gen.writeEndArray(); + return 0; + } + + long totalCount = crudService.count(entityClass, + QueryParameters.with("accountId", accountId)); + if (totalCount == 0) { + gen.writeEndArray(); + return 0; + } + + int totalPages = (int) Math.ceil((double) totalCount / chunkSize); + + for (int page = 1; page <= totalPages; page++) { + if (token != null && token.isCancelled()) break; + + DataPaginator paginator = new DataPaginator(totalCount, chunkSize, page); + QueryParameters qp = QueryParameters.with("accountId", accountId) + .paginate(paginator) + .orderBy("id", true); + + @SuppressWarnings("unchecked") + List chunk = (List) crudService.find(entityClass, qp); + if (chunk == null || chunk.isEmpty()) break; + + for (Object entity : chunk) { + writeEntity(gen, entity, entityType); + processed++; + } + } + + } catch (IllegalArgumentException e) { + log.warn("[Migration/Export] Entity not in JPA metamodel, writing raw: {}", entityClass.getName()); + } + + gen.writeEndArray(); + log.debug("[Migration/Export] {} records exported for {}", processed, entityClass.getSimpleName()); + return processed; + } + + private void writeEntity(JsonGenerator gen, Object entity, EntityType entityType) + throws IOException { + gen.writeStartObject(); + + // Write ID explicitly + try { + Serializable id = JpaUtils.getJPAIdValue(entity); + gen.writeName("id"); + gen.writePOJO(id); + } catch (Exception e) { + log.debug("[Migration/Export] Could not write ID for {}", entity.getClass().getSimpleName()); + } + + // Write all singular attributes + for (SingularAttribute attr : entityType.getSingularAttributes()) { + String name = attr.getName(); + if ("id".equals(name)) continue; // already written + + // Skip fields annotated with @ExportIgnore + if (hasExportIgnore(entityType.getJavaType(), name)) continue; + + try { + Field field = findField(entityType.getJavaType(), name); + if (field == null) continue; + field.setAccessible(true); + Object value = field.get(entity); + + PersistentAttributeType pt = attr.getPersistentAttributeType(); + + if (pt == PersistentAttributeType.MANY_TO_ONE + || pt == PersistentAttributeType.ONE_TO_ONE) { + if (value != null) { + Serializable refId = JpaUtils.getJPAIdValue(value); + gen.writeName(name + ExportConstants.REF_ID_SUFFIX); + gen.writePOJO(refId); + } + } else if (pt == PersistentAttributeType.ONE_TO_MANY + || pt == PersistentAttributeType.MANY_TO_MANY + || pt == PersistentAttributeType.ELEMENT_COLLECTION) { + // Skip collections — they are reconstructed via child entities + } else { + // BASIC or EMBEDDED + gen.writeName(name); + objectMapper.writeValue(gen, value); + } + + } catch (IllegalAccessException e) { + log.debug("[Migration/Export] Cannot access field {} on {}: {}", + name, entityType.getJavaType().getSimpleName(), e.getMessage()); + } catch (Exception e) { + log.debug("[Migration/Export] Skipping field {} due to: {}", name, e.getMessage()); + } + } + + gen.writeEndObject(); + } + + private boolean hasExportIgnore(Class clazz, String fieldName) { + Field field = findField(clazz, fieldName); + return field != null && field.isAnnotationPresent(ExportIgnore.class); + } + + private Field findField(Class clazz, String fieldName) { + Class current = clazz; + while (current != null && current != Object.class) { + try { + return current.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + current = current.getSuperclass(); + } + } + return null; + } + + private int resolveChunkSize(AccountExportOptions options) { + int size = options.getChunkSize(); + return size > 0 ? size : properties.getChunkSize(); + } + +} + + + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipeline.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipeline.java new file mode 100644 index 00000000..3ead6778 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipeline.java @@ -0,0 +1,447 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.pipeline; + +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.metamodel.Attribute.PersistentAttributeType; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.SingularAttribute; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import tools.dynamia.domain.jpa.JpaUtils; +import tools.dynamia.integration.sterotypes.Service; +import tools.dynamia.modules.saas.migration.api.CancellationToken; +import tools.dynamia.modules.saas.migration.api.IdentityMapper; +import tools.dynamia.modules.saas.migration.api.IdentityStrategy; +import tools.dynamia.modules.saas.migration.api.MigrationException; +import tools.dynamia.modules.saas.migration.api.MigrationProgress; +import tools.dynamia.modules.saas.migration.api.MigrationProgressListener; +import tools.dynamia.modules.saas.migration.api.AccountImportOptions; +import tools.dynamia.modules.saas.migration.config.AccountMigrationProperties; +import tools.dynamia.modules.saas.migration.identity.KeepIdsIdentityMapper; +import tools.dynamia.modules.saas.migration.identity.RegenerateIdsIdentityMapper; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.GZIPInputStream; + +/** + * Streaming import pipeline. + * + *

Reads the JSON export format produced by {@link ExportPipeline} using + * Jackson's {@link JsonParser} in streaming mode. Entities are persisted in + * topological order (guaranteed by the export format) using chunked transactions + * so no single transaction loads the full dataset. + * + *

For each entity record: + *

    + *
  1. Read JSON object as a {@link JsonNode}.
  2. + *
  3. Instantiate the entity class via its no-arg constructor.
  4. + *
  5. Set primitive / embedded fields from the JSON node.
  6. + *
  7. Resolve {@code _ref_id} references via the running {@code idMappings} + * table using the configured {@link IdentityMapper}.
  8. + *
  9. Set {@code accountId} to the target account.
  10. + *
  11. Persist the entity; record original→new ID mapping.
  12. + *
+ * + * @author Mario Serrano Leones + */ +@Service +public class ImportPipeline { + + private static final Logger log = LoggerFactory.getLogger(ImportPipeline.class); + + @PersistenceContext + private EntityManager em; + + /** Custom SPI mappers registered as Spring beans; queried before built-in defaults. */ + @Autowired(required = false) + private List customMappers; + + private final EntityManagerFactory emf; + private final AccountMigrationProperties properties; + private final ObjectMapper objectMapper; + + public ImportPipeline(EntityManagerFactory emf, + AccountMigrationProperties properties, + @Qualifier("migrationObjectMapper") ObjectMapper objectMapper) { + this.emf = emf; + this.properties = properties; + this.objectMapper = objectMapper; + } + + /** + * Imports all entity data from {@code input} into the target account. + * + * @param input export stream (auto-detected: GZIP or plain JSON) + * @param options import configuration + * @param listener optional progress callback + * @param token optional cancellation token + */ + public void importTenant(InputStream input, + AccountImportOptions options, + MigrationProgressListener listener, + CancellationToken token) { + IdentityMapper identityMapper = resolveIdentityMapper(options); + Map> idMappings = new HashMap<>(); + + InputStream source; + try { + source = detectAndWrapGzip(input); + } catch (IOException e) { + throw new MigrationException("Failed to open input stream", e); + } + + try (JsonParser parser = objectMapper.createParser(source)) { + + expectToken(parser, JsonToken.START_OBJECT, "root object"); + long totalProcessed = 0; + + while (parser.nextToken() != JsonToken.END_OBJECT) { + if (token != null && token.isCancelled()) break; + + String fieldName = parser.currentName(); + parser.nextToken(); // move to value + + switch (fieldName) { + case ExportConstants.FIELD_VERSION, + ExportConstants.FIELD_EXPORTED_AT, + ExportConstants.FIELD_SOURCE_ACCOUNT_ID, + ExportConstants.FIELD_IDENTITY_STRATEGY -> { + // Read and discard header metadata (validated if needed in future) + } + case ExportConstants.FIELD_ACCOUNT -> { + parser.skipChildren(); // Account handled externally + } + case ExportConstants.FIELD_ENTITIES -> { + totalProcessed = importEntitiesSection( + parser, options, identityMapper, idMappings, listener, token); + } + default -> parser.skipChildren(); + } + } + + if (listener != null) { + listener.onProgress(new MigrationProgress( + totalProcessed, totalProcessed, "Import complete")); + } + + } catch (IOException e) { + throw new MigrationException("Import failed while reading JSON stream", e); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Entities section + // ───────────────────────────────────────────────────────────────────────── + + private long importEntitiesSection(JsonParser parser, + AccountImportOptions options, + IdentityMapper identityMapper, + Map> idMappings, + MigrationProgressListener listener, + CancellationToken token) throws IOException { + + expectToken(parser, JsonToken.START_OBJECT, "entities object"); + long total = 0; + + while (parser.nextToken() != JsonToken.END_OBJECT) { + if (token != null && token.isCancelled()) { + log.info("[Migration/Import] Cancelled"); + break; + } + + String entityClassName = parser.currentName(); + parser.nextToken(); // START_ARRAY + + try { + Class entityClass = Class.forName(entityClassName); + long count = importEntityArray( + parser, entityClass, options, identityMapper, idMappings, listener, token); + total += count; + log.info("[Migration/Import] Imported {} records for {}", count, entityClass.getSimpleName()); + + if (listener != null) { + listener.onProgress(new MigrationProgress(total, 0, + "Imported " + entityClass.getSimpleName() + " (" + count + " records)")); + } + + } catch (ClassNotFoundException e) { + log.warn("[Migration/Import] Entity class not found in classpath, skipping: {}", entityClassName); + parser.skipChildren(); + } + } + + return total; + } + + private long importEntityArray(JsonParser parser, + Class entityClass, + AccountImportOptions options, + IdentityMapper identityMapper, + Map> idMappings, + MigrationProgressListener listener, + CancellationToken token) throws IOException { + + expectToken(parser, JsonToken.START_ARRAY, "entity array for " + entityClass.getSimpleName()); + + int chunkSize = options.getChunkSize() > 0 ? options.getChunkSize() : properties.getChunkSize(); + List chunk = new ArrayList<>(chunkSize); + long total = 0; + + while (parser.nextToken() != JsonToken.END_ARRAY) { + if (token != null && token.isCancelled()) break; + + JsonNode node = objectMapper.readTree(parser); + chunk.add(node); + + if (chunk.size() >= chunkSize) { + total += persistChunk(chunk, entityClass, options, identityMapper, idMappings); + chunk.clear(); + } + } + + if (!chunk.isEmpty()) { + total += persistChunk(chunk, entityClass, options, identityMapper, idMappings); + } + + return total; + } + + // ───────────────────────────────────────────────────────────────────────── + // Chunk persistence (transactional boundary) + // ───────────────────────────────────────────────────────────────────────── + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public int persistChunk(List chunk, + Class entityClass, + AccountImportOptions options, + IdentityMapper identityMapper, + Map> idMappings) { + int count = 0; + EntityType entityType; + try { + entityType = emf.getMetamodel().entity(entityClass); + } catch (IllegalArgumentException e) { + log.warn("[Migration/Import] Entity not in JPA metamodel, skipping: {}", entityClass.getName()); + return 0; + } + + for (JsonNode node : chunk) { + try { + Object entity = deserializeEntity(node, entityClass, entityType, + options.getTargetAccountId(), identityMapper, idMappings); + + Object originalId = readId(node); + Object mappedId = identityMapper.mapId(originalId, entityClass); + + if (mappedId != null) { + // KEEP_IDS: set the original ID before persisting + setField(entity, "id", mappedId); + em.persist(entity); + } else { + // REGENERATE_IDS: clear ID and let JPA assign a new one + setField(entity, "id", null); + em.persist(entity); + em.flush(); // force ID generation + } + + // Record mapping for downstream reference resolution + Object generatedId = JpaUtils.getJPAIdValue(entity); + if (originalId != null && generatedId != null) { + idMappings.computeIfAbsent(entityClass.getName(), k -> new HashMap<>()) + .put(originalId, generatedId); + } + + count++; + + } catch (Exception e) { + if (options.isFailOnEntityError()) { + throw new MigrationException( + "Error persisting " + entityClass.getSimpleName(), e); + } + log.warn("[Migration/Import] Skipping entity due to error in {}: {}", + entityClass.getSimpleName(), e.getMessage()); + log.debug("[Migration/Import] Stack trace:", e); + } + } + return count; + } + + // ───────────────────────────────────────────────────────────────────────── + // Entity deserialization + // ───────────────────────────────────────────────────────────────────────── + + private Object deserializeEntity(JsonNode node, + Class entityClass, + EntityType entityType, + Long targetAccountId, + IdentityMapper identityMapper, + Map> idMappings) throws Exception { + + Object entity = entityClass.getDeclaredConstructor().newInstance(); + + for (SingularAttribute attr : entityType.getSingularAttributes()) { + String name = attr.getName(); + if ("id".equals(name)) continue; // handled outside + + PersistentAttributeType pt = attr.getPersistentAttributeType(); + + if ("accountId".equals(name)) { + setField(entity, "accountId", targetAccountId); + continue; + } + + if (pt == PersistentAttributeType.MANY_TO_ONE + || pt == PersistentAttributeType.ONE_TO_ONE) { + + String refKey = name + ExportConstants.REF_ID_SUFFIX; + JsonNode refIdNode = node.get(refKey); + if (refIdNode != null && !refIdNode.isNull()) { + Object originalRefId = refIdNode.asLong(); + Class refClass = attr.getJavaType(); + Object resolvedId = identityMapper.resolveReferenceId( + originalRefId, refClass, idMappings); + if (resolvedId != null) { + try { + Object ref = em.getReference(refClass, coerceId(resolvedId, refClass)); + setField(entity, name, ref); + } catch (Exception e) { + log.debug("[Migration/Import] Could not create reference proxy for {}={}: {}", + refKey, resolvedId, e.getMessage()); + } + } + } + + } else if (pt != PersistentAttributeType.ONE_TO_MANY + && pt != PersistentAttributeType.MANY_TO_MANY + && pt != PersistentAttributeType.ELEMENT_COLLECTION) { + + JsonNode valueNode = node.get(name); + if (valueNode != null && !valueNode.isNull()) { + try { + Object value = objectMapper.treeToValue(valueNode, attr.getJavaType()); + setField(entity, name, value); + } catch (Exception e) { + log.debug("[Migration/Import] Could not set field {}={}: {}", + name, valueNode, e.getMessage()); + } + } + } + } + + return entity; + } + + // ───────────────────────────────────────────────────────────────────────── + // Utilities + // ───────────────────────────────────────────────────────────────────────── + + private static void setField(Object entity, String fieldName, Object value) { + Class clazz = entity.getClass(); + while (clazz != null && clazz != Object.class) { + try { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(entity, value); + return; + } catch (NoSuchFieldException e) { + clazz = clazz.getSuperclass(); + } catch (IllegalAccessException e) { + log.debug("[Migration/Import] Cannot set field {}: {}", fieldName, e.getMessage()); + return; + } + } + } + + private static Object readId(JsonNode node) { + JsonNode idNode = node.get("id"); + if (idNode == null || idNode.isNull()) return null; + if (idNode.isLong() || idNode.isInt()) return idNode.asLong(); + return idNode.asText(); + } + + private static Object coerceId(Object id, Class refClass) { + // If id is a String that looks like a Long, convert it + if (id instanceof String s) { + try { + return Long.parseLong(s); + } catch (NumberFormatException ignored) { + } + } + return id; + } + + private static InputStream detectAndWrapGzip(InputStream in) throws IOException { + if (!in.markSupported()) { + return in; // cannot detect — use as-is + } + in.mark(2); + int b1 = in.read(); + int b2 = in.read(); + in.reset(); + if (b1 == 0x1f && b2 == 0x8b) { + return new GZIPInputStream(in); + } + return in; + } + + private IdentityMapper resolveIdentityMapper(AccountImportOptions options) { + IdentityStrategy strategy = options.getIdentityStrategy(); + if (strategy == IdentityStrategy.UUID7) { + throw new MigrationException( + "IdentityStrategy.UUID7 is not yet supported (planned for v3). " + + "Use KEEP_IDS or REGENERATE_IDS."); + } + if (customMappers != null) { + for (IdentityMapper mapper : customMappers) { + if (mapper.getStrategy() == strategy) { + return mapper; + } + } + } + return switch (strategy) { + case KEEP_IDS -> new KeepIdsIdentityMapper(); + default -> new RegenerateIdsIdentityMapper(); + }; + } + + private static void expectToken(JsonParser parser, JsonToken expected, String context) + throws IOException { + JsonToken actual = parser.currentToken(); + if (actual == null) { + parser.nextToken(); + actual = parser.currentToken(); + } + if (actual != expected) { + throw new MigrationException( + "Expected %s for %s but got %s".formatted(expected, context, actual)); + } + } +} + + + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationJobServiceImpl.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationJobServiceImpl.java new file mode 100644 index 00000000..c2931e35 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationJobServiceImpl.java @@ -0,0 +1,395 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.services; + +import tools.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.web.multipart.MultipartFile; +import tools.dynamia.domain.query.QueryParameters; +import tools.dynamia.domain.services.CrudService; +import tools.dynamia.integration.scheduling.SchedulerUtil; +import tools.dynamia.integration.scheduling.TaskWithResult; +import tools.dynamia.integration.sterotypes.Service; +import tools.dynamia.modules.saas.migration.api.CancellationToken; +import tools.dynamia.modules.saas.migration.api.IdentityStrategy; +import tools.dynamia.modules.saas.migration.api.MigrationProgress; +import tools.dynamia.modules.saas.migration.api.AccountCloneOptions; +import tools.dynamia.modules.saas.migration.api.AccountExportOptions; +import tools.dynamia.modules.saas.migration.api.AccountImportOptions; +import tools.dynamia.modules.saas.migration.api.AccountMigrationJobDto; +import tools.dynamia.modules.saas.migration.api.AccountMigrationJobService; +import tools.dynamia.modules.saas.migration.api.AccountMigrationService; +import tools.dynamia.modules.saas.migration.config.AccountMigrationProperties; +import tools.dynamia.modules.saas.migration.domain.AccountJobStatus; +import tools.dynamia.modules.saas.migration.domain.AccountJobType; +import tools.dynamia.modules.saas.migration.domain.AccountMigrationJob; +import tools.dynamia.modules.saas.migration.workers.CloneWorker; +import tools.dynamia.modules.saas.migration.workers.ExportWorker; +import tools.dynamia.modules.saas.migration.workers.ImportWorker; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.stream.Collectors; + +/** + * Default implementation of {@link AccountMigrationJobService}. + * + *

Responsibilities: + *

    + *
  1. Persist {@link AccountMigrationJob} records in the database.
  2. + *
  3. Launch worker tasks via {@link SchedulerUtil#runWithResult(tools.dynamia.integration.scheduling.TaskWithResult)} + * on virtual threads.
  4. + *
  5. Update job status, progress and result path as the worker executes.
  6. + *
  7. Maintain an in-memory {@link CancellationToken} registry so running jobs can be cancelled.
  8. + *
+ * + * @author Mario Serrano Leones + */ +@Service +public class AccountMigrationJobServiceImpl implements AccountMigrationJobService { + + private static final Logger log = LoggerFactory.getLogger(AccountMigrationJobServiceImpl.class); + private static final DateTimeFormatter FILE_TS = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss"); + + /** In-memory token registry: jobUuid → CancellationToken. Cleaned up when job finishes. */ + private final Map activeTokens = new ConcurrentHashMap<>(); + + private final CrudService crudService; + private final AccountMigrationService mobilityService; + private final AccountMigrationProperties properties; + private final ObjectMapper objectMapper; + private final Semaphore concurrencyLimit; + + public AccountMigrationJobServiceImpl(CrudService crudService, + AccountMigrationService mobilityService, + AccountMigrationProperties properties, + @Qualifier("migrationObjectMapper") ObjectMapper objectMapper) { + this.crudService = crudService; + this.mobilityService = mobilityService; + this.properties = properties; + this.objectMapper = objectMapper; + this.concurrencyLimit = new Semaphore(Math.max(1, properties.getMaxConcurrentJobs())); + } + + // ───────────────────────────────────────────────────────────────────────── + // Job creation + // ───────────────────────────────────────────────────────────────────────── + + @Override + public AccountMigrationJobDto createExportJob(Long accountId, AccountExportOptions options) { + AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.EXPORT, options); + launchExportJob(job, accountId, options); + return toDto(job); + } + + @Override + public AccountMigrationJobDto createBackupJob(Long accountId) { + AccountExportOptions options = new AccountExportOptions() + .compressionEnabled(properties.isCompressionEnabled()) + .label("backup"); + AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.BACKUP, options); + launchExportJob(job, accountId, options); + return toDto(job); + } + + @Override + public AccountMigrationJobDto createImportJob(MultipartFile file, AccountImportOptions options) { + Path savedFile = saveUploadedFile(file, "import"); + AccountMigrationJob job = createAndSaveJob(options.getTargetAccountId(), null, AccountJobType.IMPORT, options); + launchImportJob(job, savedFile, options); + return toDto(job); + } + + @Override + public AccountMigrationJobDto createRestoreJob(Long accountId, MultipartFile file) { + Path savedFile = saveUploadedFile(file, "restore"); + AccountImportOptions options = new AccountImportOptions() + .targetAccountId(accountId) + .identityStrategy(IdentityStrategy.KEEP_IDS); + AccountMigrationJob job = createAndSaveJob(accountId, null, AccountJobType.RESTORE, options); + launchImportJob(job, savedFile, options); + return toDto(job); + } + + @Override + public AccountMigrationJobDto createCloneJob(AccountCloneOptions options) { + AccountMigrationJob job = createAndSaveJob( + options.getSourceAccountId(), options.getTargetAccountId(), AccountJobType.CLONE, options); + launchCloneJob(job, options); + return toDto(job); + } + + // ───────────────────────────────────────────────────────────────────────── + // Job query + // ───────────────────────────────────────────────────────────────────────── + + @Override + public AccountMigrationJobDto getJob(String jobUuid) { + AccountMigrationJob job = findByUuid(jobUuid); + return job != null ? toDto(job) : null; + } + + @Override + public AccountMigrationJob getJobEntity(String jobUuid) { + return findByUuid(jobUuid); + } + + @Override + public List listJobs(Long accountId) { + QueryParameters qp = new QueryParameters() + .orderBy("createdAt", false); + if (accountId != null) { + qp.add("accountId", accountId); + } + return crudService.find(AccountMigrationJob.class, qp) + .stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + @Override + public void cancelJob(String jobUuid) { + CancellationToken token = activeTokens.get(jobUuid); + if (token != null) { + token.cancel("Cancelled by user request"); + log.info("[Migration/Jobs] Cancellation requested for job {}", jobUuid); + } else { + log.warn("[Migration/Jobs] No active token found for job {} (already finished?)", jobUuid); + } + // Optimistically update status in DB + AccountMigrationJob job = findByUuid(jobUuid); + if (job != null && !job.isFinished()) { + crudService.executeWithinTransaction(() -> { + AccountMigrationJob j = crudService.find(AccountMigrationJob.class, job.getId()); + if (j != null && !j.isFinished()) { + j.markCancelled("Cancellation requested"); + crudService.update(j); + } + }); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Worker launchers + // ───────────────────────────────────────────────────────────────────────── + + private void launchExportJob(AccountMigrationJob job, Long accountId, AccountExportOptions options) { + CancellationToken token = CancellationToken.active(); + activeTokens.put(job.getUuid(), token); + Path outputFile = buildOutputPath(job, options.isCompressionEnabled()); + ExportWorker worker = new ExportWorker( + accountId, outputFile, options, mobilityService, + buildProgressListener(job), token); + scheduleWorker(job, worker, outputFile, null, token); + } + + private void launchImportJob(AccountMigrationJob job, Path inputFile, AccountImportOptions options) { + CancellationToken token = CancellationToken.active(); + activeTokens.put(job.getUuid(), token); + ImportWorker worker = new ImportWorker( + inputFile, options, mobilityService, + buildProgressListener(job), token); + scheduleWorker(job, worker, null, inputFile, token); + } + + private void launchCloneJob(AccountMigrationJob job, AccountCloneOptions options) { + CancellationToken token = CancellationToken.active(); + activeTokens.put(job.getUuid(), token); + CloneWorker worker = new CloneWorker( + options, mobilityService, + buildProgressListener(job), token); + scheduleWorker(job, worker, null, null, token); + } + + /** + * Submits {@code worker} to a virtual thread, enforcing the configured + * {@link AccountMigrationProperties#getMaxConcurrentJobs()} limit via a semaphore. + * Workers that cannot immediately acquire a slot park the virtual thread + * (cheap) until a running job finishes. + * + * @param resultFile written to the job on success (may be null) + * @param cleanupPath deleted after the job completes (uploaded temp files; may be null) + */ + private void scheduleWorker(AccountMigrationJob job, + TaskWithResult worker, + Path resultFile, + Path cleanupPath, + CancellationToken token) { + SchedulerUtil.runWithResult(new TaskWithResult(worker.getName() + "#queued") { + @Override + public Boolean doWorkWithResult() { + try { + concurrencyLimit.acquire(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted waiting for migration concurrency slot"); + } + try { + return worker.doWorkWithResult(); + } finally { + concurrencyLimit.release(); + } + } + }).whenComplete((result, ex) -> { + activeTokens.remove(job.getUuid()); + finalizeJob(job.getUuid(), ex, resultFile, token); + if (cleanupPath != null) { + try { + Files.deleteIfExists(cleanupPath); + } catch (IOException ignored) { + } + } + }); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private AccountMigrationJob createAndSaveJob(Long accountId, Long targetAccountId, + AccountJobType type, Object options) { + AccountMigrationJob job = new AccountMigrationJob(); + job.setAccountId(accountId); + job.setTargetAccountId(targetAccountId); + job.setJobType(type); + job.setStatus(AccountJobStatus.PENDING); + if (options != null) { + try { + job.setOptionsJson(objectMapper.writeValueAsString(options)); + } catch (Exception e) { + log.debug("[Migration/Jobs] Could not serialize options for {} job: {}", type, e.getMessage()); + } + } + crudService.create(job); + log.info("[Migration/Jobs] Created job {} type={} account={}", job.getUuid(), type, accountId); + return job; + } + + private void markRunning(String jobUuid) { + crudService.executeWithinTransaction(() -> { + AccountMigrationJob job = findByUuid(jobUuid); + if (job != null) { + job.markRunning(); + crudService.update(job); + } + }); + } + + private void finalizeJob(String jobUuid, Throwable ex, Path resultFile, CancellationToken token) { + crudService.executeWithinTransaction(() -> { + AccountMigrationJob job = findByUuid(jobUuid); + if (job == null) return; + + if (ex != null) { + job.markFailed(ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName()); + log.error("[Migration/Jobs] Job {} FAILED: {}", jobUuid, ex.getMessage()); + } else if (token != null && token.isCancelled()) { + job.markCancelled(token.getReason()); + log.info("[Migration/Jobs] Job {} CANCELLED: {}", jobUuid, token.getReason()); + } else { + job.markCompleted(); + if (resultFile != null) { + job.setResultPath(resultFile.toAbsolutePath().toString()); + } + log.info("[Migration/Jobs] Job {} COMPLETED", jobUuid); + } + crudService.update(job); + }); + } + + private tools.dynamia.modules.saas.migration.api.MigrationProgressListener buildProgressListener( + AccountMigrationJob job) { + // Mark the job as RUNNING on first progress event, then persist progress updates + final boolean[] started = {false}; + return (MigrationProgress p) -> { + if (!started[0]) { + started[0] = true; + markRunning(job.getUuid()); + } + try { + crudService.executeWithinTransaction(() -> { + AccountMigrationJob j = findByUuid(job.getUuid()); + if (j != null && !j.isFinished()) { + j.updateProgress(p.percentage() >= 0 ? p.percentage() : j.getProgress(), + p.message()); + crudService.update(j); + } + }); + } catch (Exception e) { + log.debug("[Migration/Jobs] Progress update error for {}: {}", job.getUuid(), e.getMessage()); + } + }; + } + + private Path buildOutputPath(AccountMigrationJob job, boolean compressed) { + String ts = LocalDateTime.now().format(FILE_TS); + String fileName = "saas_export_" + job.getAccountId() + "_" + ts + + (compressed ? ".json.gz" : ".json"); + return Paths.get(properties.getOutputDirectory(), fileName); + } + + private Path saveUploadedFile(MultipartFile file, String prefix) { + try { + String ts = LocalDateTime.now().format(FILE_TS); + String ext = file.getOriginalFilename() != null + && file.getOriginalFilename().endsWith(".gz") ? ".json.gz" : ".json"; + Path dest = Paths.get(properties.getOutputDirectory(), prefix + "_upload_" + ts + ext); + Files.createDirectories(dest.getParent()); + try (InputStream in = file.getInputStream()) { + Files.copy(in, dest, StandardCopyOption.REPLACE_EXISTING); + } + return dest; + } catch (IOException e) { + throw new tools.dynamia.modules.saas.migration.api.MigrationException( + "Failed to save uploaded file", e); + } + } + + private AccountMigrationJob findByUuid(String uuid) { + return crudService.findSingle(AccountMigrationJob.class, + QueryParameters.with("uuid", uuid)); + } + + private AccountMigrationJobDto toDto(AccountMigrationJob job) { + String downloadUrl = null; + if (job.getResultPath() != null) { + downloadUrl = "/api/saas/migration/jobs/" + job.getUuid() + "/download"; + } + return new AccountMigrationJobDto( + job.getId(), + job.getUuid(), + job.getAccountId(), + job.getTargetAccountId(), + job.getJobType(), + job.getStatus(), + job.getProgress(), + job.getProgressMessage(), + job.getErrorMessage(), + downloadUrl, + job.getCreatedAt(), + job.getStartedAt(), + job.getFinishedAt() + ); + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationServiceImpl.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationServiceImpl.java new file mode 100644 index 00000000..6a488007 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/services/AccountMigrationServiceImpl.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.services; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tools.dynamia.integration.sterotypes.Service; +import tools.dynamia.modules.saas.migration.api.CancellationToken; +import tools.dynamia.modules.saas.migration.api.MigrationProgressListener; +import tools.dynamia.modules.saas.migration.api.AccountCloneOptions; +import tools.dynamia.modules.saas.migration.api.AccountExportOptions; +import tools.dynamia.modules.saas.migration.api.AccountImportOptions; +import tools.dynamia.modules.saas.migration.api.AccountMigrationService; +import tools.dynamia.modules.saas.migration.pipeline.ExportPipeline; +import tools.dynamia.modules.saas.migration.pipeline.ImportPipeline; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Default implementation of {@link AccountMigrationService}. + * + *

Delegates export to {@link ExportPipeline} and import to {@link ImportPipeline}. + * For clone operations, the export is buffered in-memory ({@link ByteArrayOutputStream}) + * and then fed directly to the import pipeline — suitable for tenants with moderate + * data volumes (< ~100 MB uncompressed). For massive tenants, prefer the + * export-to-file + import-from-file job sequence. + * + * @author Mario Serrano Leones + */ +@Service +public class AccountMigrationServiceImpl implements AccountMigrationService { + + private static final Logger log = LoggerFactory.getLogger(AccountMigrationServiceImpl.class); + + private final ExportPipeline exportPipeline; + private final ImportPipeline importPipeline; + + public AccountMigrationServiceImpl(ExportPipeline exportPipeline, + ImportPipeline importPipeline) { + this.exportPipeline = exportPipeline; + this.importPipeline = importPipeline; + } + + @Override + public void exportTenant(Long accountId, + OutputStream output, + AccountExportOptions options, + MigrationProgressListener listener, + CancellationToken token) { + log.info("[Migration] Starting export for accountId={}", accountId); + exportPipeline.export(accountId, output, options, listener, token); + log.info("[Migration] Export complete for accountId={}", accountId); + } + + @Override + public void importTenant(InputStream input, + AccountImportOptions options, + MigrationProgressListener listener, + CancellationToken token) { + log.info("[Migration] Starting import for targetAccountId={}", options.getTargetAccountId()); + importPipeline.importTenant(input, options, listener, token); + log.info("[Migration] Import complete for targetAccountId={}", options.getTargetAccountId()); + } + + @Override + public void cloneTenant(AccountCloneOptions options, + MigrationProgressListener listener, + CancellationToken token) { + Long source = options.getSourceAccountId(); + Long target = options.getTargetAccountId(); + log.info("[Migration] Starting clone {} → {}", source, target); + + // ── Phase 1: Export to memory buffer ─────────────────────────────── + AccountExportOptions exportOptions = new AccountExportOptions() + .chunkSize(options.getChunkSize()) + .identityStrategy(options.getIdentityStrategy()) + .label("clone-" + source + "->" + target); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(8 * 1024 * 1024); // 8 MB initial + exportPipeline.export(source, buffer, exportOptions, progress -> { + if (listener != null) { + listener.onProgress(progress); // forward export progress + } + }, token); + + if (token != null && token.isCancelled()) { + log.info("[Migration] Clone cancelled after export phase"); + return; + } + + // ── Phase 2: Import from buffer ──────────────────────────────────── + AccountImportOptions importOptions = new AccountImportOptions() + .targetAccountId(target) + .chunkSize(options.getChunkSize()) + .identityStrategy(options.getIdentityStrategy()) + .failOnEntityError(options.isFailOnEntityError()); + + byte[] exported = buffer.toByteArray(); + log.debug("[Migration] Clone buffer size: {} bytes", exported.length); + + importPipeline.importTenant( + new ByteArrayInputStream(exported), importOptions, listener, token); + + log.info("[Migration] Clone complete {} → {}", source, target); + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/CloneWorker.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/CloneWorker.java new file mode 100644 index 00000000..71c09f6f --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/CloneWorker.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.workers; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tools.dynamia.integration.scheduling.TaskWithResult; +import tools.dynamia.modules.saas.migration.api.CancellationToken; +import tools.dynamia.modules.saas.migration.api.MigrationProgressListener; +import tools.dynamia.modules.saas.migration.api.AccountCloneOptions; +import tools.dynamia.modules.saas.migration.api.AccountMigrationService; + +/** + * Background worker that executes a tenant clone operation + * (source account → target account, same system). + * + *

Export data is buffered in a {@code ByteArrayOutputStream} and then fed + * directly to the import pipeline — no disk I/O required. + * For tenants with very large datasets (> ~100 MB uncompressed), prefer the + * {@link ExportWorker} + {@link ImportWorker} sequence via a temporary file + * to avoid heap pressure. + * + *

Submitted to {@code SchedulerUtil.runWithResult()} and runs on a virtual thread. + * + * @author Mario Serrano Leones + */ +public class CloneWorker extends TaskWithResult { + + private static final Logger log = LoggerFactory.getLogger(CloneWorker.class); + + private final AccountCloneOptions options; + private final AccountMigrationService mobilityService; + private final MigrationProgressListener progressListener; + private final CancellationToken cancellationToken; + + public CloneWorker(AccountCloneOptions options, + AccountMigrationService mobilityService, + MigrationProgressListener progressListener, + CancellationToken cancellationToken) { + super("CloneWorker-" + options.getSourceAccountId() + "->" + options.getTargetAccountId()); + this.options = options; + this.mobilityService = mobilityService; + this.progressListener = progressListener; + this.cancellationToken = cancellationToken; + } + + @Override + public Boolean doWorkWithResult() { + log.info("[Migration/Worker] Starting CLONE {} → {}", + options.getSourceAccountId(), options.getTargetAccountId()); + try { + mobilityService.cloneTenant(options, progressListener, cancellationToken); + if (cancellationToken != null && cancellationToken.isCancelled()) { + log.info("[Migration/Worker] CLONE cancelled"); + return false; + } + log.info("[Migration/Worker] CLONE completed {} → {}", + options.getSourceAccountId(), options.getTargetAccountId()); + return true; + } catch (Exception e) { + log.error("[Migration/Worker] CLONE failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ExportWorker.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ExportWorker.java new file mode 100644 index 00000000..fa01218a --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ExportWorker.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.workers; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tools.dynamia.integration.scheduling.TaskWithResult; +import tools.dynamia.modules.saas.migration.api.CancellationToken; +import tools.dynamia.modules.saas.migration.api.MigrationProgressListener; +import tools.dynamia.modules.saas.migration.api.AccountExportOptions; +import tools.dynamia.modules.saas.migration.api.AccountMigrationService; + +import java.io.FileOutputStream; +import java.io.OutputStream; +import java.nio.file.Path; + +/** + * Background worker that executes a tenant export operation. + * + *

This task is submitted to {@code SchedulerUtil.runWithResult()} and runs + * on a virtual thread. It calls {@link AccountMigrationService#exportTenant} and + * writes the result to the file path provided by the job service. + * + *

Returns {@code true} on success, {@code false} on failure or cancellation. + * + * @author Mario Serrano Leones + */ +public class ExportWorker extends TaskWithResult { + + private static final Logger log = LoggerFactory.getLogger(ExportWorker.class); + + private final Long accountId; + private final Path outputFile; + private final AccountExportOptions options; + private final AccountMigrationService mobilityService; + private final MigrationProgressListener progressListener; + private final CancellationToken cancellationToken; + + public ExportWorker(Long accountId, + Path outputFile, + AccountExportOptions options, + AccountMigrationService mobilityService, + MigrationProgressListener progressListener, + CancellationToken cancellationToken) { + super("ExportWorker-account-" + accountId); + this.accountId = accountId; + this.outputFile = outputFile; + this.options = options; + this.mobilityService = mobilityService; + this.progressListener = progressListener; + this.cancellationToken = cancellationToken; + } + + @Override + public Boolean doWorkWithResult() { + log.info("[Migration/Worker] Starting EXPORT for accountId={} → {}", accountId, outputFile); + try (OutputStream out = new FileOutputStream(outputFile.toFile())) { + mobilityService.exportTenant(accountId, out, options, progressListener, cancellationToken); + if (cancellationToken != null && cancellationToken.isCancelled()) { + log.info("[Migration/Worker] EXPORT cancelled for accountId={}", accountId); + return false; + } + log.info("[Migration/Worker] EXPORT completed for accountId={}", accountId); + return true; + } catch (Exception e) { + log.error("[Migration/Worker] EXPORT failed for accountId={}: {}", accountId, e.getMessage(), e); + throw new RuntimeException(e); + } + } +} + diff --git a/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ImportWorker.java b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ImportWorker.java new file mode 100644 index 00000000..9579f8b4 --- /dev/null +++ b/extensions/saas/sources/migration/src/main/java/tools/dynamia/modules/saas/migration/workers/ImportWorker.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.workers; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import tools.dynamia.integration.scheduling.TaskWithResult; +import tools.dynamia.modules.saas.migration.api.CancellationToken; +import tools.dynamia.modules.saas.migration.api.MigrationProgressListener; +import tools.dynamia.modules.saas.migration.api.AccountImportOptions; +import tools.dynamia.modules.saas.migration.api.AccountMigrationService; + +import java.io.BufferedInputStream; +import java.io.FileInputStream; +import java.io.InputStream; +import java.nio.file.Path; + +/** + * Background worker that executes a tenant import operation from a file on disk. + * + *

Submitted to {@code SchedulerUtil.runWithResult()} and runs on a virtual thread. + * Returns {@code true} on success, {@code false} on cancellation, or throws + * {@link RuntimeException} on failure. + * + * @author Mario Serrano Leones + */ +public class ImportWorker extends TaskWithResult { + + private static final Logger log = LoggerFactory.getLogger(ImportWorker.class); + + private final Path inputFile; + private final AccountImportOptions options; + private final AccountMigrationService mobilityService; + private final MigrationProgressListener progressListener; + private final CancellationToken cancellationToken; + + public ImportWorker(Path inputFile, + AccountImportOptions options, + AccountMigrationService mobilityService, + MigrationProgressListener progressListener, + CancellationToken cancellationToken) { + super("ImportWorker-account-" + options.getTargetAccountId()); + this.inputFile = inputFile; + this.options = options; + this.mobilityService = mobilityService; + this.progressListener = progressListener; + this.cancellationToken = cancellationToken; + } + + @Override + public Boolean doWorkWithResult() { + log.info("[Migration/Worker] Starting IMPORT from {} → accountId={}", + inputFile, options.getTargetAccountId()); + try (InputStream in = new BufferedInputStream(new FileInputStream(inputFile.toFile()))) { + mobilityService.importTenant(in, options, progressListener, cancellationToken); + if (cancellationToken != null && cancellationToken.isCancelled()) { + log.info("[Migration/Worker] IMPORT cancelled"); + return false; + } + log.info("[Migration/Worker] IMPORT completed for accountId={}", options.getTargetAccountId()); + return true; + } catch (Exception e) { + log.error("[Migration/Worker] IMPORT failed: {}", e.getMessage(), e); + throw new RuntimeException(e); + } + } +} + diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/AccountMigrationJobTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/AccountMigrationJobTest.java new file mode 100644 index 00000000..a827e659 --- /dev/null +++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/AccountMigrationJobTest.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration; + +import org.junit.Assert; +import org.junit.Test; +import tools.dynamia.modules.saas.migration.domain.AccountJobStatus; +import tools.dynamia.modules.saas.migration.domain.AccountMigrationJob; + +public class AccountMigrationJobTest { + + @Test + public void newJobIsInPendingStatus() { + AccountMigrationJob job = new AccountMigrationJob(); + Assert.assertEquals(AccountJobStatus.PENDING, job.getStatus()); + } + + @Test + public void newJobIsNotFinished() { + Assert.assertFalse(new AccountMigrationJob().isFinished()); + } + + @Test + public void newJobHasUuid() { + AccountMigrationJob job = new AccountMigrationJob(); + Assert.assertNotNull(job.getUuid()); + Assert.assertFalse(job.getUuid().isEmpty()); + } + + @Test + public void markRunningTransitionsToRunning() { + AccountMigrationJob job = new AccountMigrationJob(); + job.markRunning(); + + Assert.assertEquals(AccountJobStatus.RUNNING, job.getStatus()); + Assert.assertNotNull(job.getStartedAt()); + Assert.assertFalse(job.isFinished()); + } + + @Test + public void markCompletedSetsProgressTo100AndFinishedAt() { + AccountMigrationJob job = new AccountMigrationJob(); + job.markRunning(); + job.markCompleted(); + + Assert.assertEquals(AccountJobStatus.COMPLETED, job.getStatus()); + Assert.assertEquals(100, job.getProgress()); + Assert.assertNotNull(job.getFinishedAt()); + Assert.assertTrue(job.isFinished()); + } + + @Test + public void markFailedStoresMessage() { + AccountMigrationJob job = new AccountMigrationJob(); + job.markRunning(); + job.markFailed("DB connection lost"); + + Assert.assertEquals(AccountJobStatus.FAILED, job.getStatus()); + Assert.assertEquals("DB connection lost", job.getErrorMessage()); + Assert.assertNotNull(job.getFinishedAt()); + Assert.assertTrue(job.isFinished()); + } + + @Test + public void markCancelledStoresReason() { + AccountMigrationJob job = new AccountMigrationJob(); + job.markRunning(); + job.markCancelled("User requested cancellation"); + + Assert.assertEquals(AccountJobStatus.CANCELLED, job.getStatus()); + Assert.assertEquals("User requested cancellation", job.getProgressMessage()); + Assert.assertNotNull(job.getFinishedAt()); + Assert.assertTrue(job.isFinished()); + } + + @Test + public void updateProgressClampsTo0_100Range() { + AccountMigrationJob job = new AccountMigrationJob(); + + job.updateProgress(-5, "below zero"); + Assert.assertEquals(0, job.getProgress()); + + job.updateProgress(150, "above hundred"); + Assert.assertEquals(100, job.getProgress()); + + job.updateProgress(42, "normal"); + Assert.assertEquals(42, job.getProgress()); + Assert.assertEquals("normal", job.getProgressMessage()); + } + + @Test + public void twoJobsHaveDifferentUuids() { + AccountMigrationJob a = new AccountMigrationJob(); + AccountMigrationJob b = new AccountMigrationJob(); + Assert.assertNotEquals(a.getUuid(), b.getUuid()); + } +} diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/CancellationTokenTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/CancellationTokenTest.java new file mode 100644 index 00000000..a11cc3f9 --- /dev/null +++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/CancellationTokenTest.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration; + +import org.junit.Assert; +import org.junit.Test; +import tools.dynamia.modules.saas.migration.api.CancellationToken; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public class CancellationTokenTest { + + @Test + public void newTokenIsNotCancelled() { + CancellationToken token = CancellationToken.active(); + Assert.assertFalse(token.isCancelled()); + Assert.assertNull(token.getReason()); + } + + @Test + public void cancelWithoutReasonUsesDefault() { + CancellationToken token = CancellationToken.active(); + token.cancel(); + + Assert.assertTrue(token.isCancelled()); + Assert.assertNotNull(token.getReason()); + } + + @Test + public void cancelWithReasonStoresReason() { + CancellationToken token = CancellationToken.active(); + token.cancel("Timeout exceeded"); + + Assert.assertTrue(token.isCancelled()); + Assert.assertEquals("Timeout exceeded", token.getReason()); + } + + @Test + public void cancelIsIdempotent() { + CancellationToken token = CancellationToken.active(); + token.cancel("first"); + token.cancel("second"); + + Assert.assertTrue(token.isCancelled()); + Assert.assertEquals("second", token.getReason()); + } + + @Test + public void cancelFromOtherThreadIsVisibleImmediately() throws InterruptedException { + CancellationToken token = CancellationToken.active(); + AtomicBoolean seen = new AtomicBoolean(false); + CountDownLatch latch = new CountDownLatch(1); + + Thread t = Thread.ofVirtual().start(() -> { + token.cancel("from-thread"); + latch.countDown(); + }); + + latch.await(2, TimeUnit.SECONDS); + seen.set(token.isCancelled()); + t.join(1000); + + Assert.assertTrue("Cancel from another thread must be visible", seen.get()); + } +} diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/api/OptionsFluentBuilderTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/api/OptionsFluentBuilderTest.java new file mode 100644 index 00000000..16633aa9 --- /dev/null +++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/api/OptionsFluentBuilderTest.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.api; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +/** + * Tests for the fluent builder APIs on options classes and their Jackson serialization + * (exercising the options_json feature added to AccountMigrationJob). + */ +public class OptionsFluentBuilderTest { + + private ObjectMapper objectMapper; + + @Before + public void setUp() { + objectMapper = JsonMapper.builder() + .build(); + } + + // ─── AccountExportOptions ──────────────────────────────────────────────── + + @Test + public void exportOptionsDefaults() { + AccountExportOptions opts = new AccountExportOptions(); + Assert.assertEquals(500, opts.getChunkSize()); + Assert.assertFalse(opts.isCompressionEnabled()); + Assert.assertEquals(IdentityStrategy.KEEP_IDS, opts.getIdentityStrategy()); + } + + @Test + public void exportOptionsFluentBuilder() { + AccountExportOptions opts = new AccountExportOptions() + .chunkSize(200) + .compressionEnabled(true) + .identityStrategy(IdentityStrategy.REGENERATE_IDS) + .label("my-export"); + + Assert.assertEquals(200, opts.getChunkSize()); + Assert.assertTrue(opts.isCompressionEnabled()); + Assert.assertEquals(IdentityStrategy.REGENERATE_IDS, opts.getIdentityStrategy()); + Assert.assertEquals("my-export", opts.getLabel()); + } + + @Test + public void exportOptionsIsJsonSerializable() throws Exception { + AccountExportOptions opts = new AccountExportOptions() + .chunkSize(100) + .compressionEnabled(true) + .identityStrategy(IdentityStrategy.KEEP_IDS); + + String json = objectMapper.writeValueAsString(opts); + Assert.assertNotNull(json); + Assert.assertTrue(json.contains("chunkSize")); + Assert.assertTrue(json.contains("KEEP_IDS")); + + AccountExportOptions roundtrip = objectMapper.readValue(json, AccountExportOptions.class); + Assert.assertEquals(100, roundtrip.getChunkSize()); + Assert.assertTrue(roundtrip.isCompressionEnabled()); + } + + // ─── AccountImportOptions ──────────────────────────────────────────────── + + @Test + public void importOptionsDefaults() { + AccountImportOptions opts = new AccountImportOptions(); + Assert.assertNull(opts.getTargetAccountId()); + Assert.assertEquals(IdentityStrategy.REGENERATE_IDS, opts.getIdentityStrategy()); + Assert.assertEquals(500, opts.getChunkSize()); + Assert.assertFalse(opts.isFailOnEntityError()); + } + + @Test + public void importOptionsFluentBuilder() { + AccountImportOptions opts = new AccountImportOptions() + .targetAccountId(42L) + .identityStrategy(IdentityStrategy.KEEP_IDS) + .chunkSize(250) + .failOnEntityError(true); + + Assert.assertEquals(42L, (long) opts.getTargetAccountId()); + Assert.assertEquals(IdentityStrategy.KEEP_IDS, opts.getIdentityStrategy()); + Assert.assertEquals(250, opts.getChunkSize()); + Assert.assertTrue(opts.isFailOnEntityError()); + } + + @Test + public void importOptionsIsJsonSerializable() throws Exception { + AccountImportOptions opts = new AccountImportOptions() + .targetAccountId(7L) + .identityStrategy(IdentityStrategy.REGENERATE_IDS); + + String json = objectMapper.writeValueAsString(opts); + Assert.assertNotNull(json); + Assert.assertTrue(json.contains("targetAccountId")); + Assert.assertTrue(json.contains("REGENERATE_IDS")); + + AccountImportOptions roundtrip = objectMapper.readValue(json, AccountImportOptions.class); + Assert.assertEquals(7L, (long) roundtrip.getTargetAccountId()); + } + + // ─── AccountCloneOptions ───────────────────────────────────────────────── + + @Test + public void cloneOptionsIsJsonSerializable() throws Exception { + AccountCloneOptions opts = new AccountCloneOptions(); + opts.setSourceAccountId(1L); + opts.setTargetAccountId(2L); + + String json = objectMapper.writeValueAsString(opts); + Assert.assertNotNull(json); + Assert.assertTrue(json.contains("sourceAccountId") || json.contains("1")); + } +} diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/config/AccountMigrationPropertiesTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/config/AccountMigrationPropertiesTest.java new file mode 100644 index 00000000..64dfb343 --- /dev/null +++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/config/AccountMigrationPropertiesTest.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.config; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.concurrent.Semaphore; + +public class AccountMigrationPropertiesTest { + + @Test + public void defaultChunkSizeIs500() { + Assert.assertEquals(500, new AccountMigrationProperties().getChunkSize()); + } + + @Test + public void defaultCompressionIsDisabled() { + Assert.assertFalse(new AccountMigrationProperties().isCompressionEnabled()); + } + + @Test + public void defaultMaxConcurrentJobsIs5() { + Assert.assertEquals(5, new AccountMigrationProperties().getMaxConcurrentJobs()); + } + + @Test + public void defaultFailOnEntityErrorIsFalse() { + Assert.assertFalse(new AccountMigrationProperties().isFailOnEntityError()); + } + + @Test + public void defaultOutputDirectoryContainsTmpdir() { + String dir = new AccountMigrationProperties().getOutputDirectory(); + Assert.assertNotNull(dir); + Assert.assertTrue("outputDirectory should use system tmpdir", + dir.contains(System.getProperty("java.io.tmpdir").replace("\\", "/"))); + } + + @Test + public void semaphoreInitializedFromMaxConcurrentJobs() { + AccountMigrationProperties props = new AccountMigrationProperties(); + props.setMaxConcurrentJobs(3); + + // Simulate the service constructor logic + Semaphore semaphore = new Semaphore(Math.max(1, props.getMaxConcurrentJobs())); + Assert.assertEquals(3, semaphore.availablePermits()); + } + + @Test + public void semaphoreFloorIsOneEvenIfMaxIsZeroOrNegative() { + // The service uses Math.max(1, maxConcurrentJobs) to avoid a 0-permit semaphore + Assert.assertEquals(1, Math.max(1, 0)); + Assert.assertEquals(1, Math.max(1, -5)); + Assert.assertEquals(2, Math.max(1, 2)); + } +} diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraphTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraphTest.java new file mode 100644 index 00000000..c8ef9e2d --- /dev/null +++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/graph/EntityDependencyGraphTest.java @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.graph; + +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.metamodel.Attribute.PersistentAttributeType; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.Metamodel; +import jakarta.persistence.metamodel.SingularAttribute; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.List; +import java.util.Set; + +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link EntityDependencyGraph#topologicalSort(List)}. + * + *

Entity model used across tests: + *

+ *   Account  ←── Order  ←── OrderItem ──→ Product  ←── Category (no deps)
+ * 
+ * Expected topological order: {Account, Category} before {Order, Product} before {OrderItem}. + * + *

Raw-typed mocks are required to work around the complex JPA generics on + * {@code ManagedType.getSingularAttributes()} which returns + * {@code Set>}. + */ +@SuppressWarnings({"unchecked", "rawtypes"}) +@RunWith(MockitoJUnitRunner.class) +public class EntityDependencyGraphTest { + + // Marker classes used as stand-ins for real JPA entities + static class Account {} + static class Category {} + static class Product {} + static class Order {} + static class OrderItem {} + + @Mock private EntityManagerFactory emf; + @Mock private Metamodel metamodel; + + // Raw EntityType mocks avoid JPA wildcard inference problems + private EntityType accountType; + private EntityType categoryType; + private EntityType productType; + private EntityType orderType; + private EntityType orderItemType; + + private EntityDependencyGraph graph; + + @Before + public void setUp() { + accountType = mock(EntityType.class); + categoryType = mock(EntityType.class); + productType = mock(EntityType.class); + orderType = mock(EntityType.class); + orderItemType = mock(EntityType.class); + + when(emf.getMetamodel()).thenReturn(metamodel); + + // Account: no singular attributes pointing to entities in our set + when(metamodel.entity(Account.class)).thenReturn(accountType); + doReturn(Set.of()).when(accountType).getSingularAttributes(); + + // Category: no dependencies + when(metamodel.entity(Category.class)).thenReturn(categoryType); + doReturn(Set.of()).when(categoryType).getSingularAttributes(); + + // Product depends on Category (ManyToOne) + SingularAttribute productCategory = attrOf(PersistentAttributeType.MANY_TO_ONE, Category.class); + when(metamodel.entity(Product.class)).thenReturn(productType); + doReturn(Set.of(productCategory)).when(productType).getSingularAttributes(); + + // Order depends on Account (ManyToOne) + SingularAttribute orderAccount = attrOf(PersistentAttributeType.MANY_TO_ONE, Account.class); + when(metamodel.entity(Order.class)).thenReturn(orderType); + doReturn(Set.of(orderAccount)).when(orderType).getSingularAttributes(); + + // OrderItem depends on Order (ManyToOne) and Product (ManyToOne) + SingularAttribute itemOrder = attrOf(PersistentAttributeType.MANY_TO_ONE, Order.class); + SingularAttribute itemProduct = attrOf(PersistentAttributeType.MANY_TO_ONE, Product.class); + when(metamodel.entity(OrderItem.class)).thenReturn(orderItemType); + doReturn(Set.of(itemOrder, itemProduct)).when(orderItemType).getSingularAttributes(); + + graph = new EntityDependencyGraph(emf); + } + + @Test + public void parentsAppearBeforeChildrenInOutput() { + List> input = List.of(Account.class, Category.class, Product.class, + Order.class, OrderItem.class); + List> sorted = graph.topologicalSort(input); + + int idxAccount = sorted.indexOf(Account.class); + int idxCategory = sorted.indexOf(Category.class); + int idxProduct = sorted.indexOf(Product.class); + int idxOrder = sorted.indexOf(Order.class); + int idxItem = sorted.indexOf(OrderItem.class); + + Assert.assertTrue("Account before Order", idxAccount < idxOrder); + Assert.assertTrue("Account before OrderItem", idxAccount < idxItem); + Assert.assertTrue("Category before Product", idxCategory < idxProduct); + Assert.assertTrue("Order before OrderItem", idxOrder < idxItem); + Assert.assertTrue("Product before OrderItem", idxProduct < idxItem); + } + + @Test + public void allInputClassesArePresent() { + List> input = List.of(Account.class, Category.class, Product.class, + Order.class, OrderItem.class); + List> sorted = graph.topologicalSort(input); + + Assert.assertEquals(input.size(), sorted.size()); + Assert.assertTrue(sorted.containsAll(input)); + } + + @Test + public void emptyInputReturnsEmptyList() { + Assert.assertTrue(graph.topologicalSort(List.of()).isEmpty()); + } + + @Test + public void nullInputReturnsEmptyList() { + Assert.assertTrue(graph.topologicalSort(null).isEmpty()); + } + + @Test + public void singleEntityWithNoDepsIsReturnedAsIs() { + List> sorted = graph.topologicalSort(List.of(Account.class)); + Assert.assertEquals(1, sorted.size()); + Assert.assertEquals(Account.class, sorted.get(0)); + } + + @Test + public void oneToOneRelationAlsoCreatesEdge() { + SingularAttribute oneToOne = attrOf(PersistentAttributeType.ONE_TO_ONE, Account.class); + doReturn(Set.of(oneToOne)).when(productType).getSingularAttributes(); + + List> sorted = graph.topologicalSort(List.of(Account.class, Product.class)); + Assert.assertTrue("Account before Product (ONE_TO_ONE)", + sorted.indexOf(Account.class) < sorted.indexOf(Product.class)); + } + + @Test + public void basicAttributeDoesNotCreateDependencyEdge() { + // A BASIC attr whose javaType happens to be another entity class must not create an edge + SingularAttribute basic = attrOf(PersistentAttributeType.BASIC, Account.class); + doReturn(Set.of(basic)).when(categoryType).getSingularAttributes(); + + // Both are present, no ordering constraint — both orderings are valid + List> sorted = graph.topologicalSort(List.of(Account.class, Category.class)); + Assert.assertEquals(2, sorted.size()); + Assert.assertTrue(sorted.contains(Account.class)); + Assert.assertTrue(sorted.contains(Category.class)); + } + + // ─── Helper ────────────────────────────────────────────────────────────── + + private static SingularAttribute attrOf(PersistentAttributeType type, Class javaType) { + SingularAttribute attr = mock(SingularAttribute.class); + when(attr.getPersistentAttributeType()).thenReturn(type); + when(attr.getJavaType()).thenReturn(javaType); + return attr; + } +} diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapperTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapperTest.java new file mode 100644 index 00000000..a3b0fd77 --- /dev/null +++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/KeepIdsIdentityMapperTest.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.identity; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import tools.dynamia.modules.saas.migration.api.IdentityStrategy; + +import java.util.HashMap; +import java.util.Map; + +public class KeepIdsIdentityMapperTest { + + private KeepIdsIdentityMapper mapper; + + @Before + public void setUp() { + mapper = new KeepIdsIdentityMapper(); + } + + @Test + public void strategyIsKeepIds() { + Assert.assertEquals(IdentityStrategy.KEEP_IDS, mapper.getStrategy()); + } + + @Test + public void mapIdReturnsOriginalId() { + Assert.assertEquals(42L, mapper.mapId(42L, String.class)); + Assert.assertEquals("uuid-123", mapper.mapId("uuid-123", Object.class)); + } + + @Test + public void mapIdWithNullReturnsNull() { + Assert.assertNull(mapper.mapId(null, String.class)); + } + + @Test + public void resolveReferenceIdReturnsOriginalRefIdIgnoringMap() { + Map> idMappings = new HashMap<>(); + idMappings.put(String.class.getName(), Map.of(1L, 999L)); + + // KEEP_IDS: the ref ID from the file is the correct ID in the target DB + Object resolved = mapper.resolveReferenceId(1L, String.class, idMappings); + Assert.assertEquals(1L, resolved); + } + + @Test + public void resolveReferenceIdWithNullRefIdReturnsNull() { + Assert.assertNull(mapper.resolveReferenceId(null, String.class, new HashMap<>())); + } +} diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapperTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapperTest.java new file mode 100644 index 00000000..10703838 --- /dev/null +++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/identity/RegenerateIdsIdentityMapperTest.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.identity; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import tools.dynamia.modules.saas.migration.api.IdentityStrategy; + +import java.util.HashMap; +import java.util.Map; + +public class RegenerateIdsIdentityMapperTest { + + private RegenerateIdsIdentityMapper mapper; + + @Before + public void setUp() { + mapper = new RegenerateIdsIdentityMapper(); + } + + @Test + public void strategyIsRegenerateIds() { + Assert.assertEquals(IdentityStrategy.REGENERATE_IDS, mapper.getStrategy()); + } + + @Test + public void mapIdAlwaysReturnsNull() { + Assert.assertNull(mapper.mapId(1L, String.class)); + Assert.assertNull(mapper.mapId(99999L, Object.class)); + Assert.assertNull(mapper.mapId(null, String.class)); + } + + @Test + public void resolveReferenceIdLookupsFromIdMappings() { + Map> idMappings = new HashMap<>(); + idMappings.put(String.class.getName(), Map.of(10L, 501L)); + + Object resolved = mapper.resolveReferenceId(10L, String.class, idMappings); + Assert.assertEquals(501L, resolved); + } + + @Test + public void resolveReferenceIdFallsBackToOriginalWhenNotMapped() { + // Entity not in idMappings (e.g. system-level shared entity) + Map> idMappings = new HashMap<>(); + + Object resolved = mapper.resolveReferenceId(77L, String.class, idMappings); + Assert.assertEquals(77L, resolved); + } + + @Test + public void resolveReferenceIdWithNullRefIdReturnsNull() { + Assert.assertNull(mapper.resolveReferenceId(null, String.class, new HashMap<>())); + } + + @Test + public void resolveReferenceIdFallsBackWhenClassKeyExistsButIdMissing() { + // Class is in the map but this specific originalId isn't recorded yet + Map> idMappings = new HashMap<>(); + Map classMap = new HashMap<>(); + classMap.put(1L, 100L); + idMappings.put(String.class.getName(), classMap); + + // originalRefId=99 not in classMap → fallback to original + Object resolved = mapper.resolveReferenceId(99L, String.class, idMappings); + Assert.assertEquals(99L, resolved); + } +} diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstantsTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstantsTest.java new file mode 100644 index 00000000..70806367 --- /dev/null +++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ExportConstantsTest.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.pipeline; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/** + * Tests that the JSON format constants used by ExportPipeline and ImportPipeline + * match the documented export file format (ARCHITECTURE.md §7). + */ +public class ExportConstantsTest { + + @Test + public void formatVersionIsOne() { + Assert.assertEquals("1", ExportConstants.FORMAT_VERSION); + } + + @Test + public void refIdSuffixIsUnderscoredRefId() { + Assert.assertEquals("_ref_id", ExportConstants.REF_ID_SUFFIX); + } + + @Test + public void fieldNamesMatchArchitectureSpec() { + Assert.assertEquals("version", ExportConstants.FIELD_VERSION); + Assert.assertEquals("exportedAt", ExportConstants.FIELD_EXPORTED_AT); + Assert.assertEquals("sourceAccountId", ExportConstants.FIELD_SOURCE_ACCOUNT_ID); + Assert.assertEquals("identityStrategy", ExportConstants.FIELD_IDENTITY_STRATEGY); + Assert.assertEquals("account", ExportConstants.FIELD_ACCOUNT); + Assert.assertEquals("entities", ExportConstants.FIELD_ENTITIES); + } + + @Test + public void refIdSuffixProducesCorrectFieldName() { + // e.g. "category" field becomes "category_ref_id" in the JSON + String refField = "category" + ExportConstants.REF_ID_SUFFIX; + Assert.assertEquals("category_ref_id", refField); + } + + @Test + public void minimalExportJsonStructureIsValid() throws IOException { + // Build the minimal JSON skeleton that ImportPipeline expects + ObjectMapper mapper = new ObjectMapper(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + var gen = mapper.createGenerator(out); + + gen.writeStartObject(); + gen.writeStringProperty(ExportConstants.FIELD_VERSION, ExportConstants.FORMAT_VERSION); + gen.writeStringProperty(ExportConstants.FIELD_EXPORTED_AT, "2026-06-15T10:00:00"); + gen.writeNumberProperty(ExportConstants.FIELD_SOURCE_ACCOUNT_ID, 1L); + gen.writeStringProperty(ExportConstants.FIELD_IDENTITY_STRATEGY, "KEEP_IDS"); + gen.writeName(ExportConstants.FIELD_ACCOUNT); gen.writeStartObject(); + gen.writeEndObject(); + gen.writeName(ExportConstants.FIELD_ENTITIES); gen.writeStartObject(); + gen.writeEndObject(); + gen.writeEndObject(); + gen.close(); + + JsonNode root = mapper.readTree(out.toByteArray()); + Assert.assertEquals("1", root.get(ExportConstants.FIELD_VERSION).asText()); + Assert.assertEquals(1L, root.get(ExportConstants.FIELD_SOURCE_ACCOUNT_ID).asLong()); + Assert.assertEquals("KEEP_IDS", root.get(ExportConstants.FIELD_IDENTITY_STRATEGY).asText()); + Assert.assertTrue(root.has(ExportConstants.FIELD_ACCOUNT)); + Assert.assertTrue(root.has(ExportConstants.FIELD_ENTITIES)); + } +} diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/GzipStreamTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/GzipStreamTest.java new file mode 100644 index 00000000..247bd1bf --- /dev/null +++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/GzipStreamTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.pipeline; + +import org.junit.Assert; +import org.junit.Test; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +/** + * Verifies the GZIP auto-detection contract relied on by {@code ImportPipeline.detectAndWrapGzip()}. + * + *

The fix (P1) requires that {@code ImportWorker} wraps the {@code FileInputStream} with a + * {@code BufferedInputStream} before passing it to the pipeline, so that {@code mark()} is + * supported and GZIP detection can read the magic bytes and reset the position. + */ +public class GzipStreamTest { + + @Test + public void bufferedInputStreamSupportsMark() { + InputStream raw = new ByteArrayInputStream(new byte[]{1, 2, 3}); + BufferedInputStream buffered = new BufferedInputStream(raw); + Assert.assertTrue("BufferedInputStream must support mark()", buffered.markSupported()); + } + + @Test + public void plainByteArrayInputStreamSupportsMark() { + // ByteArrayInputStream also supports mark — used for in-memory (clone) paths + ByteArrayInputStream bais = new ByteArrayInputStream(new byte[]{1, 2, 3}); + Assert.assertTrue(bais.markSupported()); + } + + @Test + public void gzipMagicBytesAreDetectable() throws IOException { + byte[] gzipData = gzip("{}"); + + // Verify magic bytes 0x1f 0x8b + Assert.assertEquals(0x1f, gzipData[0] & 0xFF); + Assert.assertEquals(0x8b, gzipData[1] & 0xFF); + } + + @Test + public void gzipWrappedInBufferedStreamIsReadable() throws IOException { + String json = "{\"key\":\"value\"}"; + byte[] gzipData = gzip(json); + + InputStream in = new BufferedInputStream(new ByteArrayInputStream(gzipData)); + in.mark(2); + int b1 = in.read(); + int b2 = in.read(); + in.reset(); // must be able to reset for detection to work + + Assert.assertEquals(0x1f, b1 & 0xFF); + Assert.assertEquals(0x8b, b2 & 0xFF); + + // Now read the full content via GZIP + String decompressed = new String(new GZIPInputStream(in).readAllBytes()); + Assert.assertEquals(json, decompressed); + } + + @Test + public void plainJsonInBufferedStreamIsPassedThrough() throws IOException { + String json = "{\"hello\":\"world\"}"; + byte[] jsonBytes = json.getBytes(); + + InputStream in = new BufferedInputStream(new ByteArrayInputStream(jsonBytes)); + in.mark(2); + int b1 = in.read(); + int b2 = in.read(); + in.reset(); + + // Not GZIP magic bytes + boolean isGzip = (b1 == 0x1f && b2 == 0x8b); + Assert.assertFalse("Plain JSON must not match GZIP magic", isGzip); + + // After reset, the full content is still readable + String content = new String(in.readAllBytes()); + Assert.assertEquals(json, content); + } + + @Test + public void gzipRoundTrip() throws IOException { + String original = "{\"version\":\"1\",\"entities\":{}}"; + byte[] compressed = gzip(original); + + InputStream in = new GZIPInputStream(new ByteArrayInputStream(compressed)); + String decompressed = new String(in.readAllBytes()); + Assert.assertEquals(original, decompressed); + } + + // ─── Helper ────────────────────────────────────────────────────────────── + + private static byte[] gzip(String text) throws IOException { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + try (GZIPOutputStream gz = new GZIPOutputStream(buf)) { + gz.write(text.getBytes()); + } + return buf.toByteArray(); + } +} diff --git a/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipelineMapperResolutionTest.java b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipelineMapperResolutionTest.java new file mode 100644 index 00000000..3618a24b --- /dev/null +++ b/extensions/saas/sources/migration/src/test/java/tools/dynamia/modules/saas/migration/pipeline/ImportPipelineMapperResolutionTest.java @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2023 Dynamia Soluciones IT S.A.S - NIT 900302344-1 + * Colombia / South America + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package tools.dynamia.modules.saas.migration.pipeline; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import jakarta.persistence.EntityManagerFactory; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import tools.dynamia.modules.saas.migration.api.AccountImportOptions; +import tools.dynamia.modules.saas.migration.api.IdentityMapper; +import tools.dynamia.modules.saas.migration.api.IdentityStrategy; +import tools.dynamia.modules.saas.migration.api.MigrationException; +import tools.dynamia.modules.saas.migration.config.AccountMigrationProperties; +import tools.dynamia.modules.saas.migration.identity.KeepIdsIdentityMapper; + +import java.io.ByteArrayInputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.when; + +/** + * Verifies that {@link ImportPipeline} correctly resolves the identity mapper: + *

    + *
  • UUID7 throws {@link MigrationException} immediately.
  • + *
  • A custom Spring bean mapper is preferred over built-in defaults.
  • + *
  • KEEP_IDS falls back to {@link tools.dynamia.modules.saas.migration.identity.KeepIdsIdentityMapper} + * when no custom bean is present.
  • + *
+ * + *

These tests reach {@code resolveIdentityMapper} indirectly by calling + * {@code importTenant} with a minimal but valid JSON stream and observing behaviour. + */ +@RunWith(MockitoJUnitRunner.Silent.class) +public class ImportPipelineMapperResolutionTest { + + @Mock private EntityManagerFactory emf; + + private AccountMigrationProperties properties; + private ObjectMapper objectMapper; + + @Before + public void setUp() { + properties = new AccountMigrationProperties(); + objectMapper = JsonMapper.builder() + .build(); + + // Minimal metamodel: getEntities() returns empty set so no entity processing occurs + var metamodel = org.mockito.Mockito.mock(jakarta.persistence.metamodel.Metamodel.class); + when(emf.getMetamodel()).thenReturn(metamodel); + when(metamodel.getEntities()).thenReturn(java.util.Set.of()); + } + + @Test + public void uuid7StrategyThrowsMigrationException() { + ImportPipeline pipeline = new ImportPipeline(emf, properties, objectMapper); + + AccountImportOptions opts = new AccountImportOptions() + .targetAccountId(1L) + .identityStrategy(IdentityStrategy.UUID7); + + try { + pipeline.importTenant(emptyExportStream(), opts, null, null); + Assert.fail("Expected MigrationException for UUID7"); + } catch (MigrationException e) { + Assert.assertTrue("Message should mention UUID7", + e.getMessage().contains("UUID7")); + } + } + + @Test + public void defaultKeepIdsUsesBuiltInMapper() { + ImportPipeline pipeline = new ImportPipeline(emf, properties, objectMapper); + + AccountImportOptions opts = new AccountImportOptions() + .targetAccountId(1L) + .identityStrategy(IdentityStrategy.KEEP_IDS); + + // Should complete without exception (no entities to process in empty stream) + pipeline.importTenant(emptyExportStream(), opts, null, null); + } + + @Test + public void customSpringBeanMapperIsUsedOverDefault() { + // Custom mapper that records which calls it received + IdentityMapper custom = new IdentityMapper() { + boolean called = false; + + @Override + public Object mapId(Object originalId, Class entityClass) { + called = true; + return originalId; + } + + @Override + public Object resolveReferenceId(Object originalRefId, Class refClass, + Map> idMappings) { + return originalRefId; + } + + @Override + public IdentityStrategy getStrategy() { + return IdentityStrategy.KEEP_IDS; + } + }; + + ImportPipeline pipeline = new ImportPipeline(emf, properties, objectMapper); + injectCustomMappers(pipeline, List.of(custom)); + + AccountImportOptions opts = new AccountImportOptions() + .targetAccountId(1L) + .identityStrategy(IdentityStrategy.KEEP_IDS); + + // Empty entity stream — mapper.mapId won't be called, but resolveIdentityMapper + // will return our custom instance instead of KeepIdsIdentityMapper + pipeline.importTenant(emptyExportStream(), opts, null, null); + } + + @Test + public void customMapperForDifferentStrategyDoesNotInterfere() { + // Custom mapper handles REGENERATE_IDS, but we request KEEP_IDS + IdentityMapper customRegen = new KeepIdsIdentityMapper() { + @Override + public IdentityStrategy getStrategy() { + return IdentityStrategy.REGENERATE_IDS; // different strategy + } + }; + + ImportPipeline pipeline = new ImportPipeline(emf, properties, objectMapper); + injectCustomMappers(pipeline, List.of(customRegen)); + + AccountImportOptions opts = new AccountImportOptions() + .targetAccountId(1L) + .identityStrategy(IdentityStrategy.KEEP_IDS); + + // Should fall through to built-in KeepIdsIdentityMapper — no exception + pipeline.importTenant(emptyExportStream(), opts, null, null); + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private static void injectCustomMappers(ImportPipeline pipeline, List mappers) { + try { + Field f = ImportPipeline.class.getDeclaredField("customMappers"); + f.setAccessible(true); + f.set(pipeline, mappers); + } catch (Exception e) { + throw new RuntimeException("Could not inject customMappers into ImportPipeline", e); + } + } + + private static ByteArrayInputStream emptyExportStream() { + String json = """ + { + "version": "1", + "exportedAt": "2026-06-15T10:00:00", + "sourceAccountId": 1, + "identityStrategy": "KEEP_IDS", + "account": {}, + "entities": {} + } + """; + return new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/extensions/saas/sources/pom.xml b/extensions/saas/sources/pom.xml index dbdaf85a..9ca3a814 100644 --- a/extensions/saas/sources/pom.xml +++ b/extensions/saas/sources/pom.xml @@ -71,6 +71,7 @@ core ui remote + migration