"""
GraphQL Selection Set Module
This module provides classes for building GraphQL selection sets,
including support for nested fields, predefined selections, and directives.
Directives:
Supports @include, @skip, and custom directives on nested fields.
# Using shortcuts
builder.nested("addMetadata", args={"metadata": "$metadata"})
.include_if("$includeMetadata")
.select("entityType")
.end()
# Using generic directive
builder.nested("someField")
.directive("cacheControl", {"maxAge": 30})
.select("id")
.end()
"""
from typing import List, Dict, Any, Optional, Union, TYPE_CHECKING
if TYPE_CHECKING:
from ace.core.graphql.builder import OperationBuilder
# ==============================================================================================================
# Directive Classes
# ==============================================================================================================
[docs]
class Directive:
"""
Represents a GraphQL directive (e.g., @include, @skip, @deprecated).
Args:
name: Directive name (without @)
args: Directive arguments
Example:
Directive("include", {"if": "$includeMetadata"}) -> @include(if: $includeMetadata)
Directive("skip", {"if": "$hideField"}) -> @skip(if: $hideField)
Directive("deprecated", {"reason": "Use newField"}) -> @deprecated(reason: "Use newField")
"""
[docs]
def __init__(self, name: str, args: Optional[Dict[str, Any]] = None):
self.name = name
self.args = args or {}
[docs]
def build(self) -> str:
"""Build the directive string."""
if not self.args:
return f"@{self.name}"
args_parts = []
for key, value in self.args.items():
if isinstance(value, str) and value.startswith("$"):
args_parts.append(f"{key}: {value}")
elif isinstance(value, str):
args_parts.append(f'{key}: "{value}"')
elif isinstance(value, bool):
args_parts.append(f"{key}: {str(value).lower()}")
elif value is None:
args_parts.append(f"{key}: null")
else:
args_parts.append(f"{key}: {value}")
return f"@{self.name}({', '.join(args_parts)})"
def __repr__(self) -> str:
return f"Directive(@{self.name}, args={self.args})"
def __eq__(self, other) -> bool:
if not isinstance(other, Directive):
return False
return self.name == other.name and self.args == other.args
[docs]
class Include(Directive):
"""Shorthand for @include(if: $variable) directive."""
[docs]
def __init__(self, variable: str):
if not variable.startswith("$"):
variable = f"${variable}"
super().__init__("include", {"if": variable})
[docs]
class Skip(Directive):
"""Shorthand for @skip(if: $variable) directive."""
[docs]
def __init__(self, variable: str):
if not variable.startswith("$"):
variable = f"${variable}"
super().__init__("skip", {"if": variable})
# ==============================================================================================================
# SelectionSet — reusable, declarative selections
# ==============================================================================================================
[docs]
class SelectionSet:
"""
Represents a reusable GraphQL selection set.
Can be used standalone or merged into builders using .use()
Example:
# Define a reusable selection
POLICY_INFO = SelectionSet(
"policyId",
"policyNumber",
"effectiveDate",
nested={"coverages": SelectionSet("id", "coverageNumber")}
)
# Use in builder
builder.nested("info").use(POLICY_INFO).end()
"""
[docs]
def __init__(
self,
*fields: str,
nested: Optional[Dict[str, "SelectionSet"]] = None
):
"""
Initialize a SelectionSet.
Args:
*fields: Scalar field names to select
nested: Dictionary of nested field name -> SelectionSet
"""
self.fields: List[str] = list(fields)
self.nested: Dict[str, SelectionSet] = nested or {}
[docs]
def add_field(self, field: str) -> "SelectionSet":
"""Add a scalar field to the selection."""
if field not in self.fields:
self.fields.append(field)
return self
[docs]
def add_nested(self, name: str, selection: "SelectionSet") -> "SelectionSet":
"""Add a nested selection."""
self.nested[name] = selection
return self
[docs]
def merge(self, other: "SelectionSet") -> "SelectionSet":
"""
Merge another SelectionSet into this one.
Args:
other: SelectionSet to merge
Returns:
Self for chaining
"""
for f in other.fields:
if f not in self.fields:
self.fields.append(f)
for name, selection in other.nested.items():
if name in self.nested:
self.nested[name].merge(selection)
else:
self.nested[name] = selection
return self
[docs]
def build(self, indent: int = 0) -> str:
"""
Build the selection set string.
Args:
indent: Current indentation level (for formatting)
Returns:
GraphQL selection set string
"""
if not self.fields and not self.nested:
return ""
parts = []
# Add scalar fields
parts.extend(self.fields)
# Add nested selections
for name, selection in self.nested.items():
nested_str = selection.build(indent + 1)
if nested_str:
parts.append(f"{name} {{ {nested_str} }}")
else:
parts.append(name)
return " ".join(parts)
def __repr__(self) -> str:
return f"SelectionSet(fields={self.fields}, nested={list(self.nested.keys())})"
# ==============================================================================================================
# SelectionBuilder — fluent API for building selections
# ==============================================================================================================
[docs]
class SelectionBuilder:
"""
Builder for constructing selection sets with a fluent API.
This is used internally by OperationBuilder but can also be used standalone.
Supports directives on nested fields:
builder.nested("addMetadata", args={"metadata": "$metadata"})
.include_if("$includeMetadata")
.select("entityType")
.end()
"""
[docs]
def __init__(self, parent: Optional["OperationBuilder"] = None, field_name: Optional[str] = None, args: Optional[Dict[str, Any]] = None):
"""
Initialize a SelectionBuilder.
Args:
parent: Parent builder (for .end() navigation)
field_name: Name of the field this selection is for
args: Arguments for this field (if it's a nested operation)
"""
self._parent = parent
self._field_name = field_name
self._args = args or {}
self._directives: List[Directive] = []
self._fields: List[str] = []
self._nested: Dict[str, "SelectionBuilder"] = {}
self._nested_order: List[str] = [] # Preserve insertion order
[docs]
def select(self, *fields: str) -> "SelectionBuilder":
"""
Add scalar fields to the selection.
Args:
*fields: Field names to select
Returns:
Self for chaining
"""
for f in fields:
if f not in self._fields:
self._fields.append(f)
return self
[docs]
def nested(self, field_name: str, args: Optional[Dict[str, Any]] = None) -> "SelectionBuilder":
"""
Add a nested field selection.
Args:
field_name: Name of the nested field
args: Arguments for the nested field (for operations like linkParticipants)
Returns:
New SelectionBuilder for the nested field
"""
nested_builder = SelectionBuilder(parent=self, field_name=field_name, args=args)
self._nested[field_name] = nested_builder
if field_name not in self._nested_order:
self._nested_order.append(field_name)
return nested_builder
[docs]
def use(self, selection: SelectionSet) -> "SelectionBuilder":
"""
Apply a predefined SelectionSet to this builder.
Args:
selection: SelectionSet to apply
Returns:
Self for chaining
"""
# Add scalar fields
for f in selection.fields:
if f not in self._fields:
self._fields.append(f)
# Add nested selections
for name, nested_selection in selection.nested.items():
if name not in self._nested:
nested_builder = SelectionBuilder(parent=self, field_name=name)
self._nested[name] = nested_builder
self._nested_order.append(name)
self._nested[name].use(nested_selection)
return self
# ==================================
# Directive Support
# ==================================
[docs]
def directive(self, name: str, args: Optional[Dict[str, Any]] = None) -> "SelectionBuilder":
"""
Add a directive to this field.
This is the generic method for adding any GraphQL directive.
Args:
name: Directive name (without @)
args: Directive arguments
Returns:
Self for chaining
Example:
.nested("addMetadata", args={"metadata": "$metadata"})
.directive("include", {"if": "$includeMetadata"})
.select("entityType")
.end()
.nested("someField")
.directive("cacheControl", {"maxAge": 30})
.select("id")
.end()
"""
self._directives.append(Directive(name, args))
return self
[docs]
def include_if(self, variable: str) -> "SelectionBuilder":
"""
Add @include(if: $variable) directive to this field.
The field and its selections will only be included in the response
when the variable evaluates to true.
Args:
variable: Boolean variable name (with or without $ prefix)
Returns:
Self for chaining
Example:
.nested("addMetadata", args={"metadata": "$metadata"})
.include_if("$includeMetadata")
.select("entityType")
.nested("data").select("key", "value").end()
.end()
"""
self._directives.append(Include(variable))
return self
[docs]
def skip_if(self, variable: str) -> "SelectionBuilder":
"""
Add @skip(if: $variable) directive to this field.
The field and its selections will be skipped (excluded from response)
when the variable evaluates to true.
Args:
variable: Boolean variable name (with or without $ prefix)
Returns:
Self for chaining
Example:
.nested("sensitiveData")
.skip_if("$redacted")
.select("ssn", "taxId")
.end()
"""
self._directives.append(Skip(variable))
return self
# ==================================
# Navigation and Building
# ==================================
[docs]
def end(self) -> Union["SelectionBuilder", "OperationBuilder"]:
"""
Return to the parent builder.
Returns:
Parent builder for continued chaining
"""
if self._parent is None:
raise ValueError("Cannot call end() on root builder")
return self._parent
def _format_args(self) -> str:
"""Format arguments for this field."""
if not self._args:
return ""
args_parts = []
for key, value in self._args.items():
if isinstance(value, str) and value.startswith("$"):
args_parts.append(f"{key}: {value}")
elif isinstance(value, str):
args_parts.append(f'{key}: "{value}"')
elif isinstance(value, bool):
args_parts.append(f"{key}: {str(value).lower()}")
elif value is None:
args_parts.append(f"{key}: null")
else:
args_parts.append(f"{key}: {value}")
return f"({', '.join(args_parts)})"
def _format_directives(self) -> str:
"""Format all directives for this field."""
if not self._directives:
return ""
return " " + " ".join(d.build() for d in self._directives)
[docs]
def build(self) -> str:
"""
Build the selection set string.
Returns:
GraphQL selection set string (without outer braces)
"""
parts = []
# Add scalar fields
parts.extend(self._fields)
# Add nested selections in order
for name in self._nested_order:
nested_builder = self._nested[name]
nested_content = nested_builder.build()
args_str = nested_builder._format_args()
directives_str = nested_builder._format_directives()
if nested_content:
parts.append(f"{name}{args_str}{directives_str} {{ {nested_content} }}")
else:
parts.append(f"{name}{args_str}{directives_str}")
return " ".join(parts)
[docs]
def to_selection_set(self) -> SelectionSet:
"""
Convert this builder to a reusable SelectionSet.
Note: Directives are not preserved in SelectionSet (they are execution-time concerns).
Returns:
SelectionSet representation
"""
nested = {}
for name in self._nested_order:
nested[name] = self._nested[name].to_selection_set()
return SelectionSet(*self._fields, nested=nested)