Skip to content

Commit de4eef9

Browse files
add assigned vertices (#323)
1 parent 4996826 commit de4eef9

15 files changed

Lines changed: 655 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.8.15]
9+
10+
### Added
11+
12+
- **`FieldType.UUID`** — logical scalar (incl. `LIST` `item_type`); TigerGraph / Nebula / Postgres DDL store as `STRING` / `TEXT`.
13+
- **`Vertex.assigned`** / **`identity_mode == "assigned"`** — intentional UUID primary key, distinct from `blank`. Empty identity is minted with `uuid4()` at assemble time (before edge projection); writer keeps an idempotent safety net. Assigned vertices do **not** participate in blank-edge resolution.
14+
- **`VertexConfig.assigned_vertices`**; helpers in **`graflo.db.identity_uuid`** (`ensure_assigned_uuid`, `validate_uuid_value`, …).
15+
- Docs: four identity modes and blank vs assigned in [vertex identity](docs/concepts/schema/vertex_identity.md); UUID moved to supported field types.
16+
817
## [1.8.14]
918

1019
### Added

docs/concepts/architecture/core_components.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,17 @@ A `Vertex` describes vertices and their logical identity. It supports:
9191
- **LIST-typed properties cannot be identity / hash-identity sources**
9292
- Filtering conditions
9393
- **`blank: true`** — placeholder vertex with no natural key; identity defaults to **`id`** when omitted
94+
- **`assigned: true`** — intentional UUID primary key; identity defaults to **`id`**; mint at assemble (not blank-edge resolution)
9495
- **`hash_identity_properties`** — when non-empty, SHA256 hash of these source fields produces a deterministic synthetic **`id`** (see [Vertex identity modes](../schema/vertex_identity.md))
95-
- **`Vertex.identity_mode`** — derived runtime mode: **`natural`** (upsert on `identity`), **`hash`**, or **`blank`**
96+
- **`Vertex.identity_mode`** — derived runtime mode: **`natural`**, **`hash`**, **`blank`**, or **`assigned`**
9697

9798
#### Supported field types
9899

99100
| `FieldType` | Shape | Notes |
100101
|-------------|-------|-------|
101102
| `INT` `UINT` `FLOAT` `DOUBLE` `BOOL` `STRING` `DATETIME` | scalar | Existing scalar types |
102-
| `LIST` | + required `item_type` (scalar above) | Homogeneous, **one level** only — no `LIST[LIST[…]]`, no object schemas |
103+
| `UUID` | scalar | Logical UUID; TigerGraph / Nebula / Postgres DDL store as `STRING` / `TEXT` |
104+
| `LIST` | + required `item_type` (scalar above, including `UUID`) | Homogeneous, **one level** only — no `LIST[LIST[…]]`, no object schemas |
103105

104106
Declare types on each property as a mapping (`name` / `type` / optional `item_type`).
105107
String shorthand (`properties: [id, name]`) still works and leaves types unset.
@@ -197,16 +199,15 @@ Not in `FieldType` yet — do not author these in manifests:
197199

198200
| Type | Status | Sketch |
199201
|------|--------|--------|
200-
| `UUID` | PR2 | Logical scalar; TigerGraph/Nebula still store as `STRING` |
201202
| `MAP` | follow-up | `key_type` + `value_type` (scalar); native TG/Arango; Cypher targets raise (maps are not storable node/rel properties) |
202203
| `SET` | follow-up (low) | TigerGraph-oriented; prefer `LIST` elsewhere |
203204

204205
Identity defaults at schema level (`VertexConfig`):
205206

206207
- **`identity_from_all_properties: true`** (default) — vertices without explicit **`identity`** use all **`properties`** names as the logical key.
207-
- **`identity_from_all_properties: false`** — each non-blank vertex must declare **`identity`** explicitly; blank vertices still default to **`id`**.
208+
- **`identity_from_all_properties: false`** — each non-blank / non-assigned vertex must declare **`identity`** explicitly; blank and assigned vertices still default to **`id`**.
208209

