Mutual Exclusivity Validation
Input Validation Patterns
Section titled “Input Validation Patterns”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
@oneOfdirective) - 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.
Transport Agnosticism
Section titled “Transport Agnosticism”Mutual exclusivity validation is transport-agnostic — it runs in the Executor before the response is serialized for any transport.
Validation Benefits
Section titled “Validation Benefits”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.
Approach 1: Separate Mutations (Clearest)
Section titled “Approach 1: Separate Mutations (Clearest)”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 fraiseqlfrom fraiseql.scalars import ID
@fraiseql.typeclass 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.""" passApproach 2: SQL CHECK Constraints
Section titled “Approach 2: SQL CHECK Constraints”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_responseLANGUAGE 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 fraiseqlfrom fraiseql.scalars import ID
@fraiseql.inputclass 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.typeclass 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.""" passTry It Yourself
Section titled “Try It Yourself”Execute mutations with different validation patterns below:
Use Cases
Section titled “Use Cases”E-Commerce: Product Creation
Section titled “E-Commerce: Product Creation”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.""" passB2B: Enterprise vs. Standard Customer
Section titled “B2B: Enterprise vs. Standard Customer”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) ));Travel Booking: Flexible Destinations
Section titled “Travel Booking: Flexible Destinations”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 ));Error Messages
Section titled “Error Messages”When the Rust engine’s built-in validators reject a request, they return clear, actionable messages:
| Validator | Error Message |
|---|---|
| OneOf | Exactly one of [field1, field2] must be provided, but N were provided |
| AnyOf | At least one of [field1, field2] must be provided |
| ConditionalRequired | Since 'field' is provided, 'requiredField1', 'requiredField2' must also be provided |
| RequiredIfAbsent | Since '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" } }}Performance Notes
Section titled “Performance Notes”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
API Reference (Rust)
Section titled “API Reference (Rust)”These validators are used internally by the FraiseQL Rust engine. They are documented here for contributors and Rust integration authors.
OneOfValidator
Section titled “OneOfValidator”pub struct OneOfValidator;
impl OneOfValidator { pub fn validate( input: &Value, field_names: &[String], context_path: Option<&str>, ) -> Result<()>}AnyOfValidator
Section titled “AnyOfValidator”pub struct AnyOfValidator;
impl AnyOfValidator { pub fn validate( input: &Value, field_names: &[String], context_path: Option<&str>, ) -> Result<()>}ConditionalRequiredValidator
Section titled “ConditionalRequiredValidator”pub struct ConditionalRequiredValidator;
impl ConditionalRequiredValidator { pub fn validate( input: &Value, if_field_present: &str, then_required: &[String], context_path: Option<&str>, ) -> Result<()>}RequiredIfAbsentValidator
Section titled “RequiredIfAbsentValidator”pub struct RequiredIfAbsentValidator;
impl RequiredIfAbsentValidator { pub fn validate( input: &Value, absent_field: &str, then_required: &[String], context_path: Option<&str>, ) -> Result<()>}Migration from Custom Logic
Section titled “Migration from Custom Logic”If you have custom validation code in a resolver-based framework, here is the recommended mapping for FraiseQL:
Before: Custom JavaScript resolver
Section titled “Before: Custom JavaScript resolver”// Apollo Server — manual validation in resolverfunction 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"); }}After: SQL CHECK constraint in FraiseQL
Section titled “After: SQL CHECK constraint in FraiseQL”-- Enforce the constraint at the database levelALTER TABLE tb_checkoutADD 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.
Next Steps
Section titled “Next Steps”- Validation Rules Reference - Complete rule documentation
- Error Handling - Handle validation errors gracefully
- Schema Design - Designing inputs for your use cases