Skip to content

Commit 2c778e4

Browse files
Merge pull request #245 from rainlanguage/2026-08-25-issue-151
Declare Transaction in mapping.entities, and pin the list to the schema
2 parents b2932e5 + 51b9148 commit 2c778e4

4 files changed

Lines changed: 122 additions & 11 deletions

File tree

‎CLAUDE.md‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,11 @@ means adding it to `LibCopyArtifacts.contracts()` AND
2929

3030
`foundry.toml`'s `ffi = true` and its filesystem permissions exist for
3131
`CopyArtifacts` and for `test/subgraph/`, which reads the built interface
32-
artifact with `jq`, the subgraph manifest and compose file with `yq`, and the
33-
git index with `git` — all over `vm.ffi`, so none of them needs an
34-
`fs_permissions` entry, and all three binaries have to be on `PATH` in the sol
35-
shell. Nothing else here shells out or touches the filesystem.
32+
artifact and `subgraph/schema.graphql` with `jq`, the subgraph manifest and
33+
compose file with `yq`, and the git index with `git` — all over `vm.ffi`, so
34+
none of them needs an `fs_permissions` entry, and all three binaries have to be
35+
on `PATH` in the sol shell. Nothing else here shells out or touches the
36+
filesystem.
3637

3738
## The subgraph is SOURCE only (#149)
3839

@@ -61,7 +62,10 @@ cheatcodes at a path — not as text. A substring search answers a different
6162
question: a commented-out line satisfies a positive one, and a key respelled
6263
`address :` or `"address":` or moved into a flow mapping defeats a negative one.
6364
So new manifest assertions name the node they are about; do not add one that
64-
greps the file.
65+
greps the file. `schema.graphql` is the one file it does read by line, because
66+
no parser for it is in this lane — guarded by counting the `@entity` lines and
67+
asserting the types read against that count, so a shape it cannot read fails
68+
instead of under-reporting.
6569

6670
## Licensing
6771

‎foundry.toml‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,20 @@ cbor_metadata = false
1414
# `script/CopyArtifacts.sol` and its freshness test shell out to `jq` via
1515
# `vm.ffi` to extract the deterministic subset of the forge artifact,
1616
# `test/subgraph/SubgraphManifest.t.sol` shells out to `jq` for that artifact's
17-
# event signatures and to `yq` to read `subgraph/subgraph.yaml` as parsed YAML
18-
# rather than as text, and `test/subgraph/SubgraphMatchstick.t.sol` shells out
19-
# to `git` for what the index tracks and to `yq` for the compose file. All
20-
# three are on `PATH` in the rainix sol shell.
17+
# event signatures and for `subgraph/schema.graphql`'s entity types and to `yq`
18+
# to read `subgraph/subgraph.yaml` as parsed YAML rather than as text, and
19+
# `test/subgraph/SubgraphMatchstick.t.sol` shells out to `git` for what the
20+
# index tracks and to `yq` for the compose file. All three are on `PATH` in the
21+
# rainix sol shell.
2122
ffi = true
2223

2324
libs = ["dependencies"]
2425

