diff --git a/crates/libgraphql-core-v1/src/lib.rs b/crates/libgraphql-core-v1/src/lib.rs index 4375d55..1fc3731 100644 --- a/crates/libgraphql-core-v1/src/lib.rs +++ b/crates/libgraphql-core-v1/src/lib.rs @@ -34,6 +34,7 @@ pub mod directive_annotation; pub mod error_note; pub mod located; pub mod names; +pub mod operation_kind; pub mod schema; pub mod schema_source_map; pub mod span; @@ -43,6 +44,7 @@ pub(crate) mod validators; pub mod value; pub use crate::located::Located; +pub use crate::operation_kind::OperationKind; pub use crate::schema_source_map::LineCol; pub use crate::schema_source_map::SchemaSourceMap; pub use crate::span::Span; diff --git a/crates/libgraphql-core-v1/src/operation_kind.rs b/crates/libgraphql-core-v1/src/operation_kind.rs new file mode 100644 index 0000000..7cfb35d --- /dev/null +++ b/crates/libgraphql-core-v1/src/operation_kind.rs @@ -0,0 +1,30 @@ +/// The kind of GraphQL operation. +/// +/// See [Operations](https://spec.graphql.org/September2025/#sec-Language.Operations). +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[derive(serde::Deserialize, serde::Serialize)] +pub enum OperationKind { + Mutation, + Query, + Subscription, +} + +impl From for OperationKind { + fn from(kind: libgraphql_parser::ast::OperationKind) -> Self { + match kind { + libgraphql_parser::ast::OperationKind::Mutation => Self::Mutation, + libgraphql_parser::ast::OperationKind::Query => Self::Query, + libgraphql_parser::ast::OperationKind::Subscription => Self::Subscription, + } + } +} + +impl std::fmt::Display for OperationKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Mutation => "mutation", + Self::Query => "query", + Self::Subscription => "subscription", + }) + } +} diff --git a/crates/libgraphql-core-v1/src/schema/schema_build_error.rs b/crates/libgraphql-core-v1/src/schema/schema_build_error.rs index 0fdc7d8..a825d3a 100644 --- a/crates/libgraphql-core-v1/src/schema/schema_build_error.rs +++ b/crates/libgraphql-core-v1/src/schema/schema_build_error.rs @@ -1,4 +1,5 @@ use crate::error_note::ErrorNote; +use crate::operation_kind::OperationKind; use crate::schema::type_validation_error::TypeValidationError; use crate::span::Span; @@ -95,7 +96,7 @@ pub enum SchemaBuildErrorKind { (already bound to `{type_name}`)" )] DuplicateOperationDefinition { - operation: String, + operation: OperationKind, type_name: String, }, @@ -214,7 +215,7 @@ pub enum SchemaBuildErrorKind { #[error("root {operation} type `{type_name}` is not defined")] RootOperationTypeNotDefined { - operation: String, + operation: OperationKind, type_name: String, }, @@ -224,7 +225,7 @@ pub enum SchemaBuildErrorKind { )] RootOperationTypeNotObjectType { actual_kind: crate::types::GraphQLTypeKind, - operation: String, + operation: OperationKind, type_name: String, }, diff --git a/crates/libgraphql-core-v1/src/schema/schema_builder.rs b/crates/libgraphql-core-v1/src/schema/schema_builder.rs index 24400a8..5ba4dc5 100644 --- a/crates/libgraphql-core-v1/src/schema/schema_builder.rs +++ b/crates/libgraphql-core-v1/src/schema/schema_builder.rs @@ -3,9 +3,10 @@ use crate::error_note::ErrorNoteKind; use crate::names::DirectiveName; use crate::names::FieldName; use crate::names::TypeName; -use crate::schema::schema_def::Schema; +use crate::operation_kind::OperationKind; use crate::schema::schema_build_error::SchemaBuildError; use crate::schema::schema_build_error::SchemaBuildErrorKind; +use crate::schema::schema_def::Schema; use crate::schema::schema_errors::SchemaErrors; use crate::schema_source_map::SchemaSourceMap; use crate::span::SourceMapId; @@ -28,6 +29,10 @@ use crate::types::ParameterDefinition; use crate::types::ScalarKind; use crate::types::ScalarType; use crate::types::TypeAnnotation; +use crate::validators::InputObjectTypeValidator; +use crate::validators::ObjectOrInterfaceTypeValidator; +use crate::validators::UnionTypeValidator; +use crate::validators::validate_directive_definitions; use crate::value::Value; use indexmap::IndexMap; use libgraphql_parser::ast; @@ -531,27 +536,24 @@ impl SchemaBuilder { let span = ast_helpers::span_from_ast( root_op.span, source_map_id, ); - let slot = match root_op.operation_kind { - ast::OperationKind::Query => { + let operation: OperationKind = + root_op.operation_kind.into(); + let slot = match operation { + OperationKind::Query => { &mut self.query_type_name }, - ast::OperationKind::Mutation => { + OperationKind::Mutation => { &mut self.mutation_type_name }, - ast::OperationKind::Subscription => { + OperationKind::Subscription => { &mut self.subscription_type_name }, }; - let op_str = match root_op.operation_kind { - ast::OperationKind::Query => "query", - ast::OperationKind::Mutation => "mutation", - ast::OperationKind::Subscription => "subscription", - }; if let Some((existing_name, existing_span)) = slot { // https://spec.graphql.org/September2025/#sec-Root-Operation-Types self.errors.push(SchemaBuildError::new( SchemaBuildErrorKind::DuplicateOperationDefinition { - operation: op_str.to_string(), + operation, type_name: existing_name.to_string(), }, span, @@ -568,13 +570,208 @@ impl SchemaBuilder { } } - /// Validates and finalizes the schema. Placeholder for - /// Task 16. + /// Validates and finalizes the schema. + /// + /// Resolves root operation types, validates all type and + /// directive definitions, and returns an immutable [`Schema`] + /// on success. On failure, returns a [`SchemaErrors`] + /// containing all accumulated errors. + /// + /// # Validation phases + /// + /// 1. **Root query type resolution** -- uses the explicit + /// `schema { query: ... }` binding if present, otherwise + /// defaults to `"Query"` per the + /// [spec](https://spec.graphql.org/September2025/#sec-Root-Operation-Types). + /// 2. **Root type validation** -- ensures query, mutation, and + /// subscription root types exist and are object types. + /// 3. **Empty type checks** -- rejects object/interface types + /// with no fields, unions with no members, and enums with + /// no values. + /// 4. **Type-system validators** -- runs + /// [`ObjectOrInterfaceTypeValidator`], + /// [`UnionTypeValidator`], + /// [`InputObjectTypeValidator`], and + /// [`validate_directive_definitions`] to enforce + /// cross-type reference rules. + /// + /// See [Schema](https://spec.graphql.org/September2025/#sec-Schema). // TODO: SchemaErrors wraps Vec which is // large. Consider boxing once error strategy is finalized. #[allow(clippy::result_large_err)] - pub fn build(self) -> Result { - todo!() + pub fn build(mut self) -> Result { + // Step 1: Resolve root query type name. + // + // If an explicit `schema { query: ... }` was provided, use + // that binding. Otherwise, default to "Query" per the spec: + // https://spec.graphql.org/September2025/#sec-Root-Operation-Types + let query_type_name = match &self.query_type_name { + Some((name, _)) => name.clone(), + None => TypeName::new("Query"), + }; + + if !self.types.contains_key(&query_type_name) { + self.errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::NoQueryOperationTypeDefined, + self.query_type_name + .as_ref() + .map(|(_, span)| *span) + .unwrap_or(Span::builtin()), + vec![], + )); + } + + // Step 2: Validate root types are object types. + // + // Clone names/spans up front to avoid borrowing `self` + // immutably while calling `validate_root_type` mutably. + let query_span = self.query_type_name + .as_ref() + .map(|(_, span)| *span) + .unwrap_or(Span::builtin()); + let mutation_binding = self.mutation_type_name + .as_ref() + .map(|(name, span)| (name.clone(), *span)); + let subscription_binding = self.subscription_type_name + .as_ref() + .map(|(name, span)| (name.clone(), *span)); + + self.validate_root_type( + OperationKind::Query, Some(&query_type_name), query_span, + ); + if let Some((ref name, span)) = mutation_binding { + self.validate_root_type( + OperationKind::Mutation, Some(name), span, + ); + } + if let Some((ref name, span)) = subscription_binding { + self.validate_root_type( + OperationKind::Subscription, Some(name), span, + ); + } + + // Step 3: Check for empty types (build-level checks). + for graphql_type in self.types.values() { + match graphql_type { + GraphQLType::Object(obj) => { + if obj.fields().is_empty() { + self.errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::EmptyObjectOrInterfaceType { + type_kind: graphql_type.type_kind(), + type_name: obj.name().to_string(), + }, + obj.span(), + vec![], + )); + } + }, + GraphQLType::Interface(iface) => { + if iface.fields().is_empty() { + self.errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::EmptyObjectOrInterfaceType { + type_kind: graphql_type.type_kind(), + type_name: iface.name().to_string(), + }, + iface.span(), + vec![], + )); + } + }, + GraphQLType::Union(union_t) => { + if union_t.members().is_empty() { + self.errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::EmptyUnionType { + type_name: union_t.name().to_string(), + }, + union_t.span(), + vec![], + )); + } + }, + GraphQLType::Enum(enum_t) => { + if enum_t.values().is_empty() { + self.errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::EnumWithNoValues { + type_name: enum_t.name().to_string(), + }, + enum_t.span(), + vec![], + )); + } + }, + _ => {}, + } + } + + // Step 4: Run validators. + let mut validation_errors = Vec::new(); + + for graphql_type in self.types.values() { + match graphql_type { + GraphQLType::Object(obj) => { + let errs = ObjectOrInterfaceTypeValidator::new( + obj.as_ref(), + &self.types, + ).validate(); + validation_errors.extend(errs); + }, + GraphQLType::Interface(iface) => { + let errs = ObjectOrInterfaceTypeValidator::new( + iface.as_ref(), + &self.types, + ).validate(); + validation_errors.extend(errs); + }, + GraphQLType::Union(union_t) => { + let errs = UnionTypeValidator::new( + union_t.as_ref(), + &self.types, + ).validate(); + validation_errors.extend(errs); + }, + GraphQLType::InputObject(input_obj) => { + let errs = InputObjectTypeValidator::new( + input_obj.as_ref(), + &self.types, + ).validate(); + validation_errors.extend(errs); + }, + _ => {}, + } + } + + // Validate directive definitions. + let directive_errs = validate_directive_definitions( + &self.directive_defs, + &self.types, + ); + validation_errors.extend(directive_errs); + + // Wrap TypeValidationErrors into SchemaBuildErrors. + for tve in validation_errors { + let span = tve.span(); + self.errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::TypeValidation(tve), + span, + vec![], + )); + } + + // Step 5: Return result. + if !self.errors.is_empty() { + return Err(SchemaErrors::new(self.errors)); + } + + Ok(Schema { + directive_defs: self.directive_defs, + mutation_type_name: self.mutation_type_name + .map(|(name, _)| name), + query_type_name, + source_maps: self.source_maps, + subscription_type_name: self.subscription_type_name + .map(|(name, _)| name), + types: self.types, + }) } /// Convenience: parse a schema string and build in one step. @@ -589,6 +786,51 @@ impl SchemaBuilder { sb.build() } + // --------------------------------------------------------- + // Root type validation helper + // --------------------------------------------------------- + + /// Validates that a root operation type (if it exists in the + /// type map) is an object type. Emits + /// `RootOperationTypeNotDefined` (for mutation/subscription + /// only -- query uses `NoQueryOperationTypeDefined` instead) + /// or `RootOperationTypeNotObjectType`. + fn validate_root_type( + &mut self, + operation: OperationKind, + type_name: Option<&TypeName>, + span: Span, + ) { + let Some(name) = type_name else { return }; + let Some(graphql_type) = self.types.get(name) else { + // Only emit RootOperationTypeNotDefined for + // mutation/subscription. Query missing is handled + // separately via NoQueryOperationTypeDefined. + if operation != OperationKind::Query { + self.errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::RootOperationTypeNotDefined { + operation, + type_name: name.to_string(), + }, + span, + vec![], + )); + } + return; + }; + if !matches!(graphql_type, GraphQLType::Object(_)) { + self.errors.push(SchemaBuildError::new( + SchemaBuildErrorKind::RootOperationTypeNotObjectType { + actual_kind: graphql_type.type_kind(), + operation, + type_name: name.to_string(), + }, + span, + vec![], + )); + } + } + // --------------------------------------------------------- // Test accessors // --------------------------------------------------------- @@ -628,7 +870,7 @@ impl SchemaBuilder { } // --------------------------------------------------------- -// Parser span translation helper +// AST conversion helpers // --------------------------------------------------------- /// Translates a parser [`SourceSpan`](libgraphql_parser::SourceSpan) diff --git a/crates/libgraphql-core-v1/src/schema/schema_def.rs b/crates/libgraphql-core-v1/src/schema/schema_def.rs index 2641233..120f044 100644 --- a/crates/libgraphql-core-v1/src/schema/schema_def.rs +++ b/crates/libgraphql-core-v1/src/schema/schema_def.rs @@ -1,9 +1,268 @@ +use crate::names::DirectiveName; +use crate::names::TypeName; +use crate::schema_source_map::SchemaSourceMap; +use crate::types::DirectiveDefinition; +use crate::types::EnumType; +use crate::types::GraphQLType; +use crate::types::InputObjectType; +use crate::types::InterfaceType; +use crate::types::ObjectType; +use crate::types::ScalarType; +use crate::types::UnionType; +use indexmap::IndexMap; + /// A fully validated, immutable GraphQL schema. /// /// Produced by -/// [`SchemaBuilder::build()`](crate::schema::SchemaBuilder::build). -/// Task 16 will provide the full implementation. +/// [`SchemaBuilder::build()`](crate::schema::SchemaBuilder::build) +/// after all type definitions, directive definitions, and schema +/// metadata have been loaded and validated against the +/// [GraphQL specification](https://spec.graphql.org/September2025/#sec-Schema). +/// +/// `Schema` provides typed accessors for looking up types by name +/// and category, querying root operation types, and resolving +/// source locations via stored source maps. +/// +/// # Example +/// +/// ```rust +/// # use libgraphql_core_v1 as libgraphql_core; +/// use libgraphql_core::schema::SchemaBuilder; +/// +/// let schema = SchemaBuilder::build_from_str( +/// "type Query { hello: String }", +/// ).unwrap(); +/// +/// assert_eq!(schema.query_type_name().as_str(), "Query"); +/// assert!(schema.query_type().is_some()); +/// assert!(schema.object_type("Query").is_some()); +/// ``` +/// +/// See [Schema](https://spec.graphql.org/September2025/#sec-Schema). #[derive(Clone, Debug, PartialEq)] +#[derive(serde::Deserialize, serde::Serialize)] pub struct Schema { - _private: (), + pub(crate) directive_defs: IndexMap, + pub(crate) mutation_type_name: Option, + pub(crate) query_type_name: TypeName, + pub(crate) source_maps: Vec, + pub(crate) subscription_type_name: Option, + pub(crate) types: IndexMap, +} + +impl Schema { + // --------------------------------------------------------- + // Generic lookups + // --------------------------------------------------------- + + /// Returns the type with the given name, or `None` if no such + /// type is defined. + /// + /// See [Types](https://spec.graphql.org/September2025/#sec-Types). + pub fn get_type(&self, name: &str) -> Option<&GraphQLType> { + self.types.get(name) + } + + /// Returns the directive definition with the given name, or + /// `None` if no such directive is defined. + /// + /// See [Type System Directives](https://spec.graphql.org/September2025/#sec-Type-System.Directives). + pub fn get_directive(&self, name: &str) -> Option<&DirectiveDefinition> { + self.directive_defs.get(name) + } + + // --------------------------------------------------------- + // Typed lookups + // --------------------------------------------------------- + + /// Returns the named type as an [`ObjectType`], or `None` if + /// not found or not an object type. + /// + /// See [Objects](https://spec.graphql.org/September2025/#sec-Objects). + pub fn object_type(&self, name: &str) -> Option<&ObjectType> { + self.types.get(name).and_then(|t| t.as_object()) + } + + /// Returns the named type as an [`InterfaceType`], or `None` + /// if not found or not an interface type. + /// + /// See [Interfaces](https://spec.graphql.org/September2025/#sec-Interfaces). + pub fn interface_type(&self, name: &str) -> Option<&InterfaceType> { + self.types.get(name).and_then(|t| t.as_interface()) + } + + /// Returns the named type as an [`EnumType`], or `None` if + /// not found or not an enum type. + /// + /// See [Enums](https://spec.graphql.org/September2025/#sec-Enums). + pub fn enum_type(&self, name: &str) -> Option<&EnumType> { + self.types.get(name).and_then(|t| t.as_enum()) + } + + /// Returns the named type as a [`ScalarType`], or `None` if + /// not found or not a scalar type. + /// + /// See [Scalars](https://spec.graphql.org/September2025/#sec-Scalars). + pub fn scalar_type(&self, name: &str) -> Option<&ScalarType> { + self.types.get(name).and_then(|t| t.as_scalar()) + } + + /// Returns the named type as a [`UnionType`], or `None` if + /// not found or not a union type. + /// + /// See [Unions](https://spec.graphql.org/September2025/#sec-Unions). + pub fn union_type(&self, name: &str) -> Option<&UnionType> { + self.types.get(name).and_then(|t| t.as_union()) + } + + /// Returns the named type as an [`InputObjectType`], or + /// `None` if not found or not an input object type. + /// + /// See [Input Objects](https://spec.graphql.org/September2025/#sec-Input-Objects). + pub fn input_object_type(&self, name: &str) -> Option<&InputObjectType> { + self.types.get(name).and_then(|t| t.as_input_object()) + } + + // --------------------------------------------------------- + // Typed iterators + // --------------------------------------------------------- + + /// Returns an iterator over all object types in the schema. + /// + /// See [Objects](https://spec.graphql.org/September2025/#sec-Objects). + pub fn object_types(&self) -> impl Iterator { + self.types.values().filter_map(|t| t.as_object()) + } + + /// Returns an iterator over all interface types in the schema. + /// + /// See [Interfaces](https://spec.graphql.org/September2025/#sec-Interfaces). + pub fn interface_types(&self) -> impl Iterator { + self.types.values().filter_map(|t| t.as_interface()) + } + + /// Returns an iterator over all enum types in the schema. + /// + /// See [Enums](https://spec.graphql.org/September2025/#sec-Enums). + pub fn enum_types(&self) -> impl Iterator { + self.types.values().filter_map(|t| t.as_enum()) + } + + /// Returns all types (objects and interfaces) that declare + /// they implement the given interface name. + /// + /// This performs a linear scan of all types. For schemas with + /// a large number of types where this is called frequently, + /// consider building an index. + /// + /// See [IsValidImplementation](https://spec.graphql.org/September2025/#IsValidImplementation()). + pub fn types_implementing( + &self, + interface_name: &str, + ) -> Vec<&GraphQLType> { + self.types.values().filter(|t| { + match t { + GraphQLType::Object(obj) => { + obj.interfaces().iter().any(|l| { + l.value.as_str() == interface_name + }) + }, + GraphQLType::Interface(iface) => { + iface.interfaces().iter().any(|l| { + l.value.as_str() == interface_name + }) + }, + _ => false, + } + }).collect() + } + + // --------------------------------------------------------- + // Root operation types + // --------------------------------------------------------- + + /// Returns the query root operation object type. + /// + /// Always `Some` for a valid schema -- every schema must + /// define a query root type. + /// + /// See [Root Operation Types](https://spec.graphql.org/September2025/#sec-Root-Operation-Types). + pub fn query_type(&self) -> Option<&ObjectType> { + self.object_type(self.query_type_name.as_str()) + } + + /// Returns the name of the query root operation type. + /// + /// See [Root Operation Types](https://spec.graphql.org/September2025/#sec-Root-Operation-Types). + pub fn query_type_name(&self) -> &TypeName { + &self.query_type_name + } + + /// Returns the mutation root operation object type, or `None` + /// if the schema does not define a mutation type. + /// + /// See [Root Operation Types](https://spec.graphql.org/September2025/#sec-Root-Operation-Types). + pub fn mutation_type(&self) -> Option<&ObjectType> { + self.mutation_type_name + .as_ref() + .and_then(|name| self.object_type(name.as_str())) + } + + /// Returns the name of the mutation root operation type, or + /// `None` if not defined. + /// + /// See [Root Operation Types](https://spec.graphql.org/September2025/#sec-Root-Operation-Types). + pub fn mutation_type_name(&self) -> Option<&TypeName> { + self.mutation_type_name.as_ref() + } + + /// Returns the subscription root operation object type, or + /// `None` if the schema does not define a subscription type. + /// + /// See [Root Operation Types](https://spec.graphql.org/September2025/#sec-Root-Operation-Types). + pub fn subscription_type(&self) -> Option<&ObjectType> { + self.subscription_type_name + .as_ref() + .and_then(|name| self.object_type(name.as_str())) + } + + /// Returns the name of the subscription root operation type, + /// or `None` if not defined. + /// + /// See [Root Operation Types](https://spec.graphql.org/September2025/#sec-Root-Operation-Types). + pub fn subscription_type_name(&self) -> Option<&TypeName> { + self.subscription_type_name.as_ref() + } + + // --------------------------------------------------------- + // Collection accessors + // --------------------------------------------------------- + + /// Returns all types registered in the schema, keyed by name. + /// + /// Includes both user-defined types and the five built-in + /// scalar types. + pub fn types(&self) -> &IndexMap { + &self.types + } + + /// Returns all directive definitions registered in the schema, + /// keyed by name. + /// + /// Includes both user-defined directives and the five built-in + /// directives (`@skip`, `@include`, `@deprecated`, + /// `@specifiedBy`, `@oneOf`). + pub fn directive_defs( + &self, + ) -> &IndexMap { + &self.directive_defs + } + + /// Returns the source maps stored in this schema. + /// + /// Source maps allow resolving byte-offset spans to + /// line/column positions within the original source text. + pub fn source_maps(&self) -> &[SchemaSourceMap] { + &self.source_maps + } } diff --git a/crates/libgraphql-core-v1/src/schema/tests/schema_builder_tests.rs b/crates/libgraphql-core-v1/src/schema/tests/schema_builder_tests.rs index 2aee4a9..9b2bcd5 100644 --- a/crates/libgraphql-core-v1/src/schema/tests/schema_builder_tests.rs +++ b/crates/libgraphql-core-v1/src/schema/tests/schema_builder_tests.rs @@ -1,9 +1,12 @@ use crate::error_note::ErrorNoteKind; use crate::names::TypeName; +use crate::operation_kind::OperationKind; use crate::schema::SchemaBuildErrorKind; use crate::schema::SchemaBuilder; +use crate::schema::TypeValidationErrorKind; use crate::span::Span; use crate::type_builders::ObjectTypeBuilder; +use crate::types::GraphQLTypeKind; use crate::types::ScalarKind; // Verifies that SchemaBuilder::new() pre-seeds the five built-in @@ -445,3 +448,427 @@ fn load_str_parse_error_has_proper_span() { not Span::builtin()", ); } + +// ----------------------------------------------------------- +// SchemaBuilder::build() tests (Task 16) +// ----------------------------------------------------------- + +// Verifies that a minimal schema with just `type Query { ... }` +// builds successfully and that query_type() returns the Query +// object type. This exercises the implicit query type resolution +// path (no explicit `schema { query: ... }` definition). +// +// See https://spec.graphql.org/September2025/#sec-Root-Operation-Types +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_from_str_minimal() { + let schema = SchemaBuilder::build_from_str( + "type Query { hello: String }", + ).unwrap(); + + assert_eq!(schema.query_type_name().as_str(), "Query"); + let query_type = schema.query_type() + .expect("query_type() should return the Query object"); + assert!(query_type.field("hello").is_some()); +} + +// Verifies that a schema containing all six type kinds (object, +// interface, union, enum, scalar, input object) builds +// successfully and that each type is queryable via both the +// generic get_type() and the typed lookup accessors. +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_from_str_with_all_type_kinds() { + let schema = SchemaBuilder::build_from_str( + "type Query { node: Node, search: SearchResult }\n\ + interface Node { id: ID! }\n\ + type User implements Node { id: ID!, name: String }\n\ + union SearchResult = User\n\ + enum Status { ACTIVE INACTIVE }\n\ + scalar DateTime\n\ + input CreateInput { name: String! }", + ).unwrap(); + + // Generic lookup + assert!(schema.get_type("Query").is_some()); + assert!(schema.get_type("Node").is_some()); + assert!(schema.get_type("User").is_some()); + assert!(schema.get_type("SearchResult").is_some()); + assert!(schema.get_type("Status").is_some()); + assert!(schema.get_type("DateTime").is_some()); + assert!(schema.get_type("CreateInput").is_some()); + assert!(schema.get_type("NonExistent").is_none()); + + // Typed lookups + assert!(schema.object_type("Query").is_some()); + assert!(schema.object_type("User").is_some()); + assert!(schema.interface_type("Node").is_some()); + assert!(schema.union_type("SearchResult").is_some()); + assert!(schema.enum_type("Status").is_some()); + assert!(schema.scalar_type("DateTime").is_some()); + assert!(schema.input_object_type("CreateInput").is_some()); + + // Typed lookups return None for wrong category + assert!(schema.object_type("Node").is_none()); + assert!(schema.interface_type("Query").is_none()); +} + +// Verifies that building a schema with no Query type (and no +// explicit schema definition) produces a +// NoQueryOperationTypeDefined error. +// +// See https://spec.graphql.org/September2025/#sec-Root-Operation-Types +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_no_query_type_fails() { + let result = SchemaBuilder::build_from_str( + "type Foo { x: Int }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_no_query = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::NoQueryOperationTypeDefined, + ) + }); + assert!( + has_no_query, + "expected NoQueryOperationTypeDefined error", + ); +} + +// Verifies that binding a root operation type to a non-object +// type (e.g. an enum) produces a RootOperationTypeNotObjectType +// error. +// +// See https://spec.graphql.org/September2025/#sec-Root-Operation-Types +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_root_type_not_object_fails() { + let result = SchemaBuilder::build_from_str( + "schema { query: MyEnum }\n\ + enum MyEnum { A B }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_not_object = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::RootOperationTypeNotObjectType { + actual_kind: GraphQLTypeKind::Enum, + .. + }, + ) + }); + assert!( + has_not_object, + "expected RootOperationTypeNotObjectType error", + ); +} + +// Verifies that an object type with zero fields produces an +// EmptyObjectOrInterfaceType error during build. +// +// Note: the parser may or may not accept `type Foo {}`. We +// construct this scenario programmatically via ObjectTypeBuilder +// to ensure the empty-fields check in build() is exercised +// independently of parser behavior. +// +// See https://spec.graphql.org/September2025/#sec-Objects +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_empty_object_type_fails() { + let mut sb = SchemaBuilder::new(); + // Add empty object type programmatically + let empty_obj = ObjectTypeBuilder::new( + "EmptyObj", Span::dummy(), + ).unwrap(); + sb.absorb_type(empty_obj).unwrap(); + + // Also add a valid Query type so we don't get + // NoQueryOperationTypeDefined. + sb.load_str("type Query { x: Int }").unwrap(); + + let result = sb.build(); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_empty = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::EmptyObjectOrInterfaceType { + type_kind: GraphQLTypeKind::Object, + .. + }, + ) + }); + assert!( + has_empty, + "expected EmptyObjectOrInterfaceType error for object", + ); +} + +// Verifies that implementing a non-existent interface produces +// a TypeValidation error wrapping +// ImplementsUndefinedInterface during build. +// +// See https://spec.graphql.org/September2025/#IsValidImplementation() +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_invalid_interface_impl_fails() { + let result = SchemaBuilder::build_from_str( + "type Query implements NonExistent { x: Int }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_validation_error = errors.errors().iter().any(|e| { + if let SchemaBuildErrorKind::TypeValidation(tve) = e.kind() { + matches!( + tve.kind(), + TypeValidationErrorKind::ImplementsUndefinedInterface { + .. + }, + ) + } else { + false + } + }); + assert!( + has_validation_error, + "expected TypeValidation(ImplementsUndefinedInterface)", + ); +} + +// Verifies that a successfully built schema exposes correct +// typed lookups, iterators, and types_implementing() results. +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_valid_schema_typed_lookups() { + let schema = SchemaBuilder::build_from_str( + "type Query { user: User }\n\ + interface Node { id: ID! }\n\ + type User implements Node { id: ID!, name: String }\n\ + type Post implements Node { id: ID!, title: String }\n\ + enum Role { ADMIN USER }\n\ + input CreateUserInput { name: String! }", + ).unwrap(); + + // object_types iterator + let obj_names: Vec<_> = schema.object_types() + .map(|o| o.name().as_str().to_string()) + .collect(); + assert!(obj_names.contains(&"Query".to_string())); + assert!(obj_names.contains(&"User".to_string())); + assert!(obj_names.contains(&"Post".to_string())); + + // interface_types iterator + let iface_names: Vec<_> = schema.interface_types() + .map(|i| i.name().as_str().to_string()) + .collect(); + assert!(iface_names.contains(&"Node".to_string())); + + // enum_types iterator + let enum_names: Vec<_> = schema.enum_types() + .map(|e| e.name().as_str().to_string()) + .collect(); + assert!(enum_names.contains(&"Role".to_string())); + + // types_implementing + let node_implementors = schema.types_implementing("Node"); + assert_eq!( + node_implementors.len(), + 2, + "User and Post both implement Node", + ); + let implementor_names: Vec<_> = node_implementors.iter() + .map(|t| t.name().to_string()) + .collect(); + assert!(implementor_names.contains(&"User".to_string())); + assert!(implementor_names.contains(&"Post".to_string())); + + // types() and directive_defs() collection accessors + assert!(schema.types().len() > 5); // 5 builtins + user types + assert!(schema.directive_defs().len() >= 5); // 5 builtins + + // source_maps() + assert!( + !schema.source_maps().is_empty(), + "should have at least the builtin source map", + ); +} + +// Verifies that SchemaBuilder::build_from_str() is a +// convenient one-step parse-and-build that produces a valid +// Schema. +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_from_str_convenience() { + let schema = SchemaBuilder::build_from_str( + "type Query { hello: String }", + ).unwrap(); + assert_eq!(schema.query_type_name().as_str(), "Query"); + assert!(schema.query_type().is_some()); +} + +// Verifies that build() correctly resolves the implicit Query +// type when no explicit `schema { ... }` definition is present. +// Per the spec, if no schema definition exists, the default +// query type name is "Query". +// +// See https://spec.graphql.org/September2025/#sec-Root-Operation-Types +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_implicit_query_type_resolution() { + // No `schema { ... }` definition -- should implicitly use + // "Query" as the query root type. + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }", + ).unwrap(); + assert_eq!(schema.query_type_name().as_str(), "Query"); + assert!(schema.query_type().is_some()); +} + +// Verifies that an explicit `schema { query: MyQuery }` +// overrides the default "Query" name, and that the schema +// correctly resolves the custom query type name. +// +// See https://spec.graphql.org/September2025/#sec-Root-Operation-Types +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_explicit_query_type_name() { + let schema = SchemaBuilder::build_from_str( + "schema { query: RootQuery }\n\ + type RootQuery { x: Int }", + ).unwrap(); + assert_eq!( + schema.query_type_name().as_str(), + "RootQuery", + ); + assert!(schema.query_type().is_some()); +} + +// Verifies that mutation and subscription root types are +// correctly resolved when defined via schema { ... }. +// +// See https://spec.graphql.org/September2025/#sec-Root-Operation-Types +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_with_mutation_and_subscription() { + let schema = SchemaBuilder::build_from_str( + "schema {\n\ + query: Query\n\ + mutation: Mutation\n\ + subscription: Subscription\n\ + }\n\ + type Query { x: Int }\n\ + type Mutation { doThing: Boolean }\n\ + type Subscription { onThing: Boolean }", + ).unwrap(); + + assert!(schema.mutation_type().is_some()); + assert_eq!( + schema.mutation_type_name().unwrap().as_str(), + "Mutation", + ); + assert!(schema.subscription_type().is_some()); + assert_eq!( + schema.subscription_type_name().unwrap().as_str(), + "Subscription", + ); +} + +// Verifies that get_directive() returns both built-in and +// custom directives, and that the schema preserves directive +// definitions after build. +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_directive_lookups() { + let schema = SchemaBuilder::build_from_str( + "type Query { x: Int }\n\ + directive @auth on FIELD_DEFINITION", + ).unwrap(); + + // Built-in directives + assert!(schema.get_directive("skip").is_some()); + assert!(schema.get_directive("include").is_some()); + assert!(schema.get_directive("deprecated").is_some()); + assert!(schema.get_directive("specifiedBy").is_some()); + assert!(schema.get_directive("oneOf").is_some()); + + // Custom directive + assert!(schema.get_directive("auth").is_some()); + + // Non-existent + assert!(schema.get_directive("nonexistent").is_none()); +} + +// Verifies that a mutation root type pointing to a non-existent +// type produces a RootOperationTypeNotDefined error. +// +// See https://spec.graphql.org/September2025/#sec-Root-Operation-Types +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_mutation_root_not_defined_fails() { + let result = SchemaBuilder::build_from_str( + "schema { query: Query, mutation: Missing }\n\ + type Query { x: Int }", + ); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::RootOperationTypeNotDefined { + operation: OperationKind::Mutation, + .. + }, + ) + }); + assert!( + has_error, + "expected RootOperationTypeNotDefined for mutation", + ); +} + +// Verifies that an enum type with no values produces an +// EnumWithNoValues error during build. Since the parser +// typically requires at least one value, we construct this +// scenario by loading an enum via the builder and confirming +// the error is caught during build(). +// +// Written by Claude Code, reviewed by a human. +#[test] +fn build_enum_with_no_values_fails() { + let mut sb = SchemaBuilder::new(); + sb.load_str("type Query { x: Int }").unwrap(); + + // Create an empty enum programmatically + let empty_enum = crate::type_builders::EnumTypeBuilder::new( + "EmptyEnum", Span::dummy(), + ).unwrap(); + sb.absorb_type(empty_enum).unwrap(); + + let result = sb.build(); + assert!(result.is_err()); + let errors = result.unwrap_err(); + let has_error = errors.errors().iter().any(|e| { + matches!( + e.kind(), + SchemaBuildErrorKind::EnumWithNoValues { .. }, + ) + }); + assert!(has_error, "expected EnumWithNoValues error"); +} diff --git a/libgraphql-core-v1-plan.md b/libgraphql-core-v1-plan.md index 3cc84b5..266f5dc 100644 --- a/libgraphql-core-v1-plan.md +++ b/libgraphql-core-v1-plan.md @@ -3632,11 +3632,14 @@ pub fn build(mut self) -> Result { **Tests:** Full-pipeline: parse -> load -> build -> query results. Both valid and invalid. -- [ ] Implement `Schema` with typed query API and full rustdocs -- [ ] Implement `SchemaBuilder::build()` orchestrating all validators -- [ ] Add `EmptyUnionType`, `EmptyObjectOrInterfaceType`, and `EnumWithNoValues` checks in `build()` — these are `SchemaBuildErrorKind` variants (not `TypeValidationErrorKind`), so they belong in the build pipeline rather than in per-type validators -- [ ] Write end-to-end schema building tests (valid schemas, invalid schemas with specific error assertions) -- [ ] Commit: `[libgraphql-core-v1] Add Schema struct and SchemaBuilder::build()` +- [x] Implement `Schema` with typed query API and full rustdocs +- [x] Implement `SchemaBuilder::build()` orchestrating all validators +- [x] Add `EmptyUnionType`, `EmptyObjectOrInterfaceType`, and `EnumWithNoValues` checks in `build()` +- [x] Create `OperationKind` enum (pulled forward from Task 18) with `From` and `Display` +- [x] Write end-to-end schema building tests (valid schemas, invalid schemas with specific error assertions) +- [x] Commit: `[libgraphql-core-v1] Add Schema struct and SchemaBuilder::build()` + +**Completion Notes:** Full `Schema` struct with typed query API (generic lookups, typed lookups, typed iterators, root operation types, `types_implementing()`). `build()` orchestrates 5 phases: resolve root query type (implicit "Query" default), validate root types are Object types, check for empty types, run all 4 validators, produce `Schema`. `OperationKind` enum pulled forward from Task 18 — used in error variants instead of `String` for type safety. `From` conversion replaces one-off helper function. ---