209-
**Blank vertices:** set **`blank: true`** on the vertex entry under **`schema.graph.vertex_config.vertices`**. **`VertexConfig.blank_vertices`** is a derived list of names (not a separate YAML field). **`VertexConfig.hash_identity_vertices`** lists vertices with hash-derived identity. At runtime, **`ResourceRuntime`** keeps only vertex types referenced by that resource’s pipeline (and edge-inference selectors); blank types that are declared in the schema but not used by the resource are not injected automatically—include a **`vertex`** (or edge) step when the placeholder must be populated.
210+
**Blank vertices:** set **`blank: true`** on the vertex entry under **`schema.graph.vertex_config.vertices`**. **`VertexConfig.blank_vertices`** is a derived list of names (not a separate YAML field). **`VertexConfig.hash_identity_vertices`** lists vertices with hash-derived identity; **`VertexConfig.assigned_vertices`** lists intentional UUID-PK vertices. At runtime, **`ResourceRuntime`** keeps only vertex types referenced by that resource’s pipeline (and edge-inference selectors); blank types that are declared in the schema but not used by the resource are not injected automatically—include a **`vertex`** (or edge) step when the placeholder must be populated.
210211

211212
Algorithmic identity inference from record samples: **`graflo.db.identity_inference`** (`IdentityInferencer`, `apply_identity_inference_to_vertices`). See [Vertex identity modes](../schema/vertex_identity.md) and [Example 15](../../examples/example-15.md).
212213

docs/concepts/schema/vertex_identity.md

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,35 @@
22

33
GraFlo vertices declare how records are matched during upserts through fields on the logical **`Vertex`** model in `schema.graph.vertex_config`. Identity semantics are **not** configured in `DatabaseProfile` or `IngestionModel`.
44

5-
## Three runtime modes
5+
## Four runtime modes
66

7-
Each vertex resolves to one of three modes via the derived property **`Vertex.identity_mode`**:
7+
Each vertex resolves to one of four modes via the derived property **`Vertex.identity_mode`**. Modes describe **how the upsert key is obtained** (not string encoding). They are mutually exclusive.
88

9-
| `identity_mode` | `blank` | `hash_identity_properties` | `identity` | Write-time behavior |
10-
|---|---|---|---|---|
11-
| **`natural`** | `false` | `[]` | `[f]` or `[f1, f2, ...]` | Upsert on declared fields (same code path for one or many fields) |
12-
| **`hash`** | `false` | `[f1, f2, ...]` | `["id"]` | SHA256 of hash sources → synthetic `id`, then upsert |
13-
| **`blank`** | `true` | `[]` | `["id"]` | Random UUID → synthetic `id`, then upsert |
9+
| `identity_mode` | Authored signal | `identity` | Key behavior |
10+
|---|---|---|---|
11+
| **`natural`** | default | `[f]` or `[f1, f2, …]` | Upsert on declared fields. If a field is typed **`UUID`**, validate shape when present — **do not invent**. |
12+
| **`hash`** | non-empty `hash_identity_properties` | `["id"]` | SHA256 of hash sources → synthetic `id`, then upsert |
13+
| **`assigned`** | `assigned: true` | `["id"]` | Intentional UUID PK: empty → `uuid4()` at **assemble** (before edge projection); writer is an idempotent safety net. **Not** blank-edge resolution. |
14+
| **`blank`** | `blank: true` | `["id"]` | Placeholder: random UUID at write time; listed in `blank_vertices`; **does** blank-edge resolution |
1415

1516
Unary and composite natural keys are the **same runtime mode**. The upsert path passes `Vertex.identity` to the database as `match_keys`; width does not change the write branch.
1617

18+
### Blank vs assigned
19+
20+
Both may mint a random UUID when the synthetic `id` is empty, but they are not interchangeable:
21+
22+
| | `blank` | `assigned` |
23+
|---|---|---|
24+
| Meaning | No business identity / placeholder | Intentional UUID primary key |
25+
| Mint timing | Writer (`_assign_blank_vertex_ids`) | Assemble (before `assemble_edges`); writer net is idempotent |
26+
| Blank-edge resolution | Yes (`_resolve_blank_edges`) | **No** |
27+
| Typical use | Mentions, ephemeral join stubs | Events / entities whose PK is a UUID |
28+
1729
## Schema fields
1830

