diff --git a/packages/cli/src/config_parsing/entity_parsing.rs b/packages/cli/src/config_parsing/entity_parsing.rs index 3b68e9dea..ba2309468 100644 --- a/packages/cli/src/config_parsing/entity_parsing.rs +++ b/packages/cli/src/config_parsing/entity_parsing.rs @@ -127,6 +127,7 @@ impl Schema { self.check_enum_type_defs()? .check_schema_for_reserved_words()? .check_duplicate_naming_between_enums_and_entities()? + .check_capitalized_entity_name_collisions()? .check_related_type_defs_exist()? .validate_entity_field_types() } @@ -187,6 +188,41 @@ impl Schema { } } + // The handler context and generated types expose each entity under its + // capitalized name, so entities whose names differ only by the first + // letter's case (e.g. `user` and `User`) would map to the same accessor + // and silently shadow each other at runtime. + fn check_capitalized_entity_name_collisions(self) -> anyhow::Result { + let mut by_capitalized: HashMap> = HashMap::new(); + for name in self.entities.keys() { + by_capitalized + .entry(name.capitalize()) + .or_default() + .push(name.clone()); + } + + let mut collisions = by_capitalized + .into_iter() + .filter(|(_, names)| names.len() > 1) + .map(|(capitalized, mut names)| { + names.sort(); + format!("{} (from {})", capitalized, names.join(", ")) + }) + .collect::>(); + + if collisions.is_empty() { + Ok(self) + } else { + collisions.sort(); + Err(anyhow!( + "Schema contains entities whose names collide when capitalized. Each entity is \ + exposed on the handler context under its capitalized name, so these must be \ + unique: {}", + collisions.join("; ") + )) + } + } + fn try_get_type_def(&self, name: &String) -> anyhow::Result> { match (self.entities.get(name), self.enums.get(name)) { (None, None) => Err(anyhow!("No type definition '{}' exists in schema", name)), @@ -2240,6 +2276,32 @@ type TestEntity { assert_eq!(pg_field.linked_entity, None); } + #[test] + fn rejects_entities_that_collide_when_capitalized() { + let schema_str = r#" +type user { id: ID! } +type User { id: ID! } + "#; + let err = Schema::from_string(schema_str) + .expect_err("expected a capitalized entity-name collision error"); + let message = format!("{err:#}"); + assert!( + message.contains("collide when capitalized") + && message.contains("User (from User, user)"), + "unexpected error: {message}" + ); + } + + #[test] + fn allows_entities_that_are_unique_when_capitalized() { + let schema_str = r#" +type user { id: ID! } +type post { id: ID! } + "#; + let schema = Schema::from_string(schema_str).unwrap(); + assert_eq!(schema.entities.len(), 2); + } + #[test] fn test_decimal_precision_config_happy_path() { let schema_str = r#" diff --git a/packages/cli/src/hbs_templating/codegen_templates.rs b/packages/cli/src/hbs_templating/codegen_templates.rs index d1858f0b4..541d24deb 100644 --- a/packages/cli/src/hbs_templating/codegen_templates.rs +++ b/packages/cli/src/hbs_templating/codegen_templates.rs @@ -1484,7 +1484,7 @@ switch chainId {{ .map(|entity| { format!( " \\\"{}\": handlerEntityOperations,", - entity.name.original, + entity.name.capitalized, entity.name.capitalized, entity.name.capitalized, ) @@ -1818,7 +1818,7 @@ type testIndexerEntityOperations<'entity> = { .map(|entity| { format!( " \\\"{}\": testIndexerEntityOperations,", - entity.name.original, entity.name.capitalized, + entity.name.capitalized, entity.name.capitalized, ) }) .collect::>() diff --git a/packages/envio-tests/test/MockIndexerHandlers_test.res b/packages/envio-tests/test/MockIndexerHandlers_test.res index 7569cf222..0f737e979 100644 --- a/packages/envio-tests/test/MockIndexerHandlers_test.res +++ b/packages/envio-tests/test/MockIndexerHandlers_test.res @@ -41,6 +41,61 @@ indexer.onEvent({ contract: "Token", event: "Transfer" }, async ({ event, contex t.expect(config.name).toBe("mock-handlers") }) + // https://github.com/enviodev/hyperindex/issues/1478 + // Lowercase schema entity names keep their original casing for the GraphQL + // schema and the physical Postgres/ClickHouse tables, while the handler + // context accessor is capitalized to match the generated types. + it("capitalizes the context accessor but keeps physical names lowercase", t => { + let {config} = InternalTestIndexer.fromUserApi( + ~schema=` +type pool_snapshots { + id: ID! + value: BigInt! + owner: user_account! +} + +type user_account { + id: ID! + snapshots: [pool_snapshots!]! @derivedFrom(field: "owner") +} +`, + ~handlers=` +import { indexer } from "envio"; + +indexer.onEvent({ contract: "Token", event: "Transfer" }, async ({ event, context }) => { + context.User_account.set({ id: event.params.to }); + context.Pool_snapshots.set({ + id: event.params.to, + value: event.params.value, + owner_id: event.params.to, + }); +}); +`, + ~configYaml=yaml, + ) + let poolSnapshots = config.userEntitiesByName->Dict.getUnsafe("Pool_snapshots") + let userAccount = config.userEntitiesByName->Dict.getUnsafe("User_account") + t.expect({ + "accessorKeys": config.userEntitiesByName->Dict.keysToArray->Array.toSorted(String.compare), + "physicalNames": [poolSnapshots.name, userAccount.name]->Array.toSorted(String.compare), + "tableNames": [poolSnapshots.table.tableName, userAccount.table.tableName]->Array.toSorted( + String.compare, + ), + "linkedEntities": poolSnapshots.table + ->Table.getLinkedEntityFields + ->Array.map(((_, linkedEntityName)) => linkedEntityName), + "derivedFromEntities": userAccount.table + ->Table.getDerivedFromFields + ->Array.map(df => df.derivedFromEntity), + }).toEqual({ + "accessorKeys": ["Pool_snapshots", "User_account"], + "physicalNames": ["pool_snapshots", "user_account"], + "tableNames": ["pool_snapshots", "user_account"], + "linkedEntities": ["user_account"], + "derivedFromEntities": ["pool_snapshots"], + }) + }) + it("throws the exact diagnostic on a nonexistent event", t => { t.expect( () => diff --git a/packages/envio-tests/test/UserApiValidation_test.res b/packages/envio-tests/test/UserApiValidation_test.res index 1c201f9da..45c779843 100644 --- a/packages/envio-tests/test/UserApiValidation_test.res +++ b/packages/envio-tests/test/UserApiValidation_test.res @@ -1534,6 +1534,16 @@ type Token @storage { `, "Config parse error: Failed converting schema doc to schema struct: Failed constructing entities in schema from document: @storage on \`Token\` enables no storage. At least one of {postgres, clickhouse} must be true.", ), + ( + // Entities are exposed on the handler context under their capitalized + // name, so two entities differing only in first-letter case collide. + "rejects entity names that collide when capitalized", + ` +type user { id: ID! } +type User { id: ID! } +`, + "Config parse error: Failed converting schema doc to schema struct: Schema contains entities whose names collide when capitalized. Each entity is exposed on the handler context under its capitalized name, so these must be unique: User (from User, user)", + ), ]->Array.forEach(((name, schema, message)) => { it(name, t => expectParseError(t, ~schema, baseYaml, message)) }) diff --git a/packages/envio/src/Config.res b/packages/envio/src/Config.res index 8e7e41c45..48860a89e 100644 --- a/packages/envio/src/Config.res +++ b/packages/envio/src/Config.res @@ -995,10 +995,14 @@ let fromPublic = (publicConfigJson: JSON.t) => { let allEntities = userEntities->Array.concat([EnvioAddresses.entityConfig]) + // Keyed by the capitalized entity name to match the handler-context + // accessor (`context.Pool_snapshots`) the generated types expose, while + // entityConfig.name stays the original schema name used for the physical + // Postgres/ClickHouse tables. let userEntitiesByName = userEntities ->Array.map(entityConfig => { - (entityConfig.name, entityConfig) + (entityConfig.name->Utils.String.capitalize, entityConfig) }) ->Dict.fromArray diff --git a/packages/envio/src/TestIndexer.res b/packages/envio/src/TestIndexer.res index 042a440e7..29342fa5d 100644 --- a/packages/envio/src/TestIndexer.res +++ b/packages/envio/src/TestIndexer.res @@ -199,7 +199,11 @@ let handleWriteBatch = ( if deleted->Array.length > 0 { entityObj->Dict.set("deleted", deleted->(Utils.magic: array => unknown)) } - change->Dict.set(entityName, entityObj->(Utils.magic: dict => unknown)) + // Match the capitalized entity accessor the generated change types expose. + change->Dict.set( + entityName->Utils.String.capitalize, + entityObj->(Utils.magic: dict => unknown), + ) } }) | None => () @@ -699,7 +703,9 @@ let createTestIndexer = (): t<'processConfig> => { entityOpsDict ->Dict.toArray ->Array.forEach(((name, ops)) => { - result->Dict.set(name, ops->(Utils.magic: entityOperations => unknown)) + // Expose the capitalized accessor (indexer.Pool_snapshots) the generated + // types declare, matching the handler-context keys. + result->Dict.set(name->Utils.String.capitalize, ops->(Utils.magic: entityOperations => unknown)) }) result->Dict.set(