Skip to content
Merged
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
62 changes: 62 additions & 0 deletions packages/cli/src/config_parsing/entity_parsing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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<Self> {
let mut by_capitalized: HashMap<String, Vec<String>> = 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::<Vec<_>>();

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<TypeDef<'_>> {
match (self.entities.get(name), self.enums.get(name)) {
(None, None) => Err(anyhow!("No type definition '{}' exists in schema", name)),
Expand Down Expand Up @@ -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#"
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/hbs_templating/codegen_templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1484,7 +1484,7 @@ switch chainId {{
.map(|entity| {
format!(
" \\\"{}\": handlerEntityOperations<Entities.{}.t, Entities.{}.getWhereFilter>,",
entity.name.original,
entity.name.capitalized,
entity.name.capitalized,
entity.name.capitalized,
)
Expand Down Expand Up @@ -1818,7 +1818,7 @@ type testIndexerEntityOperations<'entity> = {
.map(|entity| {
format!(
" \\\"{}\": testIndexerEntityOperations<Entities.{}.t>,",
entity.name.original, entity.name.capitalized,
entity.name.capitalized, entity.name.capitalized,
)
})
.collect::<Vec<_>>()
Expand Down
55 changes: 55 additions & 0 deletions packages/envio-tests/test/MockIndexerHandlers_test.res
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the issue-link-only comment.

This comment records issue history but does not explain behavior the code cannot show. Remove it or replace it with a concise invariant explaining why lowercase entity names are covered.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/envio-tests/test/MockIndexerHandlers_test.res` at line 44, Remove
the issue-link-only comment near the lowercase entity-name coverage in
MockIndexerHandlers_test.res; do not add replacement text unless a concise
invariant is needed to explain behavior not evident from the test.

Source: Coding guidelines

// 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(
() =>
Expand Down
10 changes: 10 additions & 0 deletions packages/envio-tests/test/UserApiValidation_test.res
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
Expand Down
6 changes: 5 additions & 1 deletion packages/envio/src/Config.res
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +998 to 1007

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "fromArray|capitalize|userEntitiesByName" \
  packages/envio/src/Config.res \
  packages/envio/src/TestIndexer.res \
  packages/envio/src/Utils.res

Repository: enviodev/hyperindex

Length of output: 2910


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '930,1060p' packages/envio/src/Config.res
printf '\n---\n'
sed -n '180,220p' packages/envio/src/TestIndexer.res
printf '\n---\n'
sed -n '690,720p' packages/envio/src/TestIndexer.res
printf '\n---\n'
sed -n '415,440p' packages/envio/src/Utils.res
printf '\n---\n'
sed -n '615,640p' packages/envio/src/Utils.res

Repository: enviodev/hyperindex

Length of output: 9025


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "duplicate|collision|unique|entity.*name|name.*duplicate|capitalize" packages/envio/src/Config.res packages/envio/src -g '!**/node_modules/**'

Repository: enviodev/hyperindex

Length of output: 11730


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' packages/envio/src/Utils.res | sed -n '120,220p'
printf '\n---\n'
sed -n '560,700p' packages/envio/src/Utils.res

Repository: enviodev/hyperindex

Length of output: 7761


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "entityConfig|userEntities|entities" packages/envio/src/Config.res -n

Repository: enviodev/hyperindex

Length of output: 1386


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "entity.*(set|unique|duplicate|collision)|Dict\\.fromArray|fromArrayUnsafe|capitalize" packages/envio/src/Config.res packages/envio/src -g '!**/node_modules/**'

Repository: enviodev/hyperindex

Length of output: 5713


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "^[^/].*name.*regex|regex.*name|lowercase|snake_case|entity name|contract name|validate.*name|allowed.*name" packages/envio/src/Config.res packages/envio/src -g '!**/node_modules/**'

Repository: enviodev/hyperindex

Length of output: 5368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '400,560p' packages/envio/src/Config.res
printf '\n---\n'
sed -n '560,700p' packages/envio/src/Config.res
printf '\n---\n'
rg -n "entityJsonSchema|name:|parseEntitiesFromJson|capitalize" packages/envio/src/Config.res packages/envio/src -g '!**/node_modules/**'

Repository: enviodev/hyperindex

Length of output: 17702


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '400,560p' packages/envio/src/Config.res
printf '\n---\n'
sed -n '560,700p' packages/envio/src/Config.res
printf '\n---\n'
rg -n "entityJsonSchema|parseEntitiesFromJson|capitalize|name:" packages/envio/src/Config.res packages/envio/src -g '!**/node_modules/**'

Repository: enviodev/hyperindex

Length of output: 17702


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '320,420p' packages/envio/src/Config.res
printf '\n---\n'
python3 - <<'PY'
def capitalize(s):
    return s[:1].upper() + s[1:]
samples = ["foo", "Foo", "fOo", "bar", "Bar", "pool_snapshots", "Pool_snapshots"]
mapped = {}
for s in samples:
    k = capitalize(s)
    mapped.setdefault(k, []).append(s)
print(mapped)
PY

Repository: enviodev/hyperindex

Length of output: 3780


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "^module Dict|fromArray" packages/envio/src/Utils.res
printf '\n---\n'
sed -n '500,650p' packages/envio/src/Utils.res

Repository: enviodev/hyperindex

Length of output: 5467


Reject capitalized entity-name collisions in config parsing. parseEntitiesFromJson accepts arbitrary strings, and Utils.String.capitalize can map distinct entities like foo and Foo to the same userEntitiesByName key. Enforce uniqueness before Dict.fromArray in packages/envio/src/Config.res so one entity can’t silently shadow another; the TestIndexer maps can keep using the capitalized accessor keys.

📍 Affects 2 files
  • packages/envio/src/Config.res#L998-L1007 (this comment)
  • packages/envio/src/TestIndexer.res#L202-L206
  • packages/envio/src/TestIndexer.res#L706-L708
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/envio/src/Config.res` around lines 998 - 1007, Update
parseEntitiesFromJson in packages/envio/src/Config.res at lines 998-1007 to
detect duplicate keys produced by capitalizing entityConfig.name before calling
Dict.fromArray, and reject the configuration with the existing parsing error
mechanism instead of allowing shadowing. Keep the capitalized accessor-key
behavior unchanged; the TestIndexer mappings at
packages/envio/src/TestIndexer.res lines 202-206 and 706-708 require no direct
changes.


Expand Down
10 changes: 8 additions & 2 deletions packages/envio/src/TestIndexer.res
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,11 @@ let handleWriteBatch = (
if deleted->Array.length > 0 {
entityObj->Dict.set("deleted", deleted->(Utils.magic: array<string> => unknown))
}
change->Dict.set(entityName, entityObj->(Utils.magic: dict<unknown> => unknown))
// Match the capitalized entity accessor the generated change types expose.
change->Dict.set(
entityName->Utils.String.capitalize,
entityObj->(Utils.magic: dict<unknown> => unknown),
)
}
})
| None => ()
Expand Down Expand Up @@ -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(
Expand Down