Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: auto-increment for entity id #244

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@
"dist/**/*",
"src/**/*"
]
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you revert this change?

20 changes: 11 additions & 9 deletions src/checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { CheckpointRecord, CheckpointsStore, MetadataId } from './stores/checkpo
import { BaseProvider, StarknetProvider, BlockNotFoundError } from './providers';
import { createLogger, Logger, LogLevel } from './utils/logger';
import { getConfigChecksum, getContractsFromConfig } from './utils/checkpoint';
import { extendSchema } from './utils/graphql';
import { ensureGqlCompatibilty, extendSchema } from './utils/graphql';
import { createKnex } from './knex';
import { AsyncMySqlPool, createMySqlPool } from './mysql';
import { createPgPool } from './pg';
Expand All @@ -29,7 +29,8 @@ export default class Checkpoint {
public config: CheckpointConfig;
public writer: CheckpointWriters;
public opts?: CheckpointOptions;
public schema: string;
public schemaCustom: string;
public schemaGql: string;

private readonly entityController: GqlEntityController;
private readonly log: Logger;
Expand All @@ -47,7 +48,7 @@ export default class Checkpoint {
constructor(
config: CheckpointConfig,
writer: CheckpointWriters,
schema: string,
schemaCustom: string,
opts?: CheckpointOptions
) {
const validationResult = checkpointConfigSchema.safeParse(config);
Expand All @@ -58,11 +59,12 @@ export default class Checkpoint {
this.config = config;
this.writer = writer;
this.opts = opts;
this.schema = extendSchema(schema);
this.schemaCustom = extendSchema(schemaCustom);
this.schemaGql = ensureGqlCompatibilty(schemaCustom);

this.validateConfig();

this.entityController = new GqlEntityController(this.schema, config);
this.entityController = new GqlEntityController(this.schemaGql, config);

this.sourceContracts = getContractsFromConfig(config);
this.cpBlocksCache = [];
Expand All @@ -72,10 +74,10 @@ export default class Checkpoint {
level: opts?.logLevel || LogLevel.Error,
...(opts?.prettifyLogs
? {
transport: {
target: 'pino-pretty'
}
transport: {
target: 'pino-pretty'
}
}
: {})
});

Expand Down Expand Up @@ -191,7 +193,7 @@ export default class Checkpoint {
await this.store.createStore();
await this.store.setMetadata(MetadataId.LastIndexedBlock, 0);

await this.entityController.createEntityStores(this.knex);
await this.entityController.createEntityStores(this.knex, this.schemaCustom);
}

/**
Expand Down
93 changes: 76 additions & 17 deletions src/graphql/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
singleEntityQueryName,
getNonNullType
} from '../utils/graphql';
import { CheckpointConfig } from '../types';
import { autoIncrementFieldPattern, CheckpointConfig, tableNamePattern } from '../types';
import { querySingle, queryMulti, ResolverContext, getNestedResolver } from './resolvers';

/**
Expand Down Expand Up @@ -193,10 +193,24 @@ export class GqlEntityController {
* );
* ```
*
will execute the following SQL when declaring id as autoincrement:
* ```sql
* DROP TABLE IF EXISTS votes;
* CREATE TABLE votes (
* id integer not null primary key autoincrement,
* name VARCHAR(128),
* );
* ```
*
*/
public async createEntityStores(knex: Knex): Promise<{ builder: Knex.SchemaBuilder }> {
public async createEntityStores(
knex: Knex,
schema: string
): Promise<{ builder: Knex.SchemaBuilder }> {
let builder = knex.schema;

const autoIncrementFieldsMap = this.extractAutoIncrementFields(schema);

if (this.schemaObjects.length === 0) {
return { builder };
}
Expand All @@ -205,34 +219,79 @@ export class GqlEntityController {
const tableName = pluralize(type.name.toLowerCase());

builder = builder.dropTableIfExists(tableName).createTable(tableName, t => {
t.primary(['id']);
//if there is no autoIncrement fields on current table, mark the id as primary
if (
autoIncrementFieldsMap.size == 0 ||
autoIncrementFieldsMap.get(tableName)?.length === 0
) {
t.primary(['id']);
}

this.getTypeFields(type).forEach(field => {
const fieldType = field.type instanceof GraphQLNonNull ? field.type.ofType : field.type;
if (isListType(fieldType) && fieldType.ofType instanceof GraphQLObjectType) return;
const sqlType = this.getSqlType(field.type);

let column =
'options' in sqlType
? t[sqlType.name](field.name, ...sqlType.options)
: t[sqlType.name](field.name);

if (field.type instanceof GraphQLNonNull) {
column = column.notNullable();
}

if (!['text', 'json'].includes(sqlType.name)) {
column.index();
//Check if field is declared as autoincrement
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
//Check if field is declared as autoincrement

if (autoIncrementFieldsMap.get(tableName)?.includes(field.name)) {
t.increments(field.name, { primaryKey: true });
} else {
const sqlType = this.getSqlType(field.type);
let column =
'options' in sqlType
? t[sqlType.name](field.name, ...sqlType.options)
: t[sqlType.name](field.name);

if (field.type instanceof GraphQLNonNull) {
column = column.notNullable();
}

if (!['text', 'json'].includes(sqlType.name)) {
column.index();
}
}
});
});
});

await builder;

return { builder };
}

/**
* Parse schema to extract table and fields witrh annotation autoIncrement
* @param schema
* @returns A map where key is table name and values a list of fields with annotation autoIncrement
*/
private extractAutoIncrementFields(schema: string) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This shouldn't be handled with regexes. Directive can be handled natively with GraphQL.

Copy link
Author

Choose a reason for hiding this comment

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

Hi, can you guide me to some example of what you want. From my understanding autoIncrement directive is not native in GraphQL so i need to implement the logic behind this new directive. Thank you.

Copy link
Contributor

Choose a reason for hiding this comment

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

As we extended GraphQL with our custom directive here, we can then access that directive directly from our GraphQL tree.

This way you can look at the field (id field in this example, and check if it has @autoincrement directive, same as we do with @derivedFrom:

const directives = field.astNode?.directives ?? [];
const derivedFromDirective = directives.find(dir => dir.name.value === 'derivedFrom');
if (!derivedFromDirective) {
throw new Error(`field ${field.name} is missing derivedFrom directive`);
}

That means we don't have to bother with writing our own dummy parser for GraphQL to extract autoIncrementFields, and just check for field.astNode.directives here:

if (
autoIncrementFieldsMap.size == 0 ||
autoIncrementFieldsMap.get(tableName)?.length === 0
) {
t.primary(['id']);
}

Copy link
Author

Choose a reason for hiding this comment

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

OK thx for the hint. I will check it and modify accordingly.

const autoIncrementFieldsMap = new Map<string, string[]>();
let currentTable = '';

schema.split('\n').forEach(line => {
const matchTable = line.match(tableNamePattern);
//check for current table name
if (matchTable && matchTable[1]) {
const tableName = pluralize.plural(matchTable[1].toLocaleLowerCase());
if (tableName !== currentTable) {
currentTable = tableName;
}
} else {
//check for fields
const matchAutoIncrement = line.match(autoIncrementFieldPattern());
if (matchAutoIncrement && matchAutoIncrement[1]) {
const field = matchAutoIncrement[1];
// Check if the key already exists in the map
if (autoIncrementFieldsMap.has(currentTable)) {
const existingFields = autoIncrementFieldsMap.get(currentTable);
existingFields?.push(field);
} else {
autoIncrementFieldsMap.set(currentTable, [field]);
}
}
}
});

return autoIncrementFieldsMap;
}

/**
* Generates a query based on the first entity discovered
* in a schema. If no entities are found in the schema
Expand Down
20 changes: 20 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,23 @@ export function isFullBlock(block: Block): block is FullBlock {
export function isDeployTransaction(tx: Transaction | PendingTransaction): tx is DeployTransaction {
return tx.type === 'DEPLOY';
}

/**
* AutoIncrement annotation in schema.gql
*/
export const autoIncrementTag = /@autoIncrement/;

/**
* Regex pattern to find table name in schema.gql
*/
export const tableNamePattern = /type\s+(\w+)\s+{/;

/**
* Regex pattern to find field name having annotion "@autoIncrement" in schema.gql
* Consider only field type : Int!,ID!, BigInt,
* @returns regex expression : /(\w+):\s(Int|ID|BigInt)!?\s+@autoIncrement/
*/
export function autoIncrementFieldPattern(): RegExp {
const fieldPattern = /(\w+):\s(Int|ID|BigInt)!?\s+/;
return new RegExp(fieldPattern.source + autoIncrementTag.source);
}
10 changes: 10 additions & 0 deletions src/utils/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from 'graphql';
import { jsonToGraphQLQuery } from 'json-to-graphql-query';
import pluralize from 'pluralize';
import { autoIncrementTag } from '../types';

export const extendSchema = (schema: string): string => {
return `directive @derivedFrom(field: String!) on FIELD_DEFINITION
Expand Down Expand Up @@ -79,3 +80,12 @@ export const getNonNullType = (type: GraphQLOutputType): GraphQLOutputType => {

return type;
};

/**
* Gql schema do not manage autoincrement fields. We have to remove the attribute to make schema for gql valid
* @param schema
* @returns schema without autoincrement annotation
*/
export const ensureGqlCompatibilty = (schema: string): string => {
return schema.replace(new RegExp(autoIncrementTag.source, 'g'), '');
};
22 changes: 22 additions & 0 deletions test/unit/graphql/__snapshots__/controller.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,28 @@ create index \`votes_decimal_index\` on \`votes\` (\`decimal\`);
create index \`votes_big_decimal_index\` on \`votes\` (\`big_decimal\`)"
`;

exports[`GqlEntityController createEntityStores should work with autoincrement 1`] = `
"drop table if exists \`votes\`;
create table \`votes\` (\`id\` integer not null primary key autoincrement, \`name\` varchar(128), \`authenticators\` json, \`big_number\` bigint, \`decimal\` float, \`big_decimal\` float);
create index \`votes_name_index\` on \`votes\` (\`name\`);
create index \`votes_big_number_index\` on \`votes\` (\`big_number\`);
create index \`votes_decimal_index\` on \`votes\` (\`decimal\`);
create index \`votes_big_decimal_index\` on \`votes\` (\`big_decimal\`)"
`;

exports[`GqlEntityController createEntityStores should work with autoincrement and nested objects 1`] = `
"drop table if exists \`votes\`;
create table \`votes\` (\`id\` integer not null primary key autoincrement, \`name\` varchar(128), \`authenticators\` json, \`big_number\` bigint, \`decimal\` float, \`big_decimal\` float, \`poster\` varchar(128));
create index \`votes_name_index\` on \`votes\` (\`name\`);
create index \`votes_big_number_index\` on \`votes\` (\`big_number\`);
create index \`votes_decimal_index\` on \`votes\` (\`decimal\`);
create index \`votes_big_decimal_index\` on \`votes\` (\`big_decimal\`);
create index \`votes_poster_index\` on \`votes\` (\`poster\`);
drop table if exists \`posters\`;
create table \`posters\` (\`id\` integer not null primary key autoincrement, \`name\` varchar(128) not null);
create index \`posters_name_index\` on \`posters\` (\`name\`)"
`;

exports[`GqlEntityController generateQueryFields should work 1`] = `
"type Query {
vote(id: Int!): Vote
Expand Down
69 changes: 64 additions & 5 deletions test/unit/graphql/controller.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { GraphQLObjectType, GraphQLSchema, printSchema } from 'graphql';
import knex from 'knex';
import { GqlEntityController } from '../../../src/graphql/controller';
import { autoIncrementTag } from '../../../src/types';

const regex = new RegExp(autoIncrementTag.source, 'g');

describe('GqlEntityController', () => {
describe('generateQueryFields', () => {
Expand All @@ -17,7 +20,6 @@ type Vote {
name: 'Query',
fields: queryFields
});

const schema = printSchema(new GraphQLSchema({ query: querySchema }));
expect(schema).toMatchSnapshot();
});
Expand Down Expand Up @@ -56,7 +58,7 @@ type Vote {
});

it('should work', async () => {
const controller = new GqlEntityController(`
const schema = `
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd use just a single test case to test everything (just put autoincrement and nested properties in here) or
use it.each to avoid duplication of code.

scalar BigInt
scalar Decimal
scalar BigDecimal
Expand All @@ -69,10 +71,67 @@ type Vote {
decimal: Decimal
big_decimal: BigDecimal
}
`);
const { builder } = await controller.createEntityStores(mockKnex);
`;

const controller = new GqlEntityController(schema);
const { builder } = await controller.createEntityStores(mockKnex, schema);

const createQuery = builder.toString();
expect(createQuery).toMatchSnapshot();
});

it('should work with autoincrement', async () => {
const schema = `
scalar BigInt
scalar Decimal
scalar BigDecimal

type Vote {
id: Int! @autoIncrement
name: String
authenticators: [String]
big_number: BigInt
decimal: Decimal
big_decimal: BigDecimal
}

`;

const schemaGql = schema.replace(regex, '');
const controller = new GqlEntityController(schemaGql);
const { builder } = await controller.createEntityStores(mockKnex, schema);
const createQuery = builder.toString();
expect(createQuery).toMatchSnapshot();
});

it('should work with autoincrement and nested objects', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

I feel like it could just be the only test case in this file (it should work).

const schema = `
scalar BigInt
scalar Decimal
scalar BigDecimal

type Vote {
id: Int! @autoIncrement
name: String
authenticators: [String]
big_number: BigInt
decimal: Decimal
big_decimal: BigDecimal
poster : Poster
}

type Poster {
id: ID! @autoIncrement
name: String!
}

`;

expect(builder.toString()).toMatchSnapshot();
const schemaGql = schema.replace(new RegExp(autoIncrementTag.source, 'g'), '');
const controller = new GqlEntityController(schemaGql);
const { builder } = await controller.createEntityStores(mockKnex, schema);
const createQuery = builder.toString();
expect(createQuery).toMatchSnapshot();
});
});

Expand Down