Skip to content

feat(compiler): Introduce ExecutableDocumentBuilder for operations with multiple sources#1017

Open
trevor-scheer wants to merge 9 commits intoapollographql:mainfrom
trevor-scheer:executable_document_builder
Open

feat(compiler): Introduce ExecutableDocumentBuilder for operations with multiple sources#1017
trevor-scheer wants to merge 9 commits intoapollographql:mainfrom
trevor-scheer:executable_document_builder

Conversation

@trevor-scheer
Copy link
Contributor

@trevor-scheer trevor-scheer commented Nov 25, 2025

Note

This PR was almost entirely written by Claude. I've reviewed this extensively myself and included the prompt and implementation plan for posterity.

Details

Prompt

@crates/apollo-compiler/src/executable/from_ast.rs @crates/apollo-compiler/src/schema/from_ast.rs

Create a plan and write to local .claude folder. ExecutableDocument and Schema are quite similar. I need a builder for ExecutableDocuments consisting of multiple sources. Schema already has this concept of schema builder, and you can parse schema AST into a schema builder. I want to be able to parse executable AST into an executable document builder.

ExecutableDocumentBuilder Implementation Plan

Context

Currently, ExecutableDocument is created directly from a single AST document via the document_from_ast function in crates/apollo-compiler/src/executable/from_ast.rs. However, Schema has a more sophisticated builder pattern (SchemaBuilder in crates/apollo-compiler/src/schema/from_ast.rs) that allows building a schema from multiple source files incrementally.

We need to implement an ExecutableDocumentBuilder that mirrors the SchemaBuilder pattern, allowing users to:

  1. Build executable documents from multiple source files
  2. Accumulate operations and fragments across multiple AST documents
  3. Handle collisions and errors gracefully
  4. Preserve source information for all definitions

Key Design Parallels with SchemaBuilder

SchemaBuilder Structure (lines 7-15 of schema/from_ast.rs)

pub struct SchemaBuilder {
    adopt_orphan_extensions: bool,
    ignore_builtin_redefinitions: bool,
    pub(crate) schema: Schema,
    schema_definition: SchemaDefinitionStatus,
    orphan_type_extensions: IndexMap<Name, Vec<ast::Definition>>,
    pub(crate) errors: DiagnosticList,
}

Proposed ExecutableDocumentBuilder Structure

pub struct ExecutableDocumentBuilder {
    pub(crate) document: ExecutableDocument,
    schema: Option<Schema>,  // Optional schema for type checking
    pub(crate) errors: DiagnosticList,
}

Implementation Steps

1. Create ExecutableDocumentBuilder Struct

File: crates/apollo-compiler/src/executable/from_ast.rs

Add the builder struct at the beginning of the file:

  • Store the in-progress ExecutableDocument
  • Store an optional Schema reference for validation during building
  • Store accumulated DiagnosticList
  • NO configuration flags needed (unlike SchemaBuilder which has adopt_orphan_extensions and ignore_builtin_redefinitions)

2. Implement Builder Constructor and Configuration

File: crates/apollo-compiler/src/executable/from_ast.rs

Add methods:

  • new(schema: Option<&Schema>) -> Self - Create a new builder with optional schema
  • Similar to SchemaBuilder::new() which clones built-in types (lines 69-71 of schema/from_ast.rs)

3. Refactor document_from_ast to Use Builder Pattern

File: crates/apollo-compiler/src/executable/from_ast.rs

Current function signature (line 9):

pub(crate) fn document_from_ast(
    schema: Option<&Schema>,
    document: &ast::Document,
    errors: &mut DiagnosticList,
    type_system_definitions_are_errors: bool,
) -> ExecutableDocument

Refactor to:

  • Keep existing function as a convenience wrapper that creates a builder internally
  • Create new ExecutableDocumentBuilder::add_ast_document(&mut self, document: &ast::Document, type_system_definitions_are_errors: bool)
  • Move the document building logic (lines 15-125) into the builder's add_ast_document method

Key differences from current implementation:

  • Instead of returning ExecutableDocument immediately, accumulate into self.document
  • Handle operation name collisions (lines 31-60) by checking self.document.operations
  • Handle fragment name collisions (lines 93-106) by checking self.document.fragments
  • Accumulate source maps into self.document.sources

4. Implement Core Builder Methods

File: crates/apollo-compiler/src/executable/from_ast.rs

Add methods mirroring SchemaBuilder:

a) add_ast_document (similar to lines 106-269 of schema/from_ast.rs)

  • Take &ast::Document and process all executable definitions
  • Accumulate operations into self.document.operations:
    • Named operations go into operations.named
    • Anonymous operations go into operations.anonymous
    • Detect and report collisions
  • Accumulate fragments into self.document.fragments
  • Report type system definitions as errors if type_system_definitions_are_errors is true
  • Merge source maps from document.sources into self.document.sources

b) build (similar to lines 277-280 of schema/from_ast.rs)

