Skip to main content

Struct field assignment desugaring

Hamelin automatically expands struct field assignments into complete struct reconstructions, a process called struct field assignment desugaring. When you write event.status = "active" where event already contains a struct value, Hamelin appears to allow you to modify the struct in place. It does this by expanding the entire struct into individual field bindings, and replacing only the target field with your new value. This desugaring happens whenever you assign to a field path where a parent identifier already holds a struct value, whether that struct came from an array access, field reference, function call, or even a struct literal. The process ensures you can modify deeply nested data without manually specifying every field that needs to be preserved.

How struct literals become narrow bindings

Before understanding field assignment desugaring, you need to see how Hamelin handles struct literals in the first place. When you assign a struct literal to an identifier, Hamelin immediately "cracks" it open into individual field bindings rather than storing it as a single struct value. Each field in the literal becomes its own binding in the environment, with the field path as its identifier. No parent binding is created at all - the struct literal simply expands directly into its component field bindings. This flattening happens immediately when the struct literal is assigned, not later when fields are accessed. These narrow bindings behave exactly like any other bindings in the environment, which means they can be individually modified, dropped, or used in expressions.

SET event = {user_id: "alice", action: "login", timestamp: now(), status: "pending"}

// Desugars to:
SET event.user_id = "alice"
| SET event.action = "login"
| SET event.timestamp = now()
| SET event.status = "pending"

The struct literal assignment creates four field bindings without any parent binding. The event identifier never exists as a binding itself - it's just the prefix that groups these fields conceptually. Each field binding stands alone and can be referenced directly as event.user_id or used in expressions. If you SELECT event later, Hamelin reconstructs the struct from these individual bindings. This decomposition into narrow bindings makes struct literals extremely flexible for manipulation.

You can add new fields to this flattened struct simply by creating new bindings with the appropriate prefix. When you write SET event.ip_address = "192.168.1.1", you're just adding another binding to the environment that happens to share the event prefix. Hamelin recognizes this pattern and includes the new field when reconstructing the struct. Similarly, you can modify existing fields by reassigning them - SET event.status = "success" simply overwrites the existing event.status binding. The struct literal decomposition makes these operations natural extensions of regular variable assignment.

SET event = {user_id: "alice", action: "login", timestamp: now()}
| SET event.session_id = generate_id() // Adds new field
| SET event.action = "authenticated" // Modifies existing field
| DROP event.timestamp // Removes field

// Results in these bindings:
SET event.user_id = "alice"
| SET event.action = "authenticated"
| SET event.session_id = generate_id()

Non-splittable struct expressions

Not all struct values get flattened into narrow bindings like struct literals do. When you access a struct from an array element, field reference, or function call, Hamelin stores it as a single struct-typed binding rather than decomposing it. These "non-splittable" struct expressions maintain their structure as a cohesive unit in the environment. The distinction matters because modifying a field within these structs requires a different approach - this is where field assignment desugaring becomes essential. Without desugaring, you'd have to manually extract every field, modify the one you want, and rebuild the entire struct.

FROM events
| SET first = events[0]

// This creates just one binding:
// first = <struct value from array>

// NOT these bindings:
// first.user_id = ...
// first.action = ...
// etc.

The array access creates a single binding first that contains the entire struct value. Unlike with struct literals, there are no individual field bindings for first.user_id, first.action, etc. The struct remains intact as a single value in the environment. If you want to modify a field within this struct, you can't just reassign first.action because that binding doesn't exist yet. This is the key difference between splittable struct literals and non-splittable struct expressions.

The desugaring transformation

Assigning to a field of a non-splittable struct triggers Hamelin's desugaring transformation. The assignment first.status = "active" where first contains a struct value starts the desugaring process. Hamelin recognizes that first holds a struct and expands the entire assignment into a series of operations that reconstruct the struct with the modification. The system doesn't error or create an isolated field - it transforms the single assignment into multiple bindings. The desugaring creates explicit bindings for every field, with your target field getting the new value and all other fields preserving their original values through field references.

FROM events  
| SET first = events[0] // first contains {user_id, action, timestamp, status}
| SET first.status = "active"

// Desugars to:
SET first.status = "active"
| SET first.user_id = first.user_id
| SET first.action = first.action
| SET first.timestamp = first.timestamp

The desugaring process transforms one field assignment into multiple bindings that collectively reconstruct the struct. Your assigned field first.status gets the value "active" as intended. Every other field in the original struct gets a binding that references its current value through a field reference expression. This transformation happens transparently during compilation, converting your simple field assignment into the equivalent manual reconstruction.

Multiple field modifications

