Skip to content

Commit

Permalink
Utils for constructing v2 mutations (#125)
Browse files Browse the repository at this point in the history
  • Loading branch information
willmarks committed May 1, 2023
1 parent 307b87a commit 74e66e1
Show file tree
Hide file tree
Showing 9 changed files with 352 additions and 14 deletions.
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,48 @@ const query = `{
const data = await client.gql('default', query);
```

## GraphQL Query Builder

The QueryBuilder class is a utility to help construct GraphQL mutations from Faros models.

Example constructing the GraphQL mutation that upserts an application and deployment.

```ts
// The QueryBuilder manages origin for you
const qb = new QueryBuilder(ORIGIN);

const application: MutationParams = {
model: 'compute_Application',
key: {
name: '<application_name>',
platform: '<application_platform>',
},
};
const deployment: MutationParams = {
model: 'cicd_Deployment',
key: {
uid: '<deployment_uid',
source: '<deployment_source>',
},
body: {
// Fields that reference another model need to be refs
application: qb.ref(application),
status: {
category: 'Success',
detail: '<status_detail>',
},
},
};

const mutations = [
qb.upsert(application),
qb.upsert(deployment)
];

// Send your mutations to Faros!
await client.sendMutations(mutations);
```

Please read the [Faros documentation][farosdocs] to learn more.

[farosdocs]: https://docs.faros.ai
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"scripts": {
"build": "tsc -p src",
"clean": "rm -rf lib node_modules out",
"fix": "npm run lint -- --fix && npm run pretty",
"fix": "npm run lint -- --fix",
"lint": "eslint 'src/**/*.ts' 'test/**/*.ts'",
"prepare": "husky install",
"pretty": "prettier --write 'src/**/*.ts' 'test/**/*.ts'",
Expand Down
24 changes: 17 additions & 7 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import VError from 'verror';
import {makeAxiosInstanceWithRetry} from './axios';
import {wrapApiError} from './errors';
import {paginatedQuery} from './graphql/graphql';
import {batchMutation} from './graphql/query-builder';
import {Schema} from './graphql/types';
import {
Account,
FarosClientConfig,
GraphVersion,
Location,
Model,
Mutation,
NamedQuery,
Phantom,
SecretName,
Expand Down Expand Up @@ -136,7 +138,7 @@ export class FarosClient {
await this.api.post(`/graphs/${graph}/models`, models, {
headers: {'content-type': 'application/graphql'},
params: {
...(schema && {schema})
...(schema && {schema}),
},
});
} catch (err: any) {
Expand Down Expand Up @@ -178,23 +180,23 @@ export class FarosClient {
}

queryParameters(): string | undefined {
return this.graphVersion === GraphVersion.V2 ?
`phantoms=${this.phantoms}` :
undefined;
return this.graphVersion === GraphVersion.V2
? `phantoms=${this.phantoms}`
: undefined;
}

private async doGql(
graph: string,
query: string,
variables?: any,
variables?: any
): Promise<any> {
try {
const req = variables ? {query, variables} : {query};
const queryParams = this.queryParameters();
const urlSuffix = queryParams ? `?${queryParams}` : '';
const {data} = await this.api.post(
`/graphs/${graph}/graphql${urlSuffix}`,
req,
req
);
return data;
} catch (err: any) {
Expand All @@ -209,6 +211,14 @@ export class FarosClient {
return data.data;
}

async sendMutations(graph: string, mutations: Mutation[]): Promise<any> {
const gql = batchMutation(mutations);
if (gql) {
return await this.gql(graph, gql);
}
return undefined;
}

/* returns both data (as res.data) and errors (as res.errors) */
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async rawGql(graph: string, query: string, variables?: any): Promise<any> {
Expand Down Expand Up @@ -321,7 +331,7 @@ export class FarosClient {
if (isEmpty(pageInfoPath)) {
// use offset and limit
return {
async* [Symbol.asyncIterator](): AsyncIterator<any> {
async *[Symbol.asyncIterator](): AsyncIterator<any> {
let offset = 0;
let hasNextPage = true;
while (hasNextPage) {
Expand Down
189 changes: 189 additions & 0 deletions src/graphql/query-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import {EnumType, jsonToGraphQLQuery} from 'json-to-graphql-query';

import {
ConflictClause,
Mutation,
MutationObject,
MutationReference,
} from '../types';

export interface MutationFields {
[field: string]: MutationFieldValue;
}

export class Ref {
constructor(readonly params: RefParams) {}
}

export interface RefParams {
model: string;
key: MutationFields;
}

export interface MutationParams extends RefParams {
body?: MutationFields;
mask?: string[];
}

interface CategoryDetail {
category: string;
detail: string;
}

export type MutationFieldValue = string | number | CategoryDetail | Ref;

export class QueryBuilder {
constructor(private readonly origin: string) {}

/**
* Creates an upsert mutation.
* @param model The Faros model
* @param key The object's key fields
* @param body The object's non key fields
* @param mask The column mask of what to update on conflict
* @returns The upsert mutation
*/
upsert(params: MutationParams): Mutation {
const {model} = params;
const mutationObj = this.mutationObj(params);
return {
mutation: {
[`insert_${model}_one`]: {__args: mutationObj, id: true},
},
};
}

/**
* Creates a Ref with the given RefParams that can be used inside the key
* or body of an upsert.
*/
ref(params: RefParams): Ref {
return new Ref(params);
}

/**
* Create a mutation object that will update every field unless an explicit
* mask is provided. If the model contains fields that reference another
* model, those fields will be recursively turned into MutationReferences.
* @param model The Faros model
* @param key The object's key fields
* @param body The object's fields non reference fields
* @param mask An explicit column mask for onConflict clause
* @returns The mutation object
*/
private mutationObj(params: MutationParams): MutationObject {
const {model, key, body, mask} = params;
const cleanObj = removeUndefinedProperties({...key, ...body} ?? {});

const mutObj: any = {};
const fullMask = [];
for (const [k, v] of Object.entries(cleanObj)) {
if (v instanceof Ref) {
mutObj[k] = this.mutationRef(v.params);
// ref's key should be suffixed with Id for onConflict field
fullMask.push(`${k}Id`);
} else {
mutObj[k] = v;
fullMask.push(k);
}
}

const conflictMask = mask ?? fullMask;
mutObj.origin = this.origin;
conflictMask.push('origin');

return {
object: mutObj,
on_conflict: this.createConflictClause(model, conflictMask),
};
}

/**
* Creates a reference to an object. The object is unchanged if it exists.
* If the object does not exist already, then a phantom node is created
* using the models key fields and null origin. Recursively turns any
* fields that are references into MutationReferences.
* @param model The Faros model
* @param key An object containing the model's key fields
* @returns The mutation reference
*/
private mutationRef(params: RefParams): MutationReference {
const {model, key} = params;

const mutData: any = {};
for (const [k, v] of Object.entries(key)) {
if (v instanceof Ref) {
mutData[k] = this.mutationRef(v.params);
} else {
mutData[k] = v;
}
}

return {
data: mutData,
on_conflict: this.createConflictClause(model, ['refreshedAt']),
};
}

private createConflictClause(model: string, mask: string[]): ConflictClause {
return {
constraint: new EnumType(`${model}_pkey`),
update_columns: mask.map((c) => new EnumType(c)),
};
}
}

export function mask(object: any): string[] {
return Object.keys(removeUndefinedProperties(object));
}

function removeUndefinedProperties(object: MutationFields): MutationFields {
const result = {...object};
Object.keys(result).forEach(
(key) => result[key] === undefined && delete result[key]
);
return result;
}

/**
* Constructs a gql query from an array of json mutations.
* The outputted qql mutation might look like:
*
* mutation {
* i1: insert_cicd_Artifact_one(object: {uid: "u1b"}) {
* id
* refreshedAt
* }
* i2: insert_cicd_Artifact_one(object: {uid: "u2b"}) {
* id
* refreshedAt
* }
* }
*
* Notable here are the i1/i2 aliases.
* These are required when multiple operations share the same
* name (e.g. insert_cicd_Artifact_one) and are supported in
* jsonToGraphQLQuery with __aliasFor directive.
*
* @return batch gql mutation or undefined if the input is undefined, empty
* or doesn't contain any mutations.
*/
export function batchMutation(mutations: Mutation[]): string | undefined {
if (mutations.length) {
const queryObj: any = {};
mutations.forEach((query, idx) => {
if (query.mutation) {
const queryType = Object.keys(query.mutation)[0];
const queryBody = query.mutation[queryType];
queryObj[`m${idx}`] = {
__aliasFor: queryType,
...queryBody,
};
}
});
if (Object.keys(queryObj).length > 0) {
return jsonToGraphQLQuery({mutation: queryObj});
}
}
return undefined;
}
8 changes: 2 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export {
Query,
} from './graphql/types';
export {HasuraSchemaLoader} from './graphql/hasura-schema-loader';
export {QueryBuilder, mask, batchMutation} from './graphql/query-builder';
export {
AnyRecord,
FlattenContext,
Expand Down Expand Up @@ -64,9 +65,4 @@ export {
} from './graphql/graphql';
export {FarosGraphSchema} from './schema';
export {Utils} from './utils';
export {
FieldPaths,
getFieldPaths,
asV2AST,
QueryAdapter,
} from './adapter';
export {FieldPaths, getFieldPaths, asV2AST, QueryAdapter} from './adapter';
27 changes: 27 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {EnumType} from 'json-to-graphql-query';

export interface FarosClientConfig {
readonly url: string;
readonly apiKey: string;
Expand Down Expand Up @@ -80,3 +82,28 @@ export interface Model {
keySchema: any;
dataSchema: any;
}

export interface Mutation {
mutation: {
[key: string]: {
__args: MutationObject;
id: boolean;
};
};
}

export interface MutationObject {
object?: any;
data?: any;
on_conflict: ConflictClause;
}

export interface MutationReference {
data: any;
on_conflict: ConflictClause;
}

export interface ConflictClause {
constraint: EnumType;
update_columns: EnumType[];
}
1 change: 1 addition & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Duration} from 'luxon';
import VError from 'verror';


export class Utils {
static urlWithoutTrailingSlashes(url: string): string {
return new URL(url).toString().replace(/\/{1,10}$/, '');
Expand Down
3 changes: 3 additions & 0 deletions test/__snapshots__/query-builder.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`query builder creates mutations 1`] = `"mutation { m0: insert_compute_Application_one (object: {name: \\"<application_name>\\", platform: \\"<application_platform>\\", origin: \\"test-origin\\"}, on_conflict: {constraint: compute_Application_pkey, update_columns: [name, platform, origin]}) { id } m1: insert_cicd_Organization_one (object: {uid: \\"<organization_uid>\\", source: \\"<organization_source>\\", origin: \\"test-origin\\"}, on_conflict: {constraint: cicd_Organization_pkey, update_columns: [uid, source, origin]}) { id } m2: insert_cicd_Pipeline_one (object: {uid: \\"<pipeline_uid>\\", organization: {data: {uid: \\"<organization_uid>\\", source: \\"<organization_source>\\"}, on_conflict: {constraint: cicd_Organization_pkey, update_columns: [refreshedAt]}}, origin: \\"test-origin\\"}, on_conflict: {constraint: cicd_Pipeline_pkey, update_columns: [uid, organizationId, origin]}) { id } m3: insert_cicd_Build_one (object: {uid: \\"<cicd_Build>\\", pipeline: {data: {uid: \\"<pipeline_uid>\\", organization: {data: {uid: \\"<organization_uid>\\", source: \\"<organization_source>\\"}, on_conflict: {constraint: cicd_Organization_pkey, update_columns: [refreshedAt]}}}, on_conflict: {constraint: cicd_Pipeline_pkey, update_columns: [refreshedAt]}}, name: \\"<build_name>\\", origin: \\"test-origin\\"}, on_conflict: {constraint: cicd_Build_pkey, update_columns: [uid, pipelineId, name, origin]}) { id } m4: insert_cicd_Deployment_one (object: {uid: \\"<deployment_uid\\", source: \\"<deployment_source>\\", application: {data: {name: \\"<application_name>\\", platform: \\"<application_platform>\\"}, on_conflict: {constraint: compute_Application_pkey, update_columns: [refreshedAt]}}, build: {data: {uid: \\"<cicd_Build>\\", pipeline: {data: {uid: \\"<pipeline_uid>\\", organization: {data: {uid: \\"<organization_uid>\\", source: \\"<organization_source>\\"}, on_conflict: {constraint: cicd_Organization_pkey, update_columns: [refreshedAt]}}}, on_conflict: {constraint: cicd_Pipeline_pkey, update_columns: [refreshedAt]}}}, on_conflict: {constraint: cicd_Build_pkey, update_columns: [refreshedAt]}}, status: {category: \\"Success\\", detail: \\"<status_detail>\\"}, origin: \\"test-origin\\"}, on_conflict: {constraint: cicd_Deployment_pkey, update_columns: [uid, source, applicationId, buildId, status, origin]}) { id } }"`;
Loading

0 comments on commit 74e66e1

Please sign in to comment.