Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions gnd/src/abi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,62 @@ pub fn normalize_abi_json(abi_str: &str) -> Result<serde_json::Value> {
))
}

/// Preprocess ABI JSON to normalize artifact formats and add defaults
/// required by alloy's ABI parser:
/// - `anonymous: false` for events (alloy requires this field)
/// - `param{index}` names for unnamed event parameters (to match graph-cli behavior)
///
/// This is shared by codegen and validation so that both parse the ABI in
/// exactly the same way: an ABI that codegen accepts must also be accepted by
/// validation (and vice versa).
pub fn preprocess_abi_json(abi_str: &str) -> Result<String> {
// Normalize to get the ABI array from various artifact formats
let mut abi = normalize_abi_json(abi_str)?;

if let Some(items) = abi.as_array_mut() {
for item in items {
if let Some(obj) = item.as_object_mut() {
let is_event = obj
.get("type")
.and_then(|t| t.as_str())
.map(|t| t == "event")
.unwrap_or(false);

if is_event {
// Add anonymous: false for events if missing (alloy requires it)
if !obj.contains_key("anonymous") {
obj.insert("anonymous".to_string(), serde_json::Value::Bool(false));
}

// Add param{index} names for unnamed event parameters
if let Some(inputs) = obj.get_mut("inputs") {
add_default_event_param_names(inputs);
}
}
}
}
}

serde_json::to_string(&abi).context("Failed to serialize processed ABI")
}

