Skip to content

Commit

Permalink
feat: In-Memory DmlHandler Test Fixture (#809)
Browse files Browse the repository at this point in the history
In order to enable quick testing of indexer code for the purposes of
local indexer development, there needs to be a way to functionally mock
dependencies that are used by the Indexer code. These dependencies are
roughly encapsulated under the context object. In particular context.db
is of high importance as it is the method through with Indexers interact
with their persistent data. This PR focuses on creating an MVP of an in
memory DML Handler Test Fixture which can be used interchangeably with
an actual DmlHandler. This allows context.db calls to pretend to be real
calls to an actual Postgres DB without actually having an instance
running (Which would be hard to integrate into unit testing).

This PR is mainly focused on getting a basic DmlHandler test fixture out
the door. It has the same functions which roughly behave similarly to if
they were called on an actual DB. There is the added benefit of being
extremely quick to tear down as all data is represented in memory,
making it suitable for fast iteration through unit testing. However,
since I am essentially mocking Postgres DB responses, the correctness
standard is much lower than using an actual PG DB, but since DmlHandler
simplifies user interactions with the DB anyway, we can mock a great
deal of Indexer context.db use cases sufficiently to serve as useful for
end users.
  • Loading branch information
darunrs authored Jul 18, 2024
1 parent 7dfb2aa commit f1c1757
Show file tree
Hide file tree
Showing 6 changed files with 585 additions and 11 deletions.
1 change: 0 additions & 1 deletion frontend/src/utils/pgSchemaTypeGen.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ export class PgSchemaTypeGen {
) {
this.addColumn(columnSpec, columns);
} else if (
Object.prototype.hasOwnProperty.call(columnSpec, 'constraint') &&
columnSpec.constraint_type === 'primary key'
) {
for (const foreignKeyDef of columnSpec.definition) {
Expand Down
1 change: 1 addition & 0 deletions runner/src/dml-handler/dml-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe('DML Handler tests', () => {
format: pgFormat
} as unknown as PgClient;
TABLE_DEFINITION_NAMES = {
tableName: 'test_table',
originalTableName: '"test_table"',
originalColumnNames: new Map<string, string>([
['account_id', 'account_id'],
Expand Down
27 changes: 18 additions & 9 deletions runner/src/dml-handler/dml-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@ import type IndexerConfig from '../indexer-config/indexer-config';
import { type Tracer, trace, type Span } from '@opentelemetry/api';
import { type QueryResult } from 'pg';

type WhereClauseMulti = Record<string, (string | number | Array<string | number>)>;
type WhereClauseSingle = Record<string, (string | number)>;

export default class DmlHandler {
export type PostgresRowValue = string | number | any;
export type PostgresRow = Record<string, PostgresRowValue>;
export type WhereClauseMulti = Record<string, (PostgresRowValue | PostgresRowValue[])>;
export type WhereClauseSingle = Record<string, PostgresRowValue>;

export interface IDmlHandler {
insert: (tableDefinitionNames: TableDefinitionNames, rowsToInsert: PostgresRow[]) => Promise<PostgresRow[]>
select: (tableDefinitionNames: TableDefinitionNames, whereObject: WhereClauseMulti, limit: number | null) => Promise<PostgresRow[]>
update: (tableDefinitionNames: TableDefinitionNames, whereObject: WhereClauseSingle, updateObject: any) => Promise<PostgresRow[]>
upsert: (tableDefinitionNames: TableDefinitionNames, rowsToUpsert: PostgresRow[], conflictColumns: string[], updateColumns: string[]) => Promise<PostgresRow[]>
delete: (tableDefinitionNames: TableDefinitionNames, whereObject: WhereClauseMulti) => Promise<PostgresRow[]>
}
export default class DmlHandler implements IDmlHandler {
validTableNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
pgClient: PgClient;
tracer: Tracer;
Expand Down Expand Up @@ -53,7 +62,7 @@ export default class DmlHandler {
return { queryVars, whereClause };
}

async insert (tableDefinitionNames: TableDefinitionNames, rowsToInsert: any[]): Promise<any[]> {
async insert (tableDefinitionNames: TableDefinitionNames, rowsToInsert: PostgresRow[]): Promise<PostgresRow[]> {
if (!rowsToInsert?.length) {
return [];
}
Expand All @@ -67,7 +76,7 @@ export default class DmlHandler {
return result.rows;
}

async select (tableDefinitionNames: TableDefinitionNames, whereObject: WhereClauseMulti, limit: number | null = null): Promise<any[]> {
async select (tableDefinitionNames: TableDefinitionNames, whereObject: WhereClauseMulti, limit: number | null = null): Promise<PostgresRow[]> {
const { queryVars, whereClause } = this.getWhereClause(whereObject, tableDefinitionNames.originalColumnNames);
let query = `SELECT * FROM ${this.indexerConfig.schemaName()}.${tableDefinitionNames.originalTableName} WHERE ${whereClause}`;
if (limit !== null) {
Expand All @@ -78,7 +87,7 @@ export default class DmlHandler {
return result.rows;
}

async update (tableDefinitionNames: TableDefinitionNames, whereObject: WhereClauseSingle, updateObject: any): Promise<any[]> {
async update (tableDefinitionNames: TableDefinitionNames, whereObject: WhereClauseSingle, updateObject: any): Promise<PostgresRow[]> {
const updateKeys = Object.keys(updateObject).map((col) => tableDefinitionNames.originalColumnNames.get(col) ?? col);
const updateParam = Array.from({ length: updateKeys.length }, (_, index) => `${updateKeys[index]}=$${index + 1}`).join(', ');
const whereKeys = Object.keys(whereObject).map((col) => tableDefinitionNames.originalColumnNames.get(col) ?? col);
Expand All @@ -91,7 +100,7 @@ export default class DmlHandler {
return result.rows;
}

async upsert (tableDefinitionNames: TableDefinitionNames, rowsToUpsert: any[], conflictColumns: string[], updateColumns: string[]): Promise<any[]> {
async upsert (tableDefinitionNames: TableDefinitionNames, rowsToUpsert: PostgresRow[], conflictColumns: string[], updateColumns: string[]): Promise<PostgresRow[]> {
if (!rowsToUpsert?.length) {
return [];
}
Expand All @@ -108,7 +117,7 @@ export default class DmlHandler {
return result.rows;
}

async delete (tableDefinitionNames: TableDefinitionNames, whereObject: WhereClauseMulti): Promise<any[]> {
async delete (tableDefinitionNames: TableDefinitionNames, whereObject: WhereClauseMulti): Promise<PostgresRow[]> {
const { queryVars, whereClause } = this.getWhereClause(whereObject, tableDefinitionNames.originalColumnNames);
const query = `DELETE FROM ${this.indexerConfig.schemaName()}.${tableDefinitionNames.originalTableName} WHERE ${whereClause} RETURNING *`;

Expand Down
205 changes: 205 additions & 0 deletions runner/src/dml-handler/in-memory-dml-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { type TableDefinitionNames } from '../indexer';
import InMemoryDmlHandler from './in-memory-dml-handler';

const DEFAULT_ITEM_1_WITHOUT_ID = {
account_id: 'TEST_NEAR',
block_height: 1,
content: 'CONTENT',
accounts_liked: [],
};

const DEFAULT_ITEM_1_WITH_ID = {
id: 1,
account_id: 'TEST_NEAR',
block_height: 1,
content: 'CONTENT',
accounts_liked: [],
};

const DEFAULT_ITEM_2_WITHOUT_ID = {
account_id: 'TEST_NEAR',
block_height: 2,
content: 'CONTENT',
accounts_liked: [],
};

const DEFAULT_ITEM_2_WITH_ID = {
id: 2,
account_id: 'TEST_NEAR',
block_height: 2,
content: 'CONTENT',
accounts_liked: [],
};

describe('DML Handler Fixture Tests', () => {
const SIMPLE_SCHEMA = `CREATE TABLE
"posts" (
"id" SERIAL NOT NULL,
"account_id" VARCHAR NOT NULL,
"block_height" DECIMAL(58, 0) NOT NULL,
"content" TEXT NOT NULL,
"accounts_liked" JSONB NOT NULL DEFAULT '[]',
CONSTRAINT "posts_pkey" PRIMARY KEY ("id", "account_id")
);`;
const TABLE_DEFINITION_NAMES: TableDefinitionNames = {
tableName: 'posts',
originalTableName: '"posts"',
originalColumnNames: new Map<string, string>([])
};

let dmlHandler: InMemoryDmlHandler;

beforeEach(() => {
dmlHandler = new InMemoryDmlHandler(SIMPLE_SCHEMA);
});

test('select rows', async () => {
const inputObj = [DEFAULT_ITEM_1_WITHOUT_ID, DEFAULT_ITEM_2_WITHOUT_ID];

await dmlHandler.insert(TABLE_DEFINITION_NAMES, inputObj);

const selectSingleValue = await dmlHandler.select(TABLE_DEFINITION_NAMES, { id: 1 });
expect(selectSingleValue[0].id).toEqual(1);

const selectMultipleValues = await dmlHandler.select(TABLE_DEFINITION_NAMES, { account_id: 'TEST_NEAR', block_height: [1, 2] });
expect(selectMultipleValues[0].account_id).toEqual('TEST_NEAR');
expect(selectMultipleValues[1].account_id).toEqual('TEST_NEAR');
expect(selectMultipleValues[0].block_height).toEqual(1);
expect(selectMultipleValues[1].block_height).toEqual(2);

expect(await dmlHandler.select(TABLE_DEFINITION_NAMES, { account_id: 'unknown_near' })).toEqual([]);
});

test('insert two rows with serial column', async () => {
const inputObj = [DEFAULT_ITEM_1_WITHOUT_ID, DEFAULT_ITEM_2_WITHOUT_ID];

const correctResult = [DEFAULT_ITEM_1_WITH_ID, DEFAULT_ITEM_2_WITH_ID];

const result = await dmlHandler.insert(TABLE_DEFINITION_NAMES, inputObj);
expect(result).toEqual(correctResult);
});

test('reject insert after specifying serial column value', async () => {
const inputObjWithSerial = [DEFAULT_ITEM_1_WITH_ID];
const inputObj = [DEFAULT_ITEM_2_WITHOUT_ID];

await dmlHandler.insert(TABLE_DEFINITION_NAMES, inputObjWithSerial);
await expect(dmlHandler.insert(TABLE_DEFINITION_NAMES, inputObj)).rejects.toThrow('Cannot insert row twice into the same table');
});

test('reject insert after not specifying primary key value', async () => {
const inputObj = [{
block_height: 1,
content: 'CONTENT',
accounts_liked: [],
}];

await expect(dmlHandler.insert(TABLE_DEFINITION_NAMES, inputObj)).rejects.toThrow('Inserted row must specify value for primary key columns');
});

test('update rows', async () => {
const inputObj = [DEFAULT_ITEM_1_WITHOUT_ID, DEFAULT_ITEM_2_WITHOUT_ID];

await dmlHandler.insert(TABLE_DEFINITION_NAMES, inputObj);

const updateOne = await dmlHandler.update(TABLE_DEFINITION_NAMES, { account_id: 'TEST_NEAR', block_height: 2 }, { content: 'UPDATED_CONTENT' });
const selectOneUpdate = await dmlHandler.select(TABLE_DEFINITION_NAMES, { account_id: 'TEST_NEAR', block_height: 2 });
expect(updateOne).toEqual(selectOneUpdate);

const updateAll = await dmlHandler.update(TABLE_DEFINITION_NAMES, { account_id: 'TEST_NEAR' }, { content: 'final content' });
const selectAllUpdated = await dmlHandler.select(TABLE_DEFINITION_NAMES, { account_id: 'TEST_NEAR' });
expect(updateAll).toEqual(selectAllUpdated);
});

test('update criteria matches nothing', async () => {
const inputObj = [DEFAULT_ITEM_1_WITHOUT_ID, DEFAULT_ITEM_2_WITHOUT_ID];

await dmlHandler.insert(TABLE_DEFINITION_NAMES, inputObj);

const updateNone = await dmlHandler.update(TABLE_DEFINITION_NAMES, { account_id: 'none_near' }, { content: 'UPDATED_CONTENT' });
const selectUpdated = await dmlHandler.select(TABLE_DEFINITION_NAMES, { content: 'UPDATED_CONTENT' });
expect(updateNone).toEqual([]);
expect(selectUpdated).toEqual([]);
});

test('upsert rows', async () => {
const inputObj = [DEFAULT_ITEM_1_WITHOUT_ID];

await dmlHandler.insert(TABLE_DEFINITION_NAMES, inputObj);

const upsertObj = [{
account_id: 'TEST_NEAR',
block_height: 1,
content: 'UPSERT',
accounts_liked: [],
},
{
account_id: 'TEST_NEAR',
block_height: 2,
content: 'UPSERT',
accounts_liked: [],
}];

const upserts = await dmlHandler.upsert(TABLE_DEFINITION_NAMES, upsertObj, ['account_id', 'block_height'], ['content']);

const selectAll = await dmlHandler.select(TABLE_DEFINITION_NAMES, { account_id: 'TEST_NEAR' });
expect(upserts).toEqual(selectAll);
});

test('upsert rows with non unique conflcit columns', async () => {
const inputObj = [DEFAULT_ITEM_1_WITHOUT_ID, DEFAULT_ITEM_2_WITHOUT_ID];

await dmlHandler.insert(TABLE_DEFINITION_NAMES, inputObj);

const upsertObj = [{
account_id: 'TEST_NEAR',
block_height: 1,
content: 'UPSERT',
accounts_liked: [],
},
{
account_id: 'TEST_NEAR',
block_height: 2,
content: 'UPSERT',
accounts_liked: [],
}];

await expect(dmlHandler.upsert(TABLE_DEFINITION_NAMES, upsertObj, ['account_id'], ['content'])).rejects.toThrow('Conflict update criteria cannot affect row twice');
});

test('reject upsert due to duplicate row', async () => {
const inputObj = [DEFAULT_ITEM_1_WITH_ID, DEFAULT_ITEM_1_WITH_ID];

await expect(dmlHandler.upsert(TABLE_DEFINITION_NAMES, inputObj, ['id', 'account_id'], ['content'])).rejects.toThrow('Conflict update criteria cannot affect row twice');
});

test('reject upsert after specifying serial column value', async () => {
const inputObjWithSerial = [DEFAULT_ITEM_1_WITH_ID];
const inputObj = [DEFAULT_ITEM_1_WITHOUT_ID];

await dmlHandler.upsert(TABLE_DEFINITION_NAMES, inputObjWithSerial, ['id', 'account_id'], ['content']);
await expect(dmlHandler.upsert(TABLE_DEFINITION_NAMES, inputObj, ['id', 'account_id'], ['content'])).rejects.toThrow('Cannot insert row twice into the same table');
});

test('reject insert after not specifying primary key value', async () => {
const inputObj = [{
block_height: 1,
content: 'CONTENT',
accounts_liked: [],
}];

await expect(dmlHandler.upsert(TABLE_DEFINITION_NAMES, inputObj, ['id', 'account_id'], ['content'])).rejects.toThrow('Inserted row must specify value for primary key columns');
});

test('delete rows', async () => {
const inputObj = [DEFAULT_ITEM_1_WITHOUT_ID, DEFAULT_ITEM_2_WITHOUT_ID];

const correctResponse = [DEFAULT_ITEM_1_WITH_ID, DEFAULT_ITEM_2_WITH_ID];

await dmlHandler.insert(TABLE_DEFINITION_NAMES, inputObj);

const deletedRows = await dmlHandler.delete(TABLE_DEFINITION_NAMES, { account_id: 'TEST_NEAR' });

expect(deletedRows).toEqual(correctResponse);
});
});
Loading

0 comments on commit f1c1757

Please sign in to comment.