2526
# The filesystem access in this repo. `script/CopyArtifacts.sol` reads forge
2627
# build output and writes the stable subset for the rust bindings to consume;
2728
# `test/script/CopyArtifacts.t.sol` reads both back to assert the committed copy
28-
# is fresh. The `test/subgraph/` tests need no entry: they read through `yq` and
29-
# `git` over `vm.ffi`, which this list does not gate.
29+
# is fresh. The `test/subgraph/` tests need no entry: they read through `jq`,
30+
# `yq` and `git` over `vm.ffi`, which this list does not gate.
3031
fs_permissions = [
3132
{ access = "read", path = "out/" },
3233
{ access = "read", path = "test/fixtures/" },

‎subgraph/subgraph.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dataSources:
1414
entities:
1515
- MetaBoard
1616
- MetaV1
17+
- Transaction
1718
abis:
1819
- name: MetaBoard
1920
file: ../out/IMetaBoardV1_2.sol/IMetaBoardV1_2.json

‎test/subgraph/SubgraphManifest.t.sol‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ contract SubgraphManifestTest is Test {
4141
/// The subgraph manifest, as a template: no deployment fact in it.
4242
string constant SUBGRAPH_YAML = "subgraph/subgraph.yaml";
4343

44+
/// The schema the mapping stores into, and the other side of the
45+
/// `mapping.entities` check.
46+
string constant SCHEMA_GRAPHQL = "subgraph/schema.graphql";
47+
4448
/// The interface whose ABI the manifest decodes with. Named rather than
4549
/// read out of the manifest, so that the manifest is compared to an
4650
/// expectation instead of to itself.
@@ -277,6 +281,107 @@ contract SubgraphManifestTest is Test {
277281
);
278282
}
279283

284+
/// The schema's entity declarations, as JSON: `types` are the entity type
285+
/// names, `declarations` is how many lines declare one.
286+
///
287+
/// A LINE read, which every manifest check here is forbidden from being,
288+
/// because there is no schema parser to reach for: this lane deliberately
289+
/// has no node, and `yq` does not speak GraphQL. `declarations` is what
290+
/// makes that safe. It counts every non-comment line carrying the entity
291+
/// directive at all, and `schemaEntityTypes` holds `types` to that count,
292+
/// so a schema written in a shape this cannot read — the directive on a
293+
/// line of its own, a description carrying the word — fails loudly rather
294+
/// than silently reporting fewer entities than the file declares. Comment
295+
/// lines are dropped first, since a commented-out declaration is not one,
296+
/// which is the same distinction reading the manifest as YAML gets for
297+
/// free.
298+
/// @return The `{declarations, types}` object as JSON.
299+
function schemaEntityJson() internal returns (string memory) {
300+
string[] memory cmd = new string[](4);
301+
cmd[0] = "jq";
302+
cmd[1] = "-Rn";
303+
cmd[2] = "[inputs | select(test(\"^[[:space:]]*#\") | not) | select(test(\"@entity\"))]"
304+
" | {declarations: length, types: [.[]"
305+
" | scan(\"^type[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]+@entity\") | .[0]]}";
306+
cmd[3] = SCHEMA_GRAPHQL;
307+
return string(vm.ffi(cmd));
308+
}
309+
310+
/// Every entity type the schema declares.
311+
/// @return The entity type names.
312+
function schemaEntityTypes() internal returns (string[] memory) {
313+
string memory json = schemaEntityJson();
314+
string[] memory types = vm.parseJsonStringArray(json, ".types");
315+
assertEq(
316+
types.length,
317+
vm.parseJsonUint(json, ".declarations"),
318+
"schema.graphql declares @entity on a line that is not a type declaration this can read"
319+
);
320+
return types;
321+
}
322+
323+
/// The manifest MUST declare the entities the mapping stores.
324+
///
325+
/// `mapping.entities` is the manifest's claim about which schema entities
326+
/// the handler writes. Nothing downstream contradicts a stale one —
327+
/// graph-node does not enforce the list at runtime and `graph codegen` does
328+
/// not read it — which is how `Transaction`, stored by
329+
/// `createTransactionEntity` on every event, sat unlisted
330+
/// (rainlanguage/rain.metadata#151).
331+
///
332+
/// The schema is the other side, rather than a list restated here, because
333+
/// it is a separate file that codegen does read, and because
334+
/// `dataSources` is pinned to one entry: there is exactly one mapping for
335+
/// its entities to belong to, so an entity the schema declares is one that
336+
/// mapping stores or dead schema. Both directions are asserted and neither
337+
/// implies the other: a schema entity the manifest omits is #151 again, and
338+
/// a manifest entity the schema does not declare is a name `graph build`
339+
/// rejects.
340+
function testManifestDeclaresTheEntitiesTheMappingStores() external {
341+
string memory json = manifestJson();
342+
string memory entitiesPath = string.concat(THE_DATA_SOURCE, ".mapping.entities");
343+
assertTrue(vm.keyExistsJson(json, entitiesPath), "subgraph.yaml declares no mapping.entities");
344+
345+
string[] memory declared = vm.parseJsonStringArray(json, entitiesPath);
346+
string[] memory schema = schemaEntityTypes();
347+
348+
for (uint256 i = 0; i < schema.length; i++) {
349+
assertTrue(
350+
_holds(declared, schema[i]),
351+
string.concat(
352+
"subgraph.yaml's mapping.entities omits ", schema[i], ", which schema.graphql declares @entity"
353+
)
354+
);
355+
}
356+
for (uint256 i = 0; i < declared.length; i++) {
357+
assertTrue(
358+
_holds(schema, declared[i]),
359+
string.concat(
360+
"subgraph.yaml's mapping.entities declares ",
361+
declared[i],
362+
", which schema.graphql declares no @entity type for"
363+
)
364+
);
365+
}
366+
assertEq(
367+
declared.length,
368+
schema.length,
369+
"subgraph.yaml's mapping.entities and schema.graphql name the same entities, one of them twice"
370+
);
371+
}
372+
373+
/// @param names The names to search.
374+
/// @param name The name to find.
375+
/// @return Whether the list holds the name.
376+
function _holds(string[] memory names, string memory name) internal pure returns (bool) {
377+
for (uint256 i = 0; i < names.length; i++) {
378+
if (keccak256(bytes(names[i])) == keccak256(bytes(name))) {
379+
return true;
380+
}
381+
}
382+
return false;
383+
}
384+
280385
/// Every node in the document keyed `network` that is not the placeholder,
281386
/// by path.
282387
///

0 commit comments

Comments
 (0)