/// Add `param{index}` names to unnamed event parameters to match graph-cli behavior.
/// Alloy defaults missing names to empty strings, but for events we want `param0`, `param1`, etc.
fn add_default_event_param_names(params: &mut serde_json::Value) {
if let Some(params_arr) = params.as_array_mut() {
for (index, param) in params_arr.iter_mut().enumerate() {
if let Some(obj) = param.as_object_mut()
&& !obj.contains_key("name")
{
obj.insert(
"name".to_string(),
serde_json::Value::String(format!("param{}", index)),
);
}
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
146 changes: 146 additions & 0 deletions gnd/src/commands/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow};
use clap::Parser;
use graphql_tools::parser::schema as gql;
use inflector::Inflector;
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::generate_event_entities;
use crate::scaffold::manifest::{EventInfo, extract_events_from_abi};
use crate::services::ContractService;

Expand Down Expand Up @@ -94,6 +96,14 @@ pub async fn run_add(opt: AddOpt) -> Result<()> {
.map(String::from)
.unwrap_or_else(|| "mainnet".to_string());

// Get the schema file path from the manifest (default ./schema.graphql)
let schema_rel = manifest
.get("schema")
.and_then(|s| s.get("file"))
.and_then(|f| f.as_str())
.map(String::from)
.unwrap_or_else(|| "schema.graphql".to_string());

// Fetch or load ABI
let (abi, contract_name, start_block) = get_contract_info(&opt, &network).await?;

Expand All @@ -120,6 +130,9 @@ pub async fn run_add(opt: AddOpt) -> Result<()> {
// Add mapping file
add_mapping_file(project_dir, &contract_name, &events)?;

// Append entity types for the new contract's events to schema.graphql
add_schema_entities(project_dir, &schema_rel, &events)?;

// Update manifest
update_manifest(
&opt.manifest,
Expand Down Expand Up @@ -288,6 +301,68 @@ fn add_mapping_file(project_dir: &Path, contract_name: &str, events: &[EventInfo
Ok(())
}

/// Append entity types for the new data source's events to `schema.graphql`.
///
/// Entities whose type name already exists in the schema are skipped (with a
/// notice) so that re-running `add`, or adding a contract that shares event
/// names with an existing one, never produces duplicate type definitions that
/// would fail to compile.
fn add_schema_entities(project_dir: &Path, schema_rel: &str, events: &[EventInfo]) -> Result<()> {
let schema_path = project_dir.join(schema_rel);

let existing = fs::read_to_string(&schema_path).unwrap_or_default();
let mut existing_names = existing_type_names(&existing);

let mut new_blocks: Vec<String> = Vec::new();
for (name, block) in generate_event_entities(events) {
if existing_names.contains(&name) {
step(
Step::Skip,
&format!("Entity {} already in schema, skipping", name),
);
continue;
}
existing_names.insert(name);
new_blocks.push(block);
}

if new_blocks.is_empty() {
return Ok(());
}

// Build the appended content, preserving existing schema content and
// separating type definitions with a blank line.
let mut content = existing.trim_end().to_string();
if !content.is_empty() {
content.push_str("\n\n");
}
content.push_str(&new_blocks.join("\n\n"));
content.push('\n');

step(Step::Write, &format!("Updating {}", schema_path.display()));
fs::write(&schema_path, content).context("Failed to write schema.graphql")?;

Ok(())
}

/// Collect the names of all object types defined in a GraphQL schema.
///
/// Returns an empty set if the schema is empty or cannot be parsed (a malformed
/// schema is surfaced later by codegen's schema validation, not here).
fn existing_type_names(schema: &str) -> std::collections::HashSet<String> {
use gql::{Definition, TypeDefinition};

let mut names = std::collections::HashSet::new();
if let Ok(doc) = gql::parse_schema::<String>(schema) {
for def in &doc.definitions {
if let Definition::TypeDefinition(TypeDefinition::Object(obj)) = def {
names.insert(obj.name.clone());
}
}
}
names
}

/// Generate mapping handlers for events.
fn generate_mapping(contract_name: &str, events: &[EventInfo]) -> String {
let mut imports = String::new();
Expand Down Expand Up @@ -592,6 +667,77 @@ mod tests {
assert!(mapping.contains("../generated/schema"));
}

#[test]
fn test_existing_type_names() {
let schema = r#"
type Transfer @entity(immutable: true) { id: Bytes! }
type Approval @entity { id: Bytes! }
"#;
let names = existing_type_names(schema);
assert!(names.contains("Transfer"));
assert!(names.contains("Approval"));
assert_eq!(names.len(), 2);
}

#[test]
fn test_existing_type_names_empty_or_invalid() {
assert!(existing_type_names("").is_empty());
assert!(existing_type_names("this is not graphql {{{").is_empty());
}

fn transfer_event() -> EventInfo {
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,
},
EventInput {
name: "value".to_string(),
solidity_type: "uint256".to_string(),
indexed: false,
},
],
}
}

#[test]
fn test_add_schema_entities_appends_new_type() {
let dir = tempfile::tempdir().unwrap();
let schema = "type Existing @entity { id: Bytes! }\n";
fs::write(dir.path().join("schema.graphql"), schema).unwrap();

add_schema_entities(dir.path(), "schema.graphql", &[transfer_event()]).unwrap();

let updated = fs::read_to_string(dir.path().join("schema.graphql")).unwrap();
// Existing content preserved
assert!(updated.contains("type Existing @entity"));
// New entity appended with id and standard block fields
assert!(updated.contains("type Transfer @entity(immutable: true)"));
assert!(updated.contains("id: Bytes!"));
assert!(updated.contains("from: Bytes!"));
assert!(updated.contains("value: BigInt!"));
assert!(updated.contains("blockNumber: BigInt!"));
assert!(updated.contains("transactionHash: Bytes!"));
}

#[test]
fn test_add_schema_entities_skips_existing() {
let dir = tempfile::tempdir().unwrap();
// Schema already declares a Transfer type
let schema = "type Transfer @entity { id: Bytes! }\n";
fs::write(dir.path().join("schema.graphql"), schema).unwrap();

add_schema_entities(dir.path(), "schema.graphql", &[transfer_event()]).unwrap();

let updated = fs::read_to_string(dir.path().join("schema.graphql")).unwrap();
// The colliding type is skipped: still exactly one `type Transfer`
assert_eq!(updated.matches("type Transfer").count(), 1);
}

#[test]
fn test_generate_mapping_empty_events() {
let events: Vec<EventInfo> = vec![];
Expand Down
119 changes: 66 additions & 53 deletions gnd/src/commands/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use graph::abi::JsonAbi;
use graphql_tools::parser::schema as gql;
use semver::Version;

use crate::abi::normalize_abi_json;
use crate::abi::preprocess_abi_json;
use crate::codegen::{
AbiCodeGenerator, Class, GENERATED_FILE_NOTE, ModuleImports, SchemaCodeGenerator,
Template as CodegenTemplate, TemplateCodeGenerator, TemplateKind,
Expand Down Expand Up @@ -237,58 +237,6 @@ fn generate_schema_types(
Ok(true)
}

/// Preprocess ABI JSON to normalize artifact formats and add defaults
/// required by alloy's ABI parser:
/// - `anonymous: false` for events (alloy requires this field)
/// - `param{index}` names for unnamed event parameters (to match graph-cli behavior)
fn preprocess_abi_json(abi_str: &str) -> Result<String> {
// Normalize to get the ABI array from various artifact formats
let mut abi = normalize_abi_json(abi_str)?;

if let Some(items) = abi.as_array_mut() {
for item in items {
if let Some(obj) = item.as_object_mut() {
let is_event = obj
.get("type")
.and_then(|t| t.as_str())
.map(|t| t == "event")
.unwrap_or(false);

if is_event {
// Add anonymous: false for events if missing (alloy requires it)
if !obj.contains_key("anonymous") {
obj.insert("anonymous".to_string(), serde_json::Value::Bool(false));
}

// Add param{index} names for unnamed event parameters
if let Some(inputs) = obj.get_mut("inputs") {
add_default_event_param_names(inputs);
}
}
}
}
}

serde_json::to_string(&abi).context("Failed to serialize processed ABI")
}

/// Add `param{index}` names to unnamed event parameters to match graph-cli behavior.
/// Alloy defaults missing names to empty strings, but for events we want `param0`, `param1`, etc.
fn add_default_event_param_names(params: &mut serde_json::Value) {
if let Some(params_arr) = params.as_array_mut() {
for (index, param) in params_arr.iter_mut().enumerate() {
if let Some(obj) = param.as_object_mut()
&& !obj.contains_key("name")
{
obj.insert(
"name".to_string(),
serde_json::Value::String(format!("param{}", index)),
);
}
}
}
}

/// Generate types from an ABI file.
fn generate_abi_types(name: &str, abi_path: &Path, output_dir: &Path) -> Result<()> {
step(Step::Load, &format!("Load ABI from {}", abi_path.display()));
Expand Down Expand Up @@ -840,6 +788,71 @@ dataSources:
);
}

/// Test that codegen handles a NEAR manifest: it parses `kind: near`,
/// generates schema types, and produces no ABI directories (NEAR has no
/// ABIs). This guards the schema-only codegen path for non-Ethereum
/// protocols.
#[tokio::test]
async fn test_codegen_near_schema_only() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let output_dir = project_dir.join("generated");

let manifest_content = r#"
specVersion: 0.0.5
schema:
file: ./schema.graphql
dataSources:
- kind: near
name: receipts
network: near-mainnet
source:
account: wnear.flux-dev
startBlock: 100
mapping:
apiVersion: 0.0.5
language: wasm/assemblyscript
entities:
- ExampleEntity
receiptHandlers:
- handler: handleReceipt
file: ./src/receipts.ts
"#;
fs::write(project_dir.join("subgraph.yaml"), manifest_content).unwrap();

let schema_content = r#"
type ExampleEntity @entity(immutable: true) {
id: Bytes!
count: BigInt!
}
"#;
fs::write(project_dir.join("schema.graphql"), schema_content).unwrap();
fs::create_dir_all(project_dir.join("src")).unwrap();
fs::write(project_dir.join("src/receipts.ts"), "").unwrap();

let opt = CodegenOpt {
manifest: project_dir.join("subgraph.yaml"),
output_dir: output_dir.clone(),
skip_migrations: true,
watch: false,
ipfs: "https://api.thegraph.com/ipfs/api/v0".to_string(),
};
generate_types(&opt).await.unwrap();

// Schema types are generated...
assert!(
output_dir.join("schema.ts").exists(),
"schema.ts should be generated for NEAR"
);
let schema_ts = fs::read_to_string(output_dir.join("schema.ts")).unwrap();
assert!(schema_ts.contains("export class ExampleEntity"));
// ...but no per-data-source ABI directory is created.
assert!(
!output_dir.join("receipts").exists(),
"NEAR data source must not produce an ABI directory"
);
}

/// Test that codegen fails when referenced ABI file does not exist.
///
/// Before Step 2, codegen did not validate the manifest. Now it calls
Expand Down
Loading
Loading