When you modify multiple fields of the same struct, each modification builds on the previous state. The first modification expands the struct into individual bindings, and subsequent modifications operate on those bindings rather than the original struct. Each field assignment layers on top of the previous changes, creating a natural composition pattern. The order of modifications matters because each desugaring uses the current environment state, which includes bindings created by earlier desugarings. This incremental approach lets you progressively transform structs through a series of field assignments without complex manual reconstruction.

FROM security_logs
| SET entry = logs[0] // {event_id, severity, message, timestamp}
| SET entry.severity = "HIGH"
| SET entry.reviewed = true
| SET entry.reviewer = "alice"

// First assignment desugars to:
SET entry.severity = "HIGH"
| SET entry.event_id = entry.event_id
| SET entry.message = entry.message
| SET entry.timestamp = entry.timestamp

// Second assignment adds:
SET entry.reviewed = true

// Third assignment adds:
SET entry.reviewer = "alice"

The first field assignment triggers a complete desugaring that creates bindings for all original fields. The entry.severity field gets the new value "HIGH" while other fields get field reference bindings. When you assign entry.reviewed = true, Hamelin recognizes that entry already has field bindings from the desugaring, so it simply adds the new binding without triggering another desugaring. The third assignment similarly adds the reviewer field. The final result is a struct with all original fields plus the two new fields you added.

Nested struct modification

Desugaring handles nested struct modifications by identifying the correct level to expand based on which identifier holds an actual struct value. When you write event.metadata.risk_score = 100, Hamelin examines each level of the path to find where the struct boundary exists. If event holds a struct value but metadata is just a field within it (not a separate binding), the desugaring happens at the event level. The system reconstructs the entire event struct with a modified metadata field that contains your new risk_score. This process works recursively, handling arbitrary levels of nesting as long as each level properly resolves to struct types.

FROM security_events
| SET event = events[0] // {id, metadata: {risk_score: 50, category: "auth"}, timestamp}
| SET event.metadata = {risk_score: 100, category: event.metadata.category, flagged: true}

// Desugars to:
SET event.metadata.risk_score = 100
| SET event.metadata.category = event.metadata.category
| SET event.metadata.flagged = true
| SET event.id = event.id
| SET event.timestamp = event.timestamp

Modifying nested structs requires reconstructing the nested struct explicitly as shown above. The assignment to event.metadata replaces the entire nested struct with a new one that includes the modifications. The desugaring process ensures that all other fields of the parent event struct get preserved through field references. This pattern of replacing entire nested structs while preserving parent fields is common when working with deeply nested data structures.

How DROP uses desugaring

The DROP command leverages the same desugaring mechanism but with inverted logic - instead of adding or modifying fields, it reconstructs the struct with specific fields excluded. When you DROP a field from a non-splittable struct, Hamelin expands all fields except the ones being dropped. This creates the same pattern of bindings with field references, but omits the dropped fields entirely. The resulting bindings represent the struct minus the dropped fields. This elegant reuse of the desugaring mechanism makes DROP work consistently with the rest of Hamelin's struct manipulation features.

FROM user_events
| SET user = events[0] // {id, email, password_hash, last_login, preferences}
| DROP user.password_hash, user.email

// Desugars to (includes all fields EXCEPT dropped ones):
SET user.id = user.id
| SET user.last_login = user.last_login
| SET user.preferences = user.preferences

The DROP desugaring creates bindings for every field except password_hash and email, effectively removing them from the struct. Each preserved field gets a field reference binding that maintains its current value. If you later SELECT user, you'll get a struct containing only id, last_login, and preferences. This approach to dropping fields integrates seamlessly with other struct operations.

Field ordering in desugared structs

When Hamelin reconstructs a struct after desugaring, modified fields appear first, followed by preserved fields in their original order. This consistent ordering rule makes modifications visible in the struct's schema and helps track what changed. You see this ordering whether modifying fields through assignment or adding new fields to an existing struct. Each modification moves that field to the front of the field order, creating a kind of modification history encoded in the structure itself. While this changes the field order from the original struct, it provides valuable visibility into what transformations have been applied, and since Hamelin fields have nominal equivalence rather than positional equivalence, this reordering is safe.

FROM events
| SET evt = events[0] // Original: {timestamp, user_id, action, session_id, ip_address}
| SET evt.action = "logout"
| SET evt.duration = 30

// Resulting field order:
// {duration, action, timestamp, user_id, session_id, ip_address}

The modified action field and newly added duration field both appear at the front of the reconstructed struct. The original fields maintain their relative ordering (timestamp still comes before user_id, which comes before session_id). This predictable ordering helps when debugging or when you need to understand what transformations have been applied to a struct. The consistency of this rule across all struct operations makes Hamelin's behavior predictable even with complex nested modifications.