Skip to content

Mutual Exclusivity Validation

FraiseQL provides four validators for handling complex field relationships and conditional requirements in GraphQL inputs. These validators run in the Rust engine at request time, before any SQL executes, eliminating entire classes of bugs that plague traditional GraphQL frameworks.

  • OneOf: Exactly one field from a set must be provided (mirrors the GraphQL @oneOf directive)
  • AnyOf: At least one field from a set must be provided (FraiseQL exclusive)
  • ConditionalRequired: If one field is present, others must be too (FraiseQL exclusive)
  • RequiredIfAbsent: If one field is absent, others must be provided (FraiseQL exclusive)

Three of these patterns are not part of the GraphQL specification — they enable validation patterns impossible with standard @oneOf.

Mutual exclusivity validation is transport-agnostic — it runs in the Executor before the response is serialized for any transport.

Because these validators run before SQL execution, you get:

  • No bad data reaches the database — invalid inputs are rejected at the GraphQL layer
  • Clear error messages — specific, actionable messages suitable for UI display
  • Zero SQL overhead — failed validations never generate a SQL query
  • Better developer experience — errors appear at the client, not buried in database logs

Expressing Mutual Exclusivity in Your Schema

Section titled “Expressing Mutual Exclusivity in Your Schema”

The recommended approach for mutual exclusivity constraints in a FraiseQL application is to design them into your GraphQL input types and enforce them in SQL.

Instead of a single mutation with optional exclusive fields, define separate mutations for each case:

# Instead of one mutation with confusing exclusive options...
# createPost(input: CreatePostInput) # where authorId XOR authorPayload
# Use two clearly-named mutations:
mutation CreatePostByAuthor($authorId: ID!, $title: String!, $content: String!) {
createPostByAuthor(authorId: $authorId, title: $title, content: $content) {
id
title
}
}
mutation CreatePostWithNewAuthor($authorName: String!, $title: String!, $content: String!) {
createPostWithNewAuthor(authorName: $authorName, title: $title, content: $content) {
id
title
}
}
import fraiseql
from fraiseql.scalars import ID
@fraiseql.type
class Post:
id: ID
title: str
content: str
@fraiseql.mutation(sql_source="fn_create_post_by_author", operation="CREATE")
def create_post_by_author(author_id: ID, title: str, content: str) -> Post:
"""Create a post linked to an existing author."""
pass
@fraiseql.mutation(sql_source="fn_create_post_with_new_author", operation="CREATE")
def create_post_with_new_author(author_name: str, title: str, content: str) -> Post:
"""Create a post and create its author in one step."""
pass

For exclusive fields that must coexist in one mutation, enforce the constraint in PostgreSQL:

CREATE TABLE tb_post (
pk_post BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
identifier TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
-- Mutual exclusivity: either fk_author or author_name, never both, never neither
fk_author BIGINT REFERENCES tb_user(pk_user),
author_name TEXT,
CONSTRAINT chk_post_author_xor CHECK (
(fk_author IS NOT NULL AND author_name IS NULL)
OR
(fk_author IS NULL AND author_name IS NOT NULL)
)
);

The mutation function enforces the constraint at the database level:

CREATE FUNCTION fn_create_post(
p_title TEXT,
p_content TEXT,
p_author_id UUID DEFAULT NULL,
p_author_name TEXT DEFAULT NULL
) RETURNS mutation_response
LANGUAGE plpgsql AS $$
DECLARE
v_fk_author BIGINT;
v_id UUID := gen_random_uuid();
v_identifier TEXT;
BEGIN
-- Resolve author ID to internal FK if provided
IF p_author_id IS NOT NULL THEN
SELECT pk_user INTO v_fk_author FROM tb_user WHERE id = p_author_id;
IF NOT FOUND THEN
RETURN ROW('failed:not_found', 'Author not found', NULL, 'Post', NULL, NULL, NULL, NULL)::mutation_response;
END IF;
END IF;
v_identifier := 'post-' || v_id::text;
INSERT INTO tb_post (id, identifier, title, content, fk_author, author_name)
VALUES (v_id, v_identifier, p_title, p_content, v_fk_author, p_author_name);
-- CHECK constraint fires here; PostgreSQL raises an error if violated
RETURN ROW('success', 'Post created', v_id, 'Post', NULL, NULL, NULL, NULL)::mutation_response;
EXCEPTION
WHEN check_violation THEN
RETURN ROW('failed:validation', 'Provide either authorId or authorName, not both', NULL, 'Post', NULL, NULL, NULL, NULL)::mutation_response;
END;
$$;

Approach 3: Address Reference vs. Inline Address

Section titled “Approach 3: Address Reference vs. Inline Address”

A common “required if absent” pattern — provide either a saved address ID or full address details:

