Skip to content

Commit 5af0ba5

Browse files
committed
feat: Enhance enum value handling: sanitize values for GraphQL compliance and annotate modified values with metadata
Signed-off-by: JD Alvarez <8550265+jdacoello@users.noreply.github.com>
1 parent 8dbb733 commit 5af0ba5

File tree

7 files changed

+365
-20
lines changed

7 files changed

+365
-20
lines changed

docs/s2dm.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ type Vehicle_Cabin @vspec(element: BRANCH, fqn: "Vehicle.Cabin") {
4444
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`.
4545
Likewise, the `driverPosition` was derived from `Vehicle.Cabin.DriverPosition` and it was an `ATTRIBUTE`.
4646

47+
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)).
48+
4749
### VSS Data Types Support
4850
The exporter handles all `vspec` data types as follows:
4951
- **Strings** → GraphQL String
@@ -52,6 +54,25 @@ The exporter handles all `vspec` data types as follows:
5254
- **Arrays** → GraphQL Lists
5355
- **Allowed values** → GraphQL Enums
5456

57+
#### Enum Value Sanitization
58+
59+
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:
60+
61+
- **Spaces & special characters** → Converted to underscores (`"some value"` → `SOME_VALUE`)
62+
- **CamelCase** → Converted to SCREAMING_SNAKE_CASE (`"HTTPSProtocol"` → `HTTPS_PROTOCOL`)
63+
- **Leading digits** → Prefixed with underscore (`"123abc"` → `_123ABC`)
64+
65+
When enum values are modified, the original VSS value is preserved using `@vspec` metadata:
66+
67+
```graphql
68+
enum Vehicle_Connection_Protocol_Enum @vspec(element: SENSOR, fqn: "Vehicle.Connection.Protocol", metadata: [{key: "allowed", value: "['HTTPSProtocol', 'TCPProtocol']"}]) {
69+
HTTPS_PROTOCOL @vspec(metadata: [{key: "originalName", value: "HTTPSProtocol"}])
70+
TCP_PROTOCOL @vspec(metadata: [{key: "originalName", value: "TCPProtocol"}])
71+
}
72+
```
73+
74+
This ensures complete traceability between the VSS source and the generated GraphQL schema.
75+
5576
### VSS Instances Become GraphQL Structures
5677
When your `vspec` has instances (like multiple seats), the exporter creates proper GraphQL types:
5778

src/vss_tools/exporters/s2dm/predefined_elements/directives.graphql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Annotation directive for Vspec mapping information."""
22
directive @vspec(
33
"""The element of the Vspec language to which the item in the GraphQL schema maps."""
4-
element: VspecElement!,
4+
element: VspecElement,
55
"""The Fully Qualified Name (FQN) of the related element in Vspec (aka., path). Example: Vehicle.Cabin.Door.Window.position"""
66
fqn: String
77
"""Additional metadata associated with the mapping."""

src/vss_tools/exporters/s2dm/type_builders.py

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818

1919
from __future__ import annotations
2020

21+
import re
2122
from typing import Any
2223

24+
import caseconverter
2325
import pandas as pd
2426
from graphql import (
2527
GraphQLArgument,
@@ -171,29 +173,85 @@ def create_allowed_enums(
171173
for fqn, row in leaves_df[leaves_df["allowed"].notna()].iterrows():
172174
if allowed := row.get("allowed"):
173175
enum_name = f"{convert_name_for_graphql_schema(fqn, GraphQLElementType.TYPE, S2DM_CONVERSIONS)}_Enum"
174-
values = {_clean_enum_name(str(v)): GraphQLEnumValue(v) for v in allowed}
176+
177+
# Track values and their modifications
178+
values = {}
179+
modified_values = {}
180+
181+
for v in allowed:
182+
sanitized, was_modified = _sanitize_enum_value_for_graphql(str(v))
183+
values[sanitized] = GraphQLEnumValue(v)
184+
185+
# Track if value was modified for metadata annotation
186+
if was_modified:
187+
modified_values[sanitized] = str(v)
188+
175189
enums[enum_name] = GraphQLEnumType(enum_name, values, description=f"Allowed values for {fqn}.")
176190

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

181-
allowed_values_graphql = {_clean_enum_name(str(v)): str(v).replace('"', "'") for v in allowed}
195+
allowed_values_graphql = {
196+
_sanitize_enum_value_for_graphql(str(v))[0]: str(v).replace('"', "'") for v in allowed
197+
}
182198

183199
metadata[enum_name] = {
184200
"fqn": fqn,
185201
"vss_type": vss_type,
186202
"allowed_values": allowed_values_graphql,
203+
"modified_values": modified_values, # Store modified values for directive annotations
187204
}
188205

189206
return enums, metadata
190207

191208

192-
def _clean_enum_name(value: str) -> str:
193-
"""Sanitize enum value names for GraphQL."""
194-
if value[0].isdigit():
195-
value = f"_{value}"
196-
return value.replace(".", "_DOT_").replace("-", "_DASH_")
209+
def _sanitize_enum_value_for_graphql(original_value: str) -> tuple[str, bool]:
210+
"""
211+
Sanitize enum value for GraphQL schema compliance.
212+
213+
Converts values with spaces, camelCase, or other invalid characters to valid GraphQL enum values.
214+
Uses caseconverter to properly handle camelCase word boundaries.
215+
216+
Examples:
217+
"some value" -> "SOME_VALUE"
218+
"SOME VALUE" -> "SOME_VALUE"
219+
"PbCa" -> "PB_CA"
220+
"HTTPSConnection" -> "HTTPS_CONNECTION"
221+
"value-with-dash" -> "VALUE_WITH_DASH"
222+
223+
Args:
224+
original_value: The original enum value from VSS
225+
226+
Returns:
227+
Tuple of (sanitized_value, was_modified)
228+
- sanitized_value: Valid GraphQL enum value name
229+
- was_modified: True if the value was changed, False otherwise
230+
"""
231+
232+
# Handle empty or whitespace-only strings
233+
if not original_value or not original_value.strip():
234+
raise ValueError(f"Cannot create GraphQL enum value from empty or whitespace-only string: {original_value!r}")
235+
236+
# Replace with underscore all the special characters that are not allowed in GraphQL enum names
237+
sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", original_value)
238+
239+
# Convert to caseconverter MACRO_CASE (i.e., SCREAMING_SNAKE_CASE)
240+
if re.search(r"[A-Z]{2,}", sanitized) and re.search(r"[a-z]", sanitized):
241+
words: list[str] = []
242+
for segment in sanitized.split("_"):
243+
words.extend(re.findall(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+", segment))
244+
sanitized = "_".join(caseconverter.macrocase(word, strip_punctuation=False) for word in words if word)
245+
else:
246+
sanitized = caseconverter.macrocase(sanitized, strip_punctuation=False)
247+
248+
# Handle enum values starting with digits
249+
sanitized = f"_{sanitized}" if sanitized[0].isdigit() else sanitized
250+
251+
# Check if modification occurred
252+
was_modified = sanitized != original_value
253+
254+
return sanitized, was_modified
197255

198256

199257
def create_struct_types(

src/vss_tools/utils/graphql_directive_processor.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -117,28 +117,53 @@ def _process_allowed_enum_directives(
117117
Process allowed value enum directives.
118118
119119
Annotates the enum type itself with @vspec(element, fqn, metadata),
120-
but does NOT annotate individual enum values.
120+
and annotates individual enum values that were modified with @vspec(metadata).
121121
"""
122122
for enum_name, enum_data in allowed_enums_metadata.items():
123123
fqn = enum_data.get("fqn", "")
124124
vss_type = enum_data.get("vss_type", "ATTRIBUTE")
125125
allowed_values_dict = enum_data.get("allowed_values", {})
126+
modified_values = enum_data.get("modified_values", {})
126127

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

133+
in_target_enum = False
132134
for i, line in enumerate(lines):
133-
if line.strip().startswith(f"enum {enum_name}") and "@vspec" not in line:
134-
# Annotate the enum type (not individual values)
135-
# Format: @vspec(element: ATTRIBUTE, fqn: "...", metadata: [{key: "allowed", value: "[...]"}])
136-
directive = (
137-
f'@vspec(element: {vss_type}, fqn: "{fqn}", '
138-
f'metadata: [{{key: "allowed", value: "[{allowed_str}]"}}])'
139-
)
140-
lines[i] = line.replace(" {", f" {directive} {{")
141-
break
135+
if line.strip().startswith(f"enum {enum_name}"):
136+
if "@vspec" not in line:
137+
# Annotate the enum type
138+
directive = (
139+
f'@vspec(element: {vss_type}, fqn: "{fqn}", '
140+
f'metadata: [{{key: "allowed", value: "[{allowed_str}]"}}])'
141+
)
142+
lines[i] = line.replace(" {", f" {directive} {{")
143+
in_target_enum = True
144+
continue
145+
elif line.strip().startswith("enum ") and in_target_enum:
146+
in_target_enum = False
147+
continue
148+
elif line.strip() == "}" and in_target_enum:
149+
in_target_enum = False
150+
continue
151+
152+
# Process individual enum values that were modified
153+
if in_target_enum and line.strip() and not line.strip().startswith('"'):
154+
stripped_line = line.strip()
155+
156+
for enum_value_name, original_value in modified_values.items():
157+
enum_value_key = f"{enum_name}.{enum_value_name}"
158+
if stripped_line.startswith(enum_value_name) and enum_value_key not in processed_values:
159+
if "@vspec" not in line:
160+
indent = line[: len(line) - len(line.lstrip())]
161+
# Annotate modified enum value with original value in metadata
162+
directive = f'@vspec(metadata: [{{key: "originalName", value: "{original_value}"}}])'
163+
lines[i] = f"{indent}{enum_value_name} {directive}"
164+
165+
processed_values.add(enum_value_key)
166+
break
142167

143168
return lines
144169

0 commit comments

Comments
 (0)