pub fn build(self) -> Result<ExecutableDocument, WithErrors<ExecutableDocument>>
  • Call build_inner() and convert errors to result

c) build_inner (similar to lines 282-349 of schema/from_ast.rs)

pub(crate) fn build_inner(self) -> (ExecutableDocument, DiagnosticList)
  • Return the accumulated document and error list
  • NO orphan handling needed (no executable extensions concept)

5. Update ExecutableDocument API

File: crates/apollo-compiler/src/executable/mod.rs

Add a builder() method (similar to lines 439-441 of schema/mod.rs):

pub fn builder(schema: Option<&Valid<Schema>>) -> ExecutableDocumentBuilder {
    ExecutableDocumentBuilder::new(schema)
}

Consider deprecating or updating the existing parse method to internally use the builder.

6. Update Parser Integration

File: crates/apollo-compiler/src/parser.rs

Add new method similar to parse_into_schema_builder:

pub fn parse_into_executable_builder(
    &mut self,
    schema: Option<&Valid<Schema>>,
    source_text: impl Into<String>,
    path: impl AsRef<Path>,
    builder: &mut ExecutableDocumentBuilder,
)

Update parse_executable_inner (lines 261-271) to optionally use the builder pattern.

7. Export Builder in Public API

File: crates/apollo-compiler/src/executable/mod.rs

Add to the public exports (around line 71):

pub use crate::executable::from_ast::ExecutableDocumentBuilder;

8. Add Documentation

Add comprehensive documentation for:

  • ExecutableDocumentBuilder struct
  • All public builder methods
  • Example usage showing multi-file document building
  • Migration guide from document_from_ast to builder pattern

9. Update Tests

File: Create crates/apollo-compiler/src/executable/from_ast_tests.rs or update existing tests

Add tests for:

  • Building document from multiple sources
  • Handling operation name collisions across files
  • Handling fragment name collisions across files
  • Source map preservation across multiple files
  • Mixed anonymous and named operations
  • Type system definitions in executable documents (error cases)

Key Differences from SchemaBuilder

  1. No Extensions: ExecutableDocument doesn't have the concept of extensions like Schema does, so no orphan handling
  2. No Built-ins: No built-in operations or fragments to initialize
  3. Simpler Collision Handling: Only need to track operation and fragment name collisions
  4. Schema is Optional: Schema is only needed for type validation, not for structure

Migration Path

For backwards compatibility:

  1. Keep document_from_ast as a public API
  2. Internally refactor it to use ExecutableDocumentBuilder
  3. Add deprecation notice recommending builder pattern for multi-file use cases
  4. Existing code continues to work unchanged

Benefits

  1. Multi-file Support: Users can build executable documents from multiple GraphQL files
  2. Consistency: Mirrors the SchemaBuilder API pattern users already know
  3. Flexibility: Optional schema allows building with or without type checking
  4. Better Error Handling: Accumulate all errors across files before returning
  5. Source Tracking: Properly track which file each operation/fragment came from

Example Usage

use apollo_compiler::{Schema, ExecutableDocument};

// Parse schema
let schema = Schema::parse_and_validate(schema_sdl, "schema.graphql")?;

// Build executable document from multiple files
let mut builder = ExecutableDocument::builder(Some(&schema));
Parser::new().parse_into_executable_builder(
    Some(&schema),
    query1_source,
    "query1.graphql",
    &mut builder
);
Parser::new().parse_into_executable_builder(
    Some(&schema),
    query2_source,
    "query2.graphql",
    &mut builder
);

let document = builder.build()?;

Files to Modify

  1. crates/apollo-compiler/src/executable/from_ast.rs - Main implementation
  2. crates/apollo-compiler/src/executable/mod.rs - Public API and exports
  3. crates/apollo-compiler/src/parser.rs - Parser integration
  4. Test files - Comprehensive test coverage

Estimated Complexity

  • Low Risk: Pattern is well-established by SchemaBuilder
  • Medium Complexity: ~300-400 lines of new code
  • High Value: Enables multi-file executable document composition

In frontend projects, operation documents are commonly created from multiple sources. Fragment definitions are often treated as global and stitched in by clients.

Example:
Fragment def
Usage

In order to build tooling that accommodates this common use case, we should be able to build ExecutableDocuments from multiple sources. Fortunately, we already accommodate a parallel use case for schema via the SchemaBuilder, so this implementation leans heavily on that pattern and prior art.

This change largely extracts the functionality out of document_from_ast and into ExecutableDocumentBuilder.

Add ExecutableDocumentBuilder following the same pattern as SchemaBuilder,
enabling users to build executable documents from multiple source files.

Key features:
- Build executable documents from multiple GraphQL files
- Proper collision detection for operations and fragments across files
- Optional schema validation during building
- Source tracking for all definitions
- Backwards compatible with existing document_from_ast function

API additions:
- ExecutableDocumentBuilder struct with new(), add_ast_document(), and build() methods
- ExecutableDocument::builder() convenience method
- Parser::parse_into_executable_builder() for multi-file parsing

