diff --git a/gnd/src/commands/add.rs b/gnd/src/commands/add.rs index 280253d5509..ef936884dfe 100644 --- a/gnd/src/commands/add.rs +++ b/gnd/src/commands/add.rs @@ -3,19 +3,23 @@ //! This command adds a new data source to an existing subgraph, generating //! the necessary manifest entries, schema types, and mapping stubs. +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, anyhow}; use clap::Parser; -use inflector::Inflector; +use graphql_tools::parser::schema as gql; use serde_json::Value as JsonValue; use crate::config::networks::update_networks_file; use crate::formatter::format_typescript; use crate::output::{Step, step}; -use crate::scaffold::ScaffoldOptions; use crate::scaffold::manifest::{EventInfo, extract_events_from_abi}; +use crate::scaffold::{ + MAPPING_API_VERSION, ResolvedEvent, ScaffoldOptions, disambiguate_events, + generate_event_entity, generate_event_handlers, to_kebab_case, +}; use crate::services::ContractService; #[derive(Clone, Debug, Parser)] @@ -97,6 +101,14 @@ pub async fn run_add(opt: AddOpt) -> Result<()> { // Fetch or load ABI let (abi, contract_name, start_block) = get_contract_info(&opt, &network).await?; + // A data source / template name must be unique within the subgraph. + if existing_source_names(&manifest).contains(&contract_name) { + return Err(anyhow!( + "Data source or template named '{}' already exists. Choose a different name with --contract-name.", + contract_name + )); + } + // Get project directory let project_dir = crate::manifest::manifest_dir(&opt.manifest); @@ -111,14 +123,20 @@ pub async fn run_add(opt: AddOpt) -> Result<()> { index_events: true, // Always index events for add command }; - // Extract events from ABI + // Extract events from the ABI and resolve their names against the entities + // already present in the subgraph (handles overloads and collisions). Both + // the manifest's entity lists and the types declared in schema.graphql count + // as existing, since either can be the source of a name collision. let events = extract_events_from_abi(&scaffold_options); + let mut existing = existing_entities(&manifest); + existing.extend(schema_entity_types(project_dir, &manifest)); + let resolved = resolve_events(events, &existing, &contract_name, opt.merge_entities)?; // Add ABI file add_abi_file(project_dir, &contract_name, &abi)?; // Add mapping file - add_mapping_file(project_dir, &contract_name, &events)?; + add_mapping_file(project_dir, &contract_name, &resolved)?; // Update manifest update_manifest( @@ -127,22 +145,27 @@ pub async fn run_add(opt: AddOpt) -> Result<()> { &contract_name, &network, start_block, - &events, + &resolved, )?; - // Update networks.json + // Declare the new event entities in the schema so codegen/build succeed. + add_schema_entities(project_dir, &manifest, &resolved)?; + + // Update networks.json if the subgraph uses one. let networks_path = project_dir.join(&opt.network_file); - update_networks_file( - &networks_path, - &network, - &contract_name, - &opt.address, - start_block, - )?; - step( - Step::Write, - &format!("Updated {}", opt.network_file.display()), - ); + if networks_path.exists() { + update_networks_file( + &networks_path, + &network, + &contract_name, + &opt.address, + start_block, + )?; + step( + Step::Write, + &format!("Updated {}", opt.network_file.display()), + ); + } step(Step::Done, &format!("Added data source: {}", contract_name)); @@ -232,41 +255,16 @@ fn add_abi_file(project_dir: &Path, contract_name: &str, abi: &JsonValue) -> Res Ok(()) } -/// Sanitize a field name for GraphQL. -fn sanitize_field_name(name: &str) -> String { - if name.is_empty() { - return "value".to_string(); - } - - let mut result = name.to_string(); - - // Convert to camelCase if starts with uppercase - if result - .chars() - .next() - .map(|c| c.is_uppercase()) - .unwrap_or(false) - { - let mut chars = result.chars(); - if let Some(first) = chars.next() { - result = first.to_lowercase().collect::() + chars.as_str(); - } - } - - // Avoid reserved words - match result.as_str() { - "id" => "eventId".to_string(), - "type" => "eventType".to_string(), - _ => result, - } -} - /// Add mapping file for the new data source. -fn add_mapping_file(project_dir: &Path, contract_name: &str, events: &[EventInfo]) -> Result<()> { +fn add_mapping_file( + project_dir: &Path, + contract_name: &str, + events: &[ResolvedEvent], +) -> Result<()> { let src_dir = project_dir.join("src"); fs::create_dir_all(&src_dir).context("Failed to create src directory")?; - let mapping_file = src_dir.join(format!("{}.ts", contract_name.to_kebab_case())); + let mapping_file = src_dir.join(format!("{}.ts", to_kebab_case(contract_name))); if mapping_file.exists() { step( @@ -276,7 +274,7 @@ fn add_mapping_file(project_dir: &Path, contract_name: &str, events: &[EventInfo return Ok(()); } - let mapping_content = generate_mapping(contract_name, events); + let mapping_content = generate_event_handlers(contract_name, events); let formatted = format_typescript(&mapping_content).unwrap_or(mapping_content); step( @@ -288,74 +286,167 @@ fn add_mapping_file(project_dir: &Path, contract_name: &str, events: &[EventInfo Ok(()) } -/// Generate mapping handlers for events. -fn generate_mapping(contract_name: &str, events: &[EventInfo]) -> String { - let mut imports = String::new(); - let mut handlers = String::new(); - - imports.push_str("import { BigInt, Bytes } from \"@graphprotocol/graph-ts\"\n"); +/// Collect the names of all existing data sources and templates in the manifest. +fn existing_source_names(manifest: &serde_yaml::Value) -> HashSet { + let mut names = HashSet::new(); + for key in ["dataSources", "templates"] { + if let Some(seq) = manifest.get(key).and_then(|v| v.as_sequence()) { + for item in seq { + if let Some(name) = item.get("name").and_then(|n| n.as_str()) { + names.insert(name.to_string()); + } + } + } + } + names +} - if events.is_empty() { - return imports; +/// Collect the entity names already declared by existing data sources / templates. +fn existing_entities(manifest: &serde_yaml::Value) -> HashSet { + let mut entities = HashSet::new(); + for key in ["dataSources", "templates"] { + if let Some(seq) = manifest.get(key).and_then(|v| v.as_sequence()) { + for item in seq { + if let Some(list) = item + .get("mapping") + .and_then(|m| m.get("entities")) + .and_then(|e| e.as_sequence()) + { + for entity in list.iter().filter_map(|e| e.as_str()) { + entities.insert(entity.to_string()); + } + } + } + } } + entities +} - // Import event types - let event_imports: Vec = events - .iter() - .map(|e| format!("{} as {}Event", e.name, e.name)) - .collect(); +/// Collect the `@entity` type names declared in the subgraph's schema.graphql. +/// +/// The schema is the real source of truth for declared types, so a hand-written +/// `type Foo @entity` that no mapping lists in its `entities` still counts as a +/// collision. Returns empty on a missing or unparseable schema (falling back to +/// the manifest's entity lists). +fn schema_entity_types(project_dir: &Path, manifest: &serde_yaml::Value) -> HashSet { + let schema_file = manifest + .get("schema") + .and_then(|s| s.get("file")) + .and_then(|f| f.as_str()) + .unwrap_or("schema.graphql"); + let Ok(content) = fs::read_to_string(project_dir.join(schema_file)) else { + return HashSet::new(); + }; + entity_types_in_schema(&content) +} - imports.push_str(&format!( - "import {{ {} }} from \"../generated/{}/{}\"\n", - event_imports.join(", "), - contract_name, - contract_name - )); - - // Import entity types - let entity_imports: Vec = events.iter().map(|e| e.name.clone()).collect(); - - imports.push_str(&format!( - "import {{ {} }} from \"../generated/schema\"\n", - entity_imports.join(", ") - )); - - // Generate handler for each event - for event in events { - handlers.push('\n'); - handlers.push_str(&generate_event_handler(event)); +/// Parse GraphQL schema text and return the names of object types marked +/// `@entity`. Returns empty if the schema does not parse. +fn entity_types_in_schema(content: &str) -> HashSet { + let Ok(ast) = gql::parse_schema::(content) else { + return HashSet::new(); + }; + ast.definitions + .into_iter() + .filter_map(|def| match def { + gql::Definition::TypeDefinition(gql::TypeDefinition::Object(obj)) + if obj.directives.iter().any(|d| d.name == "entity") => + { + Some(obj.name) + } + _ => None, + }) + .collect() +} + +/// Resolve event names against the entities already present in the subgraph. +/// +/// - Events overloaded within this ABI are disambiguated (`Transfer`, `Transfer1`). +/// - An event whose name collides with an existing entity is either merged into +/// that entity (`merge_entities`: reuse it, keeping the handler so this +/// contract's events are still indexed, but don't redeclare the type) or +/// renamed with the contract prefix so both entities can coexist. +fn resolve_events( + events: Vec, + existing: &HashSet, + contract_name: &str, + merge_entities: bool, +) -> Result> { + let mut resolved = disambiguate_events(events); + + for r in &mut resolved { + // Check the name we would actually declare (the disambiguated alias), + // not the raw event name, so an overload alias like `Transfer1` that + // collides with an existing entity is still caught. + if !existing.contains(&r.entity_name) { + continue; + } + if merge_entities { + // Reuse the existing entity (entity_name already equals it). Merge is + // by name only, so a signature mismatch surfaces at codegen/build. + r.declare_in_schema = false; + } else { + let prefixed = format!("{}{}", contract_name, r.alias); + if existing.contains(&prefixed) { + return Err(anyhow!( + "Entity '{}' already exists; cannot rename '{}' to avoid a collision. Choose a different contract name.", + prefixed, + r.entity_name + )); + } + r.entity_name = prefixed; + } } - format!("{}\n{}", imports, handlers) + Ok(resolved) } -/// Generate a handler function for an event. -fn generate_event_handler(event: &EventInfo) -> String { - let event_name = &event.name; - - let mut field_assignments = String::new(); - for input in &event.inputs { - let field_name = sanitize_field_name(&input.name); - field_assignments.push_str(&format!( - " entity.{} = event.params.{}\n", - field_name, input.name +/// Append entity type definitions for the new events to the subgraph schema. +/// +/// The mapping and manifest reference one entity per event; without this the +/// referenced types never exist in schema.graphql and codegen/build fail. +fn add_schema_entities( + project_dir: &Path, + manifest: &serde_yaml::Value, + events: &[ResolvedEvent], +) -> Result<()> { + let mut additions = String::new(); + for resolved in events { + // Reused entities already exist in the schema; don't redeclare them. + if !resolved.declare_in_schema { + continue; + } + additions.push('\n'); + additions.push_str(&generate_event_entity( + &resolved.entity_name, + &resolved.event.inputs, )); + additions.push('\n'); + } + + if additions.is_empty() { + return Ok(()); } - format!( - r#"export function handle{event_name}(event: {event_name}Event): void {{ - let entity = new {event_name}( - event.transaction.hash.concatI32(event.logIndex.toI32()) - ) + let schema_file = manifest + .get("schema") + .and_then(|s| s.get("file")) + .and_then(|f| f.as_str()) + .unwrap_or("schema.graphql"); + let schema_path = project_dir.join(schema_file); -{field_assignments} entity.blockNumber = event.block.number - entity.blockTimestamp = event.block.timestamp - entity.transactionHash = event.transaction.hash + step(Step::Write, &format!("Updating {}", schema_path.display())); - entity.save() -}} -"# - ) + use std::io::Write; + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&schema_path) + .with_context(|| format!("Failed to open schema file: {}", schema_path.display()))?; + file.write_all(additions.as_bytes()) + .context("Failed to append entities to schema")?; + + Ok(()) } /// Update the manifest with the new data source. @@ -365,7 +456,7 @@ fn update_manifest( contract_name: &str, network: &str, start_block: Option, - events: &[EventInfo], + events: &[ResolvedEvent], ) -> Result<()> { let content = fs::read_to_string(manifest_path).context("Failed to read manifest")?; @@ -391,15 +482,15 @@ fn update_manifest( // Build event handlers let mut event_handlers = Vec::new(); - for event in events { + for resolved in events { let mut handler = serde_yaml::Mapping::new(); handler.insert( serde_yaml::Value::String("event".to_string()), - serde_yaml::Value::String(event.signature.clone()), + serde_yaml::Value::String(resolved.event.signature.clone()), ); handler.insert( serde_yaml::Value::String("handler".to_string()), - serde_yaml::Value::String(format!("handle{}", event.name)), + serde_yaml::Value::String(format!("handle{}", resolved.alias)), ); event_handlers.push(serde_yaml::Value::Mapping(handler)); } @@ -407,7 +498,7 @@ fn update_manifest( // Build entities list let entities: Vec = events .iter() - .map(|e| serde_yaml::Value::String(e.name.clone())) + .map(|e| serde_yaml::Value::String(e.entity_name.clone())) .collect(); // Build ABI entry @@ -429,7 +520,7 @@ fn update_manifest( ); mapping.insert( serde_yaml::Value::String("apiVersion".to_string()), - serde_yaml::Value::String("0.0.9".to_string()), + serde_yaml::Value::String(MAPPING_API_VERSION.to_string()), ); mapping.insert( serde_yaml::Value::String("language".to_string()), @@ -449,7 +540,7 @@ fn update_manifest( ); mapping.insert( serde_yaml::Value::String("file".to_string()), - serde_yaml::Value::String(format!("./src/{}.ts", contract_name.to_kebab_case())), + serde_yaml::Value::String(format!("./src/{}.ts", to_kebab_case(contract_name))), ); // Build the data source @@ -494,7 +585,6 @@ fn update_manifest( #[cfg(test)] mod tests { use super::*; - use crate::scaffold::manifest::{EventInfo, EventInput}; #[tokio::test] async fn test_invalid_address() { @@ -519,87 +609,6 @@ mod tests { ); } - #[test] - fn test_sanitize_field_name() { - assert_eq!(sanitize_field_name("owner"), "owner"); - assert_eq!(sanitize_field_name("Owner"), "owner"); - assert_eq!(sanitize_field_name(""), "value"); - // Reserved words - assert_eq!(sanitize_field_name("id"), "eventId"); - assert_eq!(sanitize_field_name("type"), "eventType"); - } - - #[test] - fn test_to_kebab_case() { - assert_eq!("MyContract".to_kebab_case(), "my-contract"); - assert_eq!("SimpleToken".to_kebab_case(), "simple-token"); - assert_eq!("Contract".to_kebab_case(), "contract"); - assert_eq!("contract".to_kebab_case(), "contract"); - assert_eq!("ERC20Token".to_kebab_case(), "erc20-token"); - } - - #[test] - fn test_generate_event_handler() { - let event = EventInfo { - name: "Approval".to_string(), - signature: "Approval(address,address,uint256)".to_string(), - inputs: vec![ - EventInput { - name: "owner".to_string(), - solidity_type: "address".to_string(), - indexed: true, - }, - EventInput { - name: "spender".to_string(), - solidity_type: "address".to_string(), - indexed: true, - }, - EventInput { - name: "value".to_string(), - solidity_type: "uint256".to_string(), - indexed: false, - }, - ], - }; - - let handler = generate_event_handler(&event); - assert!(handler.contains("export function handleApproval")); - assert!(handler.contains("event: ApprovalEvent")); - assert!(handler.contains("new Approval(")); - assert!(handler.contains("entity.owner = event.params.owner")); - assert!(handler.contains("entity.spender = event.params.spender")); - assert!(handler.contains("entity.value = event.params.value")); - assert!(handler.contains("entity.save()")); - } - - #[test] - fn test_generate_mapping() { - let events = vec![EventInfo { - name: "Transfer".to_string(), - signature: "Transfer(address,address,uint256)".to_string(), - inputs: vec![EventInput { - name: "from".to_string(), - solidity_type: "address".to_string(), - indexed: true, - }], - }]; - - let mapping = generate_mapping("Token", &events); - assert!(mapping.contains("import { BigInt, Bytes }")); - assert!(mapping.contains("Transfer as TransferEvent")); - assert!(mapping.contains("../generated/Token/Token")); - assert!(mapping.contains("import { Transfer }")); - assert!(mapping.contains("../generated/schema")); - } - - #[test] - fn test_generate_mapping_empty_events() { - let events: Vec = vec![]; - let mapping = generate_mapping("Empty", &events); - assert!(mapping.contains("import { BigInt, Bytes }")); - assert!(!mapping.contains("export function handle")); - } - #[tokio::test] async fn test_missing_manifest() { let opt = AddOpt { @@ -617,4 +626,111 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Manifest file")); } + + fn ev(name: &str) -> EventInfo { + EventInfo { + name: name.to_string(), + signature: format!("{}()", name), + inputs: vec![], + } + } + + fn entities(names: &[&str]) -> HashSet { + names.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_resolve_events_no_collision() { + let resolved = + resolve_events(vec![ev("Transfer")], &entities(&[]), "Token", false).unwrap(); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].alias, "Transfer"); + assert_eq!(resolved[0].entity_name, "Transfer"); + assert!(resolved[0].declare_in_schema); + } + + #[test] + fn test_resolve_events_overload_disambiguates() { + // Two events named Transfer in one ABI get distinct aliases. + let resolved = resolve_events( + vec![ev("Transfer"), ev("Transfer")], + &entities(&[]), + "Token", + false, + ) + .unwrap(); + assert_eq!(resolved[0].alias, "Transfer"); + assert_eq!(resolved[1].alias, "Transfer1"); + assert_eq!(resolved[1].entity_name, "Transfer1"); + } + + #[test] + fn test_resolve_events_collision_merge_reuses() { + let resolved = resolve_events( + vec![ev("Transfer")], + &entities(&["Transfer"]), + "Token", + true, + ) + .unwrap(); + // Reuse the existing entity, don't redeclare it, but keep the handler. + assert_eq!(resolved[0].entity_name, "Transfer"); + assert!(!resolved[0].declare_in_schema); + } + + #[test] + fn test_resolve_events_collision_no_merge_renames() { + let resolved = resolve_events( + vec![ev("Transfer")], + &entities(&["Transfer"]), + "Token", + false, + ) + .unwrap(); + assert_eq!(resolved[0].entity_name, "TokenTransfer"); + assert!(resolved[0].declare_in_schema); + } + + #[test] + fn test_resolve_events_collision_renamed_also_exists_errors() { + let result = resolve_events( + vec![ev("Transfer")], + &entities(&["Transfer", "TokenTransfer"]), + "Token", + false, + ); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_events_overload_alias_collision_renames() { + // The second overload's disambiguated alias (Transfer1) collides with an + // existing entity; the collision must be detected on the alias, not the + // raw event name. + let resolved = resolve_events( + vec![ev("Transfer"), ev("Transfer")], + &entities(&["Transfer1"]), + "Token", + false, + ) + .unwrap(); + assert_eq!(resolved[0].entity_name, "Transfer"); + assert_eq!(resolved[1].alias, "Transfer1"); + assert_eq!(resolved[1].entity_name, "TokenTransfer1"); + } + + #[test] + fn test_entity_types_in_schema() { + let schema = r#" + type Foo @entity { id: Bytes! } + type Bar @entity(immutable: true) { id: Bytes! } + type NotAnEntity { id: Bytes! } + "#; + let types = entity_types_in_schema(schema); + assert!(types.contains("Foo")); + assert!(types.contains("Bar")); + assert!(!types.contains("NotAnEntity")); + // A schema that doesn't parse yields no types rather than erroring. + assert!(entity_types_in_schema("this is not graphql {{{").is_empty()); + } } diff --git a/gnd/src/scaffold/manifest.rs b/gnd/src/scaffold/manifest.rs index 5e1affb7673..b0b731ff4be 100644 --- a/gnd/src/scaffold/manifest.rs +++ b/gnd/src/scaffold/manifest.rs @@ -1,6 +1,9 @@ //! Manifest (subgraph.yaml) generation for scaffold. +use std::collections::HashMap; + use super::ScaffoldOptions; +use crate::shared::handle_reserved_word; /// Generate the subgraph.yaml manifest content. pub fn generate_manifest(options: &ScaffoldOptions) -> String { @@ -21,11 +24,13 @@ pub fn generate_manifest(options: &ScaffoldOptions) -> String { source.push_str(&format!(" startBlock: {}\n", start_block)); } - // Get event handlers from ABI - let event_handlers = get_event_handlers(options); + // Resolve events once (disambiguating overloaded names) so the handlers and + // entities lists stay consistent. + let events = disambiguate_events(extract_events_from_abi(options)); + let event_handlers = get_event_handlers(contract_name, &events); format!( - r#"specVersion: 1.3.0 + r#"specVersion: {spec_version} indexerHints: prune: auto schema: @@ -37,7 +42,7 @@ dataSources: source: {source} mapping: kind: ethereum/events - apiVersion: 0.0.9 + apiVersion: {api_version} language: wasm/assemblyscript entities:{entities} abis: @@ -46,15 +51,14 @@ dataSources: eventHandlers:{event_handlers} file: {mapping_file} "#, - entities = get_entities(options), + spec_version = super::SPEC_VERSION, + api_version = super::MAPPING_API_VERSION, + entities = get_entities(&events), ) } -/// Get event handlers from ABI. -fn get_event_handlers(options: &ScaffoldOptions) -> String { - let contract_name = &options.contract_name; - let events = extract_events_from_abi(options); - +/// Get event handlers for the manifest. +fn get_event_handlers(contract_name: &str, events: &[ResolvedEvent]) -> String { if events.is_empty() { // Default placeholder handler return format!( @@ -66,40 +70,108 @@ fn get_event_handlers(options: &ScaffoldOptions) -> String { let mut handlers = String::new(); for event in events { - let handler_name = format!("handle{}", event.name); handlers.push_str(&format!( - "\n - event: {}\n handler: {}", - event.signature, handler_name + "\n - event: {}\n handler: handle{}", + event.event.signature, event.alias )); } handlers } -/// Get entities list for manifest. -fn get_entities(options: &ScaffoldOptions) -> String { - let events = extract_events_from_abi(options); - +/// Get entities list for the manifest. +fn get_entities(events: &[ResolvedEvent]) -> String { if events.is_empty() { return "\n - ExampleEntity".to_string(); } - // Always use event names from ABI, regardless of index_events let mut entities = String::new(); for event in events { - entities.push_str(&format!("\n - {}", event.name)); + entities.push_str(&format!("\n - {}", event.entity_name)); } entities } /// Event info extracted from ABI. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct EventInfo { pub name: String, pub signature: String, pub inputs: Vec, } +/// An event resolved to the concrete names the generators render. +/// +/// Decided once (here / by collision resolution) so the schema, mapping and +/// manifest generators never derive names independently: +/// - `alias` names the handler function and the ABI event-type import; it is +/// disambiguated for events overloaded within a single ABI. +/// - `entity_name` names the GraphQL entity type, the `new` expression and the +/// schema import; it gains a contract prefix when it collides with an entity +/// that already exists in the subgraph. +/// - `declare_in_schema` is false when the event reuses an entity that already +/// exists (a merge), so the type must not be redeclared. +#[derive(Debug, Clone)] +pub struct ResolvedEvent { + pub event: EventInfo, + pub alias: String, + pub entity_name: String, + pub declare_in_schema: bool, +} + +/// Resolve events for a fresh scaffold, disambiguating names that are overloaded +/// within one ABI by suffixing repeats (`Transfer`, `Transfer1`, ...). There are +/// no existing entities to collide with, so each entity is declared as-is. +pub fn disambiguate_events(events: Vec) -> Vec { + let mut seen: HashMap = HashMap::new(); + events + .into_iter() + .map(|event| { + let count = seen.entry(event.name.clone()).or_insert(0); + let alias = if *count == 0 { + event.name.clone() + } else { + format!("{}{}", event.name, count) + }; + *count += 1; + let entity_name = alias.clone(); + ResolvedEvent { + event, + alias, + entity_name, + declare_in_schema: true, + } + }) + .collect() +} + +/// The `event.params.` accessor for each input, mirroring the names the +/// ABI codegen gives the generated getters: reserved words are escaped and +/// unnamed params become `param`, with a counter for any collisions. +/// Keeps the mapping's right-hand side in sync with the generated bindings. +pub fn event_param_accessors(inputs: &[EventInput]) -> Vec { + let mut seen: HashMap = HashMap::new(); + inputs + .iter() + .enumerate() + .map(|(index, input)| { + let base = if input.name.is_empty() { + format!("param{index}") + } else { + handle_reserved_word(&input.name) + }; + let count = seen.entry(base.clone()).or_insert(0); + let name = if *count == 0 { + base.clone() + } else { + format!("{base}{count}") + }; + *count += 1; + name + }) + .collect() +} + /// Event input parameter. #[derive(Debug, Clone)] pub struct EventInput { @@ -324,4 +396,21 @@ mod tests { let sig = format_event_signature("Transfer", &inputs); assert_eq!(sig, "Transfer(indexed address,uint256)"); } + + #[test] + fn test_disambiguate_events() { + let ev = |name: &str| EventInfo { + name: name.to_string(), + signature: format!("{}()", name), + inputs: vec![], + }; + let resolved = disambiguate_events(vec![ev("Transfer"), ev("Transfer"), ev("Approval")]); + // Repeated names are suffixed; the first occurrence is unchanged. + assert_eq!(resolved[0].alias, "Transfer"); + assert_eq!(resolved[0].entity_name, "Transfer"); + assert_eq!(resolved[1].alias, "Transfer1"); + assert_eq!(resolved[1].entity_name, "Transfer1"); + assert_eq!(resolved[2].alias, "Approval"); + assert!(resolved.iter().all(|r| r.declare_in_schema)); + } } diff --git a/gnd/src/scaffold/mapping.rs b/gnd/src/scaffold/mapping.rs index 9034cf4ec2c..8749c91ab2b 100644 --- a/gnd/src/scaffold/mapping.rs +++ b/gnd/src/scaffold/mapping.rs @@ -1,7 +1,8 @@ //! Mapping (AssemblyScript) generation for scaffold. use super::ScaffoldOptions; -use super::manifest::{EventInfo, extract_events_from_abi}; +use super::manifest::{EventInfo, ResolvedEvent, extract_events_from_abi}; +use super::sanitize_field_name; /// Generate the mapping.ts content. pub fn generate_mapping(options: &ScaffoldOptions) -> String { @@ -16,7 +17,8 @@ pub fn generate_mapping(options: &ScaffoldOptions) -> String { return generate_placeholder_mapping(contract_name, &events, options); } - generate_event_handlers(contract_name, &events, options) + let resolved = super::disambiguate_events(events); + generate_event_handlers(contract_name, &resolved) } /// Generate a fallback mapping when no events are found in ABI. @@ -51,18 +53,20 @@ fn generate_placeholder_mapping( events: &[EventInfo], options: &ScaffoldOptions, ) -> String { + let resolved = super::disambiguate_events(events.to_vec()); + let mut output = String::new(); // Import graph-ts types output.push_str("import {\n BigInt,\n Bytes\n} from \"@graphprotocol/graph-ts\"\n"); - // Import contract class and all events + // Import contract class and all events (by disambiguated alias). output.push_str(&format!("import {{\n {contract_name},\n")); - for (i, event) in events.iter().enumerate() { - let suffix = if i < events.len() - 1 { ",\n" } else { "\n" }; + for (i, event) in resolved.iter().enumerate() { + let suffix = if i < resolved.len() - 1 { ",\n" } else { "\n" }; output.push_str(&format!( " {} as {}Event{}", - event.name, event.name, suffix + event.alias, event.alias, suffix )); } output.push_str(&format!( @@ -73,18 +77,17 @@ fn generate_placeholder_mapping( output.push_str("import { ExampleEntity } from \"../generated/schema\"\n"); // Generate first handler with full example code - let first_event = &events[0]; output.push_str(&generate_first_placeholder_handler( - first_event, + &resolved[0], contract_name, options, )); // Generate empty stub handlers for remaining events - for event in events.iter().skip(1) { + for event in resolved.iter().skip(1) { output.push_str(&format!( "\nexport function handle{}(event: {}Event): void {{}}\n", - event.name, event.name + event.alias, event.alias )); } @@ -93,19 +96,20 @@ fn generate_placeholder_mapping( /// Generate the first handler with full example code. fn generate_first_placeholder_handler( - event: &EventInfo, + event: &ResolvedEvent, contract_name: &str, options: &ScaffoldOptions, ) -> String { - let event_name = &event.name; + let event_name = &event.alias; // Generate field assignments for first 2 event params let mut field_assignments = String::new(); - for input in event.inputs.iter().take(2) { - let field_name = sanitize_param_name(&input.name); + let accessors = super::event_param_accessors(&event.event.inputs); + for (input, accessor) in event.event.inputs.iter().zip(&accessors).take(2) { + let field_name = sanitize_field_name(&input.name); field_assignments.push_str(&format!( " entity.{} = event.params.{}\n", - field_name, input.name + field_name, accessor )); } @@ -153,22 +157,18 @@ export function handle{event_name}(event: {event_name}Event): void {{ ) } -/// Generate event handlers for all events in the ABI. -fn generate_event_handlers( - contract_name: &str, - events: &[super::manifest::EventInfo], - _options: &ScaffoldOptions, -) -> String { - let mut imports = String::new(); - let mut handlers = String::new(); +/// Generate event handlers for all resolved events. +pub fn generate_event_handlers(contract_name: &str, events: &[ResolvedEvent]) -> String { + let mut imports = String::from("import { BigInt, Bytes } from \"@graphprotocol/graph-ts\"\n"); - // Import graph-ts types - imports.push_str("import { BigInt, Bytes } from \"@graphprotocol/graph-ts\"\n"); + if events.is_empty() { + return imports; + } - // Import event types + // Import event types (by ABI alias). let event_imports: Vec = events .iter() - .map(|e| format!("{} as {}Event", e.name, e.name)) + .map(|e| format!("{} as {}Event", e.alias, e.alias)) .collect(); imports.push_str(&format!( @@ -178,15 +178,16 @@ fn generate_event_handlers( contract_name )); - // Import entity types - let entity_imports: Vec = events.iter().map(|e| e.name.clone()).collect(); + // Import entity types. + let entity_imports: Vec = events.iter().map(|e| e.entity_name.clone()).collect(); imports.push_str(&format!( "import {{ {} }} from \"../generated/schema\"\n", entity_imports.join(", ") )); - // Generate handler for each event + // Generate handler for each event. + let mut handlers = String::new(); for event in events { handlers.push('\n'); handlers.push_str(&generate_single_handler(event)); @@ -195,23 +196,26 @@ fn generate_event_handlers( format!("{}\n{}", imports, handlers) } -/// Generate a handler function for a single event. -fn generate_single_handler(event: &super::manifest::EventInfo) -> String { - let event_name = &event.name; +/// Generate a handler function for a single resolved event. +fn generate_single_handler(resolved: &ResolvedEvent) -> String { + let alias = &resolved.alias; + let entity_name = &resolved.entity_name; - // Generate field assignments from event parameters + // Generate field assignments from event parameters. The accessor mirrors the + // generated binding getter (escaped reserved words, param for unnamed). let mut field_assignments = String::new(); - for input in &event.inputs { - let field_name = sanitize_param_name(&input.name); + let accessors = super::event_param_accessors(&resolved.event.inputs); + for (input, accessor) in resolved.event.inputs.iter().zip(&accessors) { + let field_name = sanitize_field_name(&input.name); field_assignments.push_str(&format!( " entity.{} = event.params.{}\n", - field_name, input.name + field_name, accessor )); } format!( - r#"export function handle{event_name}(event: {event_name}Event): void {{ - let entity = new {event_name}( + r#"export function handle{alias}(event: {alias}Event): void {{ + let entity = new {entity_name}( event.transaction.hash.concatI32(event.logIndex.toI32()) ) @@ -225,34 +229,6 @@ fn generate_single_handler(event: &super::manifest::EventInfo) -> String { ) } -/// Sanitize parameter name for use in AssemblyScript. -fn sanitize_param_name(name: &str) -> String { - if name.is_empty() { - return "value".to_string(); - } - - // Convert to camelCase if starts with uppercase - let mut result = name.to_string(); - if result - .chars() - .next() - .map(|c| c.is_uppercase()) - .unwrap_or(false) - { - let mut chars = result.chars(); - if let Some(first) = chars.next() { - result = first.to_lowercase().collect::() + chars.as_str(); - } - } - - // Avoid reserved words - match result.as_str() { - "id" => "eventId".to_string(), - "type" => "eventType".to_string(), - _ => result, - } -} - /// Extract callable functions from ABI for documentation comments. fn extract_callable_functions(options: &ScaffoldOptions) -> String { let Some(abi) = &options.abi else { @@ -394,14 +370,6 @@ mod tests { assert!(mapping.contains("event.params.value")); } - #[test] - fn test_sanitize_param_name() { - assert_eq!(sanitize_param_name("from"), "from"); - assert_eq!(sanitize_param_name("TokenId"), "tokenId"); - assert_eq!(sanitize_param_name("id"), "eventId"); - assert_eq!(sanitize_param_name(""), "value"); - } - #[test] fn test_extract_callable_functions() { let abi = json!([ @@ -433,4 +401,38 @@ mod tests { // transfer is nonpayable, should not be included assert!(!functions.contains("transfer")); } + + #[test] + fn test_generate_mapping_reserved_and_unnamed_params() { + let abi = json!([ + { + "type": "event", + "name": "Act", + "inputs": [ + {"name": "new", "type": "uint256", "indexed": false}, + {"name": "", "type": "address", "indexed": false} + ] + } + ]); + + let options = ScaffoldOptions { + contract_name: "RW".to_string(), + abi: Some(abi), + index_events: true, + ..Default::default() + }; + + let mapping = generate_mapping(&options); + // The RHS must match the generated getter: `new` -> `new_`, unnamed -> `param1`. + assert!( + mapping.contains("event.params.new_"), + "reserved param accessor should be escaped, got:\n{}", + mapping + ); + assert!( + mapping.contains("event.params.param1"), + "unnamed param accessor should be param, got:\n{}", + mapping + ); + } } diff --git a/gnd/src/scaffold/mod.rs b/gnd/src/scaffold/mod.rs index 2a9e7f18d39..72cb5d70a37 100644 --- a/gnd/src/scaffold/mod.rs +++ b/gnd/src/scaffold/mod.rs @@ -5,11 +5,16 @@ pub mod manifest; mod mapping; +mod naming; mod schema; -pub use manifest::{EventInfo, EventInput, extract_events_from_abi, generate_manifest}; -pub use mapping::generate_mapping; -pub use schema::generate_schema; +pub use manifest::{ + EventInfo, EventInput, ResolvedEvent, disambiguate_events, event_param_accessors, + extract_events_from_abi, generate_manifest, +}; +pub use mapping::{generate_event_handlers, generate_mapping}; +pub(crate) use naming::sanitize_field_name; +pub use schema::{generate_event_entity, generate_schema}; use std::fs; use std::path::Path; @@ -60,6 +65,10 @@ const GRAPH_CLI_VERSION: &str = "0.98.0"; const GRAPH_TS_VERSION: &str = "0.37.0"; const MATCHSTICK_VERSION: &str = "0.6.0"; +/// Manifest format versions emitted by the scaffolder. +pub(crate) const SPEC_VERSION: &str = "1.3.0"; +pub(crate) const MAPPING_API_VERSION: &str = "0.0.9"; + /// Generate all scaffold files and write to directory. pub fn generate_scaffold(dir: &Path, options: &ScaffoldOptions) -> Result<()> { step(Step::Generate, "Generating scaffold files"); diff --git a/gnd/src/scaffold/naming.rs b/gnd/src/scaffold/naming.rs new file mode 100644 index 00000000000..54d4563f2cb --- /dev/null +++ b/gnd/src/scaffold/naming.rs @@ -0,0 +1,86 @@ +//! Shared name sanitization for scaffold code generation. +//! +//! A single source of truth for turning ABI parameter names into valid GraphQL +//! field / AssemblyScript identifiers, used by both the schema and mapping +//! generators so the two never disagree. + +/// Sanitize a parameter name into a valid GraphQL/AssemblyScript field identifier. +pub(crate) fn sanitize_field_name(name: &str) -> String { + if name.is_empty() { + return "value".to_string(); + } + + // Identifiers must start with a letter or underscore; replace anything else. + let mut result = String::new(); + for (i, c) in name.chars().enumerate() { + if i == 0 && c.is_ascii_digit() { + result.push('_'); + } + if c.is_alphanumeric() || c == '_' { + result.push(c); + } else { + result.push('_'); + } + } + + // Convert a leading uppercase to camelCase. + if result + .chars() + .next() + .map(|c| c.is_uppercase()) + .unwrap_or(false) + { + let mut chars = result.chars(); + if let Some(first) = chars.next() { + result = first.to_lowercase().collect::() + chars.as_str(); + } + } + + // Avoid clashing with the entity `id` field and the GraphQL `type` keyword. + let result = match result.as_str() { + "id" => "eventId".to_string(), + "type" => "eventType".to_string(), + _ => result, + }; + + // Escape reserved words via the shared list, so the entity field name + // matches the member the schema/ABI codegen generates from the same list. + crate::shared::handle_reserved_word(&result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_field_name() { + // Normal names pass through. + assert_eq!(sanitize_field_name("owner"), "owner"); + assert_eq!(sanitize_field_name("from"), "from"); + // Empty -> placeholder. + assert_eq!(sanitize_field_name(""), "value"); + // Leading uppercase -> camelCase. + assert_eq!(sanitize_field_name("Owner"), "owner"); + assert_eq!(sanitize_field_name("TokenId"), "tokenId"); + // Names that clash with the entity id / GraphQL keyword. + assert_eq!(sanitize_field_name("id"), "eventId"); + assert_eq!(sanitize_field_name("type"), "eventType"); + // Reserved words are suffixed so the generated code compiles. These use + // the shared list, so words only in it (for/default/null/void/instanceof) + // are covered too — and stay consistent with the schema/ABI codegen. + assert_eq!(sanitize_field_name("new"), "new_"); + assert_eq!(sanitize_field_name("class"), "class_"); + assert_eq!(sanitize_field_name("return"), "return_"); + assert_eq!(sanitize_field_name("for"), "for_"); + assert_eq!(sanitize_field_name("default"), "default_"); + assert_eq!(sanitize_field_name("null"), "null_"); + assert_eq!(sanitize_field_name("void"), "void_"); + assert_eq!(sanitize_field_name("instanceof"), "instanceof_"); + // Leading uppercase reserved word still resolves after camelCasing. + assert_eq!(sanitize_field_name("New"), "new_"); + // Leading digit -> underscore prefix. + assert_eq!(sanitize_field_name("0value"), "_0value"); + // Non-alphanumeric -> underscore. + assert_eq!(sanitize_field_name("a-b"), "a_b"); + } +} diff --git a/gnd/src/scaffold/schema.rs b/gnd/src/scaffold/schema.rs index b7141f66f3d..ed47de3e041 100644 --- a/gnd/src/scaffold/schema.rs +++ b/gnd/src/scaffold/schema.rs @@ -2,6 +2,7 @@ use super::ScaffoldOptions; use super::manifest::{EventInput, extract_events_from_abi}; +use super::sanitize_field_name; /// Generate the schema.graphql content. pub fn generate_schema(options: &ScaffoldOptions) -> String { @@ -17,11 +18,11 @@ pub fn generate_schema(options: &ScaffoldOptions) -> String { return generate_example_entity(&events[0].inputs); } - // Generate entity for each event + // Generate an entity for each event, disambiguating overloaded names. let mut schema = String::new(); - for event in events { - let entity = generate_event_entity(&event.name, &event.inputs); + for resolved in super::disambiguate_events(events) { + let entity = generate_event_entity(&resolved.entity_name, &resolved.event.inputs); schema.push_str(&entity); schema.push_str("\n\n"); } @@ -55,7 +56,7 @@ fn generate_example_entity(inputs: &[EventInput]) -> String { } /// Generate an entity type for an event. -fn generate_event_entity(event_name: &str, inputs: &[EventInput]) -> String { +pub fn generate_event_entity(entity_name: &str, inputs: &[EventInput]) -> String { let mut fields = String::new(); // ID field @@ -76,7 +77,7 @@ fn generate_event_entity(event_name: &str, inputs: &[EventInput]) -> String { format!( "# Declare entity types as immutable when possible for better performance\n\ type {} @entity(immutable: true) {{\n{}\n}}", - event_name, fields + entity_name, fields ) } @@ -88,6 +89,7 @@ fn solidity_to_graphql(solidity_type: &str) -> &'static str { return match solidity_to_graphql(inner) { "Bytes" => "[Bytes!]", "BigInt" => "[BigInt!]", + "Int" => "[Int!]", "String" => "[String!]", "Boolean" => "[Boolean!]", _ => "[Bytes!]", @@ -112,53 +114,35 @@ fn solidity_to_graphql(solidity_type: &str) -> &'static str { | "bytes23" | "bytes24" | "bytes25" | "bytes26" | "bytes27" | "bytes28" | "bytes29" | "bytes30" | "bytes31" | "bytes32" => "Bytes", - // Integer types - all map to BigInt for simplicity - t if t.starts_with("uint") || t.starts_with("int") => "BigInt", + // Integers: small widths fit in an i32 (GraphQL Int), the rest need BigInt. + t if t.starts_with("uint") || t.starts_with("int") => int_to_graphql(t), // Default to Bytes for unknown types _ => "Bytes", } } -/// Sanitize a field name to be a valid GraphQL identifier. -fn sanitize_field_name(name: &str) -> String { - if name.is_empty() { - return "value".to_string(); - } - - // GraphQL field names must start with a letter or underscore - let mut result = String::new(); - - for (i, c) in name.chars().enumerate() { - if i == 0 && c.is_ascii_digit() { - result.push('_'); - } - if c.is_alphanumeric() || c == '_' { - result.push(c); - } else { - result.push('_'); - } - } - - // Convert to camelCase if starts with uppercase - if result - .chars() - .next() - .map(|c| c.is_uppercase()) - .unwrap_or(false) - { - let mut chars = result.chars(); - if let Some(first) = chars.next() { - result = first.to_lowercase().collect::() + chars.as_str(); - } - } - - // Avoid reserved words - match result.as_str() { - "id" => "eventId".to_string(), - "type" => "eventType".to_string(), - _ => result, - } +/// Map a Solidity integer type to GraphQL `Int` when it fits in an i32, else +/// `BigInt`. An i32 holds signed ints up to 32 bits and unsigned ints up to 24 +/// bits, matching graph-cli's AssemblyScript type conversion. +fn int_to_graphql(solidity_type: &str) -> &'static str { + let (signed, width) = match solidity_type.strip_prefix("uint") { + Some(rest) => (false, rest), + None => match solidity_type.strip_prefix("int") { + Some(rest) => (true, rest), + None => return "BigInt", + }, + }; + + // A bare `int` / `uint` is 256 bits. + let bits: u32 = if width.is_empty() { + 256 + } else { + width.parse().unwrap_or(256) + }; + + let fits_i32 = if signed { bits <= 32 } else { bits <= 24 }; + if fits_i32 { "Int" } else { "BigInt" } } #[cfg(test)] @@ -270,8 +254,6 @@ mod tests { assert_eq!(solidity_to_graphql("address"), "Bytes"); assert_eq!(solidity_to_graphql("bool"), "Boolean"); assert_eq!(solidity_to_graphql("string"), "String"); - assert_eq!(solidity_to_graphql("uint256"), "BigInt"); - assert_eq!(solidity_to_graphql("int8"), "BigInt"); assert_eq!(solidity_to_graphql("bytes32"), "Bytes"); assert_eq!(solidity_to_graphql("bytes"), "Bytes"); assert_eq!(solidity_to_graphql("address[]"), "[Bytes!]"); @@ -279,12 +261,20 @@ mod tests { } #[test] - fn test_sanitize_field_name() { - assert_eq!(sanitize_field_name("from"), "from"); - assert_eq!(sanitize_field_name("tokenId"), "tokenId"); - assert_eq!(sanitize_field_name("TokenId"), "tokenId"); - assert_eq!(sanitize_field_name("123value"), "_123value"); - assert_eq!(sanitize_field_name("id"), "eventId"); - assert_eq!(sanitize_field_name(""), "value"); + fn test_integer_width_mapping() { + // Small widths that fit in an i32 map to Int. + assert_eq!(solidity_to_graphql("int8"), "Int"); + assert_eq!(solidity_to_graphql("int32"), "Int"); + assert_eq!(solidity_to_graphql("uint8"), "Int"); + assert_eq!(solidity_to_graphql("uint24"), "Int"); + // Wider integers need BigInt (uint32 does not fit an i32). + assert_eq!(solidity_to_graphql("uint32"), "BigInt"); + assert_eq!(solidity_to_graphql("int40"), "BigInt"); + assert_eq!(solidity_to_graphql("uint256"), "BigInt"); + assert_eq!(solidity_to_graphql("int"), "BigInt"); + assert_eq!(solidity_to_graphql("uint"), "BigInt"); + // Arrays follow the element type. + assert_eq!(solidity_to_graphql("int8[]"), "[Int!]"); + assert_eq!(solidity_to_graphql("uint64[]"), "[BigInt!]"); } } diff --git a/gnd/tests/cli_commands.rs b/gnd/tests/cli_commands.rs index 2b4967bfb7b..192d201c3be 100644 --- a/gnd/tests/cli_commands.rs +++ b/gnd/tests/cli_commands.rs @@ -431,6 +431,13 @@ fn test_add_datasource() { "Initial manifest should not have SecondContract" ); + // The added contract's event entity should not exist in the schema yet. + let schema_before = fs::read_to_string(subgraph_dir.join("schema.graphql")).unwrap(); + assert!( + !schema_before.contains("type Trigger"), + "Initial schema should not have the Trigger entity" + ); + // Now add another datasource let second_abi_path = test_abis_path().join("LimitedContract.json"); let output = run_gnd( @@ -465,6 +472,231 @@ fn test_add_datasource() { manifest_after.contains("0x2222222222222222222222222222222222222222"), "Updated manifest should have second contract address" ); + + // The added Trigger event collides with the first contract's Trigger entity; + // without --merge-entities it is renamed with the contract prefix so both + // can coexist, and declared in the schema. + let schema_after = fs::read_to_string(subgraph_dir.join("schema.graphql")).unwrap(); + assert!( + schema_after.contains("type SecondContractTrigger @entity"), + "Updated schema should declare the renamed entity, got:\n{}", + schema_after + ); +} + +#[test] +fn test_add_merge_entities_reuses_existing() { + let temp_dir = TempDir::new().unwrap(); + let subgraph_dir = temp_dir.path().join("merge-test"); + + // Init with --index-events so the schema actually declares the event entities. + let abi_path = test_abis_path().join("SimpleContract.json"); + run_gnd_success( + &[ + "init", + "--from-contract", + "0x1111111111111111111111111111111111111111", + "--abi", + abi_path.to_str().unwrap(), + "--network", + "mainnet", + "--contract-name", + "FirstContract", + "--index-events", + "merge-test", + ], + temp_dir.path(), + ); + + // Add a contract whose Trigger event collides, with --merge-entities. + let second_abi_path = test_abis_path().join("LimitedContract.json"); + run_gnd_success( + &[ + "add", + "0x2222222222222222222222222222222222222222", + "--abi", + second_abi_path.to_str().unwrap(), + "--contract-name", + "SecondContract", + "--merge-entities", + ], + &subgraph_dir, + ); + + // Merge reuses the existing Trigger entity: no renamed type is declared... + let schema_after = fs::read_to_string(subgraph_dir.join("schema.graphql")).unwrap(); + assert!( + !schema_after.contains("SecondContractTrigger"), + "merge should reuse Trigger, not declare a renamed entity, got:\n{}", + schema_after + ); + + // ...but the handler is still generated, writing into the shared entity. + let mapping = fs::read_to_string(subgraph_dir.join("src").join("second-contract.ts")).unwrap(); + assert!( + mapping.contains("new Trigger("), + "merged handler should write into the existing Trigger entity, got:\n{}", + mapping + ); +} + +#[test] +fn test_add_overloaded_events_disambiguate() { + let temp_dir = TempDir::new().unwrap(); + let subgraph_dir = temp_dir.path().join("over-test"); + + // A base subgraph without overloaded events. + let abi_path = test_abis_path().join("LimitedContract.json"); + run_gnd_success( + &[ + "init", + "--from-contract", + "0x1111111111111111111111111111111111111111", + "--abi", + abi_path.to_str().unwrap(), + "--network", + "mainnet", + "--contract-name", + "Base", + "over-test", + ], + temp_dir.path(), + ); + + // An ABI with two events of the same name (a Solidity overload). + let overloaded_abi = temp_dir.path().join("Overloaded.json"); + fs::write( + &overloaded_abi, + r#"[ + {"type":"event","name":"Ping","inputs":[{"name":"account","type":"address","indexed":true}]}, + {"type":"event","name":"Ping","inputs":[{"name":"amount","type":"uint256","indexed":false}]} + ]"#, + ) + .unwrap(); + + run_gnd_success( + &[ + "add", + "0x2222222222222222222222222222222222222222", + "--abi", + overloaded_abi.to_str().unwrap(), + "--contract-name", + "Over", + ], + &subgraph_dir, + ); + + // The two Ping events get distinct entities and handlers. + let schema = fs::read_to_string(subgraph_dir.join("schema.graphql")).unwrap(); + assert!( + schema.contains("type Ping @entity") && schema.contains("type Ping1 @entity"), + "overloaded events should produce Ping and Ping1 entities, got:\n{}", + schema + ); + + let mapping = fs::read_to_string(subgraph_dir.join("src").join("over.ts")).unwrap(); + assert!( + mapping.contains("handlePing(") && mapping.contains("handlePing1("), + "overloaded events should produce handlePing and handlePing1, got:\n{}", + mapping + ); +} + +#[test] +fn test_init_overloaded_events_disambiguate() { + let temp_dir = TempDir::new().unwrap(); + let subgraph_dir = temp_dir.path().join("over-init"); + + // An ABI with two events of the same name (a Solidity overload). + let overloaded_abi = temp_dir.path().join("Overloaded.json"); + fs::write( + &overloaded_abi, + r#"[ + {"type":"event","name":"Ping","inputs":[{"name":"account","type":"address","indexed":true}]}, + {"type":"event","name":"Ping","inputs":[{"name":"amount","type":"uint256","indexed":false}]} + ]"#, + ) + .unwrap(); + + run_gnd_success( + &[ + "init", + "--from-contract", + "0x1111111111111111111111111111111111111111", + "--abi", + overloaded_abi.to_str().unwrap(), + "--network", + "mainnet", + "--contract-name", + "Over", + "--index-events", + "over-init", + ], + temp_dir.path(), + ); + + // init must disambiguate too, or it emits duplicate types / handlers. + let schema = fs::read_to_string(subgraph_dir.join("schema.graphql")).unwrap(); + assert!( + schema.contains("type Ping @entity") && schema.contains("type Ping1 @entity"), + "init should disambiguate overloaded events, got:\n{}", + schema + ); + + let mapping = fs::read_to_string(subgraph_dir.join("src").join("over.ts")).unwrap(); + assert!( + mapping.contains("handlePing(") && mapping.contains("handlePing1("), + "init mapping should disambiguate overloaded handlers, got:\n{}", + mapping + ); +} + +#[test] +fn test_add_duplicate_name_errors() { + let temp_dir = TempDir::new().unwrap(); + let subgraph_dir = temp_dir.path().join("dup-test"); + + let abi_path = test_abis_path().join("SimpleContract.json"); + run_gnd_success( + &[ + "init", + "--from-contract", + "0x1111111111111111111111111111111111111111", + "--abi", + abi_path.to_str().unwrap(), + "--network", + "mainnet", + "--contract-name", + "FirstContract", + "dup-test", + ], + temp_dir.path(), + ); + + // Adding a data source whose name already exists must fail. + let second_abi_path = test_abis_path().join("LimitedContract.json"); + let output = run_gnd( + &[ + "add", + "0x2222222222222222222222222222222222222222", + "--abi", + second_abi_path.to_str().unwrap(), + "--contract-name", + "FirstContract", + ], + &subgraph_dir, + ); + + assert!( + !output.status.success(), + "gnd add should fail when the data source name already exists" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("already exists"), + "error should mention the name already exists, got: {}", + stderr + ); } // ============================================================================