├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .flowconfig ├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── src ├── Logger.js ├── bin │ ├── commands │ │ ├── .eslintrc │ │ └── generate-loaders.js │ └── index.js ├── errors.js ├── index.js ├── queries │ ├── getDatabaseColumns.js │ ├── getDatabaseIndexes.js │ └── index.js ├── routines │ ├── getByIds.js │ ├── getByIdsUsingJoiningTable.js │ └── index.js ├── types.js └── utilities │ ├── createColumnSelector.js │ ├── createLoaderTypePropertyDeclaration.js │ ├── formatPropertyName.js │ ├── formatTypeName.js │ ├── generateDataLoaderFactory.js │ ├── generateFlowTypeDocument.js │ ├── getFlowType.js │ ├── indent.js │ ├── index.js │ ├── isJoiningTable.js │ ├── isNumberType.js │ ├── isStringType.js │ └── pluralizeTableName.js └── test ├── .eslintrc ├── helpers └── index.js └── postloader └── utilities ├── createColumnSelector.js ├── createLoaderTypePropertyDeclaration.js ├── formatPropertyName.js ├── formatTypeName.js ├── generateDataLoaderFactory.js ├── generateFlowTypeDocument.js ├── getFlowType.js └── isJoiningTable.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "plugins": [ 5 | "istanbul" 6 | ] 7 | } 8 | }, 9 | "plugins": [ 10 | "macros", 11 | "@babel/transform-flow-strip-types" 12 | ], 13 | "presets": [ 14 | [ 15 | "@babel/env", 16 | { 17 | "targets": { 18 | "node": 10 19 | } 20 | } 21 | ] 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gajus/postloader/61c31a5f6df51f17b7fece1bea5328d0c4bf0cef/.eslintignore -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "canonical", 4 | "canonical/flowtype" 5 | ], 6 | "root": true, 7 | "rules": { 8 | "flowtype/no-weak-types": 0, 9 | "no-continue": 0, 10 | "no-duplicate-imports": 0, 11 | "no-restricted-syntax": 0 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/.*/test/.* 3 | /dist/.* 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: gajus 2 | patreon: gajus 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | node_modules 4 | *.log 5 | .* 6 | !.babelrc 7 | !.editorconfig 8 | !.eslintignore 9 | !.eslintrc 10 | !.flowconfig 11 | !.gitignore 12 | !.npmignore 13 | !.travis.yml 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | coverage 4 | .* 5 | *.log 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | - 10 5 | script: 6 | - npm run build 7 | - npm run test 8 | - npm run lint 9 | - nyc --silent npm run test 10 | - nyc report --reporter=text-lcov | coveralls 11 | - nyc check-coverage --lines 60 12 | after_success: 13 | - npm run build 14 | - semantic-release 15 | notifications: 16 | email: false 17 | sudo: false 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Gajus Kuizinas (http://gajus.com/) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the Gajus Kuizinas (http://gajus.com/) nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL ANUARY BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PostLoader 2 | 3 | [![GitSpo Mentions](https://gitspo.com/badges/mentions/gajus/postloader?style=flat-square)](https://gitspo.com/mentions/gajus/postloader) 4 | [![Travis build status](http://img.shields.io/travis/gajus/postloader/master.svg?style=flat-square)](https://travis-ci.org/gajus/postloader) 5 | [![Coveralls](https://img.shields.io/coveralls/gajus/postloader.svg?style=flat-square)](https://coveralls.io/github/gajus/postloader) 6 | [![NPM version](http://img.shields.io/npm/v/postloader.svg?style=flat-square)](https://www.npmjs.org/package/postloader) 7 | [![Canonical Code Style](https://img.shields.io/badge/code%20style-canonical-blue.svg?style=flat-square)](https://github.com/gajus/canonical) 8 | [![Twitter Follow](https://img.shields.io/twitter/follow/kuizinas.svg?style=social&label=Follow)](https://twitter.com/kuizinas) 9 | 10 | A scaffolding tool for projects using [DataLoader](https://github.com/facebook/dataloader), [Flow](https://flow.org/) and [PostgreSQL](https://www.postgresql.org/). 11 | 12 | * [Motivation](#motivation) 13 | * [What makes this different from using an ORM?](#what-makes-this-different-from-using-an-orm) 14 | * [Behaviour](#behaviour) 15 | * [Unique key loader](#unique-key-loader) 16 | * [Non-unique `_id` loaders](#non-unique-id-loader) 17 | * [Non-unique joining table loader](#non-unique-joining-table-loader) 18 | * [Naming conventions](#naming-conventions) 19 | * [Type names](#type-names) 20 | * [Property names](#property-names) 21 | * [Loader names](#loader-names) 22 | * [Usage examples](#usage-examples) 23 | * [Generate DataLoader loaders for all database tables](#generate-dataloader-loaders-for-all-database-tables) 24 | * [Consume the generated code](#consume-the-generated-code) 25 | * [Handling non-nullable columns in materialized views](#handling-non-nullable-columns-in-materialized-views) 26 | 27 | ## Motivation 28 | 29 | Keeping database and codebase in sync is hard. Whenever changes are done to the database schema, these changes need to be reflected in the codebase's type declarations. 30 | 31 | Most of the loaders are needed to perform simple PK look ups, e.g. `UserByIdLoader`. Writing this logic for every table is a mundane task. 32 | 33 | PostLoader solves both of these problems by: 34 | 35 | 1. Creating type declarations for all database tables. 36 | 1. Creating loaders for the most common lookups. 37 | 38 | If you are interested to learn more, I have written an article on the subject: [I reduced GraphQL codebase size by 40% and increased type coverage to 90%+. Using code generation to create data loaders for all database resources.](https://medium.com/@gajus/i-reduced-graphql-codebase-size-by-40-and-increased-type-coverage-to-90-a2c87fdc78d3). 39 | 40 | ### What makes this different from using an ORM? 41 | 42 | 1. ORM is not going to give you strict types and code completion. 43 | 1. ORM has runtime overhead for constructing the queries and formatting the results. 44 | 45 | ## Behaviour 46 | 47 | PostLoader is a CLI program (and a collection of utilities) used to generate code based on a PostgreSQL database schema. 48 | 49 | The generated code consists of: 50 | 51 | 1. Flow type declarations describing every table in the database. 52 | 1. A factory function used to construct a collection of loaders. 53 | 54 | ### Unique key loader 55 | 56 | A loader is created for every column in a unique index ([unique indexes including multiple columns are not supported](https://github.com/gajus/postloader/issues/1)), e.g. `UserByIdLoader`. 57 | 58 | ### Non-unique `_id` loader 59 | 60 | A loader is created for every column that has name ending with `_id`. 61 | 62 | A non-unique loader is used to return multiple rows per lookup, e.g. `CitiesByCountryIdLoader`. The underlying data in this example comes from a table named "city". PostLoader is using [`pluralize`](https://www.npmjs.com/package/pluralize) module to pluralize the table name. 63 | 64 | ### Non-unique joining table loader 65 | 66 | A loader is created for every resource discoverable via a joining table. 67 | 68 | 1. A joining table consists of at least 2 columns that have names ending `_id`. 69 | 1. The table name is a concatenation of the column names (without `_id` suffix) (in alphabetical order, i.e. `genre_movie`, not `movie_genre`). 70 | 71 | #### Example 72 | 73 | Assume a many-to-many relationship of movies and genres: 74 | 75 | ```sql 76 | CREATE TABLE movie ( 77 | id integer NOT NULL, 78 | name text 79 | ); 80 | 81 | CREATE TABLE venue ( 82 | id integer NOT NULL, 83 | name text 84 | ); 85 | 86 | CREATE TABLE genre_movie ( 87 | id integer NOT NULL, 88 | genre_id integer NOT NULL, 89 | movie_id integer NOT NULL 90 | ); 91 | 92 | ``` 93 | 94 | Provided the above schema, PostLoader will create two non-unique loaders: 95 | 96 | * `MoviesByGenreIdLoader` 97 | * `GenresByMovieIdLoader` 98 | 99 | ## Naming conventions 100 | 101 | ### Type names 102 | 103 | Type names are created from table names. 104 | 105 | Table name is camel cased, the first letter is uppercased and suffixed with "RecordType", e.g. "movie_rating" becomes `MovieRatingRecordType`. 106 | 107 | ### Property names 108 | 109 | Property names of type declarations are derived from the respective table column names. 110 | 111 | Column names are camel cased, e.g. "first_name" becomes `firstName`. 112 | 113 | ### Loader names 114 | 115 | Loader names are created from table names and column names. 116 | 117 | Table name is camel cased, the first letter is uppercased, suffixed with "By" constant, followed by the name of the property (camel cased, the first letter is uppercased) used to load the resource, followed by "Loader" constant, e.g. a record from "user" table with "id" column can be loaded using `UserByIdLoader` loader. 118 | 119 | ## Usage examples 120 | 121 | ### Generate DataLoader loaders for all database tables 122 | 123 | ```bash 124 | export POSTLOADER_DATABASE_CONNECTION_URI=postgres://postgres:password@127.0.0.1/test 125 | export POSTLOADER_COLUMN_FILTER="return /* exclude tables that have a _view */ !columns.map(column => column.tableName).includes(tableName + '_view')" 126 | export POSTLOADER_TABLE_NAME_MAPPER="return tableName.endsWith('_view') ? tableName.slice(0, -5) : tableName;" 127 | export POSTLOADER_DATA_TYPE_MAP="{\"email\":\"text\"}" 128 | 129 | postloader generate-loaders > ./PostLoader.js 130 | 131 | ``` 132 | 133 | This generates a file containing a factory function used to construct a DataLoader for every table in the database and Flow type declarations in the following format: 134 | 135 | ```js 136 | // @flow 137 | 138 | import { 139 | getByIds, 140 | getByIdsUsingJoiningTable 141 | } from 'postloader'; 142 | import DataLoader from 'dataloader'; 143 | import type { 144 | DatabaseConnectionType 145 | } from 'slonik'; 146 | 147 | export type UserRecordType = {| 148 | +id: number, 149 | +email: string, 150 | +givenName: string | null, 151 | +familyName: string | null, 152 | +password: string, 153 | +createdAt: string, 154 | +updatedAt: string | null, 155 | +pseudonym: string 156 | |}; 157 | 158 | // [..] 159 | 160 | export type LoadersType = {| 161 | +UserByIdLoader: DataLoader, 162 | +UsersByAffiliateIdLoader: DataLoader>, 163 | // [..] 164 | |}; 165 | 166 | // [..] 167 | 168 | export const createLoaders = (connection: DatabaseConnectionType) => { 169 | const UserByIdLoader = new DataLoader((ids) => { 170 | return getByIds(connection, 'user', ids, 'id', '"id", "email", "given_name" "givenName", "family_name" "familyName", "password", "created_at" "createdAt", "updated_at" "updatedAt", "pseudonym"', false); 171 | }); 172 | const UsersByAffiliateIdLoader = new DataLoader((ids) => { 173 | return getByIdsUsingJoiningTable(connection, 'affiliate_user', 'user', 'user', 'affiliate', 'r2."id", r2."email", r2."given_name" "givenName", r2."family_name" "familyName", r2."password", r2."created_at" "createdAt", r2."updated_at" "updatedAt", r2."pseudonym"', ids); 174 | }); 175 | 176 | // [..] 177 | 178 | return { 179 | UserByIdLoader, 180 | UsersByAffiliateIdLoader, 181 | // [..] 182 | }; 183 | }; 184 | 185 | 186 | ``` 187 | 188 | Notice that the generated file depends on `postloader` package, i.e. you must install `postloader` as the main project dependency (as opposed to a development dependency). 189 | 190 | ### Consume the generated code 191 | 192 | 1. Dump the generated code to a file in your project tree, e.g. `/generated/PostLoader.js`. 193 | 1. Create PostgreSQL connection resource using [Slonik](https://github.com/gajus/slonik). 194 | 1. Import `createLoaders` factory function from the generated file. 195 | 1. Create the loaders collections. 196 | 1. Consume the loaders. 197 | 198 | Example: 199 | 200 | ```js 201 | // @flow 202 | 203 | import { 204 | createPool 205 | } from 'slonik'; 206 | import { 207 | createLoaders 208 | } from './generated/PostLoader'; 209 | import type { 210 | UserRecordType 211 | } from './generated/PostLoader'; 212 | 213 | const pool = createPool('postgres://'); 214 | 215 | const loaders = createLoaders(pool); 216 | 217 | const user = await loaders.UserByIdLoader.load(1); 218 | 219 | const updateUserPassword = (user: UserRecordType, newPassword: string) => { 220 | // [..] 221 | }; 222 | 223 | ``` 224 | 225 | You can optionally pass a second parameter to `createLoaders` – loader configuration map, e.g. 226 | 227 | ```js 228 | const loaders = createLoaders(connection, { 229 | UserByIdLoader: { 230 | cache: false 231 | } 232 | }); 233 | 234 | ``` 235 | 236 | ### Handling non-nullable columns in materialized views 237 | 238 | Unfortunately, PostgreSQL does not describe materilized view columns as non-nullable even when you add a constraint that enforce this contract ([see this Stack Overflow question](https://stackoverflow.com/q/47242219/368691)). 239 | 240 | For materialied views, you need to explicitly identify which collumns are non-nullable. This can be done by adding `POSTLOAD_NOTNULL` comment to the column, e.g. 241 | 242 | ```sql 243 | COMMENT ON COLUMN user.id IS 'POSTLOAD_NOTNULL'; 244 | COMMENT ON COLUMN user.email IS 'POSTLOAD_NOTNULL'; 245 | COMMENT ON COLUMN user.password IS 'POSTLOAD_NOTNULL'; 246 | COMMENT ON COLUMN user.created_at IS 'POSTLOAD_NOTNULL'; 247 | COMMENT ON COLUMN user.pseudonym IS 'POSTLOAD_NOTNULL'; 248 | 249 | ``` 250 | 251 | Alternatively, update the `pg_attribute.attnotnull` value of the target columns, e.g. 252 | 253 | ```sql 254 | CREATE OR REPLACE FUNCTION set_attribute_not_null(view_name TEXT, column_names TEXT[]) 255 | RETURNS void AS 256 | $$ 257 | BEGIN 258 | UPDATE pg_catalog.pg_attribute 259 | SET attnotnull = true 260 | WHERE attrelid IN ( 261 | SELECT 262 | pa1.attrelid 263 | FROM pg_class pc1 264 | INNER JOIN pg_namespace pn1 ON pn1.oid = pc1.relnamespace 265 | INNER JOIN pg_attribute pa1 ON pa1.attrelid = pc1.oid AND pa1.attnum > 0 AND NOT pa1.attisdropped 266 | WHERE 267 | pn1.nspname = 'public' AND 268 | pc1.relkind = 'm' AND 269 | pc1.relname = view_name AND 270 | pa1.attname = ANY(column_names) 271 | ); 272 | END; 273 | $$ language 'plpgsql'; 274 | 275 | set_attribute_not_null('person_view', ARRAY['id', 'imdb_id', 'tmdb_id', 'headshot_image_name', 'name']); 276 | 277 | ``` 278 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "email": "gajus@gajus.com", 4 | "name": "Gajus Kuizinas", 5 | "url": "http://gajus.com" 6 | }, 7 | "ava": { 8 | "babel": { 9 | "compileAsTests": [ 10 | "test/helpers/**/*" 11 | ] 12 | }, 13 | "files": [ 14 | "test/postloader/**/*" 15 | ], 16 | "require": [ 17 | "@babel/register" 18 | ] 19 | }, 20 | "bin": "./dist/bin/index.js", 21 | "dependencies": { 22 | "es6-error": "^4.1.1", 23 | "lodash": "^4.17.15", 24 | "pluralize": "^8.0.0", 25 | "roarr": "^2.15.3", 26 | "slonik": "^22.4.11", 27 | "slonik-sql-tag-raw": "^1.0.2", 28 | "yargs": "^15.3.1" 29 | }, 30 | "description": "A scaffolding tool for projects using DataLoader, Flow and PostgreSQL.", 31 | "devDependencies": { 32 | "@ava/babel": "^1.0.1", 33 | "@babel/cli": "^7.10.1", 34 | "@babel/core": "^7.10.2", 35 | "@babel/node": "^7.10.1", 36 | "@babel/plugin-transform-flow-strip-types": "^7.10.1", 37 | "@babel/preset-env": "^7.10.2", 38 | "@babel/register": "^7.10.1", 39 | "ava": "^3.9.0", 40 | "babel-plugin-istanbul": "^6.0.0", 41 | "babel-plugin-macros": "^2.8.0", 42 | "coveralls": "^3.1.0", 43 | "eslint": "^7.2.0", 44 | "eslint-config-canonical": "^20.0.6", 45 | "flow-bin": "^0.127.0", 46 | "flow-copy-source": "^2.0.9", 47 | "husky": "^4.2.5", 48 | "inline-loops.macro": "^1.2.2", 49 | "nyc": "^15.1.0", 50 | "semantic-release": "^17.0.8" 51 | }, 52 | "engines": { 53 | "node": ">=10" 54 | }, 55 | "husky": { 56 | "hooks": { 57 | "pre-commit": "npm run test && npm run lint && npm run build" 58 | } 59 | }, 60 | "keywords": [ 61 | "flowtype", 62 | "postgres" 63 | ], 64 | "license": "BSD-3-Clause", 65 | "main": "./dist/index.js", 66 | "name": "postloader", 67 | "nyc": { 68 | "all": true, 69 | "exclude": [ 70 | "src/bin", 71 | "src/queries" 72 | ], 73 | "include": [ 74 | "src/**/*.js" 75 | ], 76 | "instrument": false, 77 | "reporter": [ 78 | "html", 79 | "text-summary" 80 | ], 81 | "require": [ 82 | "@babel/register" 83 | ], 84 | "silent": true, 85 | "sourceMap": false 86 | }, 87 | "repository": { 88 | "type": "git", 89 | "url": "https://github.com/gajus/postloader" 90 | }, 91 | "scripts": { 92 | "build": "rm -fr ./dist && NODE_ENV=production babel ./src --out-dir ./dist --copy-files --source-maps && flow-copy-source src dist", 93 | "dev": "NODE_ENV=development babel ./src --out-dir ./dist --copy-files --source-maps --watch", 94 | "lint": "eslint ./src && flow", 95 | "test": "NODE_ENV=test nyc ava --verbose --serial && nyc report" 96 | }, 97 | "version": "1.0.0" 98 | } 99 | -------------------------------------------------------------------------------- /src/Logger.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import Logger from 'roarr'; 4 | 5 | export default Logger.child({ 6 | package: 'postloader', 7 | }); 8 | -------------------------------------------------------------------------------- /src/bin/commands/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "filenames/match-regex": 0 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/bin/commands/generate-loaders.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | createPool, 5 | } from 'slonik'; 6 | import type { 7 | ColumnType, 8 | DataTypeMapType, 9 | } from '../../types'; 10 | import { 11 | getDatabaseColumns, 12 | getDatabaseIndexes, 13 | } from '../../queries'; 14 | import { 15 | generateDataLoaderFactory, 16 | } from '../../utilities'; 17 | 18 | type ArgvType = {| 19 | +columnFilter?: string, 20 | +dataTypeMap?: string, 21 | +databaseConnectionUri: string, 22 | +tableNameMapper?: string, 23 | |}; 24 | 25 | type ColumnFilterType = (tableName: string, columnName: string, columns: $ReadOnlyArray) => boolean; 26 | type TableNameMapperType = (tableName: string, columns: $ReadOnlyArray) => string; 27 | 28 | export const command = 'generate-loaders'; 29 | export const desc = ''; 30 | 31 | export const builder = (yargs: Object): void => { 32 | yargs 33 | .options({ 34 | 'column-filter': { 35 | description: 'Function used to filter columns. Function is constructed using `new Function`. Function receives table name as the first parameter, column name as the second parameter and all database columns as the third parameter (parameter names are "tableName", "columnName" and "columns").', 36 | type: 'string', 37 | }, 38 | 'data-type-map': { 39 | description: 'A JSON string describing an object mapping user-defined database types to Flow types, e.g. {"email": "string"}', 40 | type: 'string', 41 | }, 42 | 'database-connection-uri': { 43 | demand: true, 44 | }, 45 | 'table-name-mapper': { 46 | description: 'Function used to map table names. Function is constructed using `new Function`. Function receives table name as the first parameter and all database columns as the second parameter (parameter names are "tableName" and "columns").', 47 | type: 'string', 48 | }, 49 | }); 50 | }; 51 | 52 | export const handler = async (argv: ArgvType): Promise => { 53 | // eslint-disable-next-line no-extra-parens, no-new-func 54 | const filterColumn: ColumnFilterType = (argv.columnFilter ? new Function('tableName', 'columnName', 'columns', argv.columnFilter) : null: any); 55 | 56 | // eslint-disable-next-line no-extra-parens, no-new-func 57 | const mapTableName: TableNameMapperType = (argv.tableNameMapper ? new Function('tableName', 'columns', argv.tableNameMapper) : null: any); 58 | 59 | const dataTypeMap: DataTypeMapType = argv.dataTypeMap ? JSON.parse(argv.dataTypeMap) : {}; 60 | 61 | const pool = await createPool(argv.databaseConnectionUri); 62 | 63 | const columns = await getDatabaseColumns(pool); 64 | 65 | const normalizedColumns = columns 66 | .filter((column) => { 67 | if (!filterColumn) { 68 | return true; 69 | } 70 | 71 | return filterColumn(column.tableName, column.name, columns); 72 | }) 73 | .map((column) => { 74 | return { 75 | ...column, 76 | isNullable: column.comment && column.comment.includes('POSTLOAD_NOTNULL') ? false : column.isNullable, 77 | }; 78 | }) 79 | .map((column) => { 80 | if (!mapTableName) { 81 | return column; 82 | } 83 | 84 | return { 85 | ...column, 86 | mappedTableName: mapTableName(column.tableName, columns), 87 | }; 88 | }); 89 | 90 | const indexes = await getDatabaseIndexes(pool); 91 | 92 | // eslint-disable-next-line no-console 93 | console.log(generateDataLoaderFactory(normalizedColumns, indexes, dataTypeMap)); 94 | }; 95 | -------------------------------------------------------------------------------- /src/bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import yargs from 'yargs'; 4 | 5 | process.on('unhandledRejection', (reason) => { 6 | // eslint-disable-next-line no-console 7 | console.error(reason); 8 | 9 | // eslint-disable-next-line no-process-exit 10 | process.exit(); 11 | }); 12 | 13 | process.on('uncaughtException', (error) => { 14 | // eslint-disable-next-line no-console 15 | console.error(error); 16 | 17 | // eslint-disable-next-line no-process-exit 18 | process.exit(); 19 | }); 20 | 21 | yargs 22 | .env('POSTLOADER') 23 | .commandDir('commands') 24 | .help() 25 | .wrap(80) 26 | .parse(); 27 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /* eslint-disable fp/no-class, fp/no-this */ 4 | 5 | import ExtendableError from 'es6-error'; 6 | 7 | export class PostLoaderError extends ExtendableError {} 8 | 9 | export class NotFoundError extends PostLoaderError {} 10 | 11 | export class UnexpectedStateError extends PostLoaderError {} 12 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export { 4 | getByIds, 5 | getByIdsUsingJoiningTable, 6 | } from './routines'; 7 | export { 8 | PostLoaderError, 9 | NotFoundError, 10 | } from './errors'; 11 | -------------------------------------------------------------------------------- /src/queries/getDatabaseColumns.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | sql, 5 | } from 'slonik'; 6 | import type { 7 | ColumnType, 8 | DatabaseConnectionType, 9 | } from '../types'; 10 | 11 | export default async (connection: DatabaseConnectionType): Promise<$ReadOnlyArray> => { 12 | return connection.any(sql` 13 | SELECT 14 | pc1.relname "tableName", 15 | pa1.attname "name", 16 | pd1.description "comment", 17 | pg_catalog.format_type (pa1.atttypid, NULL) "dataType", 18 | pc1.relkind = 'm' "isMaterializedView", 19 | NOT(pa1.attnotnull) "isNullable" 20 | FROM pg_class pc1 21 | INNER JOIN pg_namespace pn1 ON pn1.oid = pc1.relnamespace 22 | INNER JOIN 23 | pg_attribute pa1 ON pa1.attrelid = pc1.oid 24 | AND pa1.attnum > 0 25 | AND NOT pa1.attisdropped 26 | LEFT JOIN pg_description pd1 ON pd1.objoid = pa1.attrelid AND pd1.objsubid = pa1.attnum 27 | WHERE 28 | pn1.nspname = 'public' AND 29 | pc1.relkind IN ('r', 'm') 30 | ORDER BY 31 | pc1.relname ASC, 32 | pa1.attnum ASC 33 | `); 34 | }; 35 | -------------------------------------------------------------------------------- /src/queries/getDatabaseIndexes.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | sql, 5 | } from 'slonik'; 6 | import type { 7 | DatabaseConnectionType, 8 | IndexType, 9 | } from '../types'; 10 | 11 | export default async (connection: DatabaseConnectionType): Promise<$ReadOnlyArray> => { 12 | return connection.any(sql` 13 | SELECT 14 | c1.relname "tableName", 15 | c2.relname "indexName", 16 | i1.indisunique "indexIsUnique", 17 | array_agg(a1.attname)::text[] "columnNames" 18 | FROM 19 | pg_class c1, 20 | pg_class c2, 21 | pg_index i1, 22 | pg_attribute a1 23 | WHERE 24 | c1.oid = i1.indrelid 25 | AND c2.oid = i1.indexrelid 26 | AND a1.attrelid = c1.oid 27 | AND a1.attnum = ANY(i1.indkey) 28 | AND c1.relkind IN ('r', 'm') 29 | GROUP BY 30 | c1.relname, 31 | c2.relname, 32 | i1.indisunique 33 | ORDER BY 34 | c1.relname, 35 | c2.relname 36 | `); 37 | }; 38 | -------------------------------------------------------------------------------- /src/queries/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export {default as getDatabaseColumns} from './getDatabaseColumns'; 4 | export {default as getDatabaseIndexes} from './getDatabaseIndexes'; 5 | -------------------------------------------------------------------------------- /src/routines/getByIds.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | sql, 5 | } from 'slonik'; 6 | import { 7 | raw, 8 | } from 'slonik-sql-tag-raw'; 9 | import { 10 | camelCase, 11 | } from 'lodash'; 12 | import { 13 | filter, 14 | find, 15 | } from 'inline-loops.macro'; 16 | import Logger from '../Logger'; 17 | import { 18 | NotFoundError, 19 | } from '../errors'; 20 | import type { 21 | DatabaseConnectionType, 22 | } from '../types'; 23 | 24 | const log = Logger.child({ 25 | namespace: 'getByIds', 26 | }); 27 | 28 | export default async ( 29 | connection: DatabaseConnectionType, 30 | tableName: string, 31 | ids: $ReadOnlyArray, 32 | idName: string = 'id', 33 | identifiers: string, 34 | resultIsArray: boolean, 35 | ): Promise<$ReadOnlyArray> => { 36 | let rows = []; 37 | 38 | if (ids.length > 0) { 39 | const idType = typeof ids[0] === 'number' ? 'int4' : 'text'; 40 | 41 | // @todo Do not use slonik-sql-tag-raw. 42 | 43 | rows = await connection.any(sql` 44 | SELECT ${raw(identifiers)} 45 | FROM ${sql.identifier([tableName])} 46 | WHERE ${sql.identifier([idName])} = ANY(${sql.array(ids, idType)}) 47 | `); 48 | } 49 | 50 | const results = []; 51 | 52 | const targetPropertyName = camelCase(idName); 53 | 54 | if (resultIsArray) { 55 | for (const id of ids) { 56 | const result = filter(rows, (row) => { 57 | return row[targetPropertyName] === id; 58 | }); 59 | 60 | results.push(result); 61 | } 62 | } else { 63 | for (const id of ids) { 64 | let result = find(rows, (row) => { 65 | return row[targetPropertyName] === id; 66 | }); 67 | 68 | if (!result) { 69 | log.warn({ 70 | id, 71 | idName, 72 | tableName, 73 | }, 'resource not found'); 74 | 75 | result = new NotFoundError(); 76 | } 77 | 78 | results.push(result); 79 | } 80 | } 81 | 82 | return results; 83 | }; 84 | -------------------------------------------------------------------------------- /src/routines/getByIdsUsingJoiningTable.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | sql, 5 | } from 'slonik'; 6 | import { 7 | raw, 8 | } from 'slonik-sql-tag-raw'; 9 | import { 10 | filter, 11 | } from 'inline-loops.macro'; 12 | import type { 13 | DatabaseConnectionType, 14 | } from '../types'; 15 | 16 | export default async ( 17 | connection: DatabaseConnectionType, 18 | joiningTableName: string, 19 | targetResourceTableName: string, 20 | joiningKeyName: string, 21 | lookupKeyName: string, 22 | identifiers: string, 23 | ids: $ReadOnlyArray, 24 | ): Promise<$ReadOnlyArray> => { 25 | let rows = []; 26 | 27 | // @todo Do not use slonik-sql-tag-raw. 28 | 29 | if (ids.length > 0) { 30 | rows = await connection.any(sql` 31 | SELECT 32 | ${sql.identifier(['r1', lookupKeyName + '_id'])} "POSTLOADER_LOOKUP_KEY", 33 | ${raw(identifiers)} 34 | FROM ${sql.identifier([joiningTableName])} r1 35 | INNER JOIN ${sql.identifier([targetResourceTableName])} r2 ON r2.id = ${sql.identifier(['r1', joiningKeyName + '_id'])} 36 | WHERE ${sql.identifier(['r1', lookupKeyName + '_id'])} = ANY(${sql.array(ids, 'int4')}) 37 | `); 38 | } 39 | 40 | const results = []; 41 | 42 | for (const id of ids) { 43 | const result = filter(rows, (row) => { 44 | return row.POSTLOADER_LOOKUP_KEY === id; 45 | }); 46 | 47 | results.push(result); 48 | } 49 | 50 | return results; 51 | }; 52 | -------------------------------------------------------------------------------- /src/routines/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export {default as getByIds} from './getByIds'; 4 | export {default as getByIdsUsingJoiningTable} from './getByIdsUsingJoiningTable'; 5 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export type { 4 | DatabaseConnectionType, 5 | } from 'slonik'; 6 | 7 | export type DataTypeMapType = { 8 | +[key: string]: string, 9 | ... 10 | }; 11 | 12 | export type ColumnType = {| 13 | +comment: string | null, 14 | +dataType: string, 15 | +isMaterializedView: boolean, 16 | +isNullable: boolean, 17 | +mappedTableName?: string, 18 | +name: string, 19 | +tableName: string, 20 | |}; 21 | 22 | export type IndexType = {| 23 | +columnNames: $ReadOnlyArray, 24 | +indexIsUnique: boolean, 25 | +indexName: string, 26 | +tableName: string, 27 | |}; 28 | -------------------------------------------------------------------------------- /src/utilities/createColumnSelector.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | camelCase, 5 | } from 'lodash'; 6 | import type { 7 | ColumnType, 8 | } from '../types'; 9 | 10 | export default (columns: $ReadOnlyArray, alias: ?string): string => { 11 | const selectorAlias = alias ? alias + '.' : ''; 12 | 13 | return columns 14 | .map((column) => { 15 | const normalizedColumnName = camelCase(column.name); 16 | 17 | return column.name === normalizedColumnName ? selectorAlias + '"' + normalizedColumnName + '"' : selectorAlias + '"' + column.name + '" "' + normalizedColumnName + '"'; 18 | }) 19 | .join(', '); 20 | }; 21 | -------------------------------------------------------------------------------- /src/utilities/createLoaderTypePropertyDeclaration.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import Logger from '../Logger'; 4 | import formatTypeName from './formatTypeName'; 5 | import isNumberType from './isNumberType'; 6 | import isStringType from './isStringType'; 7 | 8 | const log = Logger.child({ 9 | namespace: 'createLoaderTypePropertyDeclaration', 10 | }); 11 | 12 | export default (loaderName: string, dataTypeName: string, resourceName: string, resultIsArray: boolean) => { 13 | let keyType: 'number' | 'string'; 14 | 15 | if (isNumberType(dataTypeName)) { 16 | keyType = 'number'; 17 | } else if (isStringType(dataTypeName)) { 18 | keyType = 'string'; 19 | } else { 20 | log.error({ 21 | dataTypeName, 22 | loaderName, 23 | resourceName, 24 | resultIsArray, 25 | }, 'key type cannot be resolved to a string or number'); 26 | 27 | throw new Error('Cannot resolve key type.'); 28 | } 29 | 30 | if (resultIsArray) { 31 | return '+' + loaderName + ': DataLoader<' + keyType + ', $ReadOnlyArray<' + formatTypeName(resourceName) + '>>'; 32 | } else { 33 | return '+' + loaderName + ': DataLoader<' + keyType + ', ' + formatTypeName(resourceName) + '>'; 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/utilities/formatPropertyName.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | camelCase, 5 | } from 'lodash'; 6 | 7 | export default (columnName: string): string => { 8 | return camelCase(columnName); 9 | }; 10 | -------------------------------------------------------------------------------- /src/utilities/formatTypeName.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | camelCase, 5 | upperFirst, 6 | } from 'lodash'; 7 | 8 | export default (tableName: string): string => { 9 | return upperFirst(camelCase(tableName)) + 'RecordType'; 10 | }; 11 | -------------------------------------------------------------------------------- /src/utilities/generateDataLoaderFactory.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import pluralize from 'pluralize'; 4 | import { 5 | camelCase, 6 | upperFirst, 7 | } from 'lodash'; 8 | import type { 9 | ColumnType, 10 | DataTypeMapType, 11 | IndexType, 12 | } from '../types'; 13 | import { 14 | UnexpectedStateError, 15 | } from '../errors'; 16 | import Logger from '../Logger'; 17 | import generateFlowTypeDocument from './generateFlowTypeDocument'; 18 | import indent from './indent'; 19 | import isJoiningTable from './isJoiningTable'; 20 | import createColumnSelector from './createColumnSelector'; 21 | import createLoaderTypePropertyDeclaration from './createLoaderTypePropertyDeclaration'; 22 | import pluralizeTableName from './pluralizeTableName'; 23 | 24 | const log = Logger.child({ 25 | namespace: 'generateDataLoaderFactory', 26 | }); 27 | 28 | const createLoaderByIdsDeclaration = (loaderName: string, tableName: string, keyColumnName, columnSelector: string, resultIsArray: boolean) => { 29 | return `const ${loaderName} = new DataLoader((ids) => { 30 | return getByIds(connection, '${tableName}', ids, '${keyColumnName}', '${columnSelector}', ${String(resultIsArray)}); 31 | }, dataLoaderConfigurationMap.${loaderName});`; 32 | }; 33 | 34 | const createLoaderByIdsUsingJoiningTableDeclaration = ( 35 | loaderName: string, 36 | joiningTableName: string, 37 | targetResourceTableName: string, 38 | joiningKeyName: string, 39 | lookupKeyName: string, 40 | columnSelector: string, 41 | ) => { 42 | return `const ${loaderName} = new DataLoader((ids) => { 43 | return getByIdsUsingJoiningTable(connection, '${joiningTableName}', '${targetResourceTableName}', '${joiningKeyName}', '${lookupKeyName}', '${columnSelector}', ids); 44 | }, dataLoaderConfigurationMap.${loaderName});`; 45 | }; 46 | 47 | // eslint-disable-next-line complexity 48 | export default ( 49 | unnormalisedColumns: $ReadOnlyArray, 50 | indexes: $ReadOnlyArray, 51 | dataTypeMap: DataTypeMapType, 52 | ): string => { 53 | const columns = unnormalisedColumns 54 | .map((column) => { 55 | return { 56 | ...column, 57 | mappedTableName: column.mappedTableName || column.tableName, 58 | }; 59 | }); 60 | 61 | if (columns.length === 0) { 62 | throw new UnexpectedStateError('Must know multiple columns.'); 63 | } 64 | 65 | const tableNames = columns 66 | .map((column) => { 67 | return column.tableName; 68 | }) 69 | .filter((tableName, index, self) => { 70 | return self.indexOf(tableName) === index; 71 | }); 72 | 73 | const loaders = []; 74 | const loaderNames = []; 75 | const loaderTypes = []; 76 | 77 | for (const tableName of tableNames) { 78 | const tableColumns = columns.filter((column) => { 79 | return column.tableName === tableName; 80 | }); 81 | 82 | if (tableColumns.length === 0) { 83 | continue; 84 | } 85 | 86 | const mappedTableName = tableColumns[0].mappedTableName; 87 | 88 | const resouceName = upperFirst(camelCase(mappedTableName)); 89 | 90 | for (const tableColumn of tableColumns) { 91 | const tableColumnSelector = createColumnSelector(tableColumns); 92 | 93 | if (tableColumn.name.endsWith('_id')) { 94 | const loaderName = pluralize(resouceName) + 'By' + upperFirst(camelCase(tableColumn.name)) + 'Loader'; 95 | 96 | loaders.push(createLoaderByIdsDeclaration(loaderName, tableName, tableColumn.name, tableColumnSelector, true)); 97 | 98 | const loaderType = createLoaderTypePropertyDeclaration( 99 | loaderName, 100 | dataTypeMap[tableColumn.dataType] ? dataTypeMap[tableColumn.dataType] : tableColumn.dataType, 101 | tableColumn.mappedTableName, 102 | true, 103 | ); 104 | 105 | loaderTypes.push(loaderType); 106 | 107 | loaderNames.push(loaderName); 108 | } 109 | } 110 | 111 | const tableUniqueIndexes = indexes.filter((index) => { 112 | return index.tableName === tableName && index.indexIsUnique === true && index.columnNames.length === 1; 113 | }); 114 | 115 | for (const tableUniqueIndex of tableUniqueIndexes) { 116 | const tableColumnSelector = createColumnSelector(tableColumns); 117 | 118 | const maybeIndexColumnName = tableUniqueIndex.columnNames[0]; 119 | 120 | if (!maybeIndexColumnName) { 121 | throw new Error('Unexpected state.'); 122 | } 123 | 124 | const indexColumn = tableColumns.find((column) => { 125 | return column.name === maybeIndexColumnName; 126 | }); 127 | 128 | if (!indexColumn) { 129 | throw new Error('Unexpected state.'); 130 | } 131 | 132 | const loaderName = resouceName + 'By' + upperFirst(camelCase(indexColumn.name)) + 'Loader'; 133 | 134 | loaders.push(createLoaderByIdsDeclaration(loaderName, tableName, indexColumn.name, tableColumnSelector, false)); 135 | 136 | const loaderType = createLoaderTypePropertyDeclaration( 137 | loaderName, 138 | dataTypeMap[indexColumn.dataType] ? dataTypeMap[indexColumn.dataType] : indexColumn.dataType, 139 | indexColumn.mappedTableName, 140 | false, 141 | ); 142 | 143 | loaderTypes.push(loaderType); 144 | 145 | loaderNames.push(loaderName); 146 | } 147 | } 148 | 149 | for (const tableName of tableNames) { 150 | const tableColumns = columns.filter((column) => { 151 | return column.tableName === tableName; 152 | }); 153 | 154 | if (tableColumns.length === 0) { 155 | continue; 156 | } 157 | 158 | if (!isJoiningTable(tableName, tableColumns)) { 159 | continue; 160 | } 161 | 162 | const firstIdColumnNames = tableColumns 163 | .map((column) => { 164 | return column.name; 165 | }) 166 | .filter((columnName) => { 167 | return columnName.endsWith('_id'); 168 | }) 169 | .map((columnName) => { 170 | return columnName.slice(0, -3); 171 | }) 172 | .slice(0, 2); 173 | 174 | if (firstIdColumnNames.length < 2) { 175 | throw new Error('Unexpected state.'); 176 | } 177 | 178 | const relations = [ 179 | { 180 | key: firstIdColumnNames[0], 181 | resource: firstIdColumnNames[1], 182 | }, 183 | { 184 | key: firstIdColumnNames[1], 185 | resource: firstIdColumnNames[0], 186 | }, 187 | ]; 188 | 189 | for (const relation of relations) { 190 | const loaderName = upperFirst(camelCase(pluralizeTableName(relation.resource))) + 'By' + upperFirst(camelCase(relation.key + '_id')) + 'Loader'; 191 | 192 | if (loaderNames.includes(loaderName)) { 193 | continue; 194 | } 195 | 196 | const resourceTableColumns = columns.filter((column) => { 197 | return column.mappedTableName === relation.resource; 198 | }); 199 | 200 | if (!resourceTableColumns.length) { 201 | log.warn({ 202 | relation, 203 | }, 'resource without columns'); 204 | 205 | continue; 206 | } 207 | 208 | const realResourceTableName = resourceTableColumns[0].tableName; 209 | 210 | const tableColumnSelector = createColumnSelector(resourceTableColumns, 'r2'); 211 | 212 | loaders.push(createLoaderByIdsUsingJoiningTableDeclaration(loaderName, tableName, realResourceTableName, relation.resource, relation.key, tableColumnSelector)); 213 | 214 | const keyColumn = tableColumns.find((column) => { 215 | return column.name === relation.key + '_id'; 216 | }); 217 | 218 | if (!keyColumn) { 219 | throw new Error('Unexpected state.'); 220 | } 221 | 222 | const loaderType = createLoaderTypePropertyDeclaration( 223 | loaderName, 224 | dataTypeMap[keyColumn.dataType] ? dataTypeMap[keyColumn.dataType] : keyColumn.dataType, 225 | relation.resource, 226 | true, 227 | ); 228 | 229 | loaderTypes.push(loaderType); 230 | 231 | loaderNames.push(loaderName); 232 | } 233 | } 234 | 235 | loaderTypes.sort((a, b) => { 236 | return a.localeCompare(b); 237 | }); 238 | 239 | loaderNames.sort((a, b) => { 240 | return a.localeCompare(b); 241 | }); 242 | 243 | return `// @flow 244 | 245 | import { 246 | getByIds, 247 | getByIdsUsingJoiningTable 248 | } from 'postloader'; 249 | import DataLoader from 'dataloader'; 250 | import type { 251 | DatabaseConnectionType 252 | } from 'slonik'; 253 | ${generateFlowTypeDocument(columns, dataTypeMap)} 254 | 255 | export type LoadersType = {| 256 | ${loaderTypes.map((body) => { 257 | return indent(body, 2); 258 | }).sort().join(',\n')} 259 | |}; 260 | 261 | export const createLoaders = (connection: $Shape, dataLoaderConfigurationMap: Object = {}): LoadersType => { 262 | ${loaders 263 | .map((body) => { 264 | return indent(body, 2); 265 | }) 266 | .join('\n')} 267 | 268 | return { 269 | ${loaderNames 270 | .map((body) => { 271 | return indent(body, 4); 272 | }) 273 | .join(',\n')} 274 | }; 275 | };`; 276 | }; 277 | -------------------------------------------------------------------------------- /src/utilities/generateFlowTypeDocument.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | sortBy, 5 | } from 'lodash'; 6 | import type { 7 | ColumnType, 8 | DataTypeMapType, 9 | } from '../types'; 10 | import getFlowType from './getFlowType'; 11 | import formatTypeName from './formatTypeName'; 12 | import formatPropertyName from './formatPropertyName'; 13 | 14 | const generateFlowTypeDeclarationBody = (columns: $ReadOnlyArray, dataTypeMap: DataTypeMapType = {}): string => { 15 | const sortedColumns = sortBy(columns, 'column_name'); 16 | 17 | const propertyDeclarations = []; 18 | 19 | for (const column of sortedColumns) { 20 | const propertyDataType = dataTypeMap[column.dataType] ? getFlowType(dataTypeMap[column.dataType]) : getFlowType(column.dataType); 21 | 22 | propertyDeclarations.push('+' + formatPropertyName(column.name) + ': ' + propertyDataType + (column.isNullable ? ' | null' : '')); 23 | } 24 | 25 | return propertyDeclarations.join('\n'); 26 | }; 27 | 28 | export default (unnormalisedColumns: $ReadOnlyArray, dataTypeMap: DataTypeMapType = {}): string => { 29 | const columns = unnormalisedColumns 30 | .map((column) => { 31 | return { 32 | ...column, 33 | mappedTableName: column.mappedTableName || column.tableName, 34 | }; 35 | }); 36 | 37 | const tableNames = columns 38 | .map((column) => { 39 | return column.mappedTableName || column.tableName; 40 | }) 41 | .filter((tableName, index, self) => { 42 | return self.indexOf(tableName) === index; 43 | }); 44 | 45 | const typeDeclarations = []; 46 | 47 | for (const tableName of tableNames) { 48 | const tableColumns = columns.filter((column) => { 49 | return column.mappedTableName === tableName; 50 | }); 51 | 52 | const typeName = formatTypeName(tableName); 53 | 54 | const typeDeclaration = ` 55 | type ${typeName} = {| 56 | ${generateFlowTypeDeclarationBody(tableColumns, dataTypeMap).split('\n').sort().join(',\n ')} 57 | |};`; 58 | 59 | typeDeclarations.push(typeDeclaration); 60 | } 61 | 62 | const exportedTypes = tableNames.map((tableName) => { 63 | return formatTypeName(tableName); 64 | }) 65 | .sort() 66 | .join(',\n '); 67 | 68 | return typeDeclarations.join('\n') + `\n 69 | export type { 70 | ${exportedTypes} 71 | };`; 72 | }; 73 | -------------------------------------------------------------------------------- /src/utilities/getFlowType.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import Logger from '../Logger'; 4 | import isNumberType from './isNumberType'; 5 | import isStringType from './isStringType'; 6 | 7 | const log = Logger.child({ 8 | namespace: 'getFlowType', 9 | }); 10 | 11 | export default (databaseTypeName: string): string => { 12 | if (databaseTypeName === 'json') { 13 | return 'Object'; 14 | } 15 | 16 | if (databaseTypeName === 'boolean') { 17 | return 'boolean'; 18 | } 19 | 20 | if (isStringType(databaseTypeName)) { 21 | return 'string'; 22 | } 23 | 24 | if (isNumberType(databaseTypeName)) { 25 | return 'number'; 26 | } 27 | 28 | if (databaseTypeName === 'json' || databaseTypeName === 'jsonb') { 29 | return 'Object'; 30 | } 31 | 32 | log.warn({ 33 | databaseTypeName, 34 | }, 'unknown type'); 35 | 36 | return 'any'; 37 | }; 38 | -------------------------------------------------------------------------------- /src/utilities/indent.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export default (message: string, width: number) => { 4 | return message 5 | .split('\n') 6 | .map((line) => { 7 | return ' '.repeat(width) + line; 8 | }) 9 | .join('\n') 10 | .replace(/\s+^/g, ''); 11 | }; 12 | -------------------------------------------------------------------------------- /src/utilities/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export {default as generateDataLoaderFactory} from './generateDataLoaderFactory'; 4 | export {default as generateFlowTypeDocument} from './generateFlowTypeDocument'; 5 | export {default as getFlowType} from './getFlowType'; 6 | export {default as indent} from './indent'; 7 | export {default as isNumberType} from './isNumberType'; 8 | export {default as isJoiningTable} from './isJoiningTable'; 9 | -------------------------------------------------------------------------------- /src/utilities/isJoiningTable.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { 4 | ColumnType, 5 | } from '../types'; 6 | 7 | export default (tableName: string, columns: $ReadOnlyArray): boolean => { 8 | const firstIdColumnNames = columns 9 | .map((column) => { 10 | return column.name; 11 | }) 12 | .filter((columnName) => { 13 | return columnName.endsWith('_id'); 14 | }) 15 | .map((columnName) => { 16 | return columnName.slice(0, -3); 17 | }) 18 | .slice(0, 2) 19 | .sort(); 20 | 21 | if (firstIdColumnNames.length < 2) { 22 | return false; 23 | } 24 | 25 | return firstIdColumnNames.join('_') === tableName; 26 | }; 27 | -------------------------------------------------------------------------------- /src/utilities/isNumberType.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /** 4 | * @see https://www.postgresql.org/docs/current/static/datatype-numeric.html 5 | */ 6 | const numericTypes = [ 7 | 'bigint', 8 | 'bigserial', 9 | 'decimal', 10 | 'double precision', 11 | 'integer', 12 | 'numeric', 13 | 'real', 14 | 'serial', 15 | 'smallint', 16 | 'timestamp with time zone', 17 | 'timestamp', 18 | ]; 19 | 20 | export default (databaseTypeName: string): boolean => { 21 | return numericTypes.includes(databaseTypeName); 22 | }; 23 | -------------------------------------------------------------------------------- /src/utilities/isStringType.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /** 4 | * @see https://www.postgresql.org/docs/current/static/datatype-numeric.html 5 | */ 6 | const characterTypeRule = /^(?:text|character|coordinates|uuid)(\s|$)/; 7 | 8 | export default (databaseTypeName: string): boolean => { 9 | return characterTypeRule.test(databaseTypeName); 10 | }; 11 | -------------------------------------------------------------------------------- /src/utilities/pluralizeTableName.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import pluralize from 'pluralize'; 4 | 5 | export default (tableName: string): string => { 6 | return pluralize(tableName.split('_').join(' ')).split(' ').join('_'); 7 | }; 8 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "canonical/ava", 3 | "rules": { 4 | "filenames/match-regex": 0, 5 | "id-length": 0 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/helpers/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { 4 | ColumnType, 5 | } from '../../src/types'; 6 | 7 | export const createColumn = (columns: any): ColumnType => { 8 | return columns; 9 | }; 10 | 11 | export const createColumnWithName = (columnName: string): ColumnType => { 12 | return createColumn({ 13 | name: columnName, 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /test/postloader/utilities/createColumnSelector.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import test from 'ava'; 4 | import createColumnSelector from '../../../src/utilities/createColumnSelector'; 5 | import { 6 | createColumnWithName, 7 | } from '../../helpers'; 8 | 9 | test('creates multiple column selector', (t) => { 10 | const columnSelector = createColumnSelector([ 11 | createColumnWithName('foo'), 12 | createColumnWithName('bar_baz'), 13 | ]); 14 | 15 | t.is(columnSelector, '"foo", "bar_baz" "barBaz"'); 16 | }); 17 | 18 | test('creates multiple column selector using an alias', (t) => { 19 | const columnSelector = createColumnSelector([ 20 | createColumnWithName('foo'), 21 | createColumnWithName('bar_baz'), 22 | ], 't1'); 23 | 24 | t.is(columnSelector, 't1."foo", t1."bar_baz" "barBaz"'); 25 | }); 26 | -------------------------------------------------------------------------------- /test/postloader/utilities/createLoaderTypePropertyDeclaration.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import test from 'ava'; 4 | import createLoaderTypePropertyDeclaration from '../../../src/utilities/createLoaderTypePropertyDeclaration'; 5 | 6 | test('generates loader type property declaration', (t) => { 7 | t.is(createLoaderTypePropertyDeclaration('FooLoader', 'text', 'foo', false), '+FooLoader: DataLoader'); 8 | t.is(createLoaderTypePropertyDeclaration('FooLoader', 'integer', 'foo', false), '+FooLoader: DataLoader'); 9 | }); 10 | 11 | test('generates loader type property declaration (array)', (t) => { 12 | t.is(createLoaderTypePropertyDeclaration('FooLoader', 'text', 'foo', true), '+FooLoader: DataLoader>'); 13 | t.is(createLoaderTypePropertyDeclaration('FooLoader', 'integer', 'foo', true), '+FooLoader: DataLoader>'); 14 | }); 15 | 16 | test('throws an error if data type cannot resolve to a string or number', (t) => { 17 | t.throws((): void => { 18 | createLoaderTypePropertyDeclaration('FooLoader', 'unknown', 'foo', false); 19 | }, { 20 | message: 'Cannot resolve key type.', 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/postloader/utilities/formatPropertyName.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import test from 'ava'; 4 | import formatPropertyName from '../../../src/utilities/formatPropertyName'; 5 | 6 | test('camel cases the input', (t) => { 7 | t.is(formatPropertyName('foo bar baz'), 'fooBarBaz'); 8 | }); 9 | -------------------------------------------------------------------------------- /test/postloader/utilities/formatTypeName.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import test from 'ava'; 4 | import formatTypeName from '../../../src/utilities/formatTypeName'; 5 | 6 | test('pascal cases input and appends RecordType', (t) => { 7 | t.is(formatTypeName('foo bar baz'), 'FooBarBazRecordType'); 8 | }); 9 | -------------------------------------------------------------------------------- /test/postloader/utilities/generateDataLoaderFactory.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import test from 'ava'; 4 | import { 5 | trim, 6 | } from 'lodash'; 7 | import generateDataLoaderFactory from '../../../src/utilities/generateDataLoaderFactory'; 8 | 9 | test('creates a loader for unique indexes', (t) => { 10 | const actual = trim(generateDataLoaderFactory( 11 | [ 12 | { 13 | comment: '', 14 | dataType: 'text', 15 | isMaterializedView: false, 16 | isNullable: false, 17 | name: 'bar', 18 | tableName: 'foo', 19 | }, 20 | ], 21 | [ 22 | { 23 | columnNames: [ 24 | 'bar', 25 | ], 26 | indexIsUnique: true, 27 | indexName: 'quux', 28 | tableName: 'foo', 29 | }, 30 | ], 31 | {}, 32 | )); 33 | 34 | const expected = trim(` 35 | // @flow 36 | 37 | import { 38 | getByIds, 39 | getByIdsUsingJoiningTable 40 | } from 'postloader'; 41 | import DataLoader from 'dataloader'; 42 | import type { 43 | DatabaseConnectionType 44 | } from 'slonik'; 45 | 46 | type FooRecordType = {| 47 | +bar: string 48 | |}; 49 | 50 | export type { 51 | FooRecordType 52 | }; 53 | 54 | export type LoadersType = {| 55 | +FooByBarLoader: DataLoader 56 | |}; 57 | 58 | export const createLoaders = (connection: $Shape, dataLoaderConfigurationMap: Object = {}): LoadersType => { 59 | const FooByBarLoader = new DataLoader((ids) => { 60 | return getByIds(connection, 'foo', ids, 'bar', '"bar"', false); 61 | }, dataLoaderConfigurationMap.FooByBarLoader); 62 | 63 | return { 64 | FooByBarLoader 65 | }; 66 | };`); 67 | 68 | // eslint-disable-next-line ava/prefer-power-assert 69 | t.is(actual, expected); 70 | }); 71 | 72 | test('creates a loader for unique indexes (uses mappedTableName when available)', (t) => { 73 | const actual = trim(generateDataLoaderFactory( 74 | [ 75 | { 76 | comment: '', 77 | dataType: 'text', 78 | isMaterializedView: false, 79 | isNullable: false, 80 | mappedTableName: 'baz', 81 | name: 'bar', 82 | tableName: 'foo', 83 | }, 84 | ], 85 | [ 86 | { 87 | columnNames: [ 88 | 'bar', 89 | ], 90 | indexIsUnique: true, 91 | indexName: 'quux', 92 | tableName: 'foo', 93 | }, 94 | ], 95 | {}, 96 | )); 97 | 98 | const expected = trim(` 99 | // @flow 100 | 101 | import { 102 | getByIds, 103 | getByIdsUsingJoiningTable 104 | } from 'postloader'; 105 | import DataLoader from 'dataloader'; 106 | import type { 107 | DatabaseConnectionType 108 | } from 'slonik'; 109 | 110 | type BazRecordType = {| 111 | +bar: string 112 | |}; 113 | 114 | export type { 115 | BazRecordType 116 | }; 117 | 118 | export type LoadersType = {| 119 | +BazByBarLoader: DataLoader 120 | |}; 121 | 122 | export const createLoaders = (connection: $Shape, dataLoaderConfigurationMap: Object = {}): LoadersType => { 123 | const BazByBarLoader = new DataLoader((ids) => { 124 | return getByIds(connection, 'foo', ids, 'bar', '"bar"', false); 125 | }, dataLoaderConfigurationMap.BazByBarLoader); 126 | 127 | return { 128 | BazByBarLoader 129 | }; 130 | };`); 131 | 132 | // eslint-disable-next-line ava/prefer-power-assert 133 | t.is(actual, expected); 134 | }); 135 | 136 | test('creates a loader for _id columns', (t) => { 137 | const actual = trim(generateDataLoaderFactory( 138 | [ 139 | { 140 | comment: '', 141 | dataType: 'text', 142 | isMaterializedView: false, 143 | isNullable: false, 144 | mappedTableName: 'baz', 145 | name: 'bar_id', 146 | tableName: 'foo', 147 | }, 148 | ], 149 | [], 150 | {}, 151 | )); 152 | 153 | const expected = trim(` 154 | // @flow 155 | 156 | import { 157 | getByIds, 158 | getByIdsUsingJoiningTable 159 | } from 'postloader'; 160 | import DataLoader from 'dataloader'; 161 | import type { 162 | DatabaseConnectionType 163 | } from 'slonik'; 164 | 165 | type BazRecordType = {| 166 | +barId: string 167 | |}; 168 | 169 | export type { 170 | BazRecordType 171 | }; 172 | 173 | export type LoadersType = {| 174 | +BazsByBarIdLoader: DataLoader> 175 | |}; 176 | 177 | export const createLoaders = (connection: $Shape, dataLoaderConfigurationMap: Object = {}): LoadersType => { 178 | const BazsByBarIdLoader = new DataLoader((ids) => { 179 | return getByIds(connection, 'foo', ids, 'bar_id', '"bar_id" "barId"', true); 180 | }, dataLoaderConfigurationMap.BazsByBarIdLoader); 181 | 182 | return { 183 | BazsByBarIdLoader 184 | }; 185 | };`); 186 | 187 | // eslint-disable-next-line ava/prefer-power-assert 188 | t.is(actual, expected); 189 | }); 190 | 191 | test('creates a loader for a join table', (t) => { 192 | const actual = trim(generateDataLoaderFactory( 193 | [ 194 | { 195 | comment: '', 196 | dataType: 'text', 197 | isMaterializedView: false, 198 | isNullable: false, 199 | mappedTableName: 'bar', 200 | name: 'id', 201 | tableName: 'bar', 202 | }, 203 | { 204 | comment: '', 205 | dataType: 'text', 206 | isMaterializedView: false, 207 | isNullable: false, 208 | mappedTableName: 'foo', 209 | name: 'id', 210 | tableName: 'foo', 211 | }, 212 | { 213 | comment: '', 214 | dataType: 'text', 215 | isMaterializedView: false, 216 | isNullable: false, 217 | mappedTableName: 'bar_foo', 218 | name: 'bar_id', 219 | tableName: 'bar_foo', 220 | }, 221 | { 222 | comment: '', 223 | dataType: 'text', 224 | isMaterializedView: false, 225 | isNullable: false, 226 | mappedTableName: 'bar_foo', 227 | name: 'foo_id', 228 | tableName: 'bar_foo', 229 | }, 230 | ], 231 | [], 232 | {}, 233 | )); 234 | 235 | const expected = trim(` 236 | // @flow 237 | 238 | import { 239 | getByIds, 240 | getByIdsUsingJoiningTable 241 | } from 'postloader'; 242 | import DataLoader from 'dataloader'; 243 | import type { 244 | DatabaseConnectionType 245 | } from 'slonik'; 246 | 247 | type BarRecordType = {| 248 | +id: string 249 | |}; 250 | 251 | type FooRecordType = {| 252 | +id: string 253 | |}; 254 | 255 | type BarFooRecordType = {| 256 | +barId: string, 257 | +fooId: string 258 | |}; 259 | 260 | export type { 261 | BarFooRecordType, 262 | BarRecordType, 263 | FooRecordType 264 | }; 265 | 266 | export type LoadersType = {| 267 | +BarFoosByBarIdLoader: DataLoader>, 268 | +BarFoosByFooIdLoader: DataLoader>, 269 | +BarsByFooIdLoader: DataLoader>, 270 | +FoosByBarIdLoader: DataLoader> 271 | |}; 272 | 273 | export const createLoaders = (connection: $Shape, dataLoaderConfigurationMap: Object = {}): LoadersType => { 274 | const BarFoosByBarIdLoader = new DataLoader((ids) => { 275 | return getByIds(connection, 'bar_foo', ids, 'bar_id', '"bar_id" "barId", "foo_id" "fooId"', true); 276 | }, dataLoaderConfigurationMap.BarFoosByBarIdLoader); 277 | const BarFoosByFooIdLoader = new DataLoader((ids) => { 278 | return getByIds(connection, 'bar_foo', ids, 'foo_id', '"bar_id" "barId", "foo_id" "fooId"', true); 279 | }, dataLoaderConfigurationMap.BarFoosByFooIdLoader); 280 | const FoosByBarIdLoader = new DataLoader((ids) => { 281 | return getByIdsUsingJoiningTable(connection, 'bar_foo', 'foo', 'foo', 'bar', 'r2."id"', ids); 282 | }, dataLoaderConfigurationMap.FoosByBarIdLoader); 283 | const BarsByFooIdLoader = new DataLoader((ids) => { 284 | return getByIdsUsingJoiningTable(connection, 'bar_foo', 'bar', 'bar', 'foo', 'r2."id"', ids); 285 | }, dataLoaderConfigurationMap.BarsByFooIdLoader); 286 | 287 | return { 288 | BarFoosByBarIdLoader, 289 | BarFoosByFooIdLoader, 290 | BarsByFooIdLoader, 291 | FoosByBarIdLoader 292 | }; 293 | };`); 294 | 295 | // eslint-disable-next-line ava/prefer-power-assert 296 | t.is(actual, expected); 297 | }); 298 | -------------------------------------------------------------------------------- /test/postloader/utilities/generateFlowTypeDocument.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import test from 'ava'; 4 | import { 5 | trim, 6 | } from 'lodash'; 7 | import generateFlowTypeDocument from '../../../src/utilities/generateFlowTypeDocument'; 8 | import { 9 | createColumn, 10 | } from '../../helpers'; 11 | 12 | test('create flow type document with one type', (t) => { 13 | const actual = trim(generateFlowTypeDocument([ 14 | createColumn({ 15 | mappedTableName: 'bar', 16 | name: 'foo', 17 | }), 18 | ])); 19 | 20 | const expected = trim(` 21 | type BarRecordType = {| 22 | +foo: any 23 | |}; 24 | 25 | export type { 26 | BarRecordType 27 | };`); 28 | 29 | t.is(actual, expected); 30 | }); 31 | 32 | test('create flow type document with multiple types', (t) => { 33 | const actual = trim(generateFlowTypeDocument([ 34 | createColumn({ 35 | mappedTableName: 'qux', 36 | name: 'baz', 37 | }), 38 | createColumn({ 39 | mappedTableName: 'bar', 40 | name: 'foo', 41 | }), 42 | ])); 43 | 44 | const expected = trim(` 45 | type QuxRecordType = {| 46 | +baz: any 47 | |}; 48 | 49 | type BarRecordType = {| 50 | +foo: any 51 | |}; 52 | 53 | export type { 54 | BarRecordType, 55 | QuxRecordType 56 | };`); 57 | 58 | t.is(actual, expected); 59 | }); 60 | -------------------------------------------------------------------------------- /test/postloader/utilities/getFlowType.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import test from 'ava'; 4 | import getFlowType from '../../../src/utilities/getFlowType'; 5 | 6 | const knownTypes = { 7 | bigint: 'number', 8 | boolean: 'boolean', 9 | character: 'string', 10 | coordinates: 'string', 11 | integer: 'number', 12 | json: 'Object', 13 | text: 'string', 14 | timestamp: 'number', 15 | }; 16 | 17 | test('correctly maps known types', (t) => { 18 | const databaseTypeNames = Object.keys(knownTypes); 19 | 20 | for (const databaseTypeName of databaseTypeNames) { 21 | const flowType = knownTypes[databaseTypeName]; 22 | 23 | if (typeof flowType !== 'string') { 24 | throw new TypeError('Unexpected type'); 25 | } 26 | 27 | t.is(getFlowType(databaseTypeName), flowType, flowType); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /test/postloader/utilities/isJoiningTable.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import test from 'ava'; 4 | import isJoiningTable from '../../../src/utilities/isJoiningTable'; 5 | import { 6 | createColumnWithName, 7 | } from '../../helpers'; 8 | 9 | test('correctly recognizes a joining table (genre_movie)', (t) => { 10 | const tableIsJoining = isJoiningTable( 11 | 'genre_movie', 12 | [ 13 | createColumnWithName('genre_id'), 14 | createColumnWithName('movie_id'), 15 | ], 16 | ); 17 | 18 | t.true(tableIsJoining); 19 | }); 20 | 21 | test('correctly recognizes a joining table (event_event_attribute)', (t) => { 22 | const tableIsJoining = isJoiningTable( 23 | 'event_event_attribute', 24 | [ 25 | createColumnWithName('event_id'), 26 | createColumnWithName('event_attribute_id'), 27 | ], 28 | ); 29 | 30 | t.true(tableIsJoining); 31 | }); 32 | 33 | test('correctly recognizes not a joining table', (t) => { 34 | const tableIsJoining = isJoiningTable( 35 | 'genre_movie', 36 | [ 37 | createColumnWithName('id'), 38 | createColumnWithName('name'), 39 | ], 40 | ); 41 | 42 | t.false(tableIsJoining); 43 | }); 44 | --------------------------------------------------------------------------------