Example usage:
```rust
let mut builder = ExecutableDocument::builder(Some(&schema));
Parser::new().parse_into_executable_builder(
    Some(&schema),
    "query GetUser { user { id } }",
    "query1.graphql",
    &mut builder,
);
Parser::new().parse_into_executable_builder(
    Some(&schema),
    "query GetPost { post { title } }",
    "query2.graphql",
    &mut builder,
);
let document = builder.build().unwrap();
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@trevor-scheer trevor-scheer requested a review from a team as a code owner November 25, 2025 15:19
Add 8 new tests covering:
- Building documents from multiple files
- Combining queries and fragments from separate files
- Operation name collision detection across files
- Fragment name collision detection across files
- Building without a schema (validation-free mode)
- Source information preservation
- Anonymous/named operation mixing (error case)
- Multiple fragments in single query

All tests pass successfully, verifying the builder handles
multi-file scenarios correctly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@trevor-scheer trevor-scheer force-pushed the executable_document_builder branch from e830b14 to 04b9622 Compare November 25, 2025 15:24
Copy link
Contributor

@tninesling tninesling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking good, I just have a few suggestions.

pub fn new(schema: Option<&Schema>) -> Self {
Self {
document: ExecutableDocument::new(),
schema: schema.cloned(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the APIs where this is used take a reference to the schema, so we should be able to avoid cloning this. Avoiding the clone is particularly important when working with very large schemas. Can you update the builder to hold a Option<&Schema> instead of an owned one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! Just noting this adds a lifetime to the builder but agree this is preferable.
f71a00e

match definition {
ast::Definition::OperationDefinition(operation) => {
if let Some(name) = &operation.name {
// Named operation
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: There are a lot of comments like "Named operation" or "overwritten" that aren't totally clear. In this case, I feels it's confusing to label this "Named operation" when we're specifically looking for an anonymous operation. It might be worth clearing up those comments.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few of these are stray adds, a few are preserved from the lifted and shifted document_from_ast fn. I've gone through and preserved what was there before and tidied up the other bits. Happy to revisit and clean up the rest if you like, this pass was to minimize the footprint.
6da8915

let mut builder = ExecutableDocumentBuilder {
document: ExecutableDocument::new(),
schema: schema.cloned(),
errors: std::mem::replace(errors, DiagnosticList::new(Default::default())),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern of replacing the input with default and then writing back to the mutable reference with the same data seems odd to me. It seems like it would make more sense to let the builder borrow the mutable reference and append to the diagnostic list as necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this could be a take instead if that sits better (I think it's the same as what's written though).
I took your suggestion and made this a mutable reference though. The API for the builder is a bit less nice but I don't feel strongly either way. Let me know what you think! 8296091

/// [`ExecutableDocumentBuilder::build`].
pub fn parse_into_executable_builder(
&mut self,
_schema: Option<&Valid<Schema>>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this parameter intended to be used? If not, we can remove this, since it's a net-new API.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this ended up in the builder and unneeded 👍
95cb501

let query2 = "query GetPost { post { id title } }";

let mut builder = ExecutableDocument::builder(Some(&schema));
Parser::new().parse_into_executable_builder(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like we're missing an ExecutableDocumentBuilder::parse() function equivalent to the one on SchemaBuilder. The SchemaBuilder one just delegates to the equivalent Parser::parse_into_schema_builder(). I think that would be a more fluent interface so the end-user doesn't have to construct these ephemeral Parser instances themselves.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's nice.
d7c9f75

}

#[test]
fn builder_detects_operation_name_collision() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add another test which asserts this properly collects diagnostics from multiple sources? I want to make sure that diagnostic rewriting logic doesn't accidentally wipe previous diagnostics.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@trevor-scheer
Copy link
Contributor Author

Thanks for the speedy review @tninesling! And nice to see ya, so to speak 😄

@trevor-scheer
Copy link
Contributor Author

@tninesling I spotted this issue comment from @lrlna this morning - we should see if they'd prefer this not land.

Copy link
Contributor

@tninesling tninesling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a nice ergonomic improvement to the API, and the changes look good with the recent adjustments. I don't think @lrlna has had a chance to take a detailed look, and it looks like they're out until next week. Feel free to add them as a reviewer if you're looking for their specific feedback, but from my perspective, this is good to go.

@trevor-scheer
Copy link
Contributor Author

@tninesling Ultimately your call. I'm happy for this to land but also I'm completely unblocked on using this, just building against my branch for now. I'd err on hearing their input in case they had any particular hesitations or alternatives in mind!

@trevor-scheer
Copy link
Contributor Author

@tninesling i don't have the perms to add reviewers, would you mind adding them, or bring this to their attention?

A happy middle ground option: introduce this API as experimental/feature-gated until @lrlna has the opportunity to provide their feedback.

I'm still in no rush, but just don't want this to go by the wayside forever either. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants