diff --git a/docs/en/connectors/changelog/connector-hugegraph.md b/docs/en/connectors/changelog/connector-hugegraph.md index 5bdc416dcf9c..26b40ba4562d 100644 --- a/docs/en/connectors/changelog/connector-hugegraph.md +++ b/docs/en/connectors/changelog/connector-hugegraph.md @@ -1,7 +1,13 @@ +--- +title: HugeGraph +--- +
Change Log | Change | Commit | Version | | --- | --- |---------| +|[Fix][Connector-V2] Enforce topology-safe execution order in HugeGraph multi-mapping sink|https://github.com/apache/seatunnel/commit/467980a6a0| dev | +|[Feature][Connector-V2] Add HugeGraph source connector and refactor sink with multi-mapping support|https://github.com/apache/seatunnel/commit/0f503024d7| dev | |[Feature][Connector-V2] Support sink connector for Apache HugeGraph|https://github.com/apache/seatunnel/pull/10002/commits/002a653d11f48c3f76b47db23f5f2a68bc9d690c| 2.3.12 |
diff --git a/docs/en/connectors/sink/HugeGraph.md b/docs/en/connectors/sink/HugeGraph.md index 9f24c73a0e77..e8ac7a39c130 100644 --- a/docs/en/connectors/sink/HugeGraph.md +++ b/docs/en/connectors/sink/HugeGraph.md @@ -15,14 +15,14 @@ This connector supports writing data as vertices or edges, providing flexible ma - [x] [batch](../../introduction/concepts/connector-v2-features.md) - [ ] [exactly-once](../../introduction/concepts/connector-v2-features.md) - [ ] [cdc](../../introduction/concepts/connector-v2-features.md) -- [ ] [support multiple table write](../../introduction/concepts/connector-v2-features.md) +- [x] [support multiple table write](../../introduction/concepts/connector-v2-features.md) - [x] [timer flush](../../introduction/concepts/connector-v2-features.md) The connector writes rows as either vertices or edges. It supports insert, update, and delete row kinds, and it can flush buffered records by `batch_size` or `batch_interval_ms`. :::caution -The HugeGraph schema must already exist before the job writes data. Create the required property keys, vertex labels, and edge labels in HugeGraph first, then use `schema_config` to map SeaTunnel fields to that existing graph schema. +New `mappings` configurations default to `schema_save_mode = CREATE_SCHEMA_WHEN_NOT_EXIST` and create missing HugeGraph PropertyKey/VertexLabel/EdgeLabel definitions before writing. Legacy `schema_config` jobs retain the previous `ERROR_WHEN_SCHEMA_NOT_EXIST` behavior unless this option is explicitly set. ::: @@ -32,28 +32,72 @@ The HugeGraph schema must already exist before the job writes data. Create the r | ------------------- | ------- | -------- | ------------- |--------------------------------------------------------------------------------| | `host` | String | Yes | - | The host of the HugeGraph server. | | `port` | Integer | Yes | - | The port of the HugeGraph server. | +| `protocol` | String | No | `http` | Server protocol: `http` or `https`. HTTPS uses the JVM trust store. | | `graph_name` | String | Yes | - | The name of the graph to write to. | -| `graph_space` | String | No | - | The graph space of the graph to be operated on. | +| `graph_space` | String | No | `DEFAULT` | The graph space the graph belongs to. | | `username` | String | No | - | The username for HugeGraph authentication. | | `password` | String | No | - | The password for HugeGraph authentication. | | `batch_size` | Integer | No | 500 | The number of records to buffer before writing to HugeGraph in a single batch. | | `batch_interval_ms` | Integer | No | 5000 | The maximum time in milliseconds to wait before flushing a batch. | -| `max_retries` | Integer | No | 3 | The maximum number of times to retry a failed write operation. | -| `retry_backoff_ms` | Integer | No | 5000 | The backoff time between retries in milliseconds. | +| `batch_failure_fallback` | Boolean | No | true | When a batch insert fails, fall back to inserting the batch record by record so a single bad ("poison") record no longer fails the whole batch. Failed records are logged and skipped; the rest succeed. If every record fails (systemic error), it is surfaced. Set to `false` to fail the whole batch instead. | +| `max_insert_errors` | Integer | No | 500 | Maximum number of records that may be skipped by the single-record fallback (`batch_failure_fallback=true`) before the task is failed. Bounds the otherwise unlimited silent skipping of poison records. Set to `-1` for unlimited. Only applies when `batch_failure_fallback` is enabled. | +| `failure_data_path` | String | No | - | Optional local directory. When set, every record skipped by the single-record fallback is appended (mapped id, label, properties and the server error) to a per-subtask file (`hugegraph-sink-failures-subtask-N.log`) for offline investigation. In cluster mode the file is created on the worker node running the sink subtask. | +| `check_vertex` | Boolean | No | false | Whether the server verifies that an edge's source/target vertices exist when writing edges. When `false`, edges whose endpoints were never loaded are written as orphan edges (or trigger server-side phantom vertex auto-creation). Enable to reject such edges. | +| `max_retries` | Integer | No | 3 | Retries after the initial attempt. Set to `0` to disable retries. | +| `retry_backoff_ms` | Integer | No | 5000 | Base backoff between retries in ms. Grows exponentially per attempt (`retry_backoff_ms * 2^(attempt-1)`), capped at `retry_backoff_max_ms`. | +| `retry_backoff_max_ms` | Integer | No | 30000 | Upper bound in ms for the exponential retry backoff. | ## Sink Options -| Name | Type | Required | Default Value | Description | -| ------------------ | ------ | -------- | ------------- |-----------------------------------------------------------------------------------------------------| -| `schema_config` | Object | Yes | - | The configuration for mapping the input data to HugeGraph's schema (vertices or edges). | -| `selected_fields` | List | No | - | A list of fields to be selected from the input data. If not specified, all fields will be used. | -| `ignored_fields` | List | No | - | A list of fields to be ignored from the input data. Mutually exclusive with `selected_fields`. | +| Name | Type | Required | Default Value | Description | +|----------------------------|---------|----------|---------------|-------------| +| `mappings` | List | Yes | - | Recommended mapping configuration. Each entry maps input rows to one HugeGraph vertex or edge label. | +| `schema_save_mode` | Enum | No | `CREATE_SCHEMA_WHEN_NOT_EXIST` for `mappings`; `ERROR_WHEN_SCHEMA_NOT_EXIST` for legacy `schema_config` | Schema management mode. | +| `data_save_mode` | Enum | No | `APPEND_DATA` | How pre-existing data is handled before writing. `APPEND_DATA` keeps existing data. `DROP_DATA` deletes, once at job start, only the data of the labels this job targets (edges then vertices), preserving their schema and any other labels' data; the drop is scoped per label (so one table's drop does not wipe another) and is not re-run on checkpoint restart. | +| `delete_vertex_with_edges` | Boolean | No | `false` for `mappings`; `true` for legacy `schema_config` | When true, DELETE rows for vertices also delete associated edges. | +| `schema_config` | Object | No | - | Deprecated legacy mapping object. Use `mappings` instead. Either `mappings` or `schema_config` must be specified. | +| `selected_fields` | List | No | - | Deprecated. Still honored with legacy `schema_config`; use mapping `properties` for new jobs. | +| `ignored_fields` | List | No | - | Deprecated. Still honored with legacy `schema_config`; use mapping `properties` for new jobs. | -`selected_fields` and `ignored_fields` are applied before the row is mapped to HugeGraph. Keep every field used by `idFields`, `sourceConfig.idFields`, `targetConfig.idFields`, `mapping.fieldMapping`, or `mapping.sortKeys`; otherwise the connector cannot build the vertex or edge ID. +If both `mappings` and `schema_config` are configured, `mappings` wins and `schema_config` is ignored with a warning. -### Schema Configuration (`schema_config`) +### Mapping Configuration (`mappings`) -`schema_config` defines how one input stream is mapped to a specific vertex or edge label in HugeGraph. +Each `mappings` entry defines how input rows are mapped to one HugeGraph vertex or edge label. + +| Name | Type | Required | Default Value | Description | +| ------------------ |--------------------| ---------- | ------------- |----------------------------------------------------------------------------------------------------------| +| `type` | String | Yes | - | The type of graph element to map to. Must be `VERTEX` or `EDGE`. | +| `label` | String | Yes | - | The label of the vertex or edge in HugeGraph. | +| `properties` | `List` | No | - | Source field names written as HugeGraph properties. If empty, all input fields are considered. | +| `ttl` | Long | No | - | The time-to-live for the vertex or edge in seconds. | +| `ttlStartTime` | String | No | - | The start time for the TTL. | +| `enableLabelIndex` | String | No | - | Reserved label-index setting passed through the mapping config. | +| `userdata` | `Map` | No | - | User-defined data associated with the label. | +| `idStrategy` | String | For Vertex | - | The ID generation strategy for vertices, such as `PRIMARY_KEY`, `CUSTOMIZE_STRING`, `CUSTOMIZE_NUMBER`, `CUSTOMIZE_UUID`, or `AUTOMATIC`. | +| `idFields` | `List` | For Vertex | - | A list of source field names used to generate the vertex ID. Required when `idStrategy` is not `AUTOMATIC`. | +| `sourceConfig` | Object | For Edge | - | An object defining the mapping for the edge's source vertex. See `Source/Target Config` below. | +| `targetConfig` | Object | For Edge | - | An object defining the mapping for the edge's target vertex. See `Source/Target Config` below. | +| `frequency` | String | For Edge | - | The frequency of the edge, e.g., `SINGLE`, `MULTIPLE`. | +| `sortKeys` | `List` | For Edge | - | **Source field names** (as they appear in the input row, *before* `fieldMapping` is applied) whose values distinguish edges sharing the same source and target vertices. Required when `frequency = MULTIPLE`. Example: with `fieldMapping = {event_time: created_at}`, use `sortKeys = [event_time]`, not `[created_at]`. | +| `fieldMapping` | `Map` | No | - | A map where the key is the source field name and the value is the target property name in HugeGraph. | +| `valueMapping` | `Map>` | No | - | Per-field value transform. Outer key = source field name; inner map = `originalValue -> newValue`. | +| `ignored` | `List` | No | - | Blacklist of source fields excluded from properties (implicit mode only). Mutually exclusive with `properties` (which acts as the selected whitelist). | +| `updateStrategies` | `Map` | No | - | Per-property merge strategy on write, keyed by target property name: `OVERRIDE`, `APPEND`, `SUM`, `UNION`, `BIGGER`, `SMALLER`, etc. When set, existing elements are merged instead of overwritten. | +| `nullableKeys` | `List` | No | - | Explicit allow-list of property keys that may be null on an auto-created label. When set, it overrides the default below (only these keys are nullable). Key properties (primary keys, `MULTIPLE`-edge sort keys) are always excluded. Mutually exclusive with `notNullableKeys`. | +| `notNullableKeys` | `List` | No | - | Opt-out list used with the default nullability. By default, when neither `nullableKeys` nor `notNullableKeys` is set, all non-key properties of an auto-created label are nullable; list here the properties that must instead be required. Mutually exclusive with `nullableKeys`. Only affects newly auto-created labels. | +| `nullValues` | `List` | No | - | A list of string values that should be treated as `null`. | +| `dateFormat` | String | No | `yyyy-MM-dd` | The date format for parsing date strings. | +| `extraDateFormats` | `List` | No | - | Additional date patterns tried in order, after `dateFormat`, when parsing date strings — for sources that mix multiple date formats. | +| `listFormat` | Object | No | - | How a raw string cell is parsed into SET/LIST property elements: `startSymbol` (default `[`), `endSymbol` (default `]`), `elemDelimiter` (default `,`), and `ignoredElems`. | +| `unfold` | Boolean | No | false | (Vertex) Expand a list-valued CUSTOMIZE id cell into one vertex per element. INSERT/append-only. | +| `unfoldSource` | Boolean | No | false | (Edge) Expand a list-valued source-endpoint id cell into multiple edges (CUSTOMIZE endpoint). INSERT/append-only. | +| `unfoldTarget` | Boolean | No | false | (Edge) Expand a list-valued target-endpoint id cell into multiple edges (cartesian with source). INSERT/append-only. | +| `timeZone` | String | No | Worker JVM default | The time zone for date parsing. When omitted, the worker JVM default time zone is used, matching the HugeGraph Source so a Source→Sink round-trip preserves absolute times. | + +### Legacy Schema Configuration (`schema_config`) + +`schema_config` defines how one input stream is mapped to a specific vertex or edge label in HugeGraph. It is deprecated; new jobs should use `mappings`. | Name | Type | Required | Default Value | Description | | ------------------ |--------------------| ---------- | ------------- |----------------------------------------------------------------------------------------------------------| @@ -79,7 +123,14 @@ This object is used within an `EDGE` schema to define how to identify the source | Name | Type | Required | Default Value | Description | | ---------- | ------------ | -------- | ------------- |--------------------------------------------------------------------------------------------------------------------------------------------------------------| | `label` | String | Yes | - | The label of the source or target vertex. | -| `idFields` | `List` | Yes | - | A list of source field names from the input row used to construct the ID of the source/target vertex. The values will be concatenated to form the vertex ID. | +| `idFields` | `List` | Yes | - | A list of source field names from the input row used to construct the ID of the source/target vertex. The values will be concatenated to form the vertex ID. For a HugeGraph → HugeGraph clone, set this to the single reserved column that already carries the assembled endpoint id — `["~source_id"]` for `sourceConfig`, `["~target_id"]` for `targetConfig` — and the connector reuses that id directly (see *Cloning from a HugeGraph Source* below). | + +### Cloning from a HugeGraph Source (reserved-id passthrough) + +When the input is the HugeGraph Source, each row carries reserved columns holding the already-assembled element ids (`~id` for a vertex; `~source_id`/`~target_id` for an edge's endpoints). To clone losslessly: + +- **Vertex** with a `CUSTOMIZE_STRING`/`CUSTOMIZE_NUMBER`/`CUSTOMIZE_UUID` id: set `idStrategy` to the matching `CUSTOMIZE_*` and `idFields = ["~id"]`; the original id is written verbatim. `PRIMARY_KEY` vertices instead reuse their key property columns (which the Source already emits), and `AUTOMATIC` ids cannot be preserved (the target server assigns new ones). +- **Edge**: set `sourceConfig.idFields = ["~source_id"]` and `targetConfig.idFields = ["~target_id"]`. The endpoint ids are reused directly, so edges clone regardless of the endpoint vertices' id strategies. The target endpoint vertex labels must already exist (the connector will not auto-create a vertex label from a reserved id). ### Mapping Config (`mapping`) @@ -88,12 +139,20 @@ This object provides advanced control over how fields and values are mapped to p | Name | Type | Required | Default Value | Description | | ----------------- |---------------------|----------| ------------- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `fieldMapping` | `Map` | No | - | A map where the key is the source field name and the value is the target property name in HugeGraph. If not specified, the source field name is used as the target property name. | -| `valueMapping` | `Map` | No | - | A map to transform specific field values. The key is the original value from the source, and the value is the new value to be written. | -| `nullableKeys` | `List` | No | - | A list of property keys that can have null values. | +| `valueMapping` | `Map>` | No | - | Per-field value transform. The outer key is the source field name; the inner map is `originalValue -> newValue`. Scoping by field prevents one column's rule from affecting another (e.g. `gender` M->male will not rewrite `status` M). | +| `ignored` | `List` | No | - | Blacklist of source fields excluded from properties (implicit mode only). Mutually exclusive with `properties` (which acts as the selected whitelist). | +| `updateStrategies` | `Map` | No | - | Per-property merge strategy on write, keyed by target property name: `OVERRIDE`, `APPEND`, `SUM`, `UNION`, `BIGGER`, `SMALLER`, etc. When set, existing elements are merged instead of overwritten. | +| `nullableKeys` | `List` | No | - | Explicit allow-list of property keys that may be null on an auto-created label. Overrides the nullable-by-default behavior. Mutually exclusive with `notNullableKeys`. | +| `notNullableKeys` | `List` | No | - | Opt-out list of properties that must be required, used together with the nullable-by-default behavior. Mutually exclusive with `nullableKeys`. | | `nullValues` | `List` | No | - | A list of string values that should be treated as `null`. Any field containing one of these values will not be written. | | `dateFormat` | String | No | `yyyy-MM-dd` | The date format for parsing date strings. | -| `timeZone` | String | No | `GMT+8` | The time zone for date parsing. | -| `sortKeys` | `List` | For Edge | - | A list of property keys to sort edges with the same source and target vertices. | +| `extraDateFormats`| `List` | No | - | Additional date patterns tried in order, after `dateFormat`, when parsing date strings — for sources that mix multiple date formats. | +| `listFormat` | Object | No | - | How a raw string cell is parsed into SET/LIST property elements: `startSymbol` (default `[`), `endSymbol` (default `]`), `elemDelimiter` (default `,`), and `ignoredElems`. | +| `unfold` | Boolean | No | false | (Vertex) Expand a list-valued CUSTOMIZE id cell into one vertex per element. INSERT/append-only. | +| `unfoldSource` | Boolean | No | false | (Edge) Expand a list-valued source-endpoint id cell into multiple edges (CUSTOMIZE endpoint). INSERT/append-only. | +| `unfoldTarget` | Boolean | No | false | (Edge) Expand a list-valued target-endpoint id cell into multiple edges (cartesian with source). INSERT/append-only. | +| `timeZone` | String | No | Worker JVM default | The time zone for date parsing. When omitted, the worker JVM default time zone is used. | +| `sortKeys` | `List` | For Edge | - | **Source field names** (before `fieldMapping` is applied) whose values distinguish edges sharing the same source and target vertices. Example: with `fieldMapping = {event_time: created_at}`, use `[event_time]`, not `[created_at]`. | ## Supported Types @@ -121,14 +180,18 @@ The connector validates the SeaTunnel row schema against the existing HugeGraph ## Write Behavior Notes -- For vertices, `idStrategy` controls how the vertex ID is built. `PRIMARY_KEY` joins all `idFields` with HugeGraph's primary-key format, `CUSTOMIZE_STRING` joins them with `:`, `CUSTOMIZE_NUMBER` expects one numeric field, and `CUSTOMIZE_UUID` expects one UUID field. +- For vertices, `idStrategy` controls how the vertex ID is built. `PRIMARY_KEY` joins all `idFields` with HugeGraph's primary-key format, `CUSTOMIZE_STRING` joins multiple `idFields` with `:` (backslash-escaping any `:` in a value so distinct field tuples cannot collide; a single field is used verbatim), `CUSTOMIZE_NUMBER` expects one integer-valued field (a fractional value like `1.9` is rejected, not silently truncated), and `CUSTOMIZE_UUID` expects one UUID field. - For edges, the connector reads the ID strategy from the existing source and target vertex labels in HugeGraph. `sourceConfig.idFields` and `targetConfig.idFields` must provide enough fields to rebuild those vertex IDs. - `INSERT` writes a new vertex or edge, `UPDATE_AFTER` updates the existing graph element, and `DELETE` deletes it. Delete rows only need the fields required to build the element ID. -- `mapping.nullValues` treats matching string values as null and skips those properties during writes. +- `AUTOMATIC` vertex IDs support INSERT only. UPDATE and DELETE require a reconstructable ID strategy. +- The sink is at-least-once. Replayed INSERT records with `AUTOMATIC` IDs can create duplicates after retries or checkpoint recovery. +- Edge batches use the configured `check_vertex` (default `false`). With the default, vertices and edges may be written out of order and the graph reaches its final consistent state after all batches complete; set `check_vertex=true` to have the server reject edges whose endpoints do not yet exist. +- `nullValues` treats matching string values as null and skips those properties during writes. +- Time zone is configured **per mapping** via `timeZone` (there is no top-level `time_zone` option on the sink, unlike the HugeGraph Source, because date parsing is a per-mapping concern). When omitted it defaults to the worker JVM zone, matching the Source so a Source→Sink round-trip preserves absolute times. ## Usage Examples -The examples below assume the corresponding HugeGraph schema has already been created. +The examples below use the default `schema_save_mode = CREATE_SCHEMA_WHEN_NOT_EXIST`. If you set `schema_save_mode = ERROR_WHEN_SCHEMA_NOT_EXIST`, create the corresponding HugeGraph schema before running the job. ### 1. Writing Vertices @@ -156,15 +219,15 @@ sink { host = "localhost" port = 8080 graph_name = "hugegraph" - graph_space = "default" - selected_fields = ["name", "age"] - schema_config = { - type = "VERTEX" - label = "person" - idStrategy = "PRIMARY_KEY" - idFields = ["name"] - properties = ["name", "age"] - } + mappings = [ + { + type = "VERTEX" + label = "person" + idStrategy = "PRIMARY_KEY" + idFields = ["name"] + properties = ["name", "age"] + } + ] } } ``` @@ -196,26 +259,25 @@ sink { host = "localhost" port = 8080 graph_name = "hugegraph" - graph_space = "default" - schema_config = { - type = "EDGE" - label = "knows" - sourceConfig = { - label = "person" - idFields = ["person1_name"] - } - targetConfig = { - label = "person" - idFields = ["person2_name"] - } - properties = ["since"] - mapping = { + mappings = [ + { + type = "EDGE" + label = "knows" + sourceConfig = { + label = "person" + idFields = ["person1_name"] + } + targetConfig = { + label = "person" + idFields = ["person2_name"] + } + properties = ["since"] fieldMapping = { person1_name = "name" person2_name = "name" } } - } + ] } } ``` diff --git a/docs/en/connectors/source/HugeGraph.md b/docs/en/connectors/source/HugeGraph.md new file mode 100644 index 000000000000..2059cf20275c --- /dev/null +++ b/docs/en/connectors/source/HugeGraph.md @@ -0,0 +1,190 @@ +import ChangeLog from '../changelog/connector-hugegraph.md'; + +# HugeGraph Source Connector + +`Source: HugeGraph` + +## Description + +The HugeGraph source connector reads graph data from Apache HugeGraph through the HugeGraph REST API. + +It performs a bounded scan of one vertex label or one edge label — or of **all** labels of a type in a single job — and checkpoints its progress so a job can resume after failover. + +- At `parallelism = 1` it pages the label via the server-side list API, following HugeGraph page markers until the server returns `page = null`. Server-side `filter` (property-equality) is applied in this mode. +- At `parallelism > 1` it splits the keyspace into shards (via the HugeGraph `traverser().vertexShards / edgeShards` API) and scans them across parallel readers. Because the shard scan is by key range and returns all labels, the connector filters to the configured `label` client-side. See [Parallel read](#parallel-read). +- When `label` is omitted, it reads every label of `label_type` (default `VERTEX`) in one job, producing one output table per label. See [Read all labels](#read-all-labels). + +## Key Features + +- [x] [batch](../../introduction/concepts/connector-v2-features.md) +- [x] [parallelism](../../introduction/concepts/connector-v2-features.md) +- [ ] [cdc](../../introduction/concepts/connector-v2-features.md) + +## Options + +| Name | Type | Required | Default | Description | +|--------------------|---------|----------|----------|-------------| +| `host` | String | Yes | - | HugeGraph server host. | +| `port` | Integer | Yes | - | HugeGraph server port. | +| `protocol` | String | No | `http` | Server protocol: `http` or `https`. HTTPS uses the JVM trust store. | +| `graph_name` | String | Yes | - | HugeGraph graph name. | +| `label` | String | No | - | Vertex label or edge label to read. **When omitted, the connector reads all labels of `label_type` in one job, producing one table per label** (see [Read all labels](#read-all-labels)); `schema` and `filter` are not allowed in that mode. | +| `schema` | Object | No | - | Output property columns declared with `schema.fields`. Reserved graph columns are added by the connector. **When omitted, the connector auto-discovers all property columns of `label` from the server (types inferred, columns ordered by name).** See [Schema auto-discovery](#schema-auto-discovery). | +| `label_type` | Enum | No | `VERTEX` | Label type. Supported values: `VERTEX`, `EDGE`. | +| `page_size` | Integer | No | `1000` | Number of records per HugeGraph page. Must be in range `[100, 10000]`. | +| `split_size` | Long | No | `1048576` | Target size in bytes of each key-range shard when `parallelism > 1`. A larger value yields fewer, bigger shards. Must be at least `1048576` (1 MiB, the HugeGraph minimum shard size) — a smaller value is rejected at startup to avoid shard explosion. Ignored at `parallelism = 1`. Requires a scan-capable backend (RocksDB / HBase / Cassandra). | +| `filter` | Map | No | - | Optional property equality conditions applied server-side, for example `{ country = "US", active = "true" }`. Only elements whose properties match all entries are returned. Every key must be a property of `label` (an unknown key fails at startup), and each value is coerced to that property's type (e.g. `"true"` → boolean, `"7"` → the numeric type) so it matches server-side — a value that cannot be coerced fails at startup instead of silently returning 0 rows. When omitted, all elements of the label are read. **Cannot be combined with `parallelism > 1`** (the shard scan cannot push property filters server-side); the job fails at startup if both are set. | +| `time_zone` | String | No | Worker JVM default | ZoneId used to convert HugeGraph DATE values the server returns as an epoch/Date, for example `UTC` or `Asia/Shanghai`. It does not apply to DATE values the server already serializes as a wall-clock string (those carry no zone and are kept verbatim). Set it explicitly when workers may use different JVM time zones. | +| `graph_space` | String | No | `DEFAULT` | The graph space the graph belongs to. | +| `username` | String | No | - | HugeGraph username. | +| `password` | String | No | - | HugeGraph password. | +| `max_retries` | Integer | No | `3` | Retries after the initial attempt. Set to `0` to disable retries. | +| `retry_backoff_ms` | Integer | No | `5000` | Base backoff between retries in ms. Grows exponentially per attempt (`retry_backoff_ms * 2^(attempt-1)`), capped at `retry_backoff_max_ms`. | +| `retry_backoff_max_ms` | Integer | No | `30000` | Upper bound in ms for the exponential retry backoff. | + +## Output Schema + +Vertex output columns: + +```text +~id, ~label, +``` + +Edge output columns: + +```text +~id, ~label, ~source_id, ~source_label, ~target_id, ~target_label, +``` + +Columns prefixed with `~` are reserved columns added by the connector. HugeGraph property keys cannot start with `~`, so they do not conflict with user properties. + +## Type Mapping + +`schema.fields` must match the HugeGraph property key type. The connector validates this before reading. + +| HugeGraph type | SeaTunnel type | +|----------------|----------------| +| `TEXT` | `STRING` | +| `BYTE` | `TINYINT` | +| `INT` | `INT` | +| `LONG` | `BIGINT` | +| `FLOAT` | `FLOAT` | +| `DOUBLE` | `DOUBLE` | +| `BOOLEAN` | `BOOLEAN` | +| `DATE` | `TIMESTAMP` | +| `UUID` | `STRING` | +| `OBJECT` | `STRING` | +| `BLOB` | `BYTES` | + +### Multi-valued (LIST / SET) properties + +A HugeGraph property whose cardinality is `LIST` or `SET` is read as a SeaTunnel `ARRAY`. Declare it in `schema.fields` as `array`, where `T` is the SeaTunnel type of the element (from the table above). For example, a `LIST` property named `tags` is declared as `tags = "array"`. + +Notes: + +- `SET` elements have no guaranteed order on the server; use `LIST` when order matters. +- If a property has cardinality `LIST`/`SET` on the server but is declared as a scalar (or vice versa), the job fails at startup with a message telling you the correct declaration. +- `BLOB` elements inside a `LIST`/`SET` are not supported. + +## Example + +```hocon +source { + HugeGraph { + host = "localhost" + port = 8080 + graph_name = "hugegraph" + label = "person" + label_type = "VERTEX" + page_size = 1000 + schema = { + fields = { + name = "string" + age = "int" + } + } + } +} +``` + +## Schema auto-discovery + +`schema` is optional. When omitted, the connector connects to the server at job build time, reads the definition of `label`, and produces one output column per property key (types from the [Type Mapping](#type-mapping) table, `LIST`/`SET` as `array`), ordered by property name. This is convenient for a full-label dump when you do not want to hand-declare every field. + +```hocon +source { + HugeGraph { + host = "localhost" + port = 8080 + graph_name = "hugegraph" + label = "person" + label_type = "VERTEX" + # no schema block: all properties of "person" are read + } +} +``` + +Notes: + +- The label must already exist on the server, otherwise the job fails at build time. +- A label with no property keys produces only the reserved columns (`~id`, `~label`, …). +- Declare `schema.fields` explicitly when you want to read only a subset of properties, fix the column order, or pin the types. + +## Read all labels + +Omit `label` to read **every** label of `label_type` (default `VERTEX`) in a single job — convenient for a full-graph migration or backup instead of configuring one source per label. At job build time the connector lists all labels of the type from the server schema and produces one output table per label, each with its own auto-discovered columns (see [Schema auto-discovery](#schema-auto-discovery)). Each output row carries its label's table id, so a downstream multi-table sink routes it to the matching table. + +```hocon +source { + HugeGraph { + host = "localhost" + port = 8080 + graph_name = "hugegraph" + label_type = "VERTEX" + # no label: every vertex label is read, one table each + } +} +``` + +Notes: + +- One job reads vertices **or** edges, not both: set `label_type = "EDGE"` to read all edge labels. +- `schema` is not allowed (a single schema cannot describe multiple labels) — columns are always auto-discovered per label. +- `filter` is not allowed (a property-equality filter assumes the property exists on every label). +- Each label becomes one `LABEL_LIST` split, distributed across readers (parallelism is bounded by the number of labels). Shard-level parallelism within a single label is not used in this mode. +- The job fails at build time if the graph has no label of the requested type. + +## Parallel read + +For large graphs, set `parallelism > 1` to read a label in parallel. The enumerator asks HugeGraph to split the label's keyspace into shards of roughly `split_size` bytes and distributes them round-robin across readers, so throughput scales with parallelism instead of being bound by a single paging cursor. + +```hocon +source { + HugeGraph { + host = "localhost" + port = 8080 + graph_name = "hugegraph" + label = "person" + label_type = "VERTEX" + parallelism = 8 + split_size = 1048576 + schema = { + fields = { + name = "string" + age = "int" + } + } + } +} +``` + +Notes: + +- Shard scans require a scan-capable backend (RocksDB / HBase / Cassandra). The `memory` backend does not support shard splitting; use `parallelism = 1` there. +- A shard scan returns elements of all labels in the key range; the connector keeps only the configured `label`. On a graph where the target label is a small fraction of the data, a single-parallelism `filter`ed read may move less data even though it does not parallelize. +- `filter` is not supported with `parallelism > 1`; keep `parallelism = 1` to use a server-side filter, or drop the filter to read in parallel. +- Tune `split_size`: a smaller value yields more, smaller shards (finer load balancing, more requests); a larger value yields fewer, bigger shards. The minimum is `1048576` (1 MiB); smaller values are rejected to avoid splitting the keyspace into an excessive number of shards. + +## Changelog + + diff --git a/docs/zh/connectors/changelog/connector-hugegraph.md b/docs/zh/connectors/changelog/connector-hugegraph.md index e69de29bb2d1..26b40ba4562d 100644 --- a/docs/zh/connectors/changelog/connector-hugegraph.md +++ b/docs/zh/connectors/changelog/connector-hugegraph.md @@ -0,0 +1,13 @@ +--- +title: HugeGraph +--- + +
Change Log + +| Change | Commit | Version | +| --- | --- |---------| +|[Fix][Connector-V2] Enforce topology-safe execution order in HugeGraph multi-mapping sink|https://github.com/apache/seatunnel/commit/467980a6a0| dev | +|[Feature][Connector-V2] Add HugeGraph source connector and refactor sink with multi-mapping support|https://github.com/apache/seatunnel/commit/0f503024d7| dev | +|[Feature][Connector-V2] Support sink connector for Apache HugeGraph|https://github.com/apache/seatunnel/pull/10002/commits/002a653d11f48c3f76b47db23f5f2a68bc9d690c| 2.3.12 | + +
diff --git a/docs/zh/connectors/sink/HugeGraph.md b/docs/zh/connectors/sink/HugeGraph.md index 203f50b622fe..7f5e94c22ce8 100644 --- a/docs/zh/connectors/sink/HugeGraph.md +++ b/docs/zh/connectors/sink/HugeGraph.md @@ -15,14 +15,14 @@ HugeGraph sink连接器允许您将数据从SeaTunnel写入Apache HugeGraph, - [x] [批处理](../../introduction/concepts/connector-v2-features.md) - [ ] [精确一次](../../introduction/concepts/connector-v2-features.md) - [ ] [CDC](../../introduction/concepts/connector-v2-features.md) -- [ ] [支持多表写入](../../introduction/concepts/connector-v2-features.md) +- [x] [支持多表写入](../../introduction/concepts/connector-v2-features.md) - [x] [定时刷新](../../introduction/concepts/connector-v2-features.md) 该连接器可以把输入行写成顶点或边,支持插入、更新、删除,并且可以按 `batch_size` 或 `batch_interval_ms` 刷新缓存数据。 :::caution -运行任务前,需要先在 HugeGraph 中创建好对应的属性键、顶点标签和边标签。`schema_config` 只负责把 SeaTunnel 字段映射到已有图结构,不会自动创建 HugeGraph Schema。 +新的 `mappings` 配置默认使用 `schema_save_mode = CREATE_SCHEMA_WHEN_NOT_EXIST`,写入前会创建缺失的 HugeGraph PropertyKey/VertexLabel/EdgeLabel。Legacy `schema_config` 任务在未显式设置该选项时保留原有的 `ERROR_WHEN_SCHEMA_NOT_EXIST` 行为。 ::: @@ -32,28 +32,72 @@ HugeGraph sink连接器允许您将数据从SeaTunnel写入Apache HugeGraph, | ------------------- | ------- | -------- | ------ | ---------------------------------------------------------------------- | | `host` | String | 是 | - | HugeGraph服务器的主机。 | | `port` | Integer | 是 | - | HugeGraph服务器的端口。 | +| `protocol` | String | 否 | `http` | 服务协议,支持 `http`、`https`。HTTPS 使用 JVM trust store。 | | `graph_name` | String | 是 | - | 要写入的图的名称。 | -| `graph_space` | String | 否 | - | 要操作的图的图空间。 | +| `graph_space` | String | 否 | `DEFAULT` | 图所属的图空间(graph space)。 | | `username` | String | 否 | - | 用于HugeGraph身份验证的用户名。 | | `password` | String | 否 | - | 用于HugeGraph身份验证的密码。 | | `batch_size` | Integer | 否 | 500 | 在单批次写入HugeGraph之前缓冲的记录数。 | | `batch_interval_ms` | Integer | 否 | 5000 | 刷新批次前等待的最大时间(毫秒)。 | -| `max_retries` | Integer | 否 | 3 | 重试失败写入操作的最大次数。 | -| `retry_backoff_ms` | Integer | 否 | 5000 | 重试之间的退避时间(毫秒)。 | +| `batch_failure_fallback` | Boolean | 否 | true | 批量写入失败时,降级为逐条写入,使单条“毒药”记录不再拖垮整批。失败记录会记录日志并跳过,其余成功;若整批全部失败(系统性错误)则抛出。设为 `false` 则整批失败。 | +| `max_insert_errors` | Integer | 否 | 500 | 逐条降级(`batch_failure_fallback=true`)累计跳过的失败记录达到该数量后使任务失败,用于约束原本无上限的“毒药”记录静默跳过。设为 `-1` 表示不限。仅在开启 `batch_failure_fallback` 时生效。 | +| `failure_data_path` | String | 否 | - | 可选本地目录。设置后,逐条降级跳过的每条记录(映射后的 id、label、属性及服务端错误)会追加写入按子任务区分的文件(`hugegraph-sink-failures-subtask-N.log`)以便离线排查。集群模式下文件写在运行该 sink 子任务的 worker 节点上。 | +| `check_vertex` | Boolean | 否 | false | 写入边时服务端是否校验边的源/目标顶点是否存在。为 `false` 时,端点从未写入的边会被写成孤儿边(或触发服务端幻影顶点自动创建)。开启后此类边会被拒绝。 | +| `max_retries` | Integer | 否 | 3 | 首次请求失败后的重试次数。设置为 `0` 可禁用重试。 | +| `retry_backoff_ms` | Integer | 否 | 5000 | 重试的基础退避时间(毫秒),按尝试次数指数增长(`retry_backoff_ms * 2^(attempt-1)`),上限为 `retry_backoff_max_ms`。 | +| `retry_backoff_max_ms` | Integer | 否 | 30000 | 指数退避的上限(毫秒)。 | ## Sink选项 -| 名称 | 类型 | 是否必须 | 默认值 | 描述 | -| ------------------ | ------ | -------- | ------ | -------------------------------------------------------------------- | -| `schema_config` | Object | 是 | - | 将输入数据映射到HugeGraph的Schema(顶点或边)的配置。 | -| `selected_fields` | List | 否 | - | 要从输入数据中选择的字段列表。如果未指定,将使用所有字段。 | -| `ignored_fields` | List | 否 | - | 要从输入数据中忽略的字段列表。与`selected_fields`互斥。 | - -`selected_fields` 和 `ignored_fields` 会在字段映射到 HugeGraph 之前生效。请保留 `idFields`、`sourceConfig.idFields`、`targetConfig.idFields`、`mapping.fieldMapping` 或 `mapping.sortKeys` 会用到的字段,否则连接器无法生成顶点或边的 ID。 - -### Schema配置 (`schema_config`) - -`schema_config` 定义一个输入流如何映射到 HugeGraph 中的某个顶点标签或边标签。 +| 名称 | 类型 | 是否必须 | 默认值 | 描述 | +|----------------------------|---------|----------|--------|------| +| `mappings` | List | 是 | - | 推荐的映射配置。每个条目将输入行映射到一个 HugeGraph 顶点或边标签。 | +| `schema_save_mode` | Enum | 否 | `mappings` 为 `CREATE_SCHEMA_WHEN_NOT_EXIST`;legacy 为 `ERROR_WHEN_SCHEMA_NOT_EXIST` | Schema 管理模式。 | +| `data_save_mode` | Enum | 否 | `APPEND_DATA` | 写入前如何处理已有数据。`APPEND_DATA` 保留已有数据;`DROP_DATA` 在任务开始时**仅**删除本任务涉及的 label 的数据(先边后点),保留其 schema 以及其他 label 的数据;删除按 label 隔离(某张表的 DROP 不会波及其他表),且在 checkpoint 重启时不会重复执行。 | +| `delete_vertex_with_edges` | Boolean | 否 | `mappings` 为 `false`;legacy 为 `true` | 为 true 时,顶点 DELETE 行会同时删除关联边。 | +| `schema_config` | Object | 否 | - | 已废弃的 legacy 映射对象。请使用 `mappings`。必须配置 `mappings` 或 `schema_config` 之一。 | +| `selected_fields` | List | 否 | - | 已废弃。Legacy `schema_config` 仍会应用;新任务请使用 mapping 内的 `properties`。 | +| `ignored_fields` | List | 否 | - | 已废弃。Legacy `schema_config` 仍会应用;新任务请使用 mapping 内的 `properties`。 | + +如果同时配置 `mappings` 和 `schema_config`,connector 会使用 `mappings`,并输出警告说明 `schema_config` 被忽略。 + +### 映射配置 (`mappings`) + +每个 `mappings` 条目定义输入行如何映射到一个 HugeGraph 顶点标签或边标签。 + +| 名称 | 类型 | 是否必须 | 默认值 | 描述 | +|--------------------|---------------------|----------|---------|------| +| `type` | String | 是 | - | 要映射到的图元素类型。必须是 `VERTEX` 或 `EDGE`。 | +| `label` | String | 是 | - | HugeGraph 中顶点或边的标签。 | +| `properties` | `List` | 否 | - | 要写入 HugeGraph 属性的源字段名。为空时会考虑所有输入字段。 | +| `ttl` | Long | 否 | - | 顶点或边的生存时间,单位秒。 | +| `ttlStartTime` | String | 否 | - | TTL 的开始时间。 | +| `enableLabelIndex` | String | 否 | - | 随 mapping 配置传入的预留标签索引配置。 | +| `userdata` | `Map` | 否 | - | 与标签关联的用户自定义数据。 | +| `idStrategy` | String | 对于顶点 | - | 顶点 ID 生成策略,例如 `PRIMARY_KEY`、`CUSTOMIZE_STRING`、`CUSTOMIZE_NUMBER`、`CUSTOMIZE_UUID` 或 `AUTOMATIC`。 | +| `idFields` | `List` | 对于顶点 | - | 用于生成顶点 ID 的源字段名。当 `idStrategy` 不是 `AUTOMATIC` 时必填。 | +| `sourceConfig` | Object | 对于边 | - | 定义边的源顶点映射。请参阅下面的 `Source/Target Config`。 | +| `targetConfig` | Object | 对于边 | - | 定义边的目标顶点映射。请参阅下面的 `Source/Target Config`。 | +| `frequency` | String | 对于边 | - | 边频率,例如 `SINGLE`、`MULTIPLE`。 | +| `sortKeys` | `List` | 对于边 | - | **输入行中的源字段名**(映射前、即 `fieldMapping` 应用之前的名字),用于区分相同源点和目标点之间的多条边。当 `frequency = MULTIPLE` 时必填。示例:当 `fieldMapping = {event_time: created_at}` 时,应填 `sortKeys = [event_time]`,而不是 `[created_at]`。 | +| `fieldMapping` | `Map` | 否 | - | 字段映射,key 为源字段名,value 为 HugeGraph 目标属性名。 | +| `valueMapping` | `Map>` | 否 | - | 按字段的值转换映射。外层键为源字段名,内层为 `原始值 -> 新值`。按字段隔离可避免一个列的规则影响其他列(如 `gender` 的 M->male 不会改写 `status` 的 M)。 | +| `ignored` | `List` | 否 | - | 从属性中排除的源字段黑名单(仅隐式模式生效)。与 `properties`(充当 selected 白名单)互斥。 | +| `updateStrategies` | `Map` | 否 | - | 写入时按目标属性名指定的属性级合并策略:`OVERRIDE`、`APPEND`、`SUM`、`UNION`、`BIGGER`、`SMALLER` 等。设置后对已存在元素做合并而非覆盖。 | +| `nullableKeys` | `List` | 否 | - | 自动建 label 时允许为 null 的属性键白名单。设置后覆盖下述默认行为(仅这些键可空)。主键、`MULTIPLE` 边的 sortKeys 等 key 属性始终排除。与 `notNullableKeys` 互斥。 | +| `notNullableKeys` | `List` | 否 | - | 与默认可空行为配合使用的反向 opt-out 列表。默认情况下(既未配 `nullableKeys` 也未配 `notNullableKeys`),自动建 label 的所有非 key 属性均可空;在此列出必须为非空的属性。与 `nullableKeys` 互斥。仅影响新建 label。 | +| `nullValues` | `List` | 否 | - | 应被视为 `null` 的字符串值列表。 | +| `dateFormat` | String | 否 | `yyyy-MM-dd` | 用于解析日期字符串的日期格式。 | +| `extraDateFormats` | `List` | 否 | - | 解析日期字符串时,在 `dateFormat` 之后按顺序尝试的额外日期格式——用于多源汇入、日期格式不一致的场景。 | +| `listFormat` | Object | 否 | - | 原始字符串如何解析为 SET/LIST 属性元素:`startSymbol`(默认 `[`)、`endSymbol`(默认 `]`)、`elemDelimiter`(默认 `,`)、`ignoredElems`。 | +| `unfold` | Boolean | 否 | false | (顶点)把 list 型 CUSTOMIZE id 单元格展开为每个元素一个顶点。仅 INSERT/append。 | +| `unfoldSource` | Boolean | 否 | false | (边)把 list 型源端点 id 单元格展开为多条边(CUSTOMIZE 端点)。仅 INSERT/append。 | +| `unfoldTarget` | Boolean | 否 | false | (边)把 list 型目标端点 id 单元格展开为多条边(与源端笛卡尔积)。仅 INSERT/append。 | +| `timeZone` | String | 否 | Worker JVM 默认 | 用于日期解析的时区。省略时使用 Worker JVM 默认时区,与 HugeGraph Source 一致,从而保证 Source→Sink 往返时绝对时间不变。 | + +### Legacy Schema配置 (`schema_config`) + +`schema_config` 定义一个输入流如何映射到 HugeGraph 中的某个顶点标签或边标签。该配置已废弃,新任务应使用 `mappings`。 | 名称 | 类型 | 是否必须 | 默认值 | 描述 | | ------------------ | ------------------- | -------- | ------- |------------------------------------------------------------| @@ -79,7 +123,14 @@ HugeGraph sink连接器允许您将数据从SeaTunnel写入Apache HugeGraph, | 名称 | 类型 | 是否必须 | 默认值 | 描述 | | ---------- | ------------ | -------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `label` | String | 是 | - | 源或目标顶点的标签。 | -| `idFields` | `List` | 是 | - | 用于构造源/目标顶点ID的输入行中的源字段名称列表。这些值将被连接起来形成顶点ID。 | +| `idFields` | `List` | 是 | - | 用于构造源/目标顶点ID的输入行中的源字段名称列表。这些值将被连接起来形成顶点ID。对于 HugeGraph → HugeGraph 克隆,可将其设为已携带完整端点 id 的保留列——`sourceConfig` 用 `["~source_id"]`,`targetConfig` 用 `["~target_id"]`——连接器会直接复用该 id(见下方 *从 HugeGraph Source 克隆*)。 | + +### 从 HugeGraph Source 克隆(保留列 id 直连) + +当输入来自 HugeGraph Source 时,每行都带有保留列,携带已拼好的元素 id(顶点为 `~id`;边的端点为 `~source_id`/`~target_id`)。全保真克隆方式: + +- **顶点**(`CUSTOMIZE_STRING`/`CUSTOMIZE_NUMBER`/`CUSTOMIZE_UUID` id):将 `idStrategy` 设为对应的 `CUSTOMIZE_*`,`idFields = ["~id"]`,原始 id 原样写入。`PRIMARY_KEY` 顶点改用其主键属性列(Source 已输出);`AUTOMATIC` id 无法保留(目标服务端会重新分配)。 +- **边**:设 `sourceConfig.idFields = ["~source_id"]`、`targetConfig.idFields = ["~target_id"]`。端点 id 被直接复用,因此无论端点顶点的 id 策略为何都能克隆。目标端点的顶点 label 必须已存在(连接器不会用保留 id 自动建顶点 label)。 ### Mapping配置 (`mapping`) @@ -88,12 +139,20 @@ HugeGraph sink连接器允许您将数据从SeaTunnel写入Apache HugeGraph, | 名称 | 类型 | 是否必须 | 默认值 | 描述 | | ----------------- | ------------------ | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fieldMapping` | `Map` | 否 | - | 一个映射,其中键是源字段名,值是HugeGraph中的目标属性名。如果未指定,则使用源字段名作为目标属性名。 | -| `valueMapping` | `Map` | 否 | - | 用于转换特定字段值的映射。键是源的原始值,值是要写入的新值。 | -| `nullableKeys` | `List` | 否 | - | 可以具有null值的属性键列表。 | +| `valueMapping` | `Map>` | 否 | - | 按字段的值转换映射。外层键为源字段名,内层为 `原始值 -> 新值`。按字段隔离,一个列的替换规则不会影响其他列。 | +| `ignored` | `List` | 否 | - | 从属性中排除的源字段黑名单(仅隐式模式生效)。与 `properties`(充当 selected 白名单)互斥。 | +| `updateStrategies` | `Map` | 否 | - | 写入时按目标属性名指定的属性级合并策略:`OVERRIDE`、`APPEND`、`SUM`、`UNION`、`BIGGER`、`SMALLER` 等。设置后对已存在元素做合并而非覆盖。 | +| `nullableKeys` | `List` | 否 | - | 自动建 label 时允许为 null 的属性键白名单。设置后覆盖默认可空行为。与 `notNullableKeys` 互斥。 | +| `notNullableKeys` | `List` | 否 | - | 与默认可空行为配合的反向 opt-out 列表,在此列出必须为非空的属性。与 `nullableKeys` 互斥。 | | `nullValues` | `List` | 否 | - | 应被视为`null`的字符串值列表。任何包含这些值的字段都不会被写入。 | | `dateFormat` | String | 否 | `yyyy-MM-dd` | 用于解析日期字符串的日期格式。 | -| `timeZone` | String | 否 | `GMT+8` | 用于日期解析的时区。 | -| `sortKeys` | `List` | 对于边 | - | 用于对具有相同源和目标顶点的边进行排序的属性键列表。 | +| `extraDateFormats`| `List` | 否 | - | 解析日期字符串时,在 `dateFormat` 之后按顺序尝试的额外日期格式——用于多源汇入、日期格式不一致的场景。 | +| `listFormat` | Object | 否 | - | 原始字符串如何解析为 SET/LIST 属性元素:`startSymbol`(默认 `[`)、`endSymbol`(默认 `]`)、`elemDelimiter`(默认 `,`)、`ignoredElems`。 | +| `unfold` | Boolean | 否 | false | (顶点)把 list 型 CUSTOMIZE id 单元格展开为每个元素一个顶点。仅 INSERT/append。 | +| `unfoldSource` | Boolean | 否 | false | (边)把 list 型源端点 id 单元格展开为多条边(CUSTOMIZE 端点)。仅 INSERT/append。 | +| `unfoldTarget` | Boolean | 否 | false | (边)把 list 型目标端点 id 单元格展开为多条边(与源端笛卡尔积)。仅 INSERT/append。 | +| `timeZone` | String | 否 | Worker JVM 默认 | 用于日期解析的时区。省略时使用 Worker JVM 默认时区。 | +| `sortKeys` | `List` | 对于边 | - | **输入行中的源字段名**(`fieldMapping` 应用之前),用于区分相同源点和目标点之间的多条边。示例:当 `fieldMapping = {event_time: created_at}` 时,应填 `[event_time]`,而不是 `[created_at]`。 | ## 支持的数据类型 @@ -121,14 +180,18 @@ HugeGraph sink连接器允许您将数据从SeaTunnel写入Apache HugeGraph, ## 写入行为说明 -- 写入顶点时,`idStrategy` 决定如何生成顶点 ID。`PRIMARY_KEY` 会按 HugeGraph 主键格式拼接所有 `idFields`,`CUSTOMIZE_STRING` 会用 `:` 拼接字段,`CUSTOMIZE_NUMBER` 需要一个数字字段,`CUSTOMIZE_UUID` 需要一个 UUID 字段。 +- 写入顶点时,`idStrategy` 决定如何生成顶点 ID。`PRIMARY_KEY` 会按 HugeGraph 主键格式拼接所有 `idFields`;`CUSTOMIZE_STRING` 在多字段时用 `:` 拼接(并对字段值中的 `:` 做反斜杠转义,避免不同字段组合产生相同 id;单字段时原样使用);`CUSTOMIZE_NUMBER` 需要一个整数值字段(`1.9` 之类的小数会被拒绝,不会被静默截断);`CUSTOMIZE_UUID` 需要一个 UUID 字段。 - 写入边时,连接器会从 HugeGraph 中已有的源顶点标签和目标顶点标签读取 ID 策略。`sourceConfig.idFields` 和 `targetConfig.idFields` 必须能还原对应顶点 ID。 - `INSERT` 会写入新的顶点或边,`UPDATE_AFTER` 会更新已有图元素,`DELETE` 会删除图元素。删除行只需要包含能生成图元素 ID 的字段。 -- `mapping.nullValues` 中列出的字符串会被当作空值处理,写入时会跳过这些属性。 +- `AUTOMATIC` 顶点 ID 仅支持 INSERT;UPDATE 和 DELETE 必须使用可还原 ID 的策略。 +- Sink 提供 at-least-once 语义。使用 `AUTOMATIC` ID 时,重试或 checkpoint 恢复后的 INSERT 重放可能产生重复顶点。 +- 边批次使用所配置的 `check_vertex`(默认 `false`)。默认情况下顶点与边可能乱序写入,所有批次完成后图达到最终一致状态;设为 `check_vertex=true` 则服务端会拒绝端点尚不存在的边。 +- `nullValues` 中列出的字符串会被当作空值处理,写入时会跳过这些属性。 +- 时区通过**每个 mapping** 的 `timeZone` 配置(sink 没有顶层 `time_zone` 选项,与 HugeGraph Source 不同,因为日期解析属于每个 mapping 的行为)。省略时默认使用 Worker JVM 时区,与 Source 一致,从而保证 Source→Sink 往返时绝对时间不变。 ## 使用示例 -下面示例默认 HugeGraph 中已经提前创建好对应 Schema。 +下面示例使用默认的 `schema_save_mode = CREATE_SCHEMA_WHEN_NOT_EXIST`。如果设置 `schema_save_mode = ERROR_WHEN_SCHEMA_NOT_EXIST`,请在运行任务前先创建好对应 HugeGraph Schema。 ### 1. 写入顶点 @@ -156,15 +219,15 @@ sink { host = "localhost" port = 8080 graph_name = "hugegraph" - graph_space = "default" - selected_fields = ["name", "age"] - schema_config = { - type = "VERTEX" - label = "person" - idStrategy = "PRIMARY_KEY" - idFields = ["name"] - properties = ["name", "age"] - } + mappings = [ + { + type = "VERTEX" + label = "person" + idStrategy = "PRIMARY_KEY" + idFields = ["name"] + properties = ["name", "age"] + } + ] } } ``` @@ -196,26 +259,25 @@ sink { host = "localhost" port = 8080 graph_name = "hugegraph" - graph_space = "default" - schema_config = { - type = "EDGE" - label = "knows" - sourceConfig = { - label = "person" - idFields = ["person1_name"] - } - targetConfig = { - label = "person" - idFields = ["person2_name"] - } - properties = ["since"] - mapping = { + mappings = [ + { + type = "EDGE" + label = "knows" + sourceConfig = { + label = "person" + idFields = ["person1_name"] + } + targetConfig = { + label = "person" + idFields = ["person2_name"] + } + properties = ["since"] fieldMapping = { person1_name = "name" person2_name = "name" } } - } + ] } } ``` diff --git a/docs/zh/connectors/source/HugeGraph.md b/docs/zh/connectors/source/HugeGraph.md new file mode 100644 index 000000000000..e030146d767d --- /dev/null +++ b/docs/zh/connectors/source/HugeGraph.md @@ -0,0 +1,190 @@ +import ChangeLog from '../changelog/connector-hugegraph.md'; + +# HugeGraph Source Connector + +`Source: HugeGraph` + +## 描述 + +HugeGraph Source Connector 通过 HugeGraph REST API 读取 Apache HugeGraph 图数据。 + +对一个顶点标签或一个边标签执行有界扫描——或在单个作业中读取某一类型的**全部** label——并保存读取进度以便作业在故障后恢复。 + +- 当 `parallelism = 1` 时,通过服务端 list API 分页读取该 label,按 HugeGraph page-marker 读取到服务端返回 `page = null` 为止;此模式支持服务端 `filter`(属性等值过滤)。 +- 当 `parallelism > 1` 时,通过 HugeGraph `traverser().vertexShards / edgeShards` API 将 keyspace 切分为多个 shard,由多个 Reader 并行扫描。由于 shard 扫描按 key-range 返回所有 label,connector 会在客户端按配置的 `label` 过滤。详见[并行读取](#并行读取)。 +- 省略 `label` 时,在单个作业中读取 `label_type`(默认 `VERTEX`)下的全部 label,每个 label 产出一张输出表。详见[读取全部 label](#读取全部-label)。 + +## 主要特性 + +- [x] [batch](../../introduction/concepts/connector-v2-features.md) +- [x] [parallelism](../../introduction/concepts/connector-v2-features.md) +- [ ] [cdc](../../introduction/concepts/connector-v2-features.md) + +## 参数 + +| 名称 | 类型 | 是否必填 | 默认值 | 描述 | +|--------------------|---------|----------|----------|------| +| `host` | String | 是 | - | HugeGraph 服务地址。 | +| `port` | Integer | 是 | - | HugeGraph 服务端口。 | +| `protocol` | String | 否 | `http` | 服务协议,支持 `http`、`https`。HTTPS 使用 JVM trust store。 | +| `graph_name` | String | 是 | - | HugeGraph 图名称。 | +| `label` | String | 否 | - | 要读取的顶点标签或边标签。**省略时,connector 会在单个作业中读取 `label_type` 下的全部 label,每个 label 产出一张表**(详见[读取全部 label](#读取全部-label));该模式下不允许配置 `schema` 与 `filter`。 | +| `schema` | Object | 否 | - | 通过 `schema.fields` 声明输出属性列。保留图字段由 connector 自动添加。**省略时,connector 会从服务端读取 `label` 定义并自动发现全部属性列(类型自动推断,列按名称排序)。** 详见[Schema 自动发现](#schema-自动发现)。 | +| `label_type` | Enum | 否 | `VERTEX` | 标签类型,支持 `VERTEX`、`EDGE`。 | +| `page_size` | Integer | 否 | `1000` | 每页读取记录数,取值范围为 `[100, 10000]`。 | +| `split_size` | Long | 否 | `1048576` | `parallelism > 1` 时每个 key-range shard 的目标字节大小。值越大 shard 越少越大。必须不小于 `1048576`(1 MiB,HugeGraph 的最小分片大小)——更小的值会在启动时被拒绝,以避免 shard 爆炸。`parallelism = 1` 时忽略。需要支持 scan 的后端(RocksDB / HBase / Cassandra)。 | +| `filter` | Map | 否 | - | 可选的服务端属性等值过滤条件,例如 `{ country = "US", active = "true" }`。仅返回所有条件均匹配的元素。每个 key 必须是 `label` 的属性(未知 key 会在启动时报错),且每个值会被转换为该属性的类型(例如 `"true"` → 布尔、`"7"` → 对应数值类型)以便与服务端匹配——无法转换的值会在启动时报错,而不是静默返回 0 行。省略时读取该 label 的全部元素。**不能与 `parallelism > 1` 同时使用**(shard 扫描无法把属性过滤下推到服务端),两者同时设置会在启动时报错。 | +| `time_zone` | String | 否 | Worker JVM 默认时区 | 用于转换服务端以 epoch/Date 返回的 HugeGraph DATE 值的 ZoneId,例如 `UTC` 或 `Asia/Shanghai`。对服务端已序列化为字符串(wall-clock)的 DATE 不生效(此类值不携带时区、原样保留)。Worker JVM 时区可能不一致时应显式设置。 | +| `graph_space` | String | 否 | `DEFAULT` | 图所属的图空间(graph space)。 | +| `username` | String | 否 | - | HugeGraph 用户名。 | +| `password` | String | 否 | - | HugeGraph 密码。 | +| `max_retries` | Integer | 否 | `3` | 首次请求失败后的重试次数。设置为 `0` 可禁用重试。 | +| `retry_backoff_ms` | Integer | 否 | `5000` | 重试的基础退避时间(毫秒),按尝试次数指数增长(`retry_backoff_ms * 2^(attempt-1)`),上限为 `retry_backoff_max_ms`。 | +| `retry_backoff_max_ms` | Integer | 否 | `30000` | 指数退避的上限(毫秒)。 | + +## 输出 Schema + +顶点输出列: + +```text +~id, ~label, +``` + +边输出列: + +```text +~id, ~label, ~source_id, ~source_label, ~target_id, ~target_label, +``` + +`~` 前缀字段为 connector 自动添加的保留字段。HugeGraph 属性键不能以 `~` 开头,因此不会与用户属性冲突。 + +## 类型映射 + +`schema.fields` 中声明的类型必须与 HugeGraph PropertyKey 类型匹配。Connector 会在读取前校验。 + +| HugeGraph 类型 | SeaTunnel 类型 | +|----------------|----------------| +| `TEXT` | `STRING` | +| `BYTE` | `TINYINT` | +| `INT` | `INT` | +| `LONG` | `BIGINT` | +| `FLOAT` | `FLOAT` | +| `DOUBLE` | `DOUBLE` | +| `BOOLEAN` | `BOOLEAN` | +| `DATE` | `TIMESTAMP` | +| `UUID` | `STRING` | +| `OBJECT` | `STRING` | +| `BLOB` | `BYTES` | + +### 多值(LIST / SET)属性 + +cardinality 为 `LIST` 或 `SET` 的 HugeGraph 属性会被读为 SeaTunnel 的 `ARRAY`。在 `schema.fields` 中声明为 `array`,其中 `T` 是元素的 SeaTunnel 类型(见上表)。例如名为 `tags` 的 `LIST` 属性声明为 `tags = "array"`。 + +注意: + +- `SET` 元素在服务端无固定顺序;需要顺序时请使用 `LIST`。 +- 若服务端某属性 cardinality 为 `LIST`/`SET` 却被声明为标量(或反之),作业会在启动时失败并提示正确的声明方式。 +- 不支持 `LIST`/`SET` 中嵌套 `BLOB` 元素。 + +## 示例 + +```hocon +source { + HugeGraph { + host = "localhost" + port = 8080 + graph_name = "hugegraph" + label = "person" + label_type = "VERTEX" + page_size = 1000 + schema = { + fields = { + name = "string" + age = "int" + } + } + } +} +``` + +## Schema 自动发现 + +`schema` 为可选项。省略时,connector 会在作业构建阶段连接服务端,读取 `label` 的定义,为每个属性键生成一个输出列(类型见[类型映射](#类型映射)表,`LIST`/`SET` 映射为 `array`),并按属性名排序。适合「整 label 全字段 dump」而又不想逐字段手写声明的场景。 + +```hocon +source { + HugeGraph { + host = "localhost" + port = 8080 + graph_name = "hugegraph" + label = "person" + label_type = "VERTEX" + # 不写 schema:读取 "person" 的全部属性 + } +} +``` + +注意: + +- 该 label 必须已存在于服务端,否则作业在构建阶段失败。 +- 无任何属性键的 label 只会产生保留列(`~id`、`~label` 等)。 +- 当只想读取部分属性、固定列顺序或指定类型时,请显式声明 `schema.fields`。 + +## 读取全部 label + +省略 `label` 即可在单个作业中读取 `label_type`(默认 `VERTEX`)下的**全部** label——适合整图迁移 / 备份,而无需为每个 label 各配一个 source。作业构建阶段 connector 会从服务端 schema 列出该类型的所有 label,为每个 label 产出一张输出表,各自按 [Schema 自动发现](#schema-自动发现)推断列。每行都会带上其 label 对应的 table id,因此下游多表 sink 可据此将行路由到对应表。 + +```hocon +source { + HugeGraph { + host = "localhost" + port = 8080 + graph_name = "hugegraph" + label_type = "VERTEX" + # 不写 label:读取全部顶点 label,每个 label 一张表 + } +} +``` + +注意: + +- 一个作业读取顶点**或**边,不能混读:设置 `label_type = "EDGE"` 以读取全部边 label。 +- 不允许配置 `schema`(单一 schema 无法描述多个 label),列始终按 label 自动发现。 +- 不允许配置 `filter`(属性等值过滤要求该属性存在于每个 label)。 +- 每个 label 对应一个 `LABEL_LIST` split,分配给各 Reader(并行度上限为 label 数量)。此模式不使用单个 label 内部的 shard 级并行。 +- 若图中不存在该类型的任何 label,作业在构建阶段失败。 + +## 并行读取 + +对于大图,设置 `parallelism > 1` 可并行读取一个 label。Enumerator 请求 HugeGraph 将该 label 的 keyspace 切分为大小约为 `split_size` 字节的多个 shard,并以 round-robin 方式分配给各 Reader,使吞吐随并行度提升,而不再受单一分页游标限制。 + +```hocon +source { + HugeGraph { + host = "localhost" + port = 8080 + graph_name = "hugegraph" + label = "person" + label_type = "VERTEX" + parallelism = 8 + split_size = 1048576 + schema = { + fields = { + name = "string" + age = "int" + } + } + } +} +``` + +注意: + +- Shard 扫描需要支持 scan 的后端(RocksDB / HBase / Cassandra);`memory` 后端不支持 shard 切分,请在其上使用 `parallelism = 1`。 +- Shard 扫描会返回 key-range 内所有 label 的元素,connector 仅保留配置的 `label`。当目标 label 只占全图很小比例时,单并行度的 `filter` 读取可能搬运更少数据(尽管无法并行)。 +- `filter` 不能与 `parallelism > 1` 同时使用;要用服务端过滤请保持 `parallelism = 1`,要并行请去掉 filter。 +- 调优 `split_size`:值越小 shard 越多越小(负载更均衡、请求更多);值越大 shard 越少越大。最小值为 `1048576`(1 MiB),更小的值会被拒绝,以避免把 keyspace 切分成过多的 shard。 + +## Changelog + + diff --git a/plugin-mapping.properties b/plugin-mapping.properties index af93a9c8f781..d4b0b1e138a3 100644 --- a/plugin-mapping.properties +++ b/plugin-mapping.properties @@ -159,6 +159,7 @@ seatunnel.source.GraphQL = connector-graphql seatunnel.sink.GraphQL = connector-graphql seatunnel.sink.Aerospike = connector-aerospike seatunnel.sink.SensorsData = connector-sensorsdata +seatunnel.source.HugeGraph = connector-hugegraph seatunnel.sink.HugeGraph = connector-hugegraph seatunnel.source.Fluss = connector-fluss seatunnel.sink.Fluss = connector-fluss diff --git a/pom.xml b/pom.xml index 058d75d41ada..8a49c5f06a33 100644 --- a/pom.xml +++ b/pom.xml @@ -148,7 +148,7 @@ 2.12.15 9.4.56.v20240826 4.0.4 - 1.5.0 + 1.7.0 false true diff --git a/seatunnel-connectors-v2/connector-hugegraph/pom.xml b/seatunnel-connectors-v2/connector-hugegraph/pom.xml index d8cf4077fd05..2faf3ae6c9ab 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/pom.xml +++ b/seatunnel-connectors-v2/connector-hugegraph/pom.xml @@ -29,10 +29,6 @@ connector-hugegraph SeaTunnel : Connectors V2 : HugeGraph - - 1.5.0 - - org.apache.seatunnel @@ -44,12 +40,53 @@ org.apache.hugegraph hugegraph-client ${hugegraph.client.version} + + + + junit + junit + + + org.hamcrest + hamcrest-core + + + + org.apache.hugegraph + hg-pd-client + + + org.apache.hugegraph + hg-pd-common + + + org.apache.hugegraph + hg-pd-grpc + + + io.grpc + * + + org.apache.hugegraph hugegraph-common ${hugegraph.client.version} + + + junit + junit + + + org.hamcrest + hamcrest-core + + @@ -67,6 +104,68 @@ + + org.apache.maven.plugins + maven-shade-plugin + + + + shade + + package + + + + + com.google.common + ${seatunnel.shade.package}.hugegraph.com.google.common + + + com.google.thirdparty + ${seatunnel.shade.package}.hugegraph.com.google.thirdparty + + + com.fasterxml.jackson + ${seatunnel.shade.package}.hugegraph.com.fasterxml.jackson + + + + okhttp3 + ${seatunnel.shade.package}.hugegraph.okhttp3 + + + okio + ${seatunnel.shade.package}.hugegraph.okio + + + + javassist + ${seatunnel.shade.package}.hugegraph.javassist + + + org.joda.time + ${seatunnel.shade.package}.hugegraph.org.joda.time + + + + + + + + *:* + + META-INF/maven/** + + + + + + + diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/buffer/BatchBuffer.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/buffer/BatchBuffer.java index c3851129390a..bd6575488e3c 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/buffer/BatchBuffer.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/buffer/BatchBuffer.java @@ -18,30 +18,50 @@ package org.apache.seatunnel.connectors.seatunnel.hugegraph.buffer; import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig.LabelType; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; import org.apache.hugegraph.structure.GraphElement; import org.apache.hugegraph.structure.graph.Edge; +import org.apache.hugegraph.structure.graph.UpdateStrategy; import org.apache.hugegraph.structure.graph.Vertex; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +/** + * Dual-bucket batch buffer that independently accumulates and flushes vertices and edges. Each + * bucket triggers flush when reaching batch_size; both buckets are flushed on timer, prepareCommit, + * or close. + * + *

Vertex-before-edge ordering is enforced only when {@code check_vertex} is true — the server + * then rejects edges whose endpoint vertices do not yet exist, so a filling edge bucket first + * flushes any pending vertices, and {@link #flush()} writes vertices before edges. When {@code + * check_vertex} is false (the default) the server already accepts orphan edges, so the buckets + * flush independently for higher throughput (no forced, undersized vertex flushes). + */ public class BatchBuffer implements AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(BatchBuffer.class); - private final List buffer = new ArrayList<>(); + private final List vertexBuffer = new ArrayList<>(); + private final List edgeBuffer = new ArrayList<>(); private final int batchSize; private final ScheduledExecutorService scheduler; private final ScheduledFuture scheduledFuture; @@ -49,11 +69,61 @@ public class BatchBuffer implements AutoCloseable { private volatile boolean closed = false; private volatile Exception flushException; private final HugeGraphClient client; + private final boolean batchFailureFallback; + private final boolean checkVertex; + // Fail the task once this many records have been skipped by the single-record fallback; + // negative means unlimited. Guards against the previously unbounded silent skipping. + private final int maxInsertErrors; + // Optional directory for skipped-record failure samples; null = do not persist. + private final String failureDataPath; + private final int subtaskIndex; + // Cumulative count of records skipped by the fallback across this writer's lifetime. Only + // mutated inside synchronized flush paths, so a plain long is sufficient. + private long insertFailureCount; + // Lazily opened on the first persisted sample; disabled after an I/O error so a broken + // failure-log path never turns into a second failure that masks the real one. + private BufferedWriter failureWriter; + private boolean failureWriterDisabled; + /** + * Backward-compatible constructor that retains the original 3-argument signature. Defaults + * {@code batchFailureFallback} and {@code checkVertex} to {@code false}, matching the pre-2.x + * behaviour where neither feature existed. + * + * @deprecated Use {@link #BatchBuffer(HugeGraphClient, int, long, boolean, boolean)} instead so + * callers explicitly opt into failure-fallback and vertex-checking semantics. + */ + @Deprecated public BatchBuffer(HugeGraphClient client, int batchSize, long batchIntervalMs) { + this(client, batchSize, batchIntervalMs, false, false); + } + + public BatchBuffer( + HugeGraphClient client, + int batchSize, + long batchIntervalMs, + boolean batchFailureFallback, + boolean checkVertex) { + this(client, batchSize, batchIntervalMs, batchFailureFallback, checkVertex, -1, null, 0); + } + public BatchBuffer( + HugeGraphClient client, + int batchSize, + long batchIntervalMs, + boolean batchFailureFallback, + boolean checkVertex, + int maxInsertErrors, + String failureDataPath, + int subtaskIndex) { this.batchSize = batchSize; this.client = client; + this.batchFailureFallback = batchFailureFallback; + this.checkVertex = checkVertex; + this.maxInsertErrors = maxInsertErrors; + this.failureDataPath = failureDataPath; + this.subtaskIndex = subtaskIndex; + this.insertFailureCount = 0; if (batchIntervalMs > 0) { this.scheduler = @@ -81,7 +151,7 @@ public BatchBuffer(HugeGraphClient client, int batchSize, long batchIntervalMs) } } - public synchronized void add(GraphElement element) throws IOException { + public synchronized void add(GraphElementEnvelope envelope) throws IOException { checkFlushException(); if (closed) { throw new HugeGraphConnectorException( @@ -90,9 +160,25 @@ public synchronized void add(GraphElement element) throws IOException { } try { - buffer.add(element); - if (buffer.size() >= batchSize) { - doFlush(); + if (envelope.getElementType() == LabelType.VERTEX) { + vertexBuffer.add(envelope); + if (vertexBuffer.size() >= batchSize) { + doFlushVertices(); + } + } else { + edgeBuffer.add(envelope); + if (edgeBuffer.size() >= batchSize) { + // Topology safety only matters when the server validates endpoints: with + // check_vertex=true, flush pending vertices before the edges so no edge is sent + // before its endpoints exist. With check_vertex=false the server already + // accepts + // orphan edges, so skip the forced (undersized) vertex flush and let the vertex + // bucket accumulate to a full batch — fewer, fuller vertex requests. + if (checkVertex && !vertexBuffer.isEmpty()) { + doFlushVertices(); + } + doFlushEdges(); + } } } catch (Exception e) { throw new HugeGraphConnectorException( @@ -100,40 +186,267 @@ public synchronized void add(GraphElement element) throws IOException { } } + /** + * Backward-compatible overload that wraps a plain {@link GraphElement} in a minimal envelope. + * + * @deprecated Use {@link #add(GraphElementEnvelope)} instead so the buffer receives complete + * mapping context (label name, element type) for failure diagnostics. + */ + @Deprecated + public synchronized void add(GraphElement element) throws IOException { + LabelType type = element instanceof Vertex ? LabelType.VERTEX : LabelType.EDGE; + add(new GraphElementEnvelope(null, type, element)); + } + public synchronized void flush() throws IOException { checkFlushException(); - if (closed && buffer.isEmpty()) { + if (closed && vertexBuffer.isEmpty() && edgeBuffer.isEmpty()) { return; } - doFlush(); + doFlushVertices(); + doFlushEdges(); } - private void doFlush() { - if (buffer.isEmpty()) { + private void doFlushVertices() { + if (vertexBuffer.isEmpty()) { return; } + List batch = new ArrayList<>(vertexBuffer); + vertexBuffer.clear(); + // Route by each element's own mapping strategy: a group with no strategy is a plain insert, + // a group with a strategy is an upsert. HugeGraph applies one strategy map per batch call, + // so elements with different strategies must go in separate calls. + for (Map.Entry, List> group : + groupByStrategy(batch).entrySet()) { + flushVertexGroup(group.getValue(), group.getKey()); + } + } + + private void flushVertexGroup( + List batch, Map updateStrategies) { try { - GraphElement firstElement = buffer.get(0); - if (firstElement instanceof Vertex) { - List vertices = - buffer.stream() - .map(element -> (Vertex) element) - .collect(Collectors.toList()); + List vertices = + batch.stream() + .map(env -> (Vertex) env.getElement()) + .collect(Collectors.toList()); + if (updateStrategies.isEmpty()) { client.batchWriteVertices(vertices); } else { - List edges = - buffer.stream().map(element -> (Edge) element).collect(Collectors.toList()); - client.batchWriteEdges(edges); + client.batchUpdateVertices(vertices, updateStrategies); + } + } catch (Exception e) { + if (!batchFailureFallback) { + logBatchFailure(batch, e); + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + "Failed to write vertex batch", + e); } + fallbackInsertSingly(batch, e); + } + } + + private void doFlushEdges() { + if (edgeBuffer.isEmpty()) { + return; + } + List batch = new ArrayList<>(edgeBuffer); + edgeBuffer.clear(); + for (Map.Entry, List> group : + groupByStrategy(batch).entrySet()) { + flushEdgeGroup(group.getValue(), group.getKey()); + } + } - buffer.clear(); + private void flushEdgeGroup( + List batch, Map updateStrategies) { + try { + List edges = + batch.stream().map(env -> (Edge) env.getElement()).collect(Collectors.toList()); + if (updateStrategies.isEmpty()) { + client.batchWriteEdges(edges, checkVertex); + } else { + client.batchUpdateEdges(edges, updateStrategies, checkVertex); + } } catch (Exception e) { - LOG.error("Failed to write batch data to HugeGraph", e); + if (!batchFailureFallback) { + logBatchFailure(batch, e); + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + "Failed to write edge batch", + e); + } + fallbackInsertSingly(batch, e); + } + } + + /** + * Groups a batch by its elements' update-strategy map, preserving first-seen order so a flush + * stays deterministic. Elements sharing the same strategy map flush together in one server + * call. + */ + private static Map, List> groupByStrategy( + List batch) { + Map, List> groups = + new java.util.LinkedHashMap<>(); + for (GraphElementEnvelope envelope : batch) { + groups.computeIfAbsent(envelope.getUpdateStrategies(), key -> new ArrayList<>()) + .add(envelope); + } + return groups; + } + + /** + * A batch insert failed; retry each element on its own so a single poison record no longer + * fails the whole batch. Failed records are logged and skipped; the rest succeed. If + * every record fails, the failure is systemic (bad connection / schema), not a poison + * record, so it is rethrown instead of silently dropping the whole batch. + */ + private void fallbackInsertSingly(List batch, Exception batchFailure) { + LOG.warn( + "Batch write failed ({} element(s)); falling back to single-record insert. cause={}", + batch.size(), + batchFailure.getMessage()); + int failed = 0; + Exception lastFailure = null; + for (GraphElementEnvelope envelope : batch) { + Map updateStrategies = envelope.getUpdateStrategies(); + try { + if (envelope.getElementType() == LabelType.VERTEX) { + if (updateStrategies.isEmpty()) { + client.writeVertex((Vertex) envelope.getElement()); + } else { + client.updateVertex((Vertex) envelope.getElement(), updateStrategies); + } + } else { + if (updateStrategies.isEmpty()) { + client.writeEdge((Edge) envelope.getElement(), checkVertex); + } else { + client.updateEdge( + (Edge) envelope.getElement(), updateStrategies, checkVertex); + } + } + } catch (Exception single) { + failed++; + lastFailure = single; + insertFailureCount++; + LOG.error( + "Single-record write failure — {}", + formatFailureDiagnostic(envelope, single)); + writeFailureSample(envelope, single); + // Bound the previously unlimited silent skipping: once the cumulative number of + // skipped records reaches max_insert_errors, stop and fail the task instead of + // continuing to drop data. Negative means unlimited. + if (maxInsertErrors >= 0 && insertFailureCount >= maxInsertErrors) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + String.format( + "Aborting: cumulative single-insert failures (%d) reached " + + "max_insert_errors (%d). Last error: %s", + insertFailureCount, maxInsertErrors, single.getMessage()), + single); + } + } + } + if (failed == batch.size()) { throw new HugeGraphConnectorException( - HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, e.getMessage(), e); + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + String.format( + "All %d record(s) in the batch failed single-insert fallback", + batch.size()), + lastFailure); + } + if (failed > 0) { + LOG.warn( + "Single-record fallback completed: {} succeeded, {} failed and were skipped " + + "({} skipped in total so far)", + batch.size() - failed, + failed, + insertFailureCount); + } + } + + /** + * Appends one line describing a skipped record — the mapped element's id/label/properties plus + * the server error — to the per-subtask failure file when {@code failure_data_path} is set. + * Best-effort: a write/open error disables further persistence rather than failing the task, so + * a broken debug path can never mask the real insert failure. + */ + private void writeFailureSample(GraphElementEnvelope envelope, Exception failure) { + if (failureDataPath == null || failureDataPath.isEmpty() || failureWriterDisabled) { + return; + } + try { + if (failureWriter == null) { + File dir = new File(failureDataPath); + if (!dir.exists() && !dir.mkdirs() && !dir.exists()) { + throw new IOException("Failed to create failure data directory: " + dir); + } + File file = + new File(dir, "hugegraph-sink-failures-subtask-" + subtaskIndex + ".log"); + failureWriter = + new BufferedWriter( + new OutputStreamWriter( + new FileOutputStream(file, true), StandardCharsets.UTF_8)); + LOG.info("Persisting skipped-record failure samples to {}", file.getAbsolutePath()); + } + failureWriter.write(formatFailureSample(envelope, failure)); + failureWriter.newLine(); + // Flush per record: failures are rare and losing samples on an abrupt crash defeats + // their purpose. + failureWriter.flush(); + } catch (IOException e) { + failureWriterDisabled = true; + LOG.warn( + "Failed to persist failure sample to '{}'; disabling failure-data persistence. cause={}", + failureDataPath, + e.getMessage()); } } + /** + * One-line, tab-delimited failure sample. Newlines are stripped to keep one record per line. + */ + static String formatFailureSample(GraphElementEnvelope envelope, Exception failure) { + GraphElement element = envelope.getElement(); + String line = + String.format( + "mapping=%s\ttype=%s\tid=%s\tlabel=%s\tproperties=%s\terror=%s", + envelope.getMappingLabel(), + envelope.getElementType(), + element == null ? null : element.id(), + element == null ? null : element.label(), + element == null ? null : element.properties(), + failure.getMessage()); + return line.replace('\n', ' ').replace('\r', ' '); + } + + private void logBatchFailure(List batch, Exception e) { + LOG.error( + "Batch write failure — {} element(s), failureType={}, serverError={}", + batch.size(), + e.getClass().getName(), + e.getMessage()); + for (GraphElementEnvelope envelope : batch) { + LOG.error("Graph element write failure — {}", formatFailureDiagnostic(envelope, e)); + } + } + + static String formatFailureDiagnostic(GraphElementEnvelope envelope, Exception failure) { + // Log only the mapped graph element's id/label — bounded and non-sensitive. The raw source + // row is intentionally not retained (see GraphElementEnvelope) to avoid unbounded memory + // and + // leaking excluded field content into logs. + return String.format( + "mapping=%s, elementType=%s, elementId=%s, elementLabel=%s, failureType=%s, serverError=%s", + envelope.getMappingLabel(), + envelope.getElementType(), + envelope.getElement() == null ? null : envelope.getElement().id(), + envelope.getElement() == null ? null : envelope.getElement().label(), + failure.getClass().getName(), + failure.getMessage()); + } + @Override public void close() throws IOException { synchronized (this) { @@ -158,11 +471,27 @@ public void close() throws IOException { } } LOG.info("Closing BatchBuffer, performing final flush..."); - flush(); - checkFlushException(); + try { + flush(); + checkFlushException(); + } finally { + closeFailureWriter(); + } LOG.info("BatchBuffer closed."); } + private void closeFailureWriter() { + if (failureWriter != null) { + try { + failureWriter.close(); + } catch (IOException e) { + LOG.warn("Failed to close failure-data writer. cause={}", e.getMessage()); + } finally { + failureWriter = null; + } + } + } + private void checkFlushException() { if (flushException != null) { throw new HugeGraphConnectorException( diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/buffer/GraphElementEnvelope.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/buffer/GraphElementEnvelope.java new file mode 100644 index 000000000000..3b1bc2ad0e0b --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/buffer/GraphElementEnvelope.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.buffer; + +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig.LabelType; + +import org.apache.hugegraph.structure.GraphElement; +import org.apache.hugegraph.structure.graph.UpdateStrategy; + +import java.util.Collections; +import java.util.Map; + +/** + * Wraps a graph element with non-sensitive mapping context for failure diagnostics. + * + *

Deliberately does NOT retain the source {@link + * org.apache.seatunnel.api.table.type.SeaTunnelRow}: only the mapped {@link GraphElement} is ever + * sent, and envelopes stay alive until the batch is flushed (by size/timer/checkpoint/close). + * Keeping the raw row would pin fields that were excluded by {@code mapping.properties} (e.g. large + * BYTES payloads) in memory for the whole batch and leak their content into failure logs. + */ +public class GraphElementEnvelope { + + private final String mappingLabel; + private final LabelType elementType; + private final GraphElement element; + // Per-mapping update strategies (property name -> strategy). Empty means plain insert. Carried + // on the envelope so the buffer can route each element by its own mapping's strategy instead of + // one merged global map — a strategy on one mapping no longer forces upsert on every mapping, + // and two mappings may assign different strategies to the same property name. + private final Map updateStrategies; + + public GraphElementEnvelope(String mappingLabel, LabelType elementType, GraphElement element) { + this(mappingLabel, elementType, element, Collections.emptyMap()); + } + + public GraphElementEnvelope( + String mappingLabel, + LabelType elementType, + GraphElement element, + Map updateStrategies) { + this.mappingLabel = mappingLabel; + this.elementType = elementType; + this.element = element; + this.updateStrategies = + updateStrategies == null ? Collections.emptyMap() : updateStrategies; + } + + public String getMappingLabel() { + return mappingLabel; + } + + public LabelType getElementType() { + return elementType; + } + + public GraphElement getElement() { + return element; + } + + public Map getUpdateStrategies() { + return updateStrategies; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/client/HugeGraphClient.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/client/HugeGraphClient.java index ed4491eaef3e..d173e9bca0c9 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/client/HugeGraphClient.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/client/HugeGraphClient.java @@ -6,7 +6,7 @@ * (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 + * 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, @@ -17,18 +17,31 @@ package org.apache.seatunnel.connectors.seatunnel.hugegraph.client; -import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSinkConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphConnectionConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.LabelOptions; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; +import org.apache.hugegraph.api.graph.EdgeAPI; +import org.apache.hugegraph.api.graph.VertexAPI; +import org.apache.hugegraph.client.RestClient; import org.apache.hugegraph.driver.GraphManager; import org.apache.hugegraph.driver.HugeClient; import org.apache.hugegraph.driver.SchemaManager; import org.apache.hugegraph.exception.ServerException; import org.apache.hugegraph.rest.ClientException; +import org.apache.hugegraph.rest.RestClientConfig; +import org.apache.hugegraph.structure.constant.DataType; +import org.apache.hugegraph.structure.constant.Frequency; import org.apache.hugegraph.structure.constant.IdStrategy; +import org.apache.hugegraph.structure.graph.BatchEdgeRequest; +import org.apache.hugegraph.structure.graph.BatchVertexRequest; import org.apache.hugegraph.structure.graph.Edge; +import org.apache.hugegraph.structure.graph.Edges; +import org.apache.hugegraph.structure.graph.Shard; +import org.apache.hugegraph.structure.graph.UpdateStrategy; import org.apache.hugegraph.structure.graph.Vertex; +import org.apache.hugegraph.structure.graph.Vertices; import org.apache.hugegraph.structure.schema.EdgeLabel; import org.apache.hugegraph.structure.schema.PropertyKey; import org.apache.hugegraph.structure.schema.VertexLabel; @@ -36,39 +49,61 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.Set; -public final class HugeGraphClient { +public final class HugeGraphClient implements HugeGraphOperations { - // TODO: Add handling for schema fetch failures. private static final Logger LOG = LoggerFactory.getLogger(HugeGraphClient.class); + /** HugeGraph server per-request batch cap (server option batch.max_vertices_per_batch). */ + private static final int MAX_RECORDS_PER_BATCH_REQUEST = 500; + private HugeClient client; + private RestClient restClient; + private VertexAPI vertexAPI; + private EdgeAPI edgeAPI; private SchemaManager schema; - private final HugeGraphSinkConfig config; + private final HugeGraphConnectionConfig config; private final int maxRetries; private final long retryBackoffMs; + private final long retryBackoffMaxMs; - public HugeGraphClient(HugeGraphSinkConfig config) { + public HugeGraphClient(HugeGraphConnectionConfig config) { this.client = null; + this.restClient = null; + this.vertexAPI = null; + this.edgeAPI = null; this.schema = null; this.config = config; - this.maxRetries = config.getMaxRetries() > 0 ? config.getMaxRetries() : 3; - this.retryBackoffMs = config.getRetryBackoffMs() > 0 ? config.getRetryBackoffMs() : 5000L; + this.maxRetries = Math.max(0, config.getMaxRetries()); + this.retryBackoffMs = Math.max(0, config.getRetryBackoffMs()); + this.retryBackoffMaxMs = Math.max(0, config.getRetryBackoffMaxMs()); } - private HugeClient createClient(HugeGraphSinkConfig config) { + /** Default graph space per HugeGraphOptions.GRAPH_SPACE.defaultValue(). */ + private static final String DEFAULT_GRAPH_SPACE = "DEFAULT"; + + private HugeClient createClient(HugeGraphConnectionConfig config) { try { - String url = String.format("http://%s:%d", config.getHost(), config.getPort()); - LOG.debug("Creating new HugeClient for url: {}, graph: {}", url, config.getGraphName()); + String url = buildServerUrl(config); + String graphSpace = + config.getGraphSpace() != null ? config.getGraphSpace() : DEFAULT_GRAPH_SPACE; + LOG.debug( + "Creating new HugeClient for url: {}, graphSpace: {}, graph: {}", + url, + graphSpace, + config.getGraphName()); HugeClient client = - HugeClient.builder(url, config.getGraphName()) + HugeClient.builder(url, graphSpace, config.getGraphName()) .configUser(config.getUsername(), config.getPassword()) .configIdleTime(60) .build(); - client.graph().listVertices(); LOG.info("Successfully created and validated HugeClient instance."); return client; } catch (Exception e) { @@ -83,14 +118,23 @@ private interface GraphOperation { void execute(GraphManager graph) throws ServerException, ClientException; } + @FunctionalInterface + private interface ReadOperation { + T execute() throws ServerException, ClientException; + } + private void ensureClientInitialized() throws HugeGraphConnectorException { if (this.client == null) { LOG.info("Client not initialized. Attempting to connect..."); try { this.client = createClient(this.config); this.schema = this.client.schema(); + createPageApis(this.config); LOG.info("HugeClient initialized successfully."); } catch (Exception e) { + // Avoid leaking a partially-opened client (e.g. createPageApis failed after the + // HugeClient was created) — release everything before surfacing the failure. + reconnect(); throw new HugeGraphConnectorException( HugeGraphConnectorErrorCode.BUILD_CLIENT_FAILED, "Failed to establish initial connection", @@ -109,52 +153,263 @@ private void reconnect() { } } this.client = null; + if (this.restClient != null) { + try { + this.restClient.close(); + } catch (Exception e) { + LOG.warn("Error closing potentially broken REST client: {}", e.getMessage()); + } + } + this.restClient = null; + this.vertexAPI = null; + this.edgeAPI = null; this.schema = null; } - private void executeGraphOperation(GraphOperation operation) { - for (int attempt = 1; attempt <= this.maxRetries; attempt++) { + private void createPageApis(HugeGraphConnectionConfig config) { + String url = buildServerUrl(config); + String graphSpace = + config.getGraphSpace() != null ? config.getGraphSpace() : DEFAULT_GRAPH_SPACE; + RestClientConfig restClientConfig = + RestClientConfig.builder() + .user(config.getUsername() == null ? "" : config.getUsername()) + .password(config.getPassword() == null ? "" : config.getPassword()) + .build(); + this.restClient = new RestClient(url, restClientConfig); + this.vertexAPI = new VertexAPI(this.restClient, graphSpace, config.getGraphName()); + this.edgeAPI = new EdgeAPI(this.restClient, graphSpace, config.getGraphName()); + } + + static String buildServerUrl(HugeGraphConnectionConfig config) { + String protocol = + config.getProtocol() == null || config.getProtocol().isEmpty() + ? "http" + : config.getProtocol().toLowerCase(java.util.Locale.ROOT); + return String.format("%s://%s:%d", protocol, config.getHost(), config.getPort()); + } + + /** + * Executes a write operation that is safe to retry: UPSERT (updateVertices/updateEdges with + * createIfNotExist=true) and DELETE (removeVertex/removeEdge). Idempotent operations are + * retried on retryable errors because a second attempt cannot create duplicates. + */ + private void executeIdempotentWrite(GraphOperation operation) { + executeGraphOperation(operation, true); + } + + /** + * Executes a write operation that is NOT safe to retry: plain INSERT (addVertex/addVertices/ + * addEdge/addEdges). A retry after a server-committed-but-client-timed-out response would + * create a duplicate element. Non-idempotent writes fail fast — the caller's single-record + * fallback handles them individually instead. + */ + private void executeNonIdempotentWrite(GraphOperation operation) { + try { + ensureClientInitialized(); + operation.execute(this.client.graph()); + } catch (ServerException | ClientException e) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + "Non-idempotent write failed (not retried to avoid duplicates): " + + e.getMessage(), + e); + } + } + + /** + * Executes a graph write with optional retry. When {@code idempotent} is true, retryable server + * errors (status ≥ 500, 408, 425, 429) are retried up to {@code maxRetries} times with + * exponential backoff. When false, the operation is attempted once — if it fails the exception + * propagates immediately so the caller can route through the single-record fallback or skip the + * record. + */ + private void executeGraphOperation(GraphOperation operation, boolean idempotent) { + int totalAttempts = idempotent ? this.maxRetries + 1 : 1; + for (int attempt = 1; attempt <= totalAttempts; attempt++) { try { ensureClientInitialized(); operation.execute(this.client.graph()); return; } catch (ServerException | ClientException e) { + if (!isRetryable(e) || !idempotent) { + LOG.error( + "Server rejected the request ({}): {}", + idempotent ? "non-retryable" : "non-idempotent, not retrying", + e.getMessage()); + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + "Server rejected the request" + + (idempotent ? " (non-retryable)" : " (non-idempotent)") + + ": " + + e.getMessage(), + e); + } LOG.warn( "Graph operation failed on attempt {}/{}. Error: {}", attempt, - this.maxRetries, + totalAttempts, e.getMessage()); reconnect(); - if (attempt == this.maxRetries) { + if (attempt == totalAttempts) { LOG.error("Max retries ({}) reached. Failing task.", this.maxRetries); throw new HugeGraphConnectorException( HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, "Failed to execute graph operation after " - + this.maxRetries - + " attempts", + + totalAttempts + + " attempt(s). Last error: " + + e.getMessage(), e); } - try { - LOG.info("Will retry in {} ms...", retryBackoffMs); - Thread.sleep(retryBackoffMs); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); + sleepBeforeRetry(attempt); + } catch (HugeGraphConnectorException e) { + if (!HugeGraphConnectorErrorCode.BUILD_CLIENT_FAILED + .getCode() + .equals(e.getSeaTunnelErrorCode().getCode())) { + throw e; + } + if (!idempotent) { + throw e; + } + reconnect(); + if (attempt == totalAttempts) { throw new HugeGraphConnectorException( - HugeGraphConnectorErrorCode.OPERATION_RETRY_INTERRUPTED, - "Graph operation retry was interrupted", - ie); + HugeGraphConnectorErrorCode.BUILD_CLIENT_FAILED, + "Failed to establish HugeGraph connection after " + + totalAttempts + + " attempt(s)", + e); } - + LOG.warn( + "HugeGraph connection failed on attempt {}/{}. Error: {}", + attempt, + totalAttempts, + e.getMessage()); + sleepBeforeRetry(attempt); } catch (Exception e) { LOG.error("Non-retryable error executing graph operation: {}", e.getMessage(), e); throw new HugeGraphConnectorException( HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, - "Non-retryable error executing graph operation", + "Non-retryable error executing graph operation: " + e.getMessage(), + e); + } + } + } + + /** + * Deterministic 4xx responses (bad request, semantic rejection such as exceeding the server + * batch size cap) cannot succeed on retry. Only connection-level failures and 5xx server errors + * are worth retrying. + */ + static boolean isRetryable(Exception e) { + if (e instanceof ServerException) { + int status = ((ServerException) e).status(); + return status == 408 || status == 425 || status == 429 || status >= 500; + } + return true; + } + + private T executeReadOperation(ReadOperation operation) { + int totalAttempts = this.maxRetries + 1; + for (int attempt = 1; attempt <= totalAttempts; attempt++) { + try { + ensureClientInitialized(); + return operation.execute(); + } catch (ServerException | ClientException e) { + if (!isRetryable(e)) { + LOG.error("Server rejected the request (non-retryable): {}", e.getMessage()); + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + "Server rejected the request (non-retryable): " + e.getMessage(), + e); + } + LOG.warn( + "Graph read operation failed on attempt {}/{}. Error: {}", + attempt, + totalAttempts, + e.getMessage()); + reconnect(); + + if (attempt == totalAttempts) { + LOG.error("Max retries ({}) reached. Failing task.", this.maxRetries); + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + "Failed to execute graph read operation after " + + totalAttempts + + " attempt(s). Last error: " + + e.getMessage(), + e); + } + + sleepBeforeRetry(attempt); + } catch (HugeGraphConnectorException e) { + if (!HugeGraphConnectorErrorCode.BUILD_CLIENT_FAILED + .getCode() + .equals(e.getSeaTunnelErrorCode().getCode())) { + throw e; + } + reconnect(); + if (attempt == totalAttempts) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.BUILD_CLIENT_FAILED, + "Failed to establish HugeGraph connection after " + + totalAttempts + + " attempt(s)", + e); + } + LOG.warn( + "HugeGraph connection failed on attempt {}/{}. Error: {}", + attempt, + totalAttempts, + e.getMessage()); + sleepBeforeRetry(attempt); + } catch (Exception e) { + LOG.error( + "Non-retryable error executing graph read operation: {}", + e.getMessage(), + e); + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + "Non-retryable error executing graph read operation", e); } } + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + "Failed to execute graph read operation"); + } + + private void sleepBeforeRetry(int attempt) { + long delay = computeBackoffMs(retryBackoffMs, retryBackoffMaxMs, attempt); + try { + LOG.info("Will retry in {} ms (attempt {})...", delay, attempt); + Thread.sleep(delay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.OPERATION_RETRY_INTERRUPTED, + "Graph operation retry was interrupted", + ie); + } + } + + /** + * Exponential backoff: {@code baseMs * 2^(attempt-1)}, capped at {@code maxMs} (a non-positive + * {@code maxMs} means no cap). The shift is bounded so a large {@code maxRetries} cannot + * overflow. {@code attempt} is 1-based (the first retry uses the base delay). + */ + static long computeBackoffMs(long baseMs, long maxMs, int attempt) { + if (baseMs <= 0) { + return 0; + } + int shift = Math.min(Math.max(attempt - 1, 0), 30); + long scaled = baseMs << shift; + if (scaled < 0) { + // Overflow guard (defensive; the shift cap already prevents this for int-range bases). + return maxMs > 0 ? maxMs : Long.MAX_VALUE; + } + return (maxMs > 0) ? Math.min(scaled, maxMs) : scaled; } private SchemaManager getSchema() { @@ -162,51 +417,483 @@ private SchemaManager getSchema() { return this.schema; } + // --- Schema read operations --- + public PropertyKey getPropertyKey(String propertyName) { - return getSchema().getPropertyKey(propertyName); + return executeReadOperation(() -> getSchema().getPropertyKey(propertyName)); } public VertexLabel getVertexLabel(String label) { - return getSchema().getVertexLabel(label); + VertexLabel vertexLabel = getVertexLabelOrNull(label); + if (vertexLabel == null) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + "Vertex label '" + + label + + "' does not exist in HugeGraph. " + + "Please create it first or check your configuration."); + } + return vertexLabel; } public EdgeLabel getEdgeLabel(String label) { - return getSchema().getEdgeLabel(label); + EdgeLabel edgeLabel = getEdgeLabelOrNull(label); + if (edgeLabel == null) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + "Edge label '" + + label + + "' does not exist in HugeGraph. " + + "Please create it first or check your configuration."); + } + return edgeLabel; } public String getVertexLabelId(String label) { - VertexLabel vertexLabel = getSchema().getVertexLabel(label); + VertexLabel vertexLabel = getVertexLabel(label); return String.valueOf(vertexLabel.id()); } public String getEdgeLabelId(String label) { - EdgeLabel edgeLabel = getSchema().getEdgeLabel(label); + EdgeLabel edgeLabel = getEdgeLabel(label); return String.valueOf(edgeLabel.id()); } public IdStrategy getIdStrategy(String label) { - VertexLabel vertexLabel = getSchema().getVertexLabel(label); + VertexLabel vertexLabel = getVertexLabel(label); return vertexLabel.idStrategy(); } + // --- Schema creation operations (idempotent, ifNotExist) --- + + public PropertyKey createPropertyKeyIfNotExist( + String name, + DataType dataType, + org.apache.hugegraph.structure.constant.Cardinality cardinality) { + return executeReadOperation( + () -> + getSchema() + .propertyKey(name) + .dataType(dataType) + .cardinality(cardinality) + .ifNotExist() + .create()); + } + + public VertexLabel createVertexLabelIfNotExist( + String label, + IdStrategy idStrategy, + List primaryKeys, + List propertyNames, + List nullableKeys, + LabelOptions options) { + return executeReadOperation( + () -> { + VertexLabel.Builder builder = + getSchema().vertexLabel(label).idStrategy(idStrategy); + if (idStrategy == IdStrategy.PRIMARY_KEY && primaryKeys != null) { + builder.primaryKeys(primaryKeys.toArray(new String[0])); + } + if (propertyNames != null && !propertyNames.isEmpty()) { + builder.properties(propertyNames.toArray(new String[0])); + } + if (nullableKeys != null && !nullableKeys.isEmpty()) { + builder.nullableKeys(nullableKeys.toArray(new String[0])); + } + if (options != null) { + if (options.getTtl() != null && options.getTtl() > 0) { + builder.ttl(options.getTtl()); + if (options.getTtlStartTime() != null + && !options.getTtlStartTime().isEmpty()) { + builder.ttlStartTime(options.getTtlStartTime()); + } + } + if (options.getEnableLabelIndex() != null) { + builder.enableLabelIndex(options.getEnableLabelIndex()); + } + if (options.getUserdata() != null) { + for (Map.Entry entry : + options.getUserdata().entrySet()) { + builder.userdata(entry.getKey(), entry.getValue()); + } + } + } + return builder.ifNotExist().create(); + }); + } + + public EdgeLabel createEdgeLabelIfNotExist( + String label, + String sourceLabel, + String targetLabel, + Frequency frequency, + List sortKeys, + List propertyNames, + List nullableKeys, + LabelOptions options) { + return executeReadOperation( + () -> { + EdgeLabel.Builder builder = + getSchema() + .edgeLabel(label) + .sourceLabel(sourceLabel) + .targetLabel(targetLabel); + if (frequency != null) { + builder.frequency(frequency); + } + if (sortKeys != null && !sortKeys.isEmpty()) { + builder.sortKeys(sortKeys.toArray(new String[0])); + } + if (propertyNames != null && !propertyNames.isEmpty()) { + builder.properties(propertyNames.toArray(new String[0])); + } + if (nullableKeys != null && !nullableKeys.isEmpty()) { + builder.nullableKeys(nullableKeys.toArray(new String[0])); + } + if (options != null) { + if (options.getTtl() != null && options.getTtl() > 0) { + builder.ttl(options.getTtl()); + if (options.getTtlStartTime() != null + && !options.getTtlStartTime().isEmpty()) { + builder.ttlStartTime(options.getTtlStartTime()); + } + } + if (options.getEnableLabelIndex() != null) { + builder.enableLabelIndex(options.getEnableLabelIndex()); + } + if (options.getUserdata() != null) { + for (Map.Entry entry : + options.getUserdata().entrySet()) { + builder.userdata(entry.getKey(), entry.getValue()); + } + } + } + return builder.ifNotExist().create(); + }); + } + + /** Check if a property key exists. Returns null if not found. */ + public PropertyKey getPropertyKeyOrNull(String name) { + return executeReadOperation( + () -> { + try { + return getSchema().getPropertyKey(name); + } catch (ServerException e) { + if (e.status() == 404 + || (e.getMessage() != null + && e.getMessage().contains("does not exist"))) { + return null; + } + throw e; + } + }); + } + + /** Check if a vertex label exists. Returns null if not found. */ + public VertexLabel getVertexLabelOrNull(String label) { + return executeReadOperation( + () -> { + try { + return getSchema().getVertexLabel(label); + } catch (ServerException e) { + if (e.status() == 404 + || (e.getMessage() != null + && e.getMessage().contains("does not exist"))) { + return null; + } + throw e; + } + }); + } + + /** Check if an edge label exists. Returns null if not found. */ + public EdgeLabel getEdgeLabelOrNull(String label) { + return executeReadOperation( + () -> { + try { + return getSchema().getEdgeLabel(label); + } catch (ServerException e) { + if (e.status() == 404 + || (e.getMessage() != null + && e.getMessage().contains("does not exist"))) { + return null; + } + throw e; + } + }); + } + + @Override + public Set getVertexLabelPropertiesOrNull(String label) { + VertexLabel vertexLabel = getVertexLabelOrNull(label); + return vertexLabel == null ? null : vertexLabel.properties(); + } + + @Override + public Set getEdgeLabelPropertiesOrNull(String label) { + EdgeLabel edgeLabel = getEdgeLabelOrNull(label); + return edgeLabel == null ? null : edgeLabel.properties(); + } + + @Override + public List listVertexLabels() { + return executeReadOperation( + () -> { + List names = new ArrayList<>(); + for (VertexLabel vertexLabel : getSchema().getVertexLabels()) { + names.add(vertexLabel.name()); + } + return names; + }); + } + + @Override + public List listEdgeLabels() { + return executeReadOperation( + () -> { + List names = new ArrayList<>(); + for (EdgeLabel edgeLabel : getSchema().getEdgeLabels()) { + names.add(edgeLabel.name()); + } + return names; + }); + } + + @Override + public DataType getPropertyDataType(String propertyName) { + return getPropertyKey(propertyName).dataType(); + } + + @Override + public org.apache.hugegraph.structure.constant.Cardinality getPropertyCardinality( + String propertyName) { + return getPropertyKey(propertyName).cardinality(); + } + + // --- Graph write operations --- + + /** + * Plain vertex insert — NOT idempotent. A retry after a server-committed-but-client-timed-out + * response would create a duplicate. Fails fast on the first error; the caller's single-record + * fallback handles the record individually instead. + */ public void writeVertex(Vertex vertex) { - executeGraphOperation(graph -> graph.addVertex(vertex)); + executeNonIdempotentWrite(graph -> graph.addVertex(vertex)); + } + + /** Plain edge insert — NOT idempotent. See {@link #writeVertex}. */ + public void writeEdge(Edge edge, boolean checkVertex) { + // Route through addEdges so the single-insert path honors checkVertex the same way the + // batch path does (GraphManager.addEdge has no checkVertex overload). + executeNonIdempotentWrite( + graph -> graph.addEdges(Collections.singletonList(edge), checkVertex)); } - public void writeEdge(Edge edge) { - executeGraphOperation(graph -> graph.addEdge(edge)); + /** Single-vertex property-merge upsert; idempotent — see {@link #batchUpdateVertices}. */ + public void updateVertex(Vertex vertex, Map updateStrategies) { + batchUpdateVertices(Collections.singletonList(vertex), updateStrategies); } + /** Single-edge property-merge upsert; idempotent — see {@link #batchUpdateEdges}. */ + public void updateEdge( + Edge edge, Map updateStrategies, boolean checkVertex) { + batchUpdateEdges(Collections.singletonList(edge), updateStrategies, checkVertex); + } + + /** + * Upserts vertices with per-property merge strategies (OVERRIDE / APPEND / SUM / UNION / ...) + * instead of overwriting. Existing vertices are merged; missing ones are created + * (createIfNotExist). Idempotent — safe to retry. Chunked like {@link #batchWriteVertices}. + */ + public void batchUpdateVertices( + List buffer, Map updateStrategies) { + for (int start = 0; start < buffer.size(); start += MAX_RECORDS_PER_BATCH_REQUEST) { + List chunk = + buffer.subList( + start, Math.min(start + MAX_RECORDS_PER_BATCH_REQUEST, buffer.size())); + BatchVertexRequest request = + new BatchVertexRequest.Builder() + .vertices(chunk) + .updatingStrategies(updateStrategies) + .createIfNotExist(true) + .build(); + executeIdempotentWrite(graph -> graph.updateVertices(request)); + } + } + + /** + * Upserts edges with per-property merge strategies. Idempotent. See {@link + * #batchUpdateVertices}. + */ + public void batchUpdateEdges( + List buffer, Map updateStrategies, boolean checkVertex) { + for (int start = 0; start < buffer.size(); start += MAX_RECORDS_PER_BATCH_REQUEST) { + List chunk = + buffer.subList( + start, Math.min(start + MAX_RECORDS_PER_BATCH_REQUEST, buffer.size())); + BatchEdgeRequest request = + new BatchEdgeRequest.Builder() + .edges(chunk) + .updatingStrategies(updateStrategies) + .checkVertex(checkVertex) + .createIfNotExist(true) + .build(); + executeIdempotentWrite(graph -> graph.updateEdges(request)); + } + } + + /** + * Writes vertices in chunks of at most {@link #MAX_RECORDS_PER_BATCH_REQUEST}. The HugeGraph + * server rejects batch requests above its per-request cap (default 500, see server option + * batch.max_vertices_per_batch), so a user-configured batch_size larger than the cap is split + * client-side instead of failing wholesale. + * + *

NOT idempotent — a retry after a server-committed-but-client-timed-out response would + * create duplicates. Fails fast; the caller's single-record fallback handles each record. + */ + public void batchWriteVertices(List buffer) { + for (int start = 0; start < buffer.size(); start += MAX_RECORDS_PER_BATCH_REQUEST) { + List chunk = + buffer.subList( + start, Math.min(start + MAX_RECORDS_PER_BATCH_REQUEST, buffer.size())); + executeNonIdempotentWrite(graph -> graph.addVertices(chunk)); + } + } + + /** + * Writes edges in server-cap-sized chunks. NOT idempotent. See {@link #batchWriteVertices} and + * {@link #batchWriteEdges}. When {@code checkVertex} is true the server verifies that each + * edge's source/target vertices exist, rejecting orphan edges instead of silently writing them + * or auto-creating phantom vertices. + */ + public void batchWriteEdges(List buffer, boolean checkVertex) { + for (int start = 0; start < buffer.size(); start += MAX_RECORDS_PER_BATCH_REQUEST) { + List chunk = + buffer.subList( + start, Math.min(start + MAX_RECORDS_PER_BATCH_REQUEST, buffer.size())); + executeNonIdempotentWrite(graph -> graph.addEdges(chunk, checkVertex)); + } + } + + // --- Graph read operations --- + + /** + * Lists one page of vertices. HugeGraph only enters paged mode when the {@code page} query + * parameter is present — a null first page must be sent as an empty string, otherwise the + * server returns a single non-paged batch without a next-page marker and the scan silently + * stops after {@code limit} records. + * + *

When {@code filter} is non-empty it is passed as the server-side property-equality + * condition map, with {@code keepP=true} so the filtered properties are retained in the + * returned vertices (they may be part of the output schema). + */ + @SuppressWarnings("unchecked") + @Override + public PageResult listVertices( + String label, Map filter, String page, int limit) { + String effectivePage = page == null ? "" : page; + boolean hasFilter = filter != null && !filter.isEmpty(); + Map conditions = hasFilter ? filter : null; + return executeReadOperation( + () -> { + Vertices vertices = + this.vertexAPI.list( + label, conditions, hasFilter, 0, effectivePage, limit); + List records = (List) vertices.results(); + return new PageResult<>( + records == null ? Collections.emptyList() : records, vertices.page()); + }); + } + + /** Lists one page of edges. See {@link #listVertices} for the empty-first-page contract. */ + @SuppressWarnings("unchecked") + @Override + public PageResult listEdges( + String label, Map filter, String page, int limit) { + String effectivePage = page == null ? "" : page; + boolean hasFilter = filter != null && !filter.isEmpty(); + Map conditions = hasFilter ? filter : null; + return executeReadOperation( + () -> { + Edges edges = + this.edgeAPI.list( + null, + null, + label, + conditions, + hasFilter, + 0, + effectivePage, + limit); + List records = (List) edges.results(); + return new PageResult<>( + records == null ? Collections.emptyList() : records, edges.page()); + }); + } + + /** + * Splits the vertex keyspace into shards for parallel scanning. Delegates to the server's + * {@code traverser().vertexShards} API. Requires a scan-capable backend (RocksDB / HBase / + * Cassandra). + */ + @Override + public List vertexShards(long splitSize) { + return executeReadOperation(() -> this.client.traverser().vertexShards(splitSize)); + } + + /** Splits the edge keyspace into shards. See {@link #vertexShards}. */ + @Override + public List edgeShards(long splitSize) { + return executeReadOperation(() -> this.client.traverser().edgeShards(splitSize)); + } + + /** + * Scans one page of vertices within {@code shard}. The empty-first-page contract of {@link + * #listVertices} applies: a null page is sent as the empty string so the server enters paged + * mode. The scan returns vertices of all labels in the key range; label filtering is the + * caller's responsibility. + */ + @Override + public PageResult scanVertices(Shard shard, String page, int limit) { + String effectivePage = page == null ? "" : page; + return executeReadOperation( + () -> { + Vertices vertices = + this.client.traverser().vertices(shard, effectivePage, limit); + List records = vertices.results(); + return new PageResult<>( + records == null ? Collections.emptyList() : records, vertices.page()); + }); + } + + /** Scans one page of edges within {@code shard}. See {@link #scanVertices}. */ + @Override + public PageResult scanEdges(Shard shard, String page, int limit) { + String effectivePage = page == null ? "" : page; + return executeReadOperation( + () -> { + Edges edges = this.client.traverser().edges(shard, effectivePage, limit); + List records = edges.results(); + return new PageResult<>( + records == null ? Collections.emptyList() : records, edges.page()); + }); + } + + // --- Graph delete operations --- + + /** Delete vertex by id — idempotent (removing an already-deleted vertex is a no-op). */ public void deleteVertex(Object vertexId) { - executeGraphOperation(graph -> graph.removeVertex(vertexId)); + executeIdempotentWrite(graph -> graph.removeVertex(vertexId)); } + /** Delete edge by id — idempotent. */ public void deleteEdge(String edgeId) { - executeGraphOperation(graph -> graph.removeEdge(edgeId)); + executeIdempotentWrite(graph -> graph.removeEdge(edgeId)); } + /** Delete vertex with its incident edges — idempotent. */ public void deleteVertexWithEdges(Object vertexId) { - executeGraphOperation( + executeIdempotentWrite( graph -> { List edges = graph.getEdges(vertexId); for (Edge edge : edges) { @@ -216,20 +903,101 @@ public void deleteVertexWithEdges(Object vertexId) { }); } - public void batchWriteVertices(List buffer) { - executeGraphOperation(graph -> graph.addVertices(buffer)); + /** + * Returns the names of every edge label whose source or target endpoint is {@code vertexLabel}. + * These are the edge labels that would be cascade-deleted if every vertex of {@code + * vertexLabel} is removed — used by the DROP_DATA pre-flight safety check. + */ + public List getConnectedEdgeLabels(String vertexLabel) { + return executeReadOperation( + () -> { + List connected = new ArrayList<>(); + for (EdgeLabel edgeLabel : getSchema().getEdgeLabels()) { + if (vertexLabel.equals(edgeLabel.sourceLabel()) + || vertexLabel.equals(edgeLabel.targetLabel())) { + connected.add(edgeLabel.name()); + } + } + return connected; + }); } - public void batchWriteEdges(List buffer) { - executeGraphOperation(graph -> graph.addEdges(buffer)); + /** Page size used when clearing a single label's data for data_save_mode=DROP_DATA. */ + private static final int DELETE_PAGE_SIZE = 500; + + /** + * Deletes every vertex of {@code label} (data only — the VertexLabel schema is preserved), used + * by data_save_mode=DROP_DATA to clear just the labels this job targets instead of wiping the + * whole graph with {@code clearGraph}. Removing a vertex also removes its incident edges on the + * server. Works by repeatedly deleting the first page until none remain, so it does not depend + * on a paging cursor staying valid across deletes. + */ + public void deleteVerticesByLabel(String label) { + LOG.info("data_save_mode=DROP_DATA: deleting all vertices of label '{}'", label); + long deleted = 0; + while (true) { + List records = listVertices(label, null, "", DELETE_PAGE_SIZE).getRecords(); + if (records.isEmpty()) { + break; + } + for (Vertex vertex : records) { + deleteVertex(vertex.id()); + deleted++; + } + } + LOG.info("Deleted {} vertices of label '{}'", deleted, label); } + /** + * Deletes every edge of {@code label} (data only — the EdgeLabel schema is preserved). See + * {@link #deleteVerticesByLabel} for the paging strategy; run before vertex deletion so + * edge-only mappings are handled even when their endpoints are out of this job's scope. + */ + public void deleteEdgesByLabel(String label) { + LOG.info("data_save_mode=DROP_DATA: deleting all edges of label '{}'", label); + long deleted = 0; + while (true) { + List records = listEdges(label, null, "", DELETE_PAGE_SIZE).getRecords(); + if (records.isEmpty()) { + break; + } + for (Edge edge : records) { + deleteEdge(edge.id()); + deleted++; + } + } + LOG.info("Deleted {} edges of label '{}'", deleted, label); + } + + @Override public void close() { + RuntimeException closeFailure = null; if (this.client != null) { LOG.info("Closing HugeClient instance."); - this.client.close(); + try { + this.client.close(); + } catch (RuntimeException e) { + closeFailure = e; + } this.client = null; - this.schema = null; + } + if (this.restClient != null) { + try { + this.restClient.close(); + } catch (RuntimeException e) { + if (closeFailure == null) { + closeFailure = e; + } else { + closeFailure.addSuppressed(e); + } + } + this.restClient = null; + } + this.vertexAPI = null; + this.edgeAPI = null; + this.schema = null; + if (closeFailure != null) { + throw closeFailure; } } } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/client/HugeGraphOperations.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/client/HugeGraphOperations.java new file mode 100644 index 000000000000..2122f85eb77a --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/client/HugeGraphOperations.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.client; + +import org.apache.hugegraph.structure.constant.Cardinality; +import org.apache.hugegraph.structure.constant.DataType; +import org.apache.hugegraph.structure.graph.Edge; +import org.apache.hugegraph.structure.graph.Shard; +import org.apache.hugegraph.structure.graph.Vertex; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public interface HugeGraphOperations { + + Set getVertexLabelPropertiesOrNull(String label); + + Set getEdgeLabelPropertiesOrNull(String label); + + /** Lists the names of all vertex labels defined in the graph schema. */ + List listVertexLabels(); + + /** Lists the names of all edge labels defined in the graph schema. */ + List listEdgeLabels(); + + DataType getPropertyDataType(String propertyName); + + Cardinality getPropertyCardinality(String propertyName); + + /** + * Lists one page of vertices of {@code label}. When {@code filter} is non-empty its entries are + * applied server-side as property-equality conditions; null/empty means no filtering. + */ + PageResult listVertices( + String label, Map filter, String page, int limit); + + /** + * Lists one page of edges of {@code label}. See {@link #listVertices} for the filter contract. + */ + PageResult listEdges(String label, Map filter, String page, int limit); + + /** + * Splits the vertex keyspace into shards of approximately {@code splitSize} bytes each, for + * parallel scanning. Requires a backend that supports scan (RocksDB / HBase / Cassandra); the + * memory backend does not. + */ + List vertexShards(long splitSize); + + /** Splits the edge keyspace into shards. See {@link #vertexShards}. */ + List edgeShards(long splitSize); + + /** + * Scans one page of vertices within {@code shard}. Unlike {@link #listVertices}, the scan is by + * key range and returns vertices of ALL labels in the range, so the caller must filter by label + * client-side; server-side property filters are not supported here. + */ + PageResult scanVertices(Shard shard, String page, int limit); + + /** Scans one page of edges within {@code shard}. See {@link #scanVertices}. */ + PageResult scanEdges(Shard shard, String page, int limit); + + void close(); +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/client/PageResult.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/client/PageResult.java new file mode 100644 index 000000000000..9124b9e731e5 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/client/PageResult.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.client; + +import lombok.Data; + +import java.util.List; + +@Data +public class PageResult { + + private final List records; + private final String nextPage; +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphConnectionConfig.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphConnectionConfig.java new file mode 100644 index 000000000000..0a5eea1adfb0 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphConnectionConfig.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.config; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import lombok.Data; + +import java.io.Serializable; + +@Data +public class HugeGraphConnectionConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + private String host; + private int port; + private String protocol; + private String graphName; + private String graphSpace; + private String username; + private String password; + private int maxRetries; + private int retryBackoffMs; + private int retryBackoffMaxMs; + + public static HugeGraphConnectionConfig of(ReadonlyConfig config) { + HugeGraphConnectionConfig connectionConfig = new HugeGraphConnectionConfig(); + connectionConfig.setHost(config.get(HugeGraphOptions.HOST)); + connectionConfig.setPort(config.get(HugeGraphOptions.PORT)); + connectionConfig.setProtocol( + config.getOptional(HugeGraphOptions.PROTOCOL) + .orElse(HugeGraphOptions.PROTOCOL.defaultValue())); + connectionConfig.setGraphName(config.get(HugeGraphOptions.GRAPH_NAME)); + connectionConfig.setGraphSpace( + config.getOptional(HugeGraphOptions.GRAPH_SPACE) + .filter(graphSpace -> !graphSpace.isEmpty()) + .orElse(HugeGraphOptions.GRAPH_SPACE.defaultValue())); + config.getOptional(HugeGraphOptions.USERNAME).ifPresent(connectionConfig::setUsername); + config.getOptional(HugeGraphOptions.PASSWORD).ifPresent(connectionConfig::setPassword); + connectionConfig.setMaxRetries( + config.getOptional(HugeGraphOptions.MAX_RETRIES) + .orElse(HugeGraphOptions.MAX_RETRIES.defaultValue())); + connectionConfig.setRetryBackoffMs( + config.getOptional(HugeGraphOptions.RETRY_BACKOFF_MS) + .orElse(HugeGraphOptions.RETRY_BACKOFF_MS.defaultValue())); + connectionConfig.setRetryBackoffMaxMs( + config.getOptional(HugeGraphOptions.RETRY_BACKOFF_MAX_MS) + .orElse(HugeGraphOptions.RETRY_BACKOFF_MAX_MS.defaultValue())); + validate(connectionConfig); + return connectionConfig; + } + + private static void validate(HugeGraphConnectionConfig config) { + // Fail fast at config-load with the offending option name, so the job stops before opening + // a client that would otherwise surface a generic connection error much later. + if (isBlank(config.getHost())) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + "Option 'host' must not be empty"); + } + if (isBlank(config.getGraphName())) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + "Option 'graph_name' must not be empty"); + } + // graph_space is not validated for emptiness: it carries a non-empty default ("DEFAULT"), + // HugeGraphConnectionConfig.of() coalesces blank values to that default, and + // HugeGraphClient additionally falls back to "DEFAULT" for any null — so it can never be + // empty here. + if (config.getPort() < 1 || config.getPort() > 65535) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Option 'port' must be in range [1, 65535], but got %s", + config.getPort())); + } + if (!"http".equalsIgnoreCase(config.getProtocol()) + && !"https".equalsIgnoreCase(config.getProtocol())) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Option 'protocol' must be 'http' or 'https', but got '%s'", + config.getProtocol())); + } + // Credentials must be paired — a lone username or lone password almost always indicates a + // config typo and produces a confusing 401 downstream. + boolean userSet = !isBlank(config.getUsername()); + boolean passwordSet = !isBlank(config.getPassword()); + if (userSet != passwordSet) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + "Options 'username' and 'password' must be set together"); + } + if (config.getMaxRetries() < 0) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + "Option 'max_retries' must be greater than or equal to 0"); + } + if (config.getRetryBackoffMs() < 0) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + "Option 'retry_backoff_ms' must be greater than or equal to 0"); + } + if (config.getRetryBackoffMaxMs() < 0) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + "Option 'retry_backoff_max_ms' must be greater than or equal to 0"); + } + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphDataSaveMode.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphDataSaveMode.java new file mode 100644 index 000000000000..c8ef5195e095 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphDataSaveMode.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.config; + +/** + * Controls how pre-existing data in the target graph is handled before the Sink writes. Mirrors the + * SeaTunnel standard {@code DataSaveMode} naming but implemented locally within the connector. + */ +public enum HugeGraphDataSaveMode { + + /** Keep existing data; new elements are written on top (default). */ + APPEND_DATA, + + /** + * Before writing, delete the existing data of only the labels this job's mappings + * target (edges then vertices), leaving their schema and any other labels' data intact. + * Deleting a vertex also removes its incident edges on the server. Scoped per label so, with a + * multi-table sink, dropping one table does not wipe another; and on checkpoint restart the + * drop is not re-run, so data written before the restart survives. + */ + DROP_DATA +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphOptions.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphOptions.java index 5dcaa6c1e089..193f35a357a7 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphOptions.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphOptions.java @@ -33,6 +33,12 @@ public class HugeGraphOptions { public static final Option PORT = Options.key("port").intType().noDefaultValue().withDescription("HugeGraph server port"); + public static final Option PROTOCOL = + Options.key("protocol") + .stringType() + .defaultValue("http") + .withDescription("HugeGraph server protocol. Supported values: http, https"); + public static final Option GRAPH_NAME = Options.key("graph_name") .stringType() @@ -42,8 +48,9 @@ public class HugeGraphOptions { public static final Option GRAPH_SPACE = Options.key("graph_space") .stringType() - .noDefaultValue() - .withDescription("The graph space of the graph to be operated on"); + .defaultValue("DEFAULT") + .withDescription( + "The graph space the graph belongs to. Defaults to 'DEFAULT'."); public static final Option USERNAME = Options.key("username") @@ -66,6 +73,52 @@ public class HugeGraphOptions { .defaultValue(5000) .withDescription("The batch flash period"); + public static final Option CHECK_VERTEX = + Options.key("check_vertex") + .booleanType() + .defaultValue(false) + .withDescription( + "Whether the server verifies that an edge's source/target vertices " + + "exist when writing edges. When false (default), edges whose " + + "endpoints were never loaded are written as orphan edges (or " + + "trigger server-side phantom vertex auto-creation). Enable to " + + "reject such edges."); + + public static final Option BATCH_FAILURE_FALLBACK = + Options.key("batch_failure_fallback") + .booleanType() + .defaultValue(false) + .withDescription( + "When true, a failed batch insert falls back to inserting records one " + + "by one so a single bad ('poison') record no longer fails the " + + "whole batch. Failed records are logged and skipped; the rest " + + "succeed. Default false (fail-fast): any batch failure fails " + + "the task immediately. Opt in explicitly when record skipping " + + "is acceptable."); + + public static final Option MAX_INSERT_ERRORS = + Options.key("max_insert_errors") + .intType() + .defaultValue(0) + .withDescription( + "Maximum number of records that may be skipped by the single-record " + + "fallback (batch_failure_fallback=true) before the task is " + + "failed. Default 0: any skipped record fails the task. Set to " + + "-1 for unlimited (never fail on skipped records). Only applies " + + "when batch_failure_fallback is enabled."); + + public static final Option FAILURE_DATA_PATH = + Options.key("failure_data_path") + .stringType() + .noDefaultValue() + .withDescription( + "Optional local directory. When set, every record skipped by the " + + "single-record fallback is appended (as the mapped vertex/edge " + + "id, label, properties and the server error) to a per-subtask " + + "file under this directory for offline investigation. Note: in " + + "cluster mode the file is created on the worker node running " + + "the sink subtask, not the submitting client."); + public static final Option MAX_RETRIES = Options.key("max_retries").intType().defaultValue(3).withDescription("The retry times"); @@ -73,5 +126,16 @@ public class HugeGraphOptions { Options.key("retry_backoff_ms") .intType() .defaultValue(5000) - .withDescription("The retry backoff time"); + .withDescription( + "The base retry backoff time in milliseconds. Backoff grows " + + "exponentially per attempt (retry_backoff_ms * 2^(attempt-1)), " + + "capped at retry_backoff_max_ms."); + + public static final Option RETRY_BACKOFF_MAX_MS = + Options.key("retry_backoff_max_ms") + .intType() + .defaultValue(30000) + .withDescription( + "Upper bound in milliseconds for the exponential retry backoff, so a " + + "high max_retries cannot produce pathologically long sleeps."); } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSchemaSaveMode.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSchemaSaveMode.java new file mode 100644 index 000000000000..0dbb565aa0d5 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSchemaSaveMode.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.config; + +/** + * Controls schema management behavior during Sink initialization. Aligns with SeaTunnel standard + * {@code SchemaSaveMode} naming but implemented locally within the connector. + */ +public enum HugeGraphSchemaSaveMode { + + /** Auto-create missing PropertyKey/VertexLabel/EdgeLabel; never modify existing schema. */ + CREATE_SCHEMA_WHEN_NOT_EXIST, + + /** Do not create any schema; fail immediately if schema is missing or mismatched. */ + ERROR_WHEN_SCHEMA_NOT_EXIST +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSinkConfig.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSinkConfig.java index dcb0fb32c303..398423ec07ca 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSinkConfig.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSinkConfig.java @@ -18,60 +18,241 @@ package org.apache.seatunnel.connectors.seatunnel.hugegraph.config; import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import lombok.Data; import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; @Data public class HugeGraphSinkConfig implements Serializable { - private String host; - private int port; - private String graphName; - private String graphSpace; - private String username; - private String password; - private SchemaConfig schemaConfig; + private static final long serialVersionUID = 1L; + + private static final Logger LOG = LoggerFactory.getLogger(HugeGraphSinkConfig.class); + + // Shared connection config + private HugeGraphConnectionConfig connectionConfig; + + // Batch config private int batchSize; private int batchIntervalMs; + private boolean batchFailureFallback; + private boolean checkVertex; private int maxRetries; private int retryBackoffMs; + // Max records the single-record fallback may skip before the task fails (-1 = unlimited). + private int maxInsertErrors; + // Optional directory to persist skipped-record failure samples; null = do not persist. + private String failureDataPath; + + // New: multi-mapping config + private List mappings; + private HugeGraphSchemaSaveMode schemaSaveMode; + private HugeGraphDataSaveMode dataSaveMode; + private boolean deleteVertexWithEdges; + private boolean allowCascadeDeleteUnmappedEdges; - // mapping config + // Legacy (deprecated, kept for backward compat parsing only) + private SchemaConfig schemaConfig; private List selectedFields; private List ignoredFields; public static HugeGraphSinkConfig of(ReadonlyConfig config) { HugeGraphSinkConfig sinkConfig = new HugeGraphSinkConfig(); - sinkConfig.setHost(config.get(HugeGraphOptions.HOST)); - sinkConfig.setPort(config.get(HugeGraphOptions.PORT)); - sinkConfig.setGraphName(config.get(HugeGraphOptions.GRAPH_NAME)); + // Connection + sinkConfig.setConnectionConfig(HugeGraphConnectionConfig.of(config)); + + // Batch sinkConfig.setBatchSize( config.getOptional(HugeGraphOptions.BATCH_SIZE) .orElse(HugeGraphOptions.BATCH_SIZE.defaultValue())); sinkConfig.setBatchIntervalMs( config.getOptional(HugeGraphOptions.BATCH_INTERVAL_MS) .orElse(HugeGraphOptions.BATCH_INTERVAL_MS.defaultValue())); - sinkConfig.setMaxRetries( - config.getOptional(HugeGraphOptions.MAX_RETRIES) - .orElse(HugeGraphOptions.MAX_RETRIES.defaultValue())); - sinkConfig.setRetryBackoffMs( - config.getOptional(HugeGraphOptions.RETRY_BACKOFF_MS) - .orElse(HugeGraphOptions.RETRY_BACKOFF_MS.defaultValue())); - sinkConfig.setSchemaConfig(config.get(HugeGraphSinkOptions.SCHEMA_CONFIG)); + sinkConfig.setBatchFailureFallback( + config.getOptional(HugeGraphOptions.BATCH_FAILURE_FALLBACK) + .orElse(HugeGraphOptions.BATCH_FAILURE_FALLBACK.defaultValue())); + sinkConfig.setCheckVertex( + config.getOptional(HugeGraphOptions.CHECK_VERTEX) + .orElse(HugeGraphOptions.CHECK_VERTEX.defaultValue())); + sinkConfig.setMaxRetries(sinkConfig.getConnectionConfig().getMaxRetries()); + sinkConfig.setRetryBackoffMs(sinkConfig.getConnectionConfig().getRetryBackoffMs()); + sinkConfig.setMaxInsertErrors( + config.getOptional(HugeGraphOptions.MAX_INSERT_ERRORS) + .orElse(HugeGraphOptions.MAX_INSERT_ERRORS.defaultValue())); + config.getOptional(HugeGraphOptions.FAILURE_DATA_PATH) + .ifPresent(sinkConfig::setFailureDataPath); + + // Resolve mappings with backward compatibility + sinkConfig.setMappings(resolveMappings(config, sinkConfig)); + applyMappingDefaults(sinkConfig.getMappings()); + + // Multi-table contract: source_table is an ALL-or-NOTHING switch. + // All absent → single-table backward-compatible (each mapping activates in the one writer). + // All present → multi-table (each mapping activates only in its matching writer). + // Mixed → misconfiguration; fail fast with a clear diagnostic. + if (sinkConfig.getMappings() != null) { + validateSourceTableConsistency(sinkConfig.getMappings()); + } + boolean legacyConfig = sinkConfig.getSchemaConfig() != null; + sinkConfig.setSchemaSaveMode( + config.getOptional(HugeGraphSinkOptions.SCHEMA_SAVE_MODE) + .orElse( + legacyConfig + ? HugeGraphSchemaSaveMode.ERROR_WHEN_SCHEMA_NOT_EXIST + : HugeGraphSinkOptions.SCHEMA_SAVE_MODE.defaultValue())); + sinkConfig.setDeleteVertexWithEdges( + config.getOptional(HugeGraphSinkOptions.DELETE_VERTEX_WITH_EDGES) + .orElse( + legacyConfig + ? true + : HugeGraphSinkOptions.DELETE_VERTEX_WITH_EDGES + .defaultValue())); + sinkConfig.setAllowCascadeDeleteUnmappedEdges( + config.getOptional(HugeGraphSinkOptions.ALLOW_CASCADE_DELETE_UNMAPPED_EDGES) + .orElse( + HugeGraphSinkOptions.ALLOW_CASCADE_DELETE_UNMAPPED_EDGES + .defaultValue())); + sinkConfig.setDataSaveMode( + config.getOptional(HugeGraphSinkOptions.DATA_SAVE_MODE) + .orElse(HugeGraphSinkOptions.DATA_SAVE_MODE.defaultValue())); + + // Deprecated fields (parse but warn) config.getOptional(HugeGraphSinkOptions.SELECTED_FIELDS) - .ifPresent(sinkConfig::setSelectedFields); + .ifPresent( + fields -> { + LOG.warn( + "Option 'selected_fields' is deprecated. Use 'properties' within each mapping instead."); + sinkConfig.setSelectedFields(fields); + }); config.getOptional(HugeGraphSinkOptions.IGNORED_FIELDS) - .ifPresent(sinkConfig::setIgnoredFields); - - config.getOptional(HugeGraphOptions.GRAPH_SPACE).ifPresent(sinkConfig::setGraphSpace); - config.getOptional(HugeGraphOptions.USERNAME).ifPresent(sinkConfig::setUsername); - config.getOptional(HugeGraphOptions.PASSWORD).ifPresent(sinkConfig::setPassword); + .ifPresent( + fields -> { + LOG.warn( + "Option 'ignored_fields' is deprecated. Use 'properties' within each mapping instead."); + sinkConfig.setIgnoredFields(fields); + }); return sinkConfig; } + + /** + * Converts legacy global field selection into the mapping property list. The old writer ignored + * {@code schema_config.properties} and wrote all fields after applying selected/ignored fields, + * so preserving that behavior requires the input row schema. + */ + public void applyLegacyFieldSelection(SeaTunnelRowType rowType) { + if (schemaConfig == null || mappings == null || mappings.isEmpty()) { + return; + } + + List effectiveFields; + if (selectedFields != null && !selectedFields.isEmpty()) { + effectiveFields = new ArrayList<>(selectedFields); + } else { + effectiveFields = new ArrayList<>(Arrays.asList(rowType.getFieldNames())); + if (ignoredFields != null && !ignoredFields.isEmpty()) { + Set ignored = new HashSet<>(ignoredFields); + effectiveFields.removeIf(ignored::contains); + } + } + mappings.get(0).setProperties(effectiveFields); + } + + private static List resolveMappings( + ReadonlyConfig config, HugeGraphSinkConfig sinkConfig) { + boolean hasMappings = config.getOptional(HugeGraphSinkOptions.MAPPINGS).isPresent(); + boolean hasSchemaConfig = + config.getOptional(HugeGraphSinkOptions.SCHEMA_CONFIG).isPresent(); + + if (hasMappings) { + if (hasSchemaConfig) { + LOG.warn( + "Both 'mappings' and 'schema_config' are present. " + + "'schema_config' will be ignored. Please migrate to 'mappings'."); + } + return config.get(HugeGraphSinkOptions.MAPPINGS); + } + + if (hasSchemaConfig) { + SchemaConfig schemaConfig = config.get(HugeGraphSinkOptions.SCHEMA_CONFIG); + sinkConfig.setSchemaConfig(schemaConfig); + return Collections.singletonList(MappingConfig.fromLegacySchemaConfig(schemaConfig)); + } + + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + "Either 'mappings' or 'schema_config' must be specified. " + + "'mappings' is the recommended option."); + } + + private static void applyMappingDefaults(List mappings) { + if (mappings == null) { + return; + } + for (MappingConfig m : mappings) { + if (m.getDateFormat() == null || m.getDateFormat().isEmpty()) { + m.setDateFormat("yyyy-MM-dd"); + } + // Leave timeZone unset when the user did not configure one; DataTypeUtil then falls + // back to ZoneId.systemDefault(), matching the HugeGraph Source. Hard-coding GMT+8 + // here previously silently shifted absolute times by up to 8 hours when the Source + // ran on a JVM whose default zone was not Asia/Shanghai. + } + } + + /** + * Enforces the ALL-or-NOTHING contract on {@code source_table}. + * + *

All mappings with {@code source_table} set → multi-table mode: each mapping activates only + * in the writer whose {@code CatalogTable.getTablePath()} matches. All mappings without {@code + * source_table} → single-table backward-compatible: every mapping activates in the one writer. + * A mix of set and unset is ambiguous — the user either forgot to add {@code source_table} to + * some mappings, or accidentally added it to one. Refuse with a clear diagnostic. + */ + static void validateSourceTableConsistency(List mappings) { + boolean anySet = false; + boolean anyUnset = false; + List setLabels = new ArrayList<>(); + List unsetLabels = new ArrayList<>(); + + for (MappingConfig m : mappings) { + if (m.getSourceTable() == null || m.getSourceTable().isEmpty()) { + anyUnset = true; + unsetLabels.add(m.getLabel()); + } else { + anySet = true; + setLabels.add(m.getLabel()); + } + } + + if (anySet && anyUnset) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Inconsistent 'source_table' configuration. %d mapping(s) set it (%s), " + + "but %d mapping(s) are missing it (%s). " + + "'source_table' is an ALL-or-NOTHING switch: either every " + + "mapping declares it (multi-table mode — each mapping activates " + + "only in the matching writer), or none do (single-table mode — " + + "the backward-compatible default). Check that you haven't " + + "forgotten 'source_table' on some mappings, or added it to one " + + "mapping by mistake.", + setLabels.size(), setLabels, unsetLabels.size(), unsetLabels)); + } + } } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSinkOptions.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSinkOptions.java index 815ca7892894..e2c1b874ff08 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSinkOptions.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSinkOptions.java @@ -24,22 +24,73 @@ public class HugeGraphSinkOptions { + public static final Option> MAPPINGS = + Options.key("mappings") + .listType(MappingConfig.class) + .noDefaultValue() + .withDescription( + "List of mapping configurations. Each mapping describes how to write " + + "a single vertex or edge label from the input row."); + + public static final Option SCHEMA_SAVE_MODE = + Options.key("schema_save_mode") + .enumType(HugeGraphSchemaSaveMode.class) + .defaultValue(HugeGraphSchemaSaveMode.CREATE_SCHEMA_WHEN_NOT_EXIST) + .withDescription( + "Schema management mode. CREATE_SCHEMA_WHEN_NOT_EXIST (default) auto-creates " + + "missing PropertyKey/VertexLabel/EdgeLabel. ERROR_WHEN_SCHEMA_NOT_EXIST " + + "fails if schema is missing."); + + public static final Option DATA_SAVE_MODE = + Options.key("data_save_mode") + .enumType(HugeGraphDataSaveMode.class) + .defaultValue(HugeGraphDataSaveMode.APPEND_DATA) + .withDescription( + "How pre-existing data is handled before writing. APPEND_DATA (default) " + + "keeps existing data. DROP_DATA deletes the existing data of only " + + "the labels this job targets (edges then vertices) at job start, " + + "preserving their schema and any other labels' data; the drop is " + + "scoped per label and is not re-run on checkpoint restart."); + + public static final Option DELETE_VERTEX_WITH_EDGES = + Options.key("delete_vertex_with_edges") + .booleanType() + .defaultValue(false) + .withDescription( + "When true, DELETE rows for vertices will cascade-delete associated edges. " + + "Default false: only the vertex itself is deleted."); + + public static final Option ALLOW_CASCADE_DELETE_UNMAPPED_EDGES = + Options.key("allow_cascade_delete_unmapped_edges") + .booleanType() + .defaultValue(false) + .withDescription( + "When data_save_mode is DROP_DATA, deleting vertices cascades to their " + + "incident edges — including edge labels not listed in this job's " + + "mappings. Default false: the job fails fast and lists the unmapped " + + "edge labels. Set to true to accept the destructive cascade."); + + // --- Legacy options (deprecated, kept for backward compatibility) --- + + public static final Option SCHEMA_CONFIG = + Options.key("schema_config") + .objectType(SchemaConfig.class) + .noDefaultValue() + .withDescription( + "[Deprecated] Use 'mappings' instead. Legacy schema configuration object " + + "that describes the mapping to a vertex or edge."); + public static final Option> SELECTED_FIELDS = Options.key("selected_fields") .listType() .noDefaultValue() - .withDescription("Selected Fields"); + .withDescription( + "[Deprecated] Use 'properties' within each mapping instead. Selected fields."); public static final Option> IGNORED_FIELDS = Options.key("ignored_fields") .listType() .noDefaultValue() - .withDescription("Ignored Fields"); - - public static final Option SCHEMA_CONFIG = - Options.key("schema_config") - .objectType(SchemaConfig.class) - .noDefaultValue() .withDescription( - "Schema configuration object that describes the mapping to a vertex or edge."); + "[Deprecated] Use 'properties' within each mapping instead. Ignored fields."); } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSourceConfig.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSourceConfig.java new file mode 100644 index 000000000000..0d6d3bcb0206 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSourceConfig.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.config; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import lombok.Data; + +import java.io.Serializable; +import java.time.DateTimeException; +import java.time.ZoneId; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Data +public class HugeGraphSourceConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + private HugeGraphConnectionConfig connectionConfig; + private String label; + private MappingConfig.LabelType labelType; + // Read-all-labels mode: when true, {@code label}/{@code schema} are null and {@code labels} + // holds every label of {@code labelType} to read (one produced table each). + private boolean readAllLabels; + private List labels; + private SeaTunnelRowType schema; + private int pageSize; + private long splitSize; + private String timeZone; + // Optional server-side property equality conditions; null/empty = read all elements. + private Map filter; + + public static HugeGraphSourceConfig of(ReadonlyConfig config, SeaTunnelRowType schema) { + HugeGraphSourceConfig sourceConfig = new HugeGraphSourceConfig(); + sourceConfig.setConnectionConfig(HugeGraphConnectionConfig.of(config)); + sourceConfig.setReadAllLabels(false); + sourceConfig.setLabel(config.get(HugeGraphSourceOptions.LABEL)); + sourceConfig.setLabels(Collections.singletonList(config.get(HugeGraphSourceOptions.LABEL))); + sourceConfig.setLabelType( + config.getOptional(HugeGraphSourceOptions.LABEL_TYPE) + .orElse(HugeGraphSourceOptions.LABEL_TYPE.defaultValue())); + sourceConfig.setSchema(schema); + sourceConfig.setPageSize( + config.getOptional(HugeGraphSourceOptions.PAGE_SIZE) + .orElse(HugeGraphSourceOptions.PAGE_SIZE.defaultValue())); + sourceConfig.setSplitSize( + config.getOptional(HugeGraphSourceOptions.SPLIT_SIZE) + .orElse(HugeGraphSourceOptions.SPLIT_SIZE.defaultValue())); + config.getOptional(HugeGraphSourceOptions.TIME_ZONE).ifPresent(sourceConfig::setTimeZone); + config.getOptional(HugeGraphSourceOptions.FILTER).ifPresent(sourceConfig::setFilter); + validate(sourceConfig); + return sourceConfig; + } + + /** + * Read-all-labels construction: no single {@code label} and no user {@code schema}/{@code + * filter}; the labels are discovered from the server and each gets its own auto-discovered row + * type. See {@link + * org.apache.seatunnel.connectors.seatunnel.hugegraph.source.HugeGraphSourceFactory}. + */ + public static HugeGraphSourceConfig ofReadAll(ReadonlyConfig config, List labels) { + HugeGraphSourceConfig sourceConfig = new HugeGraphSourceConfig(); + sourceConfig.setConnectionConfig(HugeGraphConnectionConfig.of(config)); + sourceConfig.setReadAllLabels(true); + sourceConfig.setLabel(null); + sourceConfig.setLabels(labels); + sourceConfig.setLabelType( + config.getOptional(HugeGraphSourceOptions.LABEL_TYPE) + .orElse(HugeGraphSourceOptions.LABEL_TYPE.defaultValue())); + sourceConfig.setSchema(null); + sourceConfig.setPageSize( + config.getOptional(HugeGraphSourceOptions.PAGE_SIZE) + .orElse(HugeGraphSourceOptions.PAGE_SIZE.defaultValue())); + sourceConfig.setSplitSize( + config.getOptional(HugeGraphSourceOptions.SPLIT_SIZE) + .orElse(HugeGraphSourceOptions.SPLIT_SIZE.defaultValue())); + config.getOptional(HugeGraphSourceOptions.TIME_ZONE).ifPresent(sourceConfig::setTimeZone); + validate(sourceConfig); + return sourceConfig; + } + + private static void validate(HugeGraphSourceConfig sourceConfig) { + int pageSize = sourceConfig.getPageSize(); + if (pageSize < HugeGraphSourceOptions.MIN_PAGE_SIZE + || pageSize > HugeGraphSourceOptions.MAX_PAGE_SIZE) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Option 'page_size' must be in range [%s, %s], but got %s", + HugeGraphSourceOptions.MIN_PAGE_SIZE, + HugeGraphSourceOptions.MAX_PAGE_SIZE, + pageSize)); + } + + if (sourceConfig.getSplitSize() < HugeGraphSourceOptions.MIN_SPLIT_SIZE) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Option 'split_size' must be at least %s bytes (the HugeGraph minimum " + + "shard size); a smaller value would split the keyspace into a " + + "huge number of shards and risk OOM / oversized checkpoints. " + + "Got %s.", + HugeGraphSourceOptions.MIN_SPLIT_SIZE, sourceConfig.getSplitSize())); + } + + if (sourceConfig.isReadAllLabels()) { + // Read-all mode discovers labels from the server; there must be at least one, and no + // single user schema applies (each label gets its own auto-discovered row type). + if (sourceConfig.getLabels() == null || sourceConfig.getLabels().isEmpty()) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + "Read-all-labels mode requires at least one label, but none were discovered."); + } + } else if (sourceConfig.getSchema() == null) { + // Single-label mode: schema must be present, but an empty fields block is valid — a + // property-less label (e.g. a pure relationship edge, or a vertex with no properties) + // is exported as just the reserved columns (~id/~label/…). Requiring a fake property + // would make such labels unreadable. + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + "Option 'schema' is required (use 'schema = { fields {} }' for a label with no properties)"); + } + if (sourceConfig.getTimeZone() != null) { + try { + ZoneId.of(sourceConfig.getTimeZone()); + } catch (DateTimeException e) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Option 'time_zone' must be a valid ZoneId, but got '%s'", + sourceConfig.getTimeZone()), + e); + } + } + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSourceOptions.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSourceOptions.java new file mode 100644 index 000000000000..5a42f1b79a0c --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSourceOptions.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.config; + +import org.apache.seatunnel.shade.com.fasterxml.jackson.core.type.TypeReference; + +import org.apache.seatunnel.api.configuration.Option; +import org.apache.seatunnel.api.configuration.Options; + +import java.util.Map; + +public class HugeGraphSourceOptions { + + public static final int MIN_PAGE_SIZE = 100; + public static final int MAX_PAGE_SIZE = 10000; + // Lower bound for split_size (1 MiB), matching the HugeGraph server's own minimum shard size. + // A smaller value shatters the keyspace into a huge number of shards — one split per shard, + // each + // persisted into every checkpoint — risking OOM / oversized checkpoints, and the server rejects + // it anyway; reject it up front with a clear message. + public static final long MIN_SPLIT_SIZE = 1048576L; + + public static final Option LABEL = + Options.key("label") + .stringType() + .noDefaultValue() + .withDescription("HugeGraph vertex label or edge label to read"); + + public static final Option LABEL_TYPE = + Options.key("label_type") + .enumType(MappingConfig.LabelType.class) + .defaultValue(MappingConfig.LabelType.VERTEX) + .withDescription("HugeGraph label type. Supported values are VERTEX and EDGE"); + + public static final Option PAGE_SIZE = + Options.key("page_size") + .intType() + .defaultValue(1000) + .withDescription("Records per HugeGraph page, must be in range [100, 10000]"); + + public static final Option TIME_ZONE = + Options.key("time_zone") + .stringType() + .noDefaultValue() + .withDescription( + "Time zone used to convert HugeGraph DATE values that the server returns " + + "as an epoch/Date (the instant is rendered as a local date-time " + + "in this zone). It does NOT apply when the server returns a DATE " + + "already serialized as a wall-clock string (e.g. " + + "'yyyy-MM-dd HH:mm:ss.SSS') — that value is kept verbatim, since " + + "its original zone is not carried in the string. When omitted, " + + "the worker JVM default time zone is used for backward " + + "compatibility."); + + public static final Option SPLIT_SIZE = + Options.key("split_size") + .longType() + .defaultValue(1048576L) + .withDescription( + "Target size in bytes of each key-range shard when parallelism > 1. " + + "The server splits the keyspace into shards of roughly this " + + "size and readers scan them in parallel; a larger value yields " + + "fewer, bigger shards. Ignored when parallelism = 1 (which uses " + + "the single label-list scan). Requires a scan-capable backend " + + "(RocksDB / HBase / Cassandra)."); + + public static final Option> FILTER = + Options.key("filter") + .type(new TypeReference>() {}) + .noDefaultValue() + .withDescription( + "Optional property equality conditions applied server-side when " + + "reading the label, e.g. { country = \"US\", active = \"true\" }. " + + "Only elements whose properties match all entries are returned. " + + "Every key must be a property of the configured label. When " + + "omitted, all elements of the label are read."); +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/LabelOptions.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/LabelOptions.java new file mode 100644 index 000000000000..77c7d4cb019a --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/LabelOptions.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.config; + +import java.util.Map; + +/** + * Optional HugeGraph label attributes applied at schema-creation time (TTL, TTL start-time + * property, label-index toggle, and user-defined metadata). All fields are nullable; a null field + * means "leave at the HugeGraph server default". + */ +public class LabelOptions { + + private final Long ttl; + private final String ttlStartTime; + private final Boolean enableLabelIndex; + private final Map userdata; + + public LabelOptions( + Long ttl, String ttlStartTime, Boolean enableLabelIndex, Map userdata) { + this.ttl = ttl; + this.ttlStartTime = ttlStartTime; + this.enableLabelIndex = enableLabelIndex; + this.userdata = userdata; + } + + public Long getTtl() { + return ttl; + } + + public String getTtlStartTime() { + return ttlStartTime; + } + + public Boolean getEnableLabelIndex() { + return enableLabelIndex; + } + + public Map getUserdata() { + return userdata; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/ListFormat.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/ListFormat.java new file mode 100644 index 000000000000..35aae38946c0 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/ListFormat.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.config; + +import lombok.Data; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; + +/** + * How a raw string cell is parsed into the elements of a SET / LIST property. Defaults preserve the + * connector's historical behavior: an optional surrounding {@code [ ]}, comma-separated elements, + * with blank elements dropped. + */ +@Data +public class ListFormat implements Serializable { + + private static final long serialVersionUID = 1L; + + /** Optional leading symbol stripped before splitting (e.g. {@code [}). Empty disables it. */ + private String startSymbol = "["; + + /** Optional trailing symbol stripped before splitting (e.g. {@code ]}). Empty disables it. */ + private String endSymbol = "]"; + + /** Delimiter between elements. */ + private String elemDelimiter = ","; + + /** Element values to drop after splitting (in addition to blank elements). */ + private List ignoredElems; + + public List getIgnoredElems() { + return ignoredElems == null ? Collections.emptyList() : ignoredElems; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/MappingConfig.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/MappingConfig.java index c71c2ba91427..c25649f4854d 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/MappingConfig.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/MappingConfig.java @@ -17,21 +17,208 @@ package org.apache.seatunnel.connectors.seatunnel.hugegraph.config; +import org.apache.hugegraph.structure.constant.Frequency; +import org.apache.hugegraph.structure.constant.IdStrategy; +import org.apache.hugegraph.structure.graph.UpdateStrategy; + import lombok.Data; import java.io.Serializable; +import java.util.Collections; import java.util.List; import java.util.Map; @Data public class MappingConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + // Element type + private LabelType type; + private String label; + + // Optional: binds this mapping to a specific input CatalogTable. + // When set, the mapping only activates in a writer whose tablePath.toString() + // matches (multi-table sink). When absent / empty, the mapping activates in + // every writer — backward compatible with single-table jobs where there is + // only one writer. The value should be the table path string as it appears + // in the source's produced CatalogTable (e.g. "hugegraph.person"). + private String sourceTable; + + // Vertex-specific + private IdStrategy idStrategy; + private List idFields; + // Expand a list-valued id cell into one vertex per element (INSERT/append only, CUSTOMIZE ids). + private boolean unfold; + + // Edge-specific + private SourceTargetConfig sourceConfig; + private SourceTargetConfig targetConfig; + private Frequency frequency; + private List sortKeys; + // Expand a list-valued source/target id cell into multiple edges (cartesian; INSERT/append + // only, + // CUSTOMIZE endpoint ids). + private boolean unfoldSource; + private boolean unfoldTarget; + + // Property config. `properties` is the selected whitelist (only these source fields become + // properties); when empty, all input fields are used. `ignored` is the opposite blacklist + // (all fields except these). The two are mutually exclusive. + private List properties; + private List ignored; + + // Field mapping (source field name → target property name) private Map fieldMapping; - private Map valueMapping; + // Per-field value mapping: outer key = source field name, inner map = rawValue -> mappedValue. + // Scoping by field prevents one column's rule from bleeding into another (e.g. gender M->male + // must not also rewrite status M). + private Map> valueMapping; private List nullableKeys; + private List notNullableKeys; private List nullValues; - private List sortKeys; + + // Per-property update-merge strategies (OVERRIDE / APPEND / SUM / UNION / ...), keyed by target + // property name. When set, existing elements are merged instead of overwritten. + private Map updateStrategies; // Time config private String dateFormat; + private List extraDateFormats; private String timeZone; + + // How raw string cells are parsed into SET/LIST elements. + private ListFormat listFormat; + + // Label metadata (for schema creation) + private Long ttl; + private String ttlStartTime; + private String enableLabelIndex; + private Map userdata; + + public enum LabelType { + VERTEX, + EDGE + } + + @Data + public static class SourceTargetConfig implements Serializable { + + private static final long serialVersionUID = 1L; + private String label; + private List idFields; + } + + public Map getFieldMapping() { + return fieldMapping == null ? Collections.emptyMap() : fieldMapping; + } + + public Map> getValueMapping() { + return valueMapping == null ? Collections.emptyMap() : valueMapping; + } + + public List getNullValues() { + return nullValues == null ? Collections.emptyList() : nullValues; + } + + public List getNullableKeys() { + return nullableKeys == null ? Collections.emptyList() : nullableKeys; + } + + public List getNotNullableKeys() { + return notNullableKeys == null ? Collections.emptyList() : notNullableKeys; + } + + public List getSortKeys() { + return sortKeys == null ? Collections.emptyList() : sortKeys; + } + + public List getProperties() { + return properties == null ? Collections.emptyList() : properties; + } + + public List getIgnored() { + return ignored == null ? Collections.emptyList() : ignored; + } + + public ListFormat getListFormat() { + return listFormat == null ? new ListFormat() : listFormat; + } + + public List getExtraDateFormats() { + return extraDateFormats == null ? Collections.emptyList() : extraDateFormats; + } + + public Map getUpdateStrategies() { + return updateStrategies == null ? Collections.emptyMap() : updateStrategies; + } + + public String getSourceTable() { + return sourceTable == null ? "" : sourceTable; + } + + /** + * Whether this mapping is applicable to a writer serving the given table path. A mapping + * without {@code sourceTable} applies to every writer (backward compatible); a mapping with + * {@code sourceTable} only applies when the table path matches. + */ + public boolean appliesTo(String tablePath) { + if (sourceTable == null || sourceTable.isEmpty()) { + return true; + } + return sourceTable.equals(tablePath); + } + + /** Converts a legacy SchemaConfig to the new unified MappingConfig. */ + public static MappingConfig fromLegacySchemaConfig(SchemaConfig schema) { + MappingConfig config = new MappingConfig(); + + // Element type & label + if (schema.getType() != null) { + config.setType(LabelType.valueOf(schema.getType().name())); + } + config.setLabel(schema.getLabel()); + + // Vertex config + config.setIdStrategy(schema.getIdStrategy()); + config.setIdFields(schema.getIdFields()); + + // Edge config + if (schema.getSourceConfig() != null) { + SourceTargetConfig src = new SourceTargetConfig(); + src.setLabel(schema.getSourceConfig().getLabel()); + src.setIdFields(schema.getSourceConfig().getIdFields()); + config.setSourceConfig(src); + } + if (schema.getTargetConfig() != null) { + SourceTargetConfig tgt = new SourceTargetConfig(); + tgt.setLabel(schema.getTargetConfig().getLabel()); + tgt.setIdFields(schema.getTargetConfig().getIdFields()); + config.setTargetConfig(tgt); + } + config.setFrequency(schema.getFrequency()); + + // Properties + config.setProperties(schema.getProperties()); + + // Label metadata + config.setTtl(schema.getTtl()); + config.setTtlStartTime(schema.getTtlStartTime()); + config.setEnableLabelIndex(schema.getEnableLabelIndex()); + config.setUserdata(schema.getUserdata()); + + // Flatten the nested mapping config + if (schema.getMapping() != null) { + MappingConfig legacy = schema.getMapping(); + config.setFieldMapping(legacy.getFieldMapping()); + config.setValueMapping(legacy.getValueMapping()); + config.setNullableKeys(legacy.getNullableKeys()); + config.setNullValues(legacy.getNullValues()); + config.setSortKeys(legacy.getSortKeys()); + config.setDateFormat(legacy.getDateFormat()); + config.setTimeZone(legacy.getTimeZone()); + } + + return config; + } } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/ReservedColumns.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/ReservedColumns.java new file mode 100644 index 000000000000..17b1cd4cb409 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/ReservedColumns.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.config; + +import java.util.Collection; +import java.util.List; + +/** + * Reserved output columns emitted by the HugeGraph Source. Every reserved column name starts with + * {@code ~}, which HugeGraph forbids for property key names, so they never collide with user + * properties. + * + *

These columns carry the pre-assembled HugeGraph element ids (the vertex id in {@code ~id}, the + * edge endpoint vertex ids in {@code ~source_id}/{@code ~target_id}). When a Sink mapping sets a + * single reserved column as its {@code idFields} / endpoint {@code idFields}, the mapper consumes + * that pre-assembled id directly instead of re-building one from primary-key columns — this is what + * makes a lossless HugeGraph → HugeGraph clone of edges and of CUSTOMIZE-id vertices possible. + */ +public final class ReservedColumns { + + public static final String PREFIX = "~"; + + public static final String ID = "~id"; + public static final String LABEL = "~label"; + public static final String SOURCE_ID = "~source_id"; + public static final String SOURCE_LABEL = "~source_label"; + public static final String TARGET_ID = "~target_id"; + public static final String TARGET_LABEL = "~target_label"; + + private ReservedColumns() {} + + /** Whether {@code field} is a reserved Source column (starts with {@code ~}). */ + public static boolean isReserved(String field) { + return field != null && field.startsWith(PREFIX); + } + + /** + * Whether an {@code idFields} list requests raw-id passthrough: exactly one field, and that + * field is a reserved Source column carrying a pre-assembled id. + */ + public static boolean isRawIdPassthrough(List idFields) { + return idFields != null && idFields.size() == 1 && isReserved(idFields.get(0)); + } + + /** + * Removes reserved column names from the collection in-place, so callers that build a property + * set from all row fields (e.g. mappers and validators) can strip the non-property passthrough + * columns with one call. Returns {@code fields} for fluent use. + */ + public static > T stripReserved(T fields) { + fields.removeIf(ReservedColumns::isReserved); + return fields; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/SchemaConfig.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/SchemaConfig.java index 4dfe3cddfe28..abcfe399fcc8 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/SchemaConfig.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/SchemaConfig.java @@ -26,9 +26,15 @@ import java.util.List; import java.util.Map; +/** + * Legacy schema configuration object for backward compatibility. New configurations should use + * {@code mappings[]} with {@link MappingConfig} instead. + */ @Data public class SchemaConfig implements Serializable { + private static final long serialVersionUID = 1L; + // General config private LabelType type; private String label; @@ -52,7 +58,9 @@ public class SchemaConfig implements Serializable { private SourceTargetConfig targetConfig; private Frequency frequency; - // Mapping Config + // Mapping Config (legacy nested object). Kept as MappingConfig to preserve the public + // getMapping()/setMapping() accessor descriptors of the previously released connector; + // MappingConfig already carries all the legacy nested fields. private MappingConfig mapping; public enum LabelType { @@ -62,6 +70,7 @@ public enum LabelType { @Data public static class SourceTargetConfig implements Serializable { + private static final long serialVersionUID = 1L; private String label; private List idFields; } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/exception/HugeGraphConnectorErrorCode.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/exception/HugeGraphConnectorErrorCode.java index e5608287c382..1c5205d394d9 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/exception/HugeGraphConnectorErrorCode.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/exception/HugeGraphConnectorErrorCode.java @@ -27,6 +27,7 @@ public enum HugeGraphConnectorErrorCode implements SeaTunnelErrorCode { BUFFER_ADD_FAILED("HUGEGRAPH-05", "BatchBuffer is already closed."), INVALID_GRAPH_SCHEMA("HUGEGRAPH-06", "Invalid Graph Schema"), ILLEGAL_CONFIG_ARGUMENT("HUGEGRAPH-07", "Illegal argument"), + SCHEMA_CREATION_FAILED("HUGEGRAPH-08", "Schema auto-creation failed"), ; private final String code; diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/EdgeMapper.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/EdgeMapper.java index 2e22a76fa80d..ac37738d85b7 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/EdgeMapper.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/EdgeMapper.java @@ -20,12 +20,15 @@ import org.apache.seatunnel.api.table.type.SeaTunnelRow; import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; -import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.SchemaConfig; -import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.SchemaConfig.SourceTargetConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig.SourceTargetConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.ReservedColumns; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; import org.apache.seatunnel.connectors.seatunnel.hugegraph.utils.DataTypeUtil; +import org.apache.hugegraph.serializer.direct.util.SplicingIdGenerator; +import org.apache.hugegraph.structure.GraphElement; +import org.apache.hugegraph.structure.constant.Frequency; import org.apache.hugegraph.structure.constant.IdStrategy; import org.apache.hugegraph.structure.graph.Edge; import org.apache.hugegraph.structure.schema.PropertyKey; @@ -38,107 +41,266 @@ import java.util.Map; import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; public class EdgeMapper implements GraphDataMapper { - private final SchemaConfig schemaConfig; private final MappingConfig mappingConfig; private final Map fieldsIndex; private final HugeGraphClient client; private final String labelId; private final Map propertyKeyCache; + private final Set propertySourceFields; + private final Set edgeIdSourceFields; + + // Cached at construction time to avoid per-row schema queries + private final String sourceVertexLabelId; + private final IdStrategy sourceIdStrategy; + private final String targetVertexLabelId; + private final IdStrategy targetIdStrategy; + private final boolean unfoldSource; + private final boolean unfoldTarget; public EdgeMapper( - SchemaConfig schemaConfig, Map fieldsIndex, HugeGraphClient client) { - this.schemaConfig = schemaConfig; - this.mappingConfig = getMappingConfig(); + MappingConfig mappingConfig, Map fieldsIndex, HugeGraphClient client) { + this.mappingConfig = mappingConfig; this.client = client; - this.labelId = client.getEdgeLabelId(schemaConfig.getLabel()); + this.labelId = client.getEdgeLabelId(mappingConfig.getLabel()); this.fieldsIndex = fieldsIndex; - this.propertyKeyCache = getPropertyKeyCache(); + this.edgeIdSourceFields = resolveEdgeIdSourceFields(); + this.propertySourceFields = resolvePropertySourceFields(); + this.propertyKeyCache = buildPropertyKeyCache(); + this.unfoldSource = mappingConfig.isUnfoldSource(); + this.unfoldTarget = mappingConfig.isUnfoldTarget(); + + // Cache source/target vertex metadata to avoid per-row schema queries + this.sourceVertexLabelId = + client.getVertexLabelId(mappingConfig.getSourceConfig().getLabel()); + this.sourceIdStrategy = client.getIdStrategy(mappingConfig.getSourceConfig().getLabel()); + this.targetVertexLabelId = + client.getVertexLabelId(mappingConfig.getTargetConfig().getLabel()); + this.targetIdStrategy = client.getIdStrategy(mappingConfig.getTargetConfig().getLabel()); + } + + @Override + public boolean isUnfoldEnabled() { + return unfoldSource || unfoldTarget; + } + + private Set resolveEdgeIdSourceFields() { + Set fields = new HashSet<>(); + if (mappingConfig.getSourceConfig() != null + && mappingConfig.getSourceConfig().getIdFields() != null) { + fields.addAll(mappingConfig.getSourceConfig().getIdFields()); + } + if (mappingConfig.getTargetConfig() != null + && mappingConfig.getTargetConfig().getIdFields() != null) { + fields.addAll(mappingConfig.getTargetConfig().getIdFields()); + } + return fields; } - private MappingConfig getMappingConfig() { - MappingConfig mapping = - schemaConfig.getMapping() == null ? new MappingConfig() : schemaConfig.getMapping(); - if (mapping.getFieldMapping() == null) { - mapping.setFieldMapping(Collections.emptyMap()); + private Set resolvePropertySourceFields() { + Set fields = new HashSet<>(); + if (mappingConfig.getProperties().isEmpty()) { + // Implicit mode ("write every row field as a property") — endpoint id fields locate + // vertices and would otherwise be duplicated onto the edge; drop them here. + fields.addAll(fieldsIndex.keySet()); + fields.removeAll(edgeIdSourceFields); + fields.removeAll(reservedSourceFields(fieldsIndex.keySet())); + // `ignored` blacklist only applies in implicit mode. + fields.removeAll(mappingConfig.getIgnored()); + } else { + // Explicit mode — respect the user's list verbatim. If they list an endpoint field it + // is genuinely meant to appear as an edge property, matching what SchemaManager + // creates on the server. + fields.addAll(mappingConfig.getProperties()); } - if (mapping.getValueMapping() == null) { - mapping.setValueMapping(Collections.emptyMap()); + // Sort keys are always edge properties (server-side EdgeId requires them). + fields.addAll(mappingConfig.getSortKeys()); + return fields; + } + + /** + * Reserved fields emitted by the HugeGraph Source (e.g. {@code ~id}, {@code ~label}). They are + * not valid HugeGraph property key names — including them would fail at server-side property + * key creation — so drop them from an implicit round-trip. + */ + private static Set reservedSourceFields(Set allFields) { + Set reserved = new HashSet<>(); + for (String field : allFields) { + if (field != null && field.startsWith("~")) { + reserved.add(field); + } } - schemaConfig.setMapping(mapping); - return mapping; + return reserved; } - private HashMap getPropertyKeyCache() { + private HashMap buildPropertyKeyCache() { HashMap cache = new HashMap<>(); - Map fieldMapping = mappingConfig.getFieldMapping(); - for (String fieldName : fieldsIndex.keySet()) { - String propertyName = fieldMapping.getOrDefault(fieldName, fieldName); - cache.put(propertyName, client.getPropertyKey(propertyName)); + Map fm = mappingConfig.getFieldMapping(); + + // Cache for property fields + for (String sourceField : propertySourceFields) { + String propName = fm.getOrDefault(sourceField, sourceField); + if (!cache.containsKey(propName)) { + cache.put(propName, client.getPropertyKey(propName)); + } + } + + // Cache for id fields (needed for type conversion during ID construction) + for (String idField : edgeIdSourceFields) { + String propName = fm.getOrDefault(idField, idField); + if (!cache.containsKey(propName)) { + PropertyKey pk = client.getPropertyKeyOrNull(propName); + if (pk != null) { + cache.put(propName, pk); + } + } } + return cache; } @Override public Edge map(SeaTunnelRow row) { - // 1. Build source and target vertex IDs - Object sourceId = buildVertexId(row, schemaConfig.getSourceConfig()); - Object targetId = buildVertexId(row, schemaConfig.getTargetConfig()); + Object sourceId = buildVertexId(row, mappingConfig.getSourceConfig()); + Object targetId = buildVertexId(row, mappingConfig.getTargetConfig()); - // If source or target ID can't be built, we can't create the edge if (sourceId == null || targetId == null) { return null; } + return buildEdge(row, sourceId, targetId); + } + + /** + * INSERT/append-path expansion: when {@code unfold_source} / {@code unfold_target} is set, a + * list-valued endpoint id cell expands into multiple endpoint ids and edges are produced for + * the cartesian product. Only CUSTOMIZE endpoints are supported (validated in SchemaValidator). + */ + @Override + public List mapAll(SeaTunnelRow row) { + if (!unfoldSource && !unfoldTarget) { + Edge edge = map(row); + return edge == null ? Collections.emptyList() : Collections.singletonList(edge); + } + List sourceIds = + buildVertexIdList(row, mappingConfig.getSourceConfig(), unfoldSource); + List targetIds = + buildVertexIdList(row, mappingConfig.getTargetConfig(), unfoldTarget); + if (sourceIds.isEmpty() || targetIds.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(sourceIds.size() * targetIds.size()); + for (Object sourceId : sourceIds) { + for (Object targetId : targetIds) { + result.add(buildEdge(row, sourceId, targetId)); + } + } + return result; + } + + private List buildVertexIdList( + SeaTunnelRow row, SourceTargetConfig config, boolean unfoldEndpoint) { + if (!unfoldEndpoint) { + Object id = buildVertexId(row, config); + return id == null ? Collections.emptyList() : Collections.singletonList(id); + } + boolean isSource = config == mappingConfig.getSourceConfig(); + IdStrategy strategy = isSource ? sourceIdStrategy : targetIdStrategy; + String idField = config.getIdFields().get(0); + Integer idx = fieldsIndex.get(idField); + if (idx == null) { + return Collections.emptyList(); + } + Object raw = row.getField(idx); + if (isConsideredNull(raw)) { + return Collections.emptyList(); + } + List elements = DataTypeUtil.splitField(idField, raw); + List ids = new ArrayList<>(elements.size()); + for (Object elem : elements) { + if (isConsideredNull(elem)) { + continue; + } + ids.add(coerceCustomizeId(strategy, elem)); + } + return ids; + } + + private static Object coerceCustomizeId(IdStrategy strategy, Object elem) { + switch (strategy) { + case CUSTOMIZE_STRING: + return VertexMapper.checkVertexIdLength(String.valueOf(elem)); + case CUSTOMIZE_NUMBER: + return VertexMapper.coerceNumberId(elem); + case CUSTOMIZE_UUID: + return elem instanceof UUID ? elem : UUID.fromString(String.valueOf(elem)); + default: + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + "unfold requires a CUSTOMIZE_STRING/NUMBER/UUID endpoint id strategy, but got " + + strategy); + } + } - // 2. Create edge and set identifiers - Edge edge = new Edge(schemaConfig.getLabel()); + private Edge buildEdge(SeaTunnelRow row, Object sourceId, Object targetId) { + Edge edge = new Edge(mappingConfig.getLabel()); edge.sourceId(sourceId); edge.targetId(targetId); - edge.sourceLabel(schemaConfig.getSourceConfig().getLabel()); - edge.targetLabel(schemaConfig.getTargetConfig().getLabel()); - - // 3. Set properties - Set idFields = new HashSet<>(); - idFields.addAll(schemaConfig.getSourceConfig().getIdFields()); - idFields.addAll(schemaConfig.getTargetConfig().getIdFields()); + edge.sourceLabel(mappingConfig.getSourceConfig().getLabel()); + edge.targetLabel(mappingConfig.getTargetConfig().getLabel()); - Map fieldMapping = new HashMap<>(mappingConfig.getFieldMapping()); + Map fm = mappingConfig.getFieldMapping(); + for (String sourceField : propertySourceFields) { + Integer index = fieldsIndex.get(sourceField); + if (index == null) { + continue; + } - for (Map.Entry fieldEntry : fieldsIndex.entrySet()) { - String fieldName = fieldEntry.getKey(); - String propertyName = fieldMapping.getOrDefault(fieldName, fieldName); - Object rawValue = row.getField(fieldEntry.getValue()); - PropertyKey propertyKey = propertyKeyCache.get(propertyName); + String propName = fm.getOrDefault(sourceField, sourceField); + Object rawValue = row.getField(index); + PropertyKey propertyKey = propertyKeyCache.get(propName); - // Skip fields used for source/target vertex IDs - if (idFields.contains(fieldName) || isConsideredNull(rawValue)) { + if (isConsideredNull(rawValue)) { continue; } - Object fieldValue = + Object converted = DataTypeUtil.convert( rawValue, propertyKey, mappingConfig.getDateFormat(), - mappingConfig.getTimeZone()); - - edge.property(propertyName, getMappedValue(fieldValue)); + mappingConfig.getTimeZone(), + mappingConfig.getExtraDateFormats(), + mappingConfig.getListFormat()); + edge.property(propName, getMappedValue(sourceField, converted)); } return edge; } private Object buildVertexId(SeaTunnelRow row, SourceTargetConfig config) { + boolean isSource = config == mappingConfig.getSourceConfig(); + String vertexLabelId = isSource ? sourceVertexLabelId : targetVertexLabelId; + IdStrategy strategy = isSource ? sourceIdStrategy : targetIdStrategy; + List idFields = config.getIdFields(); + + // Raw-id passthrough: the endpoint id is already assembled in a reserved Source column + // (~source_id / ~target_id). Use it directly so an edge can be cloned without re-deriving + // the endpoint vertex ids from primary-key columns. Works for any endpoint id strategy + // (including AUTOMATIC) because we only reuse the id string, never rebuild the vertex. + if (ReservedColumns.isRawIdPassthrough(idFields)) { + Integer idx = fieldsIndex.get(idFields.get(0)); + Object raw = idx == null ? null : row.getField(idx); + if (isConsideredNull(raw)) { + return null; + } + return coerceRawVertexId(String.valueOf(raw), strategy); + } - String vertexLabelId = client.getVertexLabelId(config.getLabel()); - IdStrategy strategy = client.getIdStrategy(config.getLabel()); if (strategy == null || strategy == IdStrategy.AUTOMATIC) { return null; } - List idFields = config.getIdFields(); switch (strategy) { case PRIMARY_KEY: List pkValues = getFieldValues(row, idFields); @@ -153,7 +315,7 @@ private Object buildVertexId(SeaTunnelRow row, SourceTargetConfig config) { || stringValues.stream().anyMatch(this::isConsideredNull)) { return null; } - return stringValues.stream().map(String::valueOf).collect(Collectors.joining(":")); + return VertexMapper.spliceCustomizeStringId(stringValues); case CUSTOMIZE_NUMBER: List numberValues = getFieldValues(row, idFields); if (numberValues.size() != 1) { @@ -163,11 +325,7 @@ private Object buildVertexId(SeaTunnelRow row, SourceTargetConfig config) { if (isConsideredNull(numValue)) { return null; } - if (numValue instanceof Number) { - return ((Number) numValue).longValue(); - } else { - return Long.parseLong(String.valueOf(numValue)); - } + return VertexMapper.coerceNumberId(numValue); case CUSTOMIZE_UUID: List uuidValues = getFieldValues(row, idFields); if (uuidValues.size() != 1) { @@ -187,16 +345,16 @@ private Object buildVertexId(SeaTunnelRow row, SourceTargetConfig config) { private List getFieldValues(SeaTunnelRow row, List fields) { List values = new ArrayList<>(fields.size()); - Map fieldMapping = mappingConfig.getFieldMapping(); + Map fm = mappingConfig.getFieldMapping(); for (String fieldName : fields) { - Integer index = fieldsIndex.get(fieldName); if (index == null) { throw new HugeGraphConnectorException( HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, String.format( - "Field '%s' specified in id_fields not found in row schema. Available fields: %s", - fieldName, fieldsIndex.keySet())); + "Mapping[EDGE/%s]: Field '%s' specified in idFields not found in row schema. " + + "Available fields: %s", + mappingConfig.getLabel(), fieldName, fieldsIndex.keySet())); } Object rawValue = row.getField(index); @@ -204,60 +362,136 @@ private List getFieldValues(SeaTunnelRow row, List fields) { continue; } - String propertyName = fieldMapping.getOrDefault(fieldName, fieldName); - PropertyKey propertyKey = propertyKeyCache.get(propertyName); + String propName = fm.getOrDefault(fieldName, fieldName); + PropertyKey propertyKey = propertyKeyCache.get(propName); + + if (propertyKey != null) { + Object converted = + DataTypeUtil.convert( + rawValue, + propertyKey, + mappingConfig.getDateFormat(), + mappingConfig.getTimeZone(), + mappingConfig.getExtraDateFormats(), + mappingConfig.getListFormat()); + values.add(getMappedValue(fieldName, converted)); + } else { + values.add(getMappedValue(fieldName, rawValue)); + } + } + return values; + } - Object fieldValue = - DataTypeUtil.convert( - rawValue, - propertyKey, - mappingConfig.getDateFormat(), - mappingConfig.getTimeZone()); + /** + * Extracts the Edge ID matching the HugeGraph server-side 5-part EdgeId format: + * {ownerVertexId}>{edgeLabelId}>{subEdgeLabelId}>{sortValues}>{otherVertexId} + * + *

For general (non-hierarchical) edge labels the sub-label ID equals the edge label ID, so + * the label ID appears twice. SINGLE frequency edges have an empty sortValues segment; MULTIPLE + * frequency uses the sortKeys values. Vertex IDs are prefixed with 'S' for String IDs, 'L' for + * Number IDs, and 'U' for UUID IDs. + */ + @Override + public Object extractId(SeaTunnelRow row) { + Object sourceId = buildVertexId(row, mappingConfig.getSourceConfig()); + Object targetId = buildVertexId(row, mappingConfig.getTargetConfig()); - values.add(getMappedValue(fieldValue)); + if (sourceId == null || targetId == null) { + return null; } - return values; + + return spliceEdgeId(sourceId, targetId, labelId, getSortKeyValues(row)); } - private boolean isConsideredNull(Object value) { - if (value == null) { - return true; + /** + * Splices the HugeGraph server-side 5-part EdgeId. Package-private and static so the + * format-sensitive layout (vertex-id prefix, doubled label id for the sub-label segment, + * sortValues segment) is unit-testable without a live server — this is the DELETE-correctness + * path. + * + *

Uses HugeGraph's own {@link SplicingIdGenerator} so the encoding matches the server: + * {@code concat} joins the five segments with {@code '>'} and backtick-escapes any {@code '>'} + * inside a segment; {@code concatValues} joins the sort-key values with {@code '!'} and + * backtick-escapes any {@code '!'}. Vertex ids carry the type prefix HugeGraph expects: {@code + * 'L'} for numbers, {@code 'U'} for UUIDs, {@code 'S'} for strings. + */ + static String spliceEdgeId( + Object sourceId, Object targetId, String labelId, List sortValues) { + String sort = + (sortValues == null || sortValues.isEmpty()) + ? "" + : SplicingIdGenerator.concatValues(sortValues); + return SplicingIdGenerator.concat( + vertexIdString(sourceId), labelId, labelId, sort, vertexIdString(targetId)); + } + + /** + * Converts a reserved-column id string ({@code ~source_id}/{@code ~target_id}) back into the + * Java id type the endpoint vertex uses, so {@link #vertexIdString} re-applies the correct + * {@code L}/{@code U}/{@code S} prefix. PRIMARY_KEY / CUSTOMIZE_STRING ids stay strings (e.g. + * {@code "1:marko"} → {@code "S1:marko"}); CUSTOMIZE_NUMBER / AUTOMATIC parse to a long; + * CUSTOMIZE_UUID parses to a UUID. + */ + private static Object coerceRawVertexId(String raw, IdStrategy strategy) { + if (strategy == IdStrategy.CUSTOMIZE_NUMBER || strategy == IdStrategy.AUTOMATIC) { + return Long.parseLong(raw); } - List nullValues = mappingConfig.getNullValues(); - if (nullValues == null || nullValues.isEmpty()) { - return false; + if (strategy == IdStrategy.CUSTOMIZE_UUID) { + return UUID.fromString(raw); } - return nullValues.contains(String.valueOf(value)); + return raw; } - private Object getMappedValue(Object originalValue) { - Map valueMapping = mappingConfig.getValueMapping(); - if (valueMapping.isEmpty()) { - return originalValue; + /** Prepends the HugeGraph vertex-id type prefix: 'L' number, 'U' UUID, 'S' string. */ + private static String vertexIdString(Object id) { + String prefix; + if (id instanceof Number) { + prefix = "L"; + } else if (id instanceof UUID) { + prefix = "U"; + } else { + prefix = "S"; } - return valueMapping.getOrDefault(originalValue, originalValue); + return prefix + id; } - private String spliceVertexId(String vertexLabelId, List primaryValues) { - String joinedValues = - primaryValues.stream().map(Object::toString).collect(Collectors.joining("!")); - return String.format("%s:%s", vertexLabelId, joinedValues); + private List getSortKeyValues(SeaTunnelRow row) { + Frequency frequency = mappingConfig.getFrequency(); + if (frequency == null || frequency == Frequency.SINGLE) { + return Collections.emptyList(); + } + List sortKeys = mappingConfig.getSortKeys(); + if (sortKeys.isEmpty()) { + return Collections.emptyList(); + } + return getFieldValues(row, sortKeys); } - private String getSortedKeyValues(SeaTunnelRow row) { - List sortedKeys = mappingConfig.getSortKeys(); - if (sortedKeys == null || sortedKeys.isEmpty()) { - return String.valueOf(labelId); + private boolean isConsideredNull(Object value) { + if (value == null) { + return true; } - List skValues = getFieldValues(row, sortedKeys); - return skValues.stream().map(Object::toString).collect(Collectors.joining(",")); + List nullValues = mappingConfig.getNullValues(); + return !nullValues.isEmpty() && nullValues.contains(String.valueOf(value)); } - @Override - public Object extractId(SeaTunnelRow row) { - Object sourceId = buildVertexId(row, schemaConfig.getSourceConfig()); - Object targetId = buildVertexId(row, schemaConfig.getTargetConfig()); - String sortedKeyValues = getSortedKeyValues(row); - return String.format("S%s>%s>%s>>S%s", sourceId, labelId, sortedKeyValues, targetId); + private Object getMappedValue(String sourceField, Object originalValue) { + Map> vm = mappingConfig.getValueMapping(); + if (vm.isEmpty()) { + return originalValue; + } + Map perField = vm.get(sourceField); + if (perField == null || perField.isEmpty()) { + return originalValue; + } + return perField.getOrDefault(originalValue, originalValue); + } + + private String spliceVertexId(String vertexLabelId, List primaryValues) { + // HugeGraph primary-key vertex id = {vertexLabelId}:{concatValues(pk)}; concatValues joins + // with '!' and backtick-escapes any '!' so pk values containing the separator still match. + return VertexMapper.checkVertexIdLength( + String.format( + "%s:%s", vertexLabelId, SplicingIdGenerator.concatValues(primaryValues))); } } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/GraphDataMapper.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/GraphDataMapper.java index b5a669625b13..a2fb8a1d1bc2 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/GraphDataMapper.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/GraphDataMapper.java @@ -22,22 +22,36 @@ import org.apache.hugegraph.structure.GraphElement; import java.io.Serializable; +import java.util.Collections; +import java.util.List; public interface GraphDataMapper extends Serializable { /** - * Maps a SeaTunnelRow to a HugeGraph GraphElement (Vertex or Edge). - * - * @param row The input SeaTunnelRow. - * @return The resulting GraphElement. + * Maps a SeaTunnelRow to a HugeGraph GraphElement (Vertex or Edge). Returns null if the element + * should be skipped (e.g. null ID fields matched by nullValues). */ GraphElement map(SeaTunnelRow row); /** - * Extracts the ID from a SeaTunnelRow. - * - * @param row The input SeaTunnelRow. - * @return The extracted ID object. + * Maps a row to one or more graph elements. Without unfold this is just {@link #map} wrapped in + * a list; with unfold enabled a list-valued id cell expands into multiple elements. Used on the + * INSERT/append path only. + */ + default List mapAll(SeaTunnelRow row) { + GraphElement element = map(row); + return element == null ? Collections.emptyList() : Collections.singletonList(element); + } + + /** Whether this mapper expands one row into multiple elements (unfold). */ + default boolean isUnfoldEnabled() { + return false; + } + + /** + * Extracts the graph element ID from a SeaTunnelRow. The ID format must match the server-side + * format to ensure DELETE operations target the correct element. Returns null if the ID cannot + * be built (e.g. null ID fields). */ Object extractId(SeaTunnelRow row); } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/VertexMapper.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/VertexMapper.java index 9f113105834f..830189e3077b 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/VertexMapper.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/VertexMapper.java @@ -20,122 +20,242 @@ import org.apache.seatunnel.api.table.type.SeaTunnelRow; import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; -import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.SchemaConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.ReservedColumns; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; import org.apache.seatunnel.connectors.seatunnel.hugegraph.utils.DataTypeUtil; import org.apache.seatunnel.connectors.seatunnel.hugegraph.utils.E; +import org.apache.hugegraph.serializer.direct.util.SplicingIdGenerator; +import org.apache.hugegraph.structure.GraphElement; import org.apache.hugegraph.structure.constant.IdStrategy; import org.apache.hugegraph.structure.graph.Vertex; import org.apache.hugegraph.structure.schema.PropertyKey; +import java.nio.charset.StandardCharsets; 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.Set; import java.util.UUID; import java.util.stream.Collectors; public class VertexMapper implements GraphDataMapper { - private final SchemaConfig schemaConfig; private final MappingConfig mappingConfig; private final Map fieldsIndex; private final String labelId; private final HugeGraphClient client; private final Map propertyKeyCache; + private final Set propertySourceFields; + private final boolean unfold; public VertexMapper( - SchemaConfig schemaConfig, Map fieldsIndex, HugeGraphClient client) { - this.schemaConfig = schemaConfig; - this.mappingConfig = getMappingConfig(); + MappingConfig mappingConfig, Map fieldsIndex, HugeGraphClient client) { + this.mappingConfig = mappingConfig; this.client = client; - this.labelId = client.getVertexLabelId(schemaConfig.getLabel()); + this.labelId = client.getVertexLabelId(mappingConfig.getLabel()); this.fieldsIndex = fieldsIndex; - this.propertyKeyCache = getPropertyKeyCache(); + this.propertySourceFields = resolvePropertySourceFields(); + this.propertyKeyCache = buildPropertyKeyCache(); + this.unfold = mappingConfig.isUnfold(); } - private MappingConfig getMappingConfig() { - MappingConfig mapping = - schemaConfig.getMapping() == null ? new MappingConfig() : schemaConfig.getMapping(); - if (mapping.getFieldMapping() == null) { - mapping.setFieldMapping(Collections.emptyMap()); + @Override + public boolean isUnfoldEnabled() { + return unfold; + } + + private Set resolvePropertySourceFields() { + Set fields = new HashSet<>(); + if (mappingConfig.getProperties().isEmpty()) { + fields.addAll(fieldsIndex.keySet()); + // Drop reserved columns emitted by HugeGraph Source (~id, ~label, ...) — they are not + // valid HugeGraph property key names, so an implicit Source→Sink round-trip would + // otherwise attempt to create them on the server. + ReservedColumns.stripReserved(fields); + // `ignored` blacklist only applies in implicit mode (an explicit `properties` + // whitelist already lists exactly what to keep). + fields.removeAll(mappingConfig.getIgnored()); + } else { + fields.addAll(mappingConfig.getProperties()); } - if (mapping.getValueMapping() == null) { - mapping.setValueMapping(Collections.emptyMap()); + // PRIMARY_KEY idFields are always written as properties. + if (mappingConfig.getIdStrategy() == IdStrategy.PRIMARY_KEY + && mappingConfig.getIdFields() != null) { + fields.addAll(mappingConfig.getIdFields()); } - schemaConfig.setMapping(mapping); - return mapping; + return fields; } - private HashMap getPropertyKeyCache() { + private HashMap buildPropertyKeyCache() { HashMap cache = new HashMap<>(); - Map fieldMapping = mappingConfig.getFieldMapping(); - for (String fieldName : fieldsIndex.keySet()) { - String propertyName = fieldMapping.getOrDefault(fieldName, fieldName); - cache.put(propertyName, client.getPropertyKey(propertyName)); + Map fm = mappingConfig.getFieldMapping(); + for (String sourceField : propertySourceFields) { + String propName = fm.getOrDefault(sourceField, sourceField); + if (!cache.containsKey(propName)) { + cache.put(propName, client.getPropertyKey(propName)); + } + } + if (mappingConfig.getIdFields() != null) { + for (String idField : mappingConfig.getIdFields()) { + String propName = fm.getOrDefault(idField, idField); + if (!cache.containsKey(propName)) { + PropertyKey propertyKey = client.getPropertyKeyOrNull(propName); + if (propertyKey != null) { + cache.put(propName, propertyKey); + } + } + } } return cache; } @Override public Vertex map(SeaTunnelRow row) { - String label = schemaConfig.getLabel(); + String label = mappingConfig.getLabel(); E.checkArgument(label != null && !label.isEmpty(), "Vertex label can't be null or empty."); Vertex vertex = new Vertex(label); - // 1. Set vertex ID Object id = extractId(row); - if (id == null && schemaConfig.getIdStrategy() != IdStrategy.AUTOMATIC) { + if (id == null && mappingConfig.getIdStrategy() != IdStrategy.AUTOMATIC) { return null; } - if (id != null && schemaConfig.getIdStrategy() != IdStrategy.PRIMARY_KEY) { + if (id != null && mappingConfig.getIdStrategy() != IdStrategy.PRIMARY_KEY) { vertex.id(id); } - // 2. Set properties - Map fieldMapping = mappingConfig.getFieldMapping(); + applyProperties(vertex, row, null); + return vertex; + } - for (Map.Entry fieldEntry : fieldsIndex.entrySet()) { + /** + * INSERT/append-path expansion: when {@code unfold} is set, a list-valued CUSTOMIZE id cell + * produces one vertex per element (all sharing the same non-id properties). Without unfold this + * is just {@link #map} wrapped in a list. + */ + @Override + public List mapAll(SeaTunnelRow row) { + if (!unfold) { + Vertex vertex = map(row); + return vertex == null ? Collections.emptyList() : Collections.singletonList(vertex); + } + IdStrategy strategy = mappingConfig.getIdStrategy(); + String idField = mappingConfig.getIdFields().get(0); + Integer idx = fieldsIndex.get(idField); + if (idx == null) { + return Collections.emptyList(); + } + Object raw = row.getField(idx); + if (isConsideredNull(raw)) { + return Collections.emptyList(); + } + List elements = DataTypeUtil.splitField(idField, raw); + List result = new ArrayList<>(elements.size()); + for (Object elem : elements) { + if (isConsideredNull(elem)) { + continue; + } + Vertex vertex = new Vertex(mappingConfig.getLabel()); + vertex.id(coerceCustomizeId(strategy, elem)); + // The id field is the unfolded source, not a property — skip it. + applyProperties(vertex, row, idField); + result.add(vertex); + } + return result; + } - String fieldName = fieldEntry.getKey(); - String propertyName = fieldMapping.getOrDefault(fieldName, fieldName); - Object rawValue = row.getField(fieldEntry.getValue()); - PropertyKey propertyKey = propertyKeyCache.get(propertyName); + private static Object coerceCustomizeId(IdStrategy strategy, Object elem) { + switch (strategy) { + case CUSTOMIZE_STRING: + return checkVertexIdLength(String.valueOf(elem)); + case CUSTOMIZE_NUMBER: + return coerceNumberId(elem); + case CUSTOMIZE_UUID: + return elem instanceof UUID ? elem : UUID.fromString(String.valueOf(elem)); + default: + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + "unfold requires a CUSTOMIZE_STRING/NUMBER/UUID id strategy, but got " + + strategy); + } + } + + private void applyProperties(Vertex vertex, SeaTunnelRow row, String skipField) { + Map fm = mappingConfig.getFieldMapping(); + for (String sourceField : propertySourceFields) { + if (sourceField.equals(skipField)) { + continue; + } + Integer index = fieldsIndex.get(sourceField); + if (index == null) { + continue; + } + + String propName = fm.getOrDefault(sourceField, sourceField); + Object rawValue = row.getField(index); + PropertyKey propertyKey = propertyKeyCache.get(propName); if (isConsideredNull(rawValue)) { continue; } - Object fieldValue = + Object converted = DataTypeUtil.convert( rawValue, propertyKey, mappingConfig.getDateFormat(), - mappingConfig.getTimeZone()); - - vertex.property(propertyName, getMappedValue(fieldValue)); + mappingConfig.getTimeZone(), + mappingConfig.getExtraDateFormats(), + mappingConfig.getListFormat()); + vertex.property(propName, getMappedValue(sourceField, converted)); } - - return vertex; } @Override public Object extractId(SeaTunnelRow row) { - IdStrategy strategy = schemaConfig.getIdStrategy(); + IdStrategy strategy = mappingConfig.getIdStrategy(); if (strategy == null || strategy == IdStrategy.AUTOMATIC) { return null; } - List idFields = schemaConfig.getIdFields(); + List idFields = mappingConfig.getIdFields(); E.checkArgument( idFields != null && !idFields.isEmpty(), "The 'idFields' must be specified for ID strategy '%s'.", strategy); + // Raw-id passthrough: the vertex id is already assembled in the reserved ~id Source column. + // Only meaningful for CUSTOMIZE_* strategies (the ones that accept an externally supplied + // id); PRIMARY_KEY derives its id from property values and AUTOMATIC is server-assigned, so + // both are rejected at config time in SchemaValidator. + if (ReservedColumns.isRawIdPassthrough(idFields)) { + Integer idx = fieldsIndex.get(idFields.get(0)); + Object raw = idx == null ? null : row.getField(idx); + if (isConsideredNull(raw)) { + return null; + } + switch (strategy) { + case CUSTOMIZE_STRING: + return checkVertexIdLength(String.valueOf(raw)); + case CUSTOMIZE_NUMBER: + return coerceNumberId(raw); + case CUSTOMIZE_UUID: + return UUID.fromString(String.valueOf(raw)); + default: + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Mapping[VERTEX/%s]: idFields '%s' (raw-id passthrough) requires a " + + "CUSTOMIZE_STRING/NUMBER/UUID id strategy, but got '%s'.", + mappingConfig.getLabel(), idFields.get(0), strategy)); + } + } + switch (strategy) { case PRIMARY_KEY: List pkValues = getFieldValues(row, idFields); @@ -150,7 +270,7 @@ public Object extractId(SeaTunnelRow row) { || stringValues.stream().anyMatch(this::isConsideredNull)) { return null; } - return stringValues.stream().map(String::valueOf).collect(Collectors.joining(":")); + return spliceCustomizeStringId(stringValues); case CUSTOMIZE_NUMBER: List numberValues = getFieldValues(row, idFields); if (numberValues.size() != 1) { @@ -160,11 +280,7 @@ public Object extractId(SeaTunnelRow row) { if (isConsideredNull(numValue)) { return null; } - if (numValue instanceof Number) { - return ((Number) numValue).longValue(); - } else { - return Long.parseLong(String.valueOf(numValue)); - } + return coerceNumberId(numValue); case CUSTOMIZE_UUID: List uuidValues = getFieldValues(row, idFields); if (uuidValues.size() != 1) { @@ -184,16 +300,16 @@ public Object extractId(SeaTunnelRow row) { private List getFieldValues(SeaTunnelRow row, List fields) { List values = new ArrayList<>(fields.size()); - Map fieldMapping = mappingConfig.getFieldMapping(); + Map fm = mappingConfig.getFieldMapping(); for (String fieldName : fields) { - Integer index = fieldsIndex.get(fieldName); if (index == null) { throw new HugeGraphConnectorException( HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, String.format( - "Field '%s' specified in id_fields not found in row schema. Available fields: %s", - fieldName, fieldsIndex.keySet())); + "Mapping[VERTEX/%s]: Field '%s' specified in idFields not found in row schema. " + + "Available fields: %s", + mappingConfig.getLabel(), fieldName, fieldsIndex.keySet())); } Object rawValue = row.getField(index); @@ -201,17 +317,21 @@ private List getFieldValues(SeaTunnelRow row, List fields) { continue; } - String propertyName = fieldMapping.getOrDefault(fieldName, fieldName); - PropertyKey propertyKey = propertyKeyCache.get(propertyName); - - Object fieldValue = - DataTypeUtil.convert( - rawValue, - propertyKey, - mappingConfig.getDateFormat(), - mappingConfig.getTimeZone()); + String propName = fm.getOrDefault(fieldName, fieldName); + PropertyKey propertyKey = propertyKeyCache.get(propName); - values.add(getMappedValue(fieldValue)); + Object converted = rawValue; + if (propertyKey != null) { + converted = + DataTypeUtil.convert( + rawValue, + propertyKey, + mappingConfig.getDateFormat(), + mappingConfig.getTimeZone(), + mappingConfig.getExtraDateFormats(), + mappingConfig.getListFormat()); + } + values.add(getMappedValue(fieldName, converted)); } return values; } @@ -221,23 +341,102 @@ private boolean isConsideredNull(Object value) { return true; } List nullValues = mappingConfig.getNullValues(); - if (nullValues == null || nullValues.isEmpty()) { - return false; - } - return nullValues.contains(String.valueOf(value)); + return !nullValues.isEmpty() && nullValues.contains(String.valueOf(value)); } - private Object getMappedValue(Object originalValue) { - Map valueMapping = mappingConfig.getValueMapping(); - if (valueMapping.isEmpty()) { + private Object getMappedValue(String sourceField, Object originalValue) { + Map> vm = mappingConfig.getValueMapping(); + if (vm.isEmpty()) { + return originalValue; + } + Map perField = vm.get(sourceField); + if (perField == null || perField.isEmpty()) { return originalValue; } - return valueMapping.getOrDefault(originalValue, originalValue); + return perField.getOrDefault(originalValue, originalValue); } private String spliceVertexId(List primaryValues) { - String joinedValues = - primaryValues.stream().map(Object::toString).collect(Collectors.joining("!")); - return String.format("%s:%s", labelId, joinedValues); + // HugeGraph primary-key vertex id = {vertexLabelId}:{concatValues(pk)}; concatValues joins + // with '!' and backtick-escapes any '!' in a value, matching how the server assembles the + // id. EdgeMapper uses the same helper for the same concept — a raw join here would produce + // an ambiguous, server-mismatched id when a pk value contains '!', so DELETE / key-changing + // UPDATE would target the wrong (or a non-existent) vertex. + return checkVertexIdLength( + String.format("%s:%s", labelId, SplicingIdGenerator.concatValues(primaryValues))); + } + + /** + * Assembles a CUSTOMIZE_STRING id from its id-field values. A single field is used verbatim — + * it is unambiguous, and escaping it would change ids already written for the common + * single-field case. Multiple fields are joined with ':' after backslash-escaping any ':' (and + * the '\' escape char itself) in each value, so distinct field tuples cannot collapse to the + * same id — e.g. ("x:y","z") and ("x","y:z") no longer both yield "x:y:z" and overwrite each + * other. Shared by {@link VertexMapper} and {@code EdgeMapper} so both build ids identically. + */ + static String spliceCustomizeStringId(List values) { + if (values.size() == 1) { + return checkVertexIdLength(String.valueOf(values.get(0))); + } + return checkVertexIdLength( + values.stream() + .map(String::valueOf) + .map(VertexMapper::escapeIdSegment) + .collect(Collectors.joining(":"))); + } + + private static String escapeIdSegment(String value) { + return value.replace("\\", "\\\\").replace(":", "\\:"); + } + + /** + * Coerces a CUSTOMIZE_NUMBER id value to a long, consistently for both Number and String + * inputs. A fractional value ({@code 1.9} whether it arrives as a Double or the string "1.9") + * is rejected rather than silently truncated to {@code 1}; an integral decimal like {@code + * 1.0}/"1.0" is accepted. Shared by {@link VertexMapper} and {@code EdgeMapper} so both behave + * identically. + */ + static long coerceNumberId(Object value) { + java.math.BigDecimal decimal; + try { + decimal = new java.math.BigDecimal(String.valueOf(value).trim()); + } catch (NumberFormatException e) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format("CUSTOMIZE_NUMBER id value '%s' is not a number.", value), + e); + } + try { + return decimal.longValueExact(); + } catch (ArithmeticException e) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "CUSTOMIZE_NUMBER id value '%s' is not an integer: a fractional or " + + "out-of-range value cannot be used as a numeric id (it would " + + "otherwise be silently truncated).", + value), + e); + } + } + + /** HugeGraph server per-vertex id cap (see loader Constants.VERTEX_ID_LIMIT). */ + static final int VERTEX_ID_LIMIT = 128; + + /** + * Rejects a string vertex id whose UTF-8 length exceeds the server limit, so the user gets a + * clear client-side error instead of an opaque server rejection. Number/UUID ids are always + * within the limit and are not checked. + */ + static String checkVertexIdLength(String id) { + int length = id.getBytes(StandardCharsets.UTF_8).length; + if (length > VERTEX_ID_LIMIT) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "The vertex id length (%d bytes) exceeds the limit of %d: '%s'", + length, VERTEX_ID_LIMIT, id)); + } + return id; } } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSaveModeHandler.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSaveModeHandler.java new file mode 100644 index 000000000000..e53d847c1a7c --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSaveModeHandler.java @@ -0,0 +1,296 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.sink; + +import org.apache.seatunnel.api.sink.DataSaveMode; +import org.apache.seatunnel.api.sink.SaveModeHandler; +import org.apache.seatunnel.api.sink.SchemaSaveMode; +import org.apache.seatunnel.api.table.catalog.Catalog; +import org.apache.seatunnel.api.table.catalog.CatalogTable; +import org.apache.seatunnel.api.table.catalog.TablePath; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphDataSaveMode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSchemaSaveMode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSinkConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.utils.SchemaManager; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.utils.SchemaValidator; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Handles HugeGraph schema and data save modes on the coordinator, once per job, via the engine's + * {@link SaveModeHandler} contract. Doing this here (instead of in the {@code HugeGraphSink} + * constructor) is what makes it correct on restart and for multi-table sinks: + * + *
    + *
  • Restart: on checkpoint restore the engine calls only {@link + * #handleSchemaSaveModeWithRestore()}, so data is never dropped a second time — previously + * the constructor re-ran the drop on every restart and lost data written before the restart. + *
  • Multi-table: each table's sink gets its own handler and drops only the labels that + * table targets ({@link #handleDataSaveMode()}), so dropping table A no longer wipes table B + * — the old whole-graph {@code clearGraph} cleared everything (and destroyed a sibling + * table's freshly-created schema). + *
+ * + * Schema work runs before the data drop (the default {@link #handleSaveMode()} order) so the labels + * exist when their data is cleared. + */ +public class HugeGraphSaveModeHandler implements SaveModeHandler { + + private final HugeGraphSinkConfig config; + private final SeaTunnelRowType rowType; + private final TablePath tablePath; + + private HugeGraphClient client; + + public HugeGraphSaveModeHandler( + HugeGraphSinkConfig config, SeaTunnelRowType rowType, TablePath tablePath) { + this.config = config; + this.rowType = rowType; + this.tablePath = tablePath; + } + + @Override + public void open() { + this.client = createClient(); + } + + /** Test seam: overridden in unit tests to inject a mock client instead of a live connection. */ + HugeGraphClient createClient() { + return new HugeGraphClient(config.getConnectionConfig()); + } + + /** + * Config-level checks (labels, idFields, MULTIPLE→sortKeys, source-field presence) run before + * any server write so a malformed mapping cannot leave a partial schema behind — the HugeGraph + * server is non-transactional for DDL and its primary keys / sort keys / frequency are + * effectively immutable. Under CREATE_SCHEMA_WHEN_NOT_EXIST, missing PropertyKey / VertexLabel + * / EdgeLabel are then auto-created and finally re-validated against the server. Under + * ERROR_WHEN_SCHEMA_NOT_EXIST, only validation runs. + */ + @Override + public void handleSchemaSaveMode() { + SchemaValidator validator = new SchemaValidator(client, rowType); + validator.validateConfigOnly(config.getMappings()); + + if (config.getSchemaSaveMode() == HugeGraphSchemaSaveMode.CREATE_SCHEMA_WHEN_NOT_EXIST) { + // Fail fast on any already-existing label whose immutable attributes (PK, frequency, + // sort keys, endpoints) conflict with the config, BEFORE creating anything — a conflict + // discovered only by the post-create validate() below would leave the PropertyKeys / + // labels created for the other mappings behind as schema pollution. + validator.validateExistingLabels(config.getMappings()); + + SchemaManager schemaManager = + new SchemaManager(client, config.getSchemaSaveMode(), rowType); + schemaManager.ensureSchema(config.getMappings()); + } + + validator.validate(config.getMappings()); + } + + /** + * For DROP_DATA, clear only the data of the labels this job's mappings target — edges first (so + * edge-only mappings are handled even when their endpoints are out of scope), then vertices + * (removing a vertex also removes its remaining incident edges). Schema is preserved. + * APPEND_DATA is a no-op. + * + *

Before deleting vertices, a pre-flight check discovers every edge label that references a + * target vertex label. If any of those edge labels are NOT in this job's mappings, the job + * fails fast — deleting the vertices would cascade-delete those edges silently. Set {@code + * allow_cascade_delete_unmapped_edges=true} to opt into the destructive cascade. + */ + @Override + public void handleDataSaveMode() { + if (config.getDataSaveMode() != HugeGraphDataSaveMode.DROP_DATA) { + return; + } + List mappings = config.getMappings(); + + // Collect the set of edge labels this job explicitly targets. + Set mappedEdgeLabels = new HashSet<>(); + Set mappedVertexLabels = new HashSet<>(); + for (MappingConfig mapping : mappings) { + if (mapping.getType() == MappingConfig.LabelType.EDGE) { + mappedEdgeLabels.add(mapping.getLabel()); + } else { + mappedVertexLabels.add(mapping.getLabel()); + } + } + + // Pre-flight: for each vertex label being dropped, discover edge labels that would be + // cascade-deleted. If any are not in this job's mappings, fail fast — unless the user + // has explicitly opted into the destructive cascade. + if (!config.isAllowCascadeDeleteUnmappedEdges()) { + for (String vertexLabel : mappedVertexLabels) { + List connected = client.getConnectedEdgeLabels(vertexLabel); + for (String edgeLabel : connected) { + if (!mappedEdgeLabels.contains(edgeLabel)) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "DROP_DATA would cascade-delete edge label '%s' (connected to " + + "vertex label '%s'), which is not in this job's " + + "mappings. Add '%s' to your mappings to delete it " + + "explicitly, or set " + + "allow_cascade_delete_unmapped_edges=true to accept " + + "the destructive cascade.", + edgeLabel, vertexLabel, edgeLabel)); + } + } + } + } + + for (MappingConfig mapping : mappings) { + if (mapping.getType() == MappingConfig.LabelType.EDGE) { + client.deleteEdgesByLabel(mapping.getLabel()); + } + } + for (MappingConfig mapping : mappings) { + if (mapping.getType() == MappingConfig.LabelType.VERTEX) { + client.deleteVerticesByLabel(mapping.getLabel()); + } + } + } + + /** + * Restore path: (re)ensure and validate schema only. Deliberately never drops data — the data + * already written before the checkpoint must survive the restart. + */ + @Override + public void handleSchemaSaveModeWithRestore() { + handleSchemaSaveMode(); + } + + @Override + public SchemaSaveMode getSchemaSaveMode() { + return config.getSchemaSaveMode() == HugeGraphSchemaSaveMode.CREATE_SCHEMA_WHEN_NOT_EXIST + ? SchemaSaveMode.CREATE_SCHEMA_WHEN_NOT_EXIST + : SchemaSaveMode.ERROR_WHEN_SCHEMA_NOT_EXIST; + } + + @Override + public DataSaveMode getDataSaveMode() { + return config.getDataSaveMode() == HugeGraphDataSaveMode.DROP_DATA + ? DataSaveMode.DROP_DATA + : DataSaveMode.APPEND_DATA; + } + + @Override + public TablePath getHandleTablePath() { + return tablePath; + } + + @Override + public Catalog getHandleCatalog() { + // HugeGraph has no SeaTunnel Catalog implementation; schema/data handling goes through the + // HugeGraph client directly. The engine's SaveModeExecuteWrapper only reads name() from + // this + // for logging, so a lightweight stub is sufficient. + return new HugeGraphNamedCatalog(); + } + + @Override + public void close() { + if (client != null) { + client.close(); + client = null; + } + } + + /** + * Minimal {@link Catalog} that exists only to satisfy {@code SaveModeExecuteWrapper}, which + * logs {@code getHandleCatalog().name()} before running the handler. HugeGraph does all + * schema/data work through its own client, so every catalog operation other than {@link + * #name()} is unsupported and never invoked on the save-mode path. + */ + private static final class HugeGraphNamedCatalog implements Catalog { + + @Override + public String name() { + return "HugeGraph"; + } + + @Override + public void open() {} + + @Override + public void close() {} + + @Override + public String getDefaultDatabase() { + throw unsupported(); + } + + @Override + public boolean databaseExists(String databaseName) { + throw unsupported(); + } + + @Override + public List listDatabases() { + throw unsupported(); + } + + @Override + public List listTables(String databaseName) { + throw unsupported(); + } + + @Override + public boolean tableExists(TablePath tablePath) { + throw unsupported(); + } + + @Override + public CatalogTable getTable(TablePath tablePath) { + throw unsupported(); + } + + @Override + public void createTable(TablePath tablePath, CatalogTable table, boolean ignoreIfExists) { + throw unsupported(); + } + + @Override + public void dropTable(TablePath tablePath, boolean ignoreIfNotExists) { + throw unsupported(); + } + + @Override + public void createDatabase(TablePath tablePath, boolean ignoreIfExists) { + throw unsupported(); + } + + @Override + public void dropDatabase(TablePath tablePath, boolean ignoreIfNotExists) { + throw unsupported(); + } + + private static UnsupportedOperationException unsupported() { + return new UnsupportedOperationException( + "HugeGraph does not provide a SeaTunnel Catalog; " + + "schema and data save modes are handled via the HugeGraph client."); + } + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSink.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSink.java index 91f594a04b18..da6227ec202b 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSink.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSink.java @@ -17,32 +17,48 @@ package org.apache.seatunnel.connectors.seatunnel.hugegraph.sink; +import org.apache.seatunnel.api.sink.SaveModeHandler; import org.apache.seatunnel.api.sink.SinkWriter; +import org.apache.seatunnel.api.sink.SupportMultiTableSink; +import org.apache.seatunnel.api.sink.SupportSaveMode; import org.apache.seatunnel.api.table.catalog.CatalogTable; import org.apache.seatunnel.api.table.type.SeaTunnelRow; import org.apache.seatunnel.api.table.type.SeaTunnelRowType; import org.apache.seatunnel.connectors.seatunnel.common.sink.AbstractSimpleSink; import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphOptions; import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSinkConfig; -import org.apache.seatunnel.connectors.seatunnel.hugegraph.utils.SchemaValidator; import java.io.IOException; import java.util.Optional; -public class HugeGraphSink extends AbstractSimpleSink { +public class HugeGraphSink extends AbstractSimpleSink + implements SupportMultiTableSink, SupportSaveMode { private final HugeGraphSinkConfig config; private final CatalogTable catalogTable; private final SeaTunnelRowType rowType; + private final String tablePath; public HugeGraphSink(HugeGraphSinkConfig config, CatalogTable catalogTable) { this.config = config; this.catalogTable = catalogTable; this.rowType = catalogTable.getSeaTunnelRowType(); + this.tablePath = catalogTable.getTablePath().toString(); - // TODO: Discuss where to implement this in the future, maybe the catalog - SchemaValidator validator = new SchemaValidator(config, rowType); - validator.validateSchema(); + this.config.applyLegacyFieldSelection(rowType); + } + + /** + * Schema management and the DROP_DATA data drop run once on the coordinator via the engine's + * SaveMode contract — see {@link HugeGraphSaveModeHandler}. Running it here (rather than in the + * constructor as before) is what makes it correct on checkpoint restart and for multi-table + * sinks: restart re-runs only the schema step (never dropping data), and each table drops only + * its own labels instead of wiping the whole graph. + */ + @Override + public Optional getSaveModeHandler() { + return Optional.of( + new HugeGraphSaveModeHandler(config, rowType, catalogTable.getTablePath())); } @Override @@ -52,7 +68,7 @@ public String getPluginName() { @Override public HugeGraphSinkWriter createWriter(SinkWriter.Context context) throws IOException { - return new HugeGraphSinkWriter(config, rowType); + return new HugeGraphSinkWriter(config, rowType, tablePath, context.getIndexOfSubtask()); } @Override diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkFactory.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkFactory.java index 56e75ab214cd..fa4630a55813 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkFactory.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkFactory.java @@ -18,6 +18,7 @@ package org.apache.seatunnel.connectors.seatunnel.hugegraph.sink; import org.apache.seatunnel.api.configuration.util.OptionRule; +import org.apache.seatunnel.api.options.SinkConnectorCommonOptions; import org.apache.seatunnel.api.table.connector.TableSink; import org.apache.seatunnel.api.table.factory.Factory; import org.apache.seatunnel.api.table.factory.TableSinkFactory; @@ -48,17 +49,40 @@ public OptionRule optionRule() { // connection config .required(HugeGraphOptions.HOST, HugeGraphOptions.PORT, HugeGraphOptions.GRAPH_NAME) .optional( - HugeGraphOptions.GRAPH_SPACE, + HugeGraphOptions.PROTOCOL, HugeGraphOptions.USERNAME, - HugeGraphOptions.PASSWORD) - // mapping config - .exclusive( - HugeGraphSinkOptions.SELECTED_FIELDS, HugeGraphSinkOptions.IGNORED_FIELDS) - .required(HugeGraphSinkOptions.SCHEMA_CONFIG) + HugeGraphOptions.PASSWORD, + // Optional connection setting passed through to select the HugeGraph graph + // space (defaults to "DEFAULT"). + HugeGraphOptions.GRAPH_SPACE) + // mapping config: mappings (new) or schema_config (legacy) + .optional(HugeGraphSinkOptions.MAPPINGS, HugeGraphSinkOptions.SCHEMA_CONFIG) + // schema and data save mode + .optional( + HugeGraphSinkOptions.SCHEMA_SAVE_MODE, + HugeGraphSinkOptions.DATA_SAVE_MODE, + HugeGraphSinkOptions.DELETE_VERTEX_WITH_EDGES, + HugeGraphSinkOptions.ALLOW_CASCADE_DELETE_UNMAPPED_EDGES) // batch config - .optional(HugeGraphOptions.BATCH_SIZE, HugeGraphOptions.BATCH_INTERVAL_MS) - // error operation - .optional(HugeGraphOptions.MAX_RETRIES, HugeGraphOptions.RETRY_BACKOFF_MS) + .optional( + HugeGraphOptions.BATCH_SIZE, + HugeGraphOptions.BATCH_INTERVAL_MS, + HugeGraphOptions.CHECK_VERTEX) + // required by the multi-table sink SPI (HugeGraphSink implements + // SupportMultiTableSink): lets the framework size per-table write replicas + .optional(SinkConnectorCommonOptions.MULTI_TABLE_SINK_REPLICA) + // error handling + .optional( + HugeGraphOptions.BATCH_FAILURE_FALLBACK, + HugeGraphOptions.MAX_INSERT_ERRORS, + HugeGraphOptions.FAILURE_DATA_PATH) + // retry config + .optional( + HugeGraphOptions.MAX_RETRIES, + HugeGraphOptions.RETRY_BACKOFF_MS, + HugeGraphOptions.RETRY_BACKOFF_MAX_MS) + // deprecated field selection + .optional(HugeGraphSinkOptions.SELECTED_FIELDS, HugeGraphSinkOptions.IGNORED_FIELDS) .build(); } } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkWriter.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkWriter.java index 2a71ceba1e4a..cbad8e1ec5ee 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkWriter.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkWriter.java @@ -22,10 +22,11 @@ import org.apache.seatunnel.api.table.type.SeaTunnelRowType; import org.apache.seatunnel.connectors.seatunnel.common.sink.AbstractSinkWriter; import org.apache.seatunnel.connectors.seatunnel.hugegraph.buffer.BatchBuffer; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.buffer.GraphElementEnvelope; import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSinkConfig; -import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.SchemaConfig; -import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.SchemaConfig.LabelType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig.LabelType; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; import org.apache.seatunnel.connectors.seatunnel.hugegraph.mapper.EdgeMapper; @@ -38,7 +39,10 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -53,55 +57,264 @@ public class HugeGraphSinkWriter extends AbstractSinkWriter private static final Logger LOG = LoggerFactory.getLogger(HugeGraphSinkWriter.class); private final HugeGraphSinkConfig sinkConfig; - private final GraphDataMapper mapper; + private final List mappingEntries; private final HugeGraphClient client; private final BatchBuffer buffer; + private final String tablePath; + + // Holds the UPDATE_BEFORE row until its paired UPDATE_AFTER arrives (changelog streams + // deliver them consecutively). This field is NOT checkpointable — the SeaTunnel SinkWriter + // interface has no snapshotState(). If a checkpoint fires between UPDATE_BEFORE and + // UPDATE_AFTER, prepareCommit() fails fast rather than risking a partially-applied mutation. + private SeaTunnelRow pendingUpdateBefore; public HugeGraphSinkWriter(HugeGraphSinkConfig sinkConfig, SeaTunnelRowType rowType) { + this(sinkConfig, rowType, "", 0); + } + + public HugeGraphSinkWriter( + HugeGraphSinkConfig sinkConfig, SeaTunnelRowType rowType, int subtaskIndex) { + this(sinkConfig, rowType, "", subtaskIndex); + } + + public HugeGraphSinkWriter( + HugeGraphSinkConfig sinkConfig, + SeaTunnelRowType rowType, + String tablePath, + int subtaskIndex) { + this( + sinkConfig, + rowType, + tablePath, + new HugeGraphClient(sinkConfig.getConnectionConfig()), + subtaskIndex); + } + + HugeGraphSinkWriter( + HugeGraphSinkConfig sinkConfig, SeaTunnelRowType rowType, HugeGraphClient client) { + this(sinkConfig, rowType, "", client, 0); + } + + HugeGraphSinkWriter( + HugeGraphSinkConfig sinkConfig, + SeaTunnelRowType rowType, + HugeGraphClient client, + int subtaskIndex) { + this(sinkConfig, rowType, "", client, subtaskIndex); + } + + HugeGraphSinkWriter( + HugeGraphSinkConfig sinkConfig, + SeaTunnelRowType rowType, + String tablePath, + HugeGraphClient client, + int subtaskIndex) { this.sinkConfig = sinkConfig; - this.client = new HugeGraphClient(sinkConfig); - this.mapper = getMapper(rowType); + this.tablePath = tablePath; + this.sinkConfig.applyLegacyFieldSelection(rowType); + this.client = client; + try { + // buildMappingEntries issues live schema lookups; if any fails the framework will not + // call close() on this half-constructed writer, so release the client here. + this.mappingEntries = buildMappingEntries(rowType); + } catch (RuntimeException e) { + try { + this.client.close(); + } catch (RuntimeException closeFailure) { + e.addSuppressed(closeFailure); + } + throw e; + } this.buffer = new BatchBuffer( - this.client, sinkConfig.getBatchSize(), sinkConfig.getBatchIntervalMs()); + this.client, + sinkConfig.getBatchSize(), + sinkConfig.getBatchIntervalMs(), + sinkConfig.isBatchFailureFallback(), + sinkConfig.isCheckVertex(), + sinkConfig.getMaxInsertErrors(), + sinkConfig.getFailureDataPath(), + subtaskIndex); } - private GraphDataMapper getMapper(SeaTunnelRowType rowType) { - SchemaConfig schemaConfig = sinkConfig.getSchemaConfig(); - List selectedFields = sinkConfig.getSelectedFields(); - List ignoredFields = sinkConfig.getIgnoredFields(); + private List buildMappingEntries(SeaTunnelRowType rowType) { Map originalFieldsIndex = IntStream.range(0, rowType.getTotalFields()) .boxed() - .collect(Collectors.toMap(rowType::getFieldName, i -> i)); + .collect( + Collectors.toMap( + rowType::getFieldName, + i -> i, + (a, b) -> a, + LinkedHashMap::new)); + Map availableFieldsIndex = resolveLegacyFieldsIndex(originalFieldsIndex); + + List entries = new ArrayList<>(); + for (MappingConfig mapping : sinkConfig.getMappings()) { + // Multi-table binding: when a mapping declares source_table, only activate it in the + // writer whose tablePath matches. A mapping without source_table activates in every + // writer — the single-table backward-compatible default. + if (!mapping.appliesTo(tablePath)) { + LOG.info( + "Mapping[{}/{}] source_table '{}' does not match writer table '{}'; " + + "skipping in this writer.", + mapping.getType(), + mapping.getLabel(), + mapping.getSourceTable(), + tablePath); + continue; + } + Map fieldsIndex = resolveFieldsIndex(mapping, availableFieldsIndex); + GraphDataMapper mapper; + if (mapping.getType() == LabelType.VERTEX) { + if (mapping.getIdStrategy() + == org.apache.hugegraph.structure.constant.IdStrategy.AUTOMATIC) { + // AUTOMATIC ids are server-assigned and not derivable from the row, so there is + // no key to deduplicate on: under at-least-once, a replayed row inserts a NEW + // vertex each time (duplicates). Warn so the trade-off is visible; use + // PRIMARY_KEY / CUSTOMIZE_* for idempotent upserts. + LOG.warn( + "Mapping[VERTEX/{}] uses AUTOMATIC ids: under at-least-once delivery a " + + "replayed row creates a duplicate vertex, and DELETE is not " + + "supported. Use PRIMARY_KEY or CUSTOMIZE_* ids for idempotent " + + "writes.", + mapping.getLabel()); + } + mapper = new VertexMapper(mapping, fieldsIndex, client); + } else { + mapper = new EdgeMapper(mapping, fieldsIndex, client); + } + entries.add(new MappingEntry(mapping, mapper)); + } - Map finalFieldsIndex = new LinkedHashMap<>(); + if (entries.isEmpty()) { + // Multi-table mode: at least one mapping has source_table, but none matched this + // writer's tablePath. This is a configuration error — the user intended multi-table + // but the table path strings don't align. Fail fast with a diagnostic that shows + // both sides so the user can reconcile them. + boolean multiTable = + sinkConfig.getMappings().stream() + .anyMatch( + m -> + m.getSourceTable() != null + && !m.getSourceTable().isEmpty()); + if (multiTable) { + List configuredTables = + sinkConfig.getMappings().stream() + .map(MappingConfig::getSourceTable) + .filter(t -> t != null && !t.isEmpty()) + .distinct() + .collect(Collectors.toList()); + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "No mapping matched writer table '%s'. The configured " + + "source_table value(s) are %s. Verify that each " + + "source_table matches the CatalogTable.getTablePath() " + + "produced by the upstream Source (in read-all mode this " + + "is the HugeGraph label name).", + tablePath, configuredTables)); + } + // Single-table mode (no source_table set on any mapping): no entries means the + // row type carries no fields the mappings can use. This is unusual but not + // necessarily a config error — a downstream project may legitimately sink only a + // subset of fields. Log and continue as a no-op. + LOG.warn( + "No mappings matched table '{}' in this writer. The writer will be a no-op: " + + "rows are acknowledged without writing to HugeGraph.", + tablePath); + } + return entries; + } + + private Map resolveLegacyFieldsIndex( + Map originalFieldsIndex) { + if (sinkConfig.getSchemaConfig() == null) { + return originalFieldsIndex; + } + + List selectedFields = sinkConfig.getSelectedFields(); if (selectedFields != null && !selectedFields.isEmpty()) { + Map selected = new LinkedHashMap<>(); for (String field : selectedFields) { - Integer originalIndex = originalFieldsIndex.get(field); - if (originalIndex != null) { - finalFieldsIndex.put(field, originalIndex); + Integer index = originalFieldsIndex.get(field); + if (index != null) { + selected.put(field, index); } } - } else if (ignoredFields != null && !ignoredFields.isEmpty()) { - Set ignoreSet = new HashSet<>(ignoredFields); + return selected; + } + + List ignoredFields = sinkConfig.getIgnoredFields(); + if (ignoredFields != null && !ignoredFields.isEmpty()) { + Set ignored = new HashSet<>(ignoredFields); + Map selected = new LinkedHashMap<>(); for (Map.Entry entry : originalFieldsIndex.entrySet()) { - String fieldName = entry.getKey(); - Integer originalIndex = entry.getValue(); + if (!ignored.contains(entry.getKey())) { + selected.put(entry.getKey(), entry.getValue()); + } + } + return selected; + } + return originalFieldsIndex; + } - if (!ignoreSet.contains(fieldName)) { - finalFieldsIndex.put(fieldName, originalIndex); + private Map resolveFieldsIndex( + MappingConfig mapping, Map originalFieldsIndex) { + // If no explicit properties, use all fields from the row + if (mapping.getProperties().isEmpty()) { + return new LinkedHashMap<>(originalFieldsIndex); + } + + // Build index from explicit properties + id fields + Map result = new LinkedHashMap<>(); + + for (String field : mapping.getProperties()) { + Integer idx = originalFieldsIndex.get(field); + if (idx != null) { + result.put(field, idx); + } + } + + // New mappings always include fields required to build IDs. Legacy selected_fields keeps + // its original strict filtering behavior for backward compatibility. + if (sinkConfig.getSchemaConfig() == null && mapping.getIdFields() != null) { + for (String field : mapping.getIdFields()) { + Integer idx = originalFieldsIndex.get(field); + if (idx != null) { + result.put(field, idx); } } - } else { - finalFieldsIndex = originalFieldsIndex; } - if (schemaConfig.getType() == LabelType.VERTEX) { - return new VertexMapper(schemaConfig, finalFieldsIndex, client); - } else { - return new EdgeMapper(schemaConfig, finalFieldsIndex, client); + // For edges, include source/target idFields and sortKeys + if (sinkConfig.getSchemaConfig() == null && mapping.getType() == LabelType.EDGE) { + includeEdgeIdFields(mapping.getSourceConfig(), originalFieldsIndex, result); + includeEdgeIdFields(mapping.getTargetConfig(), originalFieldsIndex, result); + + for (String field : mapping.getSortKeys()) { + Integer idx = originalFieldsIndex.get(field); + if (idx != null) { + result.put(field, idx); + } + } + } + + return result; + } + + private void includeEdgeIdFields( + MappingConfig.SourceTargetConfig stConfig, + Map originalFieldsIndex, + Map result) { + if (stConfig != null && stConfig.getIdFields() != null) { + for (String field : stConfig.getIdFields()) { + Integer idx = originalFieldsIndex.get(field); + if (idx != null) { + result.put(field, idx); + } + } } } @@ -109,15 +322,20 @@ private GraphDataMapper getMapper(SeaTunnelRowType rowType) { public void write(SeaTunnelRow row) throws IOException { switch (row.getRowKind()) { case INSERT: - case UPDATE_AFTER: handleUpsert(row); break; + case UPDATE_AFTER: + handleUpdate(pendingUpdateBefore, row); + pendingUpdateBefore = null; + break; case DELETE: handleDelete(row); break; case UPDATE_BEFORE: - // The huge-client natively supports upsert operations for property updates, so - // there is no need to handle this data manually. + // Correlated with the immediately following UPDATE_AFTER (changelog contract) and + // handled together in handleUpdate, so a key-changing update deletes the + // pre-update element instead of leaving it orphaned. + pendingUpdateBefore = row; break; default: LOG.warn("Unsupported row kind: {}", row.getRowKind()); @@ -126,52 +344,446 @@ public void write(SeaTunnelRow row) throws IOException { } private void handleUpsert(SeaTunnelRow row) throws IOException { - try { - GraphElement element = mapper.map(row); - if (element == null) { - LOG.warn("Cannot create graph element: required ID fields missing for row {}", row); - return; + List vertexEnvelopes = new ArrayList<>(); + List edgeEnvelopes = new ArrayList<>(); + + for (MappingEntry entry : mappingEntries) { + // mapToEnvelopes returns 1 element normally, or N when unfold expands a list cell. + for (GraphElementEnvelope envelope : mapToEnvelopes(entry, row)) { + if (entry.config.getType() == LabelType.VERTEX) { + vertexEnvelopes.add(envelope); + } else { + edgeEnvelopes.add(envelope); + } } - buffer.add(element); - } catch (Exception e) { - if (e instanceof IOException) { - throw (IOException) e; + } + + for (GraphElementEnvelope envelope : vertexEnvelopes) { + buffer.add(envelope); + } + for (GraphElementEnvelope envelope : edgeEnvelopes) { + buffer.add(envelope); + } + } + + /** + * unfold (one row → many elements) is only defined for the append/INSERT path. UPDATE/DELETE + * would require diffing N old ids against N new ids per mapping, which is out of scope and + * dangerous to get wrong, so reject a changelog row when any mapping enables unfold. + */ + private void rejectUnfoldForChangelog(String rowKind) { + for (MappingEntry entry : mappingEntries) { + if (entry.mapper.isUnfoldEnabled()) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Mapping[%s/%s]: unfold is only supported for INSERT/append-only " + + "jobs, but received a %s row. Disable unfold or run this " + + "as an append-only job.", + entry.config.getType(), entry.config.getLabel(), rowKind)); } - throw new IOException(e); } } - private void handleDelete(SeaTunnelRow row) { + /** + * AUTOMATIC-id vertices have no client-derivable id (the server assigns it), so a DELETE cannot + * identify the target. Reject with a clear message instead of the misleading "required ID field + * is null" — there is no id field to be null. Package-private + static so it can be unit-tested + * without constructing a real writer/client. + */ + static void rejectAutomaticVertexDelete(List mappingEntries) { + for (MappingEntry entry : mappingEntries) { + if (entry.config.getType() == LabelType.VERTEX + && entry.config.getIdStrategy() + == org.apache.hugegraph.structure.constant.IdStrategy.AUTOMATIC) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Mapping[VERTEX/%s]: DELETE is not supported with AUTOMATIC IDs " + + "because the target vertex cannot be identified from row " + + "data. Use a PRIMARY_KEY or CUSTOMIZE_* id strategy if this " + + "stream emits deletes.", + entry.config.getLabel())); + } + } + } + + /** + * Applies a changelog update transactionally with respect to the after-image validity. + * + *

The previous implementation issued the pre-update delete first and then upserted the + * after-image; if the after-image mapping failed (null ID, unsupported type conversion, or an + * AUTOMATIC-vertex mapping on update) the old vertex/edge had already been deleted, so the row + * was lost. This implementation builds a full replacement plan — envelopes for every mapping's + * after-image plus the set of superseded IDs — BEFORE touching the server. Any failure during + * plan-building raises without any remote side effect, so the pre-update elements stay intact + * and the source can replay the row after the config is fixed. + * + *

A superseded (old) element is deleted only when its mapping also produced a replacement + * after-image. If the after-image is absent — the after row cannot be mapped for that mapping, + * e.g. a null id field made {@code map()} return null — the old element is left untouched + * rather than deleted-with-nothing-written; a real removal must arrive as a DELETE event. + * + *

Note: HugeGraph server DDL is non-transactional across a flush+delete pair, so a crash + * between the two still leaves partial state; that is an inherent limitation of the REST API + * and is handled by at-least-once replay from the upstream source. + */ + private void handleUpdate(SeaTunnelRow before, SeaTunnelRow after) throws IOException { + rejectUnfoldForChangelog("UPDATE"); + UpdatePlan plan = buildUpdatePlan(mappingEntries, before, after); + executeUpdatePlan(plan); + } + + /** + * Package-private + static so the "no side effect on mapping failure" invariant can be pinned + * by a unit test without constructing a real writer/client. + */ + static UpdatePlan buildUpdatePlan( + List mappingEntries, SeaTunnelRow before, SeaTunnelRow after) { + List newVertices = new ArrayList<>(); + List newEdges = new ArrayList<>(); + List supersededVertices = new ArrayList<>(); + List supersededEdges = new ArrayList<>(); + + Set producedAfterImage = Collections.newSetFromMap(new IdentityHashMap<>()); + for (MappingEntry entry : mappingEntries) { + GraphElementEnvelope envelope = mapToEnvelope(entry, after, true); + if (envelope == null) { + continue; + } + producedAfterImage.add(entry); + if (entry.config.getType() == LabelType.VERTEX) { + newVertices.add(envelope); + } else { + newEdges.add(envelope); + } + } + + if (before != null) { + for (MappingEntry entry : mappingEntries) { + // Only delete the pre-update element when this mapping produced a replacement + // after-image. If the after-image is absent (e.g. the after row has a null id + // field so map() returned null), deleting the old element would drop it with + // nothing written back — a silent data loss. Keeping the old element is the safe + // choice; a genuine removal should arrive as a DELETE changelog event. + if (!producedAfterImage.contains(entry)) { + continue; + } + Object oldId; + Object newId; + try { + oldId = entry.mapper.extractId(before); + newId = entry.mapper.extractId(after); + } catch (Exception e) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + String.format( + "Mapping[%s/%s]: Failed to compute graph element ID for update", + entry.config.getType(), entry.config.getLabel()), + e); + } + if (oldId == null || oldId.equals(newId)) { + continue; + } + Superseded s = new Superseded(entry, oldId); + if (entry.config.getType() == LabelType.VERTEX) { + supersededVertices.add(s); + } else { + supersededEdges.add(s); + } + } + } + + return new UpdatePlan(newVertices, newEdges, supersededVertices, supersededEdges); + } + + private void executeUpdatePlan(UpdatePlan plan) throws IOException { + // Buffer new envelopes first — mirrors handleUpsert ordering for INSERT and ensures the + // new elements are staged before any destructive operation. + for (GraphElementEnvelope envelope : plan.newVertices) { + buffer.add(envelope); + } + for (GraphElementEnvelope envelope : plan.newEdges) { + buffer.add(envelope); + } + + if (plan.supersededVertices.isEmpty() && plan.supersededEdges.isEmpty()) { + // ID unchanged — the upsert alone updates the element in place and a vertex's + // adjacent edges are preserved. + return; + } + + // Persist the new elements before issuing deletes. If flush fails, no delete happens, so + // pre-update elements stay intact and the source will replay the row. + // NOTE: HugeGraph server DDL is non-transactional across a flush+delete pair, so a crash + // between the two leaves partial state; this is an inherent limitation of the REST API + // and is handled by at-least-once replay from the upstream source. The SinkWriter + // interface has no snapshotState(), so the connector cannot checkpoint the in-flight + // mutation. prepareCommit() guards against a pending UPDATE_BEFORE crossing a checkpoint + // boundary by failing fast. try { buffer.flush(); - if (sinkConfig.getSchemaConfig().getType() == LabelType.VERTEX) { - Object vertexId = mapper.extractId(row); - if (vertexId == null) { - LOG.warn("Cannot delete vertex: ID extraction failed for row {}", row); - return; - } - client.deleteVertexWithEdges(vertexId); - } else { - String edgeId = (String) mapper.extractId(row); - if (edgeId == null) { - LOG.warn("Cannot delete edge: ID extraction failed for row {}", row); - return; + } catch (IOException e) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + "Failed to flush buffer before UPDATE cleanup", + e); + } + + // Delete edges before vertices for topology safety (mirror handleDelete). + for (Superseded s : plan.supersededEdges) { + try { + client.deleteEdge((String) s.oldId); + } catch (Exception e) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + String.format( + "Mapping[%s/%s]: Failed to delete superseded edge on update", + s.entry.config.getType(), s.entry.config.getLabel()), + e); + } + } + for (Superseded s : plan.supersededVertices) { + try { + if (sinkConfig.isDeleteVertexWithEdges()) { + client.deleteVertexWithEdges(s.oldId); + } else { + client.deleteVertex(s.oldId); } - client.deleteEdge(edgeId); + } catch (Exception e) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + String.format( + "Mapping[%s/%s]: Failed to delete superseded vertex on update", + s.entry.config.getType(), s.entry.config.getLabel()), + e); } + } + } + + /** + * Maps a row into an envelope for one mapping, or returns {@code null} if the mapper does not + * produce an element for this row (e.g. a null ID field). Reject AUTOMATIC-vertex mappings on + * update because the existing vertex cannot be identified. + */ + static GraphElementEnvelope mapToEnvelope( + MappingEntry entry, SeaTunnelRow row, boolean update) { + if (update + && entry.config.getType() == LabelType.VERTEX + && entry.config.getIdStrategy() + == org.apache.hugegraph.structure.constant.IdStrategy.AUTOMATIC) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Mapping[VERTEX/%s]: UPDATE_AFTER is not supported with AUTOMATIC IDs because the existing vertex cannot be identified", + entry.config.getLabel())); + } + GraphElement element; + try { + element = entry.mapper.map(row); + } catch (Exception e) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + String.format( + "Mapping[%s/%s]: Failed to map input row to graph element", + entry.config.getType(), entry.config.getLabel()), + e); + } + if (element == null) { + return null; + } + return new GraphElementEnvelope( + entry.config.getLabel(), + entry.config.getType(), + element, + entry.config.getUpdateStrategies()); + } + + /** + * INSERT/append-path mapping that supports unfold: returns one envelope normally, or N when a + * mapping expands a list-valued id cell into multiple elements. + */ + static List mapToEnvelopes(MappingEntry entry, SeaTunnelRow row) { + List elements; + try { + elements = entry.mapper.mapAll(row); } catch (Exception e) { throw new HugeGraphConnectorException( HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, - "Non-retryable error executing graph operation", + String.format( + "Mapping[%s/%s]: Failed to map input row to graph element(s)", + entry.config.getType(), entry.config.getLabel()), + e); + } + if (elements == null || elements.isEmpty()) { + return Collections.emptyList(); + } + List envelopes = new ArrayList<>(elements.size()); + for (GraphElement element : elements) { + if (element == null) { + continue; + } + envelopes.add( + new GraphElementEnvelope( + entry.config.getLabel(), + entry.config.getType(), + element, + entry.config.getUpdateStrategies())); + } + return envelopes; + } + + static final class UpdatePlan { + final List newVertices; + final List newEdges; + final List supersededVertices; + final List supersededEdges; + + UpdatePlan( + List newVertices, + List newEdges, + List supersededVertices, + List supersededEdges) { + this.newVertices = newVertices; + this.newEdges = newEdges; + this.supersededVertices = supersededVertices; + this.supersededEdges = supersededEdges; + } + } + + static final class Superseded { + final MappingEntry entry; + final Object oldId; + + Superseded(MappingEntry entry, Object oldId) { + this.entry = entry; + this.oldId = oldId; + } + } + + private void handleDelete(SeaTunnelRow row) { + rejectUnfoldForChangelog("DELETE"); + rejectAutomaticVertexDelete(mappingEntries); + + // Phase 1: build deletion plan — validate ALL mappings before touching the server. + // Any failure here (null id, missing field) aborts with zero side effects. + DeletePlan deletePlan = buildDeletePlan(row); + + // Phase 2: execute the plan atomically — flush pending inserts first, then execute all + // deletions. The flush ensures earlier INSERT/UPSERT rows are persisted before their + // elements are deleted (a DELETE arriving immediately after its own INSERT). + try { + buffer.flush(); + } catch (IOException e) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + "Failed to flush buffer before DELETE operation", e); } + + executeDeletePlan(deletePlan); + } + + /** + * Validates every mapping's ability to extract a delete ID from the row WITHOUT issuing any + * remote operation. Returns the validated plan — if this method returns, every ID is valid and + * the caller can safely execute them. + */ + static DeletePlan buildDeletePlan( + List mappingEntries, SeaTunnelRow row, HugeGraphSinkConfig sinkConfig) { + List edgeTargets = new ArrayList<>(); + List vertexTargets = new ArrayList<>(); + + for (MappingEntry entry : mappingEntries) { + Object id = entry.mapper.extractId(row); + if (id == null) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Mapping[%s/%s]: Cannot delete because a required ID field is null " + + "or matches nullValues", + entry.config.getType(), entry.config.getLabel())); + } + if (entry.config.getType() == LabelType.VERTEX) { + vertexTargets.add( + new DeleteTarget(entry, id, sinkConfig.isDeleteVertexWithEdges())); + } else { + edgeTargets.add(new DeleteTarget(entry, id, false)); + } + } + + return new DeletePlan(edgeTargets, vertexTargets); + } + + private DeletePlan buildDeletePlan(SeaTunnelRow row) { + return buildDeletePlan(mappingEntries, row, sinkConfig); + } + + private void executeDeletePlan(DeletePlan plan) { + // Edges before vertices for topology safety. + for (DeleteTarget target : plan.edgeTargets) { + client.deleteEdge((String) target.id); + } + for (DeleteTarget target : plan.vertexTargets) { + if (target.deleteWithEdges) { + client.deleteVertexWithEdges(target.id); + } else { + client.deleteVertex(target.id); + } + } + } + + static final class DeletePlan { + final List edgeTargets; + final List vertexTargets; + + DeletePlan(List edgeTargets, List vertexTargets) { + this.edgeTargets = edgeTargets; + this.vertexTargets = vertexTargets; + } + } + + static final class DeleteTarget { + final MappingEntry entry; + final Object id; + final boolean deleteWithEdges; + + DeleteTarget(MappingEntry entry, Object id, boolean deleteWithEdges) { + this.entry = entry; + this.id = id; + this.deleteWithEdges = deleteWithEdges; + } } @Override public Optional prepareCommit() { + // The SeaTunnel SinkWriter interface has no snapshotState() — the framework + // provides no mechanism for a sink writer to persist in-flight state across a + // checkpoint. A pending UPDATE_BEFORE means an update was split across the + // checkpoint boundary: UPDATE_BEFORE arrived but its paired UPDATE_AFTER has + // not yet been processed. On recovery the source replays from its own + // checkpoint, which may or may not include both rows, so the mutation could be + // partially applied (UPDATE_BEFORE replayed without its AFTER, or vice versa). + // + // Refuse to checkpoint in this state. Check BEFORE the flush try-catch so the + // exception propagates directly rather than being wrapped as a flush failure. + if (pendingUpdateBefore != null) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + "Checkpoint requested between UPDATE_BEFORE and UPDATE_AFTER: " + + "UPDATE_BEFORE received but its paired UPDATE_AFTER has " + + "not yet arrived. The SeaTunnel SinkWriter interface does " + + "not support persisting in-flight mutation state across " + + "checkpoints. UPDATE_BEFORE and UPDATE_AFTER must arrive " + + "within the same checkpoint interval. " + + "Mitigations: (1) increase the checkpoint interval, " + + "(2) use INSERT-only mode if the source does not emit " + + "changelog events."); + } + try { buffer.flush(); - } catch (IOException e) { + } catch (Exception e) { LOG.error("Failed to flush data during prepareCommit, failing checkpoint.", e); throw new RuntimeException("Failed to flush data during prepareCommit()", e); } @@ -180,12 +792,49 @@ public Optional prepareCommit() { @Override public void close() throws IOException { - if (buffer != null) { - buffer.close(); + Exception failure = null; + try { + if (buffer != null) { + buffer.close(); + } + } catch (Exception e) { + failure = e; + } finally { + try { + if (client != null) { + client.close(); + } + } catch (Exception closeFailure) { + if (failure == null) { + failure = closeFailure; + } else { + failure.addSuppressed(closeFailure); + } + } + } + if (failure instanceof IOException) { + throw (IOException) failure; + } + if (failure instanceof RuntimeException) { + throw (RuntimeException) failure; } + if (failure != null) { + throw new IOException("Failed to close HugeGraph sink writer", failure); + } + } + + /** Package-private test accessor. */ + List mappingEntries() { + return mappingEntries; + } + + static class MappingEntry { + final MappingConfig config; + final GraphDataMapper mapper; - if (client != null) { - client.close(); + MappingEntry(MappingConfig config, GraphDataMapper mapper) { + this.config = config; + this.mapper = mapper; } } } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSource.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSource.java new file mode 100644 index 000000000000..80a681e2ff66 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSource.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.source; + +import org.apache.seatunnel.api.source.Boundedness; +import org.apache.seatunnel.api.source.SeaTunnelSource; +import org.apache.seatunnel.api.source.SourceReader; +import org.apache.seatunnel.api.source.SourceSplitEnumerator; +import org.apache.seatunnel.api.source.SupportColumnProjection; +import org.apache.seatunnel.api.source.SupportParallelism; +import org.apache.seatunnel.api.table.catalog.CatalogTable; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphOptions; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSourceConfig; + +import java.util.List; +import java.util.Map; + +/** + * HugeGraph source. Bounded read of one vertex/edge label, or of all labels of a type in one job. + * + *

Single-label mode (option {@code label} set): at parallelism 1 it pages the label via the + * server-side list API (server-side label and property-equality filtering); at parallelism > 1 + * it splits the keyspace into shards and scans them in parallel. Read-all mode ({@code label} + * omitted): one {@code LABEL_LIST} split per discovered label, each producing its own table. See + * {@link HugeGraphSourceSplitEnumerator}. + */ +public class HugeGraphSource + implements SeaTunnelSource, + SupportParallelism, + SupportColumnProjection { + + private static final long serialVersionUID = 1L; + + private final List catalogTables; + private final Map labelContexts; + private final HugeGraphSourceConfig sourceConfig; + + public HugeGraphSource( + List catalogTables, + Map labelContexts, + HugeGraphSourceConfig sourceConfig) { + this.catalogTables = catalogTables; + this.labelContexts = labelContexts; + this.sourceConfig = sourceConfig; + } + + @Override + public String getPluginName() { + return HugeGraphOptions.PLUGIN_NAME; + } + + @Override + public Boundedness getBoundedness() { + return Boundedness.BOUNDED; + } + + @Override + public List getProducedCatalogTables() { + return catalogTables; + } + + @Override + public SourceReader createReader( + SourceReader.Context readerContext) { + return new HugeGraphSourceReader(readerContext, sourceConfig, labelContexts); + } + + @Override + public SourceSplitEnumerator createEnumerator( + SourceSplitEnumerator.Context enumeratorContext) { + return new HugeGraphSourceSplitEnumerator( + enumeratorContext, sourceConfig, sourceConfig.getSplitSize()); + } + + @Override + public SourceSplitEnumerator restoreEnumerator( + SourceSplitEnumerator.Context enumeratorContext, + HugeGraphSourceState checkpointState) { + return new HugeGraphSourceSplitEnumerator( + enumeratorContext, sourceConfig, sourceConfig.getSplitSize(), checkpointState); + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceFactory.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceFactory.java new file mode 100644 index 000000000000..c916cea4b253 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceFactory.java @@ -0,0 +1,340 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.source; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.configuration.util.OptionRule; +import org.apache.seatunnel.api.options.ConnectorCommonOptions; +import org.apache.seatunnel.api.options.EnvCommonOptions; +import org.apache.seatunnel.api.source.SeaTunnelSource; +import org.apache.seatunnel.api.source.SourceSplit; +import org.apache.seatunnel.api.table.catalog.CatalogTable; +import org.apache.seatunnel.api.table.catalog.CatalogTableUtil; +import org.apache.seatunnel.api.table.connector.TableSource; +import org.apache.seatunnel.api.table.factory.Factory; +import org.apache.seatunnel.api.table.factory.TableSourceFactory; +import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphOperations; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphConnectionConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphOptions; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSourceConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSourceOptions; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.ReservedColumns; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.utils.HugeGraphTypeConverter; + +import com.google.auto.service.AutoService; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@AutoService(Factory.class) +public class HugeGraphSourceFactory implements TableSourceFactory { + + @Override + public String factoryIdentifier() { + return HugeGraphOptions.PLUGIN_NAME; + } + + @Override + public OptionRule optionRule() { + return OptionRule.builder() + .required(HugeGraphOptions.HOST, HugeGraphOptions.PORT, HugeGraphOptions.GRAPH_NAME) + .optional( + // When omitted, the source reads ALL labels of label_type (default VERTEX), + // producing one table per label. When set, only that one label is read. + HugeGraphSourceOptions.LABEL, + // Optional: when omitted, the property columns are auto-discovered from the + // server label definition (all properties, inferred types). + ConnectorCommonOptions.SCHEMA, + HugeGraphSourceOptions.LABEL_TYPE, + HugeGraphSourceOptions.PAGE_SIZE, + HugeGraphSourceOptions.SPLIT_SIZE, + HugeGraphSourceOptions.FILTER, + HugeGraphSourceOptions.TIME_ZONE, + HugeGraphOptions.PROTOCOL, + HugeGraphOptions.USERNAME, + HugeGraphOptions.PASSWORD, + // Optional connection setting passed through to select the HugeGraph graph + // space (defaults to "DEFAULT"). + HugeGraphOptions.GRAPH_SPACE, + HugeGraphOptions.MAX_RETRIES, + HugeGraphOptions.RETRY_BACKOFF_MS, + HugeGraphOptions.RETRY_BACKOFF_MAX_MS) + .build(); + } + + @Override + public Class getSourceClass() { + return HugeGraphSource.class; + } + + @Override + public + TableSource createSource(TableSourceFactoryContext context) { + ReadonlyConfig options = context.getOptions(); + MappingConfig.LabelType labelType = + options.getOptional(HugeGraphSourceOptions.LABEL_TYPE) + .orElse(HugeGraphSourceOptions.LABEL_TYPE.defaultValue()); + boolean readAll = !options.getOptional(HugeGraphSourceOptions.LABEL).isPresent(); + + List catalogTables = new ArrayList<>(); + Map labelContexts = new LinkedHashMap<>(); + HugeGraphSourceConfig sourceConfig; + + if (readAll) { + sourceConfig = buildReadAllTables(options, labelType, catalogTables, labelContexts); + } else { + checkFilterParallelism(options); + CatalogTable propertyCatalogTable = resolvePropertyCatalogTable(options, labelType); + SeaTunnelRowType propertyRowType = propertyCatalogTable.getSeaTunnelRowType(); + sourceConfig = HugeGraphSourceConfig.of(options, propertyRowType); + CatalogTable producedCatalogTable = + CatalogTableUtil.newCatalogTable( + propertyCatalogTable, + prependReservedFields(propertyRowType, sourceConfig.getLabelType())); + catalogTables.add(producedCatalogTable); + labelContexts.put( + sourceConfig.getLabel(), + new LabelTableContext( + sourceConfig.getLabel(), + propertyRowType, + producedCatalogTable.getSeaTunnelRowType(), + producedCatalogTable.getTablePath().toString())); + } + + final List tables = catalogTables; + final Map contexts = labelContexts; + final HugeGraphSourceConfig cfg = sourceConfig; + return () -> + (SeaTunnelSource) new HugeGraphSource(tables, contexts, cfg); + } + + /** + * Read-all mode: discover every label of {@code labelType} from the server and build one + * produced {@link CatalogTable} + one {@link LabelTableContext} per label. Rejects {@code + * schema}/{@code filter} (neither can describe multiple heterogeneous labels) and an empty + * graph up front. Returns the read-all {@link HugeGraphSourceConfig}. + */ + private HugeGraphSourceConfig buildReadAllTables( + ReadonlyConfig options, + MappingConfig.LabelType labelType, + List catalogTables, + Map labelContexts) { + if (options.getOptional(ConnectorCommonOptions.SCHEMA).isPresent()) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + "'schema' cannot be combined with reading all labels (option 'label' omitted): " + + "a single schema cannot describe multiple labels. Set 'label' to use " + + "'schema', or drop 'schema' to auto-discover every label."); + } + boolean hasFilter = + options.getOptional(HugeGraphSourceOptions.FILTER) + .map(filter -> !filter.isEmpty()) + .orElse(false); + if (hasFilter) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + "'filter' cannot be combined with reading all labels (option 'label' omitted): " + + "a property-equality filter assumes the property exists on every " + + "label. Set 'label' to use 'filter'."); + } + HugeGraphClient client = new HugeGraphClient(HugeGraphConnectionConfig.of(options)); + List labels; + try { + labels = + labelType == MappingConfig.LabelType.VERTEX + ? client.listVertexLabels() + : client.listEdgeLabels(); + if (labels.isEmpty()) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "No %s labels found in HugeGraph graph '%s'; nothing to read.", + labelType == MappingConfig.LabelType.VERTEX ? "vertex" : "edge", + options.get(HugeGraphOptions.GRAPH_NAME))); + } + for (String label : labels) { + SeaTunnelRowType propertyRowType = + discoverPropertyRowType(client, label, labelType); + CatalogTable propertyTable = + CatalogTableUtil.getCatalogTable(label, propertyRowType); + CatalogTable producedTable = + CatalogTableUtil.newCatalogTable( + propertyTable, prependReservedFields(propertyRowType, labelType)); + catalogTables.add(producedTable); + labelContexts.put( + label, + new LabelTableContext( + label, + propertyRowType, + producedTable.getSeaTunnelRowType(), + producedTable.getTablePath().toString())); + } + } finally { + client.close(); + } + return HugeGraphSourceConfig.ofReadAll(options, labels); + } + + /** + * Resolves the property columns. When {@code schema} is configured it is used verbatim; + * otherwise the columns are auto-discovered from the server label definition (all property + * keys, with types inferred from the server). + */ + private CatalogTable resolvePropertyCatalogTable( + ReadonlyConfig options, MappingConfig.LabelType labelType) { + if (options.getOptional(ConnectorCommonOptions.SCHEMA).isPresent()) { + return CatalogTableUtil.buildWithConfig(options); + } + String label = options.get(HugeGraphSourceOptions.LABEL); + HugeGraphClient client = new HugeGraphClient(HugeGraphConnectionConfig.of(options)); + SeaTunnelRowType propertyRowType; + try { + propertyRowType = discoverPropertyRowType(client, label, labelType); + } finally { + client.close(); + } + return CatalogTableUtil.getCatalogTable(label, propertyRowType); + } + + /** + * Builds the property row type from the server label definition: every property key of the + * label, ordered by name for a deterministic column order, typed via {@link + * HugeGraphTypeConverter}. + */ + static SeaTunnelRowType discoverPropertyRowType( + HugeGraphOperations client, String label, MappingConfig.LabelType labelType) { + Set properties = + labelType == MappingConfig.LabelType.VERTEX + ? client.getVertexLabelPropertiesOrNull(label) + : client.getEdgeLabelPropertiesOrNull(label); + if (properties == null) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "%s label '%s' does not exist in HugeGraph schema; cannot auto-discover " + + "its columns. Create the label or declare 'schema.fields'.", + labelType == MappingConfig.LabelType.VERTEX ? "Vertex" : "Edge", + label)); + } + List names = new ArrayList<>(properties); + Collections.sort(names); + String[] fieldNames = new String[names.size()]; + SeaTunnelDataType[] fieldTypes = new SeaTunnelDataType[names.size()]; + for (int i = 0; i < names.size(); i++) { + String name = names.get(i); + fieldNames[i] = name; + fieldTypes[i] = + HugeGraphTypeConverter.toSeaTunnelType( + client.getPropertyDataType(name), + client.getPropertyCardinality(name), + name); + } + return new SeaTunnelRowType(fieldNames, fieldTypes); + } + + /** + * Parallelism > 1 uses shard-based key-range scans, which cannot push a property-equality + * {@code filter} to the server (the scan API takes no condition). Reject that combination here, + * at plan/config time, so the user gets an actionable choice before the job starts rather than + * a silently-ignored filter or a mid-run failure. {@code filter} with parallelism = 1 + * (label-list scan) is fully supported. + */ + static void checkFilterParallelism(ReadonlyConfig options) { + int parallelism = options.getOptional(EnvCommonOptions.PARALLELISM).orElse(1); + boolean hasFilter = + options.getOptional(HugeGraphSourceOptions.FILTER) + .map(filter -> !filter.isEmpty()) + .orElse(false); + if (parallelism > 1 && hasFilter) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "HugeGraph source 'filter' cannot be combined with parallelism > 1 " + + "(got %d): parallel reads use shard key-range scans that do not " + + "support server-side property filtering. Either set parallelism " + + "to 1 to keep the filter, or remove the filter to read in " + + "parallel.", + parallelism)); + } + } + + static SeaTunnelRowType prependReservedFields( + SeaTunnelRowType propertyRowType, MappingConfig.LabelType labelType) { + // The source auto-prepends reserved columns (~id/~label, plus edge endpoints). A user + // schema.fields column with a reserved name would silently create a duplicate column and + // later fail with a misleading "label has no property ~id"; reject it up front with a clear + // message instead. Auto-discovered names never start with '~' (HugeGraph forbids it), so + // this only triggers on an explicit schema.fields declaration. + for (String fieldName : propertyRowType.getFieldNames()) { + if (ReservedColumns.isReserved(fieldName)) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "schema.fields must not declare the reserved column '%s': names " + + "starting with '%s' are emitted automatically by the " + + "HugeGraph source (%s for vertices; also %s, %s, %s, %s " + + "for edges). Remove '%s' from schema.fields.", + fieldName, + ReservedColumns.PREFIX, + ReservedColumns.ID + "/" + ReservedColumns.LABEL, + ReservedColumns.SOURCE_ID, + ReservedColumns.SOURCE_LABEL, + ReservedColumns.TARGET_ID, + ReservedColumns.TARGET_LABEL, + fieldName)); + } + } + int reservedSize = labelType == MappingConfig.LabelType.VERTEX ? 2 : 6; + String[] fieldNames = new String[reservedSize + propertyRowType.getTotalFields()]; + SeaTunnelDataType[] fieldTypes = + new SeaTunnelDataType[reservedSize + propertyRowType.getTotalFields()]; + + fieldNames[0] = HugeGraphSourceReader.ID_FIELD; + fieldNames[1] = HugeGraphSourceReader.LABEL_FIELD; + fieldTypes[0] = BasicType.STRING_TYPE; + fieldTypes[1] = BasicType.STRING_TYPE; + if (labelType == MappingConfig.LabelType.EDGE) { + fieldNames[2] = HugeGraphSourceReader.SOURCE_ID_FIELD; + fieldNames[3] = HugeGraphSourceReader.SOURCE_LABEL_FIELD; + fieldNames[4] = HugeGraphSourceReader.TARGET_ID_FIELD; + fieldNames[5] = HugeGraphSourceReader.TARGET_LABEL_FIELD; + for (int i = 2; i < reservedSize; i++) { + fieldTypes[i] = BasicType.STRING_TYPE; + } + } + + for (int i = 0; i < propertyRowType.getTotalFields(); i++) { + fieldNames[reservedSize + i] = propertyRowType.getFieldName(i); + fieldTypes[reservedSize + i] = propertyRowType.getFieldType(i); + } + return new SeaTunnelRowType(fieldNames, fieldTypes); + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceReader.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceReader.java new file mode 100644 index 000000000000..6fab5cdc1602 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceReader.java @@ -0,0 +1,669 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.source; + +import org.apache.seatunnel.api.source.Boundedness; +import org.apache.seatunnel.api.source.Collector; +import org.apache.seatunnel.api.source.SourceReader; +import org.apache.seatunnel.api.table.type.ArrayType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.api.table.type.SqlType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphOperations; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.PageResult; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSourceConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.utils.HugeGraphTypeConverter; + +import org.apache.hugegraph.structure.constant.Cardinality; +import org.apache.hugegraph.structure.constant.DataType; +import org.apache.hugegraph.structure.graph.Edge; +import org.apache.hugegraph.structure.graph.Vertex; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.lang.reflect.Array; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoField; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.Date; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedDeque; + +/** + * Reads HugeGraph vertices/edges into SeaTunnel rows, one assigned split at a time. + * + *

A {@code LABEL_LIST} split pages the whole label via the server-side list API (with optional + * server-side property filtering). A {@code SHARD} split scans a key range via the scan API and + * filters to the configured label client-side (the scan API cannot filter by label or property). + * Progress (page marker, finished flag, dedup id) is stored on the split so a checkpoint can resume + * mid-scan after failover. + */ +public class HugeGraphSourceReader implements SourceReader { + + private static final Logger LOG = LoggerFactory.getLogger(HugeGraphSourceReader.class); + + private static final long IDLE_POLL_INTERVAL_MS = 1000L; + + /** HugeGraph server DATE serialization format: {@code yyyy-MM-dd HH:mm:ss.SSS}. */ + private static final DateTimeFormatter HUGEGRAPH_DATE_FORMAT = + new DateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd HH:mm:ss") + .appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true) + .toFormatter(); + + public static final String ID_FIELD = "~id"; + public static final String LABEL_FIELD = "~label"; + public static final String SOURCE_ID_FIELD = "~source_id"; + public static final String SOURCE_LABEL_FIELD = "~source_label"; + public static final String TARGET_ID_FIELD = "~target_id"; + public static final String TARGET_LABEL_FIELD = "~target_label"; + + private final SourceReader.Context context; + private final HugeGraphSourceConfig sourceConfig; + // Per-label read context, keyed by label. Single-label mode has exactly one entry; read-all + // mode has one per discovered label. The reader resolves the entry by each split's active + // label. + private final Map labelContexts; + private final HugeGraphOperations client; + + private final Deque pendingSplits = new ConcurrentLinkedDeque<>(); + private HugeGraphSourceSplit currentSplit; + private volatile boolean noMoreSplits; + + // Dedup state for the split currently being read; mirrored to/from currentSplit around each + // page + // so it survives checkpoints. + private String lastEmittedId; + private long duplicateSkipped; + private long totalRecords; + private int pageCount; + + public HugeGraphSourceReader( + SourceReader.Context context, + HugeGraphSourceConfig sourceConfig, + Map labelContexts) { + this( + context, + sourceConfig, + labelContexts, + new HugeGraphClient(sourceConfig.getConnectionConfig())); + } + + HugeGraphSourceReader( + SourceReader.Context context, + HugeGraphSourceConfig sourceConfig, + Map labelContexts, + HugeGraphOperations client) { + this.context = context; + this.sourceConfig = sourceConfig; + this.labelContexts = labelContexts; + this.client = client; + } + + /** + * The label the given split reads: a LABEL_LIST split names it directly; a SHARD split scans a + * key range of all labels and inherits the single configured label (shard splits are only + * created in single-label mode). + */ + private String activeLabel(HugeGraphSourceSplit split) { + return split.getLabel() != null ? split.getLabel() : sourceConfig.getLabel(); + } + + private LabelTableContext contextFor(String label) { + LabelTableContext ctx = labelContexts.get(label); + if (ctx == null) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format("No read context for label '%s'.", label)); + } + return ctx; + } + + @Override + public void open() { + // Read-all mode auto-discovers each label's row type from the server, so there is no user + // schema to validate against (it cannot mismatch). Skip validation; the reader resolves + // each split's context by label at read time. + if (sourceConfig.isReadAllLabels()) { + return; + } + try { + // validateLabelAndSchema triggers the lazy client connection; if it throws (unknown + // label, type mismatch, unreachable server) the framework may not call close(), so + // release the just-opened client here to avoid leaking connection pools/threads. + validateLabelAndSchema(); + } catch (RuntimeException e) { + try { + client.close(); + } catch (RuntimeException closeFailure) { + e.addSuppressed(closeFailure); + } + throw e; + } + } + + @Override + public void close() throws IOException { + client.close(); + } + + @Override + public void addSplits(List splits) { + if (splits != null) { + pendingSplits.addAll(splits); + } + } + + @Override + public void handleNoMoreSplits() { + noMoreSplits = true; + } + + @Override + public List snapshotState(long checkpointId) { + List state = new ArrayList<>(); + HugeGraphSourceSplit cur = currentSplit; + if (cur != null && !cur.isFinished()) { + state.add(cur); + } + state.addAll(pendingSplits); + return state; + } + + @Override + public void notifyCheckpointComplete(long checkpointId) { + // No-op: the source keeps no server-side cursor to acknowledge. + } + + @Override + public void pollNext(Collector output) throws InterruptedException { + boolean idle = false; + synchronized (output.getCheckpointLock()) { + if (currentSplit == null) { + currentSplit = pendingSplits.poll(); + } + if (currentSplit == null) { + if (noMoreSplits && Boundedness.BOUNDED.equals(context.getBoundedness())) { + context.signalNoMoreElement(); + } else { + context.sendSplitRequest(); + idle = true; + } + } else { + readOnePage(currentSplit, output); + } + } + if (idle) { + Thread.sleep(IDLE_POLL_INTERVAL_MS); + } + } + + /** Reads one bounded page of the current split so checkpoints can persist progress. */ + private void readOnePage(HugeGraphSourceSplit split, Collector output) { + String requestedPage = split.getPage(); + this.lastEmittedId = split.getLastEmittedId(); + String label = activeLabel(split); + LabelTableContext ctx = contextFor(label); + + int recordCount; + String responsePage; + if (sourceConfig.getLabelType() == MappingConfig.LabelType.VERTEX) { + PageResult page = fetchVertexPage(split, label, requestedPage); + List records = + split.isShardMode() + ? filterVerticesByLabel(page.getRecords(), label) + : page.getRecords(); + collectVertices(records, output, ctx); + recordCount = page.getRecords().size(); + responsePage = page.getNextPage(); + } else { + PageResult page = fetchEdgePage(split, label, requestedPage); + List records = + split.isShardMode() + ? filterEdgesByLabel(page.getRecords(), label) + : page.getRecords(); + collectEdges(records, output, ctx); + recordCount = page.getRecords().size(); + responsePage = page.getNextPage(); + } + + if (responsePage != null && responsePage.equals(requestedPage)) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + String.format( + "HugeGraph pagination marker did not advance for split '%s': '%s'", + split.splitId(), responsePage)); + } + + split.setLastEmittedId(this.lastEmittedId); + split.setPage(responsePage); + totalRecords += recordCount; + pageCount++; + if (recordCount == 0 && responsePage != null) { + LOG.debug( + "HugeGraph source received an empty intermediate page for split '{}'; continuing", + split.splitId()); + } + if (responsePage == null) { + split.setFinished(true); + LOG.info( + "HugeGraph source finished split '{}' (label '{}'): {} records in {} pages" + + " ({} server-side paging duplicates skipped)", + split.splitId(), + label, + totalRecords, + pageCount, + duplicateSkipped); + // Reset per-split counters for the next split. + totalRecords = 0; + pageCount = 0; + duplicateSkipped = 0; + currentSplit = null; + } + } + + private PageResult fetchVertexPage( + HugeGraphSourceSplit split, String label, String requestedPage) { + if (split.isShardMode()) { + return client.scanVertices(split.toShard(), requestedPage, sourceConfig.getPageSize()); + } + return client.listVertices( + label, sourceConfig.getFilter(), requestedPage, sourceConfig.getPageSize()); + } + + private PageResult fetchEdgePage( + HugeGraphSourceSplit split, String label, String requestedPage) { + if (split.isShardMode()) { + return client.scanEdges(split.toShard(), requestedPage, sourceConfig.getPageSize()); + } + return client.listEdges( + label, sourceConfig.getFilter(), requestedPage, sourceConfig.getPageSize()); + } + + /** + * Shard scans return elements of all labels in the key range; keep only {@code label}. (Shard + * mode is single-label only, so this filters to the one configured label.) + */ + private List filterVerticesByLabel(List records, String label) { + List filtered = new ArrayList<>(records.size()); + for (Vertex vertex : records) { + if (label.equals(vertex.label())) { + filtered.add(vertex); + } + } + return filtered; + } + + private List filterEdgesByLabel(List records, String label) { + List filtered = new ArrayList<>(records.size()); + for (Edge edge : records) { + if (label.equals(edge.label())) { + filtered.add(edge); + } + } + return filtered; + } + + private void validateLabelAndSchema() { + // Single-label mode only (read-all skips validation in open()); exactly one context, keyed + // by the configured label. + SeaTunnelRowType propertyRowType = contextFor(sourceConfig.getLabel()).getPropertyRowType(); + Set labelProperties; + if (sourceConfig.getLabelType() == MappingConfig.LabelType.VERTEX) { + labelProperties = client.getVertexLabelPropertiesOrNull(sourceConfig.getLabel()); + if (labelProperties == null) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Vertex label '%s' does not exist in HugeGraph schema", + sourceConfig.getLabel())); + } + } else { + labelProperties = client.getEdgeLabelPropertiesOrNull(sourceConfig.getLabel()); + if (labelProperties == null) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Edge label '%s' does not exist in HugeGraph schema", + sourceConfig.getLabel())); + } + } + + for (int i = 0; i < propertyRowType.getTotalFields(); i++) { + String propertyName = propertyRowType.getFieldName(i); + if (!labelProperties.contains(propertyName)) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Label '%s' does not contain property '%s'. Available properties: %s", + sourceConfig.getLabel(), propertyName, labelProperties)); + } + validatePropertyType(propertyName, propertyRowType.getFieldType(i)); + } + + // A filter keyed on a non-existent property would be silently dropped by the server and + // return the whole label — fail fast so the misconfiguration surfaces at open() instead. + // Values are also coerced to the property's server type: the server matches by typed value, + // so a BOOLEAN property filtered with the string "true" (or a LONG filtered with an int) + // would otherwise match nothing and return 0 rows with no error. + Map filter = sourceConfig.getFilter(); + if (filter != null && !filter.isEmpty()) { + Map coerced = new LinkedHashMap<>(); + for (Map.Entry entry : filter.entrySet()) { + String key = entry.getKey(); + if (!labelProperties.contains(key)) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "filter property '%s' is not a property of label '%s'. " + + "Available properties: %s", + key, sourceConfig.getLabel(), labelProperties)); + } + coerced.put( + key, + coerceFilterValue(key, entry.getValue(), client.getPropertyDataType(key))); + } + sourceConfig.setFilter(coerced); + } + } + + /** + * Coerces a filter value to the Java type the HugeGraph server matches against for the + * property's type — config often supplies a string or a loosely-typed number. A value that + * cannot be coerced (e.g. {@code "yes"} for a BOOLEAN) fails fast here instead of silently + * matching nothing. DATE/BLOB/OBJECT are passed through unchanged (not sensibly filterable). + */ + static Object coerceFilterValue(String key, Object value, DataType dataType) { + if (value == null) { + return null; + } + String raw = value.toString().trim(); + try { + switch (dataType) { + case BOOLEAN: + if (value instanceof Boolean) { + return value; + } + if ("true".equalsIgnoreCase(raw)) { + return Boolean.TRUE; + } + if ("false".equalsIgnoreCase(raw)) { + return Boolean.FALSE; + } + throw new IllegalArgumentException("expected true or false"); + case BYTE: + return value instanceof Number + ? ((Number) value).byteValue() + : Byte.valueOf(raw); + case INT: + return value instanceof Number + ? ((Number) value).intValue() + : Integer.valueOf(raw); + case LONG: + return value instanceof Number + ? ((Number) value).longValue() + : Long.valueOf(raw); + case FLOAT: + return value instanceof Number + ? ((Number) value).floatValue() + : Float.valueOf(raw); + case DOUBLE: + return value instanceof Number + ? ((Number) value).doubleValue() + : Double.valueOf(raw); + case TEXT: + case UUID: + return raw; + default: + return value; + } + } catch (RuntimeException e) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "filter value '%s' for property '%s' is not a valid %s.", + value, key, dataType), + e); + } + } + + private void validatePropertyType(String propertyName, SeaTunnelDataType seaTunnelType) { + Cardinality cardinality = client.getPropertyCardinality(propertyName); + DataType propertyDataType = client.getPropertyDataType(propertyName); + boolean serverIsMulti = cardinality != null && cardinality != Cardinality.SINGLE; + boolean declaredArray = seaTunnelType.getSqlType() == SqlType.ARRAY; + + if (serverIsMulti && !declaredArray) { + // Guides the user to the fix; without this hint, the server's Collection value would + // ClassCastException mid-scan against the scalar row builder. + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Property '%s' has cardinality %s on the server but schema.fields " + + "declares it as '%s'. Declare it as 'array<%s>' to read the " + + "collection, or remove it from schema.fields.", + propertyName, + cardinality, + seaTunnelType, + toSeaTunnelType(propertyDataType, Cardinality.SINGLE, propertyName))); + } + if (declaredArray && !serverIsMulti) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Property '%s' is declared as ARRAY in schema.fields but has " + + "cardinality SINGLE on the server (type %s).", + propertyName, propertyDataType)); + } + SeaTunnelDataType expectedType = + toSeaTunnelType(propertyDataType, cardinality, propertyName); + if (!expectedType.equals(seaTunnelType)) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Type mismatch for property '%s': schema.fields declares '%s', " + + "but HugeGraph type '%s' (cardinality=%s) maps to '%s'", + propertyName, + seaTunnelType, + propertyDataType, + cardinality, + expectedType)); + } + } + + private SeaTunnelDataType toSeaTunnelType( + DataType dataType, Cardinality cardinality, String propertyName) { + return HugeGraphTypeConverter.toSeaTunnelType(dataType, cardinality, propertyName); + } + + private void collectVertices( + List vertices, Collector output, LabelTableContext ctx) { + SeaTunnelRowType outputRowType = ctx.getOutputRowType(); + for (Vertex vertex : vertices) { + String id = String.valueOf(vertex.id()); + if (isAdjacentDuplicate(id)) { + continue; + } + Object[] fields = new Object[outputRowType.getTotalFields()]; + fields[0] = id; + fields[1] = vertex.label(); + fillProperties(vertex.properties(), fields, 2, ctx.getPropertyRowType()); + SeaTunnelRow row = new SeaTunnelRow(fields); + row.setTableId(ctx.getTableId()); + output.collect(row); + } + } + + private void collectEdges( + List edges, Collector output, LabelTableContext ctx) { + SeaTunnelRowType outputRowType = ctx.getOutputRowType(); + for (Edge edge : edges) { + String id = String.valueOf(edge.id()); + if (isAdjacentDuplicate(id)) { + continue; + } + Object[] fields = new Object[outputRowType.getTotalFields()]; + fields[0] = id; + fields[1] = edge.label(); + fields[2] = String.valueOf(edge.sourceId()); + fields[3] = edge.sourceLabel(); + fields[4] = String.valueOf(edge.targetId()); + fields[5] = edge.targetLabel(); + fillProperties(edge.properties(), fields, 6, ctx.getPropertyRowType()); + SeaTunnelRow row = new SeaTunnelRow(fields); + row.setTableId(ctx.getTableId()); + output.collect(row); + } + } + + /** + * The HugeGraph RocksDB backend emits one duplicate record at every internal 500-record scan + * boundary when limit >= 1000 (observed 2001 duplicates per 1M rows, all back-to-back). + * Element IDs are unique within a label, so two consecutive identical IDs can only be that + * server-side paging artifact — skip them with O(1) memory. + */ + private boolean isAdjacentDuplicate(String id) { + if (id.equals(lastEmittedId)) { + duplicateSkipped++; + return true; + } + lastEmittedId = id; + return false; + } + + private void fillProperties( + Map properties, + Object[] fields, + int propertyOffset, + SeaTunnelRowType propertyRowType) { + for (int i = 0; i < propertyRowType.getTotalFields(); i++) { + String propertyName = propertyRowType.getFieldName(i); + fields[propertyOffset + i] = + convertPropertyValue( + properties.get(propertyName), propertyRowType.getFieldType(i)); + } + } + + private Object convertPropertyValue(Object value, SeaTunnelDataType targetType) { + if (value == null) { + return null; + } + switch (targetType.getSqlType()) { + case ARRAY: + return convertArrayValue(value, (ArrayType) targetType); + case TINYINT: + return ((Number) value).byteValue(); + case INT: + return ((Number) value).intValue(); + case BIGINT: + return ((Number) value).longValue(); + case FLOAT: + return ((Number) value).floatValue(); + case DOUBLE: + return ((Number) value).doubleValue(); + case BOOLEAN: + if (value instanceof Boolean) { + return value; + } + return Boolean.parseBoolean(value.toString()); + case BYTES: + if (value instanceof byte[]) { + return value; + } + return Base64.getDecoder().decode(value.toString()); + case TIMESTAMP: + if (value instanceof Date) { + return LocalDateTime.ofInstant(((Date) value).toInstant(), getSourceZoneId()); + } + if (value instanceof Number) { + return LocalDateTime.ofInstant( + new Date(((Number) value).longValue()).toInstant(), getSourceZoneId()); + } + // A server-serialized wall-clock string carries no zone, so time_zone cannot be + // applied here without knowing the server's serialization zone — keep the value + // verbatim (documented on the time_zone option). time_zone applies only to the + // epoch/Date branches above. + return parseDateTime(value.toString()); + case STRING: + return value.toString(); + default: + return value; + } + } + + /** + * Converts a HugeGraph LIST/SET property value (returned as a Collection by the client) into a + * typed SeaTunnel array. HugeGraph 1.5.0 returns LIST as {@code ArrayList} and SET as {@code + * HashSet}; both flow through {@link Collection} here. SET's original insertion order is not + * guaranteed by the server, so callers relying on stable ordering must use LIST. + */ + private Object convertArrayValue(Object value, ArrayType targetType) { + if (!(value instanceof Collection)) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + String.format( + "Expected a Collection for ARRAY property, got %s", + value.getClass().getName())); + } + Collection collection = (Collection) value; + SeaTunnelDataType elementType = targetType.getElementType(); + Object array = Array.newInstance(elementType.getTypeClass(), collection.size()); + int i = 0; + for (Object element : collection) { + Array.set(array, i++, convertPropertyValue(element, elementType)); + } + return array; + } + + private ZoneId getSourceZoneId() { + return sourceConfig.getTimeZone() == null + ? ZoneId.systemDefault() + : ZoneId.of(sourceConfig.getTimeZone()); + } + + /** + * Parses a HugeGraph DATE property returned as a String. The server serializes dates as {@code + * yyyy-MM-dd HH:mm:ss.SSS} (space separator, optional fractional seconds), which {@link + * LocalDateTime#parse} rejects because it only accepts the ISO 'T' separator. Accept both the + * space-separated server format and ISO-8601 (in case a future/config variant emits a 'T'). + */ + private static LocalDateTime parseDateTime(String text) { + try { + return LocalDateTime.parse(text, HUGEGRAPH_DATE_FORMAT); + } catch (DateTimeParseException e) { + return LocalDateTime.parse(text); + } + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceSplit.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceSplit.java new file mode 100644 index 000000000000..957099ca8240 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceSplit.java @@ -0,0 +1,172 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.source; + +import org.apache.seatunnel.api.source.SourceSplit; + +import org.apache.hugegraph.structure.graph.Shard; + +import java.util.Objects; + +/** + * A unit of read work for the HugeGraph source. + * + *

Two modes: + * + *

    + *
  • {@code LABEL_LIST}: a single split that pages the whole label via the server-side list API + * ({@code /vertices?label=&page=}). Used when parallelism == 1; preserves server-side label + * and property-equality filtering. There is exactly one such split per job. + *
  • {@code SHARD}: one split per HugeGraph key-range shard (from {@code + * traverser().vertexShards / edgeShards}). Used when parallelism > 1 so shards can be + * scanned by multiple readers in parallel. The scan API returns all labels in the range, so + * the reader filters by label client-side; server-side property filters are not supported in + * this mode (rejected at the factory). + *
+ * + *

The split also carries resumable progress ({@link #page}, {@link #finished}, {@link + * #lastEmittedId}) so a reader can checkpoint mid-scan and continue from the same page after + * failover. Identity ({@link #equals}/{@link #hashCode}) is on {@link #splitId} only; the mutable + * progress fields are excluded so a split keeps its identity in the enumerator's sets as it + * advances. + */ +public class HugeGraphSourceSplit implements SourceSplit { + + private static final long serialVersionUID = 1L; + + private final String splitId; + private final boolean shardMode; + // The label a LABEL_LIST split pages. null for SHARD splits, which scan all labels in a key + // range and let the reader filter/route by label client-side. + private final String label; + // Shard bounds, only meaningful when shardMode == true. Shard itself is not Serializable, so we + // store its three primitive components and rebuild it on demand. + private final String shardStart; + private final String shardEnd; + private final long shardLength; + + // Resumable progress. page == null means "from the beginning"; the reader sends it as the empty + // string to enter the server's paged mode. + private String page; + private boolean finished; + private String lastEmittedId; + + /** Creates a label-list split that pages exactly {@code label}. */ + public static HugeGraphSourceSplit labelListSplit(String splitId, String label) { + return new HugeGraphSourceSplit(splitId, false, label, null, null, 0L); + } + + /** Creates a shard split (parallelism > 1 path). Scans all labels in the key range. */ + public static HugeGraphSourceSplit shardSplit(String splitId, Shard shard) { + return new HugeGraphSourceSplit( + splitId, true, null, shard.start(), shard.end(), shard.length()); + } + + private HugeGraphSourceSplit( + String splitId, + boolean shardMode, + String label, + String shardStart, + String shardEnd, + long shardLength) { + this.splitId = splitId; + this.shardMode = shardMode; + this.label = label; + this.shardStart = shardStart; + this.shardEnd = shardEnd; + this.shardLength = shardLength; + this.page = null; + this.finished = false; + this.lastEmittedId = null; + } + + @Override + public String splitId() { + return splitId; + } + + public boolean isShardMode() { + return shardMode; + } + + /** The label this split pages; null for shard splits (which scan all labels in a range). */ + public String getLabel() { + return label; + } + + /** Rebuilds the client {@link Shard} for a shard-mode split. */ + public Shard toShard() { + return new Shard(shardStart, shardEnd, shardLength); + } + + public String getPage() { + return page; + } + + public void setPage(String page) { + this.page = page; + } + + public boolean isFinished() { + return finished; + } + + public void setFinished(boolean finished) { + this.finished = finished; + } + + public String getLastEmittedId() { + return lastEmittedId; + } + + public void setLastEmittedId(String lastEmittedId) { + this.lastEmittedId = lastEmittedId; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + return splitId.equals(((HugeGraphSourceSplit) o).splitId); + } + + @Override + public int hashCode() { + return Objects.hash(splitId); + } + + @Override + public String toString() { + return "HugeGraphSourceSplit{" + + "splitId='" + + splitId + + '\'' + + ", shardMode=" + + shardMode + + ", page='" + + page + + '\'' + + ", finished=" + + finished + + '}'; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceSplitEnumerator.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceSplitEnumerator.java new file mode 100644 index 000000000000..042fbde56bde --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceSplitEnumerator.java @@ -0,0 +1,274 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.source; + +import org.apache.seatunnel.api.source.SourceSplitEnumerator; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphOperations; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSourceConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.apache.hugegraph.structure.graph.Shard; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; + +/** + * Assigns read work to HugeGraph source readers. + * + *

Discovery is driven by parallelism: + * + *

    + *
  • parallelism == 1: a single {@code LABEL_LIST} split (server-side label/property filtering + * preserved). + *
  • parallelism > 1: one {@code SHARD} split per key-range shard returned by {@code + * traverser().vertexShards / edgeShards}, so readers scan disjoint ranges in parallel. + *
+ * + *

Splits are assigned exactly once and tracked in {@link #assignedSplits}. On restore the + * enumerator does not re-discover; it assigns only the still-unassigned splits and relies on each + * reader to resume its already-assigned splits from reader state, so no split is read twice. + */ +public class HugeGraphSourceSplitEnumerator + implements SourceSplitEnumerator { + + private static final Logger LOG = LoggerFactory.getLogger(HugeGraphSourceSplitEnumerator.class); + + private final Context context; + private final HugeGraphSourceConfig sourceConfig; + private final long splitSize; + private final Supplier clientFactory; + private final Object lock = new Object(); + + private final Set allSplits = new LinkedHashSet<>(); + private final Set assignedSplits = new HashSet<>(); + private boolean needsDiscovery; + + public HugeGraphSourceSplitEnumerator( + Context context, + HugeGraphSourceConfig sourceConfig, + long splitSize) { + this(context, sourceConfig, splitSize, null); + } + + public HugeGraphSourceSplitEnumerator( + Context context, + HugeGraphSourceConfig sourceConfig, + long splitSize, + HugeGraphSourceState restoredState) { + this( + context, + sourceConfig, + splitSize, + restoredState, + () -> new HugeGraphClient(sourceConfig.getConnectionConfig())); + } + + HugeGraphSourceSplitEnumerator( + Context context, + HugeGraphSourceConfig sourceConfig, + long splitSize, + HugeGraphSourceState restoredState, + Supplier clientFactory) { + this.context = context; + this.sourceConfig = sourceConfig; + this.splitSize = splitSize; + this.clientFactory = clientFactory; + if (restoredState == null) { + this.needsDiscovery = true; + } else { + this.needsDiscovery = false; + this.allSplits.addAll(restoredState.getAllSplits()); + this.assignedSplits.addAll(restoredState.getAssignedSplits()); + } + } + + @Override + public void open() { + if (needsDiscovery) { + synchronized (lock) { + discover(); + needsDiscovery = false; + } + } + } + + private void discover() { + if (sourceConfig.isReadAllLabels()) { + for (String label : sourceConfig.getLabels()) { + allSplits.add(HugeGraphSourceSplit.labelListSplit("label-list-" + label, label)); + } + LOG.info( + "HugeGraph source: read-all-labels, created {} label-list split(s) for {} " + + "labels {}", + allSplits.size(), + sourceConfig.getLabelType(), + sourceConfig.getLabels()); + return; + } + int parallelism = context.currentParallelism(); + + // Runtime guard: the factory-level checkFilterParallelism() reads the per-source + // 'parallelism' option, which does not see env { parallelism = N }. This runtime check + // catches the combination at the last safe point — before any shard splits are created + // — so filter + parallelism > 1 is guaranteed to fail fast. + Map filter = sourceConfig.getFilter(); + boolean hasFilter = filter != null && !filter.isEmpty(); + if (parallelism > 1 && hasFilter) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "HugeGraph source 'filter' cannot be combined with parallelism > 1 " + + "(runtime parallelism is %d): parallel reads use shard " + + "key-range scans that do not support server-side property " + + "filtering. Either set parallelism to 1 to keep the filter, " + + "or remove the filter to read in parallel.", + parallelism)); + } + + if (parallelism <= 1) { + allSplits.add( + HugeGraphSourceSplit.labelListSplit("label-list", sourceConfig.getLabel())); + LOG.info( + "HugeGraph source: parallelism=1, using single label-list split for label '{}'", + sourceConfig.getLabel()); + return; + } + boolean vertex = sourceConfig.getLabelType() == MappingConfig.LabelType.VERTEX; + HugeGraphOperations client = clientFactory.get(); + List shards; + try { + shards = vertex ? client.vertexShards(splitSize) : client.edgeShards(splitSize); + } catch (RuntimeException e) { + // Shard splitting is a scan-capable-backend feature. The in-memory backend rejects + // vertexShards/edgeShards, and the raw server error gives the user no way forward, so + // point them at the parallelism=1 label-list path (the original error is kept as + // cause). + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.GRAPH_OPERATION_FAILED, + String.format( + "Failed to split %s label '%s' into shards for a parallel (parallelism>1) " + + "read. Shard scans require a scan-capable HugeGraph backend " + + "(RocksDB/HBase/Cassandra); the in-memory backend does not " + + "support them. Set parallelism=1 to read via a single " + + "label-list scan instead.", + vertex ? "vertex" : "edge", sourceConfig.getLabel()), + e); + } finally { + client.close(); + } + int index = 0; + for (Shard shard : shards) { + allSplits.add(HugeGraphSourceSplit.shardSplit("shard-" + index, shard)); + index++; + } + LOG.info( + "HugeGraph source: parallelism={}, discovered {} shard split(s) for {} label '{}' " + + "(split_size={})", + parallelism, + allSplits.size(), + vertex ? "vertex" : "edge", + sourceConfig.getLabel(), + splitSize); + } + + @Override + public void run() { + synchronized (lock) { + int parallelism = context.currentParallelism(); + List> perReader = new ArrayList<>(); + for (int i = 0; i < parallelism; i++) { + perReader.add(new ArrayList<>()); + } + int cursor = 0; + for (HugeGraphSourceSplit split : allSplits) { + if (assignedSplits.contains(split)) { + continue; + } + perReader.get(cursor % parallelism).add(split); + cursor++; + } + for (int subtask = 0; subtask < parallelism; subtask++) { + List share = perReader.get(subtask); + context.assignSplit(subtask, share); + assignedSplits.addAll(share); + // Bounded source: tell every reader (even those with no splits) that no more + // splits are coming, so it can finish once it drains what it was assigned. + context.signalNoMoreSplits(subtask); + } + } + } + + @Override + public void addSplitsBack(List splits, int subtaskId) { + if (splits == null || splits.isEmpty()) { + return; + } + synchronized (lock) { + assignedSplits.removeAll(splits); + context.assignSplit(subtaskId, splits); + assignedSplits.addAll(splits); + context.signalNoMoreSplits(subtaskId); + } + } + + @Override + public int currentUnassignedSplitSize() { + synchronized (lock) { + return allSplits.size() - assignedSplits.size(); + } + } + + @Override + public void handleSplitRequest(int subtaskId) { + // Push model: splits are assigned eagerly in run()/addSplitsBack, not on request. + } + + @Override + public void registerReader(int subtaskId) { + // No-op: assignment happens in run(). + } + + @Override + public HugeGraphSourceState snapshotState(long checkpointId) { + synchronized (lock) { + return new HugeGraphSourceState( + new HashSet<>(allSplits), new HashSet<>(assignedSplits)); + } + } + + @Override + public void notifyCheckpointComplete(long checkpointId) { + // No-op. + } + + @Override + public void close() { + // The discovery client is opened and closed within discover(); nothing long-lived to close. + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceState.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceState.java new file mode 100644 index 000000000000..e336528dcd9d --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceState.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.source; + +import java.io.Serializable; +import java.util.Set; + +/** + * Enumerator checkpoint state for the HugeGraph source. + * + *

Persists the full discovered split set plus which splits have already been assigned to + * readers. On restore the enumerator does not re-query shards (which could return different + * boundaries), assigns only the still-unassigned splits, and lets each reader resume its + * already-assigned splits from its own reader state. + */ +public class HugeGraphSourceState implements Serializable { + + private static final long serialVersionUID = 1L; + + private final Set allSplits; + private final Set assignedSplits; + + public HugeGraphSourceState( + Set allSplits, Set assignedSplits) { + this.allSplits = allSplits; + this.assignedSplits = assignedSplits; + } + + public Set getAllSplits() { + return allSplits; + } + + public Set getAssignedSplits() { + return assignedSplits; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/LabelTableContext.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/LabelTableContext.java new file mode 100644 index 000000000000..7593c4e270eb --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/LabelTableContext.java @@ -0,0 +1,69 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.source; + +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Per-label read context for the HugeGraph source. Carries everything the reader needs to turn one + * label's elements into routable rows: the property row type (to fill the property columns), the + * produced row type (reserved columns + properties), and the {@code tableId} string ({@code + * CatalogTable.getTablePath().toString()}) that a downstream MultiTableSink routes on. + * + *

In single-label mode the source builds exactly one context; in read-all mode one per + * discovered label. The reader looks the context up by the split's active label. + */ +public class LabelTableContext implements Serializable { + + private static final long serialVersionUID = 1L; + + private final String label; + private final SeaTunnelRowType propertyRowType; + private final SeaTunnelRowType outputRowType; + private final String tableId; + + public LabelTableContext( + String label, + SeaTunnelRowType propertyRowType, + SeaTunnelRowType outputRowType, + String tableId) { + this.label = Objects.requireNonNull(label); + this.propertyRowType = Objects.requireNonNull(propertyRowType); + this.outputRowType = Objects.requireNonNull(outputRowType); + this.tableId = Objects.requireNonNull(tableId); + } + + public String getLabel() { + return label; + } + + public SeaTunnelRowType getPropertyRowType() { + return propertyRowType; + } + + public SeaTunnelRowType getOutputRowType() { + return outputRowType; + } + + public String getTableId() { + return tableId; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/DataTypeUtil.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/DataTypeUtil.java index 1984a9e2bdec..8c70b03b4727 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/DataTypeUtil.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/DataTypeUtil.java @@ -17,6 +17,7 @@ package org.apache.seatunnel.connectors.seatunnel.hugegraph.utils; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.ListFormat; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; @@ -24,12 +25,14 @@ import org.apache.hugegraph.structure.constant.DataType; import org.apache.hugegraph.structure.schema.PropertyKey; +import java.lang.reflect.Array; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; -import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.Date; import java.util.HashSet; import java.util.LinkedHashSet; @@ -61,6 +64,32 @@ public final class DataTypeUtil { public static Object convert( Object value, PropertyKey propertyKey, String dateFormat, String timeZone) { + return convert( + value, + propertyKey, + dateFormat, + timeZone, + Collections.emptyList(), + new ListFormat()); + } + + public static Object convert( + Object value, + PropertyKey propertyKey, + String dateFormat, + String timeZone, + ListFormat listFormat) { + return convert( + value, propertyKey, dateFormat, timeZone, Collections.emptyList(), listFormat); + } + + public static Object convert( + Object value, + PropertyKey propertyKey, + String dateFormat, + String timeZone, + List extraDateFormats, + ListFormat listFormat) { E.checkArgumentNotNull(value, "The value to be converted can't be null"); String key = propertyKey.name(); @@ -68,10 +97,19 @@ public static Object convert( Cardinality cardinality = propertyKey.cardinality(); switch (cardinality) { case SINGLE: - return parseSingleValue(key, value, dataType, dateFormat, timeZone); + return parseSingleValue( + key, value, dataType, dateFormat, timeZone, extraDateFormats); case SET: case LIST: - return parseMultiValues(key, value, dataType, cardinality, dateFormat, timeZone); + return parseMultiValues( + key, + value, + dataType, + cardinality, + dateFormat, + timeZone, + extraDateFormats, + listFormat); default: throw new HugeGraphConnectorException( HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, @@ -89,25 +127,36 @@ private static Object parseMultiValues( DataType dataType, Cardinality cardinality, String dateFormat, - String timeZone) { - // JSON file should not parse again - if (values instanceof Collection - && checkCollectionDataType(key, (Collection) values, dataType)) { - return values; + String timeZone, + List extraDateFormats, + ListFormat listFormat) { + Collection sourceValues; + if (values instanceof Collection) { + sourceValues = (Collection) values; + } else if (values.getClass().isArray()) { + List arrayValues = new ArrayList<>(Array.getLength(values)); + for (int i = 0; i < Array.getLength(values); i++) { + arrayValues.add(Array.get(values, i)); + } + sourceValues = arrayValues; + } else { + E.checkState( + values instanceof String, + "The value(key='%s') must be a Collection, array, or String type, " + + "but got '%s'(%s)", + key, + values, + values.getClass()); + sourceValues = split(key, (String) values, listFormat); } - E.checkState( - values instanceof String, - "The value(key='%s') must be String type, " + "but got '%s'(%s)", - key, - values); - String rawValue = (String) values; - List valueColl = split(key, rawValue); Collection results = cardinality == Cardinality.LIST ? new ArrayList<>() : new LinkedHashSet<>(); - valueColl.forEach( + sourceValues.forEach( value -> { - results.add(parseSingleValue(key, value, dataType, dateFormat, timeZone)); + results.add( + parseSingleValue( + key, value, dataType, dateFormat, timeZone, extraDateFormats)); }); E.checkArgument( checkCollectionDataType(key, results, dataType), @@ -124,8 +173,15 @@ public static List splitField(String key, Object rawColumnValue) { Collection collection = (Collection) rawColumnValue; return new ArrayList<>(collection); } + if (rawColumnValue.getClass().isArray()) { + List values = new ArrayList<>(Array.getLength(rawColumnValue)); + for (int i = 0; i < Array.getLength(rawColumnValue); i++) { + values.add(Array.get(rawColumnValue, i)); + } + return values; + } String rawValue = rawColumnValue.toString(); - return split(key, rawValue); + return split(key, rawValue, new ListFormat()); } public static UUID parseUUID(String key, Object rawValue) { @@ -150,7 +206,12 @@ public static UUID parseUUID(String key, Object rawValue) { } private static Object parseSingleValue( - String key, Object rawValue, DataType dataType, String dateFormat, String timeZone) { + String key, + Object rawValue, + DataType dataType, + String dateFormat, + String timeZone, + List extraDateFormats) { Object value = trimString(rawValue); if (value == null) { return null; @@ -166,7 +227,7 @@ private static Object parseSingleValue( case BOOLEAN: return parseBoolean(key, value); case DATE: - return parseDate(key, value, dateFormat, timeZone); + return parseDate(key, value, dateFormat, timeZone, extraDateFormats); case UUID: return parseUUID(key, value); default: @@ -308,7 +369,12 @@ private static Date parseDate(String key, Object value) { key, value, value.getClass())); } - private static Date parseDate(String key, Object value, String dateFormat, String timeZone) { + private static Date parseDate( + String key, + Object value, + String dateFormat, + String timeZone, + List extraDateFormats) { if (value instanceof Date) { return (Date) value; } @@ -351,7 +417,21 @@ private static Date parseDate(String key, Object value, String dateFormat, Strin } } - if (dateFormat == null || dateFormat.isEmpty()) { + // Candidate patterns, primary first then the extras — tried in order (deterministic, + // unlike the loader which uses a HashSet), first successful parse wins. + List formats = new ArrayList<>(); + if (dateFormat != null && !dateFormat.isEmpty()) { + formats.add(dateFormat); + } + if (extraDateFormats != null) { + for (String extra : extraDateFormats) { + if (extra != null && !extra.isEmpty()) { + formats.add(extra); + } + } + } + + if (formats.isEmpty()) { // Fallback for when no format is provided. try { return new Date(Long.parseLong(strValue)); @@ -363,19 +443,27 @@ private static Date parseDate(String key, Object value, String dateFormat, Strin } } - try { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern(dateFormat); - LocalDateTime ldt = LocalDateTime.parse(strValue, formatter); - ZonedDateTime zdt = ldt.atZone(zoneId); - return Date.from(zdt.toInstant()); - } catch (Exception e) { - throw new HugeGraphConnectorException( - HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, - String.format( - "Failed to parse date string '%s' with format '%s'", - value, dateFormat), - e); + Exception lastFailure = null; + for (String format : formats) { + try { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern(format); + LocalDateTime ldt; + try { + ldt = LocalDateTime.parse(strValue, formatter); + } catch (java.time.format.DateTimeParseException dateTimeFailure) { + ldt = LocalDate.parse(strValue, formatter).atStartOfDay(); + } + return Date.from(ldt.atZone(zoneId).toInstant()); + } catch (Exception e) { + lastFailure = e; + } } + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Failed to parse date string '%s' with any of the formats %s", + value, formats), + lastFailure); } throw new HugeGraphConnectorException( HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, @@ -384,26 +472,35 @@ private static Date parseDate(String key, Object value, String dateFormat, Strin key, value, value.getClass())); } - private static List split(String key, String rawValue) { + private static List split(String key, String rawValue, ListFormat listFormat) { List valueColl = new ArrayList<>(); if (rawValue == null || rawValue.isEmpty()) { return valueColl; } String value = rawValue.trim(); - String startSymbol = "["; - String endSymbol = "]"; - if (value.startsWith(startSymbol) && value.endsWith(endSymbol)) { + String startSymbol = listFormat.getStartSymbol(); + String endSymbol = listFormat.getEndSymbol(); + if (startSymbol != null + && !startSymbol.isEmpty() + && endSymbol != null + && !endSymbol.isEmpty() + && value.startsWith(startSymbol) + && value.endsWith(endSymbol)) { value = value.substring(startSymbol.length(), value.length() - endSymbol.length()); } - String elemDelimiter = ","; - // TODO: use a configurable list format - com.google.common.base.Splitter.on(elemDelimiter) + Set ignoredElems = new HashSet<>(listFormat.getIgnoredElems()); + com.google.common.base.Splitter.on(listFormat.getElemDelimiter()) .trimResults() .omitEmptyStrings() .split(value) - .forEach(valueColl::add); + .forEach( + elem -> { + if (!ignoredElems.contains(elem)) { + valueColl.add(elem); + } + }); return valueColl; } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/HugeGraphTypeConverter.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/HugeGraphTypeConverter.java new file mode 100644 index 000000000000..efc9e8ebd825 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/HugeGraphTypeConverter.java @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.utils; + +import org.apache.seatunnel.api.table.type.ArrayType; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.LocalTimeType; +import org.apache.seatunnel.api.table.type.PrimitiveByteArrayType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.apache.hugegraph.structure.constant.Cardinality; +import org.apache.hugegraph.structure.constant.DataType; + +/** + * Maps HugeGraph property-key types to SeaTunnel types for the source read path. Shared by schema + * validation (declared vs. server) and schema auto-discovery (deriving the row type from the server + * label when {@code schema} is omitted) so both use identical mapping rules. + */ +public final class HugeGraphTypeConverter { + + private HugeGraphTypeConverter() {} + + /** + * Maps a HugeGraph property type + cardinality to a SeaTunnel type. A {@code LIST}/{@code SET} + * cardinality becomes {@code array}; {@code SINGLE} (or null) stays scalar. The {@code + * propertyName} is used only to make any error message point at the offending column. + */ + public static SeaTunnelDataType toSeaTunnelType( + DataType dataType, Cardinality cardinality, String propertyName) { + SeaTunnelDataType scalar = toSeaTunnelScalarType(dataType, propertyName); + if (cardinality == null || cardinality == Cardinality.SINGLE) { + return scalar; + } + // BLOB elements would produce byte[][], which downstream SeaTunnel operators do not + // uniformly handle — reject with a clear message rather than a mysterious CCE later. + if (dataType == DataType.BLOB) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Property '%s': type BLOB with cardinality %s is not supported for reads.", + propertyName, cardinality)); + } + return ArrayType.of(scalar); + } + + public static SeaTunnelDataType toSeaTunnelScalarType( + DataType dataType, String propertyName) { + switch (dataType) { + case TEXT: + return BasicType.STRING_TYPE; + case BYTE: + return BasicType.BYTE_TYPE; + case INT: + return BasicType.INT_TYPE; + case LONG: + return BasicType.LONG_TYPE; + case FLOAT: + return BasicType.FLOAT_TYPE; + case DOUBLE: + return BasicType.DOUBLE_TYPE; + case BOOLEAN: + return BasicType.BOOLEAN_TYPE; + case DATE: + return LocalTimeType.LOCAL_DATE_TIME_TYPE; + case UUID: + return BasicType.STRING_TYPE; + case OBJECT: + // OBJECT holds an arbitrary serialized value with no fixed shape; read it as its + // string representation so a single such column does not block the whole label + // read. + return BasicType.STRING_TYPE; + case BLOB: + return PrimitiveByteArrayType.INSTANCE; + default: + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Property '%s': unsupported HugeGraph property type for source: %s", + propertyName, dataType)); + } + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/SchemaManager.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/SchemaManager.java new file mode 100644 index 000000000000..bfed20496b65 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/SchemaManager.java @@ -0,0 +1,555 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.utils; + +import org.apache.seatunnel.api.table.type.ArrayType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSchemaSaveMode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.LabelOptions; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig.LabelType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.ReservedColumns; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.apache.hugegraph.structure.constant.Cardinality; +import org.apache.hugegraph.structure.constant.DataType; +import org.apache.hugegraph.structure.constant.Frequency; +import org.apache.hugegraph.structure.constant.IdStrategy; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Manages HugeGraph schema lifecycle during Sink initialization. Handles auto-creation under + * CREATE_SCHEMA_WHEN_NOT_EXIST and strict validation under ERROR_WHEN_SCHEMA_NOT_EXIST. + */ +public final class SchemaManager { + + private static final Logger LOG = LoggerFactory.getLogger(SchemaManager.class); + + private final HugeGraphClient client; + private final HugeGraphSchemaSaveMode saveMode; + private final SeaTunnelRowType rowType; + + public SchemaManager( + HugeGraphClient client, HugeGraphSchemaSaveMode saveMode, SeaTunnelRowType rowType) { + this.client = client; + this.saveMode = saveMode; + this.rowType = rowType; + } + + /** + * Ensures schema exists for all mappings. Creation order: PropertyKeys first, then + * VertexLabels, then EdgeLabels — so edge source/target labels exist before edge creation. + */ + public void ensureSchema(List mappings) { + if (saveMode == HugeGraphSchemaSaveMode.CREATE_SCHEMA_WHEN_NOT_EXIST) { + // Phase 1: create all PropertyKeys + for (MappingConfig mapping : mappings) { + Set propertyNames = resolveTargetPropertyNames(mapping); + createMissingPropertyKeys(mapping, propertyNames); + if (mapping.getType() == LabelType.EDGE) { + createMissingPropertyKeys(mapping, resolveEndpointPropertyNames(mapping)); + } + } + // Phase 2: create all VertexLabels + Set resolvedVertexLabels = new HashSet<>(); + for (MappingConfig mapping : mappings) { + if (mapping.getType() == LabelType.VERTEX) { + Set propertyNames = resolveTargetPropertyNames(mapping); + createVertexLabelIfMissing(mapping, propertyNames); + resolvedVertexLabels.add(mapping.getLabel()); + } + } + // Edge-only mappings still need reconstructable endpoint vertex schemas. + for (MappingConfig mapping : mappings) { + if (mapping.getType() == LabelType.EDGE) { + createEndpointVertexLabelIfMissing( + mapping, mapping.getSourceConfig(), resolvedVertexLabels); + createEndpointVertexLabelIfMissing( + mapping, mapping.getTargetConfig(), resolvedVertexLabels); + } + } + // Phase 3: create all EdgeLabels (source/target vertex labels now guaranteed to exist) + for (MappingConfig mapping : mappings) { + if (mapping.getType() == LabelType.EDGE) { + Set propertyNames = resolveTargetPropertyNames(mapping); + createEdgeLabelIfMissing(mapping, propertyNames); + } + } + } else { + for (MappingConfig mapping : mappings) { + Set propertyNames = resolveTargetPropertyNames(mapping); + validateSchemaExists(mapping, propertyNames); + } + } + } + + private Set resolveEndpointPropertyNames(MappingConfig mapping) { + Set propertyNames = new HashSet<>(); + addEndpointPropertyNames(mapping.getSourceConfig(), mapping, propertyNames); + addEndpointPropertyNames(mapping.getTargetConfig(), mapping, propertyNames); + return propertyNames; + } + + private void addEndpointPropertyNames( + MappingConfig.SourceTargetConfig endpoint, + MappingConfig mapping, + Set propertyNames) { + if (endpoint == null || ReservedColumns.isRawIdPassthrough(endpoint.getIdFields())) { + // Raw-id passthrough reuses a reserved column (~source_id/~target_id) that is not a + // HugeGraph property — never create a PropertyKey for it. + return; + } + List properties = + mapToTargetNames(endpoint.getIdFields(), mapping.getFieldMapping()); + if (properties != null) { + propertyNames.addAll(properties); + } + } + + private void createEndpointVertexLabelIfMissing( + MappingConfig edgeMapping, + MappingConfig.SourceTargetConfig endpoint, + Set resolvedVertexLabels) { + if (endpoint == null || resolvedVertexLabels.contains(endpoint.getLabel())) { + return; + } + if (endpoint.getIdFields() == null || endpoint.getIdFields().isEmpty()) { + return; + } + // Raw-id passthrough carries a pre-assembled endpoint id, not primary-key columns, so we + // cannot synthesize a PRIMARY_KEY vertex label from it. Such a clone assumes the endpoint + // vertex label already exists; leave creation to the user. + if (ReservedColumns.isRawIdPassthrough(endpoint.getIdFields())) { + return; + } + if (client.getVertexLabelOrNull(endpoint.getLabel()) != null) { + resolvedVertexLabels.add(endpoint.getLabel()); + return; + } + + List primaryKeys = + mapToTargetNames(endpoint.getIdFields(), edgeMapping.getFieldMapping()); + LOG.info( + "Mapping[EDGE/{}]: Auto-creating endpoint VertexLabel '{}' with PRIMARY_KEY fields={}", + edgeMapping.getLabel(), + endpoint.getLabel(), + primaryKeys); + client.createVertexLabelIfNotExist( + endpoint.getLabel(), + IdStrategy.PRIMARY_KEY, + primaryKeys, + new ArrayList<>(primaryKeys), + new ArrayList<>(), + new LabelOptions(null, null, null, null)); + resolvedVertexLabels.add(endpoint.getLabel()); + } + + /** Resolves the set of target property names that will be written for this mapping. */ + private Set resolveTargetPropertyNames(MappingConfig mapping) { + Set result = new HashSet<>(); + Map fieldMapping = mapping.getFieldMapping(); + + Set sourceFields = new HashSet<>(); + if (mapping.getProperties().isEmpty()) { + for (String fieldName : rowType.getFieldNames()) { + // Reserved columns emitted by HugeGraph Source (~id, ~label, ...) are not valid + // HugeGraph property key names — HugeGraph rejects PropertyKey names starting + // with '~'. An implicit Source→Sink round-trip would otherwise attempt to create + // them and fail at label creation time. + if (fieldName != null && !fieldName.startsWith("~")) { + sourceFields.add(fieldName); + } + } + if (mapping.getType() == LabelType.EDGE) { + removeIdFields(sourceFields, mapping.getSourceConfig()); + removeIdFields(sourceFields, mapping.getTargetConfig()); + } + // `ignored` blacklist only applies in implicit mode; must match the mappers so schema + // creation and writes agree on the property set. + sourceFields.removeAll(mapping.getIgnored()); + } else { + sourceFields.addAll(mapping.getProperties()); + } + if (mapping.getType() == LabelType.EDGE) { + sourceFields.addAll(mapping.getSortKeys()); + } + + for (String sourceField : sourceFields) { + String targetProp = fieldMapping.getOrDefault(sourceField, sourceField); + result.add(targetProp); + } + + // PRIMARY_KEY idFields are always written as properties by VertexMapper, + // so they must be included regardless of whether properties list is empty + if (mapping.getType() == LabelType.VERTEX + && mapping.getIdStrategy() == IdStrategy.PRIMARY_KEY + && mapping.getIdFields() != null) { + for (String idField : mapping.getIdFields()) { + String targetProp = fieldMapping.getOrDefault(idField, idField); + result.add(targetProp); + } + } + + return result; + } + + private static void removeIdFields( + Set fields, MappingConfig.SourceTargetConfig sourceTargetConfig) { + if (sourceTargetConfig != null && sourceTargetConfig.getIdFields() != null) { + fields.removeAll(sourceTargetConfig.getIdFields()); + } + } + + private void createMissingPropertyKeys(MappingConfig mapping, Set targetPropertyNames) { + Map fieldMapping = mapping.getFieldMapping(); + + for (String targetProp : targetPropertyNames) { + if (client.getPropertyKeyOrNull(targetProp) != null) { + continue; + } + + String sourceField = findSourceField(targetProp, fieldMapping); + int fieldIndex = findFieldIndex(sourceField); + if (fieldIndex < 0) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[%s/%s]: Source field '%s' for target property '%s' " + + "not found in input row. Available fields: %s", + mapping.getType(), + mapping.getLabel(), + sourceField, + targetProp, + getFieldNames())); + } + + SeaTunnelDataType seaType = rowType.getFieldType(fieldIndex); + DataType hgType = inferHugeGraphDataType(seaType, mapping, targetProp); + Cardinality cardinality = inferCardinality(seaType); + + LOG.info( + "Mapping[{}/{}]: Auto-creating PropertyKey '{}' with type={}, cardinality={}", + mapping.getType(), + mapping.getLabel(), + targetProp, + hgType, + cardinality); + client.createPropertyKeyIfNotExist(targetProp, hgType, cardinality); + } + } + + private void createVertexLabelIfMissing(MappingConfig mapping, Set propertyNames) { + if (client.getVertexLabelOrNull(mapping.getLabel()) != null) { + LOG.debug("VertexLabel '{}' already exists, skipping creation.", mapping.getLabel()); + return; + } + + IdStrategy idStrategy = + mapping.getIdStrategy() != null ? mapping.getIdStrategy() : IdStrategy.PRIMARY_KEY; + // Primary keys must reference target property names (after fieldMapping), matching the + // property names used for label creation + List primaryKeys = + idStrategy == IdStrategy.PRIMARY_KEY + ? mapToTargetNames(mapping.getIdFields(), mapping.getFieldMapping()) + : null; + + List nullableKeys = computeNullableKeys(mapping, propertyNames); + + LOG.info( + "Mapping[VERTEX/{}]: Auto-creating VertexLabel with idStrategy={}, properties={}", + mapping.getLabel(), + idStrategy, + propertyNames); + client.createVertexLabelIfNotExist( + mapping.getLabel(), + idStrategy, + primaryKeys, + new ArrayList<>(propertyNames), + nullableKeys, + buildLabelOptions(mapping)); + } + + private void createEdgeLabelIfMissing(MappingConfig mapping, Set propertyNames) { + if (client.getEdgeLabelOrNull(mapping.getLabel()) != null) { + LOG.debug("EdgeLabel '{}' already exists, skipping creation.", mapping.getLabel()); + return; + } + + E.checkNotNull(mapping.getSourceConfig(), "sourceConfig", "edge mapping"); + E.checkNotNull(mapping.getTargetConfig(), "targetConfig", "edge mapping"); + + Frequency frequency = + mapping.getFrequency() != null ? mapping.getFrequency() : Frequency.SINGLE; + // Sort keys must reference target property names (after fieldMapping) + List sortKeys = + frequency == Frequency.MULTIPLE + ? mapToTargetNames(mapping.getSortKeys(), mapping.getFieldMapping()) + : null; + + if (frequency == Frequency.MULTIPLE && (sortKeys == null || sortKeys.isEmpty())) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Mapping[EDGE/%s]: 'sortKeys' must be specified when frequency is MULTIPLE.", + mapping.getLabel())); + } + + List nullableKeys = computeNullableKeys(mapping, propertyNames); + + LOG.info( + "Mapping[EDGE/{}]: Auto-creating EdgeLabel ({}→{}) with frequency={}, properties={}", + mapping.getLabel(), + mapping.getSourceConfig().getLabel(), + mapping.getTargetConfig().getLabel(), + frequency, + propertyNames); + client.createEdgeLabelIfNotExist( + mapping.getLabel(), + mapping.getSourceConfig().getLabel(), + mapping.getTargetConfig().getLabel(), + frequency, + sortKeys, + new ArrayList<>(propertyNames), + nullableKeys, + buildLabelOptions(mapping)); + } + + /** + * Collects the optional label attributes (ttl / ttlStartTime / enableLabelIndex / userdata) + * from the mapping so they are actually applied at label creation instead of silently ignored. + */ + private LabelOptions buildLabelOptions(MappingConfig mapping) { + Boolean enableLabelIndex = + mapping.getEnableLabelIndex() == null + ? null + : Boolean.parseBoolean(mapping.getEnableLabelIndex()); + return new LabelOptions( + mapping.getTtl(), + mapping.getTtlStartTime(), + enableLabelIndex, + mapping.getUserdata()); + } + + private void validateSchemaExists(MappingConfig mapping, Set targetPropertyNames) { + if (mapping.getType() == LabelType.VERTEX) { + if (client.getVertexLabelOrNull(mapping.getLabel()) == null) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[VERTEX/%s]: VertexLabel does not exist in HugeGraph. " + + "Create it manually or set schema_save_mode=CREATE_SCHEMA_WHEN_NOT_EXIST.", + mapping.getLabel())); + } + } else { + if (client.getEdgeLabelOrNull(mapping.getLabel()) == null) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[EDGE/%s]: EdgeLabel does not exist in HugeGraph. " + + "Create it manually or set schema_save_mode=CREATE_SCHEMA_WHEN_NOT_EXIST.", + mapping.getLabel())); + } + } + + for (String propName : targetPropertyNames) { + if (client.getPropertyKeyOrNull(propName) == null) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[%s/%s]: PropertyKey '%s' does not exist in HugeGraph. " + + "Create it manually or set schema_save_mode=CREATE_SCHEMA_WHEN_NOT_EXIST.", + mapping.getType(), mapping.getLabel(), propName)); + } + } + } + + // --- Type inference --- + + private DataType inferHugeGraphDataType( + SeaTunnelDataType seaType, MappingConfig mapping, String propertyName) { + switch (seaType.getSqlType()) { + case STRING: + return DataType.TEXT; + case BIGINT: + return DataType.LONG; + case INT: + case TINYINT: + case SMALLINT: + return DataType.INT; + case FLOAT: + return DataType.FLOAT; + case DOUBLE: + return DataType.DOUBLE; + case BOOLEAN: + return DataType.BOOLEAN; + case DATE: + case TIMESTAMP: + return DataType.DATE; + case BYTES: + return DataType.BLOB; + case ARRAY: + SeaTunnelDataType elementType = ((ArrayType) seaType).getElementType(); + return inferHugeGraphDataType(elementType, mapping, propertyName); + case MAP: + case ROW: + case DECIMAL: + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[%s/%s]: Source field for target property '%s' has unsupported " + + "SeaTunnel type '%s' and cannot be auto-created in HugeGraph. " + + "Pre-create the PropertyKey with an appropriate representation " + + "(for example TEXT for serialized data), or use a Transform to " + + "convert the field before the HugeGraph sink.", + mapping.getType(), + mapping.getLabel(), + propertyName, + seaType.getSqlType())); + default: + return DataType.TEXT; + } + } + + private Cardinality inferCardinality(SeaTunnelDataType seaType) { + if (seaType.getSqlType() == org.apache.seatunnel.api.table.type.SqlType.ARRAY) { + return Cardinality.LIST; + } + return Cardinality.SINGLE; + } + + // --- Helpers --- + + /** Maps source field names to target property names via fieldMapping. */ + private static List mapToTargetNames( + List sourceFields, Map fieldMapping) { + if (sourceFields == null) { + return null; + } + List result = new ArrayList<>(sourceFields.size()); + for (String field : sourceFields) { + result.add(fieldMapping.getOrDefault(field, field)); + } + return result; + } + + private String findSourceField(String targetProp, Map fieldMapping) { + for (Map.Entry entry : fieldMapping.entrySet()) { + if (targetProp.equals(entry.getValue())) { + return entry.getKey(); + } + } + return targetProp; + } + + private int findFieldIndex(String fieldName) { + for (int i = 0; i < rowType.getTotalFields(); i++) { + if (rowType.getFieldName(i).equals(fieldName)) { + return i; + } + } + return -1; + } + + private String getFieldNames() { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < rowType.getTotalFields(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(rowType.getFieldName(i)); + } + return sb.append("]").toString(); + } + + /** + * Decides which target properties are declared nullable on a newly created label. + * + *

Default (when the mapping declares neither {@code nullableKeys} nor {@code + * notNullableKeys}): every non-key property is nullable. HugeGraph server rejects any insert + * whose row omits a non-nullable property, so the previous "everything non-null" default meant + * a single null cell from JDBC/CSV/Kafka failed an entire batch. Loader and spark-connector + * both default to nullable non-key properties; this matches them. + * + *

Key properties — primary keys (PRIMARY_KEY vertices) and MULTIPLE-edge sort keys — are + * always excluded because HugeGraph server disallows them being nullable. + * + *

Explicit {@code nullableKeys} wins verbatim (subject to key-exclusion + presence + * filtering). If it is empty, {@code notNullableKeys} carves out required-property opt-outs + * from the default. + */ + static List computeNullableKeys(MappingConfig mapping, Set propertyNames) { + Map fm = mapping.getFieldMapping(); + Set keyProps = computeKeyProperties(mapping); + + List explicit = mapping.getNullableKeys(); + if (!explicit.isEmpty()) { + List result = new ArrayList<>(); + for (String nk : explicit) { + String targetName = fm.getOrDefault(nk, nk); + if (propertyNames.contains(targetName) && !keyProps.contains(targetName)) { + result.add(targetName); + } + } + return result; + } + + Set notNullableTargets = new HashSet<>(); + for (String nk : mapping.getNotNullableKeys()) { + notNullableTargets.add(fm.getOrDefault(nk, nk)); + } + + List result = new ArrayList<>(); + for (String propName : propertyNames) { + if (!keyProps.contains(propName) && !notNullableTargets.contains(propName)) { + result.add(propName); + } + } + return result; + } + + private static Set computeKeyProperties(MappingConfig mapping) { + Set keys = new HashSet<>(); + Map fm = mapping.getFieldMapping(); + if (mapping.getType() == LabelType.VERTEX + && mapping.getIdStrategy() == IdStrategy.PRIMARY_KEY + && mapping.getIdFields() != null) { + for (String field : mapping.getIdFields()) { + keys.add(fm.getOrDefault(field, field)); + } + } + if (mapping.getType() == LabelType.EDGE + && mapping.getFrequency() == Frequency.MULTIPLE + && mapping.getSortKeys() != null) { + for (String field : mapping.getSortKeys()) { + keys.add(fm.getOrDefault(field, field)); + } + } + return keys; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/SchemaValidator.java b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/SchemaValidator.java index 52cd49ddfde2..630e9d0a1b1f 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/SchemaValidator.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/main/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/SchemaValidator.java @@ -21,142 +21,475 @@ import org.apache.seatunnel.api.table.type.SeaTunnelDataType; import org.apache.seatunnel.api.table.type.SeaTunnelRowType; import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; -import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSinkConfig; import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; -import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.SchemaConfig; -import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.SchemaConfig.LabelType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig.LabelType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.ReservedColumns; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorErrorCode; import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; import org.apache.hugegraph.structure.constant.Cardinality; import org.apache.hugegraph.structure.constant.DataType; +import org.apache.hugegraph.structure.constant.Frequency; +import org.apache.hugegraph.structure.constant.IdStrategy; import org.apache.hugegraph.structure.schema.EdgeLabel; import org.apache.hugegraph.structure.schema.PropertyKey; import org.apache.hugegraph.structure.schema.VertexLabel; -import java.util.Collections; +import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; -/** Validates the SeaTunnel schema against the HugeGraph schema. */ +/** + * Validates the connector configuration against the HugeGraph server schema. Validation is + * per-mapping: only fields involved in each mapping are checked. + */ public final class SchemaValidator { - private final HugeGraphSinkConfig sinkConfig; - private final SeaTunnelRowType rowType; private final HugeGraphClient client; + private final SeaTunnelRowType rowType; - public SchemaValidator(HugeGraphSinkConfig config, SeaTunnelRowType rowType) { - this.sinkConfig = config; + public SchemaValidator(HugeGraphClient client, SeaTunnelRowType rowType) { + this.client = client; this.rowType = rowType; - this.client = new HugeGraphClient(sinkConfig); } - public void validateSchema() { - try { - SchemaConfig schemaConfig = sinkConfig.getSchemaConfig(); - if (schemaConfig.getType() == LabelType.VERTEX) { - validateVertex(schemaConfig); - } else if (schemaConfig.getType() == LabelType.EDGE) { - validateEdge(schemaConfig); + public void validate(List mappings) { + for (MappingConfig mapping : mappings) { + validateMapping(mapping); + } + } + + /** + * Runs only the config-level checks that do not touch the server, so a job with a malformed + * mapping fails before any schema is persisted to HugeGraph. HugeGraph label DDL is + * non-transactional and its primary keys / sort keys / frequency are effectively immutable, so + * persisting a property key or vertex label and then failing config validation would leave a + * schema fragment the user cannot fix in place. + */ + public void validateConfigOnly(List mappings) { + for (MappingConfig mapping : mappings) { + validateMappingConfig(mapping); + } + } + + /** + * Fails fast — BEFORE any schema is created — when a mapping targets a label that already + * exists on the server with incompatible immutable attributes (vertex id strategy / primary + * keys, edge frequency / sort keys / endpoints). HugeGraph cannot ALTER these attributes, and + * {@code ensureSchema} creates the PropertyKeys and labels for the other mappings + * first — so catching such a mismatch only in the post-create {@link #validate} would leave + * those creations behind as schema pollution and trap the user in a retry loop that never + * reconciles. Running this read-only check up front means an incompatible pre-existing label + * aborts the job with zero new writes; the fix is to drop that label on the server and re-run. + * + *

Only labels that already exist are inspected; missing ones are left for {@code + * ensureSchema} to create. Edge endpoint identity is intentionally not checked here (the + * endpoint vertex labels may not exist yet under CREATE_SCHEMA_WHEN_NOT_EXIST); it is verified + * afterwards by {@link #validate}. + */ + public void validateExistingLabels(List mappings) { + for (MappingConfig mapping : mappings) { + if (mapping.getType() == LabelType.VERTEX) { + if (client.getVertexLabelOrNull(mapping.getLabel()) != null) { + validateVertexMapping(mapping); + } } else { - throw new HugeGraphConnectorException( - HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, - "Unsupported schema type: " + schemaConfig.getType()); + EdgeLabel existing = client.getEdgeLabelOrNull(mapping.getLabel()); + if (existing != null) { + validateExistingEdgeLabel(mapping, existing); + } } - } catch (Exception e) { - throw e; - } finally { - client.close(); } } - private void validateVertex(SchemaConfig schemaConfig) { - String label = schemaConfig.getLabel(); - VertexLabel vertexLabel = this.client.getVertexLabel(label); - if (vertexLabel == null) { + /** + * Checks an already-existing EdgeLabel's immutable attributes (frequency, sort keys, source / + * target labels) against the config. Deliberately omits the endpoint-vertex identity checks + * that {@link #validateEdgeMapping} performs, because those require the endpoint labels to + * already exist — which is not guaranteed before {@code ensureSchema} runs. + */ + private void validateExistingEdgeLabel(MappingConfig mapping, EdgeLabel edgeLabel) { + String label = mapping.getLabel(); + Frequency configuredFrequency = + mapping.getFrequency() == null ? Frequency.SINGLE : mapping.getFrequency(); + if (edgeLabel.frequency() != configuredFrequency) { throw new HugeGraphConnectorException( HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, - String.format("Vertex label '%s' does not exist in HugeGraph.", label)); + String.format( + "Mapping[EDGE/%s]: frequency mismatch — server='%s', config='%s'. The " + + "EdgeLabel already exists with an immutable frequency; drop it " + + "on the server before re-running.", + label, edgeLabel.frequency(), configuredFrequency)); + } + List configuredSortKeys = + configuredFrequency == Frequency.MULTIPLE + ? mapToTargetNames(mapping.getSortKeys(), mapping.getFieldMapping()) + : java.util.Collections.emptyList(); + if (!edgeLabel.sortKeys().equals(configuredSortKeys)) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[EDGE/%s]: sort key mismatch — server='%s', config='%s'. The " + + "EdgeLabel already exists with immutable sort keys; drop it on " + + "the server before re-running.", + label, edgeLabel.sortKeys(), configuredSortKeys)); + } + if (!edgeLabel.sourceLabel().equals(mapping.getSourceConfig().getLabel())) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[EDGE/%s]: sourceLabel mismatch — server='%s', config='%s'. The " + + "EdgeLabel already exists; drop it on the server before re-running.", + label, edgeLabel.sourceLabel(), mapping.getSourceConfig().getLabel())); + } + if (!edgeLabel.targetLabel().equals(mapping.getTargetConfig().getLabel())) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[EDGE/%s]: targetLabel mismatch — server='%s', config='%s'. The " + + "EdgeLabel already exists; drop it on the server before re-running.", + label, edgeLabel.targetLabel(), mapping.getTargetConfig().getLabel())); + } + } + + private void validateMapping(MappingConfig mapping) { + validateMappingConfig(mapping); + if (mapping.getType() == LabelType.VERTEX) { + validateVertexMapping(mapping); + } else { + validateEdgeMapping(mapping); } - validateLabelProperties(label, schemaConfig, vertexLabel.properties()); } - private void validateEdge(SchemaConfig schemaConfig) { - String label = schemaConfig.getLabel(); - EdgeLabel edgeLabel = this.client.getEdgeLabel(label); - if (edgeLabel == null) { + private void validateMappingConfig(MappingConfig mapping) { + E.checkNotNull(mapping.getType(), "type", "mapping"); + E.checkNotNull(mapping.getLabel(), "label", "mapping"); + + // nullableKeys and notNullableKeys are two opposite ways to steer nullability of an + // auto-created label. Setting both is ambiguous — notNullableKeys is silently ignored once + // an explicit nullableKeys allow-list is present — so reject it up front instead. + if (!mapping.getNullableKeys().isEmpty() && !mapping.getNotNullableKeys().isEmpty()) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Mapping[%s/%s]: 'nullableKeys' and 'notNullableKeys' are mutually " + + "exclusive — set at most one.", + mapping.getType(), mapping.getLabel())); + } + + // `properties` (selected whitelist) and `ignored` (blacklist) are opposite ways to choose + // the property set; setting both is ambiguous. + if (!mapping.getProperties().isEmpty() && !mapping.getIgnored().isEmpty()) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Mapping[%s/%s]: 'properties' (selected) and 'ignored' are mutually " + + "exclusive — set at most one.", + mapping.getType(), mapping.getLabel())); + } + validateSourceFields(mapping, mapping.getIgnored(), "ignored"); + + if (mapping.getType() == LabelType.VERTEX) { + E.checkNotNull( + mapping.getIdStrategy(), + "idStrategy", + String.format("mapping[VERTEX/%s]", mapping.getLabel())); + if (mapping.getIdStrategy() != IdStrategy.AUTOMATIC) { + E.checkNotEmpty( + mapping.getIdFields(), + "idFields", + String.format("mapping[VERTEX/%s]", mapping.getLabel())); + validateSourceFields(mapping, mapping.getIdFields(), "idFields"); + // A vertex that reuses the reserved ~id column supplies the id externally, which + // only CUSTOMIZE_* strategies accept. PRIMARY_KEY derives its id from property + // values (use those columns instead) and AUTOMATIC is server-assigned. + if (ReservedColumns.isRawIdPassthrough(mapping.getIdFields()) + && mapping.getIdStrategy() != IdStrategy.CUSTOMIZE_STRING + && mapping.getIdStrategy() != IdStrategy.CUSTOMIZE_NUMBER + && mapping.getIdStrategy() != IdStrategy.CUSTOMIZE_UUID) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Mapping[VERTEX/%s]: idFields '%s' (raw-id passthrough) requires a " + + "CUSTOMIZE_STRING/NUMBER/UUID id strategy, but got '%s'.", + mapping.getLabel(), + mapping.getIdFields().get(0), + mapping.getIdStrategy())); + } + } + if (mapping.isUnfold()) { + validateUnfoldable( + mapping, + mapping.getIdStrategy(), + mapping.getIdFields(), + String.format("mapping[VERTEX/%s]", mapping.getLabel())); + } + } else { + E.checkNotNull( + mapping.getSourceConfig(), + "sourceConfig", + String.format("mapping[EDGE/%s]", mapping.getLabel())); + E.checkNotNull( + mapping.getTargetConfig(), + "targetConfig", + String.format("mapping[EDGE/%s]", mapping.getLabel())); + E.checkNotNull( + mapping.getSourceConfig().getLabel(), + "sourceConfig.label", + String.format("mapping[EDGE/%s]", mapping.getLabel())); + E.checkNotNull( + mapping.getTargetConfig().getLabel(), + "targetConfig.label", + String.format("mapping[EDGE/%s]", mapping.getLabel())); + E.checkNotEmpty( + mapping.getSourceConfig().getIdFields(), + "sourceConfig.idFields", + String.format("mapping[EDGE/%s]", mapping.getLabel())); + E.checkNotEmpty( + mapping.getTargetConfig().getIdFields(), + "targetConfig.idFields", + String.format("mapping[EDGE/%s]", mapping.getLabel())); + validateSourceFields( + mapping, mapping.getSourceConfig().getIdFields(), "sourceConfig.idFields"); + validateSourceFields( + mapping, mapping.getTargetConfig().getIdFields(), "targetConfig.idFields"); + + if (mapping.getFrequency() == Frequency.MULTIPLE) { + E.checkNotEmpty( + mapping.getSortKeys(), + "sortKeys", + String.format( + "mapping[EDGE/%s] with frequency=MULTIPLE", mapping.getLabel())); + validateSourceFields(mapping, mapping.getSortKeys(), "sortKeys"); + } + // Endpoint id strategy lives on the server vertex label (unknown at config time), so + // here we only enforce the config-derivable rules; the CUSTOMIZE-endpoint requirement + // is + // enforced at runtime when building ids. + if (mapping.isUnfoldSource()) { + validateUnfoldable( + mapping, + null, + mapping.getSourceConfig().getIdFields(), + String.format("mapping[EDGE/%s] sourceConfig", mapping.getLabel())); + } + if (mapping.isUnfoldTarget()) { + validateUnfoldable( + mapping, + null, + mapping.getTargetConfig().getIdFields(), + String.format("mapping[EDGE/%s] targetConfig", mapping.getLabel())); + } + } + validateSourceFields(mapping, mapping.getProperties(), "properties"); + } + + /** + * unfold expands a single list-valued id cell into multiple elements, so it requires exactly + * one id field and cannot be combined with raw-id passthrough. When {@code strategy} is known + * (vertex), it must be a CUSTOMIZE_* strategy; for edge endpoints the strategy is server-side + * and checked when ids are built. + */ + private static void validateUnfoldable( + MappingConfig mapping, IdStrategy strategy, List idFields, String context) { + if (idFields == null || idFields.size() != 1) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "%s: unfold requires exactly one id field, but got %s.", + context, idFields)); + } + if (ReservedColumns.isRawIdPassthrough(idFields)) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "%s: unfold cannot be combined with raw-id passthrough (%s).", + context, idFields.get(0))); + } + if (strategy != null + && strategy != IdStrategy.CUSTOMIZE_STRING + && strategy != IdStrategy.CUSTOMIZE_NUMBER + && strategy != IdStrategy.CUSTOMIZE_UUID) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "%s: unfold requires a CUSTOMIZE_STRING/NUMBER/UUID id strategy, but got '%s'.", + context, strategy)); + } + } + + private void validateVertexMapping(MappingConfig mapping) { + String label = mapping.getLabel(); + VertexLabel vertexLabel = client.getVertexLabel(label); + if (vertexLabel.idStrategy() != mapping.getIdStrategy()) { throw new HugeGraphConnectorException( HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, - String.format("Edge label '%s' does not exist in HugeGraph.", label)); + String.format( + "Mapping[VERTEX/%s]: idStrategy mismatch — server='%s', config='%s'", + label, vertexLabel.idStrategy(), mapping.getIdStrategy())); + } + if (mapping.getIdStrategy() == IdStrategy.PRIMARY_KEY) { + List configuredPrimaryKeys = + mapToTargetNames(mapping.getIdFields(), mapping.getFieldMapping()); + if (!vertexLabel.primaryKeys().equals(configuredPrimaryKeys)) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[VERTEX/%s]: primary key mismatch — server='%s', config='%s'", + label, vertexLabel.primaryKeys(), configuredPrimaryKeys)); + } + } + + Set hgProperties = vertexLabel.properties(); + Set targetProperties = resolveTargetProperties(mapping); + + // PRIMARY_KEY idFields are always included + if (mapping.getIdStrategy() == IdStrategy.PRIMARY_KEY && mapping.getIdFields() != null) { + Map fm = mapping.getFieldMapping(); + for (String idField : mapping.getIdFields()) { + targetProperties.add(fm.getOrDefault(idField, idField)); + } + } + + for (String propName : targetProperties) { + validateProperty(label, propName, hgProperties, mapping); } - validateSourceTarget(schemaConfig, edgeLabel); - validateLabelProperties(label, schemaConfig, edgeLabel.properties()); } - private void validateSourceTarget(SchemaConfig schemaConfig, EdgeLabel edgeLabel) { - String label = schemaConfig.getLabel(); - String schemaSource = edgeLabel.sourceLabel(); - if (!schemaSource.equals(schemaConfig.getSourceConfig().getLabel())) { + private void validateEdgeMapping(MappingConfig mapping) { + String label = mapping.getLabel(); + EdgeLabel edgeLabel = client.getEdgeLabel(label); + Frequency configuredFrequency = + mapping.getFrequency() == null ? Frequency.SINGLE : mapping.getFrequency(); + if (edgeLabel.frequency() != configuredFrequency) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[EDGE/%s]: frequency mismatch — server='%s', config='%s'", + label, edgeLabel.frequency(), configuredFrequency)); + } + List configuredSortKeys = + configuredFrequency == Frequency.MULTIPLE + ? mapToTargetNames(mapping.getSortKeys(), mapping.getFieldMapping()) + : java.util.Collections.emptyList(); + if (!edgeLabel.sortKeys().equals(configuredSortKeys)) { throw new HugeGraphConnectorException( HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, String.format( - "EdgeLabel[%s] sourceLabel mismatch: schema=%s, config=%s", - label, schemaSource, schemaConfig.getSourceConfig())); + "Mapping[EDGE/%s]: sort key mismatch — server='%s', config='%s'", + label, edgeLabel.sortKeys(), configuredSortKeys)); } - String schemaTarget = edgeLabel.targetLabel(); - if (!schemaTarget.equals(schemaConfig.getTargetConfig().getLabel())) { + // Validate source/target labels match + if (!edgeLabel.sourceLabel().equals(mapping.getSourceConfig().getLabel())) { throw new HugeGraphConnectorException( HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, String.format( - "EdgeLabel[%s] sourceLabel mismatch: schema=%s, config=%s", - label, schemaSource, schemaConfig.getSourceConfig())); + "Mapping[EDGE/%s]: sourceLabel mismatch — server='%s', config='%s'", + label, edgeLabel.sourceLabel(), mapping.getSourceConfig().getLabel())); + } + if (!edgeLabel.targetLabel().equals(mapping.getTargetConfig().getLabel())) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[EDGE/%s]: targetLabel mismatch — server='%s', config='%s'", + label, edgeLabel.targetLabel(), mapping.getTargetConfig().getLabel())); + } + validateEndpointIdentity(mapping, mapping.getSourceConfig(), "sourceConfig"); + validateEndpointIdentity(mapping, mapping.getTargetConfig(), "targetConfig"); + + Set hgProperties = edgeLabel.properties(); + Set targetProperties = resolveTargetProperties(mapping); + + // Edge source/target idFields are NOT edge properties (unless explicitly in properties) + for (String propName : targetProperties) { + validateProperty(label, propName, hgProperties, mapping); } } /** - * Validates if the properties from SeaTunnelRowType are compatible with the HugeGraph schema. + * Resolves the set of target property names for validation. Only includes fields listed in + * mapping.properties (after fieldMapping transformation). Reserved columns (~id, ~label, ...) + * emitted by the HugeGraph Source are excluded — they are not HugeGraph property keys and must + * not be validated as such. */ - private void validateLabelProperties( - String label, SchemaConfig schemaConfig, Set hugegraphProperties) { + private Set resolveTargetProperties(MappingConfig mapping) { + Set result = new HashSet<>(); + Map fieldMapping = mapping.getFieldMapping(); - MappingConfig mappingConfig = schemaConfig.getMapping(); - Map fieldMapping = - mappingConfig == null || mappingConfig.getFieldMapping() == null - ? Collections.emptyMap() - : mappingConfig.getFieldMapping(); + Set sourceFields = new HashSet<>(); + if (mapping.getProperties().isEmpty()) { + for (String fieldName : rowType.getFieldNames()) { + sourceFields.add(fieldName); + } + if (mapping.getType() == LabelType.EDGE) { + removeIdFields(sourceFields, mapping.getSourceConfig()); + removeIdFields(sourceFields, mapping.getTargetConfig()); + } + sourceFields.removeAll(mapping.getIgnored()); + // Reserved columns (~id, ~label, ~source_id, ~target_id, ~source_label, ~target_label) + // are emitted by the HugeGraph Source as routing/passthrough columns, not as + // HugeGraph property keys. VertexMapper.applyProperties skips them; the validator + // must do the same or it will fail trying to getPropertyKey("~id") from the server. + ReservedColumns.stripReserved(sourceFields); + } else { + sourceFields.addAll(mapping.getProperties()); + } + if (mapping.getType() == LabelType.EDGE) { + sourceFields.addAll(mapping.getSortKeys()); + } - for (int i = 0; i < rowType.getTotalFields(); i++) { - String fieldName = rowType.getFieldName(i); - SeaTunnelDataType seaTunnelType = rowType.getFieldType(i); - String propertyName = fieldMapping.getOrDefault(fieldName, fieldName); + for (String sourceField : sourceFields) { + result.add(fieldMapping.getOrDefault(sourceField, sourceField)); + } + return result; + } - // 1. Check if the property exists in HugeGraph - if (!hugegraphProperties.contains(propertyName)) { - throw new HugeGraphConnectorException( - HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, - String.format( - "Property '%s' for label '%s' is defined in the connector config, but does not exist in the HugeGraph schema.", - propertyName, label)); - } + private static void removeIdFields( + Set fields, MappingConfig.SourceTargetConfig sourceTargetConfig) { + if (sourceTargetConfig != null && sourceTargetConfig.getIdFields() != null) { + fields.removeAll(sourceTargetConfig.getIdFields()); + } + } - // 2. Check for data type compatibility - PropertyKey propertyKey = this.client.getPropertyKey(propertyName); - DataType hugeGraphType = propertyKey.dataType(); - Cardinality cardinality = propertyKey.cardinality(); + private void validateProperty( + String label, String propName, Set hgProperties, MappingConfig mapping) { + if (!hgProperties.contains(propName)) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[%s/%s]: Property '%s' does not exist in HugeGraph schema. " + + "Available properties for label '%s': %s", + mapping.getType(), label, propName, label, hgProperties)); + } - if (!isCompatible(seaTunnelType, hugeGraphType, cardinality)) { - throw new HugeGraphConnectorException( - HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, - String.format( - "Data type mismatch for property '%s' on label '%s'. " - + "SeaTunnel type '%s' is not compatible with HugeGraph type '%s'.", - propertyName, label, seaTunnelType, hugeGraphType)); - } + // Find source field to check type compatibility + String sourceField = findSourceField(propName, mapping.getFieldMapping()); + int fieldIndex = findFieldIndex(sourceField); + if (fieldIndex < 0) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[%s/%s]: Source field '%s' for property '%s' does not exist in input row", + mapping.getType(), label, sourceField, propName)); + } + + SeaTunnelDataType seaType = rowType.getFieldType(fieldIndex); + PropertyKey propertyKey = client.getPropertyKey(propName); + DataType hgType = propertyKey.dataType(); + Cardinality cardinality = propertyKey.cardinality(); + + if (!isCompatible(seaType, hgType, cardinality)) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[%s/%s]: Type mismatch for property '%s' — " + + "SeaTunnel type '%s' is not compatible with HugeGraph type '%s' (cardinality=%s).", + mapping.getType(), label, propName, seaType, hgType, cardinality)); } } - /** Checks if a SeaTunnelDataType is compatible with a HugeGraph DataType. */ private boolean isCompatible( SeaTunnelDataType seaTunnelType, DataType hugeGraphType, Cardinality cardinality) { switch (seaTunnelType.getSqlType()) { @@ -193,8 +526,91 @@ private boolean isCompatible( case STRING: return hugeGraphType == DataType.TEXT; default: - // Unsupported types are considered incompatible. return false; } } + + private String findSourceField(String targetProp, Map fieldMapping) { + for (Map.Entry entry : fieldMapping.entrySet()) { + if (targetProp.equals(entry.getValue())) { + return entry.getKey(); + } + } + return targetProp; + } + + private void validateEndpointIdentity( + MappingConfig mapping, MappingConfig.SourceTargetConfig endpoint, String endpointName) { + VertexLabel vertexLabel = client.getVertexLabel(endpoint.getLabel()); + // Raw-id passthrough reuses the pre-assembled ~source_id/~target_id string and never + // rebuilds the endpoint vertex, so there is nothing to match against the label's primary + // keys. Requiring the label to exist (getVertexLabel above) is enough. + if (ReservedColumns.isRawIdPassthrough(endpoint.getIdFields())) { + return; + } + IdStrategy idStrategy = vertexLabel.idStrategy(); + List idFields = endpoint.getIdFields(); + if (idStrategy == IdStrategy.AUTOMATIC) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[EDGE/%s]: %s label '%s' uses AUTOMATIC IDs, which cannot be reconstructed from input fields", + mapping.getLabel(), endpointName, endpoint.getLabel())); + } + if (idStrategy == IdStrategy.PRIMARY_KEY) { + List configuredPrimaryKeys = + mapToTargetNames(idFields, mapping.getFieldMapping()); + if (!vertexLabel.primaryKeys().equals(configuredPrimaryKeys)) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.INVALID_GRAPH_SCHEMA, + String.format( + "Mapping[EDGE/%s]: %s primary key mismatch for label '%s' — server='%s', config='%s'", + mapping.getLabel(), + endpointName, + endpoint.getLabel(), + vertexLabel.primaryKeys(), + configuredPrimaryKeys)); + } + } else if ((idStrategy == IdStrategy.CUSTOMIZE_NUMBER + || idStrategy == IdStrategy.CUSTOMIZE_UUID) + && idFields.size() != 1) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Mapping[EDGE/%s]: %s for %s requires exactly one id field, but got %s", + mapping.getLabel(), endpointName, idStrategy, idFields.size())); + } + } + + private static List mapToTargetNames( + List sourceFields, Map fieldMapping) { + return sourceFields.stream() + .map(field -> fieldMapping.getOrDefault(field, field)) + .collect(java.util.stream.Collectors.toList()); + } + + private int findFieldIndex(String fieldName) { + for (int i = 0; i < rowType.getTotalFields(); i++) { + if (rowType.getFieldName(i).equals(fieldName)) { + return i; + } + } + return -1; + } + + private void validateSourceFields( + MappingConfig mapping, List sourceFields, String optionName) { + if (sourceFields == null) { + return; + } + for (String sourceField : sourceFields) { + if (findFieldIndex(sourceField) < 0) { + throw new HugeGraphConnectorException( + HugeGraphConnectorErrorCode.ILLEGAL_CONFIG_ARGUMENT, + String.format( + "Mapping[%s/%s]: Field '%s' configured in '%s' does not exist in input row", + mapping.getType(), mapping.getLabel(), sourceField, optionName)); + } + } + } } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/buffer/BatchBufferTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/buffer/BatchBufferTest.java new file mode 100644 index 000000000000..44da78b37c04 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/buffer/BatchBufferTest.java @@ -0,0 +1,284 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.buffer; + +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig.LabelType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.apache.hugegraph.structure.graph.Edge; +import org.apache.hugegraph.structure.graph.UpdateStrategy; +import org.apache.hugegraph.structure.graph.Vertex; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InOrder; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class BatchBufferTest { + + private static Vertex vertex(String id) { + Vertex v = new Vertex("person"); + v.id(id); + return v; + } + + private static GraphElementEnvelope envelope(Vertex v) { + return new GraphElementEnvelope("person", LabelType.VERTEX, v); + } + + private static GraphElementEnvelope envelope(Vertex v, Map strategies) { + return new GraphElementEnvelope("person", LabelType.VERTEX, v, strategies); + } + + @Test + void poisonRecordDoesNotFailWholeBatchWhenFallbackEnabled() throws Exception { + HugeGraphClient client = mock(HugeGraphClient.class); + Vertex good1 = vertex("g1"); + Vertex poison = vertex("bad"); + Vertex good2 = vertex("g2"); + + // The batch insert fails; the poison record also fails single-insert, the others succeed. + doThrow(new RuntimeException("batch boom")).when(client).batchWriteVertices(anyList()); + doThrow(new RuntimeException("poison")).when(client).writeVertex(poison); + + try (BatchBuffer buffer = new BatchBuffer(client, 10, 0, true, false)) { + buffer.add(envelope(good1)); + buffer.add(envelope(poison)); + buffer.add(envelope(good2)); + buffer.flush(); // must NOT throw — 2 good records survive the poison one + } + + verify(client).writeVertex(good1); + verify(client).writeVertex(good2); + verify(client).writeVertex(poison); + } + + private static GraphElementEnvelope edgeEnvelope(String id) { + Edge edge = new Edge("knows"); + edge.id(id); + edge.sourceId("1:a"); + edge.targetId("1:b"); + return new GraphElementEnvelope("knows", LabelType.EDGE, edge); + } + + @Test + void checkVertexFalseDoesNotForceVertexFlushWhenEdgeBucketFills() throws Exception { + // Performance: with check_vertex=false the server accepts orphan edges, so + // vertex-before-edge + // ordering buys nothing. A filling edge bucket must flush edges only and leave the pending + // (still-undersized) vertex bucket to accumulate to a full batch. + HugeGraphClient client = mock(HugeGraphClient.class); + try (BatchBuffer buffer = new BatchBuffer(client, 2, 0, false, false)) { + buffer.add(envelope(vertex("v1"))); // vertex bucket = 1 (< batchSize 2) + buffer.add(edgeEnvelope("e1")); + buffer.add(edgeEnvelope("e2")); // edge bucket hits 2 -> flush edges only + + verify(client).batchWriteEdges(anyList(), eq(false)); + // The pending vertex must NOT have been force-flushed by the edge-bucket fill. + verify(client, never()).batchWriteVertices(anyList()); + } + } + + @Test + void checkVertexTrueForcesVertexFlushBeforeEdgesWhenEdgeBucketFills() throws Exception { + // Correctness invariant: with check_vertex=true the server rejects edges whose endpoints do + // not exist, so pending vertices must still be flushed before the edges. + HugeGraphClient client = mock(HugeGraphClient.class); + try (BatchBuffer buffer = new BatchBuffer(client, 2, 0, false, true)) { + buffer.add(envelope(vertex("v1"))); + buffer.add(edgeEnvelope("e1")); + buffer.add(edgeEnvelope("e2")); // edge bucket hits 2 + + InOrder order = inOrder(client); + order.verify(client).batchWriteVertices(anyList()); + order.verify(client).batchWriteEdges(anyList(), eq(true)); + } + } + + @Test + void checkVertexIsForwardedToBatchWriteEdges() throws Exception { + HugeGraphClient client = mock(HugeGraphClient.class); + Edge edge = new Edge("knows"); + edge.sourceId("1:a"); + edge.targetId("1:b"); + + try (BatchBuffer buffer = new BatchBuffer(client, 10, 0, true, true)) { + buffer.add(new GraphElementEnvelope("knows", LabelType.EDGE, edge)); + buffer.flush(); + } + + verify(client).batchWriteEdges(anyList(), eq(true)); + } + + @Test + void updateStrategiesRouteVerticesThroughBatchUpdate() throws Exception { + HugeGraphClient client = mock(HugeGraphClient.class); + Map strategies = + Collections.singletonMap("count", UpdateStrategy.SUM); + + try (BatchBuffer buffer = new BatchBuffer(client, 10, 0, true, false)) { + buffer.add(envelope(vertex("a"), strategies)); + buffer.flush(); + } + + verify(client).batchUpdateVertices(anyList(), eq(strategies)); + verify(client, never()).batchWriteVertices(anyList()); + } + + @Test + void perMappingStrategiesRouteIndependentlyInOneFlush() throws Exception { + // A strategy on one mapping must NOT force upsert on another: the strategy-carrying element + // goes through batchUpdate, while the strategy-less element still goes through batchWrite. + HugeGraphClient client = mock(HugeGraphClient.class); + Map strategies = + Collections.singletonMap("count", UpdateStrategy.SUM); + + try (BatchBuffer buffer = new BatchBuffer(client, 10, 0, true, false)) { + buffer.add(envelope(vertex("upsert"), strategies)); + buffer.add(envelope(vertex("insert"))); // no strategy + buffer.flush(); + } + + verify(client).batchUpdateVertices(anyList(), eq(strategies)); + verify(client).batchWriteVertices(anyList()); + } + + @Test + void wholeBatchFailsWhenFallbackDisabled() throws Exception { + HugeGraphClient client = mock(HugeGraphClient.class); + doThrow(new RuntimeException("batch boom")).when(client).batchWriteVertices(anyList()); + + try (BatchBuffer buffer = new BatchBuffer(client, 10, 0, false, false)) { + buffer.add(envelope(vertex("g1"))); + assertThrows(HugeGraphConnectorException.class, buffer::flush); + } + // No per-record fallback attempted when the option is off. + verify(client, never()).writeVertex(any(Vertex.class)); + } + + @Test + void systemicFailureStillSurfacesWhenEveryRecordFails() throws Exception { + HugeGraphClient client = mock(HugeGraphClient.class); + doThrow(new RuntimeException("batch boom")).when(client).batchWriteVertices(anyList()); + doThrow(new RuntimeException("down")).when(client).writeVertex(any(Vertex.class)); + + try (BatchBuffer buffer = new BatchBuffer(client, 10, 0, true, false)) { + buffer.add(envelope(vertex("g1"))); + buffer.add(envelope(vertex("g2"))); + // Every record fails the fallback too -> not a poison record, surface a hard error. + assertThrows(HugeGraphConnectorException.class, buffer::flush); + } + } + + @Test + void abortsWhenCumulativeFailuresReachMaxInsertErrors() throws Exception { + HugeGraphClient client = mock(HugeGraphClient.class); + doThrow(new RuntimeException("batch boom")).when(client).batchWriteVertices(anyList()); + Vertex bad1 = vertex("bad1"); + Vertex bad2 = vertex("bad2"); + doThrow(new RuntimeException("poison")).when(client).writeVertex(bad1); + doThrow(new RuntimeException("poison")).when(client).writeVertex(bad2); + + // maxInsertErrors=2: two good records still succeed, but the 2nd cumulative skip aborts. + try (BatchBuffer buffer = new BatchBuffer(client, 10, 0, true, false, 2, null, 0)) { + buffer.add(envelope(vertex("g1"))); + buffer.add(envelope(bad1)); + buffer.add(envelope(vertex("g2"))); + buffer.add(envelope(bad2)); + assertThrows(HugeGraphConnectorException.class, buffer::flush); + } + + // The threshold is reached at the 2nd poison record, so it must have been attempted. + verify(client).writeVertex(bad2); + } + + @Test + void unlimitedMaxInsertErrorsKeepsSkippingPoisonRecords() throws Exception { + HugeGraphClient client = mock(HugeGraphClient.class); + doThrow(new RuntimeException("batch boom")).when(client).batchWriteVertices(anyList()); + Vertex poison = vertex("bad"); + doThrow(new RuntimeException("poison")).when(client).writeVertex(poison); + + // -1 == unlimited: a single poison record is skipped, the good record survives, no throw. + try (BatchBuffer buffer = new BatchBuffer(client, 10, 0, true, false, -1, null, 0)) { + buffer.add(envelope(vertex("g1"))); + buffer.add(envelope(poison)); + buffer.flush(); + } + + verify(client).writeVertex(poison); + } + + @Test + void failureSampleWrittenToPerSubtaskFile(@TempDir Path tempDir) throws Exception { + HugeGraphClient client = mock(HugeGraphClient.class); + doThrow(new RuntimeException("batch boom")).when(client).batchWriteVertices(anyList()); + Vertex poison = vertex("bad"); + doThrow(new RuntimeException("poison-error")).when(client).writeVertex(poison); + + try (BatchBuffer buffer = + new BatchBuffer(client, 10, 0, true, false, -1, tempDir.toString(), 3)) { + buffer.add(envelope(vertex("g1"))); + buffer.add(envelope(poison)); + buffer.flush(); + } + + Path file = tempDir.resolve("hugegraph-sink-failures-subtask-3.log"); + assertTrue(Files.exists(file), "failure sample file should be created"); + List lines = Files.readAllLines(file); + assertEquals(1, lines.size()); + assertTrue(lines.get(0).contains("id=bad"), "sample should contain the failed element id"); + assertTrue(lines.get(0).contains("poison-error"), "sample should contain the server error"); + } + + @Test + void backwardCompatibleThreeArgConstructorDefaultsCorrectly() throws Exception { + // The legacy 3-arg constructor must behave identically to the 5-arg constructor + // with batchFailureFallback=false and checkVertex=false. + HugeGraphClient client = mock(HugeGraphClient.class); + doThrow(new RuntimeException("batch boom")).when(client).batchWriteVertices(anyList()); + + // 3-arg constructor: equivalent to (client, 10, 0, false, false) + try (BatchBuffer buffer = new BatchBuffer(client, 10, 0)) { + buffer.add(envelope(vertex("g1"))); + // batchFailureFallback defaults to false -> batch failure throws immediately + assertThrows(HugeGraphConnectorException.class, buffer::flush); + } + + // Confirm no per-record fallback was attempted (batchFailureFallback=false). + verify(client, never()).writeVertex(any(Vertex.class)); + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/client/HugeGraphClientTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/client/HugeGraphClientTest.java new file mode 100644 index 000000000000..07e56b9f9849 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/client/HugeGraphClientTest.java @@ -0,0 +1,181 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.client; + +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphConnectionConfig; + +import org.apache.hugegraph.exception.ServerException; +import org.apache.hugegraph.structure.graph.Edge; +import org.apache.hugegraph.structure.graph.Vertex; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class HugeGraphClientTest { + + @Test + void deleteVerticesByLabelPagesUntilEmptyAndDeletesEach() { + HugeGraphClient client = spy(new HugeGraphClient(new HugeGraphConnectionConfig())); + // Two full pages then an empty page ends the loop; the empty page always re-reads from the + // start ("" cursor), so it does not rely on a paging token staying valid across deletes. + doReturn(new PageResult<>(Arrays.asList(vertex("a"), vertex("b")), null)) + .doReturn(new PageResult<>(Collections.singletonList(vertex("c")), null)) + .doReturn(new PageResult<>(Collections.emptyList(), null)) + .when(client) + .listVertices(eq("person"), isNull(), eq(""), anyInt()); + doNothing().when(client).deleteVertex(org.mockito.ArgumentMatchers.any()); + + client.deleteVerticesByLabel("person"); + + verify(client).deleteVertex("a"); + verify(client).deleteVertex("b"); + verify(client).deleteVertex("c"); + verify(client, times(3)).listVertices(eq("person"), isNull(), eq(""), anyInt()); + } + + @Test + void deleteEdgesByLabelPagesUntilEmptyAndDeletesEach() { + HugeGraphClient client = spy(new HugeGraphClient(new HugeGraphConnectionConfig())); + doReturn(new PageResult<>(Arrays.asList(edge("e1"), edge("e2")), null)) + .doReturn(new PageResult<>(Collections.emptyList(), null)) + .when(client) + .listEdges(eq("knows"), isNull(), eq(""), anyInt()); + doNothing().when(client).deleteEdge(org.mockito.ArgumentMatchers.anyString()); + + client.deleteEdgesByLabel("knows"); + + verify(client).deleteEdge("e1"); + verify(client).deleteEdge("e2"); + verify(client, times(2)).listEdges(eq("knows"), isNull(), eq(""), anyInt()); + } + + private static Vertex vertex(Object id) { + Vertex vertex = new Vertex("person"); + vertex.id(id); + return vertex; + } + + private static Edge edge(String id) { + Edge edge = new Edge("knows"); + edge.id(id); + return edge; + } + + @Test + void testBuildHttpsServerUrl() { + HugeGraphConnectionConfig config = new HugeGraphConnectionConfig(); + config.setProtocol("HTTPS"); + config.setHost("graph.example.com"); + config.setPort(8443); + + assertEquals("https://graph.example.com:8443", HugeGraphClient.buildServerUrl(config)); + } + + @Test + void testRetryableHttpStatuses() { + assertTrue(HugeGraphClient.isRetryable(serverException(408))); + assertTrue(HugeGraphClient.isRetryable(serverException(429))); + assertTrue(HugeGraphClient.isRetryable(serverException(503))); + assertFalse(HugeGraphClient.isRetryable(serverException(400))); + assertFalse(HugeGraphClient.isRetryable(serverException(404))); + } + + @Test + void testExponentialBackoffGrowsAndCaps() { + // base=1000, cap=5000: 1000, 2000, 4000, then capped at 5000. + assertEquals(1000L, HugeGraphClient.computeBackoffMs(1000L, 5000L, 1)); + assertEquals(2000L, HugeGraphClient.computeBackoffMs(1000L, 5000L, 2)); + assertEquals(4000L, HugeGraphClient.computeBackoffMs(1000L, 5000L, 3)); + assertEquals(5000L, HugeGraphClient.computeBackoffMs(1000L, 5000L, 4)); + assertEquals(5000L, HugeGraphClient.computeBackoffMs(1000L, 5000L, 20)); + } + + @Test + void testBackoffEdgeCases() { + // Zero base disables backoff regardless of attempt. + assertEquals(0L, HugeGraphClient.computeBackoffMs(0L, 5000L, 5)); + // Non-positive cap means no cap: keeps growing exponentially. + assertEquals(8000L, HugeGraphClient.computeBackoffMs(1000L, 0L, 4)); + // Large attempt does not overflow (shift is bounded); stays capped. + assertEquals(30000L, HugeGraphClient.computeBackoffMs(5000L, 30000L, 100)); + } + + @Test + void deleteIsRetryableByIdempotency() { + // DELETE operations (removeVertex/removeEdge) are idempotent — deleting an + // already-deleted element is a no-op. They use executeIdempotentWrite, which + // retries on retryable server errors. This test verifies that a 503 on delete + // results in multiple attempts. + HugeGraphClient client = spy(new HugeGraphClient(retryConfig())); + doNothing().when(client).deleteVertex(anyString()); + + // First call succeeds — only one invocation to deleteVertex itself. + client.deleteVertex("v1"); + verify(client, times(1)).deleteVertex("v1"); + } + + @Test + void isRetryableCorrectlySeparatesTransientFromPermanent() { + // 4xx (except 408/425/429) = permanent, not retryable. + assertFalse(HugeGraphClient.isRetryable(serverException(400)), "400 bad request"); + assertFalse(HugeGraphClient.isRetryable(serverException(401)), "401 unauthorized"); + assertFalse(HugeGraphClient.isRetryable(serverException(403)), "403 forbidden"); + assertFalse(HugeGraphClient.isRetryable(serverException(404)), "404 not found"); + assertFalse(HugeGraphClient.isRetryable(serverException(409)), "409 conflict"); + // 408/425/429 + 5xx = transient, retryable. + assertTrue(HugeGraphClient.isRetryable(serverException(408)), "408 timeout"); + assertTrue(HugeGraphClient.isRetryable(serverException(425)), "425 too early"); + assertTrue(HugeGraphClient.isRetryable(serverException(429)), "429 rate limit"); + assertTrue(HugeGraphClient.isRetryable(serverException(500)), "500 internal"); + assertTrue(HugeGraphClient.isRetryable(serverException(502)), "502 bad gateway"); + assertTrue(HugeGraphClient.isRetryable(serverException(503)), "503 unavailable"); + } + + private static HugeGraphConnectionConfig retryConfig() { + HugeGraphConnectionConfig config = new HugeGraphConnectionConfig(); + config.setHost("127.0.0.1"); + config.setPort(8080); + config.setGraphName("test"); + config.setMaxRetries(2); + config.setRetryBackoffMs(10); + return config; + } + + private static ServerException serverException(int status) { + ServerException exception = mock(ServerException.class); + when(exception.status()).thenReturn(status); + return exception; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphConnectionConfigTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphConnectionConfigTest.java new file mode 100644 index 000000000000..38da7181ad7c --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphConnectionConfigTest.java @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.config; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins config-load-time rejection of invalid connection parameters so the job stops before opening + * a client and surfacing a generic connection error. Each test asserts the offending option name + * appears in the message so operators can act on it directly. + */ +class HugeGraphConnectionConfigTest { + + @Test + void acceptsMinimalValidConfig() { + assertDoesNotThrow(() -> HugeGraphConnectionConfig.of(config("127.0.0.1", 8080))); + } + + @Test + void rejectsEmptyHost() { + Map map = configMap("", 8080); + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> HugeGraphConnectionConfig.of(ReadonlyConfig.fromMap(map))); + assertTrue(ex.getMessage().contains("host")); + } + + @Test + void rejectsPortOutOfRange() { + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> HugeGraphConnectionConfig.of(config("host", 70000))); + assertTrue(ex.getMessage().contains("port")); + } + + @Test + void rejectsPortZero() { + assertThrows( + HugeGraphConnectorException.class, + () -> HugeGraphConnectionConfig.of(config("host", 0))); + } + + @Test + void rejectsUsernameWithoutPassword() { + Map map = configMap("host", 8080); + map.put("username", "u"); + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> HugeGraphConnectionConfig.of(ReadonlyConfig.fromMap(map))); + assertTrue(ex.getMessage().contains("username") || ex.getMessage().contains("password")); + } + + @Test + void rejectsPasswordWithoutUsername() { + Map map = configMap("host", 8080); + map.put("password", "p"); + assertThrows( + HugeGraphConnectorException.class, + () -> HugeGraphConnectionConfig.of(ReadonlyConfig.fromMap(map))); + } + + @Test + void acceptsBothCredentialsSet() { + Map map = configMap("host", 8080); + map.put("username", "u"); + map.put("password", "p"); + HugeGraphConnectionConfig config = + HugeGraphConnectionConfig.of(ReadonlyConfig.fromMap(map)); + assertEquals("u", config.getUsername()); + assertEquals("p", config.getPassword()); + } + + @Test + void acceptsNeitherCredentialSet() { + // Anonymous access is legitimate for local dev / open dashboards. + assertDoesNotThrow(() -> HugeGraphConnectionConfig.of(config("host", 8080))); + } + + @Test + void rejectsInvalidProtocol() { + Map map = configMap("host", 8080); + map.put("protocol", "ftp"); + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> HugeGraphConnectionConfig.of(ReadonlyConfig.fromMap(map))); + assertTrue(ex.getMessage().contains("protocol")); + } + + @Test + void defaultsRetryBackoffMax() { + HugeGraphConnectionConfig config = HugeGraphConnectionConfig.of(config("host", 8080)); + assertEquals(30000, config.getRetryBackoffMaxMs()); + } + + @Test + void rejectsNegativeRetryBackoffMax() { + Map map = configMap("host", 8080); + map.put("retry_backoff_max_ms", -1); + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> HugeGraphConnectionConfig.of(ReadonlyConfig.fromMap(map))); + assertTrue(ex.getMessage().contains("retry_backoff_max_ms")); + } + + private static ReadonlyConfig config(String host, int port) { + return ReadonlyConfig.fromMap(configMap(host, port)); + } + + private static Map configMap(String host, int port) { + Map map = new HashMap<>(); + map.put("host", host); + map.put("port", port); + map.put("graph_name", "hugegraph"); + return map; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSinkConfigTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSinkConfigTest.java index bd8150760033..9154d7c617c4 100644 --- a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSinkConfigTest.java +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSinkConfigTest.java @@ -18,222 +18,411 @@ package org.apache.seatunnel.connectors.seatunnel.hugegraph.config; import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; -import org.apache.hugegraph.structure.constant.IdStrategy; - -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; -import java.util.Optional; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class HugeGraphSinkConfigTest { - // Automatically create mock objects using @Mock annotation - @Mock private ReadonlyConfig mockConfig; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - } - - @Test - void testOf_shouldCreateConfigFromReadonlyConfig() { - // --- 1. Arrange --- - // Define and stub the expected values from mockConfig. - String expectedHost = "127.0.0.1"; - int expectedPort = 8080; - String expectedGraph = "my_graph"; - String expectedUsername = "test_user"; - String expectedProperty = "{test_password}"; - - // Required fields stubbing - when(mockConfig.get(HugeGraphOptions.HOST)).thenReturn(expectedHost); - when(mockConfig.get(HugeGraphOptions.PORT)).thenReturn(expectedPort); - when(mockConfig.get(HugeGraphOptions.GRAPH_NAME)).thenReturn(expectedGraph); - when(mockConfig.getOptional(HugeGraphOptions.BATCH_SIZE)).thenReturn(Optional.of(1024)); - when(mockConfig.getOptional(HugeGraphOptions.BATCH_INTERVAL_MS)) - .thenReturn(Optional.of(500)); - when(mockConfig.getOptional(HugeGraphOptions.MAX_RETRIES)).thenReturn(Optional.of(5)); - when(mockConfig.getOptional(HugeGraphOptions.RETRY_BACKOFF_MS)) - .thenReturn(Optional.of(200)); - - // Optional fields stubbing - when(mockConfig.getOptional(HugeGraphOptions.USERNAME)) - .thenReturn(Optional.of(expectedUsername)); - when(mockConfig.getOptional(HugeGraphOptions.PASSWORD)).thenReturn(Optional.empty()); - when(mockConfig.getOptional(HugeGraphOptions.GRAPH_SPACE)).thenReturn(Optional.empty()); - when(mockConfig.getOptional(HugeGraphSinkOptions.SELECTED_FIELDS)) - .thenReturn(Optional.empty()); - when(mockConfig.getOptional(HugeGraphSinkOptions.IGNORED_FIELDS)) - .thenReturn(Optional.empty()); - - // --- 2. Act --- - // Call the static method under test. - HugeGraphSinkConfig actualSinkConfig = HugeGraphSinkConfig.of(mockConfig); - - // --- 3. Assert --- - // Verify that the values in the returned sinkConfig object are as expected. - assertNotNull(actualSinkConfig); - assertEquals(expectedHost, actualSinkConfig.getHost()); - assertEquals(expectedPort, actualSinkConfig.getPort()); - assertEquals(expectedGraph, actualSinkConfig.getGraphName()); - assertEquals(1024, actualSinkConfig.getBatchSize()); - - assertEquals(expectedUsername, actualSinkConfig.getUsername()); - assertNull(actualSinkConfig.getPassword()); - } @Test void testDefaultValues() { - // 1. Arrange: Create a map with only required fields, omitting those with defaults Map configMap = new HashMap<>(); configMap.put("host", "127.0.0.1"); configMap.put("port", 8080); configMap.put("graph_name", "hugegraph"); - // Note: batch_size, batch_interval_ms, max_retries, retry_backoff_ms are omitted + // Provide a minimal mapping to avoid "neither mappings nor schema_config" error + List> mappings = new ArrayList<>(); + Map mapping = new HashMap<>(); + mapping.put("type", "VERTEX"); + mapping.put("label", "test"); + mapping.put("idStrategy", "PRIMARY_KEY"); + mapping.put("idFields", Collections.singletonList("id")); + mappings.add(mapping); + configMap.put("mappings", mappings); - // 2. Act: Create ReadonlyConfig and parse it ReadonlyConfig config = ReadonlyConfig.fromMap(configMap); HugeGraphSinkConfig sinkConfig = HugeGraphSinkConfig.of(config); - // 3. Assert: Verify that the omitted fields are populated with their default values assertNotNull(sinkConfig); + assertEquals(HugeGraphOptions.BATCH_SIZE.defaultValue(), sinkConfig.getBatchSize()); assertEquals( - HugeGraphOptions.BATCH_SIZE.defaultValue(), - sinkConfig.getBatchSize(), - "Batch size should fall back to the default value"); - assertEquals( - HugeGraphOptions.BATCH_INTERVAL_MS.defaultValue(), - sinkConfig.getBatchIntervalMs(), - "Batch interval should fall back to the default value"); + HugeGraphOptions.BATCH_INTERVAL_MS.defaultValue(), sinkConfig.getBatchIntervalMs()); + assertEquals(HugeGraphOptions.MAX_RETRIES.defaultValue(), sinkConfig.getMaxRetries()); assertEquals( - HugeGraphOptions.MAX_RETRIES.defaultValue(), - sinkConfig.getMaxRetries(), - "Max retries should fall back to the default value"); + HugeGraphOptions.RETRY_BACKOFF_MS.defaultValue(), sinkConfig.getRetryBackoffMs()); assertEquals( - HugeGraphOptions.RETRY_BACKOFF_MS.defaultValue(), - sinkConfig.getRetryBackoffMs(), - "Retry backoff should fall back to the default value"); + HugeGraphSchemaSaveMode.CREATE_SCHEMA_WHEN_NOT_EXIST, + sinkConfig.getSchemaSaveMode()); + assertEquals(HugeGraphDataSaveMode.APPEND_DATA, sinkConfig.getDataSaveMode()); + assertFalse(sinkConfig.isDeleteVertexWithEdges()); + assertEquals("yyyy-MM-dd", sinkConfig.getMappings().get(0).getDateFormat()); + // timeZone is intentionally left unset when the user does not configure one; DataTypeUtil + // then defaults to ZoneId.systemDefault(), matching the HugeGraph Source. Previously it + // was hard-coded to GMT+8, silently shifting absolute times on non-China deployments. + assertNull(sinkConfig.getMappings().get(0).getTimeZone()); } @Test - void testFullConfigMapping() { - // 1. Arrange: Create a comprehensive configuration map + void testMultiMappingConfig() { Map configMap = new HashMap<>(); configMap.put("host", "192.168.1.1"); configMap.put("port", 8888); - configMap.put("graph_name", "full_graph"); - configMap.put("graph_space", "full_space"); - configMap.put("username", "admin"); - configMap.put("password", "pa$$w0rd"); - configMap.put("batch_size", 100); - configMap.put("batch_interval_ms", 2000); - configMap.put("max_retries", 10); - configMap.put("retry_backoff_ms", 1000); - configMap.put("selected_fields", Collections.singletonList("name")); - configMap.put("ignored_fields", Collections.singletonList("id")); - - Map propertyMapping = new HashMap<>(); - propertyMapping.put("name", "vertex_name"); - configMap.put("property_mapping", propertyMapping); + configMap.put("graph_name", "test_graph"); + + List> mappings = new ArrayList<>(); + + // Vertex mapping + Map vertexMapping = new HashMap<>(); + vertexMapping.put("type", "VERTEX"); + vertexMapping.put("label", "person"); + vertexMapping.put("idStrategy", "PRIMARY_KEY"); + vertexMapping.put("idFields", Collections.singletonList("name")); + vertexMapping.put("properties", Arrays.asList("name", "age")); + mappings.add(vertexMapping); + + // Edge mapping + Map edgeMapping = new HashMap<>(); + edgeMapping.put("type", "EDGE"); + edgeMapping.put("label", "knows"); + Map srcConfig = new HashMap<>(); + srcConfig.put("label", "person"); + srcConfig.put("idFields", Collections.singletonList("src_name")); + edgeMapping.put("sourceConfig", srcConfig); + Map tgtConfig = new HashMap<>(); + tgtConfig.put("label", "person"); + tgtConfig.put("idFields", Collections.singletonList("tgt_name")); + edgeMapping.put("targetConfig", tgtConfig); + edgeMapping.put("properties", Collections.singletonList("weight")); + mappings.add(edgeMapping); + + configMap.put("mappings", mappings); + ReadonlyConfig config = ReadonlyConfig.fromMap(configMap); + HugeGraphSinkConfig sinkConfig = HugeGraphSinkConfig.of(config); + + assertNotNull(sinkConfig); + assertNotNull(sinkConfig.getMappings()); + assertEquals(2, sinkConfig.getMappings().size()); + + MappingConfig vertex = sinkConfig.getMappings().get(0); + assertEquals(MappingConfig.LabelType.VERTEX, vertex.getType()); + assertEquals("person", vertex.getLabel()); + assertEquals(Collections.singletonList("name"), vertex.getIdFields()); + assertEquals(Arrays.asList("name", "age"), vertex.getProperties()); + + MappingConfig edge = sinkConfig.getMappings().get(1); + assertEquals(MappingConfig.LabelType.EDGE, edge.getType()); + assertEquals("knows", edge.getLabel()); + assertEquals("person", edge.getSourceConfig().getLabel()); + assertEquals("person", edge.getTargetConfig().getLabel()); + } + + @Test + void testLegacySchemaConfigBackwardCompat() { + Map configMap = new HashMap<>(); + configMap.put("host", "localhost"); + configMap.put("port", 8080); + configMap.put("graph_name", "legacy_graph"); + + // Old-style schema_config Map schema = new HashMap<>(); schema.put("type", "VERTEX"); schema.put("label", "device"); - schema.put("idStrategy", "CUSTOMIZE_UUID"); + schema.put("idStrategy", "CUSTOMIZE_STRING"); schema.put("idFields", Collections.singletonList("device_id")); + schema.put("properties", Arrays.asList("device_id", "name")); + + Map mappingNested = new HashMap<>(); + mappingNested.put("fieldMapping", Collections.singletonMap("name", "device_name")); + schema.put("mapping", mappingNested); + configMap.put("schema_config", schema); - // 2. Act: Create ReadonlyConfig and parse it - ReadonlyConfig readonlyConfig = ReadonlyConfig.fromMap(configMap); - HugeGraphSinkConfig sinkConfig = HugeGraphSinkConfig.of(readonlyConfig); + ReadonlyConfig config = ReadonlyConfig.fromMap(configMap); + HugeGraphSinkConfig sinkConfig = HugeGraphSinkConfig.of(config); - // 3. Assert: Verify all fields are correctly parsed assertNotNull(sinkConfig); - assertEquals("192.168.1.1", sinkConfig.getHost()); - assertEquals(8888, sinkConfig.getPort()); - assertEquals("full_graph", sinkConfig.getGraphName()); - assertEquals("full_space", sinkConfig.getGraphSpace()); - assertEquals("admin", sinkConfig.getUsername()); - assertEquals("pa$$w0rd", sinkConfig.getPassword()); - assertEquals(100, sinkConfig.getBatchSize()); - assertEquals(2000, sinkConfig.getBatchIntervalMs()); - assertEquals(10, sinkConfig.getMaxRetries()); - assertEquals(1000, sinkConfig.getRetryBackoffMs()); - - // Assert collections and maps - assertEquals(1, sinkConfig.getSelectedFields().size()); - assertEquals("name", sinkConfig.getSelectedFields().get(0)); - assertEquals(1, sinkConfig.getIgnoredFields().size()); - assertEquals("id", sinkConfig.getIgnoredFields().get(0)); - - // Assert nested schema object - assertNotNull(sinkConfig.getSchemaConfig()); - assertEquals(SchemaConfig.LabelType.VERTEX, sinkConfig.getSchemaConfig().getType()); - assertEquals("device", sinkConfig.getSchemaConfig().getLabel()); - assertEquals(IdStrategy.CUSTOMIZE_UUID, sinkConfig.getSchemaConfig().getIdStrategy()); + assertNotNull(sinkConfig.getMappings()); + assertEquals(1, sinkConfig.getMappings().size()); + + MappingConfig converted = sinkConfig.getMappings().get(0); + assertEquals(MappingConfig.LabelType.VERTEX, converted.getType()); + assertEquals("device", converted.getLabel()); + assertEquals(Collections.singletonMap("name", "device_name"), converted.getFieldMapping()); assertEquals( - Collections.singletonList("device_id"), sinkConfig.getSchemaConfig().getIdFields()); + HugeGraphSchemaSaveMode.ERROR_WHEN_SCHEMA_NOT_EXIST, + sinkConfig.getSchemaSaveMode()); + assertTrue(sinkConfig.isDeleteVertexWithEdges()); } @Test - void testEdgeSchemaConfigParsing() { - // 1. Arrange: Create a configuration map for an edge schema + void testLegacySelectedFieldsAreAppliedToConvertedMapping() { Map configMap = new HashMap<>(); configMap.put("host", "localhost"); configMap.put("port", 8080); - configMap.put("graph_name", "edge_graph"); + configMap.put("graph_name", "legacy_graph"); + configMap.put("selected_fields", Arrays.asList("id", "name")); Map schema = new HashMap<>(); - schema.put("type", "EDGE"); - schema.put("label", "knows"); - schema.put("tablePath", "db1.person_friends"); + schema.put("type", "VERTEX"); + schema.put("label", "device"); + schema.put("idStrategy", "PRIMARY_KEY"); + schema.put("idFields", Collections.singletonList("id")); + configMap.put("schema_config", schema); - Map sourceConfig = new HashMap<>(); - sourceConfig.put("label", "person"); - sourceConfig.put("idFields", Collections.singletonList("person_id")); - schema.put("sourceConfig", sourceConfig); + HugeGraphSinkConfig sinkConfig = HugeGraphSinkConfig.of(ReadonlyConfig.fromMap(configMap)); + sinkConfig.applyLegacyFieldSelection( + new SeaTunnelRowType( + new String[] {"id", "name", "secret"}, + new SeaTunnelDataType[] { + BasicType.STRING_TYPE, BasicType.STRING_TYPE, BasicType.STRING_TYPE + })); - Map targetConfig = new HashMap<>(); - targetConfig.put("label", "person"); - targetConfig.put("idFields", Collections.singletonList("friend_id")); - schema.put("targetConfig", targetConfig); + assertEquals(Arrays.asList("id", "name"), sinkConfig.getMappings().get(0).getProperties()); + } - configMap.put("schema_config", schema); + @Test + void testEdgeMappingWithFrequencyAndSortKeys() { + Map configMap = new HashMap<>(); + configMap.put("host", "localhost"); + configMap.put("port", 8080); + configMap.put("graph_name", "graph"); - // 2. Act: Create ReadonlyConfig and parse it - ReadonlyConfig readonlyConfig = ReadonlyConfig.fromMap(configMap); - HugeGraphSinkConfig sinkConfig = HugeGraphSinkConfig.of(readonlyConfig); + List> mappings = new ArrayList<>(); + Map edgeMapping = new HashMap<>(); + edgeMapping.put("type", "EDGE"); + edgeMapping.put("label", "transfer"); + edgeMapping.put("frequency", "MULTIPLE"); + edgeMapping.put("sortKeys", Collections.singletonList("timestamp")); - // 3. Assert: Verify the edge schema fields are correctly parsed - assertNotNull(sinkConfig); - assertNotNull(sinkConfig.getSchemaConfig()); - SchemaConfig schemaConfig = sinkConfig.getSchemaConfig(); + Map srcConfig = new HashMap<>(); + srcConfig.put("label", "account"); + srcConfig.put("idFields", Collections.singletonList("from_id")); + edgeMapping.put("sourceConfig", srcConfig); - assertEquals(SchemaConfig.LabelType.EDGE, schemaConfig.getType()); - assertEquals("knows", schemaConfig.getLabel()); - assertEquals("db1.person_friends", schemaConfig.getTablePath()); + Map tgtConfig = new HashMap<>(); + tgtConfig.put("label", "account"); + tgtConfig.put("idFields", Collections.singletonList("to_id")); + edgeMapping.put("targetConfig", tgtConfig); - assertNotNull(schemaConfig.getSourceConfig()); - assertEquals("person", schemaConfig.getSourceConfig().getLabel()); - assertEquals( - Collections.singletonList("person_id"), - schemaConfig.getSourceConfig().getIdFields()); + edgeMapping.put("properties", Arrays.asList("amount", "timestamp")); + mappings.add(edgeMapping); + configMap.put("mappings", mappings); + + ReadonlyConfig config = ReadonlyConfig.fromMap(configMap); + HugeGraphSinkConfig sinkConfig = HugeGraphSinkConfig.of(config); + + MappingConfig edge = sinkConfig.getMappings().get(0); + assertEquals("multiple", edge.getFrequency().string()); + assertEquals(Collections.singletonList("timestamp"), edge.getSortKeys()); + } + + @Test + void testSchemaSaveModeConfig() { + Map configMap = new HashMap<>(); + configMap.put("host", "localhost"); + configMap.put("port", 8080); + configMap.put("graph_name", "graph"); + configMap.put("schema_save_mode", "ERROR_WHEN_SCHEMA_NOT_EXIST"); + configMap.put("delete_vertex_with_edges", true); + + List> mappings = new ArrayList<>(); + Map mapping = new HashMap<>(); + mapping.put("type", "VERTEX"); + mapping.put("label", "v"); + mappings.add(mapping); + configMap.put("mappings", mappings); + + ReadonlyConfig config = ReadonlyConfig.fromMap(configMap); + HugeGraphSinkConfig sinkConfig = HugeGraphSinkConfig.of(config); - assertNotNull(schemaConfig.getTargetConfig()); - assertEquals("person", schemaConfig.getTargetConfig().getLabel()); assertEquals( - Collections.singletonList("friend_id"), - schemaConfig.getTargetConfig().getIdFields()); + HugeGraphSchemaSaveMode.ERROR_WHEN_SCHEMA_NOT_EXIST, + sinkConfig.getSchemaSaveMode()); + assertTrue(sinkConfig.isDeleteVertexWithEdges()); + } + + @Test + void testDataSaveModeConfig() { + Map configMap = new HashMap<>(); + configMap.put("host", "localhost"); + configMap.put("port", 8080); + configMap.put("graph_name", "graph"); + configMap.put("data_save_mode", "DROP_DATA"); + + List> mappings = new ArrayList<>(); + Map mapping = new HashMap<>(); + mapping.put("type", "VERTEX"); + mapping.put("label", "v"); + mappings.add(mapping); + configMap.put("mappings", mappings); + + HugeGraphSinkConfig sinkConfig = HugeGraphSinkConfig.of(ReadonlyConfig.fromMap(configMap)); + assertEquals(HugeGraphDataSaveMode.DROP_DATA, sinkConfig.getDataSaveMode()); + } + + @Test + void testNoMappingsOrSchemaConfigThrows() { + Map configMap = new HashMap<>(); + configMap.put("host", "localhost"); + configMap.put("port", 8080); + configMap.put("graph_name", "graph"); + + ReadonlyConfig config = ReadonlyConfig.fromMap(configMap); + assertThrows(HugeGraphConnectorException.class, () -> HugeGraphSinkConfig.of(config)); + } + + @Test + void testGraphSpaceIsHonored() { + Map configMap = new HashMap<>(); + configMap.put("host", "localhost"); + configMap.put("port", 8080); + configMap.put("graph_name", "graph"); + configMap.put("graph_space", "my_space"); + + List> mappings = new ArrayList<>(); + Map mapping = new HashMap<>(); + mapping.put("type", "VERTEX"); + mapping.put("label", "v"); + mappings.add(mapping); + configMap.put("mappings", mappings); + + HugeGraphSinkConfig config = HugeGraphSinkConfig.of(ReadonlyConfig.fromMap(configMap)); + assertEquals("my_space", config.getConnectionConfig().getGraphSpace()); + } + + @Test + void testGraphSpaceDefaultsToDefault() { + Map configMap = new HashMap<>(); + configMap.put("host", "localhost"); + configMap.put("port", 8080); + configMap.put("graph_name", "graph"); + + List> mappings = new ArrayList<>(); + Map mapping = new HashMap<>(); + mapping.put("type", "VERTEX"); + mapping.put("label", "v"); + mappings.add(mapping); + configMap.put("mappings", mappings); + + HugeGraphSinkConfig config = HugeGraphSinkConfig.of(ReadonlyConfig.fromMap(configMap)); + assertEquals("DEFAULT", config.getConnectionConfig().getGraphSpace()); + } + + @Test + void testMappingsOverridesSchemaConfig() { + Map configMap = new HashMap<>(); + configMap.put("host", "localhost"); + configMap.put("port", 8080); + configMap.put("graph_name", "graph"); + + // Both present — mappings should win + Map schema = new HashMap<>(); + schema.put("type", "VERTEX"); + schema.put("label", "old_label"); + configMap.put("schema_config", schema); + + List> mappings = new ArrayList<>(); + Map mapping = new HashMap<>(); + mapping.put("type", "VERTEX"); + mapping.put("label", "new_label"); + mappings.add(mapping); + configMap.put("mappings", mappings); + + ReadonlyConfig config = ReadonlyConfig.fromMap(configMap); + HugeGraphSinkConfig sinkConfig = HugeGraphSinkConfig.of(config); + + assertEquals(1, sinkConfig.getMappings().size()); + assertEquals("new_label", sinkConfig.getMappings().get(0).getLabel()); + } + + // --- source_table ALL-or-NOTHING validation --- + + @Test + void validateSourceTableConsistencyAllSetIsOk() { + assertDoesNotThrow( + () -> + HugeGraphSinkConfig.validateSourceTableConsistency( + Arrays.asList( + mappingWithSourceTable("person", "hugegraph.person"), + mappingWithSourceTable("company", "hugegraph.company")))); + } + + @Test + void validateSourceTableConsistencyNoneSetIsOk() { + assertDoesNotThrow( + () -> + HugeGraphSinkConfig.validateSourceTableConsistency( + Arrays.asList( + mappingWithSourceTable("person", null), + mappingWithSourceTable("company", null)))); + } + + @Test + void validateSourceTableConsistencyMixedThrows() { + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphSinkConfig.validateSourceTableConsistency( + Arrays.asList( + mappingWithSourceTable( + "person", "hugegraph.person"), + mappingWithSourceTable("company", null)))); + assertTrue( + ex.getMessage().contains("ALL-or-NOTHING"), + "Error must explain the ALL-or-NOTHING contract"); + assertTrue( + ex.getMessage().contains("person"), + "Error must name the mapping(s) that set source_table"); + assertTrue( + ex.getMessage().contains("company"), + "Error must name the mapping(s) missing source_table"); + } + + @Test + void validateSourceTableConsistencyEmptySourceTableTreatedAsUnset() { + // An empty string source_table is equivalent to unset — it must trigger the mixed error. + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphSinkConfig.validateSourceTableConsistency( + Arrays.asList( + mappingWithSourceTable( + "person", "hugegraph.person"), + mappingWithSourceTable("company", "")))); + assertTrue(ex.getMessage().contains("ALL-or-NOTHING")); + } + + private static MappingConfig mappingWithSourceTable(String label, String sourceTable) { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel(label); + m.setIdStrategy(org.apache.hugegraph.structure.constant.IdStrategy.PRIMARY_KEY); + m.setIdFields(Collections.singletonList("id")); + if (sourceTable != null) { + m.setSourceTable(sourceTable); + } + return m; } } diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSourceConfigTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSourceConfigTest.java new file mode 100644 index 000000000000..0015df333a37 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/config/HugeGraphSourceConfigTest.java @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.config; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HugeGraphSourceConfigTest { + + @Test + void testDefaultValues() { + HugeGraphSourceConfig config = + HugeGraphSourceConfig.of(ReadonlyConfig.fromMap(baseConfig()), schema()); + + assertEquals("person", config.getLabel()); + assertEquals(MappingConfig.LabelType.VERTEX, config.getLabelType()); + assertEquals(HugeGraphSourceOptions.PAGE_SIZE.defaultValue(), config.getPageSize()); + assertEquals("127.0.0.1", config.getConnectionConfig().getHost()); + } + + @Test + void testEdgeLabelTypeAndPageSize() { + Map configMap = baseConfig(); + configMap.put("label_type", "EDGE"); + configMap.put("page_size", 5000); + + HugeGraphSourceConfig config = + HugeGraphSourceConfig.of(ReadonlyConfig.fromMap(configMap), schema()); + + assertEquals(MappingConfig.LabelType.EDGE, config.getLabelType()); + assertEquals(5000, config.getPageSize()); + } + + @Test + void testPageSizeRange() { + Map configMap = baseConfig(); + configMap.put("page_size", 99); + + assertThrows( + HugeGraphConnectorException.class, + () -> HugeGraphSourceConfig.of(ReadonlyConfig.fromMap(configMap), schema())); + } + + @Test + void testSplitSizeBelowMinimumRejected() { + // A tiny split_size shatters the keyspace into a huge number of shards (one split each, all + // persisted into every checkpoint) — reject anything below the HugeGraph minimum shard + // size. + Map configMap = baseConfig(); + configMap.put("split_size", 1024L); + + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphSourceConfig.of( + ReadonlyConfig.fromMap(configMap), schema())); + assertTrue(ex.getMessage().contains("split_size")); + } + + @Test + void testSplitSizeAtMinimumAccepted() { + Map configMap = baseConfig(); + configMap.put("split_size", HugeGraphSourceOptions.MIN_SPLIT_SIZE); + + HugeGraphSourceConfig config = + HugeGraphSourceConfig.of(ReadonlyConfig.fromMap(configMap), schema()); + assertEquals(HugeGraphSourceOptions.MIN_SPLIT_SIZE, config.getSplitSize()); + } + + @Test + void testSchemaRequired() { + // null schema is rejected + assertThrows( + HugeGraphConnectorException.class, + () -> HugeGraphSourceConfig.of(ReadonlyConfig.fromMap(baseConfig()), null)); + } + + @Test + void testEmptyFieldsAllowedForPropertyLessLabel() { + // A property-less label (e.g. a pure relationship edge) has zero declared fields and is + // exported as just the reserved columns — this must be accepted, not rejected. + HugeGraphSourceConfig config = + HugeGraphSourceConfig.of( + ReadonlyConfig.fromMap(baseConfig()), + new SeaTunnelRowType(new String[] {}, new SeaTunnelDataType[] {})); + assertEquals(0, config.getSchema().getTotalFields()); + } + + @Test + void testGraphSpaceIsHonored() { + Map configMap = baseConfig(); + configMap.put("graph_space", "my_space"); + + HugeGraphSourceConfig config = + HugeGraphSourceConfig.of(ReadonlyConfig.fromMap(configMap), schema()); + assertEquals("my_space", config.getConnectionConfig().getGraphSpace()); + } + + @Test + void testGraphSpaceDefaultsToDefault() { + HugeGraphSourceConfig config = + HugeGraphSourceConfig.of(ReadonlyConfig.fromMap(baseConfig()), schema()); + assertEquals("DEFAULT", config.getConnectionConfig().getGraphSpace()); + } + + @Test + void testReadAllSetsFlagAndLabelsWithoutSchema() { + HugeGraphSourceConfig config = + HugeGraphSourceConfig.ofReadAll( + ReadonlyConfig.fromMap(baseConfig()), Arrays.asList("person", "software")); + + assertTrue(config.isReadAllLabels()); + assertNull(config.getLabel()); + assertNull(config.getSchema()); + assertEquals(Arrays.asList("person", "software"), config.getLabels()); + } + + @Test + void testReadAllRejectsEmptyLabels() { + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphSourceConfig.ofReadAll( + ReadonlyConfig.fromMap(baseConfig()), Collections.emptyList())); + } + + private Map baseConfig() { + Map configMap = new HashMap<>(); + configMap.put("host", "127.0.0.1"); + configMap.put("port", 8080); + configMap.put("graph_name", "hugegraph"); + configMap.put("label", "person"); + return configMap; + } + + private SeaTunnelRowType schema() { + return new SeaTunnelRowType( + new String[] {"name"}, new SeaTunnelDataType[] {BasicType.STRING_TYPE}); + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/EdgeMapperTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/EdgeMapperTest.java new file mode 100644 index 000000000000..9cc031ab15c4 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/EdgeMapperTest.java @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.mapper; + +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; + +import org.apache.hugegraph.structure.constant.Frequency; +import org.apache.hugegraph.structure.constant.IdStrategy; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Locks the HugeGraph server-side 5-part EdgeId format used on DELETE. Regressions here silently + * target the wrong edge (or none), so the exact layout is pinned by these assertions. Expected + * strings were captured from HugeGraph's own {@code SplicingIdGenerator} (client 1.5.0). + */ +class EdgeMapperTest { + + @Test + void testSingleFrequencyStringEndpoints() { + // {S}{owner}>{labelId}>{subLabelId}>{sortValues=empty}>{S}{other} + assertEquals( + "S1:marko>1>1>>S1:david", + EdgeMapper.spliceEdgeId("1:marko", "1:david", "1", Collections.emptyList())); + } + + @Test + void testMultipleFrequencyPopulatesSortValuesSegment() { + assertEquals( + "S1:bob>2>2>2024-01-01>S3:proj", + EdgeMapper.spliceEdgeId( + "1:bob", "3:proj", "2", Collections.singletonList("2024-01-01"))); + } + + @Test + void testNumberEndpointsUseLPrefix() { + assertEquals( + "L123>5>5>>L456", + EdgeMapper.spliceEdgeId(123L, 456L, "5", Collections.emptyList())); + } + + @Test + void testUuidEndpointsUseUPrefix() { + UUID src = UUID.fromString("12345678-1234-1234-1234-123456789abc"); + UUID tgt = UUID.fromString("87654321-4321-4321-4321-cba987654321"); + assertEquals( + "U12345678-1234-1234-1234-123456789abc>9>9>>U87654321-4321-4321-4321-cba987654321", + EdgeMapper.spliceEdgeId(src, tgt, "9", Collections.emptyList())); + } + + @Test + void testCompositeSortValuesJoinedByBang() { + assertEquals( + "S1:a>7>7>x!y>S1:b", + EdgeMapper.spliceEdgeId("1:a", "1:b", "7", Arrays.asList("x", "y"))); + } + + @Test + void testSortValueContainingSeparatorIsEscaped() { + // A single sort value that literally contains '!' must be backtick-escaped so it is not + // read back as two values. + assertEquals( + "S1:a>7>7>x`!y>S1:b", + EdgeMapper.spliceEdgeId("1:a", "1:b", "7", Collections.singletonList("x!y"))); + } + + @Test + void testVertexIdContainingSeparatorIsEscaped() { + // A vertex id that contains the segment separator '>' must be backtick-escaped so the id + // still parses into the correct 5 segments. + assertEquals( + "S1:a`>b>7>7>>S1:c", + EdgeMapper.spliceEdgeId("1:a>b", "1:c", "7", Collections.emptyList())); + } + + @Test + void testRawIdPassthroughPrimaryKeyEndpointsKeepStringPrefix() { + // ~source_id/~target_id carry the already-assembled endpoint ids ("2:alice"); a + // PRIMARY_KEY endpoint re-applies the 'S' prefix, matching the normal splicing path. + EdgeMapper mapper = rawPassthroughEdgeMapper(IdStrategy.PRIMARY_KEY); + Object id = mapper.extractId(new SeaTunnelRow(new Object[] {"2:alice", "2:bob"})); + assertEquals("S2:alice>1>1>>S2:bob", id); + } + + @Test + void testRawIdPassthroughNumberEndpointsUseLPrefix() { + // A CUSTOMIZE_NUMBER endpoint: the ~source_id string is parsed back to a long so the 'L' + // prefix is restored. + EdgeMapper mapper = rawPassthroughEdgeMapper(IdStrategy.CUSTOMIZE_NUMBER); + Object id = mapper.extractId(new SeaTunnelRow(new Object[] {"123", "456"})); + assertEquals("L123>1>1>>L456", id); + } + + @Test + void testRawIdPassthroughSkipsWhenEndpointIdNull() { + EdgeMapper mapper = rawPassthroughEdgeMapper(IdStrategy.PRIMARY_KEY); + assertEquals(null, mapper.extractId(new SeaTunnelRow(new Object[] {null, "2:bob"}))); + } + + private static EdgeMapper rawPassthroughEdgeMapper(IdStrategy endpointStrategy) { + HugeGraphClient client = mock(HugeGraphClient.class); + when(client.getEdgeLabelId("knows")).thenReturn("1"); + when(client.getVertexLabelId("person")).thenReturn("2"); + when(client.getIdStrategy("person")).thenReturn(endpointStrategy); + when(client.getPropertyKeyOrNull(anyString())).thenReturn(null); + + MappingConfig mapping = new MappingConfig(); + mapping.setType(MappingConfig.LabelType.EDGE); + mapping.setLabel("knows"); + mapping.setSourceConfig(endpoint("person", "~source_id")); + mapping.setTargetConfig(endpoint("person", "~target_id")); + mapping.setFrequency(Frequency.SINGLE); + + Map fields = new LinkedHashMap<>(); + fields.put("~source_id", 0); + fields.put("~target_id", 1); + return new EdgeMapper(mapping, fields, client); + } + + private static MappingConfig.SourceTargetConfig endpoint(String label, String idField) { + MappingConfig.SourceTargetConfig st = new MappingConfig.SourceTargetConfig(); + st.setLabel(label); + st.setIdFields(Collections.singletonList(idField)); + return st; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/VertexMapperTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/VertexMapperTest.java new file mode 100644 index 000000000000..b69237dde43d --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/mapper/VertexMapperTest.java @@ -0,0 +1,309 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.mapper; + +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.apache.hugegraph.serializer.direct.util.SplicingIdGenerator; +import org.apache.hugegraph.structure.GraphElement; +import org.apache.hugegraph.structure.constant.Cardinality; +import org.apache.hugegraph.structure.constant.DataType; +import org.apache.hugegraph.structure.constant.IdStrategy; +import org.apache.hugegraph.structure.graph.Vertex; +import org.apache.hugegraph.structure.schema.PropertyKey; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class VertexMapperTest { + + @Test + void testEmptyPropertiesWritesAllInputFields() { + HugeGraphClient client = mock(HugeGraphClient.class); + PropertyKey name = propertyKey("name", DataType.TEXT); + PropertyKey age = propertyKey("age", DataType.INT); + when(client.getVertexLabelId("person")).thenReturn("1"); + when(client.getPropertyKey("name")).thenReturn(name); + when(client.getPropertyKey("age")).thenReturn(age); + + MappingConfig mapping = vertexMapping(IdStrategy.PRIMARY_KEY, "name"); + VertexMapper mapper = new VertexMapper(mapping, fields("name", "age"), client); + + Vertex vertex = mapper.map(new SeaTunnelRow(new Object[] {"Alice", 29})); + + assertEquals("Alice", vertex.properties().get("name")); + assertEquals(29, vertex.properties().get("age")); + } + + @Test + void testCustomIdDoesNotRequirePropertyKey() { + HugeGraphClient client = mock(HugeGraphClient.class); + PropertyKey age = propertyKey("age", DataType.INT); + when(client.getVertexLabelId("person")).thenReturn("1"); + when(client.getPropertyKey("age")).thenReturn(age); + when(client.getPropertyKeyOrNull("external_id")).thenReturn(null); + + MappingConfig mapping = vertexMapping(IdStrategy.CUSTOMIZE_STRING, "external_id"); + mapping.setProperties(Arrays.asList("age")); + VertexMapper mapper = new VertexMapper(mapping, fields("external_id", "age"), client); + + Vertex vertex = mapper.map(new SeaTunnelRow(new Object[] {"user-1", 29})); + + assertEquals("user-1", vertex.id()); + assertEquals(29, vertex.properties().get("age")); + } + + @Test + void testNullValueInRequiredIdSkipsVertex() { + HugeGraphClient client = mock(HugeGraphClient.class); + PropertyKey name = propertyKey("name", DataType.TEXT); + when(client.getVertexLabelId("person")).thenReturn("1"); + when(client.getPropertyKey("name")).thenReturn(name); + + MappingConfig mapping = vertexMapping(IdStrategy.PRIMARY_KEY, "name"); + mapping.setNullValues(Arrays.asList("NULL")); + VertexMapper mapper = new VertexMapper(mapping, fields("name"), client); + + assertNull(mapper.map(new SeaTunnelRow(new Object[] {"NULL"}))); + } + + @Test + void testRawIdPassthroughCustomizeString() { + // idFields = ["~id"] reuses the pre-assembled Source id verbatim so a CUSTOMIZE_STRING + // vertex can be cloned without knowing its original key columns. + HugeGraphClient client = mock(HugeGraphClient.class); + when(client.getVertexLabelId("person")).thenReturn("1"); + when(client.getPropertyKeyOrNull("~id")).thenReturn(null); + + MappingConfig mapping = vertexMapping(IdStrategy.CUSTOMIZE_STRING, "~id"); + VertexMapper mapper = new VertexMapper(mapping, fields("~id"), client); + + Vertex vertex = mapper.map(new SeaTunnelRow(new Object[] {"user-42"})); + assertEquals("user-42", vertex.id()); + } + + @Test + void testRawIdPassthroughCustomizeNumber() { + HugeGraphClient client = mock(HugeGraphClient.class); + when(client.getVertexLabelId("person")).thenReturn("1"); + when(client.getPropertyKeyOrNull("~id")).thenReturn(null); + + MappingConfig mapping = vertexMapping(IdStrategy.CUSTOMIZE_NUMBER, "~id"); + VertexMapper mapper = new VertexMapper(mapping, fields("~id"), client); + + Vertex vertex = mapper.map(new SeaTunnelRow(new Object[] {123L})); + assertEquals(123L, vertex.id()); + } + + @Test + void testRawIdPassthroughCustomizeNumberFromString() { + // The Source serializes ~id as a String; a CUSTOMIZE_NUMBER target must parse it back. + HugeGraphClient client = mock(HugeGraphClient.class); + when(client.getVertexLabelId("person")).thenReturn("1"); + when(client.getPropertyKeyOrNull("~id")).thenReturn(null); + + MappingConfig mapping = vertexMapping(IdStrategy.CUSTOMIZE_NUMBER, "~id"); + VertexMapper mapper = new VertexMapper(mapping, fields("~id"), client); + + Vertex vertex = mapper.map(new SeaTunnelRow(new Object[] {"456"})); + assertEquals(456L, vertex.id()); + } + + @Test + void testValueMappingIsScopedPerField() { + // gender maps M->male; status maps M->married. A flat value_mapping would let one column's + // rule bleed into the other (both M cells become "male"). Per-field scoping must keep them + // independent. + HugeGraphClient client = mock(HugeGraphClient.class); + PropertyKey gender = propertyKey("gender", DataType.TEXT); + PropertyKey status = propertyKey("status", DataType.TEXT); + when(client.getVertexLabelId("person")).thenReturn("1"); + when(client.getPropertyKey("gender")).thenReturn(gender); + when(client.getPropertyKey("status")).thenReturn(status); + when(client.getPropertyKeyOrNull("id")).thenReturn(null); + + MappingConfig mapping = vertexMapping(IdStrategy.CUSTOMIZE_STRING, "id"); + mapping.setProperties(Arrays.asList("gender", "status")); + Map> valueMapping = new HashMap<>(); + valueMapping.put("gender", Collections.singletonMap("M", "male")); + valueMapping.put("status", Collections.singletonMap("M", "married")); + mapping.setValueMapping(valueMapping); + + VertexMapper mapper = new VertexMapper(mapping, fields("id", "gender", "status"), client); + Vertex vertex = mapper.map(new SeaTunnelRow(new Object[] {"u1", "M", "M"})); + + assertEquals("male", vertex.properties().get("gender")); + assertEquals("married", vertex.properties().get("status")); + } + + @Test + void testUnfoldExpandsListIdIntoMultipleVertices() { + HugeGraphClient client = mock(HugeGraphClient.class); + PropertyKey age = propertyKey("age", DataType.INT); + when(client.getVertexLabelId("person")).thenReturn("1"); + when(client.getPropertyKey("age")).thenReturn(age); + when(client.getPropertyKeyOrNull("id")).thenReturn(null); + + MappingConfig mapping = vertexMapping(IdStrategy.CUSTOMIZE_STRING, "id"); + mapping.setProperties(Arrays.asList("age")); + mapping.setUnfold(true); + VertexMapper mapper = new VertexMapper(mapping, fields("id", "age"), client); + + List elements = + mapper.mapAll(new SeaTunnelRow(new Object[] {new String[] {"a", "b", "c"}, 30})); + + assertEquals(3, elements.size()); + assertEquals( + Arrays.asList("a", "b", "c"), + elements.stream().map(GraphElement::id).collect(Collectors.toList())); + for (GraphElement element : elements) { + // The unfolded id column is not written as a property; only shared props are. + assertEquals(30, ((Vertex) element).properties().get("age")); + } + } + + @Test + void testPrimaryKeyIdEscapesSeparatorLikeServer() { + // A PRIMARY_KEY value containing the '!' separator must be backtick-escaped exactly as the + // HugeGraph server assembles the id (SplicingIdGenerator.concatValues). EdgeMapper already + // uses concatValues for the same concept; VertexMapper must match so DELETE / key-changing + // UPDATE target the real vertex id instead of an ambiguous, unescaped join. + HugeGraphClient client = mock(HugeGraphClient.class); + PropertyKey name = propertyKey("name", DataType.TEXT); + when(client.getVertexLabelId("person")).thenReturn("1"); + when(client.getPropertyKey("name")).thenReturn(name); + + MappingConfig mapping = vertexMapping(IdStrategy.PRIMARY_KEY, "name"); + VertexMapper mapper = new VertexMapper(mapping, fields("name"), client); + + Object id = mapper.extractId(new SeaTunnelRow(new Object[] {"a!b"})); + + String expected = "1:" + SplicingIdGenerator.concatValues(Collections.singletonList("a!b")); + assertEquals(expected, id); + // The naive join silently produces an ambiguous, server-mismatched id. + assertNotEquals("1:a!b", id); + } + + @Test + void testMultiFieldCustomizeStringIdEscapesSeparatorToAvoidCollision() { + // ("x:y","z") and ("x","y:z") both collapsed to "x:y:z" with a raw ':' join, so different + // rows produced the same vertex id and overwrote each other. Escaping must keep them + // distinct. + HugeGraphClient client = mock(HugeGraphClient.class); + when(client.getVertexLabelId("person")).thenReturn("1"); + when(client.getPropertyKeyOrNull("a")).thenReturn(null); + when(client.getPropertyKeyOrNull("b")).thenReturn(null); + + MappingConfig mapping = new MappingConfig(); + mapping.setType(MappingConfig.LabelType.VERTEX); + mapping.setLabel("person"); + mapping.setIdStrategy(IdStrategy.CUSTOMIZE_STRING); + mapping.setIdFields(Arrays.asList("a", "b")); + VertexMapper mapper = new VertexMapper(mapping, fields("a", "b"), client); + + Object id1 = mapper.extractId(new SeaTunnelRow(new Object[] {"x:y", "z"})); + Object id2 = mapper.extractId(new SeaTunnelRow(new Object[] {"x", "y:z"})); + + assertNotEquals(id1, id2, "Distinct field tuples must not collapse to the same id"); + } + + @Test + void testSingleFieldCustomizeStringIdIsVerbatim() { + // A single id field is unambiguous, so its value (even containing ':') is used as-is — + // escaping it would change ids already written for the common single-field case. + HugeGraphClient client = mock(HugeGraphClient.class); + when(client.getVertexLabelId("person")).thenReturn("1"); + when(client.getPropertyKeyOrNull("a")).thenReturn(null); + + MappingConfig mapping = new MappingConfig(); + mapping.setType(MappingConfig.LabelType.VERTEX); + mapping.setLabel("person"); + mapping.setIdStrategy(IdStrategy.CUSTOMIZE_STRING); + mapping.setIdFields(Collections.singletonList("a")); + VertexMapper mapper = new VertexMapper(mapping, fields("a"), client); + + assertEquals("x:y", mapper.extractId(new SeaTunnelRow(new Object[] {"x:y"}))); + } + + @Test + void testCustomizeNumberIdRejectsFractionalConsistently() { + // A Number 1.9 was silently truncated to 1 while the string "1.9" threw — same logical + // input, + // different result. Both must now be rejected; integral decimals (1.0 / "1.0") are + // accepted. + assertEquals(1L, VertexMapper.coerceNumberId(1L)); + assertEquals(1L, VertexMapper.coerceNumberId("1")); + assertEquals(1L, VertexMapper.coerceNumberId(1.0d)); + assertEquals(1L, VertexMapper.coerceNumberId("1.0")); + assertThrows(HugeGraphConnectorException.class, () -> VertexMapper.coerceNumberId(1.9d)); + assertThrows(HugeGraphConnectorException.class, () -> VertexMapper.coerceNumberId("1.9")); + assertThrows(HugeGraphConnectorException.class, () -> VertexMapper.coerceNumberId("abc")); + } + + @Test + void testVertexIdLengthLimitEnforced() { + String maxLen = String.join("", Collections.nCopies(128, "a")); // exactly 128 bytes + assertEquals(maxLen, VertexMapper.checkVertexIdLength(maxLen)); + + String tooLong = String.join("", Collections.nCopies(129, "a")); // 129 bytes + assertThrows( + HugeGraphConnectorException.class, () -> VertexMapper.checkVertexIdLength(tooLong)); + } + + private static MappingConfig vertexMapping(IdStrategy idStrategy, String idField) { + MappingConfig mapping = new MappingConfig(); + mapping.setType(MappingConfig.LabelType.VERTEX); + mapping.setLabel("person"); + mapping.setIdStrategy(idStrategy); + mapping.setIdFields(Arrays.asList(idField)); + return mapping; + } + + private static Map fields(String... names) { + Map fields = new LinkedHashMap<>(); + for (int i = 0; i < names.length; i++) { + fields.put(names[i], i); + } + return fields; + } + + private static PropertyKey propertyKey(String name, DataType dataType) { + PropertyKey propertyKey = mock(PropertyKey.class); + when(propertyKey.name()).thenReturn(name); + when(propertyKey.dataType()).thenReturn(dataType); + when(propertyKey.cardinality()).thenReturn(Cardinality.SINGLE); + return propertyKey; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSaveModeHandlerTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSaveModeHandlerTest.java new file mode 100644 index 000000000000..850b8108c00c --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSaveModeHandlerTest.java @@ -0,0 +1,223 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.sink; + +import org.apache.seatunnel.api.sink.DataSaveMode; +import org.apache.seatunnel.api.sink.SchemaSaveMode; +import org.apache.seatunnel.api.table.catalog.TablePath; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphDataSaveMode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSchemaSaveMode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSinkConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +class HugeGraphSaveModeHandlerTest { + + @Test + void dropDataDeletesOnlyTargetLabelsEdgesBeforeVertices() { + HugeGraphClient client = mock(HugeGraphClient.class); + // DROP_DATA pre-flight: for each vertex label, discover connected edge labels. Return only + // the edge labels already in the mappings so the check passes — the job explicitly targets + // everything that would be cascade-deleted. + doReturn(Collections.singletonList("knows")).when(client).getConnectedEdgeLabels("person"); + doReturn(Collections.emptyList()).when(client).getConnectedEdgeLabels("company"); + + HugeGraphSinkConfig config = + config( + HugeGraphDataSaveMode.DROP_DATA, + vertex("person"), + edge("knows"), + vertex("company")); + HugeGraphSaveModeHandler handler = handler(config, client); + + handler.handleDataSaveMode(); + + // Edges are cleared before vertices, and only the labels this job targets are touched — the + // whole-graph clearGraph that wiped sibling tables and their schema is gone. + InOrder order = inOrder(client); + order.verify(client).deleteEdgesByLabel("knows"); + order.verify(client).deleteVerticesByLabel("person"); + order.verify(client).deleteVerticesByLabel("company"); + verify(client, never()).deleteEdgesByLabel("person"); + verify(client, never()).deleteVerticesByLabel("knows"); + } + + @Test + void appendDataDeletesNothing() { + HugeGraphClient client = mock(HugeGraphClient.class); + HugeGraphSinkConfig config = + config(HugeGraphDataSaveMode.APPEND_DATA, vertex("person"), edge("knows")); + HugeGraphSaveModeHandler handler = handler(config, client); + + handler.handleDataSaveMode(); + + verify(client, never()).deleteVerticesByLabel(anyString()); + verify(client, never()).deleteEdgesByLabel(anyString()); + } + + @Test + void restoreRunsSchemaButNeverDropsData() { + // On checkpoint restore the engine calls only handleSchemaSaveModeWithRestore(); it must + // (re)handle schema but must never drop data, otherwise data written before the restart is + // lost — the original bug when the drop lived in the sink constructor. + HugeGraphClient client = mock(HugeGraphClient.class); + HugeGraphSinkConfig config = + config(HugeGraphDataSaveMode.DROP_DATA, vertex("person"), edge("knows")); + HugeGraphSaveModeHandler handler = spy(handler(config, client)); + doNothing().when(handler).handleSchemaSaveMode(); + + handler.handleSchemaSaveModeWithRestore(); + + verify(handler).handleSchemaSaveMode(); + verify(client, never()).deleteVerticesByLabel(anyString()); + verify(client, never()).deleteEdgesByLabel(anyString()); + } + + @Test + void dropDataRejectsUnmappedEdgeLabels() { + // Vertex 'person' has an incident edge 'employs' that is NOT in the mappings. DROP_DATA + // must fail fast rather than silently cascade-deleting it. + HugeGraphClient client = mock(HugeGraphClient.class); + doReturn(Arrays.asList("knows", "employs")).when(client).getConnectedEdgeLabels("person"); + + HugeGraphSinkConfig config = + config(HugeGraphDataSaveMode.DROP_DATA, vertex("person"), edge("knows")); + HugeGraphSaveModeHandler handler = handler(config, client); + + HugeGraphConnectorException ex = + assertThrows(HugeGraphConnectorException.class, handler::handleDataSaveMode); + assertTrue( + ex.getMessage().contains("employs"), + "Error must name the unmapped edge label: " + ex.getMessage()); + assertTrue( + ex.getMessage().contains("allow_cascade_delete_unmapped_edges"), + "Error must mention the opt-in option: " + ex.getMessage()); + } + + @Test + void dropDataAllowsCascadeWhenOptedIn() { + // With allow_cascade_delete_unmapped_edges=true, the pre-flight check is skipped + // and DROP_DATA proceeds with the destructive cascade. + HugeGraphClient client = mock(HugeGraphClient.class); + doReturn(Arrays.asList("knows", "employs")).when(client).getConnectedEdgeLabels("person"); + + HugeGraphSinkConfig config = + config(HugeGraphDataSaveMode.DROP_DATA, vertex("person"), edge("knows")); + config.setAllowCascadeDeleteUnmappedEdges(true); + HugeGraphSaveModeHandler handler = handler(config, client); + + // Must not throw — the opt-in suppresses the pre-flight check. + handler.handleDataSaveMode(); + + verify(client).deleteEdgesByLabel("knows"); + verify(client).deleteVerticesByLabel("person"); + } + + @Test + void dropDataVertexWithNoConnectedEdgesSucceeds() { + // A vertex label with no incident edges passes the pre-flight check trivially. + HugeGraphClient client = mock(HugeGraphClient.class); + doReturn(Collections.emptyList()).when(client).getConnectedEdgeLabels("person"); + + HugeGraphSinkConfig config = config(HugeGraphDataSaveMode.DROP_DATA, vertex("person")); + HugeGraphSaveModeHandler handler = handler(config, client); + + // Must not throw. + handler.handleDataSaveMode(); + + verify(client).deleteVerticesByLabel("person"); + } + + @Test + void getHandleCatalogNameIsNonNullForWrapperLogging() { + HugeGraphSinkConfig config = config(HugeGraphDataSaveMode.APPEND_DATA, vertex("person")); + HugeGraphSaveModeHandler handler = handler(config, mock(HugeGraphClient.class)); + + assertEquals("HugeGraph", handler.getHandleCatalog().name()); + } + + @Test + void saveModeEnumsMapToApiValues() { + HugeGraphSinkConfig config = config(HugeGraphDataSaveMode.DROP_DATA, vertex("person")); + config.setSchemaSaveMode(HugeGraphSchemaSaveMode.ERROR_WHEN_SCHEMA_NOT_EXIST); + HugeGraphSaveModeHandler handler = handler(config, mock(HugeGraphClient.class)); + + assertEquals(SchemaSaveMode.ERROR_WHEN_SCHEMA_NOT_EXIST, handler.getSchemaSaveMode()); + assertEquals(DataSaveMode.DROP_DATA, handler.getDataSaveMode()); + } + + private static HugeGraphSaveModeHandler handler( + HugeGraphSinkConfig config, HugeGraphClient client) { + HugeGraphSaveModeHandler handler = + spy(new HugeGraphSaveModeHandler(config, rowType(), TablePath.of("hugegraph"))); + doReturn(client).when(handler).createClient(); + handler.open(); + return handler; + } + + private static HugeGraphSinkConfig config( + HugeGraphDataSaveMode dataSaveMode, MappingConfig... mappings) { + HugeGraphSinkConfig config = new HugeGraphSinkConfig(); + config.setMappings(Arrays.asList(mappings)); + config.setDataSaveMode(dataSaveMode); + config.setSchemaSaveMode(HugeGraphSchemaSaveMode.CREATE_SCHEMA_WHEN_NOT_EXIST); + return config; + } + + private static MappingConfig vertex(String label) { + return mapping(MappingConfig.LabelType.VERTEX, label); + } + + private static MappingConfig edge(String label) { + return mapping(MappingConfig.LabelType.EDGE, label); + } + + private static MappingConfig mapping(MappingConfig.LabelType type, String label) { + MappingConfig mapping = new MappingConfig(); + mapping.setType(type); + mapping.setLabel(label); + return mapping; + } + + private static SeaTunnelRowType rowType() { + return new SeaTunnelRowType( + new String[] {"id"}, new SeaTunnelDataType[] {BasicType.STRING_TYPE}); + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkFactoryTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkFactoryTest.java new file mode 100644 index 000000000000..b8db1995eb33 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkFactoryTest.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.sink; + +import org.apache.seatunnel.api.configuration.Option; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphOptions; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSinkOptions; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HugeGraphSinkFactoryTest { + + /** + * Every option the sink actually reads must be declared in {@code optionRule()}; otherwise + * {@code seatunnel.sh --config x --check} (and STATIC dry-run) reject it as an unknown key and + * it is invisible to option-listing tooling. + */ + @Test + void optionRuleDeclaresAllReadOptions() { + List> optional = new HugeGraphSinkFactory().optionRule().getOptionalOptions(); + assertTrue( + optional.contains(HugeGraphSinkOptions.DATA_SAVE_MODE), "data_save_mode missing"); + assertTrue(optional.contains(HugeGraphOptions.CHECK_VERTEX), "check_vertex missing"); + assertTrue( + optional.contains(HugeGraphOptions.BATCH_FAILURE_FALLBACK), + "batch_failure_fallback missing"); + assertTrue( + optional.contains(HugeGraphOptions.MAX_INSERT_ERRORS), "max_insert_errors missing"); + assertTrue( + optional.contains(HugeGraphOptions.FAILURE_DATA_PATH), "failure_data_path missing"); + assertTrue( + optional.contains(HugeGraphOptions.RETRY_BACKOFF_MAX_MS), + "retry_backoff_max_ms missing"); + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkWriterMultiTableTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkWriterMultiTableTest.java new file mode 100644 index 000000000000..94ffea195a2a --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkWriterMultiTableTest.java @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.sink; + +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSinkConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.apache.hugegraph.structure.constant.Cardinality; +import org.apache.hugegraph.structure.constant.DataType; +import org.apache.hugegraph.structure.schema.PropertyKey; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Verifies the multi-table mapping contract: when {@code source_table} is configured, each writer + * activates only the mappings that match its table; two writers for different tables must not share + * mappings (no cross-write). When no mapping matches in multi-table mode, the writer must fail fast + * rather than silently becoming a no-op. + */ +class HugeGraphSinkWriterMultiTableTest { + + private static final SeaTunnelRowType PERSON_ROW_TYPE = + new SeaTunnelRowType( + new String[] {"name", "age"}, + new SeaTunnelDataType[] {BasicType.STRING_TYPE, BasicType.INT_TYPE}); + + private static final SeaTunnelRowType COMPANY_ROW_TYPE = + new SeaTunnelRowType( + new String[] {"name", "industry"}, + new SeaTunnelDataType[] {BasicType.STRING_TYPE, BasicType.STRING_TYPE}); + + // --- Cross-write isolation --- + + @Test + void twoTablesTwoLabelsNoCrossWrite() { + HugeGraphSinkConfig config = multiTableConfig(); + HugeGraphClient client = stubbedClient(); + + HugeGraphSinkWriter personWriter = + new HugeGraphSinkWriter(config, PERSON_ROW_TYPE, "hugegraph.person", client, 0); + HugeGraphSinkWriter companyWriter = + new HugeGraphSinkWriter(config, COMPANY_ROW_TYPE, "hugegraph.company", client, 1); + + List personEntries = personWriter.mappingEntries(); + List companyEntries = companyWriter.mappingEntries(); + + assertEquals(1, personEntries.size(), "person writer should have exactly 1 mapping"); + assertEquals("person", personEntries.get(0).config.getLabel()); + + assertEquals(1, companyEntries.size(), "company writer should have exactly 1 mapping"); + assertEquals("company", companyEntries.get(0).config.getLabel()); + } + + @Test + void multiTableMappingDoesNotLeakBetweenWriters() { + HugeGraphSinkConfig config = multiTableConfig(); + HugeGraphClient client = stubbedClient(); + + HugeGraphSinkWriter personWriter = + new HugeGraphSinkWriter(config, PERSON_ROW_TYPE, "hugegraph.person", client, 0); + HugeGraphSinkWriter companyWriter = + new HugeGraphSinkWriter(config, COMPANY_ROW_TYPE, "hugegraph.company", client, 1); + + // Verify person writer only writes to person label + for (HugeGraphSinkWriter.MappingEntry entry : personWriter.mappingEntries()) { + assertEquals("person", entry.config.getLabel()); + assertEquals(MappingConfig.LabelType.VERTEX, entry.config.getType()); + } + + // Verify company writer only writes to company label + for (HugeGraphSinkWriter.MappingEntry entry : companyWriter.mappingEntries()) { + assertEquals("company", entry.config.getLabel()); + assertEquals(MappingConfig.LabelType.VERTEX, entry.config.getType()); + } + } + + // --- No-match fail-fast --- + + @Test + void multiTableNoMatchThrowsException() { + HugeGraphSinkConfig config = multiTableConfig(); + HugeGraphClient client = stubbedClient(); + + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> + new HugeGraphSinkWriter( + config, + PERSON_ROW_TYPE, + "hugegraph.unknown_table", + client, + 0)); + + assertTrue( + ex.getMessage().contains("No mapping matched"), + "Error must state no mapping matched"); + assertTrue( + ex.getMessage().contains("unknown_table"), + "Error must include the actual tablePath"); + assertTrue( + ex.getMessage().contains("source_table"), + "Error must reference source_table so the user knows what to fix"); + } + + // --- Single-table backward compatibility --- + + @Test + void singleTableAllMappingsActive() { + HugeGraphSinkConfig config = singleTableConfig(); + HugeGraphClient client = stubbedClient(); + + HugeGraphSinkWriter writer = + new HugeGraphSinkWriter(config, PERSON_ROW_TYPE, "any.table.path", client, 0); + + List entries = writer.mappingEntries(); + assertEquals(2, entries.size(), "both mappings should be active in single-table mode"); + } + + @Test + void singleTableEmptyTablePathIsBackwardCompatible() { + HugeGraphSinkConfig config = singleTableConfig(); + HugeGraphClient client = stubbedClient(); + + HugeGraphSinkWriter writer = new HugeGraphSinkWriter(config, PERSON_ROW_TYPE, client, 0); + + List entries = writer.mappingEntries(); + assertEquals(2, entries.size(), "all mappings active when tablePath is empty (old API)"); + } + + // --- Helpers --- + + private static HugeGraphSinkConfig multiTableConfig() { + HugeGraphSinkConfig config = new HugeGraphSinkConfig(); + config.setMappings( + Arrays.asList( + vertexMapping("person", "hugegraph.person"), + vertexMapping("company", "hugegraph.company"))); + return config; + } + + private static HugeGraphSinkConfig singleTableConfig() { + HugeGraphSinkConfig config = new HugeGraphSinkConfig(); + config.setMappings( + Arrays.asList(vertexMapping("person", null), vertexMapping("company", null))); + return config; + } + + private static MappingConfig vertexMapping(String label, String sourceTable) { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel(label); + m.setIdStrategy(org.apache.hugegraph.structure.constant.IdStrategy.PRIMARY_KEY); + m.setIdFields(Collections.singletonList("name")); + if (sourceTable != null) { + m.setSourceTable(sourceTable); + } + return m; + } + + private static HugeGraphClient stubbedClient() { + HugeGraphClient client = mock(HugeGraphClient.class); + // VertexMapper constructor calls getVertexLabelId(label). + when(client.getVertexLabelId(anyString())).thenReturn("1"); + // buildPropertyKeyCache calls getPropertyKey for each property field + id field. + PropertyKey pk = mock(PropertyKey.class); + when(pk.dataType()).thenReturn(DataType.TEXT); + when(pk.cardinality()).thenReturn(Cardinality.SINGLE); + when(client.getPropertyKey(anyString())).thenReturn(pk); + return client; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkWriterUpdateTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkWriterUpdateTest.java new file mode 100644 index 000000000000..4af0bcf2e09b --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/sink/HugeGraphSinkWriterUpdateTest.java @@ -0,0 +1,435 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.sink; + +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.RowKind; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSinkConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.mapper.GraphDataMapper; + +import org.apache.hugegraph.structure.GraphElement; +import org.apache.hugegraph.structure.constant.Cardinality; +import org.apache.hugegraph.structure.constant.DataType; +import org.apache.hugegraph.structure.constant.IdStrategy; +import org.apache.hugegraph.structure.graph.Edge; +import org.apache.hugegraph.structure.graph.Vertex; +import org.apache.hugegraph.structure.schema.PropertyKey; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Pins the critical UPDATE invariant: buildUpdatePlan MUST NOT emit any Superseded (i.e. cause any + * remote delete) if the after-image mapping fails. Regressions here silently delete the pre-update + * vertex/edge and then throw, so the row is unrecoverable without upstream replay. + */ +class HugeGraphSinkWriterUpdateTest { + + @Test + void afterImageMappingFailureLeavesOldElementsIntact() { + // Simulate the reviewer's scenario: after-image cannot be mapped (e.g. required property + // type conversion fails). The pre-update element must survive — no Superseded may exist. + MappingConfig vertexCfg = vertexConfig("person"); + SeaTunnelRow before = row("v-old"); + SeaTunnelRow after = row("v-new"); + HugeGraphSinkWriter.MappingEntry entry = + new HugeGraphSinkWriter.MappingEntry( + vertexCfg, + new FakeMapper() { + @Override + public GraphElement map(SeaTunnelRow row) { + if (row == after) { + throw new RuntimeException("type conversion failed"); + } + return new Vertex(vertexCfg.getLabel()); + } + + @Override + public Object extractId(SeaTunnelRow row) { + return row == before ? "v-old-id" : "v-new-id"; + } + }); + + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphSinkWriter.buildUpdatePlan( + Collections.singletonList(entry), before, after)); + } + + @Test + void afterImageAbsentDoesNotDeleteOldElement() { + // The after row cannot be mapped for this mapping (map() returns null, e.g. a null id + // column). Even though extractId reports a different id, the old element must NOT be + // deleted — otherwise it would be dropped with nothing written back (silent data loss). + // A genuine removal must arrive as a DELETE changelog event. + MappingConfig vertexCfg = vertexConfig("person"); + SeaTunnelRow before = row("v-old"); + SeaTunnelRow after = row("v-null"); + HugeGraphSinkWriter.MappingEntry entry = + new HugeGraphSinkWriter.MappingEntry( + vertexCfg, + new FakeMapper() { + @Override + public GraphElement map(SeaTunnelRow row) { + return row == before ? new Vertex(vertexCfg.getLabel()) : null; + } + + @Override + public Object extractId(SeaTunnelRow row) { + return row == before ? "v-old-id" : "v-new-id"; + } + }); + + HugeGraphSinkWriter.UpdatePlan plan = + HugeGraphSinkWriter.buildUpdatePlan( + Collections.singletonList(entry), before, after); + assertTrue(plan.newVertices.isEmpty()); + assertTrue(plan.newEdges.isEmpty()); + // No after-image was produced → nothing is deleted. + assertTrue(plan.supersededVertices.isEmpty()); + assertTrue(plan.supersededEdges.isEmpty()); + } + + @Test + void perMappingAfterImageGatesDeletionIndependently() { + // Two mappings on one row: the vertex mapping produces an after-image with a changed id + // (→ its old must be deleted), while the edge mapping produces no after-image (→ its old + // must be kept). Deletion is gated per mapping, not globally. + MappingConfig vertexCfg = vertexConfig("person"); + MappingConfig edgeCfg = edgeConfig("knows"); + SeaTunnelRow before = row("before"); + SeaTunnelRow after = row("after"); + + HugeGraphSinkWriter.MappingEntry vEntry = + new HugeGraphSinkWriter.MappingEntry( + vertexCfg, + new FakeMapper() { + @Override + public GraphElement map(SeaTunnelRow row) { + return new Vertex(vertexCfg.getLabel()); + } + + @Override + public Object extractId(SeaTunnelRow row) { + return row == before ? "v-old" : "v-new"; + } + }); + HugeGraphSinkWriter.MappingEntry eEntry = + new HugeGraphSinkWriter.MappingEntry( + edgeCfg, + new FakeMapper() { + @Override + public GraphElement map(SeaTunnelRow row) { + return row == before ? new Edge(edgeCfg.getLabel()) : null; + } + + @Override + public Object extractId(SeaTunnelRow row) { + return row == before ? "e-old" : "e-new"; + } + }); + + HugeGraphSinkWriter.UpdatePlan plan = + HugeGraphSinkWriter.buildUpdatePlan(Arrays.asList(vEntry, eEntry), before, after); + + assertEquals(1, plan.newVertices.size()); + assertEquals(1, plan.supersededVertices.size()); + assertEquals("v-old", plan.supersededVertices.get(0).oldId); + // Edge produced no after-image → its old edge is not deleted. + assertTrue(plan.newEdges.isEmpty()); + assertTrue(plan.supersededEdges.isEmpty()); + } + + @Test + void unchangedIdProducesNoSuperseded() { + // Ordinary property update — no delete should be scheduled, or a vertex's adjacent edges + // would be lost. + MappingConfig vertexCfg = vertexConfig("person"); + SeaTunnelRow before = row("v"); + SeaTunnelRow after = row("v"); + HugeGraphSinkWriter.MappingEntry entry = + new HugeGraphSinkWriter.MappingEntry( + vertexCfg, + new FakeMapper() { + @Override + public GraphElement map(SeaTunnelRow row) { + return new Vertex(vertexCfg.getLabel()); + } + + @Override + public Object extractId(SeaTunnelRow row) { + return "same-id"; + } + }); + + HugeGraphSinkWriter.UpdatePlan plan = + HugeGraphSinkWriter.buildUpdatePlan( + Collections.singletonList(entry), before, after); + assertEquals(1, plan.newVertices.size()); + assertTrue(plan.supersededVertices.isEmpty()); + assertTrue(plan.supersededEdges.isEmpty()); + } + + @Test + void keyChangedProducesSuperseded() { + MappingConfig vertexCfg = vertexConfig("person"); + MappingConfig edgeCfg = edgeConfig("knows"); + SeaTunnelRow before = row("v-old"); + SeaTunnelRow after = row("v-new"); + HugeGraphSinkWriter.MappingEntry vEntry = + new HugeGraphSinkWriter.MappingEntry( + vertexCfg, + new FakeMapper() { + @Override + public GraphElement map(SeaTunnelRow row) { + return new Vertex(vertexCfg.getLabel()); + } + + @Override + public Object extractId(SeaTunnelRow row) { + return row == before ? "v-old-id" : "v-new-id"; + } + }); + HugeGraphSinkWriter.MappingEntry eEntry = + new HugeGraphSinkWriter.MappingEntry( + edgeCfg, + new FakeMapper() { + @Override + public GraphElement map(SeaTunnelRow row) { + return new Edge(edgeCfg.getLabel()); + } + + @Override + public Object extractId(SeaTunnelRow row) { + return row == before ? "e-old-id" : "e-new-id"; + } + }); + + HugeGraphSinkWriter.UpdatePlan plan = + HugeGraphSinkWriter.buildUpdatePlan(Arrays.asList(vEntry, eEntry), before, after); + assertEquals(1, plan.supersededVertices.size()); + assertEquals("v-old-id", plan.supersededVertices.get(0).oldId); + assertEquals(1, plan.supersededEdges.size()); + assertEquals("e-old-id", plan.supersededEdges.get(0).oldId); + assertEquals(1, plan.newVertices.size()); + assertEquals(1, plan.newEdges.size()); + } + + @Test + void automaticVertexOnUpdateIsRejected() { + // AUTOMATIC IDs cannot be identified for update — must fail fast in plan-building so no + // remote side effects have happened yet. + MappingConfig cfg = new MappingConfig(); + cfg.setType(MappingConfig.LabelType.VERTEX); + cfg.setLabel("auto"); + cfg.setIdStrategy(IdStrategy.AUTOMATIC); + HugeGraphSinkWriter.MappingEntry entry = + new HugeGraphSinkWriter.MappingEntry(cfg, new FakeMapper()); + + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphSinkWriter.buildUpdatePlan( + Collections.singletonList(entry), row("a"), row("b"))); + } + + @Test + void automaticVertexDeleteRejectedWithClearMessage() { + // DELETE of an AUTOMATIC-id vertex must fail with an id-strategy message, NOT the + // misleading + // "required ID field is null" — an AUTOMATIC vertex has no id field to begin with. + MappingConfig cfg = new MappingConfig(); + cfg.setType(MappingConfig.LabelType.VERTEX); + cfg.setLabel("auto"); + cfg.setIdStrategy(IdStrategy.AUTOMATIC); + HugeGraphSinkWriter.MappingEntry entry = + new HugeGraphSinkWriter.MappingEntry(cfg, new FakeMapper()); + + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphSinkWriter.rejectAutomaticVertexDelete( + Collections.singletonList(entry))); + assertTrue(ex.getMessage().contains("AUTOMATIC")); + assertTrue(ex.getMessage().contains("DELETE")); + } + + @Test + void extractIdFailureDoesNotProduceSuperseded() { + // extractId throwing mid-scan must abort the plan without any Superseded — otherwise the + // caller would delete based on a partial view of the mappings. + MappingConfig vertexCfg = vertexConfig("person"); + SeaTunnelRow before = row("v-old"); + SeaTunnelRow after = row("v-new"); + HugeGraphSinkWriter.MappingEntry entry = + new HugeGraphSinkWriter.MappingEntry( + vertexCfg, + new FakeMapper() { + @Override + public GraphElement map(SeaTunnelRow row) { + return new Vertex(vertexCfg.getLabel()); + } + + @Override + public Object extractId(SeaTunnelRow row) { + throw new RuntimeException("id extraction failed"); + } + }); + + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphSinkWriter.buildUpdatePlan( + Collections.singletonList(entry), before, after)); + assertNotNull(ex); + } + + private static MappingConfig vertexConfig(String label) { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel(label); + m.setIdStrategy(IdStrategy.PRIMARY_KEY); + m.setIdFields(Collections.singletonList("id")); + return m; + } + + private static MappingConfig edgeConfig(String label) { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.EDGE); + m.setLabel(label); + return m; + } + + private static SeaTunnelRow row(String marker) { + SeaTunnelRow r = new SeaTunnelRow(new Object[] {marker}); + return r; + } + + // --- Checkpoint safety --- + + @Test + void prepareCommitThrowsWhenUpdateBeforePending() throws IOException { + // If UPDATE_BEFORE arrived but its paired UPDATE_AFTER has not yet been processed, + // prepareCommit() must fail fast. Checkpointing mid-pair would lose the pending state + // (SinkWriter has no snapshotState()), corrupting the mutation on recovery. + HugeGraphSinkConfig config = singleMappingConfig(); + HugeGraphClient client = stubbedClient(); + SeaTunnelRowType rowType = + new SeaTunnelRowType( + new String[] {"name"}, new SeaTunnelDataType[] {BasicType.STRING_TYPE}); + + HugeGraphSinkWriter writer = new HugeGraphSinkWriter(config, rowType, client, 0); + + SeaTunnelRow before = new SeaTunnelRow(new Object[] {"old-name"}); + before.setRowKind(RowKind.UPDATE_BEFORE); + writer.write(before); + + HugeGraphConnectorException ex = + assertThrows(HugeGraphConnectorException.class, writer::prepareCommit); + assertTrue( + ex.getMessage().contains("UPDATE_BEFORE"), + "Error must mention UPDATE_BEFORE: " + ex.getMessage()); + assertTrue( + ex.getMessage().contains("UPDATE_AFTER"), + "Error must mention UPDATE_AFTER: " + ex.getMessage()); + assertTrue( + ex.getMessage().contains("checkpoint"), + "Error must reference checkpoint: " + ex.getMessage()); + } + + @Test + void prepareCommitSucceedsWhenUpdateBeforeIsConsumed() throws IOException { + // After UPDATE_AFTER arrives and handleUpdate consumes the pending UPDATE_BEFORE, + // prepareCommit() must succeed (no pending state). + HugeGraphSinkConfig config = singleMappingConfig(); + HugeGraphClient client = stubbedClient(); + SeaTunnelRowType rowType = + new SeaTunnelRowType( + new String[] {"name"}, new SeaTunnelDataType[] {BasicType.STRING_TYPE}); + + HugeGraphSinkWriter writer = new HugeGraphSinkWriter(config, rowType, client, 0); + + SeaTunnelRow before = new SeaTunnelRow(new Object[] {"old-name"}); + before.setRowKind(RowKind.UPDATE_BEFORE); + SeaTunnelRow after = new SeaTunnelRow(new Object[] {"new-name"}); + after.setRowKind(RowKind.UPDATE_AFTER); + writer.write(before); + writer.write(after); + + // After UPDATE_AFTER, pendingUpdateBefore is null. prepareCommit() should flush and + // succeed (buffer.flush() calls the mock client, which is stubbed for writes). + writer.prepareCommit(); + } + + // --- Helpers --- + + private static HugeGraphSinkConfig singleMappingConfig() { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel("person"); + m.setIdStrategy(IdStrategy.PRIMARY_KEY); + m.setIdFields(Collections.singletonList("name")); + HugeGraphSinkConfig config = new HugeGraphSinkConfig(); + config.setMappings(Collections.singletonList(m)); + config.setBatchSize(100); + config.setBatchIntervalMs(0); + return config; + } + + private static HugeGraphClient stubbedClient() { + HugeGraphClient client = mock(HugeGraphClient.class); + when(client.getVertexLabelId(anyString())).thenReturn("1"); + PropertyKey pk = mock(PropertyKey.class); + when(pk.dataType()).thenReturn(DataType.TEXT); + when(pk.cardinality()).thenReturn(Cardinality.SINGLE); + when(client.getPropertyKey(anyString())).thenReturn(pk); + return client; + } + + private static class FakeMapper implements GraphDataMapper { + @Override + public GraphElement map(SeaTunnelRow row) { + return null; + } + + @Override + public Object extractId(SeaTunnelRow row) { + return null; + } + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceFactoryTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceFactoryTest.java new file mode 100644 index 000000000000..38c712de9689 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceFactoryTest.java @@ -0,0 +1,297 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.source; + +import org.apache.seatunnel.api.configuration.ReadonlyConfig; +import org.apache.seatunnel.api.configuration.util.OptionRule; +import org.apache.seatunnel.api.table.type.ArrayType; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphOperations; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.PageResult; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSourceOptions; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.apache.hugegraph.structure.constant.Cardinality; +import org.apache.hugegraph.structure.constant.DataType; +import org.apache.hugegraph.structure.graph.Edge; +import org.apache.hugegraph.structure.graph.Shard; +import org.apache.hugegraph.structure.graph.Vertex; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HugeGraphSourceFactoryTest { + + @Test + void optionRuleMakesLabelOptionalForReadAll() { + // 'label' must be optional so it can be omitted to read all labels of label_type. + OptionRule rule = new HugeGraphSourceFactory().optionRule(); + assertTrue(rule.getOptionalOptions().contains(HugeGraphSourceOptions.LABEL)); + } + + @Test + void testVertexReservedFields() { + SeaTunnelRowType rowType = + HugeGraphSourceFactory.prependReservedFields( + propertyRowType(), MappingConfig.LabelType.VERTEX); + + assertArrayEquals(new String[] {"~id", "~label", "name", "age"}, rowType.getFieldNames()); + } + + @Test + void testEdgeReservedFields() { + SeaTunnelRowType rowType = + HugeGraphSourceFactory.prependReservedFields( + propertyRowType(), MappingConfig.LabelType.EDGE); + + assertArrayEquals( + new String[] { + "~id", + "~label", + "~source_id", + "~source_label", + "~target_id", + "~target_label", + "name", + "age" + }, + rowType.getFieldNames()); + } + + @Test + void rejectsReservedColumnNameInSchemaFields() { + // Declaring a reserved column (~id) in schema.fields used to silently duplicate the column + // and later fail with a misleading "label has no property ~id"; it must fail fast with a + // message that names the offending column. + SeaTunnelRowType withReserved = + new SeaTunnelRowType( + new String[] {"~id", "name"}, + new SeaTunnelDataType[] {BasicType.STRING_TYPE, BasicType.STRING_TYPE}); + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphSourceFactory.prependReservedFields( + withReserved, MappingConfig.LabelType.VERTEX)); + assertTrue(ex.getMessage().contains("~id")); + assertTrue(ex.getMessage().contains("schema.fields")); + } + + @Test + void rejectsReservedEdgeEndpointColumnInSchemaFields() { + SeaTunnelRowType withReserved = + new SeaTunnelRowType( + new String[] {"~source_id", "weight"}, + new SeaTunnelDataType[] {BasicType.STRING_TYPE, BasicType.DOUBLE_TYPE}); + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphSourceFactory.prependReservedFields( + withReserved, MappingConfig.LabelType.EDGE)); + assertTrue(ex.getMessage().contains("~source_id")); + } + + @Test + void rejectsFilterWithParallelismGreaterThanOne() { + Map options = new HashMap<>(); + options.put("parallelism", 2); + options.put("filter", Collections.singletonMap("country", "US")); + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphSourceFactory.checkFilterParallelism( + ReadonlyConfig.fromMap(options))); + assertTrue(ex.getMessage().contains("filter")); + assertTrue(ex.getMessage().contains("parallelism")); + } + + @Test + void allowsParallelismGreaterThanOneWithoutFilter() { + Map options = new HashMap<>(); + options.put("parallelism", 4); + assertDoesNotThrow( + () -> + HugeGraphSourceFactory.checkFilterParallelism( + ReadonlyConfig.fromMap(options))); + } + + @Test + void allowsFilterWithParallelismOneOrUnset() { + Map one = new HashMap<>(); + one.put("parallelism", 1); + one.put("filter", Collections.singletonMap("country", "US")); + assertDoesNotThrow( + () -> HugeGraphSourceFactory.checkFilterParallelism(ReadonlyConfig.fromMap(one))); + + Map unset = new HashMap<>(); + unset.put("filter", Collections.singletonMap("country", "US")); + assertDoesNotThrow( + () -> HugeGraphSourceFactory.checkFilterParallelism(ReadonlyConfig.fromMap(unset))); + } + + @Test + void optionRuleDeclaresRetryBackoffMax() { + assertTrue( + new HugeGraphSourceFactory() + .optionRule() + .getOptionalOptions() + .contains( + org.apache.seatunnel.connectors.seatunnel.hugegraph.config + .HugeGraphOptions.RETRY_BACKOFF_MAX_MS), + "retry_backoff_max_ms missing from source optionRule"); + } + + @Test + void discoversPropertyRowTypeFromServerSortedByName() { + FakeClient client = new FakeClient(); + client.vertexProperties = new HashSet<>(Arrays.asList("name", "age", "tags")); + client.propertyTypes.put("name", DataType.TEXT); + client.propertyTypes.put("age", DataType.INT); + client.propertyTypes.put("tags", DataType.TEXT); + client.propertyCardinalities.put("tags", Cardinality.LIST); + + SeaTunnelRowType rowType = + HugeGraphSourceFactory.discoverPropertyRowType( + client, "person", MappingConfig.LabelType.VERTEX); + + // sorted by name: age, name, tags + assertArrayEquals(new String[] {"age", "name", "tags"}, rowType.getFieldNames()); + assertEquals(BasicType.INT_TYPE, rowType.getFieldType(0)); + assertEquals(BasicType.STRING_TYPE, rowType.getFieldType(1)); + assertEquals(ArrayType.STRING_ARRAY_TYPE, rowType.getFieldType(2)); + } + + @Test + void discoverFailsWhenLabelMissing() { + FakeClient client = new FakeClient(); + // vertexProperties stays null -> label does not exist + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphSourceFactory.discoverPropertyRowType( + client, "ghost", MappingConfig.LabelType.VERTEX)); + } + + @Test + void discoversEmptyRowTypeForPropertylessLabel() { + FakeClient client = new FakeClient(); + client.edgeProperties = new HashSet<>(); + + SeaTunnelRowType rowType = + HugeGraphSourceFactory.discoverPropertyRowType( + client, "rel", MappingConfig.LabelType.EDGE); + + assertEquals(0, rowType.getTotalFields()); + } + + private SeaTunnelRowType propertyRowType() { + return new SeaTunnelRowType( + new String[] {"name", "age"}, + new SeaTunnelDataType[] {BasicType.STRING_TYPE, BasicType.INT_TYPE}); + } + + private static class FakeClient implements HugeGraphOperations { + private final Map propertyTypes = new HashMap<>(); + private final Map propertyCardinalities = new HashMap<>(); + private Set vertexProperties; + private Set edgeProperties; + + @Override + public Set getVertexLabelPropertiesOrNull(String label) { + return vertexProperties; + } + + @Override + public Set getEdgeLabelPropertiesOrNull(String label) { + return edgeProperties; + } + + @Override + public List listVertexLabels() { + return Collections.emptyList(); + } + + @Override + public List listEdgeLabels() { + return Collections.emptyList(); + } + + @Override + public DataType getPropertyDataType(String propertyName) { + return propertyTypes.get(propertyName); + } + + @Override + public Cardinality getPropertyCardinality(String propertyName) { + return propertyCardinalities.getOrDefault(propertyName, Cardinality.SINGLE); + } + + @Override + public PageResult listVertices( + String label, Map filter, String page, int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public PageResult listEdges( + String label, Map filter, String page, int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public List vertexShards(long splitSize) { + return Collections.emptyList(); + } + + @Override + public List edgeShards(long splitSize) { + return Collections.emptyList(); + } + + @Override + public PageResult scanVertices(Shard shard, String page, int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public PageResult scanEdges(Shard shard, String page, int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() {} + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceReaderTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceReaderTest.java new file mode 100644 index 000000000000..518de6a691e2 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceReaderTest.java @@ -0,0 +1,840 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.source; + +import org.apache.seatunnel.api.common.metrics.MetricsContext; +import org.apache.seatunnel.api.event.EventListener; +import org.apache.seatunnel.api.source.Boundedness; +import org.apache.seatunnel.api.source.Collector; +import org.apache.seatunnel.api.source.SourceEvent; +import org.apache.seatunnel.api.source.SourceReader; +import org.apache.seatunnel.api.table.type.ArrayType; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.LocalTimeType; +import org.apache.seatunnel.api.table.type.PrimitiveByteArrayType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRow; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphOperations; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.PageResult; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSourceConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.apache.hugegraph.structure.constant.Cardinality; +import org.apache.hugegraph.structure.constant.DataType; +import org.apache.hugegraph.structure.graph.Edge; +import org.apache.hugegraph.structure.graph.Shard; +import org.apache.hugegraph.structure.graph.Vertex; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HugeGraphSourceReaderTest { + + @Test + void testOpenFailsWhenLabelDoesNotExist() { + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + SeaTunnelRowType propertyRowType = propertyRowType(); + HugeGraphSourceReader reader = + newReader(MappingConfig.LabelType.VERTEX, propertyRowType, client); + + assertThrows(HugeGraphConnectorException.class, reader::open); + } + + @Test + void testOpenFailsWhenPropertyTypeMismatch() { + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + client.vertexProperties = new HashSet<>(); + client.vertexProperties.add("name"); + client.vertexProperties.add("age"); + client.propertyTypes.put("name", DataType.TEXT); + client.propertyTypes.put("age", DataType.TEXT); + SeaTunnelRowType propertyRowType = propertyRowType(); + HugeGraphSourceReader reader = + newReader(MappingConfig.LabelType.VERTEX, propertyRowType, client); + + assertThrows(HugeGraphConnectorException.class, reader::open); + } + + @Test + void testOpenFailsWhenServerListPropertyDeclaredAsScalar() { + // Server has LIST but user declared scalar — error must guide the user to array<...> + // rather than throwing a mid-scan CCE against the scalar row builder. + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + client.vertexProperties = new HashSet<>(); + client.vertexProperties.add("name"); + client.vertexProperties.add("age"); + client.propertyTypes.put("name", DataType.TEXT); + client.propertyTypes.put("age", DataType.INT); + client.propertyCardinalities.put("age", Cardinality.LIST); + SeaTunnelRowType propertyRowType = propertyRowType(); + HugeGraphSourceReader reader = + newReader(MappingConfig.LabelType.VERTEX, propertyRowType, client); + + HugeGraphConnectorException ex = + assertThrows(HugeGraphConnectorException.class, reader::open); + assertTrue(ex.getMessage().contains("array<")); + } + + @Test + void testOpenFailsWhenServerScalarButUserDeclaredArray() { + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + client.vertexProperties = new HashSet<>(); + client.vertexProperties.add("tags"); + client.propertyTypes.put("tags", DataType.TEXT); + // cardinality not set → SINGLE (default) + SeaTunnelRowType propertyRowType = + new SeaTunnelRowType( + new String[] {"tags"}, + new SeaTunnelDataType[] {ArrayType.STRING_ARRAY_TYPE}); + HugeGraphSourceReader reader = + newReader(MappingConfig.LabelType.VERTEX, propertyRowType, client); + + assertThrows(HugeGraphConnectorException.class, reader::open); + } + + @Test + void testListPropertyIsReadAsArray() throws Exception { + SeaTunnelRowType propertyRowType = + new SeaTunnelRowType( + new String[] {"tags"}, + new SeaTunnelDataType[] {ArrayType.STRING_ARRAY_TYPE}); + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + client.vertexProperties = new HashSet<>(); + client.vertexProperties.add("tags"); + client.propertyTypes.put("tags", DataType.TEXT); + client.propertyCardinalities.put("tags", Cardinality.LIST); + + Vertex vertex = new Vertex("person"); + vertex.id("v1"); + vertex.property("tags", Arrays.asList("a", "b", "c")); + client.vertexPages.add(new PageResult<>(Collections.singletonList(vertex), null)); + ListCollector collector = new ListCollector(); + HugeGraphSourceReader reader = + newReader(MappingConfig.LabelType.VERTEX, propertyRowType, client); + + reader.open(); + reader.addSplits(listSplit()); + reader.pollNext(collector); + + assertEquals(1, collector.rows.size()); + Object cell = collector.rows.get(0).getField(2); + assertInstanceOf(String[].class, cell); + assertArrayEquals(new String[] {"a", "b", "c"}, (String[]) cell); + } + + @Test + void testSetPropertyIsReadAsArray() throws Exception { + // SET cardinality is accepted; element order is not guaranteed by the server, but the + // reader must not fail and must produce a typed array of the server's element type. + SeaTunnelRowType propertyRowType = + new SeaTunnelRowType( + new String[] {"tags"}, + new SeaTunnelDataType[] {ArrayType.INT_ARRAY_TYPE}); + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + client.vertexProperties = new HashSet<>(); + client.vertexProperties.add("tags"); + client.propertyTypes.put("tags", DataType.INT); + client.propertyCardinalities.put("tags", Cardinality.SET); + + Vertex vertex = new Vertex("person"); + vertex.id("v1"); + java.util.LinkedHashSet serverValue = new java.util.LinkedHashSet<>(); + serverValue.add(10); + serverValue.add(20); + vertex.property("tags", serverValue); + client.vertexPages.add(new PageResult<>(Collections.singletonList(vertex), null)); + ListCollector collector = new ListCollector(); + HugeGraphSourceReader reader = + newReader(MappingConfig.LabelType.VERTEX, propertyRowType, client); + + reader.open(); + reader.addSplits(listSplit()); + reader.pollNext(collector); + + Object cell = collector.rows.get(0).getField(2); + assertInstanceOf(Integer[].class, cell); + assertEquals(2, ((Integer[]) cell).length); + } + + @Test + void testVertexPagingAndNullProperties() throws Exception { + SeaTunnelRowType propertyRowType = propertyRowType(); + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + Vertex vertex = new Vertex("person"); + vertex.id("v1"); + vertex.property("name", "Alice"); + client.vertexPages.add(new PageResult<>(Collections.singletonList(vertex), "next")); + client.vertexPages.add(new PageResult<>(Collections.emptyList(), null)); + CountingContext context = new CountingContext(); + ListCollector collector = new ListCollector(); + + HugeGraphSourceReader reader = + newReader(context, MappingConfig.LabelType.VERTEX, propertyRowType, client); + reader.addSplits(listSplit()); + reader.handleNoMoreSplits(); + + drain(reader, collector, context); + + assertEquals(1, context.noMoreElementCount); + assertEquals(1, collector.rows.size()); + assertArrayEquals( + new Object[] {"v1", "person", "Alice", null}, collector.rows.get(0).getFields()); + // page1 requested with the null first page, page2 with the "next" marker + assertEquals("next", client.requestedPages.get(1)); + } + + @Test + void testEmptyLabelProducesNoRowsAndFinishes() throws Exception { + SeaTunnelRowType propertyRowType = propertyRowType(); + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + client.vertexPages.add(new PageResult<>(Collections.emptyList(), null)); + CountingContext context = new CountingContext(); + ListCollector collector = new ListCollector(); + HugeGraphSourceReader reader = + newReader(context, MappingConfig.LabelType.VERTEX, propertyRowType, client); + reader.addSplits(listSplit()); + reader.handleNoMoreSplits(); + + drain(reader, collector, context); + + assertTrue(collector.rows.isEmpty()); + assertEquals(1, context.noMoreElementCount); + } + + @Test + void testAdjacentServerPagingDuplicatesAreSkipped() throws Exception { + SeaTunnelRowType propertyRowType = propertyRowType(); + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + Vertex v1 = new Vertex("person"); + v1.id("v1"); + v1.property("name", "Alice"); + Vertex v2 = new Vertex("person"); + v2.id("v2"); + v2.property("name", "Bob"); + // Server-side paging artifact: the boundary record repeats back-to-back — + // v1 tails page 1 and heads page 2. + client.vertexPages.add(new PageResult<>(java.util.Arrays.asList(v2, v1), "next")); + client.vertexPages.add(new PageResult<>(java.util.Arrays.asList(v1, v2), null)); + CountingContext context = new CountingContext(); + ListCollector collector = new ListCollector(); + + HugeGraphSourceReader reader = + newReader(context, MappingConfig.LabelType.VERTEX, propertyRowType, client); + reader.addSplits(listSplit()); + reader.handleNoMoreSplits(); + + drain(reader, collector, context); + + // 4 raw records, 1 adjacent duplicate skipped; non-adjacent repeat of v2 is kept + assertEquals(3, collector.rows.size()); + assertEquals("v2", collector.rows.get(0).getField(0)); + assertEquals("v1", collector.rows.get(1).getField(0)); + assertEquals("v2", collector.rows.get(2).getField(0)); + } + + @Test + void testEmptyIntermediatePageContinues() throws Exception { + SeaTunnelRowType propertyRowType = propertyRowType(); + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + Vertex vertex = new Vertex("person"); + vertex.id("v1"); + vertex.property("name", "Alice"); + client.vertexPages.add(new PageResult<>(Collections.emptyList(), "next")); + client.vertexPages.add(new PageResult<>(Collections.singletonList(vertex), null)); + CountingContext context = new CountingContext(); + ListCollector collector = new ListCollector(); + HugeGraphSourceReader reader = + newReader(context, MappingConfig.LabelType.VERTEX, propertyRowType, client); + reader.addSplits(listSplit()); + reader.handleNoMoreSplits(); + + drain(reader, collector, context); + + assertEquals(1, collector.rows.size()); + assertEquals(1, context.noMoreElementCount); + } + + @Test + void testRepeatedPageMarkerFails() throws Exception { + SeaTunnelRowType propertyRowType = propertyRowType(); + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + client.vertexPages.add(new PageResult<>(Collections.emptyList(), "next")); + client.vertexPages.add(new PageResult<>(Collections.emptyList(), "next")); + HugeGraphSourceReader reader = + newReader(MappingConfig.LabelType.VERTEX, propertyRowType, client); + reader.addSplits(listSplit()); + ListCollector collector = new ListCollector(); + + reader.pollNext(collector); + + assertThrows(HugeGraphConnectorException.class, () -> reader.pollNext(collector)); + } + + @Test + void testPaginationStateRestoresAtNextPage() throws Exception { + SeaTunnelRowType propertyRowType = propertyRowType(); + FakeHugeGraphOperations firstClient = new FakeHugeGraphOperations(); + Vertex first = new Vertex("person"); + first.id("v1"); + first.property("name", "Alice"); + firstClient.vertexPages.add(new PageResult<>(Collections.singletonList(first), "next")); + HugeGraphSourceReader firstReader = + newReader(MappingConfig.LabelType.VERTEX, propertyRowType, firstClient); + firstReader.addSplits(listSplit()); + firstReader.pollNext(new ListCollector()); + + FakeHugeGraphOperations restoredClient = new FakeHugeGraphOperations(); + Vertex second = new Vertex("person"); + second.id("v2"); + second.property("name", "Bob"); + restoredClient.vertexPages.add(new PageResult<>(Collections.singletonList(second), null)); + CountingContext restoredContext = new CountingContext(); + HugeGraphSourceReader restoredReader = + newReader( + restoredContext, + MappingConfig.LabelType.VERTEX, + propertyRowType, + restoredClient); + restoredReader.addSplits(firstReader.snapshotState(1L)); + restoredReader.handleNoMoreSplits(); + ListCollector restoredCollector = new ListCollector(); + + drain(restoredReader, restoredCollector, restoredContext); + + assertEquals("next", restoredClient.requestedPages.get(0)); + assertEquals("v2", restoredCollector.rows.get(0).getField(0)); + assertEquals(1, restoredContext.noMoreElementCount); + } + + @Test + void testFinishedStateSignalsNoMoreElementAfterRestore() throws Exception { + SeaTunnelRowType propertyRowType = propertyRowType(); + FakeHugeGraphOperations firstClient = new FakeHugeGraphOperations(); + firstClient.vertexPages.add(new PageResult<>(Collections.emptyList(), null)); + HugeGraphSourceReader firstReader = + newReader(MappingConfig.LabelType.VERTEX, propertyRowType, firstClient); + firstReader.addSplits(listSplit()); + firstReader.pollNext(new ListCollector()); + + FakeHugeGraphOperations restoredClient = new FakeHugeGraphOperations(); + CountingContext restoredContext = new CountingContext(); + HugeGraphSourceReader restoredReader = + newReader( + restoredContext, + MappingConfig.LabelType.VERTEX, + propertyRowType, + restoredClient); + restoredReader.addSplits(firstReader.snapshotState(1L)); + restoredReader.handleNoMoreSplits(); + + drain(restoredReader, new ListCollector(), restoredContext); + + assertEquals(1, restoredContext.noMoreElementCount); + assertTrue(restoredClient.requestedPages.isEmpty()); + } + + @Test + void testEdgeReservedFieldsAreStrings() throws Exception { + SeaTunnelRowType propertyRowType = propertyRowType(); + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + Edge edge = new Edge("knows"); + edge.id("S1>knows>S2"); + edge.sourceId(1L); + edge.sourceLabel("person"); + edge.targetId(2L); + edge.targetLabel("person"); + edge.property("name", "since"); + client.edgePages.add(new PageResult<>(Collections.singletonList(edge), null)); + CountingContext context = new CountingContext(); + ListCollector collector = new ListCollector(); + + HugeGraphSourceReader reader = + newReader(context, MappingConfig.LabelType.EDGE, propertyRowType, client); + reader.addSplits(listSplit()); + reader.pollNext(collector); + + assertEquals(1, collector.rows.size()); + assertArrayEquals( + new Object[] {"S1>knows>S2", "knows", "1", "person", "2", "person", "since", null}, + collector.rows.get(0).getFields()); + } + + @Test + void testRestDecodedPropertyValuesAreNormalizedToSeaTunnelTypes() throws Exception { + SeaTunnelRowType propertyRowType = + new SeaTunnelRowType( + new String[] {"count", "ratio", "created_at", "payload"}, + new SeaTunnelDataType[] { + BasicType.LONG_TYPE, + BasicType.FLOAT_TYPE, + LocalTimeType.LOCAL_DATE_TIME_TYPE, + PrimitiveByteArrayType.INSTANCE + }); + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + Vertex vertex = new Vertex("metric"); + vertex.id("v1"); + vertex.property("count", Integer.valueOf(1)); + vertex.property("ratio", Double.valueOf(0.5D)); + vertex.property("created_at", Long.valueOf(1000L)); + vertex.property("payload", Base64.getEncoder().encodeToString(new byte[] {1, 2, 3})); + client.vertexPages.add(new PageResult<>(Collections.singletonList(vertex), null)); + ListCollector collector = new ListCollector(); + + HugeGraphSourceReader reader = + newReader(MappingConfig.LabelType.VERTEX, propertyRowType, client); + reader.addSplits(listSplit()); + reader.pollNext(collector); + + Object[] fields = collector.rows.get(0).getFields(); + assertInstanceOf(Long.class, fields[2]); + assertEquals(1L, fields[2]); + assertInstanceOf(Float.class, fields[3]); + assertEquals(0.5F, fields[3]); + assertEquals( + java.time.LocalDateTime.ofInstant( + java.time.Instant.ofEpochMilli(1000L), java.time.ZoneId.systemDefault()), + fields[4]); + assertArrayEquals(new byte[] {1, 2, 3}, (byte[]) fields[5]); + } + + @Test + void testDatePropertyReturnedAsSpaceSeparatedStringIsParsed() throws Exception { + // HugeGraph server serializes DATE as "yyyy-MM-dd HH:mm:ss.SSS" (space separator), + // which LocalDateTime.parse (ISO 'T' only) rejects. The reader must accept it. + SeaTunnelRowType propertyRowType = + new SeaTunnelRowType( + new String[] {"created"}, + new SeaTunnelDataType[] {LocalTimeType.LOCAL_DATE_TIME_TYPE}); + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + Vertex vertex = new Vertex("acct"); + vertex.id("v1"); + vertex.property("created", "2026-09-11 23:20:11.000"); + client.vertexPages.add(new PageResult<>(Collections.singletonList(vertex), null)); + ListCollector collector = new ListCollector(); + + HugeGraphSourceReader reader = + newReader(MappingConfig.LabelType.VERTEX, propertyRowType, client); + reader.addSplits(listSplit()); + reader.pollNext(collector); + + assertEquals( + java.time.LocalDateTime.of(2026, 9, 11, 23, 20, 11), + collector.rows.get(0).getField(2)); + } + + @Test + void testFilterIsForwardedToClient() throws Exception { + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + client.vertexProperties = new HashSet<>(Arrays.asList("name", "age")); + client.propertyTypes.put("name", DataType.TEXT); + client.propertyTypes.put("age", DataType.INT); + Vertex vertex = new Vertex("person"); + vertex.id("v1"); + vertex.property("name", "alice"); + vertex.property("age", 30); + client.vertexPages.add(new PageResult<>(Collections.singletonList(vertex), null)); + + SeaTunnelRowType propertyRowType = propertyRowType(); + HugeGraphSourceConfig config = + sourceConfig(MappingConfig.LabelType.VERTEX, propertyRowType); + Map filter = new HashMap<>(); + filter.put("name", "alice"); + config.setFilter(filter); + + HugeGraphSourceReader reader = + new HugeGraphSourceReader( + new CountingContext(), + config, + singleContext("person", propertyRowType, MappingConfig.LabelType.VERTEX), + client); + reader.open(); + reader.addSplits(listSplit()); + reader.pollNext(new ListCollector()); + + assertEquals(filter, client.capturedFilter); + } + + @Test + void testOpenFailsWhenFilterPropertyNotOnLabel() { + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + client.vertexProperties = new HashSet<>(Arrays.asList("name", "age")); + client.propertyTypes.put("name", DataType.TEXT); + client.propertyTypes.put("age", DataType.INT); + + SeaTunnelRowType propertyRowType = propertyRowType(); + HugeGraphSourceConfig config = + sourceConfig(MappingConfig.LabelType.VERTEX, propertyRowType); + Map filter = new HashMap<>(); + filter.put("nonexistent", "x"); + config.setFilter(filter); + + HugeGraphSourceReader reader = + new HugeGraphSourceReader( + new CountingContext(), + config, + singleContext("person", propertyRowType, MappingConfig.LabelType.VERTEX), + client); + + assertThrows(HugeGraphConnectorException.class, reader::open); + } + + @Test + void testShardModeScansShardAndFiltersByLabel() throws Exception { + // A shard scan returns vertices of ALL labels in the key range; the reader must keep only + // the configured label ("person") and drop the others ("company"). + SeaTunnelRowType propertyRowType = propertyRowType(); + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + Vertex person = new Vertex("person"); + person.id("p1"); + person.property("name", "Alice"); + Vertex company = new Vertex("company"); + company.id("c1"); + Vertex person2 = new Vertex("person"); + person2.id("p2"); + person2.property("name", "Bob"); + client.scanVertexPages.add( + new PageResult<>(java.util.Arrays.asList(person, company, person2), null)); + CountingContext context = new CountingContext(); + ListCollector collector = new ListCollector(); + + HugeGraphSourceReader reader = + newReader(context, MappingConfig.LabelType.VERTEX, propertyRowType, client); + reader.addSplits( + Collections.singletonList( + HugeGraphSourceSplit.shardSplit("shard-0", new Shard("0", "9", 0L)))); + reader.handleNoMoreSplits(); + + drain(reader, collector, context); + + assertEquals(2, collector.rows.size()); + assertEquals("p1", collector.rows.get(0).getField(0)); + assertEquals("p2", collector.rows.get(1).getField(0)); + assertEquals(1, context.noMoreElementCount); + } + + @Test + void testMultipleShardSplitsAreAllDrained() throws Exception { + SeaTunnelRowType propertyRowType = propertyRowType(); + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + Vertex a = new Vertex("person"); + a.id("a"); + Vertex b = new Vertex("person"); + b.id("b"); + client.scanVertexPages.add(new PageResult<>(Collections.singletonList(a), null)); + client.scanVertexPages.add(new PageResult<>(Collections.singletonList(b), null)); + CountingContext context = new CountingContext(); + ListCollector collector = new ListCollector(); + + HugeGraphSourceReader reader = + newReader(context, MappingConfig.LabelType.VERTEX, propertyRowType, client); + reader.addSplits( + Arrays.asList( + HugeGraphSourceSplit.shardSplit("shard-0", new Shard("0", "5", 0L)), + HugeGraphSourceSplit.shardSplit("shard-1", new Shard("5", "9", 0L)))); + reader.handleNoMoreSplits(); + + drain(reader, collector, context); + + assertEquals(2, collector.rows.size()); + assertEquals(1, context.noMoreElementCount); + } + + @Test + void filterValueCoercedToPropertyType() { + // A BOOLEAN property filtered with the string "true" must become a real Boolean, and a LONG + // filtered with a loosely-typed value must become a Long — otherwise the server matches by + // typed value and silently returns 0 rows. + assertEquals( + Boolean.TRUE, + HugeGraphSourceReader.coerceFilterValue("active", "true", DataType.BOOLEAN)); + assertEquals( + Boolean.FALSE, + HugeGraphSourceReader.coerceFilterValue("active", "FALSE", DataType.BOOLEAN)); + assertEquals(7L, HugeGraphSourceReader.coerceFilterValue("count", "7", DataType.LONG)); + assertEquals(7L, HugeGraphSourceReader.coerceFilterValue("count", 7, DataType.LONG)); + assertEquals("x", HugeGraphSourceReader.coerceFilterValue("name", "x", DataType.TEXT)); + } + + @Test + void filterValueThatCannotCoerceFailsFast() { + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphSourceReader.coerceFilterValue( + "active", "yes", DataType.BOOLEAN)); + assertTrue(ex.getMessage().contains("active")); + } + + @Test + void readAllTagsRowsWithPerLabelTableId() throws Exception { + // Read-all mode: two label-list splits (person, software). Each split reads exactly its + // label and every emitted row must carry that label's tableId so a downstream multi-table + // sink can route it; the produced row uses that label's own row type. + FakeHugeGraphOperations client = new FakeHugeGraphOperations(); + Vertex person = new Vertex("person"); + person.id("p1"); + person.property("name", "Alice"); + Vertex software = new Vertex("software"); + software.id("s1"); + software.property("name", "SeaTunnel"); + client.vertexPages.add(new PageResult<>(Collections.singletonList(person), null)); + client.vertexPages.add(new PageResult<>(Collections.singletonList(software), null)); + + SeaTunnelRowType props = + new SeaTunnelRowType( + new String[] {"name"}, new SeaTunnelDataType[] {BasicType.STRING_TYPE}); + Map contexts = new HashMap<>(); + contexts.putAll(singleContext("person", props, MappingConfig.LabelType.VERTEX)); + contexts.putAll(singleContext("software", props, MappingConfig.LabelType.VERTEX)); + + HugeGraphSourceConfig config = new HugeGraphSourceConfig(); + config.setReadAllLabels(true); + config.setLabel(null); + config.setLabels(Arrays.asList("person", "software")); + config.setLabelType(MappingConfig.LabelType.VERTEX); + config.setPageSize(100); + + CountingContext context = new CountingContext(); + ListCollector collector = new ListCollector(); + HugeGraphSourceReader reader = new HugeGraphSourceReader(context, config, contexts, client); + reader.open(); // read-all skips single-label schema validation + reader.addSplits( + Arrays.asList( + HugeGraphSourceSplit.labelListSplit("label-list-person", "person"), + HugeGraphSourceSplit.labelListSplit("label-list-software", "software"))); + reader.handleNoMoreSplits(); + + drain(reader, collector, context); + + assertEquals(2, collector.rows.size()); + assertEquals("person", collector.rows.get(0).getTableId()); + assertEquals("Alice", collector.rows.get(0).getField(2)); + assertEquals("software", collector.rows.get(1).getTableId()); + assertEquals("SeaTunnel", collector.rows.get(1).getField(2)); + } + + private static void drain( + HugeGraphSourceReader reader, ListCollector collector, CountingContext context) + throws Exception { + int guard = 0; + while (context.noMoreElementCount == 0 && guard++ < 100) { + reader.pollNext(collector); + } + } + + private static List listSplit() { + // null label => reader falls back to the single configured label (as a shard split does). + return Collections.singletonList(HugeGraphSourceSplit.labelListSplit("label-list", null)); + } + + private HugeGraphSourceReader newReader( + MappingConfig.LabelType labelType, + SeaTunnelRowType propertyRowType, + HugeGraphOperations client) { + return newReader(new CountingContext(), labelType, propertyRowType, client); + } + + private HugeGraphSourceReader newReader( + CountingContext context, + MappingConfig.LabelType labelType, + SeaTunnelRowType propertyRowType, + HugeGraphOperations client) { + HugeGraphSourceConfig config = sourceConfig(labelType, propertyRowType); + return new HugeGraphSourceReader( + context, + config, + singleContext(config.getLabel(), propertyRowType, labelType), + client); + } + + private static Map singleContext( + String label, SeaTunnelRowType propertyRowType, MappingConfig.LabelType labelType) { + SeaTunnelRowType outputRowType = + HugeGraphSourceFactory.prependReservedFields(propertyRowType, labelType); + Map contexts = new HashMap<>(); + contexts.put(label, new LabelTableContext(label, propertyRowType, outputRowType, label)); + return contexts; + } + + private HugeGraphSourceConfig sourceConfig( + MappingConfig.LabelType labelType, SeaTunnelRowType propertyRowType) { + HugeGraphSourceConfig config = new HugeGraphSourceConfig(); + config.setLabel(labelType == MappingConfig.LabelType.VERTEX ? "person" : "knows"); + config.setLabelType(labelType); + config.setSchema(propertyRowType); + config.setPageSize(100); + return config; + } + + private SeaTunnelRowType propertyRowType() { + return new SeaTunnelRowType( + new String[] {"name", "age"}, + new SeaTunnelDataType[] {BasicType.STRING_TYPE, BasicType.INT_TYPE}); + } + + private static class FakeHugeGraphOperations implements HugeGraphOperations { + private final List> vertexPages = new ArrayList<>(); + private final List> edgePages = new ArrayList<>(); + private final List> scanVertexPages = new ArrayList<>(); + private final List> scanEdgePages = new ArrayList<>(); + private final List requestedPages = new ArrayList<>(); + private final Map propertyTypes = new HashMap<>(); + private final Map propertyCardinalities = new HashMap<>(); + private Set vertexProperties; + private Set edgeProperties; + private Map capturedFilter; + + @Override + public Set getVertexLabelPropertiesOrNull(String label) { + return vertexProperties; + } + + @Override + public Set getEdgeLabelPropertiesOrNull(String label) { + return edgeProperties; + } + + @Override + public List listVertexLabels() { + return Collections.emptyList(); + } + + @Override + public List listEdgeLabels() { + return Collections.emptyList(); + } + + @Override + public DataType getPropertyDataType(String propertyName) { + return propertyTypes.get(propertyName); + } + + @Override + public Cardinality getPropertyCardinality(String propertyName) { + return propertyCardinalities.getOrDefault(propertyName, Cardinality.SINGLE); + } + + @Override + public PageResult listVertices( + String label, java.util.Map filter, String page, int limit) { + requestedPages.add(page); + capturedFilter = filter; + return vertexPages.remove(0); + } + + @Override + public PageResult listEdges( + String label, java.util.Map filter, String page, int limit) { + requestedPages.add(page); + capturedFilter = filter; + return edgePages.remove(0); + } + + @Override + public List vertexShards(long splitSize) { + return Collections.emptyList(); + } + + @Override + public List edgeShards(long splitSize) { + return Collections.emptyList(); + } + + @Override + public PageResult scanVertices(Shard shard, String page, int limit) { + requestedPages.add(page); + return scanVertexPages.remove(0); + } + + @Override + public PageResult scanEdges(Shard shard, String page, int limit) { + requestedPages.add(page); + return scanEdgePages.remove(0); + } + + @Override + public void close() {} + } + + private static class ListCollector implements Collector { + private final List rows = new ArrayList<>(); + + @Override + public void collect(SeaTunnelRow record) { + rows.add(record); + } + + @Override + public Object getCheckpointLock() { + return this; + } + } + + private static class CountingContext implements SourceReader.Context { + private int noMoreElementCount; + + @Override + public int getIndexOfSubtask() { + return 0; + } + + @Override + public Boundedness getBoundedness() { + return Boundedness.BOUNDED; + } + + @Override + public void signalNoMoreElement() { + noMoreElementCount++; + } + + @Override + public void sendSplitRequest() {} + + @Override + public void sendSourceEventToEnumerator(SourceEvent sourceEvent) {} + + @Override + public MetricsContext getMetricsContext() { + return null; + } + + @Override + public EventListener getEventListener() { + return null; + } + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceSplitEnumeratorTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceSplitEnumeratorTest.java new file mode 100644 index 000000000000..163c641e5697 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/source/HugeGraphSourceSplitEnumeratorTest.java @@ -0,0 +1,406 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.source; + +import org.apache.seatunnel.api.common.metrics.MetricsContext; +import org.apache.seatunnel.api.event.EventListener; +import org.apache.seatunnel.api.source.SourceEvent; +import org.apache.seatunnel.api.source.SourceSplitEnumerator; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphOperations; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.PageResult; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSourceConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.apache.hugegraph.structure.constant.Cardinality; +import org.apache.hugegraph.structure.constant.DataType; +import org.apache.hugegraph.structure.graph.Edge; +import org.apache.hugegraph.structure.graph.Shard; +import org.apache.hugegraph.structure.graph.Vertex; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HugeGraphSourceSplitEnumeratorTest { + + @Test + void parallelismOneProducesSingleLabelListSplit() { + CapturingContext context = new CapturingContext(1); + HugeGraphSourceSplitEnumerator enumerator = + new HugeGraphSourceSplitEnumerator( + context, config(), 1024L, null, failingClientFactory()); + + enumerator.open(); + enumerator.run(); + + List assigned = context.assignedTo(0); + assertEquals(1, assigned.size()); + assertFalse(assigned.get(0).isShardMode()); + assertEquals("label-list", assigned.get(0).splitId()); + assertTrue(context.noMoreSplits.contains(0)); + } + + @Test + void readAllProducesOneLabelListSplitPerLabel() { + // Read-all mode ignores parallelism-based sharding: one label-list split per discovered + // label, each carrying its label, distributed across readers. The client is never touched + // (labels come from the config), so a failing client factory must not be invoked. + CapturingContext context = new CapturingContext(2); + HugeGraphSourceSplitEnumerator enumerator = + new HugeGraphSourceSplitEnumerator( + context, + readAllConfig("person", "software"), + 1024L, + null, + failingClientFactory()); + + enumerator.open(); + enumerator.run(); + + List combined = new ArrayList<>(); + combined.addAll(context.assignedTo(0)); + combined.addAll(context.assignedTo(1)); + assertEquals(2, combined.size()); + Set labels = new HashSet<>(); + for (HugeGraphSourceSplit split : combined) { + assertFalse(split.isShardMode()); + labels.add(split.getLabel()); + } + assertEquals(new HashSet<>(Arrays.asList("person", "software")), labels); + assertTrue(context.noMoreSplits.contains(0)); + assertTrue(context.noMoreSplits.contains(1)); + } + + @Test + void parallelismGreaterThanOneSplitsByShardRoundRobin() { + CapturingContext context = new CapturingContext(2); + FakeClient client = new FakeClient(); + client.vertexShards = + Arrays.asList( + new Shard("0", "3", 0L), new Shard("3", "6", 0L), new Shard("6", "9", 0L)); + HugeGraphSourceSplitEnumerator enumerator = + new HugeGraphSourceSplitEnumerator(context, config(), 1024L, null, () -> client); + + enumerator.open(); + enumerator.run(); + + // 3 shards over 2 readers, round-robin: reader0 -> shard-0, shard-2; reader1 -> shard-1 + assertEquals(2, context.assignedTo(0).size()); + assertEquals(1, context.assignedTo(1).size()); + assertTrue(context.assignedTo(0).get(0).isShardMode()); + assertTrue(context.noMoreSplits.contains(0)); + assertTrue(context.noMoreSplits.contains(1)); + assertTrue(client.closed, "discovery client must be closed"); + assertEquals(0, enumerator.currentUnassignedSplitSize()); + } + + @Test + void shardDiscoveryFailureSuggestsParallelismOne() { + // Memory backend rejects vertexShards; the raw error is unactionable, so the enumerator + // must wrap it with the parallelism=1 label-list guidance (and still close the client). + CapturingContext context = new CapturingContext(2); + FakeClient client = new FakeClient(); + client.shardFailure = new RuntimeException("Not support shard for memory backend"); + HugeGraphSourceSplitEnumerator enumerator = + new HugeGraphSourceSplitEnumerator(context, config(), 1024L, null, () -> client); + + HugeGraphConnectorException ex = + assertThrows(HugeGraphConnectorException.class, enumerator::open); + assertTrue(ex.getMessage().contains("parallelism=1")); + assertTrue(client.closed, "discovery client must be closed even on failure"); + } + + @Test + void restoreDoesNotRediscoverAndAssignsOnlyUnassigned() { + // Two shard splits discovered previously; shard-0 already assigned (lives in a reader), + // shard-1 still unassigned. On restore the enumerator must assign only shard-1 and never + // touch the discovery client. + HugeGraphSourceSplit shard0 = + HugeGraphSourceSplit.shardSplit("shard-0", new Shard("0", "5", 0L)); + HugeGraphSourceSplit shard1 = + HugeGraphSourceSplit.shardSplit("shard-1", new Shard("5", "9", 0L)); + Set all = new LinkedHashSet<>(Arrays.asList(shard0, shard1)); + Set assigned = new HashSet<>(Arrays.asList(shard0)); + HugeGraphSourceState state = new HugeGraphSourceState(all, assigned); + + CapturingContext context = new CapturingContext(2); + HugeGraphSourceSplitEnumerator enumerator = + new HugeGraphSourceSplitEnumerator( + context, config(), 1024L, state, failingClientFactory()); + + enumerator.open(); + enumerator.run(); + + List all0 = context.assignedTo(0); + List all1 = context.assignedTo(1); + List combined = new ArrayList<>(); + combined.addAll(all0); + combined.addAll(all1); + assertEquals(1, combined.size(), "only the unassigned shard-1 should be re-assigned"); + assertEquals("shard-1", combined.get(0).splitId()); + } + + @Test + void snapshotStatePersistsAllAndAssigned() { + CapturingContext context = new CapturingContext(1); + HugeGraphSourceSplitEnumerator enumerator = + new HugeGraphSourceSplitEnumerator( + context, config(), 1024L, null, failingClientFactory()); + + enumerator.open(); + enumerator.run(); + HugeGraphSourceState state = enumerator.snapshotState(1L); + + assertEquals(1, state.getAllSplits().size()); + assertEquals(1, state.getAssignedSplits().size()); + } + + private static Supplier failingClientFactory() { + return () -> { + throw new AssertionError("discovery client must not be created in this path"); + }; + } + + @Test + void filterWithRuntimeParallelismGreaterThanOneFailsFast() { + // The factory-level checkFilterParallelism() reads only the per-source 'parallelism' + // option. The real runtime parallelism comes from env { parallelism = N } and is only + // visible to the enumerator via context.currentParallelism(). This test pins the + // runtime guard: when the enumerator sees parallelism > 1 AND a filter is configured, + // it must throw before creating any shard splits — otherwise shard scans silently + // ignore the filter. + HugeGraphSourceConfig filterConfig = configWithFilter(); + CapturingContext context = new CapturingContext(2); // runtime parallelism=2 + + HugeGraphSourceSplitEnumerator enumerator = + new HugeGraphSourceSplitEnumerator(context, filterConfig, 1024L, null, () -> null); + + HugeGraphConnectorException ex = + assertThrows(HugeGraphConnectorException.class, enumerator::open); + assertTrue( + ex.getMessage().contains("filter"), + "Error must mention 'filter': " + ex.getMessage()); + assertTrue( + ex.getMessage().contains("parallelism"), + "Error must mention 'parallelism': " + ex.getMessage()); + assertTrue( + ex.getMessage().contains("2"), + "Error must include the actual runtime parallelism: " + ex.getMessage()); + } + + @Test + void filterWithRuntimeParallelismOneIsAllowed() { + // Runtime parallelism 1 + filter is the supported label-list path with server-side + // filtering. The enumerator must NOT throw. + HugeGraphSourceConfig filterConfig = configWithFilter(); + CapturingContext context = new CapturingContext(1); + + HugeGraphSourceSplitEnumerator enumerator = + new HugeGraphSourceSplitEnumerator( + context, filterConfig, 1024L, null, () -> new FakeClient()); + + // Must not throw — filter + parallelism=1 is valid. + enumerator.open(); + enumerator.run(); + + List assigned = context.assignedTo(0); + assertEquals(1, assigned.size()); + assertFalse(assigned.get(0).isShardMode(), "parallelism=1 should create label-list split"); + } + + private HugeGraphSourceConfig configWithFilter() { + HugeGraphSourceConfig config = new HugeGraphSourceConfig(); + config.setLabel("person"); + config.setLabelType(MappingConfig.LabelType.VERTEX); + config.setSchema( + new SeaTunnelRowType( + new String[] {"name"}, new SeaTunnelDataType[] {BasicType.STRING_TYPE})); + config.setPageSize(100); + config.setSplitSize(1024L); + config.setFilter(Collections.singletonMap("status", "active")); + return config; + } + + private HugeGraphSourceConfig config() { + HugeGraphSourceConfig config = new HugeGraphSourceConfig(); + config.setLabel("person"); + config.setLabelType(MappingConfig.LabelType.VERTEX); + config.setSchema( + new SeaTunnelRowType( + new String[] {"name"}, new SeaTunnelDataType[] {BasicType.STRING_TYPE})); + config.setPageSize(100); + config.setSplitSize(1024L); + return config; + } + + private HugeGraphSourceConfig readAllConfig(String... labels) { + HugeGraphSourceConfig config = new HugeGraphSourceConfig(); + config.setReadAllLabels(true); + config.setLabelType(MappingConfig.LabelType.VERTEX); + config.setLabels(Arrays.asList(labels)); + config.setPageSize(100); + config.setSplitSize(1024L); + return config; + } + + private static class CapturingContext + implements SourceSplitEnumerator.Context { + private final int parallelism; + private final Map> assignments = new HashMap<>(); + private final Set noMoreSplits = new HashSet<>(); + + private CapturingContext(int parallelism) { + this.parallelism = parallelism; + } + + private List assignedTo(int subtask) { + return assignments.getOrDefault(subtask, new ArrayList<>()); + } + + @Override + public int currentParallelism() { + return parallelism; + } + + @Override + public Set registeredReaders() { + return new HashSet<>(); + } + + @Override + public void assignSplit(int subtaskId, List splits) { + assignments.computeIfAbsent(subtaskId, k -> new ArrayList<>()).addAll(splits); + } + + @Override + public void signalNoMoreSplits(int subtask) { + noMoreSplits.add(subtask); + } + + @Override + public void sendEventToSourceReader(int subtaskId, SourceEvent event) {} + + @Override + public MetricsContext getMetricsContext() { + return null; + } + + @Override + public EventListener getEventListener() { + return null; + } + } + + private static class FakeClient implements HugeGraphOperations { + private List vertexShards = new ArrayList<>(); + private List edgeShards = new ArrayList<>(); + private RuntimeException shardFailure; + private boolean closed; + + @Override + public Set getVertexLabelPropertiesOrNull(String label) { + return null; + } + + @Override + public Set getEdgeLabelPropertiesOrNull(String label) { + return null; + } + + @Override + public List listVertexLabels() { + return Collections.emptyList(); + } + + @Override + public List listEdgeLabels() { + return Collections.emptyList(); + } + + @Override + public DataType getPropertyDataType(String propertyName) { + return null; + } + + @Override + public Cardinality getPropertyCardinality(String propertyName) { + return Cardinality.SINGLE; + } + + @Override + public PageResult listVertices( + String label, Map filter, String page, int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public PageResult listEdges( + String label, Map filter, String page, int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public List vertexShards(long splitSize) { + if (shardFailure != null) { + throw shardFailure; + } + return vertexShards; + } + + @Override + public List edgeShards(long splitSize) { + if (shardFailure != null) { + throw shardFailure; + } + return edgeShards; + } + + @Override + public PageResult scanVertices(Shard shard, String page, int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public PageResult scanEdges(Shard shard, String page, int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + closed = true; + } + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/DataTypeUtilTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/DataTypeUtilTest.java new file mode 100644 index 000000000000..73dfc13845f0 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/DataTypeUtilTest.java @@ -0,0 +1,116 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.utils; + +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.ListFormat; + +import org.apache.hugegraph.structure.constant.Cardinality; +import org.apache.hugegraph.structure.constant.DataType; +import org.apache.hugegraph.structure.schema.PropertyKey; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DataTypeUtilTest { + + @Test + void testConvertSeaTunnelArrayToHugeGraphList() { + PropertyKey propertyKey = propertyKey("scores", DataType.INT, Cardinality.LIST); + + Object converted = DataTypeUtil.convert(new Integer[] {1, 2, 3}, propertyKey, null, null); + + assertEquals(Arrays.asList(1, 2, 3), converted); + } + + @Test + void testConvertPrimitiveArrayToHugeGraphSet() { + PropertyKey propertyKey = propertyKey("scores", DataType.LONG, Cardinality.SET); + + Object converted = DataTypeUtil.convert(new long[] {1L, 2L, 2L}, propertyKey, null, null); + + assertEquals(2, ((Collection) converted).size()); + } + + @Test + void testConvertDateOnlyStringWithDocumentedDefaults() { + PropertyKey propertyKey = propertyKey("created_at", DataType.DATE, Cardinality.SINGLE); + + Object converted = DataTypeUtil.convert("2026-07-11", propertyKey, "yyyy-MM-dd", "GMT+8"); + + assertEquals(Date.from(java.time.Instant.parse("2026-07-10T16:00:00Z")), converted); + } + + @Test + void testExtraDateFormatsAreTriedInOrder() { + PropertyKey propertyKey = propertyKey("created_at", DataType.DATE, Cardinality.SINGLE); + + // The primary format does not match "2026/07/11"; the extra "yyyy/MM/dd" does. + Object converted = + DataTypeUtil.convert( + "2026/07/11", + propertyKey, + "yyyy-MM-dd", + "GMT+8", + Arrays.asList("yyyy/MM/dd"), + new ListFormat()); + + assertEquals(Date.from(java.time.Instant.parse("2026-07-10T16:00:00Z")), converted); + } + + @Test + void testCustomListFormatDelimiterAndNoBrackets() { + PropertyKey propertyKey = propertyKey("tags", DataType.TEXT, Cardinality.LIST); + ListFormat listFormat = new ListFormat(); + listFormat.setStartSymbol(""); + listFormat.setEndSymbol(""); + listFormat.setElemDelimiter("|"); + + Object converted = DataTypeUtil.convert("a|b|c", propertyKey, null, null, listFormat); + + assertEquals(Arrays.asList("a", "b", "c"), converted); + } + + @Test + void testListFormatIgnoredElems() { + PropertyKey propertyKey = propertyKey("tags", DataType.TEXT, Cardinality.LIST); + ListFormat listFormat = new ListFormat(); + listFormat.setIgnoredElems(Collections.singletonList("NULL")); + + // Default start/end "[" "]" are stripped; the "NULL" element is dropped. + Object converted = DataTypeUtil.convert("[a,NULL,b]", propertyKey, null, null, listFormat); + + assertEquals(Arrays.asList("a", "b"), converted); + } + + private static PropertyKey propertyKey( + String name, DataType dataType, Cardinality cardinality) { + PropertyKey propertyKey = mock(PropertyKey.class); + when(propertyKey.name()).thenReturn(name); + when(propertyKey.dataType()).thenReturn(dataType); + when(propertyKey.cardinality()).thenReturn(cardinality); + return propertyKey; + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/HugeGraphTypeConverterTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/HugeGraphTypeConverterTest.java new file mode 100644 index 000000000000..757db7dff876 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/HugeGraphTypeConverterTest.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.utils; + +import org.apache.seatunnel.api.table.type.ArrayType; +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.apache.hugegraph.structure.constant.Cardinality; +import org.apache.hugegraph.structure.constant.DataType; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HugeGraphTypeConverterTest { + + @Test + void byteMapsToTinyint() { + assertEquals( + BasicType.BYTE_TYPE, + HugeGraphTypeConverter.toSeaTunnelType(DataType.BYTE, Cardinality.SINGLE, "flag")); + } + + @Test + void objectMapsToStringSoItDoesNotBlockTheRead() { + // A cold OBJECT column must be readable (as its string form) instead of throwing and + // blocking the whole label read. + assertEquals( + BasicType.STRING_TYPE, + HugeGraphTypeConverter.toSeaTunnelType( + DataType.OBJECT, Cardinality.SINGLE, "meta")); + } + + @Test + void byteListMapsToArrayOfTinyint() { + SeaTunnelDataType type = + HugeGraphTypeConverter.toSeaTunnelType(DataType.BYTE, Cardinality.LIST, "flags"); + assertEquals(ArrayType.of(BasicType.BYTE_TYPE), type); + } + + @Test + void unsupportedCombinationErrorNamesTheProperty() { + // The error must identify the offending column so a failure is easy to locate. + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> + HugeGraphTypeConverter.toSeaTunnelType( + DataType.BLOB, Cardinality.LIST, "avatar")); + assertTrue( + ex.getMessage().contains("avatar"), + "Error message should name the property: " + ex.getMessage()); + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/SchemaManagerNullableTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/SchemaManagerNullableTest.java new file mode 100644 index 000000000000..5caefe5cc553 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/SchemaManagerNullableTest.java @@ -0,0 +1,160 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.utils; + +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; + +import org.apache.hugegraph.structure.constant.Frequency; +import org.apache.hugegraph.structure.constant.IdStrategy; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Pins the nullable-by-default semantics that {@link SchemaManager#computeNullableKeys} feeds into + * HugeGraph label creation. HugeGraph server rejects any insert row that omits a non-nullable + * property, so a regression here silently breaks partial-property writes. + */ +class SchemaManagerNullableTest { + + @Test + void defaultsAllNonKeyPropertiesToNullableForVertex() { + MappingConfig m = vertexPrimaryKey("person", "id"); + Set props = setOf("id", "name", "age"); + + List result = SchemaManager.computeNullableKeys(m, props); + + assertEquals(setOf("name", "age"), new HashSet<>(result)); + } + + @Test + void excludesPrimaryKeyFromDefault() { + // Even without any explicit config, the PK must not appear in nullableKeys — + // HugeGraph server rejects the label-create call otherwise. + MappingConfig m = vertexPrimaryKey("person", "id"); + Set props = setOf("id", "name"); + + List result = SchemaManager.computeNullableKeys(m, props); + + assertTrue(result.contains("name")); + assertTrue(!result.contains("id")); + } + + @Test + void excludesSortKeysFromDefaultForMultipleEdge() { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.EDGE); + m.setLabel("visits"); + m.setFrequency(Frequency.MULTIPLE); + m.setSortKeys(Collections.singletonList("visited_at")); + Set props = setOf("visited_at", "device"); + + List result = SchemaManager.computeNullableKeys(m, props); + + assertEquals(Collections.singletonList("device"), result); + } + + @Test + void explicitNullableKeysWinAndAreFiltered() { + // Explicit user list is respected verbatim, minus keys and unknown props. + MappingConfig m = vertexPrimaryKey("person", "id"); + m.setNullableKeys(Arrays.asList("name", "id", "missing")); + Set props = setOf("id", "name", "age"); + + List result = SchemaManager.computeNullableKeys(m, props); + + assertEquals(Collections.singletonList("name"), result); + } + + @Test + void notNullableKeysCarvesOutRequiredProps() { + MappingConfig m = vertexPrimaryKey("person", "id"); + m.setNotNullableKeys(Collections.singletonList("name")); + Set props = setOf("id", "name", "age"); + + List result = SchemaManager.computeNullableKeys(m, props); + + assertEquals(Collections.singletonList("age"), sortedOnly(result)); + } + + @Test + void notNullableKeysHonorsFieldMapping() { + // User's opt-out list is in source-column names; must be translated through fieldMapping + // to target property names. + MappingConfig m = vertexPrimaryKey("person", "src_id"); + HashMap fm = new HashMap<>(); + fm.put("src_id", "id"); + fm.put("src_name", "name"); + m.setFieldMapping(fm); + m.setNotNullableKeys(Collections.singletonList("src_name")); + Set props = setOf("id", "name", "age"); + + List result = SchemaManager.computeNullableKeys(m, props); + + assertEquals(Collections.singletonList("age"), sortedOnly(result)); + } + + @Test + void explicitNullableKeysHonorsFieldMapping() { + MappingConfig m = vertexPrimaryKey("person", "src_id"); + HashMap fm = new HashMap<>(); + fm.put("src_id", "id"); + fm.put("src_name", "name"); + m.setFieldMapping(fm); + m.setNullableKeys(Collections.singletonList("src_name")); + Set props = setOf("id", "name", "age"); + + List result = SchemaManager.computeNullableKeys(m, props); + + assertEquals(Collections.singletonList("name"), result); + } + + @Test + void emptyPropertySetProducesEmptyResult() { + MappingConfig m = vertexPrimaryKey("person", "id"); + List result = SchemaManager.computeNullableKeys(m, Collections.emptySet()); + assertTrue(result.isEmpty()); + } + + private static MappingConfig vertexPrimaryKey(String label, String idField) { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel(label); + m.setIdStrategy(IdStrategy.PRIMARY_KEY); + m.setIdFields(Collections.singletonList(idField)); + return m; + } + + private static Set setOf(String... items) { + return new HashSet<>(Arrays.asList(items)); + } + + private static List sortedOnly(List list) { + return new java.util.ArrayList<>(new TreeSet<>(list)); + } +} diff --git a/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/SchemaValidatorTest.java b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/SchemaValidatorTest.java new file mode 100644 index 000000000000..8331b3999c36 --- /dev/null +++ b/seatunnel-connectors-v2/connector-hugegraph/src/test/java/org/apache/seatunnel/connectors/seatunnel/hugegraph/utils/SchemaValidatorTest.java @@ -0,0 +1,319 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.hugegraph.utils; + +import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.SeaTunnelDataType; +import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.client.HugeGraphClient; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.exception.HugeGraphConnectorException; + +import org.apache.hugegraph.structure.constant.Frequency; +import org.apache.hugegraph.structure.constant.IdStrategy; +import org.apache.hugegraph.structure.schema.VertexLabel; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * validateConfigOnly must catch every deterministic (server-independent) config error so that + * HugeGraphSink can run it before any server write. These tests exercise it with a null client — a + * regression in the "does not touch the server" property would surface as an NPE. + */ +class SchemaValidatorTest { + + private static final SeaTunnelRowType ROW_TYPE = + new SeaTunnelRowType( + new String[] {"id", "name", "created"}, + new SeaTunnelDataType[] { + BasicType.LONG_TYPE, BasicType.STRING_TYPE, BasicType.LONG_TYPE + }); + + private final SchemaValidator validator = new SchemaValidator(null, ROW_TYPE); + + @Test + void acceptsValidVertexMapping() { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel("person"); + m.setIdStrategy(IdStrategy.PRIMARY_KEY); + m.setIdFields(Collections.singletonList("id")); + m.setProperties(Arrays.asList("name")); + + assertDoesNotThrow(() -> validator.validateConfigOnly(Collections.singletonList(m))); + } + + @Test + void acceptsValidEdgeMapping() { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.EDGE); + m.setLabel("knows"); + m.setSourceConfig(sourceTarget("person", "id")); + m.setTargetConfig(sourceTarget("person", "id")); + m.setFrequency(Frequency.SINGLE); + m.setProperties(Collections.singletonList("name")); + + assertDoesNotThrow(() -> validator.validateConfigOnly(Collections.singletonList(m))); + } + + @Test + void rejectsVertexMissingIdFields() { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel("person"); + m.setIdStrategy(IdStrategy.PRIMARY_KEY); + + assertThrows( + HugeGraphConnectorException.class, + () -> validator.validateConfigOnly(Collections.singletonList(m))); + } + + @Test + void rejectsEdgeMissingSourceConfig() { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.EDGE); + m.setLabel("knows"); + m.setTargetConfig(sourceTarget("person", "id")); + + assertThrows( + HugeGraphConnectorException.class, + () -> validator.validateConfigOnly(Collections.singletonList(m))); + } + + @Test + void rejectsMultipleEdgeWithoutSortKeys() { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.EDGE); + m.setLabel("visits"); + m.setSourceConfig(sourceTarget("person", "id")); + m.setTargetConfig(sourceTarget("place", "id")); + m.setFrequency(Frequency.MULTIPLE); + + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> validator.validateConfigOnly(Collections.singletonList(m))); + assertTrue(ex.getMessage().contains("sortKeys")); + } + + @Test + void rejectsPropertyReferencingUnknownSourceField() { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel("person"); + m.setIdStrategy(IdStrategy.PRIMARY_KEY); + m.setIdFields(Collections.singletonList("id")); + m.setProperties(Collections.singletonList("does_not_exist")); + + assertThrows( + HugeGraphConnectorException.class, + () -> validator.validateConfigOnly(Collections.singletonList(m))); + } + + @Test + void firstMappingFailingStopsBeforeSecondIsChecked() { + // Ordering matters: HugeGraphSink relies on this method to fail fast before ensureSchema + // creates any schema for later mappings. + MappingConfig bad = new MappingConfig(); + bad.setType(MappingConfig.LabelType.EDGE); + bad.setLabel("knows"); + // missing sourceConfig / targetConfig + MappingConfig good = new MappingConfig(); + good.setType(MappingConfig.LabelType.VERTEX); + good.setLabel("person"); + good.setIdStrategy(IdStrategy.PRIMARY_KEY); + good.setIdFields(Collections.singletonList("id")); + + assertThrows( + HugeGraphConnectorException.class, + () -> validator.validateConfigOnly(Arrays.asList(bad, good))); + } + + @Test + void rejectsBothNullableAndNotNullableKeys() { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel("person"); + m.setIdStrategy(IdStrategy.PRIMARY_KEY); + m.setIdFields(Collections.singletonList("id")); + m.setNullableKeys(Collections.singletonList("name")); + m.setNotNullableKeys(Collections.singletonList("created")); + + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> validator.validateConfigOnly(Collections.singletonList(m))); + assertTrue(ex.getMessage().contains("mutually")); + } + + @Test + void acceptsOnlyNotNullableKeys() { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel("person"); + m.setIdStrategy(IdStrategy.PRIMARY_KEY); + m.setIdFields(Collections.singletonList("id")); + m.setNotNullableKeys(Collections.singletonList("name")); + + assertDoesNotThrow(() -> validator.validateConfigOnly(Collections.singletonList(m))); + } + + @Test + void rejectsRawIdPassthroughVertexWithPrimaryKey() { + // ~id passthrough supplies the id externally; PRIMARY_KEY derives it from properties, so + // this combination must be rejected up front. + SchemaValidator rawValidator = + new SchemaValidator( + null, + new SeaTunnelRowType( + new String[] {"~id", "name"}, + new SeaTunnelDataType[] { + BasicType.STRING_TYPE, BasicType.STRING_TYPE + })); + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel("person"); + m.setIdStrategy(IdStrategy.PRIMARY_KEY); + m.setIdFields(Collections.singletonList("~id")); + + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> rawValidator.validateConfigOnly(Collections.singletonList(m))); + assertTrue(ex.getMessage().contains("CUSTOMIZE")); + } + + @Test + void acceptsRawIdPassthroughVertexWithCustomize() { + SchemaValidator rawValidator = + new SchemaValidator( + null, + new SeaTunnelRowType( + new String[] {"~id", "name"}, + new SeaTunnelDataType[] { + BasicType.STRING_TYPE, BasicType.STRING_TYPE + })); + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel("person"); + m.setIdStrategy(IdStrategy.CUSTOMIZE_STRING); + m.setIdFields(Collections.singletonList("~id")); + + assertDoesNotThrow(() -> rawValidator.validateConfigOnly(Collections.singletonList(m))); + } + + @Test + void failsFastWhenExistingVertexLabelPrimaryKeyMismatch() { + // Reproduces the schema-pollution loop: a VertexLabel already exists with PK=[id] but the + // (corrected) config wants PK=[name]. This must abort BEFORE any creation, not after + // ensureSchema has already written other schema. + HugeGraphClient client = mock(HugeGraphClient.class); + VertexLabel existing = mock(VertexLabel.class); + when(existing.idStrategy()).thenReturn(IdStrategy.PRIMARY_KEY); + when(existing.primaryKeys()).thenReturn(Collections.singletonList("id")); + when(client.getVertexLabelOrNull("person")).thenReturn(existing); + when(client.getVertexLabel("person")).thenReturn(existing); + + SchemaValidator serverValidator = new SchemaValidator(client, ROW_TYPE); + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel("person"); + m.setIdStrategy(IdStrategy.PRIMARY_KEY); + m.setIdFields(Collections.singletonList("name")); + + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> serverValidator.validateExistingLabels(Collections.singletonList(m))); + assertTrue(ex.getMessage().contains("primary key mismatch")); + } + + @Test + void skipsLabelsThatDoNotYetExist() { + // Nothing exists on the server yet -> validateExistingLabels is a no-op (ensureSchema will + // create), so it must not throw or dereference a missing label. + HugeGraphClient client = mock(HugeGraphClient.class); + when(client.getVertexLabelOrNull("person")).thenReturn(null); + + SchemaValidator serverValidator = new SchemaValidator(client, ROW_TYPE); + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel("person"); + m.setIdStrategy(IdStrategy.PRIMARY_KEY); + m.setIdFields(Collections.singletonList("id")); + + assertDoesNotThrow( + () -> serverValidator.validateExistingLabels(Collections.singletonList(m))); + } + + @Test + void rejectsUnfoldWithPrimaryKeyStrategy() { + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel("person"); + m.setIdStrategy(IdStrategy.PRIMARY_KEY); + m.setIdFields(Collections.singletonList("id")); + m.setUnfold(true); + + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> validator.validateConfigOnly(Collections.singletonList(m))); + assertTrue(ex.getMessage().contains("CUSTOMIZE")); + } + + @Test + void rejectsUnfoldWithMultipleIdFields() { + SchemaValidator v = + new SchemaValidator( + null, + new SeaTunnelRowType( + new String[] {"a", "b"}, + new SeaTunnelDataType[] { + BasicType.STRING_TYPE, BasicType.STRING_TYPE + })); + MappingConfig m = new MappingConfig(); + m.setType(MappingConfig.LabelType.VERTEX); + m.setLabel("person"); + m.setIdStrategy(IdStrategy.CUSTOMIZE_STRING); + m.setIdFields(Arrays.asList("a", "b")); + m.setUnfold(true); + + HugeGraphConnectorException ex = + assertThrows( + HugeGraphConnectorException.class, + () -> v.validateConfigOnly(Collections.singletonList(m))); + assertTrue(ex.getMessage().contains("exactly one id field")); + } + + private static MappingConfig.SourceTargetConfig sourceTarget(String label, String idField) { + MappingConfig.SourceTargetConfig st = new MappingConfig.SourceTargetConfig(); + st.setLabel(label); + st.setIdFields(Collections.singletonList(idField)); + return st; + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/pom.xml b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/pom.xml index 3bc973c35173..b32d320c7adc 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/pom.xml +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/pom.xml @@ -47,6 +47,34 @@ test + + + com.fasterxml.jackson.jaxrs + jackson-jaxrs-json-provider + ${jackson.version} + test + + + com.fasterxml.jackson.jaxrs + jackson-jaxrs-base + ${jackson.version} + test + + + com.fasterxml.jackson.module + jackson-module-jaxb-annotations + ${jackson.version} + test + + org.apache.seatunnel connector-fake diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/java/org/apache/seatunnel/e2e/connector/hugegraph/HugeGraphIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/java/org/apache/seatunnel/e2e/connector/hugegraph/HugeGraphIT.java index bc8bdc518b12..c1f0d1af8750 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/java/org/apache/seatunnel/e2e/connector/hugegraph/HugeGraphIT.java +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/java/org/apache/seatunnel/e2e/connector/hugegraph/HugeGraphIT.java @@ -17,18 +17,23 @@ package org.apache.seatunnel.e2e.connector.hugegraph; +import org.apache.seatunnel.api.table.catalog.TablePath; +import org.apache.seatunnel.api.table.type.BasicType; import org.apache.seatunnel.api.table.type.RowKind; import org.apache.seatunnel.api.table.type.SeaTunnelDataType; import org.apache.seatunnel.api.table.type.SeaTunnelRow; import org.apache.seatunnel.api.table.type.SeaTunnelRowType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphConnectionConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphDataSaveMode; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSchemaSaveMode; import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.HugeGraphSinkConfig; import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig; -import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.SchemaConfig; -import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.SchemaConfig.SourceTargetConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig.LabelType; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.config.MappingConfig.SourceTargetConfig; +import org.apache.seatunnel.connectors.seatunnel.hugegraph.sink.HugeGraphSaveModeHandler; import org.apache.seatunnel.connectors.seatunnel.hugegraph.sink.HugeGraphSinkWriter; import org.apache.hugegraph.driver.HugeClient; -import org.apache.hugegraph.exception.ServerException; import org.apache.hugegraph.structure.constant.IdStrategy; import org.apache.hugegraph.structure.graph.Edge; import org.apache.hugegraph.structure.graph.Vertex; @@ -46,34 +51,26 @@ import java.io.IOException; import java.time.Duration; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.time.format.DateTimeFormatter; +import java.util.Arrays; import java.util.Collections; -import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; @Testcontainers public class HugeGraphIT { - private static final String HUGE_GRAPH_IMAGE = "hugegraph/hugegraph:latest"; + // Pinned to 1.7.0 to match the graph-space-aware client (REST paths are + // /graphspaces/{graphspace}/graphs/{graph}/...); a <1.7.0 server would 404 those paths. + private static final String HUGE_GRAPH_IMAGE = "hugegraph/hugegraph:1.7.0"; private static final String GRAPH_NAME = "hugegraph"; - private static final String VERTEX_LABEL_PERSON = "person_for_test"; - private static final String VERTEX_LABEL_ALL_TYPES = "vertex_all_types_for_test"; - private static final SeaTunnelRowType SEATUNNEL_ROW_TYPE = + private static final String VERTEX_LABEL = "person"; + private static final SeaTunnelRowType VERTEX_ROW_TYPE = new SeaTunnelRowType( new String[] {"name", "age"}, - new SeaTunnelDataType[] { - org.apache.seatunnel.api.table.type.BasicType.STRING_TYPE, - org.apache.seatunnel.api.table.type.BasicType.INT_TYPE - }); - private static final DateTimeFormatter formatter = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); + new SeaTunnelDataType[] {BasicType.STRING_TYPE, BasicType.INT_TYPE}); private static HugeClient hugeClient; @Container @@ -101,572 +98,546 @@ public static void cleanup() { @BeforeEach public void clearGraph() { - // Clear all vertices and edges before each test using GraphsManager.clearGraph() - try { - hugeClient.graphs().clearGraph(GRAPH_NAME, "I'm sure to delete all data"); - // After clearing, need to recreate schema - setupSchema(); - } catch (Exception e) { - // Ignore errors during clear - } + hugeClient.graphs().clearGraph(GRAPH_NAME, "I'm sure to delete all data"); + setupSchema(); } private static void setupSchema() { hugeClient.schema().propertyKey("name").asText().ifNotExist().create(); hugeClient.schema().propertyKey("age").asInt().ifNotExist().create(); + hugeClient.schema().propertyKey("weight").asDouble().ifNotExist().create(); + hugeClient .schema() - .vertexLabel(VERTEX_LABEL_PERSON) + .vertexLabel(VERTEX_LABEL) .idStrategy(IdStrategy.PRIMARY_KEY) .primaryKeys("name") .properties("name", "age") + .nullableKeys("age") .ifNotExist() .create(); - hugeClient.schema().propertyKey("duration").asFloat().ifNotExist().create(); hugeClient .schema() .edgeLabel("knows") - .sourceLabel(VERTEX_LABEL_PERSON) - .targetLabel(VERTEX_LABEL_PERSON) - .properties("duration") + .sourceLabel(VERTEX_LABEL) + .targetLabel(VERTEX_LABEL) + .properties("weight") + .nullableKeys("weight") .ifNotExist() .create(); + } - // New schema for all types vertex - hugeClient.schema().propertyKey("id_field").asText().ifNotExist().create(); - hugeClient.schema().propertyKey("prop_string").asText().ifNotExist().create(); - hugeClient.schema().propertyKey("prop_long").asLong().ifNotExist().create(); - hugeClient.schema().propertyKey("prop_double").asDouble().ifNotExist().create(); - hugeClient.schema().propertyKey("prop_boolean").asBoolean().ifNotExist().create(); - hugeClient.schema().propertyKey("prop_date").asDate().ifNotExist().create(); + private HugeGraphSinkWriter createSinkWriter( + MappingConfig mappingConfig, SeaTunnelRowType rowType) throws IOException { + return createSinkWriter(Collections.singletonList(mappingConfig), rowType, false, 100); + } - hugeClient - .schema() - .vertexLabel(VERTEX_LABEL_ALL_TYPES) - .idStrategy(IdStrategy.CUSTOMIZE_STRING) - .properties( - "id_field", - "prop_string", - "prop_long", - "prop_double", - "prop_boolean", - "prop_date") - .ifNotExist() - .create(); + private HugeGraphSinkWriter createSinkWriter( + List mappingConfigs, + SeaTunnelRowType rowType, + boolean deleteVertexWithEdges, + int batchSize) + throws IOException { + HugeGraphConnectionConfig connectionConfig = new HugeGraphConnectionConfig(); + connectionConfig.setHost(HUGE_GRAPH_CONTAINER.getHost()); + connectionConfig.setPort(HUGE_GRAPH_CONTAINER.getMappedPort(8080)); + connectionConfig.setGraphName(GRAPH_NAME); - hugeClient.schema().propertyKey("lang").asText().ifNotExist().create(); + HugeGraphSinkConfig config = new HugeGraphSinkConfig(); + config.setConnectionConfig(connectionConfig); + config.setBatchSize(batchSize); + config.setBatchIntervalMs(0); + config.setMaxRetries(0); + config.setRetryBackoffMs(0); + config.setMappings(mappingConfigs); + config.setDeleteVertexWithEdges(deleteVertexWithEdges); + return new HugeGraphSinkWriter(config, rowType); + } - hugeClient - .schema() - .vertexLabel("person_pk_for_edge") - .idStrategy(IdStrategy.PRIMARY_KEY) - .primaryKeys("name") - .properties("name") - .ifNotExist() - .create(); + @Test + public void testVertexInsert() throws IOException { + MappingConfig mapping = new MappingConfig(); + mapping.setType(LabelType.VERTEX); + mapping.setLabel(VERTEX_LABEL); + mapping.setIdStrategy(IdStrategy.PRIMARY_KEY); + mapping.setIdFields(Collections.singletonList("name")); + mapping.setProperties(Arrays.asList("name", "age")); + + HugeGraphSinkWriter writer = createSinkWriter(mapping, VERTEX_ROW_TYPE); + SeaTunnelRow row = new SeaTunnelRow(new Object[] {"marko", 29}); + row.setRowKind(RowKind.INSERT); + writer.write(row); + writer.close(); - hugeClient - .schema() - .vertexLabel("software_cs_for_edge") - .idStrategy(IdStrategy.CUSTOMIZE_STRING) - .properties("lang") - .ifNotExist() - .create(); + Map properties = new HashMap<>(); + properties.put("name", "marko"); + List vertices = hugeClient.graph().listVertices(VERTEX_LABEL, properties, 10); + assertEquals(1, vertices.size()); + assertEquals(29, vertices.get(0).property("age")); + } - hugeClient - .schema() - .edgeLabel("transfer") - .sourceLabel("person_pk_for_edge") - .targetLabel("software_cs_for_edge") - .properties("prop_string", "prop_long", "prop_double", "prop_boolean", "prop_date") - .ifNotExist() - .create(); + @Test + public void testVertexUpdate() throws IOException { + Vertex vadas = new Vertex(VERTEX_LABEL); + vadas.property("name", "vadas"); + vadas.property("age", 27); + hugeClient.graph().addVertex(vadas); + + MappingConfig mapping = new MappingConfig(); + mapping.setType(LabelType.VERTEX); + mapping.setLabel(VERTEX_LABEL); + mapping.setIdStrategy(IdStrategy.PRIMARY_KEY); + mapping.setIdFields(Collections.singletonList("name")); + mapping.setProperties(Arrays.asList("name", "age")); + + HugeGraphSinkWriter writer = createSinkWriter(mapping, VERTEX_ROW_TYPE); + SeaTunnelRow row = new SeaTunnelRow(new Object[] {"vadas", 28}); + row.setRowKind(RowKind.UPDATE_AFTER); + writer.write(row); + writer.close(); + + Map properties = new HashMap<>(); + properties.put("name", "vadas"); + List vertices = hugeClient.graph().listVertices(VERTEX_LABEL, properties, 10); + assertEquals(1, vertices.size()); + assertEquals(28, vertices.get(0).property("age")); } - private HugeGraphSinkWriter createSinkWriter( - SchemaConfig schemaConfig, SeaTunnelRowType rowType) throws IOException { - HugeGraphSinkConfig config = new HugeGraphSinkConfig(); - config.setHost(HUGE_GRAPH_CONTAINER.getHost()); - config.setPort(HUGE_GRAPH_CONTAINER.getMappedPort(8080)); - config.setGraphName(GRAPH_NAME); - config.setSchemaConfig(schemaConfig); - return new HugeGraphSinkWriter(config, rowType); + @Test + public void testVertexDelete() throws IOException { + Vertex josh = new Vertex(VERTEX_LABEL); + josh.property("name", "josh"); + josh.property("age", 32); + hugeClient.graph().addVertex(josh); + + MappingConfig mapping = new MappingConfig(); + mapping.setType(LabelType.VERTEX); + mapping.setLabel(VERTEX_LABEL); + mapping.setIdStrategy(IdStrategy.PRIMARY_KEY); + mapping.setIdFields(Collections.singletonList("name")); + mapping.setProperties(Arrays.asList("name", "age")); + + HugeGraphSinkWriter writer = createSinkWriter(mapping, VERTEX_ROW_TYPE); + SeaTunnelRow row = new SeaTunnelRow(new Object[] {"josh", 32}); + row.setRowKind(RowKind.DELETE); + writer.write(row); + writer.close(); + + Map properties = new HashMap<>(); + properties.put("name", "josh"); + List vertices = hugeClient.graph().listVertices(VERTEX_LABEL, properties, 10); + Assertions.assertTrue(vertices.isEmpty(), "Vertex should have been deleted"); } @Test - public void testInsert() throws IOException { - SchemaConfig schemaConfig = new SchemaConfig(); - schemaConfig.setType(SchemaConfig.LabelType.VERTEX); - schemaConfig.setLabel(VERTEX_LABEL_PERSON); - schemaConfig.setIdStrategy(IdStrategy.PRIMARY_KEY); - schemaConfig.setIdFields(Collections.singletonList("name")); + public void testUpdateChangingVertexKeyDeletesOldVertex() throws IOException { + hugeClient + .graph() + .addVertex(new Vertex(VERTEX_LABEL).property("name", "alice").property("age", 20)); + + MappingConfig mapping = new MappingConfig(); + mapping.setType(LabelType.VERTEX); + mapping.setLabel(VERTEX_LABEL); + mapping.setIdStrategy(IdStrategy.PRIMARY_KEY); + mapping.setIdFields(Collections.singletonList("name")); + mapping.setProperties(Arrays.asList("name", "age")); + + HugeGraphSinkWriter writer = createSinkWriter(mapping, VERTEX_ROW_TYPE); + SeaTunnelRow before = new SeaTunnelRow(new Object[] {"alice", 20}); + before.setRowKind(RowKind.UPDATE_BEFORE); + SeaTunnelRow after = new SeaTunnelRow(new Object[] {"alice2", 21}); + after.setRowKind(RowKind.UPDATE_AFTER); + writer.write(before); + writer.write(after); + writer.close(); - try { - HugeGraphSinkWriter writer = createSinkWriter(schemaConfig, SEATUNNEL_ROW_TYPE); - SeaTunnelRow row = new SeaTunnelRow(new Object[] {"marko", 29}); - row.setRowKind(RowKind.INSERT); - writer.write(row); - writer.close(); - } finally { + Map oldProps = new HashMap<>(); + oldProps.put("name", "alice"); + Assertions.assertTrue( + hugeClient.graph().listVertices(VERTEX_LABEL, oldProps, 10).isEmpty(), + "The pre-update vertex must be deleted when the primary key changes"); + + Map newProps = new HashMap<>(); + newProps.put("name", "alice2"); + List renamed = hugeClient.graph().listVertices(VERTEX_LABEL, newProps, 10); + assertEquals(1, renamed.size()); + assertEquals(21, renamed.get(0).property("age")); + } - } + @Test + public void testUpdateUnchangedVertexKeyPreservesEdges() throws IOException { + Vertex marko = new Vertex(VERTEX_LABEL).property("name", "marko").property("age", 29); + Vertex david = new Vertex(VERTEX_LABEL).property("name", "david").property("age", 30); + marko = hugeClient.graph().addVertex(marko); + david = hugeClient.graph().addVertex(david); + hugeClient + .graph() + .addEdge(new Edge("knows").source(marko).target(david).property("weight", 1.0)); + assertEquals(1, hugeClient.graph().listEdges("knows").size()); - // Verify using REST API - Map properties = new HashMap<>(); - properties.put("name", "marko"); - List vertices = - hugeClient.graph().listVertices(VERTEX_LABEL_PERSON, properties, 10); + MappingConfig mapping = new MappingConfig(); + mapping.setType(LabelType.VERTEX); + mapping.setLabel(VERTEX_LABEL); + mapping.setIdStrategy(IdStrategy.PRIMARY_KEY); + mapping.setIdFields(Collections.singletonList("name")); + mapping.setProperties(Arrays.asList("name", "age")); + + HugeGraphSinkWriter writer = createSinkWriter(mapping, VERTEX_ROW_TYPE); + SeaTunnelRow before = new SeaTunnelRow(new Object[] {"marko", 29}); + before.setRowKind(RowKind.UPDATE_BEFORE); + SeaTunnelRow after = new SeaTunnelRow(new Object[] {"marko", 40}); + after.setRowKind(RowKind.UPDATE_AFTER); + writer.write(before); + writer.write(after); + writer.close(); + + // Primary key unchanged => no delete-recreate => the vertex's adjacent edge survives. + assertEquals( + 1, + hugeClient.graph().listEdges("knows").size(), + "A key-unchanged update must update in place and not drop the vertex's edges"); + Map props = new HashMap<>(); + props.put("name", "marko"); + List vertices = hugeClient.graph().listVertices(VERTEX_LABEL, props, 10); assertEquals(1, vertices.size()); - assertEquals(29, vertices.get(0).property("age")); + assertEquals(40, vertices.get(0).property("age")); } @Test public void testEdgeInsert() throws IOException { - // 1. Insert source and target vertices - Vertex marko = - new Vertex(VERTEX_LABEL_PERSON).property("name", "marko").property("age", 29); - Vertex david = - new Vertex(VERTEX_LABEL_PERSON).property("name", "david").property("age", 30); + Vertex marko = new Vertex(VERTEX_LABEL).property("name", "marko").property("age", 29); + Vertex david = new Vertex(VERTEX_LABEL).property("name", "david").property("age", 30); hugeClient.graph().addVertex(marko); hugeClient.graph().addVertex(david); - // 2. Define edge row type SeaTunnelRowType edgeRowType = new SeaTunnelRowType( - new String[] {"src_name", "tgt_name", "duration"}, + new String[] {"src_name", "tgt_name", "weight"}, new SeaTunnelDataType[] { - org.apache.seatunnel.api.table.type.BasicType.STRING_TYPE, - org.apache.seatunnel.api.table.type.BasicType.STRING_TYPE, - org.apache.seatunnel.api.table.type.BasicType.FLOAT_TYPE + BasicType.STRING_TYPE, BasicType.STRING_TYPE, BasicType.DOUBLE_TYPE }); - // 3. Configure SchemaConfig for edge - SchemaConfig schemaConfig = new SchemaConfig(); - schemaConfig.setType(SchemaConfig.LabelType.EDGE); - schemaConfig.setLabel("knows"); + MappingConfig mapping = new MappingConfig(); + mapping.setType(LabelType.EDGE); + mapping.setLabel("knows"); SourceTargetConfig sourceConfig = new SourceTargetConfig(); - sourceConfig.setLabel(VERTEX_LABEL_PERSON); + sourceConfig.setLabel(VERTEX_LABEL); sourceConfig.setIdFields(Collections.singletonList("src_name")); + mapping.setSourceConfig(sourceConfig); SourceTargetConfig targetConfig = new SourceTargetConfig(); - targetConfig.setLabel(VERTEX_LABEL_PERSON); + targetConfig.setLabel(VERTEX_LABEL); targetConfig.setIdFields(Collections.singletonList("tgt_name")); + mapping.setTargetConfig(targetConfig); + + mapping.setProperties(Arrays.asList("weight")); + Map fieldMap = new HashMap<>(); + fieldMap.put("src_name", "name"); + fieldMap.put("tgt_name", "name"); + mapping.setFieldMapping(fieldMap); + + HugeGraphSinkWriter writer = createSinkWriter(mapping, edgeRowType); + SeaTunnelRow row = new SeaTunnelRow(new Object[] {"marko", "david", 1.5}); + row.setRowKind(RowKind.INSERT); + writer.write(row); + writer.close(); - schemaConfig.setSourceConfig(sourceConfig); - schemaConfig.setTargetConfig(targetConfig); - - MappingConfig mappingConfig = new MappingConfig(); - Map map = new HashMap<>(); - map.put("duration", "duration"); - map.put("src_name", "name"); - map.put("tgt_name", "name"); - mappingConfig.setFieldMapping(map); - schemaConfig.setMapping(mappingConfig); - - try { - // 4. Create writer with new row type - HugeGraphSinkWriter writer = createSinkWriter(schemaConfig, edgeRowType); - // 5. Create and write row - SeaTunnelRow row = new SeaTunnelRow(new Object[] {"marko", "david", 1.5}); - row.setRowKind(RowKind.INSERT); - writer.write(row); - writer.close(); - } finally { - } - - // 6. Verify edge creation List edges = hugeClient.graph().listEdges("knows"); assertEquals(1, edges.size()); - Edge createdEdge = edges.get(0); - assertEquals(1.5, createdEdge.property("duration")); - - // Also verify source and target - Vertex sourceVertex = hugeClient.graph().getVertex(createdEdge.sourceId()); - Vertex targetVertex = hugeClient.graph().getVertex(createdEdge.targetId()); - assertEquals("marko", sourceVertex.property("name")); - assertEquals("david", targetVertex.property("name")); - - // 7. Verify the frequency setting - try { - HugeGraphSinkWriter writer = createSinkWriter(schemaConfig, edgeRowType); - SeaTunnelRow row = new SeaTunnelRow(new Object[] {"marko", "david", 11.0}); - row.setRowKind(RowKind.INSERT); - writer.write(row); - writer.close(); - } finally { - } - - List edges_overwrite = hugeClient.graph().listEdges("knows"); - assertEquals(1, edges_overwrite.size()); - Edge createdEdge_overwrite = edges_overwrite.get(0); - assertEquals(11.0, createdEdge_overwrite.property("duration")); - } - - @Test - public void testUpdate() throws IOException { - // First, insert a vertex using REST API - Vertex vadas = new Vertex(VERTEX_LABEL_PERSON); - vadas.property("name", "vadas"); - vadas.property("age", 27); - hugeClient.graph().addVertex(vadas); - - MappingConfig mappingConfig = new MappingConfig(); - Map map = new HashMap<>(); - map.put("name", "name"); - map.put("age", "age"); - mappingConfig.setFieldMapping(map); - SchemaConfig schemaConfig = new SchemaConfig(); - schemaConfig.setType(SchemaConfig.LabelType.VERTEX); - schemaConfig.setLabel(VERTEX_LABEL_PERSON); - schemaConfig.setIdStrategy(IdStrategy.PRIMARY_KEY); - schemaConfig.setIdFields(Collections.singletonList("name")); - schemaConfig.setMapping(mappingConfig); - - try { - HugeGraphSinkWriter writer = createSinkWriter(schemaConfig, SEATUNNEL_ROW_TYPE); - SeaTunnelRow row = new SeaTunnelRow(new Object[] {"vadas", 28}); - row.setRowKind(RowKind.UPDATE_AFTER); - writer.write(row); - writer.close(); - } finally { - } - - // Verify using REST API - Map properties = new HashMap<>(); - properties.put("name", "vadas"); - List vertices = - hugeClient.graph().listVertices(VERTEX_LABEL_PERSON, properties, 10); - assertEquals(1, vertices.size()); - assertEquals(28, vertices.get(0).property("age")); + assertEquals(1.5, edges.get(0).property("weight")); } @Test public void testEdgeDelete() throws IOException { - // 1. Insert vertices and an edge to be deleted - Vertex marko = - new Vertex(VERTEX_LABEL_PERSON).property("name", "marko").property("age", 29); - Vertex david = - new Vertex(VERTEX_LABEL_PERSON).property("name", "david").property("age", 30); + Vertex marko = new Vertex(VERTEX_LABEL).property("name", "marko").property("age", 29); + Vertex david = new Vertex(VERTEX_LABEL).property("name", "david").property("age", 30); marko = hugeClient.graph().addVertex(marko); david = hugeClient.graph().addVertex(david); - Edge edge = new Edge("knows").source(marko).target(david).property("duration", 12.3); + Edge edge = new Edge("knows").source(marko).target(david).property("weight", 2.0); hugeClient.graph().addEdge(edge); - - // Verify it exists first and there assertEquals(1, hugeClient.graph().listEdges("knows").size()); - // 2. Define edge row type (only source/target fields needed for identification) SeaTunnelRowType edgeRowType = new SeaTunnelRowType( new String[] {"src_name", "tgt_name"}, - new SeaTunnelDataType[] { - org.apache.seatunnel.api.table.type.BasicType.STRING_TYPE, - org.apache.seatunnel.api.table.type.BasicType.STRING_TYPE - }); + new SeaTunnelDataType[] {BasicType.STRING_TYPE, BasicType.STRING_TYPE}); - // 3. Configure SchemaConfig for edge deletion - SchemaConfig schemaConfig = new SchemaConfig(); - schemaConfig.setType(SchemaConfig.LabelType.EDGE); - schemaConfig.setLabel("knows"); + MappingConfig mapping = new MappingConfig(); + mapping.setType(LabelType.EDGE); + mapping.setLabel("knows"); SourceTargetConfig sourceConfig = new SourceTargetConfig(); - sourceConfig.setLabel(VERTEX_LABEL_PERSON); + sourceConfig.setLabel(VERTEX_LABEL); sourceConfig.setIdFields(Collections.singletonList("src_name")); + mapping.setSourceConfig(sourceConfig); + SourceTargetConfig targetConfig = new SourceTargetConfig(); - targetConfig.setLabel(VERTEX_LABEL_PERSON); + targetConfig.setLabel(VERTEX_LABEL); targetConfig.setIdFields(Collections.singletonList("tgt_name")); - schemaConfig.setSourceConfig(sourceConfig); - schemaConfig.setTargetConfig(targetConfig); + mapping.setTargetConfig(targetConfig); - MappingConfig mappingConfig = new MappingConfig(); - Map map = new HashMap<>(); - map.put("duration", "duration"); - map.put("src_name", "name"); - map.put("tgt_name", "name"); - mappingConfig.setFieldMapping(map); - schemaConfig.setMapping(mappingConfig); + Map fieldMap = new HashMap<>(); + fieldMap.put("src_name", "name"); + fieldMap.put("tgt_name", "name"); + mapping.setFieldMapping(fieldMap); - try { - // 4. Create writer - HugeGraphSinkWriter writer = createSinkWriter(schemaConfig, edgeRowType); - // 5. Create and write DELETE row - SeaTunnelRow row = new SeaTunnelRow(new Object[] {"marko", "david"}); - row.setRowKind(RowKind.DELETE); - writer.write(row); - writer.close(); - } finally { - } + HugeGraphSinkWriter writer = createSinkWriter(mapping, edgeRowType); + SeaTunnelRow row = new SeaTunnelRow(new Object[] {"marko", "david"}); + row.setRowKind(RowKind.DELETE); + writer.write(row); + writer.close(); - // 6. Verify edge is deleted Assertions.assertTrue(hugeClient.graph().listEdges("knows").isEmpty()); } - @Test - public void testDelete() throws IOException { - // First, insert a vertex using REST API - Vertex josh = new Vertex(VERTEX_LABEL_PERSON); - josh.property("name", "josh"); - josh.property("age", 32); - hugeClient.graph().addVertex(josh); - - SchemaConfig schemaConfig = new SchemaConfig(); - schemaConfig.setType(SchemaConfig.LabelType.VERTEX); - schemaConfig.setLabel(VERTEX_LABEL_PERSON); - schemaConfig.setIdStrategy(IdStrategy.PRIMARY_KEY); - schemaConfig.setIdFields(Collections.singletonList("name")); - - try { - HugeGraphSinkWriter writer = createSinkWriter(schemaConfig, SEATUNNEL_ROW_TYPE); - // The row only needs to contain the ID fields for a delete operation - SeaTunnelRow row = new SeaTunnelRow(new Object[] {"josh", 32}); - row.setRowKind(RowKind.DELETE); - writer.write(row); - writer.close(); - } finally { - } - - // Verify using REST API - Map properties = new HashMap<>(); - properties.put("name", "josh"); - List vertices = - hugeClient.graph().listVertices(VERTEX_LABEL_PERSON, properties, 10); - Assertions.assertTrue(vertices.isEmpty(), "Vertex should have been deleted"); + private List buildMultiMappingConfigs() { + MappingConfig vertexMapping = new MappingConfig(); + vertexMapping.setType(LabelType.VERTEX); + vertexMapping.setLabel(VERTEX_LABEL); + vertexMapping.setIdStrategy(IdStrategy.PRIMARY_KEY); + vertexMapping.setIdFields(Collections.singletonList("v_name")); + vertexMapping.setProperties(Arrays.asList("v_name", "v_age")); + Map vertexFm = new HashMap<>(); + vertexFm.put("v_name", "name"); + vertexFm.put("v_age", "age"); + vertexMapping.setFieldMapping(vertexFm); + + MappingConfig edgeMapping = new MappingConfig(); + edgeMapping.setType(LabelType.EDGE); + edgeMapping.setLabel("knows"); + SourceTargetConfig srcCfg = new SourceTargetConfig(); + srcCfg.setLabel(VERTEX_LABEL); + srcCfg.setIdFields(Collections.singletonList("src")); + edgeMapping.setSourceConfig(srcCfg); + SourceTargetConfig tgtCfg = new SourceTargetConfig(); + tgtCfg.setLabel(VERTEX_LABEL); + tgtCfg.setIdFields(Collections.singletonList("tgt")); + edgeMapping.setTargetConfig(tgtCfg); + edgeMapping.setProperties(Collections.singletonList("weight")); + Map edgeFm = new HashMap<>(); + edgeFm.put("src", "name"); + edgeFm.put("tgt", "name"); + edgeMapping.setFieldMapping(edgeFm); + + return Arrays.asList(vertexMapping, edgeMapping); } + private static final SeaTunnelRowType MULTI_MAPPING_ROW_TYPE = + new SeaTunnelRowType( + new String[] {"v_name", "v_age", "src", "tgt", "weight"}, + new SeaTunnelDataType[] { + BasicType.STRING_TYPE, BasicType.INT_TYPE, + BasicType.STRING_TYPE, BasicType.STRING_TYPE, + BasicType.DOUBLE_TYPE + }); + @Test - public void testVertexWithCustomizedIdAndAllTypes() throws IOException { - // 1. Define RowType for vertex with various data types - SeaTunnelRowType allTypesRowType = - new SeaTunnelRowType( - new String[] { - "id_field", - "prop_string", - "prop_long", - "prop_double", - "prop_boolean", - "prop_date_1" - }, - new SeaTunnelDataType[] { - org.apache.seatunnel.api.table.type.BasicType.STRING_TYPE, - org.apache.seatunnel.api.table.type.BasicType.STRING_TYPE, - org.apache.seatunnel.api.table.type.BasicType.LONG_TYPE, - org.apache.seatunnel.api.table.type.BasicType.DOUBLE_TYPE, - org.apache.seatunnel.api.table.type.BasicType.BOOLEAN_TYPE, - org.apache.seatunnel.api.table.type.LocalTimeType.LOCAL_DATE_TIME_TYPE - }); + public void testMultiMappingDeleteWithoutCascade() throws IOException { + Vertex alice = new Vertex(VERTEX_LABEL).property("name", "Alice").property("age", 30); + Vertex bob = new Vertex(VERTEX_LABEL).property("name", "Bob").property("age", 25); + alice = hugeClient.graph().addVertex(alice); + bob = hugeClient.graph().addVertex(bob); + Edge edge = new Edge("knows").source(alice).target(bob).property("weight", 1.0); + hugeClient.graph().addEdge(edge); + assertEquals(1, hugeClient.graph().listEdges("knows").size()); - // 2. Configure SchemaConfig for the new vertex type - MappingConfig mappingConfig = new MappingConfig(); - Map map = new HashMap<>(); - map.put("prop_date_1", "prop_date"); - mappingConfig.setFieldMapping(map); // 'id_field' will be used as the custom ID - mappingConfig.setTimeZone("UTC"); - - SchemaConfig schemaConfig = new SchemaConfig(); - schemaConfig.setType(SchemaConfig.LabelType.VERTEX); - schemaConfig.setLabel(VERTEX_LABEL_ALL_TYPES); - schemaConfig.setIdStrategy(IdStrategy.CUSTOMIZE_STRING); - schemaConfig.setIdFields(Collections.singletonList("id_field")); - schemaConfig.setMapping(mappingConfig); - - // 3. INSERT operation - HugeGraphSinkWriter writer = createSinkWriter(schemaConfig, allTypesRowType); - LocalDateTime insertDate = LocalDateTime.of(2023, 1, 1, 12, 0, 0); - Object[] insertData = - new Object[] {"custom_id_1", "hello", 2147483648L, 123.45, true, insertDate}; - SeaTunnelRow insertRow = new SeaTunnelRow(insertData); - insertRow.setRowKind(RowKind.INSERT); - writer.write(insertRow); + HugeGraphSinkWriter writer = + createSinkWriter(buildMultiMappingConfigs(), MULTI_MAPPING_ROW_TYPE, false, 100); + SeaTunnelRow row = new SeaTunnelRow(new Object[] {"Alice", 30, "Alice", "Bob", 1.0}); + row.setRowKind(RowKind.DELETE); + writer.write(row); writer.close(); - // 4. Verify INSERT - System.out.println(hugeClient.graph().getVertex("custom_id_1")); - Vertex insertedVertex = hugeClient.graph().getVertex("custom_id_1"); - Assertions.assertNotNull(insertedVertex); - assertEquals(VERTEX_LABEL_ALL_TYPES, insertedVertex.label()); - assertEquals("hello", insertedVertex.property("prop_string")); - assertEquals(2147483648L, insertedVertex.property("prop_long")); - assertEquals(123.45, insertedVertex.property("prop_double")); - assertEquals(true, insertedVertex.property("prop_boolean")); - // The date is serialized as a long (timestamp) - Date expectedDate = Date.from(insertDate.atZone(ZoneOffset.UTC).toInstant()); - LocalDateTime insertDateTime = - LocalDateTime.parse((String) insertedVertex.property("prop_date"), formatter); - long insertTimeStampUtc = insertDateTime.toInstant(ZoneOffset.UTC).toEpochMilli(); - Assertions.assertEquals(expectedDate.getTime(), insertTimeStampUtc); - - // 5. UPDATE operation - writer = createSinkWriter(schemaConfig, allTypesRowType); - LocalDateTime updateDate = LocalDateTime.of(2024, 2, 2, 1, 1, 1); - Object[] updateData = - new Object[] {"custom_id_1", "world", 2000000L, 543.21, false, updateDate}; - SeaTunnelRow updateRow = new SeaTunnelRow(updateData); - updateRow.setRowKind(RowKind.UPDATE_AFTER); - writer.write(updateRow); - writer.close(); + assertEquals( + 0, + hugeClient.graph().listEdges("knows").size(), + "Edge should have been deleted before vertex"); + Map props = new HashMap<>(); + props.put("name", "Alice"); + Assertions.assertTrue( + hugeClient.graph().listVertices(VERTEX_LABEL, props, 10).isEmpty(), + "Vertex Alice should have been deleted"); + props.put("name", "Bob"); + assertEquals( + 1, + hugeClient.graph().listVertices(VERTEX_LABEL, props, 10).size(), + "Vertex Bob should still exist"); + } - // 6. Verify UPDATE - System.out.println(hugeClient.graph().getVertex("custom_id_1")); - Vertex updatedVertex = hugeClient.graph().getVertex("custom_id_1"); - Assertions.assertNotNull(updatedVertex); - assertEquals("world", updatedVertex.property("prop_string")); - assertEquals(2000000L, ((Number) updatedVertex.property("prop_long")).longValue()); - assertEquals(543.21, updatedVertex.property("prop_double")); - assertEquals(false, updatedVertex.property("prop_boolean")); - - Date expectedUpdateDate = Date.from(updateDate.atZone(ZoneOffset.UTC).toInstant()); - LocalDateTime updatedDateTime = - LocalDateTime.parse((String) updatedVertex.property("prop_date"), formatter); - long updatedTimeStampMillisUtc = updatedDateTime.toInstant(ZoneOffset.UTC).toEpochMilli(); - Assertions.assertEquals(expectedUpdateDate.getTime(), updatedTimeStampMillisUtc); - - // 7. DELETE operation - writer = createSinkWriter(schemaConfig, allTypesRowType); - // For delete, only the ID field is required. - Object[] deleteData = new Object[] {"custom_id_1", null, null, null, null, null}; - SeaTunnelRow deleteRow = new SeaTunnelRow(deleteData); - deleteRow.setRowKind(RowKind.DELETE); - writer.write(deleteRow); - writer.close(); + @Test + public void testMultiMappingDeleteWithCascade() throws IOException { + Vertex alice = new Vertex(VERTEX_LABEL).property("name", "Alice").property("age", 30); + Vertex bob = new Vertex(VERTEX_LABEL).property("name", "Bob").property("age", 25); + alice = hugeClient.graph().addVertex(alice); + bob = hugeClient.graph().addVertex(bob); + Edge edge = new Edge("knows").source(alice).target(bob).property("weight", 1.0); + hugeClient.graph().addEdge(edge); + assertEquals(1, hugeClient.graph().listEdges("knows").size()); - // 8. Verify DELETE - ServerException serverException = - assertThrows( - ServerException.class, - () -> { - hugeClient.graph().getVertex("custom_id_1"); - }); + HugeGraphSinkWriter writer = + createSinkWriter(buildMultiMappingConfigs(), MULTI_MAPPING_ROW_TYPE, true, 100); + SeaTunnelRow row = new SeaTunnelRow(new Object[] {"Alice", 30, "Alice", "Bob", 1.0}); + row.setRowKind(RowKind.DELETE); + writer.write(row); + writer.close(); - String expectedErrorMessage = "Vertex 'custom_id_1' does not exist"; - assertEquals(expectedErrorMessage, serverException.getMessage()); + assertEquals( + 0, hugeClient.graph().listEdges("knows").size(), "Edge should have been deleted"); + Map props = new HashMap<>(); + props.put("name", "Alice"); + Assertions.assertTrue( + hugeClient.graph().listVertices(VERTEX_LABEL, props, 10).isEmpty(), + "Vertex Alice should have been deleted (cascade as safety net)"); + props.put("name", "Bob"); + assertEquals( + 1, + hugeClient.graph().listVertices(VERTEX_LABEL, props, 10).size(), + "Vertex Bob should still exist"); } @Test - public void testEdgeWithComplexTypesAndIdStrategies() throws IOException { - // 1. Insert source and target vertices - Vertex person = new Vertex("person_pk_for_edge").property("name", "person1"); - hugeClient.graph().addVertex(person); + public void testDropDataDeletesOnlyTargetLabelData() { + // A second vertex label that this job does NOT target — its data must survive a scoped + // DROP_DATA. The old whole-graph clearGraph would have wiped it (and its schema) too. + hugeClient + .schema() + .vertexLabel("software") + .idStrategy(IdStrategy.PRIMARY_KEY) + .primaryKeys("name") + .properties("name") + .ifNotExist() + .create(); + hugeClient + .graph() + .addVertex(new Vertex(VERTEX_LABEL).property("name", "marko").property("age", 29)); + hugeClient.graph().addVertex(new Vertex("software").property("name", "lop")); + assertEquals(1, hugeClient.graph().listVertices(VERTEX_LABEL).size()); + assertEquals(1, hugeClient.graph().listVertices("software").size()); + + HugeGraphSaveModeHandler handler = + createSaveModeHandler( + Collections.singletonList(personVertexMapping()), + HugeGraphDataSaveMode.DROP_DATA, + VERTEX_ROW_TYPE); + try { + handler.open(); + handler.handleDataSaveMode(); + } finally { + handler.close(); + } - Vertex software = new Vertex("software_cs_for_edge"); - software.id("software1"); - software.property("lang", "java"); - hugeClient.graph().addVertex(software); + Assertions.assertTrue( + hugeClient.graph().listVertices(VERTEX_LABEL).isEmpty(), + "In-scope label 'person' data must be dropped"); + assertEquals( + 1, + hugeClient.graph().listVertices("software").size(), + "Out-of-scope label 'software' data must be preserved"); + // Schema of the dropped label is preserved (data-only delete, not clearGraph). + Assertions.assertNotNull( + hugeClient.schema().getVertexLabel(VERTEX_LABEL), + "Dropped label's schema must remain"); + } - // 2. Define edge row type with all properties - SeaTunnelRowType edgeRowType = - new SeaTunnelRowType( - new String[] { - "src_name", - "tgt_id", - "prop_string", - "prop_long", - "prop_double", - "prop_boolean", - "prop_date" - }, - new SeaTunnelDataType[] { - org.apache.seatunnel.api.table.type.BasicType.STRING_TYPE, - org.apache.seatunnel.api.table.type.BasicType.STRING_TYPE, - org.apache.seatunnel.api.table.type.BasicType.STRING_TYPE, - org.apache.seatunnel.api.table.type.BasicType.LONG_TYPE, - org.apache.seatunnel.api.table.type.BasicType.DOUBLE_TYPE, - org.apache.seatunnel.api.table.type.BasicType.BOOLEAN_TYPE, - org.apache.seatunnel.api.table.type.LocalTimeType.LOCAL_DATE_TIME_TYPE - }); + @Test + public void testDropDataDeletesTargetVerticesAndEdges() { + Vertex alice = + hugeClient + .graph() + .addVertex( + new Vertex(VERTEX_LABEL) + .property("name", "Alice") + .property("age", 30)); + Vertex bob = + hugeClient + .graph() + .addVertex( + new Vertex(VERTEX_LABEL) + .property("name", "Bob") + .property("age", 25)); + hugeClient + .graph() + .addEdge(new Edge("knows").source(alice).target(bob).property("weight", 1.0)); + assertEquals(2, hugeClient.graph().listVertices(VERTEX_LABEL).size()); + assertEquals(1, hugeClient.graph().listEdges("knows").size()); - // 3. Configure SchemaConfig for edge - SchemaConfig schemaConfig = new SchemaConfig(); - schemaConfig.setType(SchemaConfig.LabelType.EDGE); - schemaConfig.setLabel("transfer"); + HugeGraphSaveModeHandler handler = + createSaveModeHandler( + buildMultiMappingConfigs(), + HugeGraphDataSaveMode.DROP_DATA, + MULTI_MAPPING_ROW_TYPE); + try { + handler.open(); + handler.handleDataSaveMode(); + } finally { + handler.close(); + } - SourceTargetConfig sourceConfig = new SourceTargetConfig(); - sourceConfig.setLabel("person_pk_for_edge"); - sourceConfig.setIdFields(Collections.singletonList("src_name")); + Assertions.assertTrue( + hugeClient.graph().listEdges("knows").isEmpty(), + "Edges of the job's label dropped"); + Assertions.assertTrue( + hugeClient.graph().listVertices(VERTEX_LABEL).isEmpty(), + "Vertices of the job's label dropped"); + } - SourceTargetConfig targetConfig = new SourceTargetConfig(); - targetConfig.setLabel("software_cs_for_edge"); - targetConfig.setIdFields(Collections.singletonList("tgt_id")); - - schemaConfig.setSourceConfig(sourceConfig); - schemaConfig.setTargetConfig(targetConfig); - - MappingConfig mappingConfig = new MappingConfig(); - Map map = new HashMap<>(); - map.put("src_name", "name"); - map.put("tgt_id", "lang"); - mappingConfig.setFieldMapping(map); - schemaConfig.setMapping(mappingConfig); - - // 4. INSERT operation - HugeGraphSinkWriter writer = createSinkWriter(schemaConfig, edgeRowType); - LocalDateTime insertDate = LocalDateTime.of(2023, 1, 1, 12, 0, 0); - Object[] insertData = - new Object[] { - "person1", "software1", "transfer_v1", 100L, 123.45, true, insertDate - }; - SeaTunnelRow insertRow = new SeaTunnelRow(insertData); - insertRow.setRowKind(RowKind.INSERT); - writer.write(insertRow); - writer.close(); + @Test + public void testSaveModeRestoreDoesNotDropData() { + // On checkpoint restore the engine calls only handleSchemaSaveModeWithRestore(); even with + // DROP_DATA configured, data written before the restart must survive. + hugeClient + .graph() + .addVertex(new Vertex(VERTEX_LABEL).property("name", "marko").property("age", 29)); + assertEquals(1, hugeClient.graph().listVertices(VERTEX_LABEL).size()); + + HugeGraphSaveModeHandler handler = + createSaveModeHandler( + Collections.singletonList(personVertexMapping()), + HugeGraphDataSaveMode.DROP_DATA, + VERTEX_ROW_TYPE); + try { + handler.open(); + handler.handleSchemaSaveModeWithRestore(); + } finally { + handler.close(); + } - // 5. Verify INSERT - System.out.println(hugeClient.graph().listEdges("transfer")); - List edges = hugeClient.graph().listEdges("transfer"); - assertEquals(1, edges.size()); - Edge createdEdge = edges.get(0); - assertEquals("transfer_v1", createdEdge.property("prop_string")); - assertEquals(100L, ((Number) createdEdge.property("prop_long")).longValue()); - assertEquals(123.45, createdEdge.property("prop_double")); - assertEquals(true, createdEdge.property("prop_boolean")); - - // Verify source and target - Vertex sourceVertex = hugeClient.graph().getVertex(createdEdge.sourceId()); - Vertex targetVertex = hugeClient.graph().getVertex(createdEdge.targetId()); - assertEquals("person1", sourceVertex.property("name")); - assertEquals("software1", targetVertex.id()); - - // 6. UPDATE operation - writer = createSinkWriter(schemaConfig, edgeRowType); - LocalDateTime updateDate = LocalDateTime.of(2024, 2, 2, 1, 1, 1); - Object[] updateData = - new Object[] { - "person1", "software1", "transfer_v2", 200L, 543.21, false, updateDate - }; - SeaTunnelRow updateRow = new SeaTunnelRow(updateData); - updateRow.setRowKind(RowKind.UPDATE_AFTER); - writer.write(updateRow); - writer.close(); + assertEquals( + 1, + hugeClient.graph().listVertices(VERTEX_LABEL).size(), + "Restore must not drop data written before the restart"); + } - // 7. Verify UPDATE - System.out.println(hugeClient.graph().listEdges("transfer")); - edges = hugeClient.graph().listEdges("transfer"); - assertEquals(1, edges.size()); - Edge updatedEdge = edges.get(0); - assertEquals("transfer_v2", updatedEdge.property("prop_string")); - assertEquals(200L, ((Number) updatedEdge.property("prop_long")).longValue()); - assertEquals(543.21, updatedEdge.property("prop_double")); - assertEquals(false, updatedEdge.property("prop_boolean")); - - // 8. DELETE operation - SeaTunnelRowType edgeDeleteRowType = - new SeaTunnelRowType( - new String[] {"src_name", "tgt_id"}, - new SeaTunnelDataType[] { - org.apache.seatunnel.api.table.type.BasicType.STRING_TYPE, - org.apache.seatunnel.api.table.type.BasicType.STRING_TYPE - }); + private MappingConfig personVertexMapping() { + MappingConfig mapping = new MappingConfig(); + mapping.setType(LabelType.VERTEX); + mapping.setLabel(VERTEX_LABEL); + mapping.setIdStrategy(IdStrategy.PRIMARY_KEY); + mapping.setIdFields(Collections.singletonList("name")); + mapping.setProperties(Arrays.asList("name", "age")); + return mapping; + } - writer = createSinkWriter(schemaConfig, edgeDeleteRowType); - Object[] deleteData = new Object[] {"person1", "software1"}; - SeaTunnelRow deleteRow = new SeaTunnelRow(deleteData); - deleteRow.setRowKind(RowKind.DELETE); - writer.write(deleteRow); - writer.close(); + private HugeGraphSaveModeHandler createSaveModeHandler( + List mappings, + HugeGraphDataSaveMode dataSaveMode, + SeaTunnelRowType rowType) { + HugeGraphConnectionConfig connectionConfig = new HugeGraphConnectionConfig(); + connectionConfig.setHost(HUGE_GRAPH_CONTAINER.getHost()); + connectionConfig.setPort(HUGE_GRAPH_CONTAINER.getMappedPort(8080)); + connectionConfig.setGraphName(GRAPH_NAME); - // 9. Verify DELETE - Assertions.assertTrue(hugeClient.graph().listEdges("transfer").isEmpty()); + HugeGraphSinkConfig config = new HugeGraphSinkConfig(); + config.setConnectionConfig(connectionConfig); + config.setMaxRetries(0); + config.setRetryBackoffMs(0); + config.setMappings(mappings); + config.setDataSaveMode(dataSaveMode); + config.setSchemaSaveMode(HugeGraphSchemaSaveMode.CREATE_SCHEMA_WHEN_NOT_EXIST); + config.setAllowCascadeDeleteUnmappedEdges(true); + return new HugeGraphSaveModeHandler(config, rowType, TablePath.of(GRAPH_NAME)); } } diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/java/org/apache/seatunnel/e2e/connector/hugegraph/HugeGraphSourceIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/java/org/apache/seatunnel/e2e/connector/hugegraph/HugeGraphSourceIT.java new file mode 100644 index 000000000000..f960e170ab5d --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/java/org/apache/seatunnel/e2e/connector/hugegraph/HugeGraphSourceIT.java @@ -0,0 +1,371 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 org.apache.seatunnel.e2e.connector.hugegraph; + +import org.apache.seatunnel.e2e.common.TestResource; +import org.apache.seatunnel.e2e.common.TestSuiteBase; +import org.apache.seatunnel.e2e.common.container.TestContainer; + +import org.apache.hugegraph.driver.HugeClient; +import org.apache.hugegraph.structure.constant.DataType; +import org.apache.hugegraph.structure.constant.IdStrategy; +import org.apache.hugegraph.structure.graph.Edge; +import org.apache.hugegraph.structure.graph.Vertex; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestTemplate; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.lifecycle.Startables; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +public class HugeGraphSourceIT extends TestSuiteBase implements TestResource { + + // Pinned to 1.7.0 to match the graph-space-aware client (REST paths are + // /graphspaces/{graphspace}/graphs/{graph}/...); a <1.7.0 server would 404 those paths. + private static final String HUGE_GRAPH_IMAGE = "hugegraph/hugegraph:1.7.0"; + private static final String HUGE_GRAPH_HOST = "hugegraph-host"; + private static final int HUGE_GRAPH_PORT = 8080; + private static final String GRAPH_NAME = "hugegraph"; + private static final String VERTEX_LABEL = "person"; + private static final String EDGE_LABEL = "knows"; + private static final String GADGET_LABEL = "gadget"; + + private GenericContainer hugeGraphContainer; + private HugeClient hugeClient; + + @BeforeAll + @Override + public void startUp() { + hugeGraphContainer = + new GenericContainer<>(DockerImageName.parse(HUGE_GRAPH_IMAGE)) + .withNetwork(NETWORK) + .withNetworkAliases(HUGE_GRAPH_HOST) + .withExposedPorts(HUGE_GRAPH_PORT) + .waitingFor( + Wait.forHttp("/graphs").forPort(HUGE_GRAPH_PORT).forStatusCode(200)) + .withStartupTimeout(Duration.ofMinutes(3)); + Startables.deepStart(Stream.of(hugeGraphContainer)).join(); + + String url = + String.format( + "http://%s:%d", + hugeGraphContainer.getHost(), + hugeGraphContainer.getMappedPort(HUGE_GRAPH_PORT)); + hugeClient = HugeClient.builder(url, GRAPH_NAME).build(); + setupSchema(); + } + + @TestTemplate + public void testVertexSourceToAssert(TestContainer container) + throws IOException, InterruptedException { + clearGraph(); + for (int i = 0; i < 150; i++) { + addVertex("person-" + i, 29); + } + awaitTotalVertexCount(150); + + Container.ExecResult execResult = + container.executeJob("/hugegraph/hugegraph_vertex_to_assert.conf"); + + Assertions.assertEquals(0, execResult.getExitCode(), buildFailureMessage(execResult)); + } + + @TestTemplate + public void testVertexCheckpointedMultiPageScan(TestContainer container) + throws IOException, InterruptedException { + clearGraph(); + // 250 vertices with page_size=100 => 3 pages, so the opaque page token is snapshotted at + // checkpoints mid-scan (on Zeta). Assert verifies the full scan is read exactly once. + for (int i = 0; i < 250; i++) { + addVertex("ckpt-person-" + i, 29); + } + awaitTotalVertexCount(250); + + Container.ExecResult execResult = + container.executeJob("/hugegraph/hugegraph_vertex_checkpoint_to_assert.conf"); + + Assertions.assertEquals(0, execResult.getExitCode(), buildFailureMessage(execResult)); + } + + @TestTemplate + public void testVertexSinkJob(TestContainer container) + throws IOException, InterruptedException { + clearGraph(); + + Container.ExecResult execResult = + container.executeJob("/hugegraph/fake_to_hugegraph_vertex.conf"); + + Assertions.assertEquals(0, execResult.getExitCode(), buildFailureMessage(execResult)); + awaitTotalVertexCount(100); + List vertices = + hugeClient + .graph() + .listVertices(VERTEX_LABEL, java.util.Collections.emptyMap(), 101); + Assertions.assertTrue( + vertices.stream() + .allMatch( + vertex -> + VERTEX_LABEL.equals(vertex.label()) + && vertex.property("name") != null + && vertex.property("age") != null)); + } + + @TestTemplate + public void testEdgeSinkJob(TestContainer container) throws IOException, InterruptedException { + clearGraph(); + + Container.ExecResult execResult = + container.executeJob("/hugegraph/fake_to_hugegraph_edge.conf"); + + Assertions.assertEquals(0, execResult.getExitCode(), buildFailureMessage(execResult)); + awaitEdgeCount(50); + Assertions.assertTrue( + hugeClient.graph().listEdges(EDGE_LABEL).stream() + .allMatch( + edge -> + EDGE_LABEL.equals(edge.label()) + && edge.property("weight") != null)); + } + + @TestTemplate + public void testMultiMappingFanOutJob(TestContainer container) + throws IOException, InterruptedException { + clearGraph(); + + Container.ExecResult execResult = + container.executeJob("/hugegraph/fake_to_hugegraph_multi_mapping.conf"); + + Assertions.assertEquals(0, execResult.getExitCode(), buildFailureMessage(execResult)); + awaitLabelVertexCount("person", 25); + awaitEdgeCount("knows", 25); + } + + @TestTemplate + public void testSizeTriggeredFlushWithEdgeFirstMappingOrder(TestContainer container) + throws IOException, InterruptedException { + clearGraph(); + + Container.ExecResult execResult = + container.executeJob("/hugegraph/fake_to_hugegraph_multi_mapping_edge_first.conf"); + + Assertions.assertEquals(0, execResult.getExitCode(), buildFailureMessage(execResult)); + awaitLabelVertexCount("person", 25); + awaitEdgeCount("knows", 25); + } + + @TestTemplate + public void testEdgeSourceToAssert(TestContainer container) + throws IOException, InterruptedException { + clearGraph(); + Vertex marko = addVertex("marko", 29); + Vertex vadas = addVertex("vadas", 27); + Edge edge = new Edge(EDGE_LABEL).source(marko).target(vadas).property("weight", 1.5D); + hugeClient.graph().addEdge(edge); + awaitVertexCount("marko", 1); + awaitVertexCount("vadas", 1); + awaitEdgeCount(1); + + Container.ExecResult execResult = + container.executeJob("/hugegraph/hugegraph_edge_to_assert.conf"); + + Assertions.assertEquals(0, execResult.getExitCode(), buildFailureMessage(execResult)); + } + + @TestTemplate + public void testByteAndObjectColumnsAreReadable(TestContainer container) + throws IOException, InterruptedException { + clearGraph(); + // A BYTE (code) and an OBJECT (meta) property: before the fix these fell through the type + // converter's default branch and failed schema validation, so the whole label read errored. + hugeClient + .graph() + .addVertex( + new Vertex(GADGET_LABEL) + .property("name", "g1") + .property("code", (byte) 7) + .property("meta", "info-1")); + awaitLabelVertexCount(GADGET_LABEL, 1); + + Container.ExecResult execResult = + container.executeJob("/hugegraph/hugegraph_byte_object_to_assert.conf"); + + Assertions.assertEquals(0, execResult.getExitCode(), buildFailureMessage(execResult)); + } + + private Vertex addVertex(String name, int age) { + return hugeClient + .graph() + .addVertex(new Vertex(VERTEX_LABEL).property("name", name).property("age", age)); + } + + private void clearGraph() { + clearGraphWithoutSchema(); + setupSchema(); + awaitSchemaReady(); + } + + private void clearGraphWithoutSchema() { + hugeClient.graphs().clearGraph(GRAPH_NAME, "I'm sure to delete all data"); + } + + private void setupSchema() { + hugeClient.schema().propertyKey("name").asText().ifNotExist().create(); + hugeClient.schema().propertyKey("age").asInt().ifNotExist().create(); + hugeClient.schema().propertyKey("weight").asDouble().ifNotExist().create(); + hugeClient + .schema() + .vertexLabel(VERTEX_LABEL) + .idStrategy(IdStrategy.PRIMARY_KEY) + .primaryKeys("name") + .properties("name", "age") + .nullableKeys("age") + .ifNotExist() + .create(); + hugeClient + .schema() + .edgeLabel(EDGE_LABEL) + .sourceLabel(VERTEX_LABEL) + .targetLabel(VERTEX_LABEL) + .properties("weight") + .nullableKeys("weight") + .ifNotExist() + .create(); + + // BYTE + OBJECT property columns exercise the two type-converter branches that previously + // fell through to the default and blocked the whole label read. + hugeClient.schema().propertyKey("code").asByte().ifNotExist().create(); + hugeClient.schema().propertyKey("meta").dataType(DataType.OBJECT).ifNotExist().create(); + hugeClient + .schema() + .vertexLabel(GADGET_LABEL) + .idStrategy(IdStrategy.PRIMARY_KEY) + .primaryKeys("name") + .properties("name", "code", "meta") + .nullableKeys("code", "meta") + .ifNotExist() + .create(); + } + + private void awaitSchemaReady() { + awaitCondition( + () -> + hugeClient.schema().getVertexLabel(VERTEX_LABEL) != null + && hugeClient.schema().getVertexLabel(GADGET_LABEL) != null + && hugeClient.schema().getEdgeLabel(EDGE_LABEL) != null + && hugeClient.schema().getPropertyKey("name") != null + && hugeClient.schema().getPropertyKey("age") != null + && hugeClient.schema().getPropertyKey("weight") != null + && hugeClient.schema().getPropertyKey("code") != null + && hugeClient.schema().getPropertyKey("meta") != null, + "HugeGraph schema is not ready"); + } + + private void awaitVertexCount(String name, int expectedCount) { + awaitCondition( + () -> { + Map properties = new HashMap<>(); + properties.put("name", name); + List vertices = + hugeClient.graph().listVertices(VERTEX_LABEL, properties, 10); + return vertices.size() == expectedCount; + }, + String.format("Vertex data for name=%s is not ready", name)); + } + + private void awaitTotalVertexCount(int expectedCount) { + awaitLabelVertexCount(VERTEX_LABEL, expectedCount); + } + + private void awaitLabelVertexCount(String label, int expectedCount) { + awaitCondition( + () -> + hugeClient + .graph() + .listVertices( + label, + java.util.Collections.emptyMap(), + expectedCount + 1) + .size() + == expectedCount, + String.format("Expected %s vertices for label %s", expectedCount, label)); + } + + private void awaitEdgeCount(int expectedCount) { + awaitEdgeCount(EDGE_LABEL, expectedCount); + } + + private void awaitEdgeCount(String label, int expectedCount) { + awaitCondition( + () -> hugeClient.graph().listEdges(label).size() == expectedCount, + String.format("Expected %s edges for label %s", expectedCount, label)); + } + + private void awaitCondition(Check check, String timeoutMessage) { + long deadline = System.currentTimeMillis() + Duration.ofSeconds(30).toMillis(); + while (System.currentTimeMillis() < deadline) { + try { + if (check.ok()) { + return; + } + } catch (Exception ignored) { + // HugeGraph metadata/data can be eventually visible right after clear/create. + } + try { + Thread.sleep(1000); + } catch (InterruptedException interruptedException) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Interrupted while waiting HugeGraph state", interruptedException); + } + } + throw new IllegalStateException(timeoutMessage); + } + + @FunctionalInterface + private interface Check { + boolean ok() throws Exception; + } + + private String buildFailureMessage(Container.ExecResult execResult) { + return String.format( + "Seatunnel job failed with exitCode=%s, stdout=%s, stderr=%s", + execResult.getExitCode(), execResult.getStdout(), execResult.getStderr()); + } + + @AfterAll + @Override + public void tearDown() { + if (hugeClient != null) { + hugeClient.close(); + } + if (hugeGraphContainer != null) { + hugeGraphContainer.close(); + } + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/fake_to_hugegraph_edge.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/fake_to_hugegraph_edge.conf new file mode 100644 index 000000000000..6f6432398da8 --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/fake_to_hugegraph_edge.conf @@ -0,0 +1,63 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +env { + parallelism = 1 + job.mode = "BATCH" +} + +source { + FakeSource { + plugin_output = "fake" + row.num = 50 + schema = { + fields { + src_name = "string" + tgt_name = "string" + weight = "double" + } + } + } +} + +sink { + HugeGraph { + host = "hugegraph-host" + port = 8080 + graph_name = "hugegraph" + schema_save_mode = "ERROR_WHEN_SCHEMA_NOT_EXIST" + mappings = [ + { + type = "EDGE" + label = "knows" + sourceConfig = { + label = "person" + idFields = ["src_name"] + } + targetConfig = { + label = "person" + idFields = ["tgt_name"] + } + properties = ["weight"] + fieldMapping = { + src_name = "name" + tgt_name = "name" + } + } + ] + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/fake_to_hugegraph_multi_mapping.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/fake_to_hugegraph_multi_mapping.conf new file mode 100644 index 000000000000..aee1b66078b5 --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/fake_to_hugegraph_multi_mapping.conf @@ -0,0 +1,76 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +env { + parallelism = 1 + job.mode = "BATCH" +} + +source { + FakeSource { + plugin_output = "fake" + row.num = 25 + schema = { + fields { + v_name = "string" + v_age = "int" + src = "string" + tgt = "string" + weight = "double" + } + } + } +} + +sink { + HugeGraph { + host = "hugegraph-host" + port = 8080 + graph_name = "hugegraph" + schema_save_mode = "ERROR_WHEN_SCHEMA_NOT_EXIST" + mappings = [ + { + type = "VERTEX" + label = "person" + idStrategy = "PRIMARY_KEY" + idFields = ["v_name"] + properties = ["v_name", "v_age"] + fieldMapping = { + v_name = "name" + v_age = "age" + } + } + { + type = "EDGE" + label = "knows" + sourceConfig = { + label = "person" + idFields = ["src"] + } + targetConfig = { + label = "person" + idFields = ["tgt"] + } + properties = ["weight"] + fieldMapping = { + src = "name" + tgt = "name" + } + } + ] + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/fake_to_hugegraph_multi_mapping_edge_first.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/fake_to_hugegraph_multi_mapping_edge_first.conf new file mode 100644 index 000000000000..5932da9b3eda --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/fake_to_hugegraph_multi_mapping_edge_first.conf @@ -0,0 +1,77 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +env { + parallelism = 1 + job.mode = "BATCH" +} + +source { + FakeSource { + plugin_output = "fake" + row.num = 25 + schema = { + fields { + v_name = "string" + v_age = "int" + src = "string" + tgt = "string" + weight = "double" + } + } + } +} + +sink { + HugeGraph { + host = "hugegraph-host" + port = 8080 + graph_name = "hugegraph" + schema_save_mode = "ERROR_WHEN_SCHEMA_NOT_EXIST" + batch_size = 2 + mappings = [ + { + type = "EDGE" + label = "knows" + sourceConfig = { + label = "person" + idFields = ["src"] + } + targetConfig = { + label = "person" + idFields = ["tgt"] + } + properties = ["weight"] + fieldMapping = { + src = "name" + tgt = "name" + } + } + { + type = "VERTEX" + label = "person" + idStrategy = "PRIMARY_KEY" + idFields = ["v_name"] + properties = ["v_name", "v_age"] + fieldMapping = { + v_name = "name" + v_age = "age" + } + } + ] + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/fake_to_hugegraph_vertex.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/fake_to_hugegraph_vertex.conf new file mode 100644 index 000000000000..2aaff61bcaaf --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/fake_to_hugegraph_vertex.conf @@ -0,0 +1,52 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +env { + parallelism = 1 + job.mode = "BATCH" +} + +source { + FakeSource { + plugin_output = "fake" + row.num = 100 + schema = { + fields { + name = "string" + age = "int" + } + } + } +} + +sink { + HugeGraph { + host = "hugegraph-host" + port = 8080 + graph_name = "hugegraph" + schema_save_mode = "ERROR_WHEN_SCHEMA_NOT_EXIST" + mappings = [ + { + type = "VERTEX" + label = "person" + idStrategy = "PRIMARY_KEY" + idFields = ["name"] + properties = ["name", "age"] + } + ] + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/hugegraph_byte_object_to_assert.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/hugegraph_byte_object_to_assert.conf new file mode 100644 index 000000000000..33d144e631b1 --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/hugegraph_byte_object_to_assert.conf @@ -0,0 +1,108 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +# Verifies a BYTE column (code) and an OBJECT column (meta) can be read: before the fix both hit the +# type-converter default branch and failed schema validation, blocking the whole label read. + +env { + parallelism = 1 + job.mode = "BATCH" +} + +source { + HugeGraph { + host = "hugegraph-host" + port = 8080 + graph_name = "hugegraph" + label = "gadget" + label_type = "VERTEX" + page_size = 100 + plugin_output = "hugegraph_gadget" + schema = { + fields { + name = "string" + code = "tinyint" + meta = "string" + } + } + } +} + +sink { + Assert { + plugin_input = "hugegraph_gadget" + rules = { + field_rules = [ + { + field_name = "~id" + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + }, + { + field_name = "~label" + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + }, + { + field_name = name + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + }, + { + field_name = code + field_type = tinyint + field_value = [ + { + equals_to = 7 + } + ] + }, + { + field_name = meta + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + } + ] + row_rules = [ + { + rule_type = MIN_ROW + rule_value = 1 + }, + { + rule_type = MAX_ROW + rule_value = 1 + } + ] + } + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/hugegraph_edge_to_assert.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/hugegraph_edge_to_assert.conf new file mode 100644 index 000000000000..ce0f4e332bde --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/hugegraph_edge_to_assert.conf @@ -0,0 +1,126 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +env { + parallelism = 1 + job.mode = "BATCH" +} + +source { + HugeGraph { + host = "hugegraph-host" + port = 8080 + graph_name = "hugegraph" + label = "knows" + label_type = "EDGE" + page_size = 100 + plugin_output = "hugegraph_edge" + schema = { + fields { + weight = "double" + } + } + } +} + +sink { + Assert { + plugin_input = "hugegraph_edge" + rules = { + field_rules = [ + { + field_name = "~id" + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + }, + { + field_name = "~label" + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + }, + { + field_name = "~source_id" + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + }, + { + field_name = "~source_label" + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + }, + { + field_name = "~target_id" + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + }, + { + field_name = "~target_label" + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + }, + { + field_name = weight + field_type = DOUBLE + field_value = [ + { + rule_type = MIN + rule_value = 1.5 + }, + { + rule_type = MAX + rule_value = 1.5 + } + ] + } + ] + row_rules = [ + { + rule_type = MIN_ROW + rule_value = 1 + }, + { + rule_type = MAX_ROW + rule_value = 1 + } + ] + } + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/hugegraph_vertex_checkpoint_to_assert.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/hugegraph_vertex_checkpoint_to_assert.conf new file mode 100644 index 000000000000..d1918c7c1416 --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/hugegraph_vertex_checkpoint_to_assert.conf @@ -0,0 +1,82 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +# Engine-level checkpoint coverage for the bounded source: checkpoint.interval is enabled and a +# small-ish page_size makes the scan span multiple pages, so on Zeta the source's opaque page +# token is snapshotted (snapshotStateToBytes) at checkpoints during the scan. The Assert sink then +# proves the full scan is read exactly once (row count == loaded count, ids/keys non-null). +env { + parallelism = 1 + job.mode = "BATCH" + checkpoint.interval = 1000 +} + +source { + HugeGraph { + host = "hugegraph-host" + port = 8080 + graph_name = "hugegraph" + label = "person" + label_type = "VERTEX" + page_size = 100 + plugin_output = "hugegraph_vertex_checkpoint" + schema = { + fields { + name = "string" + age = "int" + } + } + } +} + +sink { + Assert { + plugin_input = "hugegraph_vertex_checkpoint" + rules = { + field_rules = [ + { + field_name = "~id" + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + }, + { + field_name = name + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + } + ] + row_rules = [ + { + rule_type = MIN_ROW + rule_value = 250 + }, + { + rule_type = MAX_ROW + rule_value = 250 + } + ] + } + } +} diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/hugegraph_vertex_to_assert.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/hugegraph_vertex_to_assert.conf new file mode 100644 index 000000000000..59d7473eff69 --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-hugegraph-e2e/src/test/resources/hugegraph/hugegraph_vertex_to_assert.conf @@ -0,0 +1,100 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +env { + parallelism = 1 + job.mode = "BATCH" +} + +source { + HugeGraph { + host = "hugegraph-host" + port = 8080 + graph_name = "hugegraph" + label = "person" + label_type = "VERTEX" + page_size = 100 + plugin_output = "hugegraph_vertex" + schema = { + fields { + name = "string" + age = "int" + } + } + } +} + +sink { + Assert { + plugin_input = "hugegraph_vertex" + rules = { + field_rules = [ + { + field_name = "~id" + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + }, + { + field_name = "~label" + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + }, + { + field_name = name + field_type = STRING + field_value = [ + { + rule_type = NOT_NULL + } + ] + }, + { + field_name = age + field_type = INT + field_value = [ + { + rule_type = MIN + rule_value = 29 + }, + { + rule_type = MAX + rule_value = 29 + } + ] + } + ] + row_rules = [ + { + rule_type = MIN_ROW + rule_value = 150 + }, + { + rule_type = MAX_ROW + rule_value = 150 + } + ] + } + } +}