1931
### `identity`
2032

21-
Logical field name(s) used for upsert matching. For `hash` and `blank` modes the normalizer sets `identity` to `["id"]` (GraFlo canonical synthetic key; ArangoDB maps to `_key` at write time).
33+
Logical field name(s) used for upsert matching. For `hash`, `blank`, and `assigned` modes the normalizer sets `identity` to `["id"]` (GraFlo canonical synthetic key; ArangoDB maps to `_key` at write time).
2234

2335
### `hash_identity_properties`
2436

@@ -35,7 +47,30 @@ Example:
3547
3648
### `blank`
3749

38-
Placeholder vertices with no stable natural key; each record gets a random UUID at ingest time.
50+
Placeholder vertices with no stable natural key; each record gets a random UUID at ingest time and may participate in blank-edge expansion.
51+
52+
### `assigned`
53+
54+
Intentional UUID primary key. Empty identity is filled with `uuid4()` so cast-time edge projections see the key. Present valid UUIDs are preserved; invalid non-empty values raise.
55+
56+
```yaml
57+
- name: event
58+
properties:
59+
- { name: id, type: UUID }
60+
- { name: payload, type: STRING }
61+
identity: [id]
62+
assigned: true
63+
```
64+
65+
Natural key that happens to be a UUID (no new mode):
66+
67+
```yaml
68+
- name: user
69+
properties:
70+
- { name: external_id, type: UUID }
71+
- { name: email, type: STRING }
72+
identity: [external_id]
73+
```
3974

4075
## Inference vs runtime
4176

@@ -64,4 +99,4 @@ See [Example 15](../../examples/example-15.md) for a CSV → manifest → ingest
6499
| **`DatabaseProfile`** | Physical indexes and storage names only |
65100
| **`IngestionModel`** | Pipeline and connectors only; no identity semantics |
66101

67-
`VertexConfig.hash_identity_vertices` and `VertexConfig.vertices_by_identity_mode()` are derived lists for runtime introspection and `db_writer` branching.
102+
`VertexConfig.hash_identity_vertices`, `VertexConfig.blank_vertices`, `VertexConfig.assigned_vertices`, and `VertexConfig.vertices_by_identity_mode()` are derived lists for runtime introspection and `db_writer` / assemble branching.

