Add a CLI to compare entity query results from two GQL endpoints (#57)

* Add CLI to compare entities from two GQL endpoints

* Print out result diffs in compare-entity CLI

* Get the colorized result diff in compare-entity CLI

* Read query dir from config file or as an arg

* Make config file arg required

* Make queries in Example schema similar to that in graph-node

* Get non-colorized output on choosing raw-json diff option

* Make queries in eden-watcher similar to that in graph-node
This commit is contained in:
prathamesh0
2021-12-28 16:08:05 +05:30
committed by nabarun
parent 31b302c9b5
commit d979e51723
35 changed files with 535 additions and 413 deletions
+42
View File
@@ -36,3 +36,45 @@
6. Run `yarn build:example` to build the wasm files.
7. Run `yarn test`.
## Run
* Compare query results from two different GQL endpoints:
* In a config file (sample: `environments/compare-cli-config.toml`):
* Specify the two GQL endpoints in the endpoints config.
* Specify the query directory in queries config or pass as an arg. to the CLI.
* Example:
```
[endpoints]
gqlEndpoint1 = "http://localhost:8000/subgraphs/name/example1"
gqlEndpoint2 = "http://localhost:3008/graphql"
[queries]
queryDir = "../graph-test-watcher/src/gql/queries"
```
* Fire a query and get the diff of the results from the two GQL endpoints:
```bash
yarn compare-entity --config-file <config-file-path> --query-dir [query-dir] --query-name <query-name> --block-hash <block-hash> --entity-id <entity-id> --raw-json [true | false]
```
* `config-file`(alias: `cf`): Configuration file path (toml) (required).
* `query-dir`(alias: `qf`): Path to queries directory (defualt: taken from the config file).
* `query-name`(alias: `q`): Query to be fired (required).
* `block-hash`(alias: `b`): Block hash (required).
* `entity-id`(alias: `i`): Entity Id (required).
* `raw-json`(alias: `j`): Whether to print out a raw diff object (default: `false`).
Example:
```bash
yarn compare-entity --config-file environments/compare-cli-config.toml --query-name exampleEntity --block-hash 0xceed7ee9d3de97c99db12e42433cae9115bb311c516558539fb7114fa17d545b --entity-id 0x2886bae64814bd959aec4282f86f3a97bf1e16e4111b39fd7bdd592b516c66c6
```
* The program will exit with code `1` if the query results are not equal.
@@ -0,0 +1,6 @@
[endpoints]
gqlEndpoint1 = "http://localhost:8000/subgraphs/name/example1"
gqlEndpoint2 = "http://localhost:3008/graphql"
[queries]
queryDir = "../graph-test-watcher/src/gql/queries"
+14 -2
View File
@@ -32,13 +32,25 @@
"asbuild": "yarn asbuild:debug && yarn asbuild:release",
"test": "yarn asbuild:debug && mocha src/**/*.test.ts",
"build:example": "cd test/subgraph/example1 && yarn && yarn build",
"watch": "DEBUG=vulcanize:* nodemon --watch src src/watcher.ts"
"watch": "DEBUG=vulcanize:* nodemon --watch src src/watcher.ts",
"compare-entity": "DEBUG=vulcanize:* ts-node src/cli/compare/compare-entity.ts"
},
"dependencies": {
"@apollo/client": "^3.3.19",
"@types/json-diff": "^0.5.2",
"@vulcanize/assemblyscript": "0.0.1",
"@vulcanize/ipld-eth-client": "^0.1.0",
"@vulcanize/util": "^0.1.0",
"debug": "^4.3.1",
"decimal.js": "^10.3.1",
"fs-extra": "^10.0.0",
"js-yaml": "^4.1.0",
"json-bigint": "^1.0.0",
"json-diff": "^0.5.4",
"lodash": "^4.17.21",
"reflect-metadata": "^0.1.13",
"toml": "^3.0.0",
"typeorm": "^0.2.32",
"decimal.js": "^10.3.1"
"yargs": "^17.0.1"
}
}
@@ -0,0 +1,38 @@
//
// Copyright 2021 Vulcanize, Inc.
//
import assert from 'assert';
import fs from 'fs';
import path from 'path';
import { gql } from '@apollo/client/core';
import { GraphQLClient, GraphQLConfig } from '@vulcanize/ipld-eth-client';
export class Client {
_config: GraphQLConfig;
_graphqlClient: GraphQLClient;
_queryDir: string;
constructor (config: GraphQLConfig, queryDir: string) {
this._config = config;
this._queryDir = path.resolve(process.cwd(), queryDir);
const { gqlEndpoint } = config;
assert(gqlEndpoint, 'Missing gql endpoint');
this._graphqlClient = new GraphQLClient(config);
}
async getEntity ({ blockHash, queryName, id }: { blockHash: string, queryName: string, id: string }): Promise<any> {
const entityQuery = fs.readFileSync(path.resolve(this._queryDir, `${queryName}.gql`), 'utf8');
return this._graphqlClient.query(
gql(entityQuery),
{
id,
blockHash
}
);
}
}
@@ -0,0 +1,150 @@
//
// Copyright 2021 Vulcanize, Inc.
//
import yargs from 'yargs';
import 'reflect-metadata';
import path from 'path';
import toml from 'toml';
import fs from 'fs-extra';
import assert from 'assert';
import util from 'util';
import { diffString, diff } from 'json-diff';
import { Client } from './client';
interface EndpointConfig {
gqlEndpoint1: string;
gqlEndpoint2: string;
}
interface QueryConfig {
queryDir: string;
}
interface Config {
endpoints: EndpointConfig;
queries: QueryConfig;
}
const main = async (): Promise<void> => {
const argv = await yargs.parserConfiguration({
'parse-numbers': false
}).options({
configFile: {
alias: 'cf',
type: 'string',
demandOption: true,
describe: 'Configuration file path (toml)'
},
queryDir: {
alias: 'qf',
type: 'string',
describe: 'Path to queries directory'
},
blockHash: {
alias: 'b',
type: 'string',
demandOption: true,
describe: 'Blockhash'
},
queryName: {
alias: 'q',
type: 'string',
demandOption: true,
describe: 'Query name'
},
entityId: {
alias: 'i',
type: 'string',
demandOption: true,
describe: 'Id of the entity to be queried'
},
rawJson: {
alias: 'j',
type: 'boolean',
describe: 'Whether to print out raw diff object',
default: false
}
}).argv;
const config: Config = await getConfig(argv.configFile);
const { client1, client2 } = await getClients(config, argv.queryDir);
const queryName = argv.queryName;
const id = argv.entityId;
const blockHash = argv.blockHash;
const result1 = await client1.getEntity({ blockHash, queryName, id });
const result2 = await client2.getEntity({ blockHash, queryName, id });
// Getting the diff of two result objects.
let resultDiff;
if (argv.rawJson) {
resultDiff = diff(result1, result2);
if (resultDiff) {
// Use util.inspect to extend depth limit in the output.
resultDiff = util.inspect(diff(result1, result2), false, null);
}
} else {
resultDiff = diffString(result1, result2);
}
if (resultDiff) {
console.log(resultDiff);
process.exit(1);
}
};
async function getConfig (configFile: string): Promise<Config> {
const configFilePath = path.resolve(configFile);
const fileExists = await fs.pathExists(configFilePath);
if (!fileExists) {
throw new Error(`Config file not found: ${configFilePath}`);
}
const config = toml.parse(await fs.readFile(configFilePath, 'utf8'));
return config;
}
async function getClients (config: Config, queryDir?: string): Promise<{
client1: Client,
client2: Client
}> {
assert(config.endpoints, 'Missing endpoints config');
const gqlEndpoint1 = config.endpoints.gqlEndpoint1;
const gqlEndpoint2 = config.endpoints.gqlEndpoint2;
assert(gqlEndpoint1, 'Missing endpoint one');
assert(gqlEndpoint2, 'Missing endpoint two');
if (!queryDir) {
assert(config.queries, 'Missing queries config');
queryDir = config.queries.queryDir;
}
assert(queryDir, 'Query directory not provided');
const client1 = new Client({
gqlEndpoint: gqlEndpoint1
}, queryDir);
const client2 = new Client({
gqlEndpoint: gqlEndpoint2
}, queryDir);
return {
client1,
client2
};
}
main().catch(err => {
console.log(err);
}).finally(() => {
process.exit(0);
});
+22 -18
View File
@@ -40,7 +40,8 @@ export class Database {
return this._baseDatabase.close();
}
async getEntity<Entity> (entity: (new () => Entity) | string, id: string, blockHash: string): Promise<Entity | undefined> {
async getEntity<Entity> (entity: (new () => Entity) | string, id: string, blockHash?: string): Promise<Entity | undefined> {
// TODO: Take block number as an optional argument
const queryRunner = this._conn.createQueryRunner();
try {
@@ -73,33 +74,36 @@ export class Database {
}
}
async getEntityWithRelations<Entity> (entity: (new () => Entity) | string, id: string, blockHash: string, relations: { [key: string]: any }): Promise<Entity | undefined> {
async getEntityWithRelations<Entity> (entity: (new () => Entity) | string, id: string, relations: { [key: string]: any }, blockHash?: string): Promise<Entity | undefined> {
const queryRunner = this._conn.createQueryRunner();
try {
const repo = queryRunner.manager.getRepository(entity);
// Fetching blockHash for previous entity in frothy region.
const { blockHash: entityblockHash, blockNumber, id: frothyId } = await this._baseDatabase.getFrothyEntity(queryRunner, repo, { blockHash, id });
let selectQueryBuilder = repo.createQueryBuilder('entity');
if (frothyId) {
// If entity found in frothy region.
selectQueryBuilder = selectQueryBuilder.where('entity.block_hash = :entityblockHash', { entityblockHash });
} else {
// If entity not in frothy region.
const canonicalBlockNumber = blockNumber + 1;
selectQueryBuilder = selectQueryBuilder.where('entity.id = :id', { id })
.orderBy('entity.block_number', 'DESC')
.limit(1);
selectQueryBuilder = selectQueryBuilder.innerJoinAndSelect('block_progress', 'block', 'block.block_hash = entity.block_hash')
.where('block.is_pruned = false')
.andWhere('entity.block_number <= :canonicalBlockNumber', { canonicalBlockNumber })
.orderBy('entity.block_number', 'DESC')
.limit(1);
// Use blockHash if provided.
if (blockHash) {
// Fetching blockHash for previous entity in frothy region.
const { blockHash: entityblockHash, blockNumber, id: frothyId } = await this._baseDatabase.getFrothyEntity(queryRunner, repo, { blockHash, id });
if (frothyId) {
// If entity found in frothy region.
selectQueryBuilder = selectQueryBuilder.andWhere('entity.block_hash = :entityblockHash', { entityblockHash });
} else {
// If entity not in frothy region.
const canonicalBlockNumber = blockNumber + 1;
selectQueryBuilder = selectQueryBuilder.innerJoinAndSelect('block_progress', 'block', 'block.block_hash = entity.block_hash')
.andWhere('block.is_pruned = false')
.andWhere('entity.block_number <= :canonicalBlockNumber', { canonicalBlockNumber });
}
}
selectQueryBuilder = selectQueryBuilder.andWhere('entity.id = :id', { id });
// TODO: Implement query for nested relations.
Object.entries(relations).forEach(([field, data], index) => {
const { entity: relatedEntity, isArray } = data;
+2 -2
View File
@@ -177,9 +177,9 @@ export class GraphWatcher {
this._indexer = indexer;
}
async getEntity<Entity> (entity: new () => Entity, id: string, blockHash: string, relations: { [key: string]: any }): Promise<any> {
async getEntity<Entity> (entity: new () => Entity, id: string, relations: { [key: string]: any }, blockHash?: string): Promise<any> {
// Get entity from the database.
const result = await this._database.getEntityWithRelations(entity, id, blockHash, relations) as any;
const result = await this._database.getEntityWithRelations(entity, id, relations, blockHash) as any;
// Resolve any field name conflicts in the entity result.
return resolveEntityFieldConflicts(result);