├── .npmignore ├── .gitignore ├── .prettierrc ├── src ├── index.ts ├── pluginTypes.ts ├── PostGraphileManyCreatePlugin.ts ├── PostGraphileManyDeletePlugin.ts └── PostGraphileManyUpdatePlugin.ts ├── LICENSE ├── package.json ├── README.md └── tsconfig.json /.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true 3 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { makePluginByCombiningPlugins } from 'graphile-utils'; 2 | import PostGraphileManyCreatePlugin from './PostGraphileManyCreatePlugin'; 3 | import PostGraphileManyUpdatePlugin from './PostGraphileManyUpdatePlugin'; 4 | import PostGraphileManyDeletePlugin from './PostGraphileManyDeletePlugin'; 5 | 6 | const PostGraphileManyCUDPlugin = makePluginByCombiningPlugins( 7 | PostGraphileManyCreatePlugin, 8 | PostGraphileManyUpdatePlugin, 9 | PostGraphileManyDeletePlugin 10 | ); 11 | export default PostGraphileManyCUDPlugin; 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tim Moses 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "postgraphile-plugin-many-create-update-delete", 3 | "version": "1.0.7", 4 | "description": "Postgraphile plugin that enables many create, update, & delete mutations in a single transaction.", 5 | "main": "build/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/tjmoses/postgraphile-plugin-many-create-update-delete.git" 9 | }, 10 | "keywords": [ 11 | "postgraphile", 12 | "many", 13 | "create", 14 | "mutations", 15 | "many", 16 | "update", 17 | "mutations", 18 | "many", 19 | "delete", 20 | "mutations" 21 | ], 22 | "author": "Tim M", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/tjmoses/postgraphile-plugin-many-create-update-delete/issues" 26 | }, 27 | "homepage": "https://github.com/tjmoses/postgraphile-plugin-many-create-update-delete#readme", 28 | "dependencies": { 29 | "postgraphile": "^4.7.0", 30 | "graphile-utils": "^4.5.6" 31 | }, 32 | "devDependencies": { 33 | "@types/nanographql": "^2.0.1", 34 | "@types/pg": "^7.14.4", 35 | "@typescript-eslint/parser": "^3.6.0", 36 | "graphile-build": "^4.7.0", 37 | "rimraf": "^3.0.0", 38 | "typescript": "^3.9.6", 39 | "prettier-standard": "^16.4.1" 40 | }, 41 | "scripts": { 42 | "test": "echo \"Error: no test specified\" && exit 1", 43 | "start": "rimraf ./build && npx tsc", 44 | "format": "npx prettier-standard 'src/*.{ts,tsx}'", 45 | "lint": "npx prettier-standard 'src/*.{ts,tsx}' --fix" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/pluginTypes.ts: -------------------------------------------------------------------------------- 1 | import gql, { 2 | GraphQLSchema, 3 | GraphQLObjectTypeConfig, 4 | GraphQLType, 5 | GraphQLInputObjectType 6 | } from 'graphql'; 7 | import sql, { SQL } from 'pg-sql2'; 8 | import { PgClass, PgAttribute, omit } from 'graphile-build-pg'; 9 | import pgField from 'graphile-build-pg/node8plus/plugins/pgField'; 10 | import { Plugin, SchemaBuilder } from 'graphile-build'; 11 | import { parseResolveInfo } from 'graphql-parse-resolve-info'; 12 | 13 | /** 14 | * Whereas the Build object is the same for all hooks (except the build hook 15 | * which constructs it) within an individual build, the Context object changes for each hook. 16 | * The main ones are scope, Self, & fieldWithHooks(fieldName, spec, scope = {}) 17 | * https://www.graphile.org/graphile-build/context-object/ 18 | */ 19 | export interface Context { 20 | scope: { 21 | isRootMutation: boolean; 22 | [str: string]: any; 23 | }; 24 | fieldWithHooks: FieldWithHooksFunction; 25 | } 26 | export interface FieldWithHooksFunction { 27 | (fieldName: string, spec: any, fieldScope?: any): any; 28 | } 29 | 30 | /** 31 | * The build object represents the current schema build and is passed to 32 | * all hooks, hook the 'build' event to extend this object. 33 | * https://www.graphile.org/graphile-build/build-object/ 34 | * */ 35 | export interface Build { 36 | /** 37 | * https://graphql.org/graphql-js/type/ 38 | */ 39 | graphql: typeof gql; 40 | /** 41 | * https://github.com/graphile/graphile-engine/tree/master/packages/pg-sql2 42 | */ 43 | pgSql: typeof sql; 44 | /** 45 | * https://github.com/graphile/graphile-engine/blob/953675007d745be51a1c29d3e533636233d8aa0f/packages/graphile-build-pg/src/plugins/PgTypesPlugin.js#L117 46 | */ 47 | gql2pg: ( 48 | val: any | null, 49 | type: { 50 | id: string | number; 51 | domainBaseType: any; 52 | domainTypeModifier: any; 53 | isPgArray: any; 54 | namespaceName: any; 55 | name: any; 56 | arrayItemType: any; 57 | } | null, 58 | modifier: any 59 | ) => any; 60 | /** 61 | * https://github.com/graphile/graphile-engine/blob/9dca5c8631e6c336b59c499d901c774d41825c60/packages/graphile-build-pg/src/plugins/PgBasicsPlugin.js#L327 62 | */ 63 | pgOmit: typeof omit; 64 | extend: (base: Object, extra: Object, hint?: string) => any; 65 | newWithHooks: ( 66 | Class: any, 67 | spec: any, 68 | scope: any, 69 | performNonEmptyFieldsCheck?: boolean 70 | ) => any; 71 | parseResolveInfo: typeof parseResolveInfo; 72 | /** 73 | * https://github.com/graphile/graphile-engine/blob/bfe24276c9ff5eb7d3e9e7aff56a4d2ea61f30c6/packages/graphile-build-pg/src/plugins/PgIntrospectionPlugin.js#L1065 74 | * https://github.com/graphile/graphile-engine/blob/bfe24276c9ff5eb7d3e9e7aff56a4d2ea61f30c6/packages/graphile-build-pg/src/queryFromResolveDataFactory.js 75 | */ 76 | pgQueryFromResolveData: any; 77 | /** 78 | * Inflection is used for naming resulting types/fields/args/etc - it's 79 | * hookable so that other plugins may extend it or override it 80 | * https://www.graphile.org/postgraphile/inflection/#gatsby-focus-wrapper 81 | */ 82 | inflection: any; 83 | pgField: typeof pgField; 84 | [str: string]: any; 85 | } 86 | 87 | export { 88 | PgClass, 89 | PgAttribute, 90 | omit, 91 | SQL, 92 | Plugin, 93 | SchemaBuilder, 94 | GraphQLType, 95 | GraphQLInputObjectType 96 | }; 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # postgraphile-plugin-batch-create-update-delete 2 | 3 | ![npm](https://img.shields.io/npm/v/postgraphile-plugin-many-create-update-delete) 4 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) 5 | 6 | This plugin implements mutations that allow many creates, updates, and deletes in a single transaction. 7 | 8 | To support, Please 🌟 if you used / like this library. Also, please consider donating (or buying me a coffee) and/or becoming a contributor (see below) to allow this plugin to be maintained regularly and to close all open issues. 9 | 10 | ## Roadmap 11 | Here's the plan: 12 | 13 | - [ ] Add a test suite, CI, and better test example in the readme. 14 | - [ ] Update smart comments with better functionality to include individual create, update, or delete mutations vs. including them all together. 15 | - [ ] Add plugin options that allow conflict handling for inserts. This would allow updates (upsert), no action, failures (current default) based off the constraints or further options. 16 | - [ ] Add input types for the many updates and delete mutations, to show required constraints vs. using the table patch types that do not. 17 | - [ ] Add better returned payload options, preferably to return a list of modified records. 18 | - [ ] Add support for node id based updates and deletes. 19 | 20 | ## Getting Started 21 | 22 | View the [postgraphile docs](https://www.graphile.org/postgraphile/extending/#loading-additional-plugins) for information about loading the plugin via the CLI or as a NodeJS library. 23 | 24 | ## Plugin Options 25 | 26 | This plugin respects the default option to disable mutations all together via ```graphileBuildOptions```. 27 | 28 | ```js 29 | postgraphile(pgConfig, schema, { 30 | graphileBuildOptions: { 31 | pgDisableDefaultMutations: true 32 | } 33 | }); 34 | ``` 35 | 36 | ## Smart Comments 37 | 38 | You must use smart comments to enable the many create, update, and delete mutations for a table, since they are not enabled by default to prevent crowding with the other autogenerated postgraphile default mutations. The single tag ```@mncud``` is all that's needed. 39 | 40 | ```sql 41 | comment on table public."Test" is 42 | E'@mncud\n The test table is just for showing this example with comments.'; 43 | ``` 44 | 45 | ## Usage 46 | 47 | The plugin creates new mutations that allow you to batch create, update, and delete items from a given table. It works with primary keys for updates and deletes using the input patch that postgraphile generates. All creates, updates, and deletes have scoped names with "mn" in front of the mutation name to prevent conflicts with other mutations. 48 | 49 | ### Creates 50 | ```mnCreateTest``` would be an example mutation name, and we'll say it has attributes of test1 (a boolean), and name (a varchar). You'll see the required input has the clientMutationId and also a field called ```mnTest```, where ```mnTest``` will take an array of items that use the table input type. Since it uses the table input type, the required items are all listed as expected. When creating records, any attributes left off will have their values set to ```default```. 51 | 52 | ### Updates 53 | ```mnUpdateTestByName``` would be the update example name, assuming the name is the primary key. Updates have a required input with the clientMutatationId and a patch. The patch field accepts an array of table patch items. You ***MUST*** provide the primary key within the patch items b/c that is what's used in the where clause to update the correct row(s). Attributes that are not provided in the list of provided values, will not be updated. With that said, you can update different attributes in one record and leave them off in another and it will update both as expected. 54 | 55 | ### Deletes 56 | ```mnDeleteTestByName``` would be the delete example name. Deletes have a required input with the clientMutationId and a patch. The patch field accepts an array of table patch items, but only the primary key items are used. You ***MUST*** provide the primary key(s) within the patch items b/c that is what's used in the where clause to delete the correct row(s). 57 | 58 | ## Contribute 59 | 60 | 1. Fork it and create your feature branch: `git checkout -b my-new-feature` 61 | 2. Commit your changes: `git commit -am "Add some feature"` 62 | 3. Push to the branch: `git push origin my-new-feature` 63 | 4. Submit a pull request. 64 | 65 | ## Donations 66 | 67 | If you feel this project has helped you (it saved me time), please consider donating to better allow continued development and work on open issues for the community. 68 | 69 | [![](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/donate/?business=3JC38QXPNWYKS&no_recurring=0&item_name=Helping+the+community+one+step+at+a+time.¤cy_code=USD) 70 | 71 | I was not paid to work on this project, and really just wanted to help the community enhance their experience with the graphql and postgraphile realm. 72 | 73 | ## License 74 | 75 | MIT 76 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./build", /* Redirect output structure to the directory. */ 15 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true, /* Enable all strict type-checking options. */ 25 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | // "strictNullChecks": true, /* Enable strict null checks. */ 27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 28 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 29 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 30 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 31 | 32 | /* Additional Checks */ 33 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 34 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 35 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 36 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 37 | 38 | /* Module Resolution Options */ 39 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 40 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 41 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 42 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 43 | // "typeRoots": [], /* List of folders to include type definitions from. */ 44 | // "types": [], /* Type declaration files to be included in compilation. */ 45 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 46 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 47 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 48 | 49 | /* Source Map Options */ 50 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 51 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 52 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 53 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 54 | 55 | /* Experimental Options */ 56 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 57 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 58 | } 59 | } -------------------------------------------------------------------------------- /src/PostGraphileManyCreatePlugin.ts: -------------------------------------------------------------------------------- 1 | import * as T from './pluginTypes'; 2 | import debugFactory from 'debug'; 3 | const debug = debugFactory('graphile-build-pg'); 4 | 5 | const PostGraphileManyCreatePlugin: T.Plugin = ( 6 | builder: T.SchemaBuilder, 7 | options: any 8 | ) => { 9 | if (options.pgDisableDefaultMutations) return; 10 | 11 | /** 12 | * Add a hook to create the new root level create mutation 13 | */ 14 | builder.hook( 15 | // @ts-ignore 16 | 'GraphQLObjectType:fields', 17 | GQLObjectFieldsHookHandlerFcn, 18 | ['PgMutationManyCreate'], // Hook provides 19 | [], // Hook before 20 | ['PgMutationCreate'] // Hook after 21 | ); 22 | 23 | /** 24 | * Handles adding the new "many create" root level fields 25 | */ 26 | function GQLObjectFieldsHookHandlerFcn ( 27 | fields: any, 28 | build: T.Build, 29 | context: T.Context 30 | ) { 31 | const { 32 | extend, 33 | newWithHooks, 34 | parseResolveInfo, 35 | pgIntrospectionResultsByKind, 36 | pgGetGqlTypeByTypeIdAndModifier, 37 | pgGetGqlInputTypeByTypeIdAndModifier, 38 | pgSql: sql, 39 | gql2pg, 40 | graphql: { 41 | GraphQLObjectType, 42 | GraphQLInputObjectType, 43 | GraphQLNonNull, 44 | GraphQLString, 45 | GraphQLList 46 | }, 47 | pgColumnFilter, 48 | inflection, 49 | pgQueryFromResolveData: queryFromResolveData, 50 | pgOmit: omit, 51 | pgViaTemporaryTable: viaTemporaryTable, 52 | describePgEntity, 53 | sqlCommentByAddingTags, 54 | pgField 55 | } = build; 56 | 57 | const { 58 | scope: { isRootMutation }, 59 | fieldWithHooks 60 | } = context; 61 | 62 | if (!isRootMutation) return fields; 63 | 64 | let newFields: object = {}, 65 | i: number; 66 | const noOfTables = pgIntrospectionResultsByKind.class.length; 67 | for (i = 0; i < noOfTables; i++) { 68 | handleAdditionsFromTableInfo(pgIntrospectionResultsByKind.class[i]); 69 | } 70 | 71 | function handleAdditionsFromTableInfo (table: T.PgClass) { 72 | if ( 73 | !table.namespace || 74 | !table.isSelectable || 75 | !table.isInsertable || 76 | omit(table, 'create') || 77 | !table.tags.mncud 78 | ) 79 | return; 80 | 81 | const tableType: T.GraphQLType = pgGetGqlTypeByTypeIdAndModifier( 82 | table.type.id, 83 | null 84 | ); 85 | 86 | if (!tableType) { 87 | debug( 88 | `There was no table type for table '${table.namespace.name}.${table.name}', so we're not generating a create mutation for it.` 89 | ); 90 | return; 91 | } 92 | 93 | const TableInput = pgGetGqlInputTypeByTypeIdAndModifier( 94 | table.type.id, 95 | null 96 | ); 97 | if (!TableInput) { 98 | debug( 99 | `There was no input type for table '${table.namespace.name}.${table.name}', so we're going to omit it from the create mutation.` 100 | ); 101 | return; 102 | } 103 | const tableTypeName = inflection.tableType(table); 104 | 105 | // Setup args for the input type 106 | const newInputHookType = GraphQLInputObjectType; 107 | const newInputHookSpec = { 108 | name: `mn${inflection.createInputType(table)}`, 109 | description: `All input for the create mn\`${tableTypeName}\` mutation.`, 110 | fields: () => ({ 111 | clientMutationId: { 112 | description: 113 | 'An arbitrary string value with no semantic meaning. Will be included in the payload verbatim. May be used to track mutations by the client.', 114 | type: GraphQLString 115 | }, 116 | [`mn${tableTypeName}`]: { 117 | description: `The one or many \`${tableTypeName}\` to be created by this mutation.`, 118 | type: new GraphQLList(new GraphQLNonNull(TableInput)) 119 | } 120 | }) 121 | }; 122 | const newInputHookScope = { 123 | __origin: `Adding many table create input type for ${describePgEntity( 124 | table 125 | )}. 126 | You can rename the table's GraphQL type via a 'Smart Comment': 127 | \n\n ${sqlCommentByAddingTags(table, { 128 | name: 'newNameHere' 129 | })}`, 130 | isPgCreateInputType: true, 131 | pgInflection: table, 132 | pgIntrospection: table 133 | }; 134 | const InputType = newWithHooks( 135 | newInputHookType, 136 | newInputHookSpec, 137 | newInputHookScope 138 | ); 139 | 140 | // Setup args for payload type 141 | const newPayloadHookType = GraphQLObjectType; 142 | const newPayloadHookSpec = { 143 | name: `mn${inflection.createPayloadType(table)}`, 144 | description: `The output of our many create \`${tableTypeName}\` mutation.`, 145 | fields: ({ fieldWithHooks }) => { 146 | const tableName = inflection.tableFieldName(table); 147 | return { 148 | clientMutationId: { 149 | description: 150 | 'The exact same `clientMutationId` that was provided in the mutation input, unchanged and unused. May be used by a client to track mutations.', 151 | type: GraphQLString 152 | }, 153 | [tableName]: pgField( 154 | build, 155 | fieldWithHooks, 156 | tableName, 157 | { 158 | description: `The \`${tableTypeName}\` that was created by this mutation.`, 159 | type: tableType 160 | }, 161 | { 162 | isPgCreatePayloadResultField: true, 163 | pgFieldIntrospection: table 164 | } 165 | ) 166 | }; 167 | } 168 | }; 169 | const newPayloadHookScope = { 170 | __origin: `Adding many table many create payload type for ${describePgEntity( 171 | table 172 | )}. 173 | You can rename the table's GraphQL type via a 'Smart Comment': 174 | \n\n ${sqlCommentByAddingTags(table, { 175 | name: 'newNameHere' 176 | })}\n\nor disable the built-in create mutation via:\n\n 177 | ${sqlCommentByAddingTags(table, { omit: 'create' })}`, 178 | isMutationPayload: true, 179 | isPgCreatePayloadType: true, 180 | pgIntrospection: table 181 | }; 182 | const PayloadType = newWithHooks( 183 | newPayloadHookType, 184 | newPayloadHookSpec, 185 | newPayloadHookScope 186 | ); 187 | 188 | const fieldName = `mn${inflection.upperCamelCase( 189 | inflection.createField(table) 190 | )}`; 191 | 192 | function newFieldWithHooks (): T.FieldWithHooksFunction { 193 | return fieldWithHooks( 194 | fieldName, 195 | context => { 196 | context.table = table; 197 | context.relevantAttributes = table.attributes.filter( 198 | attr => 199 | pgColumnFilter(attr, build, context) && !omit(attr, 'create') 200 | ); 201 | return { 202 | description: `Creates one or many \`${tableTypeName}\`.`, 203 | type: PayloadType, 204 | args: { 205 | input: { 206 | type: new GraphQLNonNull(InputType) 207 | } 208 | }, 209 | resolve: resolver.bind(context) 210 | }; 211 | }, 212 | { 213 | pgFieldIntrospection: table, 214 | isPgCreateMutationField: true 215 | } 216 | ); 217 | } 218 | 219 | async function resolver (_data, args, resolveContext, resolveInfo) { 220 | const { input } = args; 221 | const { 222 | table, 223 | getDataFromParsedResolveInfoFragment, 224 | relevantAttributes 225 | }: { 226 | table: T.PgClass; 227 | getDataFromParsedResolveInfoFragment: any; 228 | relevantAttributes: any; 229 | // @ts-ignore 230 | } = this; 231 | const { pgClient } = resolveContext; 232 | const parsedResolveInfoFragment = parseResolveInfo(resolveInfo); 233 | // @ts-ignore 234 | parsedResolveInfoFragment.args = args; // Allow overriding via makeWrapResolversPlugin 235 | 236 | const resolveData = getDataFromParsedResolveInfoFragment( 237 | parsedResolveInfoFragment, 238 | PayloadType 239 | ); 240 | 241 | const insertedRowAlias = sql.identifier(Symbol()); 242 | const query = queryFromResolveData( 243 | insertedRowAlias, 244 | insertedRowAlias, 245 | resolveData, 246 | {}, 247 | null, 248 | resolveContext, 249 | resolveInfo.rootValue 250 | ); 251 | 252 | const sqlColumns: T.SQL[] = []; 253 | const inputData: Object[] = 254 | input[ 255 | `mn${inflection.upperCamelCase(inflection.tableFieldName(table))}` 256 | ]; 257 | if (!inputData || inputData.length === 0) return null; 258 | const sqlValues: T.SQL[][] = Array(inputData.length).fill([]); 259 | 260 | inputData.forEach((dataObj, i) => { 261 | relevantAttributes.forEach((attr: T.PgAttribute) => { 262 | const fieldName = inflection.column(attr); 263 | const dataValue = dataObj[fieldName]; 264 | // On the first run, store the attribute values 265 | if (i === 0) { 266 | sqlColumns.push(sql.identifier(attr.name)); 267 | } 268 | // If the key exists, store the data else store DEFAULT. 269 | if (Object.prototype.hasOwnProperty.call(dataObj, fieldName)) { 270 | sqlValues[i] = [ 271 | ...sqlValues[i], 272 | gql2pg(dataValue, attr.type, attr.typeModifier) 273 | ]; 274 | } else { 275 | sqlValues[i] = [...sqlValues[i], sql.raw('default')]; 276 | } 277 | }); 278 | }); 279 | 280 | const mutationQuery = sql.query` 281 | INSERT INTO ${sql.identifier(table.namespace.name, table.name)} 282 | ${ 283 | sqlColumns.length 284 | ? sql.fragment`(${sql.join(sqlColumns, ', ')}) 285 | VALUES (${sql.join( 286 | sqlValues.map( 287 | dataGroup => sql.fragment`${sql.join(dataGroup, ', ')}` 288 | ), 289 | '),(' 290 | )})` 291 | : sql.fragment`default values` 292 | } returning *`; 293 | let row; 294 | try { 295 | await pgClient.query('SAVEPOINT graphql_mutation'); 296 | const rows = await viaTemporaryTable( 297 | pgClient, 298 | sql.identifier(table.namespace.name, table.name), 299 | mutationQuery, 300 | insertedRowAlias, 301 | query 302 | ); 303 | 304 | row = rows[0]; 305 | await pgClient.query('RELEASE SAVEPOINT graphql_mutation'); 306 | } catch (e) { 307 | await pgClient.query('ROLLBACK TO SAVEPOINT graphql_mutation'); 308 | throw e; 309 | } 310 | 311 | return { 312 | clientMutationId: input.clientMutationId, 313 | data: row 314 | }; 315 | } 316 | 317 | newFields = extend( 318 | newFields, 319 | { 320 | [fieldName]: newFieldWithHooks 321 | }, 322 | `Adding create mutation for ${describePgEntity(table)}. You can omit 323 | this default mutation with a 'Smart Comment':\n\n 324 | ${sqlCommentByAddingTags(table, { 325 | omit: 'create' 326 | })}` 327 | ); 328 | } 329 | 330 | return extend( 331 | fields, 332 | newFields, 333 | `Adding the many 'create' mutation to the root mutation` 334 | ); 335 | } 336 | }; 337 | 338 | export default PostGraphileManyCreatePlugin; 339 | -------------------------------------------------------------------------------- /src/PostGraphileManyDeletePlugin.ts: -------------------------------------------------------------------------------- 1 | import * as T from './pluginTypes'; 2 | import debugFactory from 'debug'; 3 | const debug = debugFactory('graphile-build-pg'); 4 | 5 | const PostGraphileManyDeletePlugin: T.Plugin = ( 6 | builder: T.SchemaBuilder, 7 | options: any 8 | ) => { 9 | if (options.pgDisableDefaultMutations) return; 10 | 11 | /** 12 | * Add a hook to create the new root level delete mutation 13 | */ 14 | builder.hook( 15 | // @ts-ignore 16 | 'GraphQLObjectType:fields', 17 | GQLObjectFieldsHookHandlerFcn, 18 | ['PgMutationManyDelete'], // hook provides 19 | [], // hook before 20 | ['PgMutationUpdateDelete'] // hook after 21 | ); 22 | 23 | /** 24 | * Handles adding the new "many delete" root level fields 25 | */ 26 | function GQLObjectFieldsHookHandlerFcn ( 27 | fields: any, 28 | build: T.Build, 29 | context: T.Context 30 | ) { 31 | const { 32 | extend, 33 | newWithHooks, 34 | getNodeIdForTypeAndIdentifiers, 35 | getTypeAndIdentifiersFromNodeId, 36 | nodeIdFieldName, 37 | fieldDataGeneratorsByFieldNameByType, 38 | parseResolveInfo, 39 | getTypeByName, 40 | gql2pg, 41 | pgGetGqlTypeByTypeIdAndModifier, 42 | pgGetGqlInputTypeByTypeIdAndModifier, 43 | pgIntrospectionResultsByKind, 44 | pgSql: sql, 45 | graphql: { 46 | GraphQLList, 47 | GraphQLNonNull, 48 | GraphQLInputObjectType, 49 | GraphQLString, 50 | GraphQLObjectType, 51 | GraphQLID, 52 | getNamedType 53 | }, 54 | pgColumnFilter, 55 | inflection, 56 | pgQueryFromResolveData: queryFromResolveData, 57 | pgOmit: omit, 58 | pgViaTemporaryTable: viaTemporaryTable, 59 | describePgEntity, 60 | sqlCommentByAddingTags, 61 | pgField 62 | } = build; 63 | const { 64 | scope: { isRootMutation }, 65 | fieldWithHooks 66 | } = context; 67 | 68 | if (!isRootMutation || !pgColumnFilter) return fields; 69 | 70 | let newFields = {}, 71 | i: number; 72 | const noOfTables = pgIntrospectionResultsByKind.class.length; 73 | for (i = 0; i < noOfTables; i++) { 74 | handleAdditionsFromTableInfo(pgIntrospectionResultsByKind.class[i]); 75 | } 76 | 77 | function handleAdditionsFromTableInfo (table: T.PgClass) { 78 | if ( 79 | !table.namespace || 80 | !table.isDeletable || 81 | omit(table, 'delete') || 82 | !table.tags.mncud 83 | ) 84 | return; 85 | 86 | const tableType: T.GraphQLType = pgGetGqlTypeByTypeIdAndModifier( 87 | table.type.id, 88 | null 89 | ); 90 | if (!tableType) { 91 | debug( 92 | `There was no GQL Table Type for table '${table.namespace.name}.${table.name}', 93 | so we're not generating a many delete mutation for it.` 94 | ); 95 | return; 96 | } 97 | const namedType = getNamedType(tableType); 98 | const tablePatch = getTypeByName(inflection.patchType(namedType.name)); 99 | if (!tablePatch) { 100 | throw new Error( 101 | `Could not find TablePatch type for table '${table.name}'` 102 | ); 103 | } 104 | 105 | const tableTypeName = namedType.name; 106 | const uniqueConstraints = table.constraints.filter( 107 | con => con.type === 'p' 108 | ); 109 | 110 | // Setup and add the GraphQL Payload Type 111 | const newPayloadHookType = GraphQLObjectType; 112 | const newPayloadHookSpec = { 113 | name: `mn${inflection.deletePayloadType(table)}`, 114 | description: `The output of our delete mn \`${tableTypeName}\` mutation.`, 115 | fields: ({ fieldWithHooks }) => { 116 | const tableName = inflection.tableFieldName(table); 117 | const deletedNodeIdFieldName = inflection.deletedNodeId(table); 118 | 119 | return Object.assign( 120 | { 121 | clientMutationId: { 122 | description: 123 | 'The exact same `clientMutationId` that was provided in the mutation input, unchanged and unused. May be used by a client to track mutations.', 124 | type: GraphQLString 125 | }, 126 | 127 | [tableName]: pgField( 128 | build, 129 | fieldWithHooks, 130 | tableName, 131 | { 132 | description: `The \`${tableTypeName}\` that was deleted by this mutation.`, 133 | type: tableType 134 | }, 135 | {}, 136 | false 137 | ) 138 | }, 139 | { 140 | [deletedNodeIdFieldName]: fieldWithHooks( 141 | deletedNodeIdFieldName, 142 | ({ addDataGenerator }) => { 143 | const fieldDataGeneratorsByTableType = fieldDataGeneratorsByFieldNameByType.get( 144 | tableType 145 | ); 146 | const gens = 147 | fieldDataGeneratorsByTableType && 148 | fieldDataGeneratorsByTableType[nodeIdFieldName]; 149 | if (gens) { 150 | gens.forEach(gen => addDataGenerator(gen)); 151 | } 152 | return { 153 | type: GraphQLID, 154 | resolve (data) { 155 | return ( 156 | data.data.__identifiers && 157 | getNodeIdForTypeAndIdentifiers( 158 | tableType, 159 | ...data.data.__identifiers 160 | ) 161 | ); 162 | } 163 | }; 164 | }, 165 | { 166 | isPgMutationPayloadDeletedNodeIdField: true 167 | } 168 | ) 169 | } 170 | ); 171 | } 172 | }; 173 | const newPayloadHookScope = { 174 | __origin: `Adding table mn delete mutation payload type for ${describePgEntity( 175 | table 176 | )}. You can rename the table's GraphQL type via a 'Smart Comment':\n\n 177 | ${sqlCommentByAddingTags(table, { 178 | name: 'newNameHere' 179 | })}`, 180 | isMutationPayload: true, 181 | isPgDeletePayloadType: true, 182 | pgIntrospection: table 183 | }; 184 | const PayloadType = newWithHooks( 185 | newPayloadHookType, 186 | newPayloadHookSpec, 187 | newPayloadHookScope 188 | ); 189 | if (!PayloadType) { 190 | throw new Error( 191 | `Failed to determine payload type on the mn\`${tableTypeName}\` mutation` 192 | ); 193 | } 194 | // Setup and add GQL Input Types for "Unique Constraint" based updates 195 | // TODO: Add NodeId code updates 196 | uniqueConstraints.forEach(constraint => { 197 | if (omit(constraint, 'delete')) return; 198 | const keys = constraint.keyAttributes; 199 | 200 | if (!keys.every(_ => _)) { 201 | throw new Error( 202 | `Consistency error: could not find an attribute in the constraint when building the many\ 203 | delete mutation for ${describePgEntity(table)}!` 204 | ); 205 | } 206 | if (keys.some(key => omit(key, 'read'))) return; 207 | 208 | const fieldName = `mn${inflection.upperCamelCase( 209 | inflection.deleteByKeys(keys, table, constraint) 210 | )}`; 211 | 212 | const newInputHookType = GraphQLInputObjectType; 213 | 214 | const patchName = inflection.patchField( 215 | inflection.tableFieldName(table) 216 | ); 217 | 218 | const newInputHookSpec = { 219 | name: `mn${inflection.upperCamelCase( 220 | inflection.deleteByKeysInputType(keys, table, constraint) 221 | )}`, 222 | description: `All input for the delete \`${fieldName}\` mutation.`, 223 | fields: Object.assign( 224 | { 225 | clientMutationId: { 226 | type: GraphQLString 227 | } 228 | }, 229 | { 230 | [`mn${inflection.upperCamelCase(patchName)}`]: { 231 | description: `The one or many \`${tableTypeName}\` to be deleted. You must provide the PK values!`, 232 | // TODO: Add an actual type that has the PKs required 233 | // instead of using the tablePatch in another file, 234 | // and hook onto the input types to do so. 235 | //@ts-ignore 236 | type: new GraphQLList(new GraphQLNonNull(tablePatch!)) 237 | } 238 | }, 239 | {} 240 | ) 241 | }; 242 | 243 | const newInputHookScope = { 244 | __origin: `Adding table many delete mutation input type for ${describePgEntity( 245 | constraint 246 | )}, 247 | You can rename the table's GraphQL type via a 'Smart Comment':\n\n 248 | ${sqlCommentByAddingTags(table, { 249 | name: 'newNameHere' 250 | })}`, 251 | isPgDeleteInputType: true, 252 | isPgDeleteByKeysInputType: true, 253 | isMutationInput: true, 254 | pgInflection: table, 255 | pgKeys: keys 256 | }; 257 | 258 | const InputType = newWithHooks( 259 | newInputHookType, 260 | newInputHookSpec, 261 | newInputHookScope 262 | ); 263 | 264 | if (!InputType) { 265 | throw new Error( 266 | `Failed to determine input type for '${fieldName}' mutation` 267 | ); 268 | } 269 | 270 | // Define the new mutation field 271 | function newFieldWithHooks (): T.FieldWithHooksFunction { 272 | return fieldWithHooks( 273 | fieldName, 274 | context => { 275 | context.table = table; 276 | context.relevantAttributes = table.attributes.filter( 277 | attr => 278 | pgColumnFilter(attr, build, context) && !omit(attr, 'delete') 279 | ); 280 | return { 281 | description: `Deletes one or many \`${tableTypeName}\` a unique key via a patch.`, 282 | type: PayloadType, 283 | args: { 284 | input: { 285 | type: new GraphQLNonNull(InputType) 286 | } 287 | }, 288 | resolve: resolver.bind(context) 289 | }; 290 | }, 291 | { 292 | pgFieldIntrospection: table, 293 | pgFieldConstraint: constraint, 294 | isPgNodeMutation: false, 295 | isPgDeleteMutationField: true 296 | } 297 | ); 298 | } 299 | 300 | async function resolver (_data, args, resolveContext, resolveInfo) { 301 | const { input } = args; 302 | const { 303 | table, 304 | getDataFromParsedResolveInfoFragment, 305 | relevantAttributes 306 | }: { 307 | table: T.PgClass; 308 | getDataFromParsedResolveInfoFragment: any; 309 | relevantAttributes: any; 310 | // @ts-ignore 311 | } = this; 312 | const { pgClient } = resolveContext; 313 | 314 | const parsedResolveInfoFragment = parseResolveInfo(resolveInfo); 315 | // @ts-ignore 316 | parsedResolveInfoFragment.args = args; // Allow overriding via makeWrapResolversPlugin 317 | 318 | const resolveData = getDataFromParsedResolveInfoFragment( 319 | parsedResolveInfoFragment, 320 | PayloadType 321 | ); 322 | 323 | const sqlColumns: T.SQL[] = []; 324 | const inputData: Object[] = 325 | input[ 326 | `mn${inflection.upperCamelCase( 327 | inflection.patchField(inflection.tableFieldName(table)) 328 | )}` 329 | ]; 330 | if (!inputData || inputData.length === 0) return null; 331 | const sqlValues: T.SQL[][] = Array(inputData.length).fill([]); 332 | let hasConstraintValue = true; 333 | 334 | inputData.forEach((dataObj, i) => { 335 | let setOfRcvdDataHasPKValue = false; 336 | 337 | relevantAttributes.forEach((attr: T.PgAttribute) => { 338 | const fieldName = inflection.column(attr); 339 | const dataValue = dataObj[fieldName]; 340 | 341 | const isConstraintAttr = keys.some(key => key.name === attr.name); 342 | // Ensure that the field values are PKs since that's 343 | // all we care about for deletions. 344 | if (!isConstraintAttr) return; 345 | // Store all attributes on the first run. 346 | if (i === 0) { 347 | sqlColumns.push(sql.raw(attr.name)); 348 | } 349 | if (fieldName in dataObj) { 350 | sqlValues[i] = [ 351 | ...sqlValues[i], 352 | gql2pg(dataValue, attr.type, attr.typeModifier) 353 | ]; 354 | if (isConstraintAttr) { 355 | setOfRcvdDataHasPKValue = true; 356 | } 357 | } 358 | }); 359 | if (!setOfRcvdDataHasPKValue) { 360 | hasConstraintValue = false; 361 | } 362 | }); 363 | 364 | if (!hasConstraintValue) { 365 | throw new Error( 366 | `You must provide the primary key(s) in the provided data for deletes on '${inflection.pluralize( 367 | inflection._singularizedTableName(table) 368 | )}'` 369 | ); 370 | } 371 | 372 | if (sqlColumns.length === 0) return null; 373 | 374 | const mutationQuery = sql.query`\ 375 | DELETE FROM ${sql.identifier(table.namespace.name, table.name)} 376 | WHERE 377 | (${sql.join( 378 | sqlValues.map( 379 | (dataGroup, i) => 380 | sql.fragment`(${sql.join( 381 | dataGroup.map( 382 | (val, j) => sql.fragment`"${sqlColumns[j]}" = ${val}` 383 | ), 384 | ') and (' 385 | )})` 386 | ), 387 | ') or (' 388 | )}) 389 | RETURNING * 390 | `; 391 | 392 | const modifiedRowAlias = sql.identifier(Symbol()); 393 | const query = queryFromResolveData( 394 | modifiedRowAlias, 395 | modifiedRowAlias, 396 | resolveData, 397 | {}, 398 | null, 399 | resolveContext, 400 | resolveInfo.rootValue 401 | ); 402 | 403 | let row; 404 | try { 405 | await pgClient.query('SAVEPOINT graphql_mutation'); 406 | const rows = await viaTemporaryTable( 407 | pgClient, 408 | sql.identifier(table.namespace.name, table.name), 409 | mutationQuery, 410 | modifiedRowAlias, 411 | query 412 | ); 413 | 414 | row = rows[0]; 415 | await pgClient.query('RELEASE SAVEPOINT graphql_mutation'); 416 | } catch (e) { 417 | await pgClient.query('ROLLBACK TO SAVEPOINT graphql_mutation'); 418 | throw e; 419 | } 420 | 421 | if (!row) { 422 | throw new Error( 423 | `No values were deleted in collection '${inflection.pluralize( 424 | inflection._singularizedTableName(table) 425 | )}' because no values you can delete were found matching these criteria.` 426 | ); 427 | } 428 | return { 429 | clientMutationId: input.clientMutationId, 430 | data: row 431 | }; 432 | } 433 | 434 | newFields = extend( 435 | newFields, 436 | { 437 | [fieldName]: newFieldWithHooks 438 | }, 439 | `Adding mn delete mutation for ${describePgEntity(constraint)}` 440 | ); 441 | }); 442 | } 443 | 444 | return extend( 445 | fields, 446 | newFields, 447 | `Adding the many 'delete' mutation to the root mutation` 448 | ); 449 | } 450 | }; 451 | export default PostGraphileManyDeletePlugin; 452 | -------------------------------------------------------------------------------- /src/PostGraphileManyUpdatePlugin.ts: -------------------------------------------------------------------------------- 1 | import * as T from './pluginTypes'; 2 | import debugFactory from 'debug'; 3 | const debug = debugFactory('graphile-build-pg'); 4 | 5 | const PostGraphileManyUpdatePlugin: T.Plugin = ( 6 | builder: T.SchemaBuilder, 7 | options: any 8 | ) => { 9 | if (options.pgDisableDefaultMutations) return; 10 | 11 | /** 12 | * Add a hook to create the new root level create mutation 13 | */ 14 | builder.hook( 15 | // @ts-ignore 16 | 'GraphQLObjectType:fields', 17 | GQLObjectFieldsHookHandlerFcn, 18 | ['PgMutationManyUpdate'], // hook provides 19 | [], // hook before 20 | ['PgMutationUpdateDelete'] // hook after 21 | ); 22 | 23 | /** 24 | * Handles adding the new "many update" root level fields 25 | */ 26 | function GQLObjectFieldsHookHandlerFcn ( 27 | fields: any, 28 | build: T.Build, 29 | context: T.Context 30 | ) { 31 | const { 32 | extend, 33 | newWithHooks, 34 | getNodeIdForTypeAndIdentifiers, 35 | getTypeAndIdentifiersFromNodeId, 36 | nodeIdFieldName, 37 | fieldDataGeneratorsByFieldNameByType, 38 | parseResolveInfo, 39 | getTypeByName, 40 | gql2pg, 41 | pgGetGqlTypeByTypeIdAndModifier, 42 | pgGetGqlInputTypeByTypeIdAndModifier, 43 | pgIntrospectionResultsByKind, 44 | pgSql: sql, 45 | graphql: { 46 | GraphQLList, 47 | GraphQLNonNull, 48 | GraphQLInputObjectType, 49 | GraphQLString, 50 | GraphQLObjectType, 51 | GraphQLID, 52 | getNamedType 53 | }, 54 | pgColumnFilter, 55 | inflection, 56 | pgQueryFromResolveData: queryFromResolveData, 57 | pgOmit: omit, 58 | pgViaTemporaryTable: viaTemporaryTable, 59 | describePgEntity, 60 | sqlCommentByAddingTags, 61 | pgField 62 | } = build; 63 | const { 64 | scope: { isRootMutation }, 65 | fieldWithHooks 66 | } = context; 67 | 68 | if (!isRootMutation || !pgColumnFilter) return fields; 69 | 70 | let newFields = {}, 71 | i: number; 72 | const noOfTables = pgIntrospectionResultsByKind.class.length; 73 | for (i = 0; i < noOfTables; i++) { 74 | handleAdditionsFromTableInfo(pgIntrospectionResultsByKind.class[i]); 75 | } 76 | 77 | function handleAdditionsFromTableInfo (table: T.PgClass) { 78 | if ( 79 | !table.namespace || 80 | !table.isUpdatable || 81 | omit(table, 'update') || 82 | !table.tags.mncud 83 | ) 84 | return; 85 | 86 | const tableType: T.GraphQLType = pgGetGqlTypeByTypeIdAndModifier( 87 | table.type.id, 88 | null 89 | ); 90 | if (!tableType) { 91 | debug( 92 | `There was no GQL Table Type for table '${table.namespace.name}.${table.name}', 93 | so we're not generating a many update mutation for it.` 94 | ); 95 | return; 96 | } 97 | const namedType = getNamedType(tableType); 98 | const tablePatch = getTypeByName(inflection.patchType(namedType.name)); 99 | if (!tablePatch) { 100 | throw new Error( 101 | `Could not find TablePatch type for table '${table.name}'` 102 | ); 103 | } 104 | 105 | const tableTypeName = namedType.name; 106 | const uniqueConstraints = table.constraints.filter( 107 | con => con.type === 'p' 108 | ); 109 | 110 | // Setup and add the GraphQL Payload type 111 | const newPayloadHookType = GraphQLObjectType; 112 | const newPayloadHookSpec = { 113 | name: `mn${inflection.updatePayloadType(table)}`, 114 | description: `The output of our update mn \`${tableTypeName}\` mutation.`, 115 | fields: ({ fieldWithHooks }) => { 116 | const tableName = inflection.tableFieldName(table); 117 | return { 118 | clientMutationId: { 119 | description: 120 | 'The exact same `clientMutationId` that was provided in the mutation input,\ 121 | unchanged and unused. May be used by a client to track mutations.', 122 | type: GraphQLString 123 | }, 124 | [tableName]: pgField( 125 | build, 126 | fieldWithHooks, 127 | tableName, 128 | { 129 | description: `The \`${tableTypeName}\` that was updated by this mutation.`, 130 | type: tableType 131 | }, 132 | {}, 133 | false 134 | ) 135 | }; 136 | } 137 | }; 138 | const newPayloadHookScope = { 139 | __origin: `Adding table many update mutation payload type for ${describePgEntity( 140 | table 141 | )}. 142 | You can rename the table's GraphQL type via a 'Smart Comment':\n\n 143 | ${sqlCommentByAddingTags(table, { 144 | name: 'newNameHere' 145 | })}`, 146 | isMutationPayload: true, 147 | isPgUpdatePayloadType: true, 148 | pgIntrospection: table 149 | }; 150 | const PayloadType = newWithHooks( 151 | newPayloadHookType, 152 | newPayloadHookSpec, 153 | newPayloadHookScope 154 | ); 155 | if (!PayloadType) { 156 | throw new Error( 157 | `Failed to determine payload type on the mn\`${tableTypeName}\` mutation` 158 | ); 159 | } 160 | 161 | // Setup and add GQL Input Types for "Unique Constraint" based updates 162 | // TODO: Look into adding updates via NodeId 163 | uniqueConstraints.forEach(constraint => { 164 | if (omit(constraint, 'update')) return; 165 | 166 | const keys = constraint.keyAttributes; 167 | if (!keys.every(_ => _)) { 168 | throw new Error( 169 | `Consistency error: could not find an attribute in the constraint when building the many\ 170 | update mutation for ${describePgEntity(table)}!` 171 | ); 172 | } 173 | if (keys.some(key => omit(key, 'read'))) return; 174 | 175 | const fieldName = `mn${inflection.upperCamelCase( 176 | inflection.updateByKeys(keys, table, constraint) 177 | )}`; 178 | 179 | const newInputHookType = GraphQLInputObjectType; 180 | 181 | const patchName = inflection.patchField( 182 | inflection.tableFieldName(table) 183 | ); 184 | 185 | const newInputHookSpec = { 186 | name: `mn${inflection.upperCamelCase( 187 | inflection.updateByKeysInputType(keys, table, constraint) 188 | )}`, 189 | description: `All input for the update \`${fieldName}\` mutation.`, 190 | fields: Object.assign( 191 | { 192 | clientMutationId: { 193 | type: GraphQLString 194 | } 195 | }, 196 | { 197 | [`mn${inflection.upperCamelCase(patchName)}`]: { 198 | description: `The one or many \`${tableTypeName}\` to be updated.`, 199 | // TODO: Add an actual type that has the PKs required 200 | // instead of using the tablePatch in another file, 201 | // and hook onto the input types to do so. 202 | //@ts-ignore 203 | type: new GraphQLList(new GraphQLNonNull(tablePatch!)) 204 | } 205 | }, 206 | {} 207 | ) 208 | }; 209 | const newInputHookScope = { 210 | __origin: `Adding table many update mutation input type for ${describePgEntity( 211 | constraint 212 | )}, 213 | You can rename the table's GraphQL type via a 'Smart Comment':\n\n 214 | ${sqlCommentByAddingTags(table, { 215 | name: 'newNameHere' 216 | })}`, 217 | isPgUpdateInputType: true, 218 | isPgUpdateByKeysInputType: true, 219 | isMutationInput: true, 220 | pgInflection: table, 221 | pgKeys: keys 222 | }; 223 | 224 | const InputType = newWithHooks( 225 | newInputHookType, 226 | newInputHookSpec, 227 | newInputHookScope 228 | ); 229 | 230 | if (!InputType) { 231 | throw new Error( 232 | `Failed to determine input type for '${fieldName}' mutation` 233 | ); 234 | } 235 | // Define the new mutation field 236 | function newFieldWithHooks (): T.FieldWithHooksFunction { 237 | return fieldWithHooks( 238 | fieldName, 239 | context => { 240 | context.table = table; 241 | context.relevantAttributes = table.attributes.filter( 242 | attr => 243 | pgColumnFilter(attr, build, context) && !omit(attr, 'update') 244 | ); 245 | return { 246 | description: `Updates one or many \`${tableTypeName}\` using a unique key and a patch.`, 247 | type: PayloadType, 248 | args: { 249 | input: { 250 | type: new GraphQLNonNull(InputType) 251 | } 252 | }, 253 | resolve: resolver.bind(context) 254 | }; 255 | }, 256 | { 257 | pgFieldIntrospection: table, 258 | pgFieldConstraint: constraint, 259 | isPgNodeMutation: false, 260 | isPgUpdateMutationField: true 261 | } 262 | ); 263 | } 264 | 265 | async function resolver (_data, args, resolveContext, resolveInfo) { 266 | const { input } = args; 267 | const { 268 | table, 269 | getDataFromParsedResolveInfoFragment, 270 | relevantAttributes 271 | }: { 272 | table: T.PgClass; 273 | getDataFromParsedResolveInfoFragment: any; 274 | relevantAttributes: any; 275 | // @ts-ignore 276 | } = this; 277 | const { pgClient } = resolveContext; 278 | 279 | const parsedResolveInfoFragment = parseResolveInfo(resolveInfo); 280 | // @ts-ignore 281 | parsedResolveInfoFragment.args = args; // Allow overriding via makeWrapResolversPlugin 282 | 283 | const resolveData = getDataFromParsedResolveInfoFragment( 284 | parsedResolveInfoFragment, 285 | PayloadType 286 | ); 287 | 288 | const sqlColumns: T.SQL[] = []; 289 | const sqlColumnTypes: T.SQL[] = []; 290 | const allSQLColumns: T.SQL[] = []; 291 | const inputData: Object[] = 292 | input[ 293 | `mn${inflection.upperCamelCase( 294 | inflection.patchField(inflection.tableFieldName(table)) 295 | )}` 296 | ]; 297 | if (!inputData || inputData.length === 0) return null; 298 | const sqlValues: T.SQL[][] = Array(inputData.length).fill([]); 299 | 300 | const usedSQLColumns: T.SQL[] = []; 301 | const usedColSQLVals: T.SQL[][] = Array(inputData.length).fill([]); 302 | let hasConstraintValue = true; 303 | 304 | inputData.forEach((dataObj, i) => { 305 | let setOfRcvdDataHasPKValue = false; 306 | 307 | relevantAttributes.forEach((attr: T.PgAttribute) => { 308 | const fieldName = inflection.column(attr); 309 | const dataValue = dataObj[fieldName]; 310 | 311 | const isConstraintAttr = keys.some(key => key.name === attr.name); 312 | 313 | // Store all attributes on the first run. 314 | // Skip the primary keys, since we can't update those. 315 | if (i === 0 && !isConstraintAttr) { 316 | sqlColumns.push(sql.raw(attr.name)); 317 | usedSQLColumns.push(sql.raw('use_' + attr.name)); 318 | // Handle custom types 319 | if (attr.type.namespaceName !== 'pg_catalog') { 320 | sqlColumnTypes.push(sql.raw(attr.class.namespaceName + '.' + attr.type.name)); 321 | } else { 322 | sqlColumnTypes.push(sql.raw(attr.type.name)); 323 | } 324 | } 325 | // Get all of the attributes 326 | if (i === 0) { 327 | allSQLColumns.push(sql.raw(attr.name)); 328 | } 329 | // Push the data value if it exists, else push 330 | // a dummy null value (which will not be used). 331 | if (fieldName in dataObj) { 332 | sqlValues[i] = [ 333 | ...sqlValues[i], 334 | gql2pg(dataValue, attr.type, attr.typeModifier) 335 | ]; 336 | if (!isConstraintAttr) { 337 | usedColSQLVals[i] = [...usedColSQLVals[i], sql.raw('true')]; 338 | } else { 339 | setOfRcvdDataHasPKValue = true; 340 | } 341 | } else { 342 | sqlValues[i] = [...sqlValues[i], sql.raw('NULL')]; 343 | if (!isConstraintAttr) { 344 | usedColSQLVals[i] = [...usedColSQLVals[i], sql.raw('false')]; 345 | } 346 | } 347 | }); 348 | if (!setOfRcvdDataHasPKValue) { 349 | hasConstraintValue = false; 350 | } 351 | }); 352 | 353 | if (!hasConstraintValue) { 354 | throw new Error( 355 | `You must provide the primary key(s) in the updated data for updates on '${inflection.pluralize( 356 | inflection._singularizedTableName(table) 357 | )}'` 358 | ); 359 | } 360 | 361 | if (sqlColumns.length === 0) return null; 362 | 363 | // https://stackoverflow.com/questions/63290696/update-multiple-rows-using-postgresql 364 | const mutationQuery = sql.query`\ 365 | UPDATE ${sql.identifier(table.namespace.name, table.name)} t1 SET 366 | ${sql.join( 367 | sqlColumns.map( 368 | (col, i) => 369 | sql.fragment`"${col}" = (CASE WHEN t2."use_${col}" THEN t2."${col}"::${sqlColumnTypes[i]} ELSE t1."${col}" END)` 370 | ), 371 | ', ' 372 | )} 373 | FROM (VALUES 374 | (${sql.join( 375 | sqlValues.map( 376 | (dataGroup, i) => 377 | sql.fragment`${sql.join( 378 | dataGroup.concat(usedColSQLVals[i]), 379 | ', ' 380 | )}` 381 | ), 382 | '),(' 383 | )}) 384 | ) t2( 385 | ${sql.join( 386 | allSQLColumns 387 | .map(col => sql.fragment`"${col}"`) 388 | .concat( 389 | usedSQLColumns.map(useCol => sql.fragment`"${useCol}"`) 390 | ), 391 | ', ' 392 | )} 393 | ) 394 | WHERE ${sql.fragment`(${sql.join( 395 | keys.map( 396 | key => 397 | sql.fragment`t2.${sql.identifier(key.name)}::${sql.raw( 398 | key.type.name 399 | )} = t1.${sql.identifier(key.name)}` 400 | ), 401 | ') and (' 402 | )})`} 403 | RETURNING ${sql.join( 404 | allSQLColumns.map(col => sql.fragment`t1."${col}"`), 405 | ', ' 406 | )} 407 | `; 408 | 409 | const modifiedRowAlias = sql.identifier(Symbol()); 410 | const query = queryFromResolveData( 411 | modifiedRowAlias, 412 | modifiedRowAlias, 413 | resolveData, 414 | {}, 415 | null, 416 | resolveContext, 417 | resolveInfo.rootValue 418 | ); 419 | 420 | let row; 421 | try { 422 | await pgClient.query('SAVEPOINT graphql_mutation'); 423 | const rows = await viaTemporaryTable( 424 | pgClient, 425 | sql.identifier(table.namespace.name, table.name), 426 | mutationQuery, 427 | modifiedRowAlias, 428 | query 429 | ); 430 | 431 | row = rows[0]; 432 | 433 | await pgClient.query('RELEASE SAVEPOINT graphql_mutation'); 434 | } catch (e) { 435 | await pgClient.query('ROLLBACK TO SAVEPOINT graphql_mutation'); 436 | throw e; 437 | } 438 | 439 | if (!row) { 440 | throw new Error( 441 | `No values were updated in collection '${inflection.pluralize( 442 | inflection._singularizedTableName(table) 443 | )}' because no values you can update were found matching these criteria.` 444 | ); 445 | } 446 | return { 447 | clientMutationId: input.clientMutationId, 448 | data: row 449 | }; 450 | } 451 | 452 | newFields = extend( 453 | newFields, 454 | { 455 | [fieldName]: newFieldWithHooks 456 | }, 457 | `Adding mn update mutation for ${describePgEntity(constraint)}` 458 | ); 459 | }); 460 | } 461 | 462 | return extend( 463 | fields, 464 | newFields, 465 | `Adding the many 'update' mutation to the root mutation` 466 | ); 467 | } 468 | }; 469 | export default PostGraphileManyUpdatePlugin; 470 | --------------------------------------------------------------------------------