Skip to content

Commit dbbb04d

Browse files
authored
Merge pull request #16 from KvizadSaderah/feat/gts-spec-v0.7
fix: resolve e2e test failures for gts-spec v0.7
2 parents 3edda03 + 08c87d7 commit dbbb04d

5 files changed

Lines changed: 111 additions & 32 deletions

File tree

gts/src/gts/entities.py

Lines changed: 72 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class GtsEntity:
8686
selected_entity_field: Optional[str] = None
8787
selected_schema_id_field: Optional[str] = None
8888
description: str = ""
89+
raw_id: Optional[str] = None # Stores raw ID value (may be non-GTS)
8990
schemaRefs: List[Dict[str, str]] = field(default_factory=list)
9091

9192
def __init__(
@@ -122,6 +123,7 @@ def __init__(
122123
# Calculate IDs if config provided
123124
if cfg is not None:
124125
idv = self._calc_json_entity_id(cfg)
126+
self.raw_id = idv # Store raw ID even if non-GTS
125127
self.schemaId = self._calc_json_schema_id(cfg)
126128
# If no valid GTS ID found in entity fields, use schema ID as fallback
127129
if not (idv and GtsID.is_valid(idv)):
@@ -289,13 +291,11 @@ def _get_field_value(self, field: str) -> Optional[str]:
289291
return None
290292

291293
def _first_non_empty_field(self, fields: List[str]) -> Optional[Tuple[str, str]]:
292-
"""Find first non-empty field, preferring valid GTS IDs."""
293-
# First pass: look for valid GTS IDs
294-
for f in fields:
295-
v = self._get_field_value(f)
296-
if v and GtsID.is_valid(v):
297-
return f, v
298-
# Second pass: any non-empty string
294+
"""Find first non-empty field value in order.
295+
296+
Returns the first non-empty string value without preferring GTS IDs.
297+
This ensures UUID and non-GTS values are returned when they appear first.
298+
"""
299299
for f in fields:
300300
v = self._get_field_value(f)
301301
if v:
@@ -311,22 +311,74 @@ def _calc_json_entity_id(self, cfg: GtsConfig) -> str:
311311
return f"{self.file.path}#{self.list_sequence}"
312312
return self.file.path if self.file else ""
313313

314-
def _calc_json_schema_id(self, cfg: GtsConfig) -> str:
314+
def _calc_json_schema_id(self, cfg: GtsConfig) -> Optional[str]:
315+
"""Calculate schema_id based on entity type and content.
316+
317+
Rules:
318+
- For schemas: extract parent from $id chain, or fallback to $schema
319+
- For instances: look for type/schema fields in schema_id_fields
320+
- Return None if no schema reference found for instances
321+
"""
322+
# For schemas, derive from the entity ID (parent of chain)
323+
if self.is_schema:
324+
# Get entity ID (the $id field for schemas)
325+
idv = self._get_field_value("$id")
326+
if idv and GtsID.is_valid(idv):
327+
# Check if it's a chained ID (derived schema)
328+
last_tilde = idv.rfind("~")
329+
if last_tilde > 0:
330+
# Find the previous segment (parent)
331+
parent_end = last_tilde
332+
# Check if there's another segment before this one
333+
prefix = idv[:parent_end]
334+
prev_tilde = prefix.rfind("~")
335+
if prev_tilde > 0:
336+
# Has a parent chain - return first segment (base type)
337+
self.selected_schema_id_field = "$id"
338+
return prefix[: prev_tilde + 1]
339+
else:
340+
# Single segment schema - base type, return $schema
341+
schema_val = self._get_field_value("$schema")
342+
if schema_val:
343+
self.selected_schema_id_field = "$schema"
344+
return schema_val
345+
# Fallback to $schema for schemas
346+
schema_val = self._get_field_value("$schema")
347+
if schema_val:
348+
self.selected_schema_id_field = "$schema"
349+
return schema_val
350+
return None
351+
352+
# For instances, look in schema_id_fields
315353
cand = self._first_non_empty_field(cfg.schema_id_fields)
316354
if cand:
317355
self.selected_schema_id_field = cand[0]
318-
return cand[1]
319-
idv = self._calc_json_entity_id(cfg)
320-
if idv and isinstance(idv, str) and GtsID.is_valid(idv):
321-
if idv.endswith("~"):
322-
return idv
323-
last = idv.rfind("~")
324-
if last > 0:
325-
self.selected_schema_id_field = self.selected_entity_field
326-
return idv[: last + 1]
327-
if self.file and self.list_sequence is not None:
328-
return f"{self.file.path}#{self.list_sequence}"
329-
return self.file.path if self.file else ""
356+
schema_id = cand[1]
357+
# If schema_id is a chained GTS ID, extract parent (base type)
358+
if GtsID.is_valid(schema_id):
359+
last_tilde = schema_id.rfind("~")
360+
if last_tilde > 0 and not schema_id.endswith("~"):
361+
# It's an instance ID in type field - extract schema part
362+
return schema_id[: last_tilde + 1]
363+
return schema_id
364+
365+
# For instances with chained GTS ID in entity_id field, derive schema_id
366+
# BUT only if the ID is a proper chained instance ID (not a single schema segment)
367+
if self.selected_entity_field and self.selected_entity_field != "$id":
368+
# Only derive from fields like "id", not from "$id" (which is for schemas)
369+
idv = self._get_field_value(self.selected_entity_field)
370+
if idv and GtsID.is_valid(idv):
371+
# Check if it's a chained ID (instance ID) vs single segment (schema ID)
372+
if not idv.endswith("~"):
373+
# Instance ID: extract schema part (everything up to and including last ~)
374+
last_tilde = idv.rfind("~")
375+
if last_tilde > 0:
376+
self.selected_schema_id_field = self.selected_entity_field
377+
return idv[: last_tilde + 1]
378+
379+
# No schema reference found for instance
380+
# Note: Single-segment schema IDs in $id don't count as schema_id for instances
381+
return None
330382

331383
def _extract_uuid_from_content(self) -> Optional[str]:
332384
"""Extract a UUID value from content to use as instance identifier."""

gts/src/gts/ops.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ def to_dict(self) -> Dict[str, Any]:
199199
result["is_schema"] = self.is_schema
200200
else:
201201
result["error"] = self.error
202+
result["is_schema"] = self.is_schema
202203
return result
203204

204205

@@ -315,12 +316,22 @@ def add_entity(
315316
self, content: Dict[str, Any], validate: bool = False
316317
) -> GtsAddEntityResult:
317318
entity = GtsEntity(content=content, cfg=self.cfg)
318-
if not entity.gts_id:
319+
320+
# For instances (non-schemas), require an id field
321+
if not entity.is_schema:
322+
# Instance must have an id from entity_id_fields (not just derived from schema)
323+
if not entity.raw_id or not entity.selected_entity_field:
324+
return GtsAddEntityResult(
325+
ok=False, error="Instance must have an id field", is_schema=False
326+
)
327+
328+
# Schemas MUST have a valid GTS ID
329+
if entity.is_schema and not entity.gts_id:
319330
return GtsAddEntityResult(
320-
ok=False, error="Unable to detect GTS ID in entity"
331+
ok=False, error="Unable to detect GTS ID in schema"
321332
)
322333

323-
# Register the entity first
334+
# Register the entity (use raw_id for non-GTS instances)
324335
self.store.register(entity)
325336

326337
# Always validate schemas
@@ -333,17 +344,19 @@ def add_entity(
333344
)
334345

335346
# If validation is requested, validate the instance as well
336-
if validate and not entity.is_schema:
347+
if validate and not entity.is_schema and entity.gts_id:
337348
try:
338349
self.store.validate_instance(entity.gts_id.id)
339350
except Exception as e:
340351
return GtsAddEntityResult(
341352
ok=False, error=f"Validation failed: {str(e)}"
342353
)
343354

355+
# Return gts_id if available, otherwise raw_id
356+
entity_id = entity.gts_id.id if entity.gts_id else (entity.raw_id or "")
344357
return GtsAddEntityResult(
345358
ok=True,
346-
id=entity.gts_id.id,
359+
id=entity_id,
347360
schema_id=entity.schemaId,
348361
is_schema=entity.is_schema,
349362
)
@@ -460,8 +473,12 @@ def attr(self, gts_with_path: str) -> GtsPathResolver:
460473

461474
def extract_id(self, content: Dict[str, Any]) -> GtsExtractIdResult:
462475
entity = GtsEntity(content=content, cfg=self.cfg)
476+
# Always use raw_id - that's the actual value found in the entity_id_fields
477+
# Note: gts_id may be derived from schemaId as fallback, but extract-id
478+
# should return what was actually in the selected field
479+
id_value = entity.raw_id or ""
463480
return GtsExtractIdResult(
464-
id=entity.gts_id.id if entity.gts_id else "",
481+
id=id_value,
465482
schema_id=entity.schemaId,
466483
selected_entity_field=entity.selected_entity_field,
467484
selected_schema_id_field=entity.selected_schema_id_field,

gts/src/gts/server.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,9 @@ def _register_routes(self) -> None:
288288
async def add_entity(
289289
self, body: Dict[str, Any] = Body(...), validate: bool = Query(False)
290290
) -> JSONResponse:
291-
return JSONResponse(self.ops.add_entity(body, validate=validate).to_dict())
291+
result = self.ops.add_entity(body, validate=validate)
292+
status_code = 200 if result.ok else 422
293+
return JSONResponse(result.to_dict(), status_code=status_code)
292294

293295
async def add_entities(
294296
self, body: List[Dict[str, Any]] = Body(...)

gts/src/gts/store.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,18 @@ def _populate_from_reader(self) -> None:
134134
self._by_id[entity.gts_id.id] = entity
135135

136136
def register(self, entity: GtsEntity) -> None:
137-
"""Register a JsonEntity in the store."""
138-
if not entity.gts_id or not entity.gts_id.id:
139-
raise ValueError("Entity must have a valid gts_id")
140-
self._by_id[entity.gts_id.id] = entity
137+
"""Register a GtsEntity in the store.
138+
139+
If entity has a valid gts_id, use that as the key.
140+
Otherwise, use raw_id for non-GTS entities.
141+
"""
142+
if entity.gts_id and entity.gts_id.id:
143+
self._by_id[entity.gts_id.id] = entity
144+
elif entity.raw_id:
145+
# Allow non-GTS entities with raw_id (e.g., UUIDs or simple strings)
146+
self._by_id[entity.raw_id] = entity
147+
else:
148+
raise ValueError("Entity must have a valid gts_id or raw_id")
141149

142150
def register_schema(self, type_id: str, schema: Dict[str, Any]) -> None:
143151
"""

0 commit comments

Comments
 (0)