graflo/architecture/pipeline/runtime/actor/wrapper.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ def assemble(
138138
assembly_ctx = ctx
139139
else:
140140
assembly_ctx = AssemblyContext.from_extraction(ctx)
141+
# Lazy import: graflo.db pulls architecture via ConnectionManager.
142+
from graflo.db.identity_uuid import ensure_assigned_uuids_in_acc_vertex
143+
144+
ensure_assigned_uuids_in_acc_vertex(assembly_ctx.acc_vertex, self.vertex_config)
141145
assemble_edges(
142146
ctx=assembly_ctx,
143147
vertex_config=self.vertex_config,

graflo/architecture/schema/db_aware.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ def vertex_set(self):
8888
def blank_vertices(self):
8989
return self.logical.blank_vertices
9090

91+
@property
92+
def assigned_vertices(self):
93+
return self.logical.assigned_vertices
94+
9195
@property
9296
def hash_identity_vertices(self):
9397
return self.logical.hash_identity_vertices

graflo/architecture/schema/vertex.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
# Type accepted for vertex properties before normalization (for use by Edge/WeightConfig)
4040
PropertiesInputType = list[str] | list["Field"] | list[dict[str, Any]]
4141
VertexName: TypeAlias = str
42-
IdentityMode: TypeAlias = Literal["natural", "hash", "blank"]
42+
IdentityMode: TypeAlias = Literal["natural", "hash", "blank", "assigned"]
4343

4444

4545
class FieldType(BaseEnum):
@@ -56,6 +56,7 @@ class FieldType(BaseEnum):
5656
BOOL: Boolean type
5757
STRING: String type
5858
DATETIME: DateTime type
59+
UUID: Logical UUID scalar (backends store as STRING/TEXT)
5960
LIST: Homogeneous list of scalars (requires ``Field.item_type``)
6061
"""
6162

@@ -66,6 +67,7 @@ class FieldType(BaseEnum):
6667
BOOL = "BOOL"
6768
STRING = "STRING"
6869
DATETIME = "DATETIME"
70+
UUID = "UUID"
6971
LIST = "LIST"
7072

7173

@@ -79,6 +81,7 @@ class FieldType(BaseEnum):
7981
FieldType.BOOL,
8082
FieldType.STRING,
8183
FieldType.DATETIME,
84+
FieldType.UUID,
8285
}
8386
)
8487
SCALAR_FIELD_TYPE_VALUES: frozenset[str] = frozenset(
@@ -161,7 +164,7 @@ class Field(ConfigBaseModel):
161164
default=None,
162165
description=(
163166
"Element type when ``type`` is LIST. Must be a scalar "
164-
"(INT, UINT, FLOAT, DOUBLE, BOOL, STRING, DATETIME)."
167+
"(INT, UINT, FLOAT, DOUBLE, BOOL, STRING, DATETIME, UUID)."
165168
),
166169
)
167170
description: str | None = PydanticField(
@@ -416,6 +419,13 @@ class Vertex(ConfigBaseModel):
416419
"True when this vertex has no natural identity and gets an auto-generated ID."
417420
),
418421
)
422+
assigned: bool = PydanticField(
423+
default=False,
424+
description=(
425+
"True when this vertex uses an intentional UUID primary key: empty identity "
426+
"is filled with uuid4 at assemble time; not a blank-node placeholder."
427+
),
428+
)
419429
hash_identity_properties: list[str] = PydanticField(
420430
default_factory=list,
421431
description=(
@@ -475,6 +485,15 @@ def convert_hash_identity_properties(cls, v: Any) -> Any:
475485

476486
@model_validator(mode="after")
477487
def set_identity(self) -> "Vertex":
488+
if self.blank and self.assigned:
489+
raise ValueError(
490+
f"Vertex '{self.name}': blank and assigned are mutually exclusive"
491+
)
492+
if self.assigned and self.hash_identity_properties:
493+
raise ValueError(
494+
f"Vertex '{self.name}': assigned and hash_identity_properties "
495+
"are mutually exclusive"
496+
)
478497
merged_properties = _merge_duplicate_fields(self.name, list(self.properties))
479498
identity_names = _dedupe_ordered(list(self.identity))
480499
hash_identity_names = _dedupe_ordered(list(self.hash_identity_properties))
@@ -497,7 +516,8 @@ def set_identity(self) -> "Vertex":
497516
augmented = list(merged_properties)
498517
for name in identity_names + hash_identity_names:
499518
if name not in seen_names:
500-
augmented.append(Field(name=name, type=None))
519+
synth_type = FieldType.UUID if self.assigned and name == "id" else None
520+
augmented.append(Field(name=name, type=synth_type))
501521
seen_names.add(name)
502522
object.__setattr__(self, "identity", identity_names)
503523
object.__setattr__(self, "hash_identity_properties", hash_identity_names)
@@ -511,9 +531,11 @@ def property_names(self) -> list[str]:
511531

512532
@property
513533
def identity_mode(self) -> IdentityMode:
514-
"""Runtime identity mode: natural upsert, hash-derived id, or blank uuid."""
534+
"""Runtime identity mode: natural, hash, blank, or assigned UUID PK."""
515535
if self.blank:
516536
return "blank"
537+
if self.assigned:
538+
return "assigned"
517539
if self.hash_identity_properties:
518540
return "hash"
519541
return "natural"
@@ -552,7 +574,7 @@ class VertexConfig(ConfigBaseModel):
552574
default=True,
553575
description=(
554576
"When true, vertices without explicit identity fall back to all property names. "
555-
"When false, explicit identity is required except for blank vertices."
577+
"When false, explicit identity is required except for blank or assigned vertices."
556578
),
557579
)
558580
_vertices_map: dict[VertexName, Vertex] | None = PrivateAttr(default=None)
@@ -576,6 +598,11 @@ def blank_vertices(self) -> list[str]:
576598
"""Vertex names marked blank (no natural identity; auto-generated ID)."""
577599
return [v.name for v in self.vertices if v.blank]
578600

601+
@property
602+
def assigned_vertices(self) -> list[str]:
603+
"""Vertex names with intentional UUID primary keys (``assigned: true``)."""
604+
return [v.name for v in self.vertices if v.assigned]
605+
579606
@property
580607
def hash_identity_vertices(self) -> list[str]:
581608
"""Vertex names using hash-derived synthetic identity fields."""
@@ -593,7 +620,7 @@ def _normalize_vertex_identities(
593620
if not vertex.identity:
594621
if vertex.hash_identity_properties:
595622
vertex.identity = [blank_id_field]
596-
elif vertex.blank:
623+
elif vertex.blank or vertex.assigned:
597624
vertex.identity = [blank_id_field]
598625
elif self.identity_from_all_properties:
599626
vertex.identity = list(vertex.property_names)
@@ -611,7 +638,12 @@ def _normalize_vertex_identities(
611638
if field_name not in vertex.property_names
612639
]
613640
for field_name in missing:
614-
vertex.properties.append(Field(name=field_name, type=None))
641+
synth_type = (
642+
FieldType.UUID
643+
if vertex.assigned and field_name == blank_id_field
644+
else None
645+
)
646+
vertex.properties.append(Field(name=field_name, type=synth_type))
615647

616648
def _get_vertices_map(self) -> dict[VertexName, Vertex]:
617649
"""Return the vertices map (set by model validator)."""

graflo/db/field_type_support.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,24 @@ def assert_schema_field_types_supported(db_type: DBType, schema: Schema) -> None
6969

7070

7171
def tigergraph_type_for_field(field: Field) -> str:
72-
"""Return a TigerGraph attribute type string (e.g. ``LIST<STRING>``, ``INT``)."""
72+
"""Return a TigerGraph attribute type string (e.g. ``LIST<STRING>``, ``INT``).
73+
74+
Logical ``UUID`` is stored as ``STRING`` (TigerGraph has no native UUID type).
75+
"""
7376
assert_field_type_supported(DBType.TIGERGRAPH, field)
7477
if field.type is None:
7578
return FieldType.STRING.value
7679
if is_list_field_type(field.type):
7780
item = field.item_type
7881
item_val = item.value if isinstance(item, FieldType) else str(item).upper()
82+
if item_val == FieldType.UUID.value:
83+
item_val = FieldType.STRING.value
7984
return f"LIST<{item_val}>"
8085
if isinstance(field.type, FieldType):
86+
if field.type == FieldType.UUID:
87+
return FieldType.STRING.value
8188
return field.type.value
82-
return str(field.type).upper()
89+
type_upper = str(field.type).upper()
90+
if type_upper == FieldType.UUID.value:
91+
return FieldType.STRING.value
92+
return type_upper

graflo/db/identity_inference.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,13 @@
1717
from graflo.architecture.schema.graflo_output import GraFloOutput
1818
from graflo.architecture.schema.vertex import Vertex, VertexConfig
1919
from graflo.db.graph_introspection import strip_internal_properties
20+
from graflo.db.identity_uuid import UUID_PATTERN
2021

2122
logger = logging.getLogger(__name__)
2223

2324
_SYNTHETIC_ID_FIELD = "id"
2425
_SEMANTIC_PATTERN = re.compile(r"(?i)(id|uuid|key|code|pk)$")
25-
_UUID_PATTERN = re.compile(
26-
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
27-
re.IGNORECASE,
28-
)
26+
_UUID_PATTERN = UUID_PATTERN
2927
_ISO_DATETIME_PATTERN = re.compile(
3028
r"^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?$"
3129
)

0 commit comments

Comments
 (0)