Skip to content

Commit b1dc19c

Browse files
committed
fix: align with gts-spec v0.7.0
- Prohibit single-segment instance IDs (Issue #37) - Add is_wildcard field to validate-id and parse-id responses - Add is_schema field to parse-id response - Fix schema_id priority: chained ID takes precedence over type field - Validate $$id prefix (reject 'gts.' without 'gts://') - Validate wildcard patterns in candidates - Skip $id for non-schemas in schema_id extraction All 211 e2e tests pass.
1 parent dbbb04d commit b1dc19c

4 files changed

Lines changed: 94 additions & 24 deletions

File tree

gts/src/gts/entities.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,30 @@ def _calc_json_schema_id(self, cfg: GtsConfig) -> Optional[str]:
349349
return schema_val
350350
return None
351351

352-
# For instances, look in schema_id_fields
352+
# PRIORITY 1: Check entity_id_fields for a GTS ID (gtsId, id, etc.)
353+
# If found and it's a chained ID, extract schema from the chain
354+
# NOTE: Skip $id field for instances - $id should only influence schema_id for schemas
355+
entity_id_cand = self._first_non_empty_field(cfg.entity_id_fields)
356+
if entity_id_cand and GtsID.is_valid(entity_id_cand[1]):
357+
# Skip $id for non-schemas: $id without $schema means the doc is an instance
358+
# and $id should not be used to derive schema_id
359+
if entity_id_cand[0] == "$id" and not self.is_schema:
360+
pass # Skip to PRIORITY 2
361+
else:
362+
idv = entity_id_cand[1]
363+
# If already a type id (ends with '~'), use it as-is
364+
if idv.endswith("~"):
365+
self.selected_schema_id_field = entity_id_cand[0]
366+
return idv
367+
# For chained IDs (well-known instances), extract schema:
368+
# everything up to and including last '~'
369+
last_tilde = idv.rfind("~")
370+
if last_tilde > 0:
371+
self.selected_schema_id_field = entity_id_cand[0]
372+
return idv[: last_tilde + 1]
373+
374+
# PRIORITY 2: Fall back to explicit schema_id_fields (type, gtsTid, etc.)
375+
# Only check these if no chained GTS ID was found in entity_id_fields
353376
cand = self._first_non_empty_field(cfg.schema_id_fields)
354377
if cand:
355378
self.selected_schema_id_field = cand[0]
@@ -362,22 +385,7 @@ def _calc_json_schema_id(self, cfg: GtsConfig) -> Optional[str]:
362385
return schema_id[: last_tilde + 1]
363386
return schema_id
364387

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-
379388
# No schema reference found for instance
380-
# Note: Single-segment schema IDs in $id don't count as schema_id for instances
381389
return None
382390

383391
def _extract_uuid_from_content(self) -> Optional[str]:

gts/src/gts/gts.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,17 @@ def __init__(self, id: str):
212212
self.gts_id_segments.append(GtsIdSegment(i + 1, offset, parts[i]))
213213
offset += len(parts[i])
214214

215+
# Issue #37: Single-segment instance IDs are not allowed
216+
# An instance ID (not ending with ~) must be chained (have at least 2 segments)
217+
if not self.id.endswith("~") and len(self.gts_id_segments) == 1:
218+
# Check if it's a wildcard (wildcards are allowed as single segment)
219+
if not any(seg.is_wildcard for seg in self.gts_id_segments):
220+
raise GtsInvalidId(
221+
id,
222+
"Single-segment instance IDs are not allowed. "
223+
"Instance IDs must be chained (e.g., type~instance).",
224+
)
225+
215226
@property
216227
def is_type(self) -> bool:
217228
return self.id.endswith("~")

gts/src/gts/ops.py

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,15 @@ class GtsIdValidationResult:
2323
id: str
2424
valid: bool
2525
error: str = ""
26+
is_wildcard: bool = False
2627

2728
def to_dict(self) -> Dict[str, Any]:
28-
return {"id": self.id, "valid": self.valid, "error": self.error}
29+
return {
30+
"id": self.id,
31+
"valid": self.valid,
32+
"error": self.error,
33+
"is_wildcard": self.is_wildcard,
34+
}
2935

3036

3137
@dataclass
@@ -60,13 +66,17 @@ class GtsIdParseResult:
6066
ok: bool
6167
segments: List[GtsIdSegment] = field(default_factory=list)
6268
error: str = ""
69+
is_wildcard: bool = False
70+
is_schema: bool = False
6371

6472
def to_dict(self) -> Dict[str, Any]:
6573
return {
6674
"id": self.id,
6775
"ok": self.ok,
6876
"segments": [s.to_dict() for s in self.segments],
6977
"error": self.error,
78+
"is_wildcard": self.is_wildcard,
79+
"is_schema": self.is_schema,
7080
}
7181

7282

@@ -331,6 +341,18 @@ def add_entity(
331341
ok=False, error="Unable to detect GTS ID in schema"
332342
)
333343

344+
# Validate $id prefix for schemas: must use gts:// URI, not plain gts.
345+
if entity.is_schema and validate:
346+
raw_id = content.get("$id", "")
347+
if isinstance(raw_id, str):
348+
# Reject plain gts. prefix (without gts://)
349+
if raw_id.startswith("gts.") and not raw_id.startswith("gts://"):
350+
return GtsAddEntityResult(
351+
ok=False,
352+
error="Schema $id must use gts:// URI format, not plain gts. prefix",
353+
is_schema=True,
354+
)
355+
334356
# Register the entity (use raw_id for non-GTS instances)
335357
self.store.register(entity)
336358

@@ -376,15 +398,29 @@ def add_schema(self, type_id: str, schema: Dict[str, Any]) -> GtsAddSchemaResult
376398
return GtsAddSchemaResult(ok=False, error=str(e))
377399

378400
def validate_id(self, gts_id: str) -> GtsIdValidationResult:
401+
# Check if it's a wildcard pattern (contains *)
402+
is_wildcard = "*" in gts_id
379403
try:
380-
_ = GtsID(gts_id)
381-
return GtsIdValidationResult(id=gts_id, valid=True)
404+
if is_wildcard:
405+
# For wildcards, try parsing as GtsWildcard
406+
_ = GtsWildcard(gts_id)
407+
else:
408+
_ = GtsID(gts_id)
409+
return GtsIdValidationResult(id=gts_id, valid=True, is_wildcard=is_wildcard)
382410
except Exception as e:
383-
return GtsIdValidationResult(id=gts_id, valid=False, error=str(e))
411+
return GtsIdValidationResult(
412+
id=gts_id, valid=False, error=str(e), is_wildcard=is_wildcard
413+
)
384414

385415
def parse_id(self, gts_id: str) -> GtsIdParseResult:
416+
# Check if it's a wildcard pattern (contains *)
417+
is_wildcard = "*" in gts_id
386418
try:
387-
segs = GtsID(gts_id).gts_id_segments
419+
if is_wildcard:
420+
parsed = GtsWildcard(gts_id)
421+
else:
422+
parsed = GtsID(gts_id)
423+
segs = parsed.gts_id_segments
388424
segments = [
389425
GtsIdSegment(
390426
vendor=s.vendor,
@@ -397,12 +433,27 @@ def parse_id(self, gts_id: str) -> GtsIdParseResult:
397433
)
398434
for s in segs
399435
]
400-
return GtsIdParseResult(id=gts_id, ok=True, segments=segments)
436+
# is_schema: true if ends with ~ and not a wildcard ending with ~*
437+
is_schema = gts_id.endswith("~") and not is_wildcard
438+
return GtsIdParseResult(
439+
id=gts_id,
440+
ok=True,
441+
segments=segments,
442+
is_wildcard=is_wildcard,
443+
is_schema=is_schema,
444+
)
401445
except Exception as e:
402-
return GtsIdParseResult(id=gts_id, ok=False, error=str(e))
446+
return GtsIdParseResult(
447+
id=gts_id, ok=False, error=str(e), is_wildcard=is_wildcard
448+
)
403449

404450
def match_id_pattern(self, candidate: str, pattern: str) -> GtsIdMatchResult:
405451
try:
452+
# If candidate contains '*', validate it as a wildcard pattern
453+
# This catches malformed wildcards like 'a*' (wildcard not on token boundary)
454+
if "*" in candidate:
455+
# Validate candidate as a wildcard pattern first
456+
_ = GtsWildcard(candidate)
406457
c = GtsID(candidate)
407458
p = GtsWildcard(pattern)
408459
match = c.wildcard_match(p)

0 commit comments

Comments
 (0)