From 2f6d62cc6eab201e853ae0628711f91eff5f4ce2 Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Fri, 25 Jan 2019 08:24:02 -0500 Subject: [PATCH 01/16] our own resolver for grant tags refs #21 --- src/server.ts | 129 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 121 insertions(+), 8 deletions(-) diff --git a/src/server.ts b/src/server.ts index 3867e69..6ba0163 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,3 +1,4 @@ +import * as Sequelize from 'sequelize'; import { GraphQLServer } from 'graphql-yoga'; import { createContext, EXPECTED_OPTIONS_KEY } from 'dataloader-sequelize'; import { @@ -5,6 +6,7 @@ import { attributeFields, defaultArgs, defaultListArgs, + simplifyAST, } from 'graphql-sequelize'; import { @@ -14,12 +16,18 @@ import { GraphQLString, GraphQLInt, GraphQLList, + GraphQLArgumentConfig, + GraphQLEnumType, } from 'graphql'; +import { escape } from 'sequelize/lib/sql-string'; + import * as GraphQLBigInt from 'graphql-bigint'; import { Db } from './db/models'; +const MAX_LIMIT = 100; + export default function createServer(db: Db): GraphQLServer { const orderByMultiResolver = (opts, args) => { const options = { @@ -55,6 +63,10 @@ export default function createServer(db: Db): GraphQLServer { resolver.contextToOptions = { [EXPECTED_OPTIONS_KEY]: EXPECTED_OPTIONS_KEY }; + // Arguments + const grantTagArgs = ledgerListArgs(db.GrantTag, ['total']); + + // Types const shallowOrganizationType = new GraphQLObjectType({ name: 'ShallowOrganization', description: 'An organization, without grants funded or received', @@ -64,7 +76,13 @@ export default function createServer(db: Db): GraphQLServer { const grantTagType = new GraphQLObjectType({ name: 'GrantTag', description: 'Tag associated with a grant', - fields: attributeFields(db.GrantTag, { exclude: ['id'] }), + fields: { + ...attributeFields(db.GrantTag, { exclude: ['id'] }), + total: { + type: GraphQLBigInt, + //resolve: source => source.dataValues.total, + }, + }, }); const nteeGrantTypeType = new GraphQLObjectType({ @@ -132,8 +150,9 @@ export default function createServer(db: Db): GraphQLServer { }, grantTags: { type: new GraphQLList(grantTagType), + args: grantTagArgs, // @ts-ignore - resolve: resolver(db.Grant.GrantTags), + resolve: grantTagResolver(db, true), }, amount: { type: GraphQLBigInt }, }, @@ -229,17 +248,17 @@ export default function createServer(db: Db): GraphQLServer { name: 'Stats', description: 'gnl stats', fields: { - total_num_grants: { type: GraphQLInt }, - total_num_orgs: { type: GraphQLInt }, - total_grants_dollars: { type: GraphQLBigInt }, + totalNumGrants: { type: GraphQLInt }, + totalNumOrgs: { type: GraphQLInt }, + totalGrantsDollars: { type: GraphQLBigInt }, }, }), resolve: async () => { const results = await Promise.all( [ - 'SELECT COUNT(id) AS total_num_grants FROM "grant"', - 'SELECT COUNT(id) AS total_num_orgs FROM organization', - 'SELECT SUM(amount) AS total_grants_dollars FROM "grant"', + 'SELECT COUNT(id) AS totalNumGrants FROM "grant"', + 'SELECT COUNT(id) AS totalNumOrgs FROM organization', + 'SELECT SUM(amount) AS totalGrantsDollars FROM "grant"', ].map(q => db.sequelize.query(q, { type: db.Sequelize.QueryTypes.SELECT, @@ -316,6 +335,11 @@ export default function createServer(db: Db): GraphQLServer { }, resolve: resolver(db.Grant), }, + grantTags: { + type: new GraphQLList(grantTagType), + args: grantTagArgs, + resolve: grantTagResolver(db), + }, }, }), }), @@ -331,3 +355,92 @@ export default function createServer(db: Db): GraphQLServer { }, }); } + +const grantTagResolver = (db, grantId?: boolean) => async ( + opts, + { limit, offset, orderBy, orderByDirection, uuid = null }, + context, + info +) => { + const { fieldNodes } = info; + const ast = simplifyAST(fieldNodes[0], info); + + let where = ''; + // Fetching only grant tags related to a specific grant + if (grantId) { + where = `WHERE g.id=${escape(opts.dataValues.id)}`; + } else { + where = uuid ? `WHERE gt.uuid = ${escape(uuid)}` : ''; + } + + const results = await db.sequelize.query( + `SELECT gt.id, gt.uuid, gt.name, gt.description, SUM(g.amount) as total +FROM grant_tag gt +LEFT JOIN grant_grant_tag ggt ON gt.id=ggt.grant_tag_id +LEFT JOIN "grant" g ON ggt.grant_id=g.id +${where} +GROUP BY gt.id +ORDER BY ${orderBy} ${orderByDirection} +LIMIT :limit +OFFSET :offset`, + { + type: db.Sequelize.QueryTypes.SELECT, + replacements: { + limit: Math.min(limit, MAX_LIMIT), + offset, + }, + } + ); + + return results; +}; + +const ledgerListArgs = ( + model: Sequelize.Model, + orderBySpecialCols: string[] +) => ({ + orderBy: { + type: new GraphQLEnumType({ + name: `orderBy${model.name}`, + // @ts-ignore tableAttributes is not in sequelize type defs + values: Object.keys(model.tableAttributes).reduce( + (acc, cur) => ({ + ...acc, + [cur]: { value: cur }, + }), + orderBySpecialCols.reduce( + (acc, cur) => ({ + ...acc, + [cur]: { value: cur }, + }), + {} + ) + ), + }), + defaultValue: 'uuid', + description: 'sort results by given field', + }, + orderByDirection: { + type: new GraphQLEnumType({ + name: 'orderByDirection', + values: { + ASC: { value: 'ASC NULLS LAST' }, + DESC: { value: 'DESC NULLS LAST' }, + }, + }), + defaultValue: 'DESC NULLS LAST', + description: 'sort direction', + }, + limit: { + type: GraphQLInt, + defaultValue: 10, + description: `Number of items to return, maximum ${MAX_LIMIT}`, + }, + offset: { + type: GraphQLInt, + defaultValue: 0, + }, + uuid: { + type: GraphQLString, + }, +}); From 6bfa736f6362bf475f2d18189963e6b1af73a86b Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Fri, 25 Jan 2019 13:09:48 -0500 Subject: [PATCH 02/16] org tag resolver refs #21 --- src/server.ts | 103 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 15 deletions(-) diff --git a/src/server.ts b/src/server.ts index 6ba0163..d88d5f4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,7 +16,6 @@ import { GraphQLString, GraphQLInt, GraphQLList, - GraphQLArgumentConfig, GraphQLEnumType, } from 'graphql'; @@ -65,6 +64,10 @@ export default function createServer(db: Db): GraphQLServer { // Arguments const grantTagArgs = ledgerListArgs(db.GrantTag, ['total']); + const organizationTagArgs = ledgerListArgs(db.OrganizationTag, [ + 'totalFunded', + 'totalReceived', + ]); // Types const shallowOrganizationType = new GraphQLObjectType({ @@ -73,6 +76,16 @@ export default function createServer(db: Db): GraphQLServer { fields: attributeFields(db.Organization, { exclude: ['id'] }), }); + const organizationTagType = new GraphQLObjectType({ + name: 'OrganizationTag', + description: 'Tag associated with an organization', + fields: { + ...attributeFields(db.OrganizationTag, { exclude: ['id'] }), + totalFunded: { type: GraphQLBigInt }, + totalReceived: { type: GraphQLBigInt }, + }, + }); + const grantTagType = new GraphQLObjectType({ name: 'GrantTag', description: 'Tag associated with a grant', @@ -91,12 +104,6 @@ export default function createServer(db: Db): GraphQLServer { fields: attributeFields(db.NteeGrantType, { exclude: ['id'] }), }); - const organizationTagType = new GraphQLObjectType({ - name: 'OrganizationTag', - description: 'Tag associated with an organization', - fields: attributeFields(db.OrganizationTag, { exclude: ['id'] }), - }); - const nteeOrganizationTypeType = new GraphQLObjectType({ name: 'NteeOrganizationType', description: 'NTEE classification of an organization', @@ -151,8 +158,7 @@ export default function createServer(db: Db): GraphQLServer { grantTags: { type: new GraphQLList(grantTagType), args: grantTagArgs, - // @ts-ignore - resolve: grantTagResolver(db, true), + resolve: grantTagResolver(db, { limitToGrantId: true }), }, amount: { type: GraphQLBigInt }, }, @@ -219,8 +225,8 @@ export default function createServer(db: Db): GraphQLServer { }, organizationTags: { type: new GraphQLList(organizationTagType), - // @ts-ignore - resolve: resolver(db.Organization.OrganizationTags), + args: organizationTagArgs, + resolve: organizationTagResolver(db, { limitToOrganizationId: true }), }, }, }); @@ -335,6 +341,11 @@ export default function createServer(db: Db): GraphQLServer { }, resolve: resolver(db.Grant), }, + organizationTags: { + type: new GraphQLList(organizationTagType), + args: organizationTagArgs, + resolve: organizationTagResolver(db), + }, grantTags: { type: new GraphQLList(grantTagType), args: grantTagArgs, @@ -356,7 +367,69 @@ export default function createServer(db: Db): GraphQLServer { }); } -const grantTagResolver = (db, grantId?: boolean) => async ( +interface OrganizationTagResolverOptions { + limitToOrganizationId: boolean; +} + +const defaultOrganizationTagResolverOptions = { + limitToOrganizationId: false, +}; + +interface GrantTagResolverOptions { + limitToGrantId: boolean; +} + +const defaultGrantTagResolverOptions = { + limitToGrantId: false, +}; + +const organizationTagResolver = ( + db, + resolverOpts: OrganizationTagResolverOptions = defaultOrganizationTagResolverOptions +) => async ( + opts, + { limit, offset, orderBy, orderByDirection, uuid = null }, + context, + info +) => { + const { fieldNodes } = info; + const ast = simplifyAST(fieldNodes[0], info); + + let where = ''; + // Fetching only organization tags related to a specific organization + if (resolverOpts.limitToOrganizationId) { + where = `WHERE oot.organization_id=${escape(opts.dataValues.id)}`; + } else { + where = uuid ? `WHERE ot.uuid = ${escape(uuid)}` : ''; + } + + const results = await db.sequelize.query( + `SELECT ot.id, ot.uuid, ot.name, ot.description, SUM(gf.amount) as "totalFunded", SUM(gr.amount) as "totalReceived" +FROM organization_tag ot +LEFT JOIN organization_organization_tag oot ON ot.id=oot.organization_tag_id +LEFT JOIN "grant" gf ON gf.from=oot.organization_id +LEFT JOIN "grant" gr ON gr.to=oot.organization_id +${where} +GROUP BY ot.id +ORDER BY "${orderBy}" ${orderByDirection} +LIMIT :limit +OFFSET :offset`, + { + type: db.Sequelize.QueryTypes.SELECT, + replacements: { + limit: Math.min(limit, MAX_LIMIT), + offset, + }, + } + ); + + return results; +}; + +const grantTagResolver = ( + db, + resolverOpts: GrantTagResolverOptions = defaultGrantTagResolverOptions +) => async ( opts, { limit, offset, orderBy, orderByDirection, uuid = null }, context, @@ -367,7 +440,7 @@ const grantTagResolver = (db, grantId?: boolean) => async ( let where = ''; // Fetching only grant tags related to a specific grant - if (grantId) { + if (resolverOpts.limitToGrantId) { where = `WHERE g.id=${escape(opts.dataValues.id)}`; } else { where = uuid ? `WHERE gt.uuid = ${escape(uuid)}` : ''; @@ -380,7 +453,7 @@ LEFT JOIN grant_grant_tag ggt ON gt.id=ggt.grant_tag_id LEFT JOIN "grant" g ON ggt.grant_id=g.id ${where} GROUP BY gt.id -ORDER BY ${orderBy} ${orderByDirection} +ORDER BY "${orderBy}" ${orderByDirection} LIMIT :limit OFFSET :offset`, { @@ -422,7 +495,7 @@ const ledgerListArgs = ( }, orderByDirection: { type: new GraphQLEnumType({ - name: 'orderByDirection', + name: `orderByDirection${model.name}`, values: { ASC: { value: 'ASC NULLS LAST' }, DESC: { value: 'DESC NULLS LAST' }, From c05bf182b0a4a24e43865789580ae2c256e4d46d Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Fri, 25 Jan 2019 13:32:08 -0500 Subject: [PATCH 03/16] add ntee type resolvers refs #21 --- src/server.ts | 126 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 10 deletions(-) diff --git a/src/server.ts b/src/server.ts index d88d5f4..be6d1c2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -64,6 +64,11 @@ export default function createServer(db: Db): GraphQLServer { // Arguments const grantTagArgs = ledgerListArgs(db.GrantTag, ['total']); + const nteeGrantTypeArgs = ledgerListArgs(db.NteeGrantType, ['total']); + const nteeOrganizationTypeArgs = ledgerListArgs(db.NteeOrganizationType, [ + 'totalFunded', + 'totalReceived', + ]); const organizationTagArgs = ledgerListArgs(db.OrganizationTag, [ 'totalFunded', 'totalReceived', @@ -91,23 +96,27 @@ export default function createServer(db: Db): GraphQLServer { description: 'Tag associated with a grant', fields: { ...attributeFields(db.GrantTag, { exclude: ['id'] }), - total: { - type: GraphQLBigInt, - //resolve: source => source.dataValues.total, - }, + total: { type: GraphQLBigInt }, }, }); const nteeGrantTypeType = new GraphQLObjectType({ name: 'NteeGrantType', description: 'NTEE classification of a grant', - fields: attributeFields(db.NteeGrantType, { exclude: ['id'] }), + fields: { + ...attributeFields(db.NteeGrantType, { exclude: ['id'] }), + total: { type: GraphQLBigInt }, + }, }); const nteeOrganizationTypeType = new GraphQLObjectType({ name: 'NteeOrganizationType', description: 'NTEE classification of an organization', - fields: attributeFields(db.NteeOrganizationType, { exclude: ['id'] }), + fields: { + ...attributeFields(db.NteeOrganizationType, { exclude: ['id'] }), + totalFunded: { type: GraphQLBigInt }, + totalReceived: { type: GraphQLBigInt }, + }, }); const personType = new GraphQLObjectType({ @@ -152,8 +161,8 @@ export default function createServer(db: Db): GraphQLServer { }, nteeGrantTypes: { type: new GraphQLList(nteeGrantTypeType), - // @ts-ignore - resolve: resolver(db.Grant.NteeGrantTypes), + args: nteeGrantTypeArgs, + resolve: nteeGrantTypeResolver(db, { limitToGrantId: true }), }, grantTags: { type: new GraphQLList(grantTagType), @@ -220,8 +229,10 @@ export default function createServer(db: Db): GraphQLServer { }, nteeOrganizationTypes: { type: new GraphQLList(nteeOrganizationTypeType), - // @ts-ignore - resolve: resolver(db.Organization.NteeOrganizationTypes), + args: nteeOrganizationTypeArgs, + resolve: nteeOrganizationTypeResolver(db, { + limitToOrganizationId: true, + }), }, organizationTags: { type: new GraphQLList(organizationTagType), @@ -341,11 +352,21 @@ export default function createServer(db: Db): GraphQLServer { }, resolve: resolver(db.Grant), }, + nteeOrganizationTypes: { + type: new GraphQLList(nteeOrganizationTypeType), + args: nteeOrganizationTypeArgs, + resolve: nteeOrganizationTypeResolver(db), + }, organizationTags: { type: new GraphQLList(organizationTagType), args: organizationTagArgs, resolve: organizationTagResolver(db), }, + nteeGrantTypes: { + type: new GraphQLList(nteeGrantTypeType), + args: nteeGrantTypeArgs, + resolve: nteeGrantTypeResolver(db), + }, grantTags: { type: new GraphQLList(grantTagType), args: grantTagArgs, @@ -426,6 +447,49 @@ OFFSET :offset`, return results; }; +const nteeOrganizationTypeResolver = ( + db, + resolverOpts: OrganizationTagResolverOptions = defaultOrganizationTagResolverOptions +) => async ( + opts, + { limit, offset, orderBy, orderByDirection, uuid = null }, + context, + info +) => { + const { fieldNodes } = info; + const ast = simplifyAST(fieldNodes[0], info); + + let where = ''; + // Fetching only organization tags related to a specific organization + if (resolverOpts.limitToOrganizationId) { + where = `WHERE oot.organization_id=${escape(opts.dataValues.id)}`; + } else { + where = uuid ? `WHERE ot.uuid = ${escape(uuid)}` : ''; + } + + const results = await db.sequelize.query( + `SELECT ot.id, ot.uuid, ot.name, ot.code, ot.description, SUM(gf.amount) as "totalFunded", SUM(gr.amount) as "totalReceived" +FROM ntee_organization_type ot +LEFT JOIN organization_ntee_organization_type oot ON ot.id=oot.ntee_organization_type_id +LEFT JOIN "grant" gf ON gf.from=oot.organization_id +LEFT JOIN "grant" gr ON gr.to=oot.organization_id +${where} +GROUP BY ot.id +ORDER BY "${orderBy}" ${orderByDirection} +LIMIT :limit +OFFSET :offset`, + { + type: db.Sequelize.QueryTypes.SELECT, + replacements: { + limit: Math.min(limit, MAX_LIMIT), + offset, + }, + } + ); + + return results; +}; + const grantTagResolver = ( db, resolverOpts: GrantTagResolverOptions = defaultGrantTagResolverOptions @@ -468,6 +532,48 @@ OFFSET :offset`, return results; }; +const nteeGrantTypeResolver = ( + db, + resolverOpts: GrantTagResolverOptions = defaultGrantTagResolverOptions +) => async ( + opts, + { limit, offset, orderBy, orderByDirection, uuid = null }, + context, + info +) => { + const { fieldNodes } = info; + const ast = simplifyAST(fieldNodes[0], info); + + let where = ''; + // Fetching only grant tags related to a specific grant + if (resolverOpts.limitToGrantId) { + where = `WHERE g.id=${escape(opts.dataValues.id)}`; + } else { + where = uuid ? `WHERE gt.uuid = ${escape(uuid)}` : ''; + } + + const results = await db.sequelize.query( + `SELECT gt.id, gt.uuid, gt.name, gt.code, gt.description, SUM(g.amount) as total +FROM ntee_grant_type gt +LEFT JOIN grant_ntee_grant_type ggt ON gt.id=ggt.ntee_grant_type_id +LEFT JOIN "grant" g ON ggt.grant_id=g.id +${where} +GROUP BY gt.id +ORDER BY "${orderBy}" ${orderByDirection} +LIMIT :limit +OFFSET :offset`, + { + type: db.Sequelize.QueryTypes.SELECT, + replacements: { + limit: Math.min(limit, MAX_LIMIT), + offset, + }, + } + ); + + return results; +}; + const ledgerListArgs = ( model: Sequelize.Model, orderBySpecialCols: string[] From 178c51ab09b617faf80cbd7d2fbbbe3c646490cd Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Fri, 25 Jan 2019 14:25:33 -0500 Subject: [PATCH 04/16] use a more natural sort by default --- src/server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index be6d1c2..5428767 100644 --- a/src/server.ts +++ b/src/server.ts @@ -596,7 +596,7 @@ const ledgerListArgs = ( ) ), }), - defaultValue: 'uuid', + defaultValue: 'id', description: 'sort results by given field', }, orderByDirection: { @@ -607,7 +607,7 @@ const ledgerListArgs = ( DESC: { value: 'DESC NULLS LAST' }, }, }), - defaultValue: 'DESC NULLS LAST', + defaultValue: 'ASC NULLS LAST', description: 'sort direction', }, limit: { From 7e0425a5dca265160e20f532b1011cda225b9d80 Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Fri, 25 Jan 2019 16:53:31 -0500 Subject: [PATCH 05/16] add explict deps for camel-snake libraries --- package.json | 1 + yarn.lock | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/package.json b/package.json index 99b1e72..66e10cd 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "bunyan": "^1.8.12", "config": "^2.0.1", "dataloader-sequelize": "1.7.7", + "decamelize": "2.0.0", "google-auth-library": "1.6.1", "graphql": "14.0.2", "graphql-bigint": "1.0.0", diff --git a/yarn.lock b/yarn.lock index a09185d..d7f1c64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2223,6 +2223,13 @@ debug@^3.1.0: dependencies: ms "2.0.0" +decamelize@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-2.0.0.tgz#656d7bbc8094c4c788ea53c5840908c9c7d063c7" + integrity sha512-Ikpp5scV3MSYxY39ymh45ZLEecsTdv/Xj2CaQfI8RLMuwi7XvjX9H/fhraiSuU+C5w5NTDu4ZU72xNiZnurBPg== + dependencies: + xregexp "4.0.0" + decamelize@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -7475,6 +7482,11 @@ xmlbuilder@~9.0.1: resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0= +xregexp@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.0.0.tgz#e698189de49dd2a18cc5687b05e17c8e43943020" + integrity sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg== + xtend@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" From 9e8ddda600000d15d499104e65fc8dffd8483db2 Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Sat, 26 Jan 2019 15:55:20 -0500 Subject: [PATCH 06/16] fancy resolver for organizations! --- src/db/models/organization.ts | 44 ++++++++++++++ src/server.ts | 109 ++++++++++++++++++++++++---------- 2 files changed, 122 insertions(+), 31 deletions(-) diff --git a/src/db/models/organization.ts b/src/db/models/organization.ts index 784498e..b9ac6d3 100644 --- a/src/db/models/organization.ts +++ b/src/db/models/organization.ts @@ -31,6 +31,16 @@ export interface OrganizationAttributes { createdAt?: string; updatedAt?: string; + // From meta table join: + countGrantsFrom?: number; + countGrantsTo?: number; + countDistinctFunders?: number; + countDistinctRecipients?: number; + totalReceived?: number; + totalFunded?: number; + grantdatesStart?: Date; + grantdatesEnd?: Date; + // Relationships getOrganizationOrganizationTag?: Sequelize.BelongsToGetAssociationMixin< OrganizationTagInstance[] @@ -113,6 +123,40 @@ export default (sequelize: Sequelize.Sequelize) => { allowNull: true, field: 'public_funder', }, + + // From meta table join: + countGrantsFrom: { + type: new Sequelize.VIRTUAL(Sequelize.INTEGER), + field: 'count_grants_from', + }, + countGrantsTo: { + type: new Sequelize.VIRTUAL(Sequelize.INTEGER), + field: 'count_grants_to', + }, + countDistinctFunders: { + type: new Sequelize.VIRTUAL(Sequelize.INTEGER), + field: 'count_distinct_funders', + }, + countDistinctRecipients: { + type: new Sequelize.VIRTUAL(Sequelize.INTEGER), + field: 'count_distinct_recipients', + }, + totalReceived: { + type: new Sequelize.VIRTUAL(Sequelize.BIGINT), + field: 'total_received', + }, + totalFunded: { + type: new Sequelize.VIRTUAL(Sequelize.BIGINT), + field: 'total_funded', + }, + grantdatesStart: { + type: new Sequelize.VIRTUAL(Sequelize.DATEONLY), + field: 'grantdates_start', + }, + grantdatesEnd: { + type: new Sequelize.VIRTUAL(Sequelize.DATEONLY), + field: 'grantdates_end', + }, }, { createdAt: 'created_at', diff --git a/src/server.ts b/src/server.ts index 5428767..0614491 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,4 +1,5 @@ import * as Sequelize from 'sequelize'; +import * as decamelize from 'decamelize'; import { GraphQLServer } from 'graphql-yoga'; import { createContext, EXPECTED_OPTIONS_KEY } from 'dataloader-sequelize'; import { @@ -6,7 +7,6 @@ import { attributeFields, defaultArgs, defaultListArgs, - simplifyAST, } from 'graphql-sequelize'; import { @@ -27,6 +27,22 @@ import { Db } from './db/models'; const MAX_LIMIT = 100; +const organizationSpecialFields = { + countGrantsFrom: { + type: GraphQLInt, + resolve: (a, b, c, d, e, f, g) => { + debugger; + }, + }, + countGrantsTo: { type: GraphQLInt }, + countDistinctFunders: { type: GraphQLInt }, + countDistinctRecipients: { type: GraphQLInt }, + totalReceived: { type: GraphQLBigInt }, + totalFunded: { type: GraphQLBigInt }, + grantdatesStart: { type: GraphQLString }, + grantdatesEnd: { type: GraphQLString }, +}; + export default function createServer(db: Db): GraphQLServer { const orderByMultiResolver = (opts, args) => { const options = { @@ -73,6 +89,10 @@ export default function createServer(db: Db): GraphQLServer { 'totalFunded', 'totalReceived', ]); + const organizationArgs = ledgerListArgs( + db.Organization, + Object.keys(organizationSpecialFields) + ); // Types const shallowOrganizationType = new GraphQLObjectType({ @@ -138,8 +158,8 @@ export default function createServer(db: Db): GraphQLServer { }, organization: { type: shallowOrganizationType, - // @ts-ignore - resolve: resolver(db.BoardTerm.Organization), + args: organizationArgs, + resolve: organizationResolver(db), }, }, }); @@ -151,13 +171,13 @@ export default function createServer(db: Db): GraphQLServer { ...attributeFields(db.Grant, { exclude: ['id'] }), from: { type: shallowOrganizationType, - // @ts-ignore - resolve: resolver(db.Grant.Funder), + args: organizationArgs, + resolve: organizationResolver(db, opts => opts.get('from')), }, to: { type: shallowOrganizationType, - // @ts-ignore - resolve: resolver(db.Grant.Recipient), + args: organizationArgs, + resolve: organizationResolver(db, opts => opts.get('to')), }, nteeGrantTypes: { type: new GraphQLList(nteeGrantTypeType), @@ -186,8 +206,8 @@ export default function createServer(db: Db): GraphQLServer { ...attributeFields(db.News, { exclude: ['id'] }), organizations: { type: new GraphQLList(shallowOrganizationType), - // @ts-ignore - resolve: resolver(db.News.Organizations), + args: organizationArgs, + resolve: organizationResolver(db), }, grants: { type: new GraphQLList(grantType), @@ -202,6 +222,7 @@ export default function createServer(db: Db): GraphQLServer { description: 'An organization, duh', fields: { ...attributeFields(db.Organization, { exclude: ['id'] }), + ...organizationSpecialFields, boardTerms: { type: new GraphQLList(boardTermType), // @ts-ignore @@ -304,16 +325,8 @@ export default function createServer(db: Db): GraphQLServer { }, organizations: { type: new GraphQLList(organizationType), - args: { - ...defaultListArgs(), - ...defaultArgs(db.Organization), - orderByMulti: { - type: new GraphQLList(new GraphQLList(GraphQLString)), - }, - }, - resolve: resolver(db.Organization, { - before: orderByMultiResolver, - }), + args: organizationArgs, + resolve: organizationResolver(db), }, organizationMetas: { type: new GraphQLList(organizationMetaType), @@ -404,6 +417,52 @@ const defaultGrantTagResolverOptions = { limitToGrantId: false, }; +interface OrganizationIdDeducer { + (opts: any): number; +} + +const organizationResolver = ( + db, + orgIdDeducer?: OrganizationIdDeducer +) => async ( + opts, + { limit, offset, orderBy, orderByDirection, uuid = null }, + context, + info +) => { + let where = ''; + if (uuid) { + where = `WHERE o.uuid = ${escape(uuid)}`; + } else if (opts && orgIdDeducer) { + where = `WHERE o.id = ${orgIdDeducer(opts)}`; + } + + const metaCols = Object.keys(organizationSpecialFields).map( + col => `om.${decamelize(col)} AS "${col}"` + ); + + const results = await db.sequelize.query( + `SELECT o.*, ${metaCols.join(',')} +FROM organization o +LEFT JOIN organization_meta om ON o.id=om.id +${where} +ORDER BY "${orderBy}" ${orderByDirection} +LIMIT :limit +OFFSET :offset`, + { + type: db.Sequelize.QueryTypes.SELECT, + model: db.Organization, + mapToModel: true, + replacements: { + limit: Math.min(limit, MAX_LIMIT), + offset, + }, + } + ); + + return orgIdDeducer ? results[0] : results; +}; + const organizationTagResolver = ( db, resolverOpts: OrganizationTagResolverOptions = defaultOrganizationTagResolverOptions @@ -413,9 +472,6 @@ const organizationTagResolver = ( context, info ) => { - const { fieldNodes } = info; - const ast = simplifyAST(fieldNodes[0], info); - let where = ''; // Fetching only organization tags related to a specific organization if (resolverOpts.limitToOrganizationId) { @@ -456,9 +512,6 @@ const nteeOrganizationTypeResolver = ( context, info ) => { - const { fieldNodes } = info; - const ast = simplifyAST(fieldNodes[0], info); - let where = ''; // Fetching only organization tags related to a specific organization if (resolverOpts.limitToOrganizationId) { @@ -499,9 +552,6 @@ const grantTagResolver = ( context, info ) => { - const { fieldNodes } = info; - const ast = simplifyAST(fieldNodes[0], info); - let where = ''; // Fetching only grant tags related to a specific grant if (resolverOpts.limitToGrantId) { @@ -541,9 +591,6 @@ const nteeGrantTypeResolver = ( context, info ) => { - const { fieldNodes } = info; - const ast = simplifyAST(fieldNodes[0], info); - let where = ''; // Fetching only grant tags related to a specific grant if (resolverOpts.limitToGrantId) { From c81894b098b0df4e50226603ca0524221ba34ce8 Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Tue, 29 Jan 2019 08:48:17 -0500 Subject: [PATCH 07/16] more custom resolver jazz --- .../test-queries/orgs-with-grants.js | 1021 ++++++++--------- src/server.ts | 23 +- 2 files changed, 529 insertions(+), 515 deletions(-) diff --git a/integration-tests/test-queries/orgs-with-grants.js b/integration-tests/test-queries/orgs-with-grants.js index b765adf..1d125ac 100644 --- a/integration-tests/test-queries/orgs-with-grants.js +++ b/integration-tests/test-queries/orgs-with-grants.js @@ -1,41 +1,40 @@ export const query = ` -query foo { - organizationMetas( +query orgsWithGrants { + organizations( limit: 2 offset: 1 - orderByMulti: [["totalReceived", "ASC"]] + orderBy: totalReceived + orderByDirection: ASC ) { totalFunded totalReceived grantdatesStart grantdatesEnd - organization { + name + ein + duns + stateCorpId + description + address + links + founded + dissolved + legacyData + publicFunder + grantsFunded { + ...grantFields + } + grantsReceived { + ...grantFields + } + nteeOrganizationTypes { + name + code + description + } + organizationTags { name - ein - duns - stateCorpId description - address - links - founded - dissolved - legacyData - publicFunder - grantsFunded { - ...grantFields - } - grantsReceived { - ...grantFields - } - nteeOrganizationTypes { - name - code - description - } - organizationTags { - name - description - } } } } @@ -68,526 +67,522 @@ fragment grantFields on Grant { export const expected = { data: { - organizationMetas: [ + organizations: [ { - totalFunded: '40', - totalReceived: '40', + totalFunded: 40, + totalReceived: 40, grantdatesStart: '2001-02-02', grantdatesEnd: '2001-02-02', - organization: { - name: 'test organization 19', - ein: '19', - duns: '19', - stateCorpId: '19', - description: 'test organization 19 description!', - address: { - postalCode: '19', + name: 'test organization 19', + ein: '19', + duns: '19', + stateCorpId: '19', + description: 'test organization 19 description!', + address: { + postalCode: '19', + }, + links: [ + { + url: 'ftp://19', + description: 'a link', }, - links: [ - { - url: 'ftp://19', - description: 'a link', - }, - { - url: 'gopher://19', - description: 'another link', - }, - ], - founded: '2000-09-20', - dissolved: '2001-09-20', - legacyData: { - drupalId: 19, + { + url: 'gopher://19', + description: 'another link', }, - publicFunder: true, - grantsFunded: [ - { - from: { - name: 'test organization 19', - }, - to: { - name: 'test organization 18', - }, - dateFrom: '2001-02-02', - dateTo: '2010-02-02', - amount: 20, - source: 'grant 1 source', - description: 'grant 1 description', - internalNotes: 'grant 1 internal notes', - legacyData: { - drupalId: 1, - }, - federalAwardId: 'grant 1 federal award id', - nteeGrantTypes: [ - { - name: 'test ntee grant type 0', - description: 'test ntee grant type 0 description', - }, - { - name: 'test ntee grant type 1', - description: 'test ntee grant type 1 description', - }, - { - name: 'test ntee grant type 2', - description: 'test ntee grant type 2 description', - }, - ], - grantTags: [ - { - name: 'test grant tag 0', - description: 'test grant tag 0 description', - }, - { - name: 'test grant tag 1', - description: 'test grant tag 1 description', - }, - { - name: 'test grant tag 2', - description: 'test grant tag 2 description', - }, - ], + ], + founded: '2000-09-20', + dissolved: '2001-09-20', + legacyData: { + drupalId: 19, + }, + publicFunder: true, + grantsFunded: [ + { + from: { + name: 'test organization 19', }, - { - from: { - name: 'test organization 19', - }, - to: { - name: 'test organization 20', - }, - dateFrom: '2001-02-02', - dateTo: '2010-02-02', - amount: 20, - source: 'grant 1 source', - description: 'grant 1 description', - internalNotes: 'grant 1 internal notes', - legacyData: { - drupalId: 1, - }, - federalAwardId: 'grant 1 federal award id', - nteeGrantTypes: [ - { - name: 'test ntee grant type 0', - description: 'test ntee grant type 0 description', - }, - { - name: 'test ntee grant type 1', - description: 'test ntee grant type 1 description', - }, - { - name: 'test ntee grant type 2', - description: 'test ntee grant type 2 description', - }, - ], - grantTags: [ - { - name: 'test grant tag 0', - description: 'test grant tag 0 description', - }, - { - name: 'test grant tag 1', - description: 'test grant tag 1 description', - }, - { - name: 'test grant tag 2', - description: 'test grant tag 2 description', - }, - ], + to: { + name: 'test organization 18', }, - ], - grantsReceived: [ - { - from: { - name: 'test organization 18', - }, - to: { - name: 'test organization 19', - }, - dateFrom: '2001-02-02', - dateTo: '2010-02-02', - amount: 20, - source: 'grant 1 source', - description: 'grant 1 description', - internalNotes: 'grant 1 internal notes', - legacyData: { - drupalId: 1, - }, - federalAwardId: 'grant 1 federal award id', - nteeGrantTypes: [ - { - name: 'test ntee grant type 0', - description: 'test ntee grant type 0 description', - }, - { - name: 'test ntee grant type 1', - description: 'test ntee grant type 1 description', - }, - { - name: 'test ntee grant type 2', - description: 'test ntee grant type 2 description', - }, - ], - grantTags: [ - { - name: 'test grant tag 0', - description: 'test grant tag 0 description', - }, - { - name: 'test grant tag 1', - description: 'test grant tag 1 description', - }, - { - name: 'test grant tag 2', - description: 'test grant tag 2 description', - }, - ], + dateFrom: '2001-02-02', + dateTo: '2010-02-02', + amount: 20, + source: 'grant 1 source', + description: 'grant 1 description', + internalNotes: 'grant 1 internal notes', + legacyData: { + drupalId: 1, }, - { - from: { - name: 'test organization 20', - }, - to: { - name: 'test organization 19', - }, - dateFrom: '2001-02-02', - dateTo: '2010-02-02', - amount: 20, - source: 'grant 1 source', - description: 'grant 1 description', - internalNotes: 'grant 1 internal notes', - legacyData: { - drupalId: 1, - }, - federalAwardId: 'grant 1 federal award id', - nteeGrantTypes: [ - { - name: 'test ntee grant type 0', - description: 'test ntee grant type 0 description', - }, - { - name: 'test ntee grant type 1', - description: 'test ntee grant type 1 description', - }, - { - name: 'test ntee grant type 2', - description: 'test ntee grant type 2 description', - }, - ], - grantTags: [ - { - name: 'test grant tag 0', - description: 'test grant tag 0 description', - }, - { - name: 'test grant tag 1', - description: 'test grant tag 1 description', - }, - { - name: 'test grant tag 2', - description: 'test grant tag 2 description', - }, - ], + federalAwardId: 'grant 1 federal award id', + nteeGrantTypes: [ + { + name: 'test ntee grant type 0', + description: 'test ntee grant type 0 description', + }, + { + name: 'test ntee grant type 1', + description: 'test ntee grant type 1 description', + }, + { + name: 'test ntee grant type 2', + description: 'test ntee grant type 2 description', + }, + ], + grantTags: [ + { + name: 'test grant tag 0', + description: 'test grant tag 0 description', + }, + { + name: 'test grant tag 1', + description: 'test grant tag 1 description', + }, + { + name: 'test grant tag 2', + description: 'test grant tag 2 description', + }, + ], + }, + { + from: { + name: 'test organization 19', }, - ], - nteeOrganizationTypes: [ - { - name: 'test ntee organization type 1', - code: 'test ntee organization type code 1', - description: 'test ntee organization type 1 description', + to: { + name: 'test organization 20', }, - { - name: 'test ntee organization type 2', - code: 'test ntee organization type code 2', - description: 'test ntee organization type 2 description', + dateFrom: '2001-02-02', + dateTo: '2010-02-02', + amount: 20, + source: 'grant 1 source', + description: 'grant 1 description', + internalNotes: 'grant 1 internal notes', + legacyData: { + drupalId: 1, }, - { - name: 'test ntee organization type 3', - code: 'test ntee organization type code 3', - description: 'test ntee organization type 3 description', + federalAwardId: 'grant 1 federal award id', + nteeGrantTypes: [ + { + name: 'test ntee grant type 0', + description: 'test ntee grant type 0 description', + }, + { + name: 'test ntee grant type 1', + description: 'test ntee grant type 1 description', + }, + { + name: 'test ntee grant type 2', + description: 'test ntee grant type 2 description', + }, + ], + grantTags: [ + { + name: 'test grant tag 0', + description: 'test grant tag 0 description', + }, + { + name: 'test grant tag 1', + description: 'test grant tag 1 description', + }, + { + name: 'test grant tag 2', + description: 'test grant tag 2 description', + }, + ], + }, + ], + grantsReceived: [ + { + from: { + name: 'test organization 18', }, - { - name: 'test ntee organization type 4', - code: 'test ntee organization type code 4', - description: 'test ntee organization type 4 description', + to: { + name: 'test organization 19', }, - ], - organizationTags: [ - { - name: 'test organization tag 1', - description: 'test organization tag 1 description', + dateFrom: '2001-02-02', + dateTo: '2010-02-02', + amount: 20, + source: 'grant 1 source', + description: 'grant 1 description', + internalNotes: 'grant 1 internal notes', + legacyData: { + drupalId: 1, }, - { - name: 'test organization tag 2', - description: 'test organization tag 2 description', + federalAwardId: 'grant 1 federal award id', + nteeGrantTypes: [ + { + name: 'test ntee grant type 0', + description: 'test ntee grant type 0 description', + }, + { + name: 'test ntee grant type 1', + description: 'test ntee grant type 1 description', + }, + { + name: 'test ntee grant type 2', + description: 'test ntee grant type 2 description', + }, + ], + grantTags: [ + { + name: 'test grant tag 0', + description: 'test grant tag 0 description', + }, + { + name: 'test grant tag 1', + description: 'test grant tag 1 description', + }, + { + name: 'test grant tag 2', + description: 'test grant tag 2 description', + }, + ], + }, + { + from: { + name: 'test organization 20', }, - { - name: 'test organization tag 3', - description: 'test organization tag 3 description', + to: { + name: 'test organization 19', }, - { - name: 'test organization tag 4', - description: 'test organization tag 4 description', + dateFrom: '2001-02-02', + dateTo: '2010-02-02', + amount: 20, + source: 'grant 1 source', + description: 'grant 1 description', + internalNotes: 'grant 1 internal notes', + legacyData: { + drupalId: 1, }, - ], - }, + federalAwardId: 'grant 1 federal award id', + nteeGrantTypes: [ + { + name: 'test ntee grant type 0', + description: 'test ntee grant type 0 description', + }, + { + name: 'test ntee grant type 1', + description: 'test ntee grant type 1 description', + }, + { + name: 'test ntee grant type 2', + description: 'test ntee grant type 2 description', + }, + ], + grantTags: [ + { + name: 'test grant tag 0', + description: 'test grant tag 0 description', + }, + { + name: 'test grant tag 1', + description: 'test grant tag 1 description', + }, + { + name: 'test grant tag 2', + description: 'test grant tag 2 description', + }, + ], + }, + ], + nteeOrganizationTypes: [ + { + name: 'test ntee organization type 1', + code: 'test ntee organization type code 1', + description: 'test ntee organization type 1 description', + }, + { + name: 'test ntee organization type 2', + code: 'test ntee organization type code 2', + description: 'test ntee organization type 2 description', + }, + { + name: 'test ntee organization type 3', + code: 'test ntee organization type code 3', + description: 'test ntee organization type 3 description', + }, + { + name: 'test ntee organization type 4', + code: 'test ntee organization type code 4', + description: 'test ntee organization type 4 description', + }, + ], + organizationTags: [ + { + name: 'test organization tag 1', + description: 'test organization tag 1 description', + }, + { + name: 'test organization tag 2', + description: 'test organization tag 2 description', + }, + { + name: 'test organization tag 3', + description: 'test organization tag 3 description', + }, + { + name: 'test organization tag 4', + description: 'test organization tag 4 description', + }, + ], }, { - totalFunded: '40', - totalReceived: '40', + totalFunded: 40, + totalReceived: 40, grantdatesStart: '2001-02-02', grantdatesEnd: '2001-02-02', - organization: { - name: 'test organization 20', - ein: '20', - duns: '20', - stateCorpId: '20', - description: 'test organization 20 description!', - address: { - postalCode: '20', + name: 'test organization 20', + ein: '20', + duns: '20', + stateCorpId: '20', + description: 'test organization 20 description!', + address: { + postalCode: '20', + }, + links: [ + { + url: 'ftp://20', + description: 'a link', }, - links: [ - { - url: 'ftp://20', - description: 'a link', - }, - { - url: 'gopher://20', - description: 'another link', - }, - ], - founded: '2000-10-21', - dissolved: '2001-10-21', - legacyData: { - drupalId: 20, + { + url: 'gopher://20', + description: 'another link', }, - publicFunder: false, - grantsFunded: [ - { - from: { - name: 'test organization 20', - }, - to: { - name: 'test organization 19', - }, - dateFrom: '2001-02-02', - dateTo: '2010-02-02', - amount: 20, - source: 'grant 1 source', - description: 'grant 1 description', - internalNotes: 'grant 1 internal notes', - legacyData: { - drupalId: 1, - }, - federalAwardId: 'grant 1 federal award id', - nteeGrantTypes: [ - { - name: 'test ntee grant type 0', - description: 'test ntee grant type 0 description', - }, - { - name: 'test ntee grant type 1', - description: 'test ntee grant type 1 description', - }, - { - name: 'test ntee grant type 2', - description: 'test ntee grant type 2 description', - }, - ], - grantTags: [ - { - name: 'test grant tag 0', - description: 'test grant tag 0 description', - }, - { - name: 'test grant tag 1', - description: 'test grant tag 1 description', - }, - { - name: 'test grant tag 2', - description: 'test grant tag 2 description', - }, - ], + ], + founded: '2000-10-21', + dissolved: '2001-10-21', + legacyData: { + drupalId: 20, + }, + publicFunder: false, + grantsFunded: [ + { + from: { + name: 'test organization 20', }, - { - from: { - name: 'test organization 20', - }, - to: { - name: 'test organization 21', - }, - dateFrom: '2001-02-02', - dateTo: '2010-02-02', - amount: 20, - source: 'grant 1 source', - description: 'grant 1 description', - internalNotes: 'grant 1 internal notes', - legacyData: { - drupalId: 1, - }, - federalAwardId: 'grant 1 federal award id', - nteeGrantTypes: [ - { - name: 'test ntee grant type 0', - description: 'test ntee grant type 0 description', - }, - { - name: 'test ntee grant type 1', - description: 'test ntee grant type 1 description', - }, - { - name: 'test ntee grant type 2', - description: 'test ntee grant type 2 description', - }, - ], - grantTags: [ - { - name: 'test grant tag 0', - description: 'test grant tag 0 description', - }, - { - name: 'test grant tag 1', - description: 'test grant tag 1 description', - }, - { - name: 'test grant tag 2', - description: 'test grant tag 2 description', - }, - ], + to: { + name: 'test organization 19', }, - ], - grantsReceived: [ - { - from: { - name: 'test organization 19', - }, - to: { - name: 'test organization 20', - }, - dateFrom: '2001-02-02', - dateTo: '2010-02-02', - amount: 20, - source: 'grant 1 source', - description: 'grant 1 description', - internalNotes: 'grant 1 internal notes', - legacyData: { - drupalId: 1, - }, - federalAwardId: 'grant 1 federal award id', - nteeGrantTypes: [ - { - name: 'test ntee grant type 0', - description: 'test ntee grant type 0 description', - }, - { - name: 'test ntee grant type 1', - description: 'test ntee grant type 1 description', - }, - { - name: 'test ntee grant type 2', - description: 'test ntee grant type 2 description', - }, - ], - grantTags: [ - { - name: 'test grant tag 0', - description: 'test grant tag 0 description', - }, - { - name: 'test grant tag 1', - description: 'test grant tag 1 description', - }, - { - name: 'test grant tag 2', - description: 'test grant tag 2 description', - }, - ], + dateFrom: '2001-02-02', + dateTo: '2010-02-02', + amount: 20, + source: 'grant 1 source', + description: 'grant 1 description', + internalNotes: 'grant 1 internal notes', + legacyData: { + drupalId: 1, }, - { - from: { - name: 'test organization 21', - }, - to: { - name: 'test organization 20', - }, - dateFrom: '2001-02-02', - dateTo: '2010-02-02', - amount: 20, - source: 'grant 1 source', - description: 'grant 1 description', - internalNotes: 'grant 1 internal notes', - legacyData: { - drupalId: 1, - }, - federalAwardId: 'grant 1 federal award id', - nteeGrantTypes: [ - { - name: 'test ntee grant type 0', - description: 'test ntee grant type 0 description', - }, - { - name: 'test ntee grant type 1', - description: 'test ntee grant type 1 description', - }, - { - name: 'test ntee grant type 2', - description: 'test ntee grant type 2 description', - }, - ], - grantTags: [ - { - name: 'test grant tag 0', - description: 'test grant tag 0 description', - }, - { - name: 'test grant tag 1', - description: 'test grant tag 1 description', - }, - { - name: 'test grant tag 2', - description: 'test grant tag 2 description', - }, - ], + federalAwardId: 'grant 1 federal award id', + nteeGrantTypes: [ + { + name: 'test ntee grant type 0', + description: 'test ntee grant type 0 description', + }, + { + name: 'test ntee grant type 1', + description: 'test ntee grant type 1 description', + }, + { + name: 'test ntee grant type 2', + description: 'test ntee grant type 2 description', + }, + ], + grantTags: [ + { + name: 'test grant tag 0', + description: 'test grant tag 0 description', + }, + { + name: 'test grant tag 1', + description: 'test grant tag 1 description', + }, + { + name: 'test grant tag 2', + description: 'test grant tag 2 description', + }, + ], + }, + { + from: { + name: 'test organization 20', }, - ], - nteeOrganizationTypes: [ - { - name: 'test ntee organization type 1', - code: 'test ntee organization type code 1', - description: 'test ntee organization type 1 description', + to: { + name: 'test organization 21', }, - { - name: 'test ntee organization type 2', - code: 'test ntee organization type code 2', - description: 'test ntee organization type 2 description', + dateFrom: '2001-02-02', + dateTo: '2010-02-02', + amount: 20, + source: 'grant 1 source', + description: 'grant 1 description', + internalNotes: 'grant 1 internal notes', + legacyData: { + drupalId: 1, }, - { - name: 'test ntee organization type 3', - code: 'test ntee organization type code 3', - description: 'test ntee organization type 3 description', + federalAwardId: 'grant 1 federal award id', + nteeGrantTypes: [ + { + name: 'test ntee grant type 0', + description: 'test ntee grant type 0 description', + }, + { + name: 'test ntee grant type 1', + description: 'test ntee grant type 1 description', + }, + { + name: 'test ntee grant type 2', + description: 'test ntee grant type 2 description', + }, + ], + grantTags: [ + { + name: 'test grant tag 0', + description: 'test grant tag 0 description', + }, + { + name: 'test grant tag 1', + description: 'test grant tag 1 description', + }, + { + name: 'test grant tag 2', + description: 'test grant tag 2 description', + }, + ], + }, + ], + grantsReceived: [ + { + from: { + name: 'test organization 19', }, - { - name: 'test ntee organization type 4', - code: 'test ntee organization type code 4', - description: 'test ntee organization type 4 description', + to: { + name: 'test organization 20', }, - ], - organizationTags: [ - { - name: 'test organization tag 1', - description: 'test organization tag 1 description', + dateFrom: '2001-02-02', + dateTo: '2010-02-02', + amount: 20, + source: 'grant 1 source', + description: 'grant 1 description', + internalNotes: 'grant 1 internal notes', + legacyData: { + drupalId: 1, }, - { - name: 'test organization tag 2', - description: 'test organization tag 2 description', + federalAwardId: 'grant 1 federal award id', + nteeGrantTypes: [ + { + name: 'test ntee grant type 0', + description: 'test ntee grant type 0 description', + }, + { + name: 'test ntee grant type 1', + description: 'test ntee grant type 1 description', + }, + { + name: 'test ntee grant type 2', + description: 'test ntee grant type 2 description', + }, + ], + grantTags: [ + { + name: 'test grant tag 0', + description: 'test grant tag 0 description', + }, + { + name: 'test grant tag 1', + description: 'test grant tag 1 description', + }, + { + name: 'test grant tag 2', + description: 'test grant tag 2 description', + }, + ], + }, + { + from: { + name: 'test organization 21', }, - { - name: 'test organization tag 3', - description: 'test organization tag 3 description', + to: { + name: 'test organization 20', }, - { - name: 'test organization tag 4', - description: 'test organization tag 4 description', + dateFrom: '2001-02-02', + dateTo: '2010-02-02', + amount: 20, + source: 'grant 1 source', + description: 'grant 1 description', + internalNotes: 'grant 1 internal notes', + legacyData: { + drupalId: 1, }, - ], - }, + federalAwardId: 'grant 1 federal award id', + nteeGrantTypes: [ + { + name: 'test ntee grant type 0', + description: 'test ntee grant type 0 description', + }, + { + name: 'test ntee grant type 1', + description: 'test ntee grant type 1 description', + }, + { + name: 'test ntee grant type 2', + description: 'test ntee grant type 2 description', + }, + ], + grantTags: [ + { + name: 'test grant tag 0', + description: 'test grant tag 0 description', + }, + { + name: 'test grant tag 1', + description: 'test grant tag 1 description', + }, + { + name: 'test grant tag 2', + description: 'test grant tag 2 description', + }, + ], + }, + ], + nteeOrganizationTypes: [ + { + name: 'test ntee organization type 1', + code: 'test ntee organization type code 1', + description: 'test ntee organization type 1 description', + }, + { + name: 'test ntee organization type 2', + code: 'test ntee organization type code 2', + description: 'test ntee organization type 2 description', + }, + { + name: 'test ntee organization type 3', + code: 'test ntee organization type code 3', + description: 'test ntee organization type 3 description', + }, + { + name: 'test ntee organization type 4', + code: 'test ntee organization type code 4', + description: 'test ntee organization type 4 description', + }, + ], + organizationTags: [ + { + name: 'test organization tag 1', + description: 'test organization tag 1 description', + }, + { + name: 'test organization tag 2', + description: 'test organization tag 2 description', + }, + { + name: 'test organization tag 3', + description: 'test organization tag 3 description', + }, + { + name: 'test organization tag 4', + description: 'test organization tag 4 description', + }, + ], }, ], }, diff --git a/src/server.ts b/src/server.ts index 0614491..a93abfd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -207,7 +207,14 @@ export default function createServer(db: Db): GraphQLServer { organizations: { type: new GraphQLList(shallowOrganizationType), args: organizationArgs, - resolve: organizationResolver(db), + resolve: organizationResolver( + db, + undefined, + opts => + `INNER JOIN news_organizations no ON no.news_id=${ + opts.id + } AND no.organization_id=o.id` + ), }, grants: { type: new GraphQLList(grantType), @@ -421,9 +428,15 @@ interface OrganizationIdDeducer { (opts: any): number; } +// Add a join to the organization query +interface OrganizationAddJoin { + (opts: any): string; +} + const organizationResolver = ( db, - orgIdDeducer?: OrganizationIdDeducer + orgIdDeducer?: OrganizationIdDeducer, + orgAddJoin?: OrganizationAddJoin ) => async ( opts, { limit, offset, orderBy, orderByDirection, uuid = null }, @@ -437,6 +450,11 @@ const organizationResolver = ( where = `WHERE o.id = ${orgIdDeducer(opts)}`; } + let addedJoin = ''; + if (opts && orgAddJoin) { + addedJoin = orgAddJoin(opts); + } + const metaCols = Object.keys(organizationSpecialFields).map( col => `om.${decamelize(col)} AS "${col}"` ); @@ -445,6 +463,7 @@ const organizationResolver = ( `SELECT o.*, ${metaCols.join(',')} FROM organization o LEFT JOIN organization_meta om ON o.id=om.id +${addedJoin} ${where} ORDER BY "${orderBy}" ${orderByDirection} LIMIT :limit From dbe6f0b6f1248edb4c5521519b2ddee1a80f0016 Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Tue, 29 Jan 2019 09:14:24 -0500 Subject: [PATCH 08/16] oops left a debugger in there --- src/server.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/server.ts b/src/server.ts index a93abfd..3687223 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,12 +28,7 @@ import { Db } from './db/models'; const MAX_LIMIT = 100; const organizationSpecialFields = { - countGrantsFrom: { - type: GraphQLInt, - resolve: (a, b, c, d, e, f, g) => { - debugger; - }, - }, + countGrantsFrom: { type: GraphQLInt }, countGrantsTo: { type: GraphQLInt }, countDistinctFunders: { type: GraphQLInt }, countDistinctRecipients: { type: GraphQLInt }, From 06726667cc2cd4dabe1958ded76cddc045d50376 Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Tue, 29 Jan 2019 09:23:20 -0500 Subject: [PATCH 09/16] add nameLike arg for organizations & clean up tests --- integration-tests/index.test.js | 16 +++++- integration-tests/test-queries/more-meta.js | 19 +++---- .../test-queries/org-name-like.js | 25 +++++++++ src/server.ts | 54 +++++-------------- 4 files changed, 60 insertions(+), 54 deletions(-) create mode 100644 integration-tests/test-queries/org-name-like.js diff --git a/integration-tests/index.test.js b/integration-tests/index.test.js index b792b47..ba6e56d 100644 --- a/integration-tests/index.test.js +++ b/integration-tests/index.test.js @@ -8,6 +8,7 @@ import * as orgsWithGrants from './test-queries/orgs-with-grants'; import * as someNews from './test-queries/some-news'; import * as boardTerms from './test-queries/board-terms'; import * as moreMeta from './test-queries/more-meta'; +import * as orgNameLike from './test-queries/org-name-like'; const createServerInstance = async () => { const db = dbFactory(); @@ -67,13 +68,13 @@ test('organization_meta updates automatically', async () => { const { uri, instance, db } = await createServerInstance(); const query = ` query foo { - giver: organizationMetas(id: 3) { + giver: organizations(id: 3) { countGrantsTo countGrantsFrom countDistinctFunders countDistinctRecipients } - receiver: organizationMetas(id: 91) { + receiver: organizations(id: 91) { countGrantsTo countGrantsFrom countDistinctFunders @@ -136,3 +137,14 @@ query foo { instance.close(); }); + +test('filters organizations by name', async () => { + const { uri, instance } = await createServerInstance(); + + const res = await request(uri, orgNameLike.query); + + expect(res).toEqual(orgNameLike.expected.data); + + instance.close(); +}); + diff --git a/integration-tests/test-queries/more-meta.js b/integration-tests/test-queries/more-meta.js index cef2073..478243a 100644 --- a/integration-tests/test-queries/more-meta.js +++ b/integration-tests/test-queries/more-meta.js @@ -1,41 +1,36 @@ export const query = ` query foo { - organizationMetas( + organizations( limit: 2 offset: 1 - orderByMulti: [["countGrantsTo", "DESC"], ["id", "ASC"]] + orderBy: countGrantsTo + orderByDirection: DESC ) { countGrantsTo countGrantsFrom countDistinctFunders countDistinctRecipients - organization { - name - } + name } } `; export const expected = { data: { - organizationMetas: [ + organizations: [ { countGrantsTo: 16, countGrantsFrom: 16, countDistinctFunders: 16, countDistinctRecipients: 16, - organization: { - name: 'test organization 89', - }, + name: 'test organization 89', }, { countGrantsTo: 16, countGrantsFrom: 16, countDistinctFunders: 16, countDistinctRecipients: 16, - organization: { - name: 'test organization 91', - }, + name: 'test organization 91', }, ], }, diff --git a/integration-tests/test-queries/org-name-like.js b/integration-tests/test-queries/org-name-like.js new file mode 100644 index 0000000..ad9b4e4 --- /dev/null +++ b/integration-tests/test-queries/org-name-like.js @@ -0,0 +1,25 @@ +export const query = ` +query orgNameLike { + organizations(nameLike: "%organization 9%", limit: 11) { + name + } +} +`; + +export const expected = { + data: { + organizations: [ + { name: 'test organization 9' }, + { name: 'test organization 90' }, + { name: 'test organization 91' }, + { name: 'test organization 92' }, + { name: 'test organization 93' }, + { name: 'test organization 94' }, + { name: 'test organization 95' }, + { name: 'test organization 96' }, + { name: 'test organization 97' }, + { name: 'test organization 98' }, + { name: 'test organization 99' }, + ], + }, +}; diff --git a/src/server.ts b/src/server.ts index 3687223..f282d16 100644 --- a/src/server.ts +++ b/src/server.ts @@ -57,20 +57,6 @@ export default function createServer(db: Db): GraphQLServer { return options; }; - const organizationNameILikeResolver = (opts, args) => { - if (args.organizationNameILike) - opts.include = [ - { - required: true, - model: db.Organization, - where: { - name: { [db.sequelize.Op.iLike]: args.organizationNameILike }, - }, - }, - ]; - return opts; - }; - resolver.contextToOptions = { [EXPECTED_OPTIONS_KEY]: EXPECTED_OPTIONS_KEY }; // Arguments @@ -84,10 +70,12 @@ export default function createServer(db: Db): GraphQLServer { 'totalFunded', 'totalReceived', ]); - const organizationArgs = ledgerListArgs( - db.Organization, - Object.keys(organizationSpecialFields) - ); + const organizationArgs = { + ...ledgerListArgs(db.Organization, Object.keys(organizationSpecialFields)), + nameLike: { + type: GraphQLString, + }, + }; // Types const shallowOrganizationType = new GraphQLObjectType({ @@ -330,27 +318,6 @@ export default function createServer(db: Db): GraphQLServer { args: organizationArgs, resolve: organizationResolver(db), }, - organizationMetas: { - type: new GraphQLList(organizationMetaType), - args: { - ...defaultListArgs(), - ...defaultArgs(db.OrganizationMeta), - orderByMulti: { - type: new GraphQLList(new GraphQLList(GraphQLString)), - }, - organizationNameILike: { - type: GraphQLString, - }, - }, - resolve: resolver(db.OrganizationMeta, { - before: (opts, args) => { - return organizationNameILikeResolver( - orderByMultiResolver(opts, args), - args - ); - }, - }), - }, grant: { type: grantType, args: defaultArgs({ @@ -434,13 +401,17 @@ const organizationResolver = ( orgAddJoin?: OrganizationAddJoin ) => async ( opts, - { limit, offset, orderBy, orderByDirection, uuid = null }, + { limit, offset, orderBy, orderByDirection, uuid = null, id, nameLike }, context, info ) => { let where = ''; if (uuid) { where = `WHERE o.uuid = ${escape(uuid)}`; + } else if (id) { + where = `WHERE o.id= ${escape(id)}`; + } else if (nameLike) { + where = `WHERE o.name ILIKE ${escape(nameLike)}`; } else if (opts && orgIdDeducer) { where = `WHERE o.id = ${orgIdDeducer(opts)}`; } @@ -683,4 +654,7 @@ const ledgerListArgs = ( uuid: { type: GraphQLString, }, + id: { + type: GraphQLInt, + }, }); From 26373eb16532169d4971e4da10aea1fe810763d9 Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Tue, 29 Jan 2019 09:37:07 -0500 Subject: [PATCH 10/16] remove more cruft --- src/server.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/server.ts b/src/server.ts index f282d16..0835699 100644 --- a/src/server.ts +++ b/src/server.ts @@ -253,19 +253,6 @@ export default function createServer(db: Db): GraphQLServer { }, }); - const organizationMetaType = new GraphQLObjectType({ - name: 'OrganizationMeta', - description: 'Extra org info', - fields: { - ...attributeFields(db.OrganizationMeta, { exclude: ['id'] }), - organization: { - type: organizationType, - // @ts-ignore - resolve: resolver(db.OrganizationMeta.Organization), - }, - }, - }); - return new GraphQLServer({ schema: new GraphQLSchema({ query: new GraphQLObjectType({ From 9ddd3d553d122566c333e60d0ef4f250b110c160 Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Fri, 1 Feb 2019 13:07:23 -0500 Subject: [PATCH 11/16] use custom dataloaders to make queries more efficient --- integration-tests/index.test.js | 16 +-- package.json | 1 + src/server.ts | 194 +++++++++++++++++++++++--------- yarn.lock | 2 +- 4 files changed, 149 insertions(+), 64 deletions(-) diff --git a/integration-tests/index.test.js b/integration-tests/index.test.js index ba6e56d..6b0a58c 100644 --- a/integration-tests/index.test.js +++ b/integration-tests/index.test.js @@ -68,13 +68,13 @@ test('organization_meta updates automatically', async () => { const { uri, instance, db } = await createServerInstance(); const query = ` query foo { - giver: organizations(id: 3) { + giver: organization(id: 3) { countGrantsTo countGrantsFrom countDistinctFunders countDistinctRecipients } - receiver: organizations(id: 91) { + receiver: organization(id: 91) { countGrantsTo countGrantsFrom countDistinctFunders @@ -99,40 +99,36 @@ query foo { // Assert expect(resBefore).toEqual({ - giver: [ + giver: { countGrantsTo: 0, countGrantsFrom: 0, countDistinctFunders: 0, countDistinctRecipients: 0, }, - ], - receiver: [ + receiver: { countGrantsTo: 17, countGrantsFrom: 17, countDistinctFunders: 17, countDistinctRecipients: 17, }, - ], }); expect(resAfter).toEqual({ - giver: [ + giver: { countGrantsTo: 0, countGrantsFrom: 1, countDistinctFunders: 0, countDistinctRecipients: 1, }, - ], - receiver: [ + receiver: { countGrantsTo: 18, countGrantsFrom: 17, countDistinctFunders: 18, countDistinctRecipients: 17, }, - ], }); instance.close(); diff --git a/package.json b/package.json index 66e10cd..75d1737 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dependencies": { "bunyan": "^1.8.12", "config": "^2.0.1", + "dataloader": "1.4.0", "dataloader-sequelize": "1.7.7", "decamelize": "2.0.0", "google-auth-library": "1.6.1", diff --git a/src/server.ts b/src/server.ts index 0835699..2b3ab1e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import * as Sequelize from 'sequelize'; import * as decamelize from 'decamelize'; import { GraphQLServer } from 'graphql-yoga'; import { createContext, EXPECTED_OPTIONS_KEY } from 'dataloader-sequelize'; +import * as DataLoader from 'dataloader'; import { resolver, attributeFields, @@ -25,6 +26,8 @@ import * as GraphQLBigInt from 'graphql-bigint'; import { Db } from './db/models'; +import { OrganizationInstance } from './db/models/organization'; + const MAX_LIMIT = 100; const organizationSpecialFields = { @@ -39,24 +42,6 @@ const organizationSpecialFields = { }; export default function createServer(db: Db): GraphQLServer { - const orderByMultiResolver = (opts, args) => { - const options = { - order: [], - ...opts, - }; - - if (args.orderByMulti) { - options.order = options.order.concat( - args.orderByMulti.map(arg => [ - arg[0], - arg[1] === 'ASC' ? 'ASC NULLS LAST' : 'DESC NULLS LAST', - ]) - ); - } - - return options; - }; - resolver.contextToOptions = { [EXPECTED_OPTIONS_KEY]: EXPECTED_OPTIONS_KEY }; // Arguments @@ -70,6 +55,7 @@ export default function createServer(db: Db): GraphQLServer { 'totalFunded', 'totalReceived', ]); + const grantArgs = ledgerListArgs(db.Grant); const organizationArgs = { ...ledgerListArgs(db.Organization, Object.keys(organizationSpecialFields)), nameLike: { @@ -142,7 +128,7 @@ export default function createServer(db: Db): GraphQLServer { organization: { type: shallowOrganizationType, args: organizationArgs, - resolve: organizationResolver(db), + resolve: singleOrganizationResolver(), }, }, }); @@ -155,12 +141,12 @@ export default function createServer(db: Db): GraphQLServer { from: { type: shallowOrganizationType, args: organizationArgs, - resolve: organizationResolver(db, opts => opts.get('from')), + resolve: singleOrganizationResolver(opts => opts.get('from')), }, to: { type: shallowOrganizationType, args: organizationArgs, - resolve: organizationResolver(db, opts => opts.get('to')), + resolve: singleOrganizationResolver(opts => opts.get('to')), }, nteeGrantTypes: { type: new GraphQLList(nteeGrantTypeType), @@ -192,7 +178,6 @@ export default function createServer(db: Db): GraphQLServer { args: organizationArgs, resolve: organizationResolver( db, - undefined, opts => `INNER JOIN news_organizations no ON no.news_id=${ opts.id @@ -220,13 +205,13 @@ export default function createServer(db: Db): GraphQLServer { }, grantsFunded: { type: new GraphQLList(grantType), - // @ts-ignore - resolve: resolver(db.Organization.GrantsFunded), + args: grantArgs, + resolve: grantResolver(db, opts => `WHERE g.from=${opts.get('id')}`), }, grantsReceived: { type: new GraphQLList(grantType), - // @ts-ignore - resolve: resolver(db.Organization.GrantsReceived), + args: grantArgs, + resolve: grantResolver(db, opts => `WHERE g.to=${opts.get('id')}`), }, forms990: { type: new GraphQLList(form990Type), @@ -288,9 +273,9 @@ export default function createServer(db: Db): GraphQLServer { type: organizationType, args: defaultArgs({ ...db.Organization, - primaryKeyAttributes: ['uuid'], + primaryKeyAttributes: ['id', 'uuid'], }), - resolve: resolver(db.Organization), + resolve: singleOrganizationResolver(), }, news: { type: new GraphQLList(newsType), @@ -347,11 +332,15 @@ export default function createServer(db: Db): GraphQLServer { context(req) { // For each request, create a DataLoader context for Sequelize to use const dataloaderContext = createContext(db.sequelize); + const getOrganizationById = createGetOrganizationByIdDataloader(db); + const getOrganizationByUuid = createGetOrganizationByUuidDataloader(db); // Using the same EXPECTED_OPTIONS_KEY, store the DataLoader context // in the global request context return { [EXPECTED_OPTIONS_KEY]: dataloaderContext, + getOrganizationById, + getOrganizationByUuid, }; }, }); @@ -373,34 +362,137 @@ const defaultGrantTagResolverOptions = { limitToGrantId: false, }; -interface OrganizationIdDeducer { - (opts: any): number; +// Add a join to a organization query +interface AddJoin { + (opts: any): string; } -// Add a join to the organization query -interface OrganizationAddJoin { - (opts: any): string; +const grantResolver = (db, grantAddWhere?: AddJoin) => async ( + opts, + { limit, offset, orderBy, orderByDirection }, + context, + info +) => { + let where = ''; + if (grantAddWhere) { + where = grantAddWhere(opts); + } + + const results = await db.sequelize.query( + `SELECT g.* +FROM "grant" g +${where} +ORDER BY "${decamelize(orderBy)}" ${orderByDirection} +LIMIT :limit +OFFSET :offset`, + { + type: db.Sequelize.QueryTypes.SELECT, + model: db.Grant, + mapToModel: true, + replacements: { + limit: Math.min(limit, MAX_LIMIT), + offset, + }, + } + ); + + return results; +}; + +const metaCols = Object.keys(organizationSpecialFields).map( + col => `om.${decamelize(col)} AS "${decamelize(col)}"` +); + +const createGetOrganizationByIdDataloader = (db: Db) => { + return new DataLoader( + async (ids: number[]): Promise => { + const results = await db.sequelize.query( + `SELECT o.*, ${metaCols.join(',')} + FROM organization o + LEFT JOIN organization_meta om ON o.id=om.id + WHERE o.id IN(:ids)`, + { + type: db.Sequelize.QueryTypes.SELECT, + model: db.Organization, + mapToModel: true, + replacements: { ids }, + } + ); + + const ordered = ids.map( + id => + results.find(o => o.id === id) || + new Error(`cannot find organization with id ${id}`) + ); + + return ordered; + } + ); +}; + +const createGetOrganizationByUuidDataloader = (db: Db) => { + return new DataLoader( + async (uuids: number[]): Promise => { + const results = await db.sequelize.query( + `SELECT o.*, ${metaCols.join(',')} + FROM organization o + LEFT JOIN organization_meta om ON o.id=om.id + WHERE o.uuid IN(:uuids)`, + { + type: db.Sequelize.QueryTypes.SELECT, + model: db.Organization, + mapToModel: true, + replacements: { uuids }, + } + ); + + const ordered = uuids.map( + uuid => + results.find(o => o.uuid === uuid) || + new Error(`cannot find organization with uuid ${uuid}`) + ); + + return ordered; + } + ); +}; + +interface GetIdFromOpts { + (opts: any): number; } -const organizationResolver = ( - db, - orgIdDeducer?: OrganizationIdDeducer, - orgAddJoin?: OrganizationAddJoin -) => async ( +const singleOrganizationResolver = (getId?: GetIdFromOpts) => async ( + opts, + args, + context, + info +) => { + if (getId) { + return context.getOrganizationById.load(getId(opts)); + } + + if (args.id) { + return context.getOrganizationById.load(args.id); + } + + if (args.uuid) { + return context.getOrganizationByUuid.load(args.uuid); + } +}; + +const singleGrantResolver = getId => async (opts, args, context, info) => { + return context.getGrantById.load(getId(opts)); +}; + +const organizationResolver = (db, orgAddJoin?: AddJoin) => async ( opts, - { limit, offset, orderBy, orderByDirection, uuid = null, id, nameLike }, + { limit, offset, orderBy, orderByDirection, nameLike }, context, info ) => { let where = ''; - if (uuid) { - where = `WHERE o.uuid = ${escape(uuid)}`; - } else if (id) { - where = `WHERE o.id= ${escape(id)}`; - } else if (nameLike) { + if (nameLike) { where = `WHERE o.name ILIKE ${escape(nameLike)}`; - } else if (opts && orgIdDeducer) { - where = `WHERE o.id = ${orgIdDeducer(opts)}`; } let addedJoin = ''; @@ -408,17 +500,13 @@ const organizationResolver = ( addedJoin = orgAddJoin(opts); } - const metaCols = Object.keys(organizationSpecialFields).map( - col => `om.${decamelize(col)} AS "${col}"` - ); - const results = await db.sequelize.query( `SELECT o.*, ${metaCols.join(',')} FROM organization o LEFT JOIN organization_meta om ON o.id=om.id ${addedJoin} ${where} -ORDER BY "${orderBy}" ${orderByDirection} +ORDER BY "${decamelize(orderBy)}" ${orderByDirection} LIMIT :limit OFFSET :offset`, { @@ -432,7 +520,7 @@ OFFSET :offset`, } ); - return orgIdDeducer ? results[0] : results; + return results; }; const organizationTagResolver = ( @@ -595,7 +683,7 @@ OFFSET :offset`, const ledgerListArgs = ( model: Sequelize.Model, - orderBySpecialCols: string[] + orderBySpecialCols: string[] = [] ) => ({ orderBy: { type: new GraphQLEnumType({ diff --git a/yarn.lock b/yarn.lock index d7f1c64..ac91d94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2197,7 +2197,7 @@ dataloader-sequelize@1.7.7: lru-cache "^4.0.1" shimmer "^1.1.0" -dataloader@^1.2.0: +dataloader@1.4.0, dataloader@^1.2.0: version "1.4.0" resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-1.4.0.tgz#bca11d867f5d3f1b9ed9f737bd15970c65dff5c8" integrity sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw== From a794c0cd53d82a163c21d4e4a3779941b4653372 Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Sat, 2 Feb 2019 10:37:15 -0500 Subject: [PATCH 12/16] add test coverage for #9 fix --- integration-tests/index.test.js | 62 ++++++++++--------- .../test-queries/sort-org-grants-by-date.js | 57 +++++++++++++++++ 2 files changed, 91 insertions(+), 28 deletions(-) create mode 100644 integration-tests/test-queries/sort-org-grants-by-date.js diff --git a/integration-tests/index.test.js b/integration-tests/index.test.js index 6b0a58c..cf8ceb3 100644 --- a/integration-tests/index.test.js +++ b/integration-tests/index.test.js @@ -9,6 +9,7 @@ import * as someNews from './test-queries/some-news'; import * as boardTerms from './test-queries/board-terms'; import * as moreMeta from './test-queries/more-meta'; import * as orgNameLike from './test-queries/org-name-like'; +import * as sortOrgGrantsByDate from './test-queries/sort-org-grants-by-date'; const createServerInstance = async () => { const db = dbFactory(); @@ -99,36 +100,32 @@ query foo { // Assert expect(resBefore).toEqual({ - giver: - { - countGrantsTo: 0, - countGrantsFrom: 0, - countDistinctFunders: 0, - countDistinctRecipients: 0, - }, - receiver: - { - countGrantsTo: 17, - countGrantsFrom: 17, - countDistinctFunders: 17, - countDistinctRecipients: 17, - }, + giver: { + countGrantsTo: 0, + countGrantsFrom: 0, + countDistinctFunders: 0, + countDistinctRecipients: 0, + }, + receiver: { + countGrantsTo: 17, + countGrantsFrom: 17, + countDistinctFunders: 17, + countDistinctRecipients: 17, + }, }); expect(resAfter).toEqual({ - giver: - { - countGrantsTo: 0, - countGrantsFrom: 1, - countDistinctFunders: 0, - countDistinctRecipients: 1, - }, - receiver: - { - countGrantsTo: 18, - countGrantsFrom: 17, - countDistinctFunders: 18, - countDistinctRecipients: 17, - }, + giver: { + countGrantsTo: 0, + countGrantsFrom: 1, + countDistinctFunders: 0, + countDistinctRecipients: 1, + }, + receiver: { + countGrantsTo: 18, + countGrantsFrom: 17, + countDistinctFunders: 18, + countDistinctRecipients: 17, + }, }); instance.close(); @@ -144,3 +141,12 @@ test('filters organizations by name', async () => { instance.close(); }); +test('sorts organization grants by date', async () => { + const { uri, instance } = await createServerInstance(); + + const res = await request(uri, sortOrgGrantsByDate.query); + + expect(res).toEqual(sortOrgGrantsByDate.expected.data); + + instance.close(); +}); diff --git a/integration-tests/test-queries/sort-org-grants-by-date.js b/integration-tests/test-queries/sort-org-grants-by-date.js new file mode 100644 index 0000000..f5d7371 --- /dev/null +++ b/integration-tests/test-queries/sort-org-grants-by-date.js @@ -0,0 +1,57 @@ +export const query = ` +query sortOrgGrantsByDate { + organizations( + limit: 1 + offset: 0 + orderBy: countGrantsFrom + orderByDirection: DESC + ) { + name + grantsFunded(orderBy: dateFrom, orderByDirection: ASC) { + dateFrom + } + } +} +`; + +export const expected = { + data: { + organizations: [ + { + name: 'test organization 90', + grantsFunded: [ + { + dateFrom: '2001-02-02', + }, + { + dateFrom: '2001-02-02', + }, + { + dateFrom: '2001-03-03', + }, + { + dateFrom: '2001-03-03', + }, + { + dateFrom: '2001-04-04', + }, + { + dateFrom: '2001-04-04', + }, + { + dateFrom: '2001-05-05', + }, + { + dateFrom: '2001-05-05', + }, + { + dateFrom: '2001-06-06', + }, + { + dateFrom: '2001-06-06', + }, + ], + }, + ], + }, +}; From 7400da0bd3641099b9d335157e2aeb8df62948c4 Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Sun, 3 Feb 2019 16:51:26 -0500 Subject: [PATCH 13/16] big refactor! colocate model-specific graphql code with the models use sequelize virtual fields for all special model properties (tag totals) put utility functions in a helper module --- src/db/models/grant.ts | 145 +++++--- src/db/models/grantTag.ts | 103 +++++- src/db/models/nteeGrantType.ts | 119 +++++-- src/db/models/nteeOrganizationType.ts | 131 ++++++-- src/db/models/organization.ts | 312 +++++++++++++---- src/db/models/organizationTag.ts | 127 +++++-- src/helpers.ts | 82 +++++ src/scripts/tagImporter.ts | 1 + src/server.ts | 464 ++------------------------ 9 files changed, 847 insertions(+), 637 deletions(-) create mode 100644 src/helpers.ts diff --git a/src/db/models/grant.ts b/src/db/models/grant.ts index 874cae8..b79d48f 100644 --- a/src/db/models/grant.ts +++ b/src/db/models/grant.ts @@ -1,4 +1,9 @@ import * as Sequelize from 'sequelize'; +import * as decamelize from 'decamelize'; + +import { Db } from './'; + +import { ledgerListArgs, MAX_LIMIT } from '../../helpers'; import { OrganizationInstance, OrganizationAttributes } from './organization'; import { NewsInstance, NewsAttributes } from './news'; @@ -53,54 +58,56 @@ export interface LegacyData { export type GrantInstance = Sequelize.Instance & GrantAttributes; +const grantColumns = { + uuid: { + type: Sequelize.UUID, + allowNull: true, + defaultValue: Sequelize.UUIDV4, + }, + from: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: 'organization', key: 'id' }, + }, + to: { + type: Sequelize.INTEGER, + allowNull: false, + references: { model: 'organization', key: 'id' }, + }, + dateFrom: { + type: Sequelize.DATEONLY, + allowNull: true, + field: 'date_from', + }, + dateTo: { + type: Sequelize.DATEONLY, + allowNull: true, + field: 'date_to', + }, + amount: { type: Sequelize.BIGINT, allowNull: true }, + source: { type: Sequelize.TEXT, allowNull: true }, + description: { type: Sequelize.TEXT, allowNull: true }, + internalNotes: { + type: Sequelize.TEXT, + allowNull: true, + field: 'internal_notes', + }, + legacyData: { + type: Sequelize.JSON, + allowNull: true, + field: 'legacy_data', + }, + federalAwardId: { + type: Sequelize.STRING, + allowNull: true, + field: 'federal_award_id', + }, +}; + export default (sequelize: Sequelize.Sequelize) => { let Grant = sequelize.define( 'Grant', - { - uuid: { - type: Sequelize.UUID, - allowNull: true, - defaultValue: Sequelize.UUIDV4, - }, - from: { - type: Sequelize.INTEGER, - allowNull: false, - references: { model: sequelize.models.organization, key: 'id' }, - }, - to: { - type: Sequelize.INTEGER, - allowNull: false, - references: { model: sequelize.models.organization, key: 'id' }, - }, - dateFrom: { - type: Sequelize.DATEONLY, - allowNull: true, - field: 'date_from', - }, - dateTo: { - type: Sequelize.DATEONLY, - allowNull: true, - field: 'date_to', - }, - amount: { type: Sequelize.BIGINT, allowNull: true }, - source: { type: Sequelize.TEXT, allowNull: true }, - description: { type: Sequelize.TEXT, allowNull: true }, - internalNotes: { - type: Sequelize.TEXT, - allowNull: true, - field: 'internal_notes', - }, - legacyData: { - type: Sequelize.JSON, - allowNull: true, - field: 'legacy_data', - }, - federalAwardId: { - type: Sequelize.STRING, - allowNull: true, - field: 'federal_award_id', - }, - }, + grantColumns, { createdAt: 'created_at', updatedAt: 'updated_at', @@ -161,3 +168,51 @@ export default (sequelize: Sequelize.Sequelize) => { return Grant; }; + +// Add a join to a grant query +interface AddJoin { + (opts: any): string; +} + +export const grantResolver = (db: Db, grantAddWhere?: AddJoin) => async ( + opts, + { limit, offset, orderBy, orderByDirection }, + context, + info +): Promise => { + let where = ''; + if (grantAddWhere) { + where = grantAddWhere(opts); + } + + const results = await db.sequelize.query( + `SELECT g.* +FROM "grant" g +${where} +ORDER BY "${decamelize(orderBy)}" ${orderByDirection} +LIMIT :limit +OFFSET :offset`, + { + type: db.Sequelize.QueryTypes.SELECT, + model: db.Grant, + mapToModel: true, + replacements: { + limit: Math.min(limit, MAX_LIMIT), + offset, + }, + } + ); + + return results; +}; + +export const singleGrantResolver = getId => async ( + opts, + args, + context, + info +): Promise => { + return context.getGrantById.load(getId(opts)); +}; + +export const grantArgs = ledgerListArgs('Grant', Object.keys(grantColumns)); diff --git a/src/db/models/grantTag.ts b/src/db/models/grantTag.ts index 035351a..d1f775a 100644 --- a/src/db/models/grantTag.ts +++ b/src/db/models/grantTag.ts @@ -1,4 +1,14 @@ import * as Sequelize from 'sequelize'; +import { escape } from 'sequelize/lib/sql-string'; +import * as decamelize from 'decamelize'; + +import { GraphQLFieldConfigMap } from 'graphql'; + +import * as GraphQLBigInt from 'graphql-bigint'; + +import { Db } from './'; + +import { ledgerListArgs, MAX_LIMIT } from '../../helpers'; import { AbstractDrupalTagAttributes } from './abstractDrupalTag'; @@ -10,6 +20,7 @@ export interface GrantTagAttributes extends AbstractDrupalTagAttributes { name: string; description?: string; drupalId?: number; + total?: number; createdAt?: string; updatedAt?: string; } @@ -17,23 +28,29 @@ export interface GrantTagAttributes extends AbstractDrupalTagAttributes { export type GrantTagInstance = Sequelize.Instance & GrantTagAttributes; +const grantTagColumns = { + uuid: { + type: Sequelize.UUID, + allowNull: true, + defaultValue: Sequelize.UUIDV4, + }, + name: { type: Sequelize.STRING, allowNull: false }, + description: { type: Sequelize.STRING, allowNull: true }, + drupalId: { + type: Sequelize.INTEGER, + allowNull: true, + field: 'drupal_id', + }, + total: { + type: new Sequelize.VIRTUAL(Sequelize.BIGINT), + field: 'total', + }, +}; + export default (sequelize: Sequelize.Sequelize) => { let GrantTag = sequelize.define( 'GrantTag', - { - uuid: { - type: Sequelize.UUID, - allowNull: true, - defaultValue: Sequelize.UUIDV4, - }, - name: { type: Sequelize.STRING, allowNull: false }, - description: { type: Sequelize.STRING, allowNull: true }, - drupalId: { - type: Sequelize.INTEGER, - allowNull: true, - field: 'drupal_id', - }, - }, + grantTagColumns, { createdAt: 'created_at', updatedAt: 'updated_at', @@ -59,3 +76,61 @@ export default (sequelize: Sequelize.Sequelize) => { return GrantTag; }; + +export const grantTagSpecialFields: GraphQLFieldConfigMap = { + total: { type: GraphQLBigInt }, +}; + +export const grantTagArgs = ledgerListArgs( + 'GrantTag', + Object.keys(grantTagColumns) +); + +interface GrantTagResolverOptions { + limitToGrantId: boolean; +} + +const defaultGrantTagResolverOptions = { + limitToGrantId: false, +}; + +export const grantTagResolver = ( + db: Db, + resolverOpts: GrantTagResolverOptions = defaultGrantTagResolverOptions +) => async ( + opts, + { limit, offset, orderBy, orderByDirection, uuid = null }, + context, + info +): Promise => { + let where = ''; + // Fetching only grant tags related to a specific grant + if (resolverOpts.limitToGrantId) { + where = `WHERE g.id=${escape(opts.dataValues.id)}`; + } else { + where = uuid ? `WHERE gt.uuid = ${escape(uuid)}` : ''; + } + + const results = await db.sequelize.query( + `SELECT gt.id, gt.uuid, gt.name, gt.description, SUM(g.amount) as total +FROM grant_tag gt +LEFT JOIN grant_grant_tag ggt ON gt.id=ggt.grant_tag_id +LEFT JOIN "grant" g ON ggt.grant_id=g.id +${where} +GROUP BY gt.id +ORDER BY "${decamelize(orderBy)}" ${orderByDirection} +LIMIT :limit +OFFSET :offset`, + { + type: db.Sequelize.QueryTypes.SELECT, + model: db.GrantTag, + mapToModel: true, + replacements: { + limit: Math.min(limit, MAX_LIMIT), + offset, + }, + } + ); + + return results; +}; diff --git a/src/db/models/nteeGrantType.ts b/src/db/models/nteeGrantType.ts index b75a0a5..c127a28 100644 --- a/src/db/models/nteeGrantType.ts +++ b/src/db/models/nteeGrantType.ts @@ -1,4 +1,14 @@ import * as Sequelize from 'sequelize'; +import { escape } from 'sequelize/lib/sql-string'; +import * as decamelize from 'decamelize'; + +import { GraphQLFieldConfigMap } from 'graphql'; + +import * as GraphQLBigInt from 'graphql-bigint'; + +import { Db } from './'; + +import { ledgerListArgs, MAX_LIMIT } from '../../helpers'; import { AbstractDrupalTagAttributes } from './abstractDrupalTag'; @@ -12,6 +22,7 @@ export interface NteeGrantTypeAttributes extends AbstractDrupalTagAttributes { name: string; description?: string; drupalId?: number; + total?: number; createdAt?: string; updatedAt?: string; } @@ -21,34 +32,36 @@ export type NteeGrantTypeInstance = Sequelize.Instance< > & NteeGrantTypeAttributes; +const nteeGrantTypeColumns = { + uuid: { + type: Sequelize.UUID, + allowNull: true, + defaultValue: Sequelize.UUIDV4, + }, + name: { type: Sequelize.STRING, allowNull: false }, + description: { type: Sequelize.STRING, allowNull: true }, + drupalId: { + type: Sequelize.INTEGER, + allowNull: true, + field: 'drupal_id', + }, + total: { + type: new Sequelize.VIRTUAL(Sequelize.BIGINT), + field: 'total', + }, +}; + export default (sequelize: Sequelize.Sequelize) => { let NteeGrantType = sequelize.define< NteeGrantTypeInstance, NteeGrantTypeAttributes - >( - 'NteeGrantType', - { - uuid: { - type: Sequelize.UUID, - allowNull: true, - defaultValue: Sequelize.UUIDV4, - }, - name: { type: Sequelize.STRING, allowNull: false }, - description: { type: Sequelize.STRING, allowNull: true }, - drupalId: { - type: Sequelize.INTEGER, - allowNull: true, - field: 'drupal_id', - }, - }, - { - createdAt: 'created_at', - updatedAt: 'updated_at', - underscored: true, - freezeTableName: true, - tableName: 'ntee_grant_type', - } - ); + >('NteeGrantType', nteeGrantTypeColumns, { + createdAt: 'created_at', + updatedAt: 'updated_at', + underscored: true, + freezeTableName: true, + tableName: 'ntee_grant_type', + }); NteeGrantType.associate = ({ Grant, @@ -66,3 +79,61 @@ export default (sequelize: Sequelize.Sequelize) => { return NteeGrantType; }; + +export const nteeGrantTypeSpecialFields: GraphQLFieldConfigMap = { + total: { type: GraphQLBigInt }, +}; + +export const nteeGrantTypeArgs = ledgerListArgs( + 'NteeGrantType', + Object.keys(nteeGrantTypeColumns) +); + +interface NteeGrantTypeResolverOptions { + limitToGrantId: boolean; +} + +const defaultNteeGrantTypeResolverOptions = { + limitToGrantId: false, +}; + +export const nteeGrantTypeResolver = ( + db: Db, + resolverOpts: NteeGrantTypeResolverOptions = defaultNteeGrantTypeResolverOptions +) => async ( + opts, + { limit, offset, orderBy, orderByDirection, uuid = null }, + context, + info +): Promise => { + let where = ''; + // Fetching only grant tags related to a specific grant + if (resolverOpts.limitToGrantId) { + where = `WHERE g.id=${escape(opts.dataValues.id)}`; + } else { + where = uuid ? `WHERE gt.uuid = ${escape(uuid)}` : ''; + } + + const results = await db.sequelize.query( + `SELECT gt.id, gt.uuid, gt.name, gt.code, gt.description, SUM(g.amount) as total +FROM ntee_grant_type gt +LEFT JOIN grant_ntee_grant_type ggt ON gt.id=ggt.ntee_grant_type_id +LEFT JOIN "grant" g ON ggt.grant_id=g.id +${where} +GROUP BY gt.id +ORDER BY "${decamelize(orderBy)}" ${orderByDirection} +LIMIT :limit +OFFSET :offset`, + { + type: db.Sequelize.QueryTypes.SELECT, + model: db.NteeGrantType, + mapToModel: true, + replacements: { + limit: Math.min(limit, MAX_LIMIT), + offset, + }, + } + ); + + return results; +}; diff --git a/src/db/models/nteeOrganizationType.ts b/src/db/models/nteeOrganizationType.ts index 6370a06..bb8e82e 100644 --- a/src/db/models/nteeOrganizationType.ts +++ b/src/db/models/nteeOrganizationType.ts @@ -1,4 +1,14 @@ import * as Sequelize from 'sequelize'; +import { escape } from 'sequelize/lib/sql-string'; +import * as decamelize from 'decamelize'; + +import { GraphQLFieldConfigMap } from 'graphql'; + +import * as GraphQLBigInt from 'graphql-bigint'; + +import { Db } from './'; + +import { ledgerListArgs, MAX_LIMIT } from '../../helpers'; import { AbstractDrupalTagAttributes } from './abstractDrupalTag'; @@ -15,6 +25,8 @@ export interface NteeOrganizationTypeAttributes code: string; description?: string; drupalId?: number; + totalFunded?: number; + totalReceived?: number; createdAt?: string; updatedAt?: string; } @@ -24,35 +36,41 @@ export type NteeOrganizationTypeInstance = Sequelize.Instance< > & NteeOrganizationTypeAttributes; +const nteeOrganizationTypeColumns = { + uuid: { + type: Sequelize.UUID, + allowNull: true, + defaultValue: Sequelize.UUIDV4, + }, + name: { type: Sequelize.STRING, allowNull: false }, + code: { type: Sequelize.STRING, allowNull: false }, + description: { type: Sequelize.STRING, allowNull: true }, + drupalId: { + type: Sequelize.INTEGER, + allowNull: true, + field: 'drupal_id', + }, + totalFunded: { + type: new Sequelize.VIRTUAL(Sequelize.BIGINT), + field: 'total_funded', + }, + totalReceived: { + type: new Sequelize.VIRTUAL(Sequelize.BIGINT), + field: 'total_received', + }, +}; + export default (sequelize: Sequelize.Sequelize) => { let NteeOrganizationType = sequelize.define< NteeOrganizationTypeInstance, NteeOrganizationTypeAttributes - >( - 'NteeOrganizationType', - { - uuid: { - type: Sequelize.UUID, - allowNull: true, - defaultValue: Sequelize.UUIDV4, - }, - name: { type: Sequelize.STRING, allowNull: false }, - code: { type: Sequelize.STRING, allowNull: false }, - description: { type: Sequelize.STRING, allowNull: true }, - drupalId: { - type: Sequelize.INTEGER, - allowNull: true, - field: 'drupal_id', - }, - }, - { - createdAt: 'created_at', - updatedAt: 'updated_at', - underscored: true, - freezeTableName: true, - tableName: 'ntee_organization_type', - } - ); + >('NteeOrganizationType', nteeOrganizationTypeColumns, { + createdAt: 'created_at', + updatedAt: 'updated_at', + underscored: true, + freezeTableName: true, + tableName: 'ntee_organization_type', + }); NteeOrganizationType.associate = ({ Organization, @@ -73,3 +91,66 @@ export default (sequelize: Sequelize.Sequelize) => { return NteeOrganizationType; }; + +export const nteeOrganizationTypeSpecialFields: GraphQLFieldConfigMap< + never, + never +> = { + totalReceived: { type: GraphQLBigInt }, + totalFunded: { type: GraphQLBigInt }, +}; + +export const nteeOrganizationTypeArgs = ledgerListArgs( + 'NteeOrganizationType', + Object.keys(nteeOrganizationTypeColumns) +); + +interface NteeOrganizationTypeResolverOptions { + limitToOrganizationId: boolean; +} + +const defaultNteeOrganizationTypeResolverOptions = { + limitToOrganizationId: false, +}; + +export const nteeOrganizationTypeResolver = ( + db: Db, + resolverOpts: NteeOrganizationTypeResolverOptions = defaultNteeOrganizationTypeResolverOptions +) => async ( + opts, + { limit, offset, orderBy, orderByDirection, uuid = null }, + context, + info +): Promise => { + let where = ''; + // Fetching only organization tags related to a specific organization + if (resolverOpts.limitToOrganizationId) { + where = `WHERE oot.organization_id=${escape(opts.dataValues.id)}`; + } else { + where = uuid ? `WHERE ot.uuid = ${escape(uuid)}` : ''; + } + + const results = await db.sequelize.query( + `SELECT ot.id, ot.uuid, ot.name, ot.code, ot.description, SUM(gf.amount) as "total_funded", SUM(gr.amount) as "total_received" +FROM ntee_organization_type ot +LEFT JOIN organization_ntee_organization_type oot ON ot.id=oot.ntee_organization_type_id +LEFT JOIN "grant" gf ON gf.from=oot.organization_id +LEFT JOIN "grant" gr ON gr.to=oot.organization_id +${where} +GROUP BY ot.id +ORDER BY "${decamelize(orderBy)}" ${orderByDirection} +LIMIT :limit +OFFSET :offset`, + { + type: db.Sequelize.QueryTypes.SELECT, + model: db.NteeOrganizationType, + mapToModel: true, + replacements: { + limit: Math.min(limit, MAX_LIMIT), + offset, + }, + } + ); + + return results; +}; diff --git a/src/db/models/organization.ts b/src/db/models/organization.ts index b9ac6d3..04c5433 100644 --- a/src/db/models/organization.ts +++ b/src/db/models/organization.ts @@ -1,4 +1,21 @@ import * as Sequelize from 'sequelize'; +import { escape } from 'sequelize/lib/sql-string'; +import * as decamelize from 'decamelize'; +import * as DataLoader from 'dataloader'; + +import { Db } from './'; + +import { + GraphQLFieldConfigMap, + GraphQLInt, + GraphQLList, + GraphQLString, +} from 'graphql'; + +import * as GraphQLBigInt from 'graphql-bigint'; + +import { ledgerListArgs, specialCols, MAX_LIMIT } from '../../helpers'; + import { GrantInstance, GrantAttributes } from './grant'; import { NewsInstance, NewsAttributes } from './news'; import { @@ -88,84 +105,82 @@ export interface LegacyData { export type OrganizationInstance = Sequelize.Instance & OrganizationAttributes; +const organizationColumns = { + uuid: { + type: Sequelize.UUID, + allowNull: true, + defaultValue: Sequelize.UUIDV4, + }, + name: { type: Sequelize.STRING, allowNull: false }, + ein: { type: Sequelize.STRING, allowNull: true }, + duns: { type: Sequelize.STRING, allowNull: true }, + stateCorpId: { + type: Sequelize.STRING, + allowNull: true, + field: 'state_corp_id', + }, + description: { type: Sequelize.TEXT, allowNull: true }, + address: { type: Sequelize.JSON, allowNull: true }, + links: { type: Sequelize.JSON, allowNull: true }, + founded: { type: Sequelize.DATEONLY, allowNull: true }, + dissolved: { type: Sequelize.DATEONLY, allowNull: true }, + legacyData: { + type: Sequelize.JSON, + allowNull: true, + field: 'legacy_data', + }, + publicFunder: { + type: Sequelize.BOOLEAN, + allowNull: true, + field: 'public_funder', + }, + + // From meta table join: + countGrantsFrom: { + type: new Sequelize.VIRTUAL(Sequelize.INTEGER), + field: 'count_grants_from', + }, + countGrantsTo: { + type: new Sequelize.VIRTUAL(Sequelize.INTEGER), + field: 'count_grants_to', + }, + countDistinctFunders: { + type: new Sequelize.VIRTUAL(Sequelize.INTEGER), + field: 'count_distinct_funders', + }, + countDistinctRecipients: { + type: new Sequelize.VIRTUAL(Sequelize.INTEGER), + field: 'count_distinct_recipients', + }, + totalReceived: { + type: new Sequelize.VIRTUAL(Sequelize.BIGINT), + field: 'total_received', + }, + totalFunded: { + type: new Sequelize.VIRTUAL(Sequelize.BIGINT), + field: 'total_funded', + }, + grantdatesStart: { + type: new Sequelize.VIRTUAL(Sequelize.DATEONLY), + field: 'grantdates_start', + }, + grantdatesEnd: { + type: new Sequelize.VIRTUAL(Sequelize.DATEONLY), + field: 'grantdates_end', + }, +}; + export default (sequelize: Sequelize.Sequelize) => { let Organization = sequelize.define< OrganizationInstance, OrganizationAttributes - >( - 'Organization', - { - uuid: { - type: Sequelize.UUID, - allowNull: true, - defaultValue: Sequelize.UUIDV4, - }, - name: { type: Sequelize.STRING, allowNull: false }, - ein: { type: Sequelize.STRING, allowNull: true }, - duns: { type: Sequelize.STRING, allowNull: true }, - stateCorpId: { - type: Sequelize.STRING, - allowNull: true, - field: 'state_corp_id', - }, - description: { type: Sequelize.TEXT, allowNull: true }, - address: { type: Sequelize.JSON, allowNull: true }, - links: { type: Sequelize.JSON, allowNull: true }, - founded: { type: Sequelize.DATEONLY, allowNull: true }, - dissolved: { type: Sequelize.DATEONLY, allowNull: true }, - legacyData: { - type: Sequelize.JSON, - allowNull: true, - field: 'legacy_data', - }, - publicFunder: { - type: Sequelize.BOOLEAN, - allowNull: true, - field: 'public_funder', - }, - - // From meta table join: - countGrantsFrom: { - type: new Sequelize.VIRTUAL(Sequelize.INTEGER), - field: 'count_grants_from', - }, - countGrantsTo: { - type: new Sequelize.VIRTUAL(Sequelize.INTEGER), - field: 'count_grants_to', - }, - countDistinctFunders: { - type: new Sequelize.VIRTUAL(Sequelize.INTEGER), - field: 'count_distinct_funders', - }, - countDistinctRecipients: { - type: new Sequelize.VIRTUAL(Sequelize.INTEGER), - field: 'count_distinct_recipients', - }, - totalReceived: { - type: new Sequelize.VIRTUAL(Sequelize.BIGINT), - field: 'total_received', - }, - totalFunded: { - type: new Sequelize.VIRTUAL(Sequelize.BIGINT), - field: 'total_funded', - }, - grantdatesStart: { - type: new Sequelize.VIRTUAL(Sequelize.DATEONLY), - field: 'grantdates_start', - }, - grantdatesEnd: { - type: new Sequelize.VIRTUAL(Sequelize.DATEONLY), - field: 'grantdates_end', - }, - }, - { - createdAt: 'created_at', - updatedAt: 'updated_at', - underscored: true, - freezeTableName: true, - tableName: 'organization', - } - ); + >('Organization', organizationColumns, { + createdAt: 'created_at', + updatedAt: 'updated_at', + underscored: true, + freezeTableName: true, + tableName: 'organization', + }); // Set up relations Organization.associate = ({ @@ -246,3 +261,152 @@ export default (sequelize: Sequelize.Sequelize) => { return Organization; }; + +/** + * These fields are defined as "virtual" in Sequelize + * and are the product of a JOIN with the organization_meta table + */ +export const organizationSpecialFields: GraphQLFieldConfigMap = { + countGrantsFrom: { type: GraphQLInt }, + countGrantsTo: { type: GraphQLInt }, + countDistinctFunders: { type: GraphQLInt }, + countDistinctRecipients: { type: GraphQLInt }, + totalReceived: { type: GraphQLBigInt }, + totalFunded: { type: GraphQLBigInt }, + grantdatesStart: { type: GraphQLString }, + grantdatesEnd: { type: GraphQLString }, +}; + +// Add a join to a organization query +interface AddJoin { + (opts: any): string; +} + +export const organizationResolver = (db: Db, orgAddJoin?: AddJoin) => async ( + opts, + { limit, offset, orderBy, orderByDirection, nameLike }, + context, + info +): Promise => { + let where = ''; + if (nameLike) { + where = `WHERE o.name ILIKE ${escape(nameLike)}`; + } + + let addedJoin = ''; + if (opts && orgAddJoin) { + addedJoin = orgAddJoin(opts); + } + + const results = await db.sequelize.query( + `SELECT o.*, ${specialCols(organizationSpecialFields, 'om').join(',')} +FROM organization o +LEFT JOIN organization_meta om ON o.id=om.id +${addedJoin} +${where} +ORDER BY "${decamelize(orderBy)}" ${orderByDirection} +LIMIT :limit +OFFSET :offset`, + { + type: db.Sequelize.QueryTypes.SELECT, + model: db.Organization, + mapToModel: true, + replacements: { + limit: Math.min(limit, MAX_LIMIT), + offset, + }, + } + ); + + return results; +}; + +export const createGetOrganizationByIdDataloader = ( + db: Db +): DataLoader => { + return new DataLoader( + async (ids: number[]): Promise => { + const results = await db.sequelize.query( + `SELECT o.*, ${specialCols(organizationSpecialFields, 'om').join(',')} + FROM organization o + LEFT JOIN organization_meta om ON o.id=om.id + WHERE o.id IN(:ids)`, + { + type: db.Sequelize.QueryTypes.SELECT, + model: db.Organization, + mapToModel: true, + replacements: { ids }, + } + ); + + const ordered = ids.map( + id => + results.find(o => o.id === id) || + new Error(`cannot find organization with id ${id}`) + ); + + return ordered; + } + ); +}; + +export const createGetOrganizationByUuidDataloader = ( + db: Db +): DataLoader => { + return new DataLoader( + async (uuids: number[]): Promise => { + const results = await db.sequelize.query( + `SELECT o.*, ${specialCols(organizationSpecialFields, 'om').join(',')} + FROM organization o + LEFT JOIN organization_meta om ON o.id=om.id + WHERE o.uuid IN(:uuids)`, + { + type: db.Sequelize.QueryTypes.SELECT, + model: db.Organization, + mapToModel: true, + replacements: { uuids }, + } + ); + + const ordered = uuids.map( + uuid => + results.find(o => o.uuid === uuid) || + new Error(`cannot find organization with uuid ${uuid}`) + ); + + return ordered; + } + ); +}; + +interface GetIdFromOpts { + (opts: any): number; +} + +export const singleOrganizationResolver = (getId?: GetIdFromOpts) => async ( + opts, + args, + context, + info +): Promise => { + if (getId) { + return context.getOrganizationById.load(getId(opts)); + } + + if (args.id) { + return context.getOrganizationById.load(args.id); + } + + if (args.uuid) { + return context.getOrganizationByUuid.load(args.uuid); + } + + throw new Error('must supply either getId or args with id or uuid property'); +}; + +export const organizationArgs = { + ...ledgerListArgs('Organization', Object.keys(organizationColumns)), + nameLike: { + type: GraphQLString, + }, +}; diff --git a/src/db/models/organizationTag.ts b/src/db/models/organizationTag.ts index b7036e2..fdeec62 100644 --- a/src/db/models/organizationTag.ts +++ b/src/db/models/organizationTag.ts @@ -1,4 +1,12 @@ import * as Sequelize from 'sequelize'; +import { escape } from 'sequelize/lib/sql-string'; +import * as decamelize from 'decamelize'; + +import { GraphQLFieldConfigMap } from 'graphql'; + +import * as GraphQLBigInt from 'graphql-bigint'; + +import { ledgerListArgs, MAX_LIMIT } from '../../helpers'; import { AbstractDrupalTagAttributes } from './abstractDrupalTag'; @@ -10,6 +18,8 @@ export interface OrganizationTagAttributes extends AbstractDrupalTagAttributes { name: string; description?: string; drupalId?: number; + totalFunded?: number; + totalReceived?: number; createdAt?: string; updatedAt?: string; } @@ -19,34 +29,40 @@ export type OrganizationTagInstance = Sequelize.Instance< > & OrganizationTagAttributes; +const organizationTagColumns = { + uuid: { + type: Sequelize.UUID, + allowNull: true, + defaultValue: Sequelize.UUIDV4, + }, + name: { type: Sequelize.STRING, allowNull: false }, + description: { type: Sequelize.STRING, allowNull: true }, + drupalId: { + type: Sequelize.INTEGER, + allowNull: true, + field: 'drupal_id', + }, + totalFunded: { + type: new Sequelize.VIRTUAL(Sequelize.BIGINT), + field: 'total_funded', + }, + totalReceived: { + type: new Sequelize.VIRTUAL(Sequelize.BIGINT), + field: 'total_received', + }, +}; + export default (sequelize: Sequelize.Sequelize) => { let OrganizationTag = sequelize.define< OrganizationTagInstance, OrganizationTagAttributes - >( - 'OrganizationTag', - { - uuid: { - type: Sequelize.UUID, - allowNull: true, - defaultValue: Sequelize.UUIDV4, - }, - name: { type: Sequelize.STRING, allowNull: false }, - description: { type: Sequelize.STRING, allowNull: true }, - drupalId: { - type: Sequelize.INTEGER, - allowNull: true, - field: 'drupal_id', - }, - }, - { - createdAt: 'created_at', - updatedAt: 'updated_at', - underscored: true, - freezeTableName: true, - tableName: 'organization_tag', - } - ); + >('OrganizationTag', organizationTagColumns, { + createdAt: 'created_at', + updatedAt: 'updated_at', + underscored: true, + freezeTableName: true, + tableName: 'organization_tag', + }); OrganizationTag.associate = ({ Organization, @@ -67,3 +83,66 @@ export default (sequelize: Sequelize.Sequelize) => { return OrganizationTag; }; + +export const organizationTagSpecialFields: GraphQLFieldConfigMap< + never, + never +> = { + totalReceived: { type: GraphQLBigInt }, + totalFunded: { type: GraphQLBigInt }, +}; + +export const organizationTagArgs = ledgerListArgs( + 'OrganizationTag', + Object.keys(organizationTagColumns) +); + +interface OrganizationTagResolverOptions { + limitToOrganizationId: boolean; +} + +const defaultOrganizationTagResolverOptions = { + limitToOrganizationId: false, +}; + +export const organizationTagResolver = ( + db, + resolverOpts: OrganizationTagResolverOptions = defaultOrganizationTagResolverOptions +) => async ( + opts, + { limit, offset, orderBy, orderByDirection, uuid = null }, + context, + info +): Promise => { + let where = ''; + // Fetching only organization tags related to a specific organization + if (resolverOpts.limitToOrganizationId) { + where = `WHERE oot.organization_id=${escape(opts.dataValues.id)}`; + } else { + where = uuid ? `WHERE ot.uuid = ${escape(uuid)}` : ''; + } + + const results = await db.sequelize.query( + `SELECT ot.id, ot.uuid, ot.name, ot.description, SUM(gf.amount) as "total_funded", SUM(gr.amount) as "total_received" +FROM organization_tag ot +LEFT JOIN organization_organization_tag oot ON ot.id=oot.organization_tag_id +LEFT JOIN "grant" gf ON gf.from=oot.organization_id +LEFT JOIN "grant" gr ON gr.to=oot.organization_id +${where} +GROUP BY ot.id +ORDER BY "${decamelize(orderBy)}" ${orderByDirection} +LIMIT :limit +OFFSET :offset`, + { + type: db.Sequelize.QueryTypes.SELECT, + model: db.OrganizationTag, + mapToModel: true, + replacements: { + limit: Math.min(limit, MAX_LIMIT), + offset, + }, + } + ); + + return results; +}; diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..1b4d361 --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,82 @@ +import * as decamelize from 'decamelize'; + +import { + GraphQLEnumType, + GraphQLFieldConfigArgumentMap, + GraphQLFieldConfigMap, + GraphQLInt, + GraphQLString, +} from 'graphql'; + +export const MAX_LIMIT = 100; + +/** + * Produces a SQL column select fragment based on the special fields + * defined above. + * + * The provided field config will never have resolve or subscribe props, + * hence the . + */ +export const specialCols = ( + specialFields: GraphQLFieldConfigMap, + alias: string +) => + Object.keys(specialFields).map( + col => `${alias}.${decamelize(col)} AS "${decamelize(col)}"` + ); + +/** + * Helper to generate common list arguments. + * + * A little nicer than what graphql-sequelize offers: + * - order NULLs last + * - enum orderBy options + * + * Does not implement a MAX_LIMIT guard! + * That is the resolver's responsibility. + */ +export const ledgerListArgs = ( + name: string, + tableAttributes: string[] +): GraphQLFieldConfigArgumentMap => ({ + orderBy: { + type: new GraphQLEnumType({ + name: `orderBy${name}`, + values: tableAttributes.reduce( + (acc, cur) => ({ + ...acc, + [cur]: { value: cur }, + }), + {} + ), + }), + defaultValue: 'id', + description: 'sort results by given field', + }, + orderByDirection: { + type: new GraphQLEnumType({ + name: `orderByDirection${name}`, + values: { + ASC: { value: 'ASC NULLS LAST' }, + DESC: { value: 'DESC NULLS LAST' }, + }, + }), + defaultValue: 'ASC NULLS LAST', + description: 'sort direction', + }, + limit: { + type: GraphQLInt, + defaultValue: 10, + description: `Number of items to return, maximum ${MAX_LIMIT}`, + }, + offset: { + type: GraphQLInt, + defaultValue: 0, + }, + uuid: { + type: GraphQLString, + }, + id: { + type: GraphQLInt, + }, +}); diff --git a/src/scripts/tagImporter.ts b/src/scripts/tagImporter.ts index 78cad8a..d877311 100644 --- a/src/scripts/tagImporter.ts +++ b/src/scripts/tagImporter.ts @@ -42,6 +42,7 @@ function doImport() { chunk = stream.read(); while (chunk !== null) { + // @ts-ignore await cfg.model.create(chunk); chunk = stream.read(); } diff --git a/src/server.ts b/src/server.ts index 2b3ab1e..9cb7f37 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,8 +1,6 @@ import * as Sequelize from 'sequelize'; -import * as decamelize from 'decamelize'; import { GraphQLServer } from 'graphql-yoga'; import { createContext, EXPECTED_OPTIONS_KEY } from 'dataloader-sequelize'; -import * as DataLoader from 'dataloader'; import { resolver, attributeFields, @@ -14,10 +12,8 @@ import { graphql, GraphQLSchema, GraphQLObjectType, - GraphQLString, GraphQLInt, GraphQLList, - GraphQLEnumType, } from 'graphql'; import { escape } from 'sequelize/lib/sql-string'; @@ -26,43 +22,39 @@ import * as GraphQLBigInt from 'graphql-bigint'; import { Db } from './db/models'; -import { OrganizationInstance } from './db/models/organization'; - -const MAX_LIMIT = 100; - -const organizationSpecialFields = { - countGrantsFrom: { type: GraphQLInt }, - countGrantsTo: { type: GraphQLInt }, - countDistinctFunders: { type: GraphQLInt }, - countDistinctRecipients: { type: GraphQLInt }, - totalReceived: { type: GraphQLBigInt }, - totalFunded: { type: GraphQLBigInt }, - grantdatesStart: { type: GraphQLString }, - grantdatesEnd: { type: GraphQLString }, -}; +import { + organizationResolver, + organizationArgs, + organizationSpecialFields, + singleOrganizationResolver, + createGetOrganizationByIdDataloader, + createGetOrganizationByUuidDataloader, +} from './db/models/organization'; +import { grantResolver, grantArgs } from './db/models/grant'; +import { + grantTagResolver, + grantTagArgs, + grantTagSpecialFields, +} from './db/models/grantTag'; +import { + organizationTagResolver, + organizationTagArgs, + organizationTagSpecialFields, +} from './db/models/organizationTag'; +import { + nteeGrantTypeResolver, + nteeGrantTypeArgs, + nteeGrantTypeSpecialFields, +} from './db/models/nteeGrantType'; +import { + nteeOrganizationTypeResolver, + nteeOrganizationTypeArgs, + nteeOrganizationTypeSpecialFields, +} from './db/models/nteeOrganizationType'; export default function createServer(db: Db): GraphQLServer { resolver.contextToOptions = { [EXPECTED_OPTIONS_KEY]: EXPECTED_OPTIONS_KEY }; - // Arguments - const grantTagArgs = ledgerListArgs(db.GrantTag, ['total']); - const nteeGrantTypeArgs = ledgerListArgs(db.NteeGrantType, ['total']); - const nteeOrganizationTypeArgs = ledgerListArgs(db.NteeOrganizationType, [ - 'totalFunded', - 'totalReceived', - ]); - const organizationTagArgs = ledgerListArgs(db.OrganizationTag, [ - 'totalFunded', - 'totalReceived', - ]); - const grantArgs = ledgerListArgs(db.Grant); - const organizationArgs = { - ...ledgerListArgs(db.Organization, Object.keys(organizationSpecialFields)), - nameLike: { - type: GraphQLString, - }, - }; - // Types const shallowOrganizationType = new GraphQLObjectType({ name: 'ShallowOrganization', @@ -75,8 +67,7 @@ export default function createServer(db: Db): GraphQLServer { description: 'Tag associated with an organization', fields: { ...attributeFields(db.OrganizationTag, { exclude: ['id'] }), - totalFunded: { type: GraphQLBigInt }, - totalReceived: { type: GraphQLBigInt }, + ...organizationTagSpecialFields, }, }); @@ -85,7 +76,7 @@ export default function createServer(db: Db): GraphQLServer { description: 'Tag associated with a grant', fields: { ...attributeFields(db.GrantTag, { exclude: ['id'] }), - total: { type: GraphQLBigInt }, + ...grantTagSpecialFields, }, }); @@ -94,7 +85,7 @@ export default function createServer(db: Db): GraphQLServer { description: 'NTEE classification of a grant', fields: { ...attributeFields(db.NteeGrantType, { exclude: ['id'] }), - total: { type: GraphQLBigInt }, + ...nteeGrantTypeSpecialFields, }, }); @@ -103,8 +94,7 @@ export default function createServer(db: Db): GraphQLServer { description: 'NTEE classification of an organization', fields: { ...attributeFields(db.NteeOrganizationType, { exclude: ['id'] }), - totalFunded: { type: GraphQLBigInt }, - totalReceived: { type: GraphQLBigInt }, + ...nteeOrganizationTypeSpecialFields, }, }); @@ -345,391 +335,3 @@ export default function createServer(db: Db): GraphQLServer { }, }); } - -interface OrganizationTagResolverOptions { - limitToOrganizationId: boolean; -} - -const defaultOrganizationTagResolverOptions = { - limitToOrganizationId: false, -}; - -interface GrantTagResolverOptions { - limitToGrantId: boolean; -} - -const defaultGrantTagResolverOptions = { - limitToGrantId: false, -}; - -// Add a join to a organization query -interface AddJoin { - (opts: any): string; -} - -const grantResolver = (db, grantAddWhere?: AddJoin) => async ( - opts, - { limit, offset, orderBy, orderByDirection }, - context, - info -) => { - let where = ''; - if (grantAddWhere) { - where = grantAddWhere(opts); - } - - const results = await db.sequelize.query( - `SELECT g.* -FROM "grant" g -${where} -ORDER BY "${decamelize(orderBy)}" ${orderByDirection} -LIMIT :limit -OFFSET :offset`, - { - type: db.Sequelize.QueryTypes.SELECT, - model: db.Grant, - mapToModel: true, - replacements: { - limit: Math.min(limit, MAX_LIMIT), - offset, - }, - } - ); - - return results; -}; - -const metaCols = Object.keys(organizationSpecialFields).map( - col => `om.${decamelize(col)} AS "${decamelize(col)}"` -); - -const createGetOrganizationByIdDataloader = (db: Db) => { - return new DataLoader( - async (ids: number[]): Promise => { - const results = await db.sequelize.query( - `SELECT o.*, ${metaCols.join(',')} - FROM organization o - LEFT JOIN organization_meta om ON o.id=om.id - WHERE o.id IN(:ids)`, - { - type: db.Sequelize.QueryTypes.SELECT, - model: db.Organization, - mapToModel: true, - replacements: { ids }, - } - ); - - const ordered = ids.map( - id => - results.find(o => o.id === id) || - new Error(`cannot find organization with id ${id}`) - ); - - return ordered; - } - ); -}; - -const createGetOrganizationByUuidDataloader = (db: Db) => { - return new DataLoader( - async (uuids: number[]): Promise => { - const results = await db.sequelize.query( - `SELECT o.*, ${metaCols.join(',')} - FROM organization o - LEFT JOIN organization_meta om ON o.id=om.id - WHERE o.uuid IN(:uuids)`, - { - type: db.Sequelize.QueryTypes.SELECT, - model: db.Organization, - mapToModel: true, - replacements: { uuids }, - } - ); - - const ordered = uuids.map( - uuid => - results.find(o => o.uuid === uuid) || - new Error(`cannot find organization with uuid ${uuid}`) - ); - - return ordered; - } - ); -}; - -interface GetIdFromOpts { - (opts: any): number; -} - -const singleOrganizationResolver = (getId?: GetIdFromOpts) => async ( - opts, - args, - context, - info -) => { - if (getId) { - return context.getOrganizationById.load(getId(opts)); - } - - if (args.id) { - return context.getOrganizationById.load(args.id); - } - - if (args.uuid) { - return context.getOrganizationByUuid.load(args.uuid); - } -}; - -const singleGrantResolver = getId => async (opts, args, context, info) => { - return context.getGrantById.load(getId(opts)); -}; - -const organizationResolver = (db, orgAddJoin?: AddJoin) => async ( - opts, - { limit, offset, orderBy, orderByDirection, nameLike }, - context, - info -) => { - let where = ''; - if (nameLike) { - where = `WHERE o.name ILIKE ${escape(nameLike)}`; - } - - let addedJoin = ''; - if (opts && orgAddJoin) { - addedJoin = orgAddJoin(opts); - } - - const results = await db.sequelize.query( - `SELECT o.*, ${metaCols.join(',')} -FROM organization o -LEFT JOIN organization_meta om ON o.id=om.id -${addedJoin} -${where} -ORDER BY "${decamelize(orderBy)}" ${orderByDirection} -LIMIT :limit -OFFSET :offset`, - { - type: db.Sequelize.QueryTypes.SELECT, - model: db.Organization, - mapToModel: true, - replacements: { - limit: Math.min(limit, MAX_LIMIT), - offset, - }, - } - ); - - return results; -}; - -const organizationTagResolver = ( - db, - resolverOpts: OrganizationTagResolverOptions = defaultOrganizationTagResolverOptions -) => async ( - opts, - { limit, offset, orderBy, orderByDirection, uuid = null }, - context, - info -) => { - let where = ''; - // Fetching only organization tags related to a specific organization - if (resolverOpts.limitToOrganizationId) { - where = `WHERE oot.organization_id=${escape(opts.dataValues.id)}`; - } else { - where = uuid ? `WHERE ot.uuid = ${escape(uuid)}` : ''; - } - - const results = await db.sequelize.query( - `SELECT ot.id, ot.uuid, ot.name, ot.description, SUM(gf.amount) as "totalFunded", SUM(gr.amount) as "totalReceived" -FROM organization_tag ot -LEFT JOIN organization_organization_tag oot ON ot.id=oot.organization_tag_id -LEFT JOIN "grant" gf ON gf.from=oot.organization_id -LEFT JOIN "grant" gr ON gr.to=oot.organization_id -${where} -GROUP BY ot.id -ORDER BY "${orderBy}" ${orderByDirection} -LIMIT :limit -OFFSET :offset`, - { - type: db.Sequelize.QueryTypes.SELECT, - replacements: { - limit: Math.min(limit, MAX_LIMIT), - offset, - }, - } - ); - - return results; -}; - -const nteeOrganizationTypeResolver = ( - db, - resolverOpts: OrganizationTagResolverOptions = defaultOrganizationTagResolverOptions -) => async ( - opts, - { limit, offset, orderBy, orderByDirection, uuid = null }, - context, - info -) => { - let where = ''; - // Fetching only organization tags related to a specific organization - if (resolverOpts.limitToOrganizationId) { - where = `WHERE oot.organization_id=${escape(opts.dataValues.id)}`; - } else { - where = uuid ? `WHERE ot.uuid = ${escape(uuid)}` : ''; - } - - const results = await db.sequelize.query( - `SELECT ot.id, ot.uuid, ot.name, ot.code, ot.description, SUM(gf.amount) as "totalFunded", SUM(gr.amount) as "totalReceived" -FROM ntee_organization_type ot -LEFT JOIN organization_ntee_organization_type oot ON ot.id=oot.ntee_organization_type_id -LEFT JOIN "grant" gf ON gf.from=oot.organization_id -LEFT JOIN "grant" gr ON gr.to=oot.organization_id -${where} -GROUP BY ot.id -ORDER BY "${orderBy}" ${orderByDirection} -LIMIT :limit -OFFSET :offset`, - { - type: db.Sequelize.QueryTypes.SELECT, - replacements: { - limit: Math.min(limit, MAX_LIMIT), - offset, - }, - } - ); - - return results; -}; - -const grantTagResolver = ( - db, - resolverOpts: GrantTagResolverOptions = defaultGrantTagResolverOptions -) => async ( - opts, - { limit, offset, orderBy, orderByDirection, uuid = null }, - context, - info -) => { - let where = ''; - // Fetching only grant tags related to a specific grant - if (resolverOpts.limitToGrantId) { - where = `WHERE g.id=${escape(opts.dataValues.id)}`; - } else { - where = uuid ? `WHERE gt.uuid = ${escape(uuid)}` : ''; - } - - const results = await db.sequelize.query( - `SELECT gt.id, gt.uuid, gt.name, gt.description, SUM(g.amount) as total -FROM grant_tag gt -LEFT JOIN grant_grant_tag ggt ON gt.id=ggt.grant_tag_id -LEFT JOIN "grant" g ON ggt.grant_id=g.id -${where} -GROUP BY gt.id -ORDER BY "${orderBy}" ${orderByDirection} -LIMIT :limit -OFFSET :offset`, - { - type: db.Sequelize.QueryTypes.SELECT, - replacements: { - limit: Math.min(limit, MAX_LIMIT), - offset, - }, - } - ); - - return results; -}; - -const nteeGrantTypeResolver = ( - db, - resolverOpts: GrantTagResolverOptions = defaultGrantTagResolverOptions -) => async ( - opts, - { limit, offset, orderBy, orderByDirection, uuid = null }, - context, - info -) => { - let where = ''; - // Fetching only grant tags related to a specific grant - if (resolverOpts.limitToGrantId) { - where = `WHERE g.id=${escape(opts.dataValues.id)}`; - } else { - where = uuid ? `WHERE gt.uuid = ${escape(uuid)}` : ''; - } - - const results = await db.sequelize.query( - `SELECT gt.id, gt.uuid, gt.name, gt.code, gt.description, SUM(g.amount) as total -FROM ntee_grant_type gt -LEFT JOIN grant_ntee_grant_type ggt ON gt.id=ggt.ntee_grant_type_id -LEFT JOIN "grant" g ON ggt.grant_id=g.id -${where} -GROUP BY gt.id -ORDER BY "${orderBy}" ${orderByDirection} -LIMIT :limit -OFFSET :offset`, - { - type: db.Sequelize.QueryTypes.SELECT, - replacements: { - limit: Math.min(limit, MAX_LIMIT), - offset, - }, - } - ); - - return results; -}; - -const ledgerListArgs = ( - model: Sequelize.Model, - orderBySpecialCols: string[] = [] -) => ({ - orderBy: { - type: new GraphQLEnumType({ - name: `orderBy${model.name}`, - // @ts-ignore tableAttributes is not in sequelize type defs - values: Object.keys(model.tableAttributes).reduce( - (acc, cur) => ({ - ...acc, - [cur]: { value: cur }, - }), - orderBySpecialCols.reduce( - (acc, cur) => ({ - ...acc, - [cur]: { value: cur }, - }), - {} - ) - ), - }), - defaultValue: 'id', - description: 'sort results by given field', - }, - orderByDirection: { - type: new GraphQLEnumType({ - name: `orderByDirection${model.name}`, - values: { - ASC: { value: 'ASC NULLS LAST' }, - DESC: { value: 'DESC NULLS LAST' }, - }, - }), - defaultValue: 'ASC NULLS LAST', - description: 'sort direction', - }, - limit: { - type: GraphQLInt, - defaultValue: 10, - description: `Number of items to return, maximum ${MAX_LIMIT}`, - }, - offset: { - type: GraphQLInt, - defaultValue: 0, - }, - uuid: { - type: GraphQLString, - }, - id: { - type: GraphQLInt, - }, -}); From ecae09491ee48b41e2f4d6fccf4306b8ca0348d2 Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Mon, 4 Feb 2019 06:53:40 -0500 Subject: [PATCH 14/16] grab the tabs --- integration-tests/index.test.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/integration-tests/index.test.js b/integration-tests/index.test.js index cf8ceb3..a57d56c 100644 --- a/integration-tests/index.test.js +++ b/integration-tests/index.test.js @@ -69,18 +69,18 @@ test('organization_meta updates automatically', async () => { const { uri, instance, db } = await createServerInstance(); const query = ` query foo { - giver: organization(id: 3) { - countGrantsTo - countGrantsFrom - countDistinctFunders - countDistinctRecipients - } - receiver: organization(id: 91) { - countGrantsTo - countGrantsFrom - countDistinctFunders - countDistinctRecipients - } + giver: organization(id: 3) { + countGrantsTo + countGrantsFrom + countDistinctFunders + countDistinctRecipients + } + receiver: organization(id: 91) { + countGrantsTo + countGrantsFrom + countDistinctFunders + countDistinctRecipients + } }`; const resBefore = await request(uri, query); From ee3e04c9a890cf6c6432e006b8128e0a4b314a7b Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Sun, 15 Sep 2019 23:32:17 -0400 Subject: [PATCH 15/16] add nameLike for organization grants funded & received --- integration-tests/index.test.js | 37 ++++++++++++++++++++++ src/db/models/grant.ts | 55 ++++++++++++++++++++++++++++----- src/scripts/grantImporter.ts | 8 ++++- src/server.ts | 4 +-- 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/integration-tests/index.test.js b/integration-tests/index.test.js index a57d56c..68634e1 100644 --- a/integration-tests/index.test.js +++ b/integration-tests/index.test.js @@ -150,3 +150,40 @@ test('sorts organization grants by date', async () => { instance.close(); }); + +test('filter organization grants funded/received by name', async () => { + const { uri, instance } = await createServerInstance(); + + const res = await request( + uri, + ` +query filterOrganizationGrants { + organization(id: 91) { + grantsFunded( + limit: 10, + orderByDirection: ASC, + orderBy: dateFrom, + textLike: { description: "grant 2 description" } + ) { + description + } + } +} +` + ); + + expect(res).toEqual({ + organization: { + grantsFunded: [ + { + description: 'grant 2 description', + }, + { + description: 'grant 2 description', + }, + ], + }, + }); + + instance.close(); +}); diff --git a/src/db/models/grant.ts b/src/db/models/grant.ts index b79d48f..2d234bc 100644 --- a/src/db/models/grant.ts +++ b/src/db/models/grant.ts @@ -1,6 +1,9 @@ import * as Sequelize from 'sequelize'; +import { escape } from 'sequelize/lib/sql-string'; import * as decamelize from 'decamelize'; +import { GraphQLInputObjectType, GraphQLNonNull, GraphQLString } from 'graphql'; + import { Db } from './'; import { ledgerListArgs, MAX_LIMIT } from '../../helpers'; @@ -169,26 +172,34 @@ export default (sequelize: Sequelize.Sequelize) => { return Grant; }; -// Add a join to a grant query -interface AddJoin { +// Add a where stanza to a grant query +interface AddWhere { (opts: any): string; } -export const grantResolver = (db: Db, grantAddWhere?: AddJoin) => async ( +export const grantResolver = (db: Db, grantAddWhere?: AddWhere) => async ( opts, - { limit, offset, orderBy, orderByDirection }, + { limit, offset, orderBy, orderByDirection, textLike = {} }, context, info ): Promise => { - let where = ''; + let wheres: string[] = []; + if (grantAddWhere) { - where = grantAddWhere(opts); + wheres = [grantAddWhere(opts)]; } + Object.keys(textLike).forEach(k => + wheres.push(`${decamelize(k)} ILIKE ${escape(textLike[k])}`) + ); + + const whereFragment = + wheres.length > 0 ? `WHERE ${wheres.join(' AND ')}` : ''; + const results = await db.sequelize.query( `SELECT g.* FROM "grant" g -${where} +${whereFragment} ORDER BY "${decamelize(orderBy)}" ${orderByDirection} LIMIT :limit OFFSET :offset`, @@ -215,4 +226,32 @@ export const singleGrantResolver = getId => async ( return context.getGrantById.load(getId(opts)); }; -export const grantArgs = ledgerListArgs('Grant', Object.keys(grantColumns)); +const textColumns = Object.keys(grantColumns).reduce((acc, cur) => { + if ( + grantColumns[cur].type == Sequelize.TEXT || + grantColumns[cur].type === Sequelize.STRING + ) { + return [...acc, cur]; + } + return acc; +}, []); + +const textLikeArgs = textColumns.reduce( + (acc, cur) => ({ + ...acc, + [cur]: { + type: GraphQLString, + }, + }), + {} +); + +export const grantArgs = { + ...ledgerListArgs('Grant', Object.keys(grantColumns)), + textLike: { + type: new GraphQLInputObjectType({ + name: 'TextLike', + fields: textLikeArgs, + }), + }, +}; diff --git a/src/scripts/grantImporter.ts b/src/scripts/grantImporter.ts index 7ff575d..cc215d9 100644 --- a/src/scripts/grantImporter.ts +++ b/src/scripts/grantImporter.ts @@ -118,4 +118,10 @@ export const doImport = async () => { } }; -doImport(); +doImport() + .then(() => process.exit(0)) + .catch(e => { + console.error('ERROR'); + console.error(e); + process.exit(1); + }); diff --git a/src/server.ts b/src/server.ts index 9cb7f37..e91a5fd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -196,12 +196,12 @@ export default function createServer(db: Db): GraphQLServer { grantsFunded: { type: new GraphQLList(grantType), args: grantArgs, - resolve: grantResolver(db, opts => `WHERE g.from=${opts.get('id')}`), + resolve: grantResolver(db, opts => `g.from=${opts.get('id')}`), }, grantsReceived: { type: new GraphQLList(grantType), args: grantArgs, - resolve: grantResolver(db, opts => `WHERE g.to=${opts.get('id')}`), + resolve: grantResolver(db, opts => `g.to=${opts.get('id')}`), }, forms990: { type: new GraphQLList(form990Type), From 537bf90d1801a578ab34f4611784fca18c2caf27 Mon Sep 17 00:00:00 2001 From: Benjamin Chodoroff Date: Sun, 15 Sep 2019 23:32:39 -0400 Subject: [PATCH 16/16] no more sqlite --- package.json | 1 - yarn.lock | 16 +--------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/package.json b/package.json index 75d1737..b90780e 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,6 @@ "pg": "^6.1.0", "pg-copy-streams": "1.2.0", "sequelize": "4.38.0", - "sqlite3": "^4.0.2", "toml": "^2.3.3" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index ac91d94..af744b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5210,11 +5210,6 @@ nan@^2.9.2: resolved "https://registry.yarnpkg.com/nan/-/nan-2.12.1.tgz#7b1aa193e9aa86057e3c7bbd0ac448e770925552" integrity sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw== -nan@~2.10.0: - version "2.10.0" - resolved "http://registry.npmjs.org/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f" - integrity sha512-bAdJv7fBLhWC+/Bls0Oza+mvTaNQtP+1RyhhhvD95pgUJz6XM5IzgmxOkItJ9tkoCiplvAnXI1tNmmUD/eScyA== - nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -5286,7 +5281,7 @@ node-notifier@^5.2.1: shellwords "^0.1.1" which "^1.3.0" -node-pre-gyp@^0.10.0, node-pre-gyp@^0.10.3: +node-pre-gyp@^0.10.0: version "0.10.3" resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" integrity sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A== @@ -6768,15 +6763,6 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= -sqlite3@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/sqlite3/-/sqlite3-4.0.2.tgz#1bbeb68b03ead5d499e42a3a1b140064791c5a64" - integrity sha512-51ferIRwYOhzUEtogqOa/y9supADlAht98bF/gbIi6WkzRJX6Yioldxbzj1MV4yV+LgdKD/kkHwFTeFXOG4htA== - dependencies: - nan "~2.10.0" - node-pre-gyp "^0.10.3" - request "^2.87.0" - sshpk@^1.7.0: version "1.10.1" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.10.1.tgz#30e1a5d329244974a1af61511339d595af6638b0"