Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions docs/s2dm.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ type Vehicle_Cabin @vspec(element: BRANCH, fqn: "Vehicle.Cabin") {
In this example, the metadata shows that the `Vehicle_Cabin` type was derived from the Fully-Qualified Name (FQN) `Vehicle.Cabin`, and that is was a `BRANCH`.
Likewise, the `driverPosition` was derived from `Vehicle.Cabin.DriverPosition` and it was an `ATTRIBUTE`.

The `@vspec` directive is also used to annotate original names when they have been modified for GraphQL compliance (for example, see [Enum Value Sanitization](#enum-value-sanitization)).

### VSS Data Types Support
The exporter handles all `vspec` data types as follows:
- **Strings** → GraphQL String
Expand All @@ -52,6 +54,34 @@ The exporter handles all `vspec` data types as follows:
- **Arrays** → GraphQL Lists
- **Allowed values** → GraphQL Enums

#### Enum Value Sanitization

GraphQL enum values must follow strict naming rules (alphanumeric + underscore only, cannot start with a digit). The S2DM exporter automatically sanitizes VSS enum values to comply with GraphQL requirements:

- **Spaces & special characters** → Converted to underscores (`"some value"` → `SOME_VALUE`)
- **CamelCase** → Converted to SCREAMING_SNAKE_CASE (`"HTTPSProtocol"` → `HTTPS_PROTOCOL`)
- **Leading digits** → Prefixed with underscore (`"123abc"` → `_123ABC`)

When enum values are modified, the original VSS value is preserved using `@vspec` metadata for complete traceability. This applies to both **allowed value enums** and **instance dimension enums**.

**Allowed Value Enum Example:**
```graphql
enum Vehicle_Connection_Protocol_Enum @vspec(element: SENSOR, fqn: "Vehicle.Connection.Protocol", metadata: [{key: "allowed", value: "['HTTPSProtocol', 'TCPProtocol']"}]) {
HTTPS_PROTOCOL @vspec(metadata: [{key: "originalName", value: "HTTPSProtocol"}])
TCP_PROTOCOL @vspec(metadata: [{key: "originalName", value: "TCPProtocol"}])
}
```

**Instance Dimension Enum Example:**
```graphql
enum Vehicle_Cabin_Seat_InstanceTag_Dimension2 {
DRIVER_SIDE @vspec(metadata: [{key: "originalName", value: "DriverSide"}])
PASSENGER_SIDE @vspec(metadata: [{key: "originalName", value: "PassengerSide"}])
}
```

This ensures complete traceability between the VSS source and the generated GraphQL schema.

### VSS Instances Become GraphQL Structures
When your `vspec` has instances (like multiple seats), the exporter creates proper GraphQL types:

Expand All @@ -63,13 +93,13 @@ type Vehicle_Cabin_Seat_InstanceTag @instanceTag {
}

enum Vehicle_Cabin_Seat_InstanceTag_Dimension1 {
Row1
Row2
ROW1 @vspec(metadata: [{key: "originalName", value: "Row1"}])
ROW2 @vspec(metadata: [{key: "originalName", value: "Row2"}])
}

enum Vehicle_Cabin_Seat_InstanceTag_Dimension2 {
DriverSide
PassengerSide
DRIVER_SIDE @vspec(metadata: [{key: "originalName", value: "DriverSide"}])
PASSENGER_SIDE @vspec(metadata: [{key: "originalName", value: "PassengerSide"}])
}
```
Such an structure is then usable by any other type like:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Annotation directive for Vspec mapping information."""
directive @vspec(
"""The element of the Vspec language to which the item in the GraphQL schema maps."""
element: VspecElement!,
element: VspecElement,
"""The Fully Qualified Name (FQN) of the related element in Vspec (aka., path). Example: Vehicle.Cabin.Door.Window.position"""
fqn: String
"""Additional metadata associated with the mapping."""
Expand Down
93 changes: 85 additions & 8 deletions src/vss_tools/exporters/s2dm/type_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@

from __future__ import annotations

import re
from typing import Any

import caseconverter
import pandas as pd
from graphql import (
GraphQLArgument,
Expand Down Expand Up @@ -108,6 +110,8 @@ def create_instance_types(
Mapping of type names to GraphQL enum or object types
"""
types: dict[str, GraphQLEnumType | GraphQLObjectType] = {}
vspec_comments.setdefault("instance_dimension_enums", {})

for fqn, row in branches_df[branches_df["instances"].notna()].iterrows():
if instances := row.get("instances"):
base_name = convert_name_for_graphql_schema(fqn, GraphQLElementType.TYPE, S2DM_CONVERSIONS)
Expand All @@ -117,11 +121,28 @@ def create_instance_types(
fields = {}
for i, values in enumerate(dimensions, 1):
enum_name = f"{tag_name}_Dimension{i}"

# Sanitize enum values and track modifications
enum_values = {}
modified_values = {}

for v in values:
sanitized, was_modified = _sanitize_enum_value_for_graphql(str(v))
enum_values[sanitized] = GraphQLEnumValue(v)

if was_modified:
modified_values[sanitized] = str(v)

types[enum_name] = GraphQLEnumType(
enum_name,
{v: GraphQLEnumValue(v) for v in values},
enum_values,
description=f"Dimensional enum for VSS instance dimension {i}.",
)

# Store metadata for directive processor
if modified_values:
vspec_comments["instance_dimension_enums"][enum_name] = {"modified_values": modified_values}

fields[f"dimension{i}"] = GraphQLField(types[enum_name])

types[tag_name] = GraphQLObjectType(tag_name, fields)
Expand Down Expand Up @@ -171,29 +192,85 @@ def create_allowed_enums(
for fqn, row in leaves_df[leaves_df["allowed"].notna()].iterrows():
if allowed := row.get("allowed"):
enum_name = f"{convert_name_for_graphql_schema(fqn, GraphQLElementType.TYPE, S2DM_CONVERSIONS)}_Enum"
values = {_clean_enum_name(str(v)): GraphQLEnumValue(v) for v in allowed}

# Track values and their modifications
values = {}
modified_values = {}

for v in allowed:
sanitized, was_modified = _sanitize_enum_value_for_graphql(str(v))
values[sanitized] = GraphQLEnumValue(v)

# Track if value was modified for metadata annotation
if was_modified:
modified_values[sanitized] = str(v)

enums[enum_name] = GraphQLEnumType(enum_name, values, description=f"Allowed values for {fqn}.")

vss_type = row.get("type", "").upper()
if vss_type not in VSS_LEAF_TYPES:
vss_type = "ATTRIBUTE"

allowed_values_graphql = {_clean_enum_name(str(v)): str(v).replace('"', "'") for v in allowed}
allowed_values_graphql = {
_sanitize_enum_value_for_graphql(str(v))[0]: str(v).replace('"', "'") for v in allowed
}

metadata[enum_name] = {
"fqn": fqn,
"vss_type": vss_type,
"allowed_values": allowed_values_graphql,
"modified_values": modified_values, # Store modified values for directive annotations
}

return enums, metadata


def _clean_enum_name(value: str) -> str:
"""Sanitize enum value names for GraphQL."""
if value[0].isdigit():
value = f"_{value}"
return value.replace(".", "_DOT_").replace("-", "_DASH_")
def _sanitize_enum_value_for_graphql(original_value: str) -> tuple[str, bool]:
"""
Sanitize enum value for GraphQL schema compliance.

Converts values with spaces, camelCase, or other invalid characters to valid GraphQL enum values.
Uses caseconverter to properly handle camelCase word boundaries.

Examples:
"some value" -> "SOME_VALUE"
"SOME VALUE" -> "SOME_VALUE"
"PbCa" -> "PB_CA"
"HTTPSConnection" -> "HTTPS_CONNECTION"
"value-with-dash" -> "VALUE_WITH_DASH"

Args:
original_value: The original enum value from VSS

Returns:
Tuple of (sanitized_value, was_modified)
- sanitized_value: Valid GraphQL enum value name
- was_modified: True if the value was changed, False otherwise
"""

# Handle empty or whitespace-only strings
if not original_value or not original_value.strip():
raise ValueError(f"Cannot create GraphQL enum value from empty or whitespace-only string: {original_value!r}")

# Replace with underscore all the special characters that are not allowed in GraphQL enum names
sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", original_value)

# Convert to caseconverter MACRO_CASE (i.e., SCREAMING_SNAKE_CASE)
if re.search(r"[A-Z]{2,}", sanitized) and re.search(r"[a-z]", sanitized):
words: list[str] = []
for segment in sanitized.split("_"):
words.extend(re.findall(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+", segment))
sanitized = "_".join(caseconverter.macrocase(word, strip_punctuation=False) for word in words if word)
else:
sanitized = caseconverter.macrocase(sanitized, strip_punctuation=False)

# Handle enum values starting with digits
sanitized = f"_{sanitized}" if sanitized[0].isdigit() else sanitized

# Check if modification occurred
was_modified = sanitized != original_value

return sanitized, was_modified


def create_struct_types(
Expand Down
93 changes: 83 additions & 10 deletions src/vss_tools/utils/graphql_directive_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ def process_schema(

lines = self._process_unit_enum_directives(lines, unit_enums_metadata, processed_enum_values)
lines = self._process_allowed_enum_directives(lines, allowed_enums_metadata, processed_enum_values)
lines = self._process_instance_dimension_enum_directives(
lines, vspec_comments.get("instance_dimension_enums", {}), processed_enum_values
)
lines = self._process_field_directives(lines, vspec_comments)
lines = self._process_deprecated_directives(lines, vspec_comments.get("field_deprecated", {}))
lines = self._process_range_directives(lines, vspec_comments.get("field_ranges", {}))
Expand Down Expand Up @@ -117,28 +120,98 @@ def _process_allowed_enum_directives(
Process allowed value enum directives.

Annotates the enum type itself with @vspec(element, fqn, metadata),
but does NOT annotate individual enum values.
and annotates individual enum values that were modified with @vspec(metadata).
"""
for enum_name, enum_data in allowed_enums_metadata.items():
fqn = enum_data.get("fqn", "")
vss_type = enum_data.get("vss_type", "ATTRIBUTE")
allowed_values_dict = enum_data.get("allowed_values", {})
modified_values = enum_data.get("modified_values", {})

# Build the allowed values list for metadata
# GraphQL requires: value: "['val1', 'val2']" (double quotes outside, single quotes inside)
allowed_values_list = list(allowed_values_dict.values())
allowed_str = ", ".join([f"'{v}'" for v in allowed_values_list])

in_target_enum = False
for i, line in enumerate(lines):
if line.strip().startswith(f"enum {enum_name}") and "@vspec" not in line:
# Annotate the enum type (not individual values)
# Format: @vspec(element: ATTRIBUTE, fqn: "...", metadata: [{key: "allowed", value: "[...]"}])
directive = (
f'@vspec(element: {vss_type}, fqn: "{fqn}", '
f'metadata: [{{key: "allowed", value: "[{allowed_str}]"}}])'
)
lines[i] = line.replace(" {", f" {directive} {{")
break
if line.strip().startswith(f"enum {enum_name}"):
if "@vspec" not in line:
# Annotate the enum type
directive = (
f'@vspec(element: {vss_type}, fqn: "{fqn}", '
f'metadata: [{{key: "allowed", value: "[{allowed_str}]"}}])'
)
lines[i] = line.replace(" {", f" {directive} {{")
in_target_enum = True
continue
elif line.strip().startswith("enum ") and in_target_enum:
in_target_enum = False
continue
elif line.strip() == "}" and in_target_enum:
in_target_enum = False
continue

# Process individual enum values that were modified
if in_target_enum and line.strip() and not line.strip().startswith('"'):
stripped_line = line.strip()

for enum_value_name, original_value in modified_values.items():
enum_value_key = f"{enum_name}.{enum_value_name}"
if stripped_line.startswith(enum_value_name) and enum_value_key not in processed_values:
if "@vspec" not in line:
indent = line[: len(line) - len(line.lstrip())]
# Annotate modified enum value with original value in metadata
directive = f'@vspec(metadata: [{{key: "originalName", value: "{original_value}"}}])'
lines[i] = f"{indent}{enum_value_name} {directive}"

processed_values.add(enum_value_key)
break

return lines

def _process_instance_dimension_enum_directives(
self, lines: list[str], instance_dimension_enums: dict, processed_values: set
) -> list[str]:
"""
Process instance dimension enum directives.

Annotates enum values that were modified during sanitization with their original names.
"""
for enum_name, enum_data in instance_dimension_enums.items():
modified_values = enum_data.get("modified_values", {})
if not modified_values:
continue

in_target_enum = False

for i, line in enumerate(lines):
# Detect enum start
if line.strip().startswith(f"enum {enum_name}"):
in_target_enum = True
continue
elif line.strip().startswith("enum ") and in_target_enum:
in_target_enum = False
continue
elif line.strip() == "}" and in_target_enum:
in_target_enum = False
continue

# Process enum values within target enum
if in_target_enum and line.strip() and not line.strip().startswith('"'):
stripped_line = line.strip()

for enum_value_name, original_value in modified_values.items():
enum_value_key = f"{enum_name}.{enum_value_name}"

if stripped_line.startswith(enum_value_name) and enum_value_key not in processed_values:
if "@vspec" not in line:
indent = line[: len(line) - len(line.lstrip())]
directive = f'@vspec(metadata: [{{key: "originalName", value: "{original_value}"}}])'
lines[i] = f"{indent}{enum_value_name} {directive}"

processed_values.add(enum_value_key)
break

return lines

Expand Down
Loading