CREATE TABLE tb_order (
pk_order BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
identifier TEXT UNIQUE NOT NULL,
fk_address BIGINT REFERENCES tb_address(pk_address),
street TEXT,
city TEXT,
state TEXT,
zip TEXT,
CONSTRAINT chk_order_address CHECK (
fk_address IS NOT NULL
OR (street IS NOT NULL AND city IS NOT NULL AND state IS NOT NULL AND zip IS NOT NULL)
)
);
import fraiseql
from fraiseql.scalars import ID
@fraiseql.input
class CreateOrderInput:
items: list[str]
address_id: ID | None = None # Reference an existing saved address
street: str | None = None # OR provide address details inline
city: str | None = None
state: str | None = None
zip: str | None = None
@fraiseql.type
class Order:
id: ID
identifier: str
@fraiseql.mutation(sql_source="fn_create_order", operation="CREATE")
def create_order(input: CreateOrderInput) -> Order:
"""Create an order with either a saved address or inline address details."""
pass

Execute mutations with different validation patterns below:

Mutual Exclusivity Validation Example

Try submitting valid and invalid input to see validation in action. Modify the mutation to test different validator patterns!

Loading Apollo Sandbox...

This sandbox uses Apollo Sandbox (the same GraphQL IDE as fraiseql serve). Your queries execute against the endpoint below. No data is sent to Apollo. Learn more about privacy →

Products can be created by uploading an image from a URL or from a local file, but not both. Define two mutations:

@fraiseql.mutation(sql_source="fn_create_product_from_url", operation="CREATE")
def create_product_from_url(name: str, price: float, image_url: str) -> Product:
"""Create a product with an image fetched from a URL."""
pass
@fraiseql.mutation(sql_source="fn_create_product_with_upload", operation="CREATE")
def create_product_with_upload(name: str, price: float, image_upload_id: ID) -> Product:
"""Create a product with a previously uploaded image."""
pass

Different required fields for enterprise versus standard customers — use a SQL CHECK constraint:

CREATE TABLE tb_customer (
pk_customer BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
identifier TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
is_enterprise BOOLEAN NOT NULL DEFAULT false,
tax_id TEXT,
legal_address TEXT,
contract_date DATE,
CONSTRAINT chk_customer_enterprise CHECK (
NOT is_enterprise
OR (tax_id IS NOT NULL AND legal_address IS NOT NULL AND contract_date IS NOT NULL)
)
);

Travelers provide a specific city, a region, or mark themselves as flexible — at least one is required:

CREATE TABLE tb_trip (
pk_trip BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID DEFAULT gen_random_uuid() UNIQUE NOT NULL,
identifier TEXT UNIQUE NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
fk_city BIGINT REFERENCES tb_city(pk_city),
fk_region BIGINT REFERENCES tb_region(pk_region),
is_flexible BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT chk_trip_destination CHECK (
fk_city IS NOT NULL OR fk_region IS NOT NULL OR is_flexible
)
);

When the Rust engine’s built-in validators reject a request, they return clear, actionable messages:

ValidatorError Message
OneOfExactly one of [field1, field2] must be provided, but N were provided
AnyOfAt least one of [field1, field2] must be provided
ConditionalRequiredSince 'field' is provided, 'requiredField1', 'requiredField2' must also be provided
RequiredIfAbsentSince 'field' is not provided, 'requiredField1', 'requiredField2' must be provided

When a SQL CHECK constraint is violated, the mutation function returns a structured error via mutation_response:

{
"data": {
"createPost": {
"status": "failed:validation",
"message": "Provide either authorId or authorName, not both"
}
}
}

All validators are:

  • O(n) where n = number of fields in the constraint
  • Evaluated before SQL execution (fail fast)
  • Zero overhead for non-constrained fields
  • Suitable for high-throughput APIs

These validators are used internally by the FraiseQL Rust engine. They are documented here for contributors and Rust integration authors.

pub struct OneOfValidator;
impl OneOfValidator {
pub fn validate(
input: &Value,
field_names: &[String],
context_path: Option<&str>,
) -> Result<()>
}
pub struct AnyOfValidator;
impl AnyOfValidator {
pub fn validate(
input: &Value,
field_names: &[String],
context_path: Option<&str>,
) -> Result<()>
}
pub struct ConditionalRequiredValidator;
impl ConditionalRequiredValidator {
pub fn validate(
input: &Value,
if_field_present: &str,
then_required: &[String],
context_path: Option<&str>,
) -> Result<()>
}
pub struct RequiredIfAbsentValidator;
impl RequiredIfAbsentValidator {
pub fn validate(
input: &Value,
absent_field: &str,
then_required: &[String],
context_path: Option<&str>,
) -> Result<()>
}

If you have custom validation code in a resolver-based framework, here is the recommended mapping for FraiseQL:

// Apollo Server — manual validation in resolver
function validateCheckout(input) {
if (input.isPremium && !input.paymentMethod) {
throw new Error("Payment method required for premium");
}
if (input.isPremium && !input.billingAddress) {
throw new Error("Billing address required for premium");
}
}
-- Enforce the constraint at the database level
ALTER TABLE tb_checkout
ADD CONSTRAINT chk_checkout_premium CHECK (
NOT is_premium
OR (payment_method IS NOT NULL AND billing_address IS NOT NULL)
);

The mutation function returns a structured error via mutation_response when the constraint is violated, which FraiseQL surfaces to the client cleanly.